From bc41dfcf7d8b73011b50119c317f66ffdb79fb84 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 31 Oct 2026 21:54:10 +0800 Subject: [PATCH] Unlucky Launcher v0.0.1-alpha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent), MCP server 接入外部 Agent 工具,GUI 开发中。 --- .gitattributes | 34 + .github/workflows/ci.yml | 92 + .github/workflows/guards.yml | 81 + .gitignore | 24 + .rustfmt.toml | 5 + Cargo.lock | 7413 +++++++++++++++++ Cargo.toml | 88 + LICENSE | 202 + NOTICE | 30 + README.md | 122 + README.zh-CN.md | 97 + clippy.toml | 7 + crates/agent/Cargo.toml | 44 + crates/agent/src/audit.rs | 283 + crates/agent/src/brain/chat.rs | 267 + crates/agent/src/brain/exec.rs | 347 + crates/agent/src/brain/mod.rs | 323 + crates/agent/src/brain/provider.rs | 340 + crates/agent/src/brain/rig.rs | 368 + crates/agent/src/clock.rs | 139 + crates/agent/src/confirm.rs | 235 + crates/agent/src/event.rs | 197 + crates/agent/src/lib.rs | 45 + crates/agent/src/mcp/mod.rs | 159 + crates/agent/src/mcp/projection.rs | 106 + crates/agent/src/mcp/sample.rs | 80 + crates/agent/src/mcp/serve.rs | 144 + crates/agent/src/mcp/server.rs | 243 + crates/agent/src/port.rs | 315 + crates/agent/src/port/game.rs | 152 + crates/agent/src/redact.rs | 217 + crates/agent/src/registry/mod.rs | 260 + crates/agent/src/registry/schema.rs | 116 + crates/agent/src/registry/spec.rs | 262 + crates/agent/src/registry/validate.rs | 332 + crates/agent/src/testing.rs | 190 + crates/agent/src/testing/diagnose.rs | 87 + crates/agent/src/testing/game.rs | 158 + crates/agent/src/testing/settings.rs | 201 + crates/agent/src/tools/accounts.rs | 110 + crates/agent/src/tools/config.rs | 345 + crates/agent/src/tools/diagnose.rs | 335 + crates/agent/src/tools/game.rs | 199 + crates/agent/src/tools/mod.rs | 39 + crates/agent/src/tools/schema_tool.rs | 84 + crates/agent/src/tools/session.rs | 388 + crates/agent/tests/brain_loop.rs | 248 + crates/agent/tests/mcp_contract.rs | 132 + crates/agent/tests/mcp_http.rs | 336 + crates/agent/tests/schema_contract.rs | 163 + crates/agent/tests/support/mod.rs | 225 + crates/agent/tests/tools_security.rs | 365 + crates/app/Cargo.toml | 29 + crates/app/build.rs | 7 + crates/app/src/appearance_bind.rs | 124 + crates/app/src/bg_image.rs | 370 + crates/app/src/callbacks.rs | 354 + crates/app/src/conv/agent.rs | 103 + crates/app/src/conv/instances.rs | 162 + crates/app/src/conv/logs.rs | 84 + crates/app/src/conv/mod.rs | 21 + crates/app/src/conv/ui.rs | 136 + crates/app/src/conv/versions.rs | 81 + crates/app/src/dialogs.rs | 303 + crates/app/src/dto/agent.rs | 95 + crates/app/src/dto/events.rs | 86 + crates/app/src/dto/instances.rs | 151 + crates/app/src/dto/logs.rs | 76 + crates/app/src/dto/mod.rs | 23 + crates/app/src/dto/versions.rs | 60 + crates/app/src/main.rs | 94 + crates/app/src/mock.rs | 366 + crates/app/src/mock_data.rs | 226 + crates/app/src/png.rs | 140 + crates/app/src/prefs.rs | 288 + crates/app/src/router.rs | 184 + crates/app/src/settings_bridge.rs | 294 + crates/app/src/state.rs | 304 + crates/app/src/state_tests.rs | 262 + crates/app/src/theme_runtime.rs | 152 + crates/app/src/toast.rs | 152 + crates/app/src/ui_bind.rs | 333 + crates/app/ui/components/ai-panel.slint | 283 + crates/app/ui/components/badge.slint | 27 + crates/app/ui/components/glass.slint | 59 + crates/app/ui/components/icons.slint | 192 + crates/app/ui/components/label.slint | 34 + crates/app/ui/components/nav.slint | 182 + crates/app/ui/components/progress-ring.slint | 22 + crates/app/ui/components/tile.slint | 198 + crates/app/ui/components/titlebar.slint | 126 + crates/app/ui/main.slint | 303 + crates/app/ui/models.slint | 218 + crates/app/ui/pages/download.slint | 270 + crates/app/ui/pages/instances.slint | 260 + crates/app/ui/pages/logs.slint | 259 + crates/app/ui/pages/settings-appearance.slint | 146 + crates/app/ui/pages/settings.slint | 363 + crates/app/ui/shell.slint | 373 + crates/app/ui/theme/metrics.slint | 48 + crates/app/ui/theme/tokens.slint | 208 + crates/app/ui/widgets/button.slint | 83 + crates/app/ui/widgets/dialog.slint | 122 + crates/app/ui/widgets/inputs.slint | 300 + crates/app/ui/widgets/setting-item.slint | 155 + crates/app/ui/widgets/toast.slint | 87 + crates/cli/Cargo.toml | 35 + crates/cli/src/cli/help.rs | 155 + crates/cli/src/cli/mod.rs | 17 + crates/cli/src/cli/model.rs | 196 + crates/cli/src/cli/parse.rs | 365 + crates/cli/src/cli/parse/accounts.rs | 68 + crates/cli/src/cli/parse/agent.rs | 66 + crates/cli/src/commands/accounts.rs | 198 + crates/cli/src/commands/agent/mod.rs | 135 + .../cli/src/commands/agent/ports/accounts.rs | 49 + .../cli/src/commands/agent/ports/diagnose.rs | 187 + crates/cli/src/commands/agent/ports/events.rs | 136 + crates/cli/src/commands/agent/ports/game.rs | 204 + crates/cli/src/commands/agent/ports/mod.rs | 58 + .../cli/src/commands/agent/ports/session.rs | 201 + .../cli/src/commands/agent/ports/setting.rs | 211 + .../src/commands/agent/ports/setting/tests.rs | 226 + crates/cli/src/commands/agent/serve.rs | 218 + crates/cli/src/commands/config.rs | 341 + crates/cli/src/commands/config/tests.rs | 256 + crates/cli/src/commands/identity.rs | 216 + crates/cli/src/commands/install/loader.rs | 159 + .../src/commands/install/loader_processors.rs | 181 + crates/cli/src/commands/install/mod.rs | 151 + crates/cli/src/commands/install/report.rs | 262 + .../cli/src/commands/install/report_loader.rs | 274 + crates/cli/src/commands/install/vanilla.rs | 160 + crates/cli/src/commands/instances.rs | 90 + crates/cli/src/commands/java.rs | 365 + crates/cli/src/commands/java_launch.rs | 129 + crates/cli/src/commands/java_vendors.rs | 141 + crates/cli/src/commands/launch.rs | 377 + crates/cli/src/commands/meta.rs | 57 + crates/cli/src/commands/mod.rs | 155 + crates/cli/src/commands/sessions.rs | 292 + crates/cli/src/commands/snapshot.rs | 205 + crates/cli/src/context.rs | 238 + crates/cli/src/error.rs | 157 + crates/cli/src/lib.rs | 27 + crates/cli/src/main.rs | 9 + crates/cli/src/os.rs | 78 + crates/cli/src/output.rs | 136 + crates/cli/src/runner.rs | 54 + crates/cli/tests/accounts_test.rs | 293 + crates/cli/tests/agent_mcp_stdio_test.rs | 185 + crates/cli/tests/config_secret_test.rs | 179 + crates/cli/tests/exit_code_test.rs | 109 + crates/cli/tests/isolation_launch_test.rs | 345 + crates/cli/tests/java_test.rs | 197 + crates/cli/tests/os_test.rs | 115 + crates/cli/tests/output_test.rs | 287 + crates/cli/tests/parse_test.rs | 338 + crates/cli/tests/support/mod.rs | 159 + crates/core/Cargo.toml | 74 + crates/core/src/addon/loader/fabric.rs | 247 + crates/core/src/addon/loader/forge.rs | 280 + crates/core/src/addon/loader/maven.rs | 173 + crates/core/src/addon/loader/meta.rs | 242 + crates/core/src/addon/loader/mod.rs | 249 + crates/core/src/addon/loader/plan.rs | 235 + .../src/addon/loader/processors/download.rs | 83 + .../core/src/addon/loader/processors/error.rs | 181 + .../core/src/addon/loader/processors/exec.rs | 363 + .../core/src/addon/loader/processors/jar.rs | 83 + .../core/src/addon/loader/processors/mod.rs | 56 + .../core/src/addon/loader/processors/plan.rs | 366 + .../src/addon/loader/processors/prepare.rs | 201 + .../core/src/addon/loader/processors/spec.rs | 121 + .../src/addon/loader/processors/tests/exec.rs | 185 + .../src/addon/loader/processors/tests/mod.rs | 71 + .../src/addon/loader/processors/tests/plan.rs | 224 + .../src/addon/loader/processors/tests/vars.rs | 181 + .../core/src/addon/loader/processors/text.rs | 71 + .../core/src/addon/loader/processors/vars.rs | 324 + crates/core/src/addon/loader/tests/fabric.rs | 135 + crates/core/src/addon/loader/tests/forge.rs | 258 + crates/core/src/addon/loader/tests/maven.rs | 134 + crates/core/src/addon/loader/tests/mod.rs | 56 + crates/core/src/addon/loader/tests/plan.rs | 192 + crates/core/src/addon/mod.rs | 19 + crates/core/src/auth/account.rs | 339 + crates/core/src/auth/authlib.rs | 347 + crates/core/src/auth/authlib/server_meta.rs | 86 + crates/core/src/auth/authlib/tests.rs | 97 + crates/core/src/auth/body/mod.rs | 279 + crates/core/src/auth/body/tests.rs | 168 + crates/core/src/auth/error.rs | 250 + crates/core/src/auth/http.rs | 208 + crates/core/src/auth/journal.rs | 266 + crates/core/src/auth/microsoft/chain.rs | 377 + crates/core/src/auth/microsoft/endpoints.rs | 118 + crates/core/src/auth/microsoft/flow.rs | 313 + crates/core/src/auth/microsoft/mod.rs | 332 + crates/core/src/auth/microsoft/session.rs | 231 + crates/core/src/auth/mod.rs | 52 + crates/core/src/auth/offline.rs | 197 + crates/core/src/auth/secret.rs | 182 + crates/core/src/auth/store.rs | 251 + crates/core/src/auth/store/payload.rs | 247 + crates/core/src/diagnose/collectors.rs | 267 + crates/core/src/diagnose/engine.rs | 321 + crates/core/src/diagnose/environment.rs | 155 + crates/core/src/diagnose/finding.rs | 256 + crates/core/src/diagnose/fix_action.rs | 213 + crates/core/src/diagnose/fix_params.rs | 252 + crates/core/src/diagnose/hs_err.rs | 298 + crates/core/src/diagnose/hs_err_report.rs | 210 + crates/core/src/diagnose/loader.rs | 398 + crates/core/src/diagnose/mod.rs | 17 + crates/core/src/diagnose/rules.d/fabric.yaml | 141 + crates/core/src/diagnose/rules.d/forge.yaml | 136 + .../core/src/diagnose/rules.d/graphics.yaml | 221 + .../core/src/diagnose/rules.d/integrity.yaml | 138 + crates/core/src/diagnose/rules.d/jvm.yaml | 142 + crates/core/src/diagnose/rules.d/memory.yaml | 39 + .../src/diagnose/rules.d/mod_conflict.yaml | 152 + .../src/diagnose/rules.d/mod_runtime.yaml | 136 + crates/core/src/diagnose/rules.d/other.yaml | 22 + .../rules.d/samples/fabric/loader_0_12.log | 2 + .../rules.d/samples/fabric/mod_resolution.log | 3 + .../fabric/mod_resolution_collection.log | 1 + .../fabric/mod_resolution_conflict.log | 1 + .../samples/fabric/mod_resolution_details.log | 3 + .../samples/fabric/mod_resolution_missing.log | 1 + .../mod_resolution_missing_minecraft.log | 1 + .../rules.d/samples/fabric/warnings.log | 3 + .../samples/forge/bootstrap_failed.log | 1 + .../rules.d/samples/forge/duplicate_mods.log | 3 + .../rules.d/samples/forge/duplicated_mod.log | 1 + .../samples/forge/missing_dependencies.log | 2 + .../samples/forge/module_name_invalid.log | 1 + .../forge/optifine_repeat_installation.log | 1 + .../samples/forge/repeat_installation.log | 1 + .../rules.d/samples/forge/too_many_mods.log | 1 + .../samples/graphics/gl_operation_failure.log | 3 + .../samples/graphics/graphics_driver.log | 7 + .../samples/graphics/macos_display_port.log | 1 + .../samples/graphics/no_graphics_backend.log | 15 + .../samples/graphics/opengl_not_supported.log | 1 + .../samples/graphics/resolution_too_high.log | 1 + .../rules.d/samples/graphics/rtss_sodium.log | 1 + .../vulkan_device_version_too_low.log | 5 + .../graphics/vulkan_loader_missing.log | 5 + .../graphics/vulkan_missing_extension.log | 5 + .../graphics/vulkan_missing_feature.log | 5 + .../samples/hs_err/access_violation.log | 34 + .../rules.d/samples/hs_err/out_of_memory.log | 19 + .../samples/integrity/file_already_exists.log | 1 + .../samples/integrity/file_changed.log | 2 + .../samples/integrity/illegal_access.log | 2 + .../samples/integrity/incomplete_forge.log | 1 + .../integrity/mod_files_decompressed.log | 2 + .../samples/integrity/no_class_def_found.log | 2 + .../samples/integrity/no_such_method.log | 3 + .../samples/integrity/unsatisfied_link.log | 2 + .../samples/jvm/java_version_is_too_high.log | 3 + .../diagnose/rules.d/samples/jvm/jdk_9.log | 2 + .../rules.d/samples/jvm/jvm_32bit.log | 3 + .../rules.d/samples/jvm/mac_jdk8u261.log | 1 + .../rules.d/samples/jvm/modlauncher_8.log | 3 + .../rules.d/samples/jvm/need_jdk11.log | 2 + .../diagnose/rules.d/samples/jvm/openj9.log | 2 + .../rules.d/samples/jvm/too_old_java.log | 2 + .../samples/memory/memory_exceeded.log | 3 + .../rules.d/samples/memory/out_of_memory.log | 3 + .../samples/mod_conflict/jade_optifine.log | 1 + .../mod_conflict/multipart_optifine.log | 1 + .../mod_conflict/neoforge_optifine.log | 1 + .../samples/mod_conflict/optifine_forge.log | 4 + .../mod_conflict/optifine_world_load.log | 1 + .../mod_conflict/performant_optifine.log | 1 + .../samples/mod_conflict/shaders_mod.log | 1 + .../mod_conflict/twilight_optifine.log | 1 + .../samples/mod_runtime/config_failed.log | 1 + .../samples/mod_runtime/forge_error.log | 3 + .../mod_runtime/install_mixinbootstrap.log | 1 + .../mod_runtime/loading_crashed_fabric.log | 1 + .../mod_runtime/loading_crashed_forge.log | 1 + .../mod_runtime/mixin_apply_failed.log | 1 + .../samples/mod_runtime/mixin_failure.log | 1 + .../mod_runtime/night_config_fixes.log | 1 + .../rules.d/samples/other/debug_crash.log | 1 + .../rules.d/samples/world/block_model.log | 6 + .../rules.d/samples/world/entity_tick.log | 6 + crates/core/src/diagnose/rules.d/world.yaml | 39 + crates/core/src/diagnose/rules.rs | 319 + crates/core/src/diagnose/signals.rs | 396 + crates/core/src/diagnose/sources.rs | 299 + crates/core/src/diagnose/synthetic.rs | 279 + crates/core/src/download/adoptium.rs | 204 + crates/core/src/download/atomic.rs | 384 + crates/core/src/download/backoff.rs | 120 + crates/core/src/download/blocking.rs | 55 + crates/core/src/download/bytes.rs | 138 + crates/core/src/download/cache.rs | 285 + crates/core/src/download/chunked.rs | 296 + crates/core/src/download/config.rs | 46 + crates/core/src/download/engine.rs | 397 + crates/core/src/download/error.rs | 125 + crates/core/src/download/health.rs | 399 + crates/core/src/download/health_store.rs | 80 + crates/core/src/download/http.rs | 183 + crates/core/src/download/jdk_sources.rs | 178 + crates/core/src/download/mirrors.json | 169 + crates/core/src/download/mirrors.rs | 386 + crates/core/src/download/mod.rs | 90 + crates/core/src/download/progress.rs | 197 + crates/core/src/download/region.rs | 343 + crates/core/src/download/region_system.rs | 158 + crates/core/src/download/resume.rs | 235 + crates/core/src/download/transfer.rs | 305 + crates/core/src/download/transfer_chunk.rs | 163 + crates/core/src/download/types.rs | 307 + crates/core/src/download/verify.rs | 123 + crates/core/src/game/arguments.rs | 331 + crates/core/src/game/capability.rs | 83 + .../core/src/game/data/unlisted-versions.json | 209 + crates/core/src/game/error.rs | 124 + crates/core/src/game/inherit.rs | 198 + crates/core/src/game/install/assets.rs | 190 + crates/core/src/game/install/execute.rs | 361 + crates/core/src/game/install/instance.rs | 351 + crates/core/src/game/install/libraries.rs | 258 + crates/core/src/game/install/mod.rs | 186 + crates/core/src/game/install/plan.rs | 342 + crates/core/src/game/install/plan_view.rs | 72 + crates/core/src/game/install/source.rs | 353 + crates/core/src/game/install/tests/execute.rs | 270 + .../core/src/game/install/tests/instance.rs | 122 + crates/core/src/game/install/tests/mod.rs | 8 + crates/core/src/game/install/tests/plan.rs | 285 + crates/core/src/game/isolation.rs | 345 + crates/core/src/game/java/api.rs | 344 + crates/core/src/game/java/discover.rs | 270 + crates/core/src/game/java/error.rs | 138 + crates/core/src/game/java/extract.rs | 318 + crates/core/src/game/java/install.rs | 379 + crates/core/src/game/java/location.rs | 335 + crates/core/src/game/java/mapping.rs | 376 + crates/core/src/game/java/microsoft.rs | 261 + crates/core/src/game/java/mod.rs | 90 + crates/core/src/game/java/net.rs | 60 + crates/core/src/game/java/oracle.rs | 344 + crates/core/src/game/java/probe.rs | 370 + crates/core/src/game/java/release.rs | 170 + crates/core/src/game/java/select.rs | 160 + crates/core/src/game/java/source.rs | 199 + crates/core/src/game/java/target.rs | 85 + crates/core/src/game/java/vendor.rs | 219 + crates/core/src/game/java/zulu.rs | 275 + crates/core/src/game/layout.rs | 165 + crates/core/src/game/library.rs | 213 + crates/core/src/game/manifest.rs | 265 + crates/core/src/game/mod.rs | 64 + crates/core/src/game/profiles.rs | 244 + crates/core/src/game/snapshot.rs | 174 + crates/core/src/game/unlisted.rs | 54 + crates/core/src/game/version.rs | 238 + crates/core/src/launch/adapter.rs | 368 + crates/core/src/launch/args_table.rs | 253 + crates/core/src/launch/argument.rs | 205 + crates/core/src/launch/command_builder.rs | 335 + crates/core/src/launch/crash_scan.rs | 152 + crates/core/src/launch/defaults.rs | 203 + crates/core/src/launch/diagnosis_hook.rs | 97 + crates/core/src/launch/error.rs | 165 + crates/core/src/launch/exit_analysis.rs | 264 + crates/core/src/launch/game_args.rs | 206 + crates/core/src/launch/game_version.rs | 178 + crates/core/src/launch/jvm_args.rs | 149 + crates/core/src/launch/launch_session.rs | 329 + crates/core/src/launch/launcher.rs | 298 + crates/core/src/launch/log_level.rs | 114 + crates/core/src/launch/mod.rs | 75 + crates/core/src/launch/natives/jar.rs | 250 + crates/core/src/launch/natives/mod.rs | 355 + crates/core/src/launch/placeholders.rs | 173 + crates/core/src/launch/platform.rs | 95 + crates/core/src/launch/process.rs | 222 + crates/core/src/launch/pump.rs | 379 + crates/core/src/launch/redact.rs | 299 + crates/core/src/launch/request.rs | 358 + crates/core/src/launch/session.rs | 375 + crates/core/src/launch/session_index.rs | 120 + crates/core/src/launch/session_meta.rs | 62 + crates/core/src/launch/session_payload.rs | 159 + crates/core/src/launch/session_sink.rs | 64 + crates/core/src/launch/sink.rs | 138 + crates/core/src/launch/time.rs | 118 + crates/core/src/launch/tree_kill.rs | 116 + crates/core/src/launch/watchdog.rs | 71 + crates/core/src/launch/window_detect.rs | 86 + crates/core/src/lib.rs | 24 + crates/core/src/platform/atomic.rs | 79 + crates/core/src/platform/data_dir.rs | 235 + crates/core/src/platform/dir_migration.rs | 252 + crates/core/src/platform/error.rs | 124 + crates/core/src/platform/hash.rs | 95 + crates/core/src/platform/layout.rs | 223 + crates/core/src/platform/linux_caps.rs | 225 + crates/core/src/platform/macos_version.rs | 100 + crates/core/src/platform/mod.rs | 42 + crates/core/src/platform/os_info.rs | 388 + crates/core/src/platform/paths.rs | 186 + crates/core/src/platform/receipt.rs | 202 + crates/core/src/platform/redirect.rs | 137 + crates/core/src/platform/theme_probe.rs | 182 + crates/core/src/platform/vk_probe.rs | 305 + crates/core/src/platform/windows_version.rs | 215 + crates/core/src/setting/appearance.rs | 226 + crates/core/src/setting/catalog.rs | 195 + crates/core/src/setting/document.rs | 399 + crates/core/src/setting/error.rs | 121 + crates/core/src/setting/instance_keys.rs | 155 + crates/core/src/setting/json_path.rs | 124 + crates/core/src/setting/keys.rs | 352 + crates/core/src/setting/migrate.rs | 222 + crates/core/src/setting/mod.rs | 36 + crates/core/src/setting/policy.rs | 92 + crates/core/src/setting/save.rs | 217 + crates/core/src/setting/save_worker.rs | 181 + crates/core/src/setting/schema.rs | 292 + crates/core/src/setting/spec.rs | 331 + crates/core/src/setting/store.rs | 301 + .../core/tests/auth/auth_failure_journal.rs | 151 + crates/core/tests/auth/authlib_api.rs | 307 + crates/core/tests/auth/main.rs | 19 + .../core/tests/auth/microsoft_diagnostics.rs | 231 + crates/core/tests/auth/microsoft_flow.rs | 274 + crates/core/tests/auth/microsoft_refresh.rs | 283 + crates/core/tests/auth/offline_uuid.rs | 105 + crates/core/tests/auth/store_separation.rs | 240 + crates/core/tests/auth/support/microsoft.rs | 136 + crates/core/tests/auth/support/mod.rs | 279 + .../core/tests/diagnose/collectors_probe.rs | 233 + crates/core/tests/diagnose/engine_rank.rs | 233 + crates/core/tests/diagnose/hs_err_parse.rs | 143 + crates/core/tests/diagnose/loader_reload.rs | 171 + crates/core/tests/diagnose/main.rs | 83 + crates/core/tests/diagnose/rules_samples.rs | 267 + crates/core/tests/download/backoff_test.rs | 68 + crates/core/tests/download/cache_test.rs | 149 + crates/core/tests/download/chunked_test.rs | 375 + .../core/tests/download/engine_mock_test.rs | 350 + crates/core/tests/download/health_test.rs | 268 + crates/core/tests/download/mirrors_test.rs | 281 + crates/core/tests/download/partfile_test.rs | 233 + crates/core/tests/download/region_test.rs | 121 + crates/core/tests/download/resume_test.rs | 223 + crates/core/tests/download/support/mod.rs | 278 + crates/core/tests/game/arguments_rules.rs | 256 + crates/core/tests/game/helpers.rs | 66 + crates/core/tests/game/inherit_chain.rs | 265 + crates/core/tests/game/isolation.rs | 194 + crates/core/tests/game/isolation_switch.rs | 222 + crates/core/tests/game/java_discover.rs | 126 + crates/core/tests/game/java_install.rs | 291 + crates/core/tests/game/java_location.rs | 136 + crates/core/tests/game/java_mapping.rs | 106 + crates/core/tests/game/java_mirrors.rs | 96 + crates/core/tests/game/java_vendor_chain.rs | 183 + crates/core/tests/game/layout.rs | 143 + crates/core/tests/game/main.rs | 30 + crates/core/tests/game/manifest_parse.rs | 186 + crates/core/tests/game/profiles.rs | 109 + crates/core/tests/game/snapshot.rs | 75 + crates/core/tests/game/unlisted.rs | 90 + crates/core/tests/game/version_list.rs | 137 + crates/core/tests/game/vulkan_capability.rs | 125 + crates/core/tests/launch/adapter_cases.rs | 166 + crates/core/tests/launch/cmdline_snapshot.rs | 338 + .../tests/launch/command_builder_cases.rs | 148 + crates/core/tests/launch/exit_cases.rs | 169 + .../tests/launch/graphics_backend_cases.rs | 147 + crates/core/tests/launch/main.rs | 15 + .../core/tests/launch/natives_args_cases.rs | 136 + crates/core/tests/launch/natives_cases.rs | 281 + crates/core/tests/launch/pipeline_cases.rs | 347 + crates/core/tests/launch/prelude.rs | 149 + crates/core/tests/launch/process_smoke.rs | 127 + crates/core/tests/launch/pump_buffer.rs | 111 + crates/core/tests/launch/session_events.rs | 257 + crates/core/tests/launch/sink_cases.rs | 73 + .../core/tests/platform/linux_caps_cases.rs | 136 + crates/core/tests/platform/macos_cases.rs | 58 + crates/core/tests/platform/main.rs | 17 + crates/core/tests/platform/os_info_cases.rs | 299 + crates/core/tests/platform/prelude.rs | 53 + crates/core/tests/platform/rules_wiring.rs | 96 + crates/core/tests/platform/theme_cases.rs | 102 + crates/core/tests/platform/vk_cases.rs | 240 + .../tests/platform/windows_version_cases.rs | 179 + crates/core/tests/setting/appearance_keys.rs | 196 + crates/core/tests/setting/atomic_write.rs | 49 + crates/core/tests/setting/common.rs | 81 + crates/core/tests/setting/debounce.rs | 128 + crates/core/tests/setting/deprecated_keys.rs | 142 + crates/core/tests/setting/dir_migration.rs | 185 + crates/core/tests/setting/dirs.rs | 205 + crates/core/tests/setting/document.rs | 220 + crates/core/tests/setting/isolation_keys.rs | 250 + .../core/tests/setting/java_install_keys.rs | 119 + crates/core/tests/setting/layout.rs | 128 + crates/core/tests/setting/main.rs | 21 + crates/core/tests/setting/migration_chain.rs | 295 + crates/core/tests/setting/redirect.rs | 88 + crates/core/tests/setting/schema_compat.rs | 120 + crates/core/tests/setting/spec_ai.rs | 178 + crates/core/tests/setting/store.rs | 260 + deny.toml | 54 + rust-toolchain.toml | 6 + 517 files changed, 92844 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/guards.yml create mode 100644 .gitignore create mode 100644 .rustfmt.toml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 README.md create mode 100644 README.zh-CN.md create mode 100644 clippy.toml create mode 100644 crates/agent/Cargo.toml create mode 100644 crates/agent/src/audit.rs create mode 100644 crates/agent/src/brain/chat.rs create mode 100644 crates/agent/src/brain/exec.rs create mode 100644 crates/agent/src/brain/mod.rs create mode 100644 crates/agent/src/brain/provider.rs create mode 100644 crates/agent/src/brain/rig.rs create mode 100644 crates/agent/src/clock.rs create mode 100644 crates/agent/src/confirm.rs create mode 100644 crates/agent/src/event.rs create mode 100644 crates/agent/src/lib.rs create mode 100644 crates/agent/src/mcp/mod.rs create mode 100644 crates/agent/src/mcp/projection.rs create mode 100644 crates/agent/src/mcp/sample.rs create mode 100644 crates/agent/src/mcp/serve.rs create mode 100644 crates/agent/src/mcp/server.rs create mode 100644 crates/agent/src/port.rs create mode 100644 crates/agent/src/port/game.rs create mode 100644 crates/agent/src/redact.rs create mode 100644 crates/agent/src/registry/mod.rs create mode 100644 crates/agent/src/registry/schema.rs create mode 100644 crates/agent/src/registry/spec.rs create mode 100644 crates/agent/src/registry/validate.rs create mode 100644 crates/agent/src/testing.rs create mode 100644 crates/agent/src/testing/diagnose.rs create mode 100644 crates/agent/src/testing/game.rs create mode 100644 crates/agent/src/testing/settings.rs create mode 100644 crates/agent/src/tools/accounts.rs create mode 100644 crates/agent/src/tools/config.rs create mode 100644 crates/agent/src/tools/diagnose.rs create mode 100644 crates/agent/src/tools/game.rs create mode 100644 crates/agent/src/tools/mod.rs create mode 100644 crates/agent/src/tools/schema_tool.rs create mode 100644 crates/agent/src/tools/session.rs create mode 100644 crates/agent/tests/brain_loop.rs create mode 100644 crates/agent/tests/mcp_contract.rs create mode 100644 crates/agent/tests/mcp_http.rs create mode 100644 crates/agent/tests/schema_contract.rs create mode 100644 crates/agent/tests/support/mod.rs create mode 100644 crates/agent/tests/tools_security.rs create mode 100644 crates/app/Cargo.toml create mode 100644 crates/app/build.rs create mode 100644 crates/app/src/appearance_bind.rs create mode 100644 crates/app/src/bg_image.rs create mode 100644 crates/app/src/callbacks.rs create mode 100644 crates/app/src/conv/agent.rs create mode 100644 crates/app/src/conv/instances.rs create mode 100644 crates/app/src/conv/logs.rs create mode 100644 crates/app/src/conv/mod.rs create mode 100644 crates/app/src/conv/ui.rs create mode 100644 crates/app/src/conv/versions.rs create mode 100644 crates/app/src/dialogs.rs create mode 100644 crates/app/src/dto/agent.rs create mode 100644 crates/app/src/dto/events.rs create mode 100644 crates/app/src/dto/instances.rs create mode 100644 crates/app/src/dto/logs.rs create mode 100644 crates/app/src/dto/mod.rs create mode 100644 crates/app/src/dto/versions.rs create mode 100644 crates/app/src/main.rs create mode 100644 crates/app/src/mock.rs create mode 100644 crates/app/src/mock_data.rs create mode 100644 crates/app/src/png.rs create mode 100644 crates/app/src/prefs.rs create mode 100644 crates/app/src/router.rs create mode 100644 crates/app/src/settings_bridge.rs create mode 100644 crates/app/src/state.rs create mode 100644 crates/app/src/state_tests.rs create mode 100644 crates/app/src/theme_runtime.rs create mode 100644 crates/app/src/toast.rs create mode 100644 crates/app/src/ui_bind.rs create mode 100644 crates/app/ui/components/ai-panel.slint create mode 100644 crates/app/ui/components/badge.slint create mode 100644 crates/app/ui/components/glass.slint create mode 100644 crates/app/ui/components/icons.slint create mode 100644 crates/app/ui/components/label.slint create mode 100644 crates/app/ui/components/nav.slint create mode 100644 crates/app/ui/components/progress-ring.slint create mode 100644 crates/app/ui/components/tile.slint create mode 100644 crates/app/ui/components/titlebar.slint create mode 100644 crates/app/ui/main.slint create mode 100644 crates/app/ui/models.slint create mode 100644 crates/app/ui/pages/download.slint create mode 100644 crates/app/ui/pages/instances.slint create mode 100644 crates/app/ui/pages/logs.slint create mode 100644 crates/app/ui/pages/settings-appearance.slint create mode 100644 crates/app/ui/pages/settings.slint create mode 100644 crates/app/ui/shell.slint create mode 100644 crates/app/ui/theme/metrics.slint create mode 100644 crates/app/ui/theme/tokens.slint create mode 100644 crates/app/ui/widgets/button.slint create mode 100644 crates/app/ui/widgets/dialog.slint create mode 100644 crates/app/ui/widgets/inputs.slint create mode 100644 crates/app/ui/widgets/setting-item.slint create mode 100644 crates/app/ui/widgets/toast.slint create mode 100644 crates/cli/Cargo.toml create mode 100644 crates/cli/src/cli/help.rs create mode 100644 crates/cli/src/cli/mod.rs create mode 100644 crates/cli/src/cli/model.rs create mode 100644 crates/cli/src/cli/parse.rs create mode 100644 crates/cli/src/cli/parse/accounts.rs create mode 100644 crates/cli/src/cli/parse/agent.rs create mode 100644 crates/cli/src/commands/accounts.rs create mode 100644 crates/cli/src/commands/agent/mod.rs create mode 100644 crates/cli/src/commands/agent/ports/accounts.rs create mode 100644 crates/cli/src/commands/agent/ports/diagnose.rs create mode 100644 crates/cli/src/commands/agent/ports/events.rs create mode 100644 crates/cli/src/commands/agent/ports/game.rs create mode 100644 crates/cli/src/commands/agent/ports/mod.rs create mode 100644 crates/cli/src/commands/agent/ports/session.rs create mode 100644 crates/cli/src/commands/agent/ports/setting.rs create mode 100644 crates/cli/src/commands/agent/ports/setting/tests.rs create mode 100644 crates/cli/src/commands/agent/serve.rs create mode 100644 crates/cli/src/commands/config.rs create mode 100644 crates/cli/src/commands/config/tests.rs create mode 100644 crates/cli/src/commands/identity.rs create mode 100644 crates/cli/src/commands/install/loader.rs create mode 100644 crates/cli/src/commands/install/loader_processors.rs create mode 100644 crates/cli/src/commands/install/mod.rs create mode 100644 crates/cli/src/commands/install/report.rs create mode 100644 crates/cli/src/commands/install/report_loader.rs create mode 100644 crates/cli/src/commands/install/vanilla.rs create mode 100644 crates/cli/src/commands/instances.rs create mode 100644 crates/cli/src/commands/java.rs create mode 100644 crates/cli/src/commands/java_launch.rs create mode 100644 crates/cli/src/commands/java_vendors.rs create mode 100644 crates/cli/src/commands/launch.rs create mode 100644 crates/cli/src/commands/meta.rs create mode 100644 crates/cli/src/commands/mod.rs create mode 100644 crates/cli/src/commands/sessions.rs create mode 100644 crates/cli/src/commands/snapshot.rs create mode 100644 crates/cli/src/context.rs create mode 100644 crates/cli/src/error.rs create mode 100644 crates/cli/src/lib.rs create mode 100644 crates/cli/src/main.rs create mode 100644 crates/cli/src/os.rs create mode 100644 crates/cli/src/output.rs create mode 100644 crates/cli/src/runner.rs create mode 100644 crates/cli/tests/accounts_test.rs create mode 100644 crates/cli/tests/agent_mcp_stdio_test.rs create mode 100644 crates/cli/tests/config_secret_test.rs create mode 100644 crates/cli/tests/exit_code_test.rs create mode 100644 crates/cli/tests/isolation_launch_test.rs create mode 100644 crates/cli/tests/java_test.rs create mode 100644 crates/cli/tests/os_test.rs create mode 100644 crates/cli/tests/output_test.rs create mode 100644 crates/cli/tests/parse_test.rs create mode 100644 crates/cli/tests/support/mod.rs create mode 100644 crates/core/Cargo.toml create mode 100644 crates/core/src/addon/loader/fabric.rs create mode 100644 crates/core/src/addon/loader/forge.rs create mode 100644 crates/core/src/addon/loader/maven.rs create mode 100644 crates/core/src/addon/loader/meta.rs create mode 100644 crates/core/src/addon/loader/mod.rs create mode 100644 crates/core/src/addon/loader/plan.rs create mode 100644 crates/core/src/addon/loader/processors/download.rs create mode 100644 crates/core/src/addon/loader/processors/error.rs create mode 100644 crates/core/src/addon/loader/processors/exec.rs create mode 100644 crates/core/src/addon/loader/processors/jar.rs create mode 100644 crates/core/src/addon/loader/processors/mod.rs create mode 100644 crates/core/src/addon/loader/processors/plan.rs create mode 100644 crates/core/src/addon/loader/processors/prepare.rs create mode 100644 crates/core/src/addon/loader/processors/spec.rs create mode 100644 crates/core/src/addon/loader/processors/tests/exec.rs create mode 100644 crates/core/src/addon/loader/processors/tests/mod.rs create mode 100644 crates/core/src/addon/loader/processors/tests/plan.rs create mode 100644 crates/core/src/addon/loader/processors/tests/vars.rs create mode 100644 crates/core/src/addon/loader/processors/text.rs create mode 100644 crates/core/src/addon/loader/processors/vars.rs create mode 100644 crates/core/src/addon/loader/tests/fabric.rs create mode 100644 crates/core/src/addon/loader/tests/forge.rs create mode 100644 crates/core/src/addon/loader/tests/maven.rs create mode 100644 crates/core/src/addon/loader/tests/mod.rs create mode 100644 crates/core/src/addon/loader/tests/plan.rs create mode 100644 crates/core/src/addon/mod.rs create mode 100644 crates/core/src/auth/account.rs create mode 100644 crates/core/src/auth/authlib.rs create mode 100644 crates/core/src/auth/authlib/server_meta.rs create mode 100644 crates/core/src/auth/authlib/tests.rs create mode 100644 crates/core/src/auth/body/mod.rs create mode 100644 crates/core/src/auth/body/tests.rs create mode 100644 crates/core/src/auth/error.rs create mode 100644 crates/core/src/auth/http.rs create mode 100644 crates/core/src/auth/journal.rs create mode 100644 crates/core/src/auth/microsoft/chain.rs create mode 100644 crates/core/src/auth/microsoft/endpoints.rs create mode 100644 crates/core/src/auth/microsoft/flow.rs create mode 100644 crates/core/src/auth/microsoft/mod.rs create mode 100644 crates/core/src/auth/microsoft/session.rs create mode 100644 crates/core/src/auth/mod.rs create mode 100644 crates/core/src/auth/offline.rs create mode 100644 crates/core/src/auth/secret.rs create mode 100644 crates/core/src/auth/store.rs create mode 100644 crates/core/src/auth/store/payload.rs create mode 100644 crates/core/src/diagnose/collectors.rs create mode 100644 crates/core/src/diagnose/engine.rs create mode 100644 crates/core/src/diagnose/environment.rs create mode 100644 crates/core/src/diagnose/finding.rs create mode 100644 crates/core/src/diagnose/fix_action.rs create mode 100644 crates/core/src/diagnose/fix_params.rs create mode 100644 crates/core/src/diagnose/hs_err.rs create mode 100644 crates/core/src/diagnose/hs_err_report.rs create mode 100644 crates/core/src/diagnose/loader.rs create mode 100644 crates/core/src/diagnose/mod.rs create mode 100644 crates/core/src/diagnose/rules.d/fabric.yaml create mode 100644 crates/core/src/diagnose/rules.d/forge.yaml create mode 100644 crates/core/src/diagnose/rules.d/graphics.yaml create mode 100644 crates/core/src/diagnose/rules.d/integrity.yaml create mode 100644 crates/core/src/diagnose/rules.d/jvm.yaml create mode 100644 crates/core/src/diagnose/rules.d/memory.yaml create mode 100644 crates/core/src/diagnose/rules.d/mod_conflict.yaml create mode 100644 crates/core/src/diagnose/rules.d/mod_runtime.yaml create mode 100644 crates/core/src/diagnose/rules.d/other.yaml create mode 100644 crates/core/src/diagnose/rules.d/samples/fabric/loader_0_12.log create mode 100644 crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution.log create mode 100644 crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_collection.log create mode 100644 crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_conflict.log create mode 100644 crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_details.log create mode 100644 crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_missing.log create mode 100644 crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_missing_minecraft.log create mode 100644 crates/core/src/diagnose/rules.d/samples/fabric/warnings.log create mode 100644 crates/core/src/diagnose/rules.d/samples/forge/bootstrap_failed.log create mode 100644 crates/core/src/diagnose/rules.d/samples/forge/duplicate_mods.log create mode 100644 crates/core/src/diagnose/rules.d/samples/forge/duplicated_mod.log create mode 100644 crates/core/src/diagnose/rules.d/samples/forge/missing_dependencies.log create mode 100644 crates/core/src/diagnose/rules.d/samples/forge/module_name_invalid.log create mode 100644 crates/core/src/diagnose/rules.d/samples/forge/optifine_repeat_installation.log create mode 100644 crates/core/src/diagnose/rules.d/samples/forge/repeat_installation.log create mode 100644 crates/core/src/diagnose/rules.d/samples/forge/too_many_mods.log create mode 100644 crates/core/src/diagnose/rules.d/samples/graphics/gl_operation_failure.log create mode 100644 crates/core/src/diagnose/rules.d/samples/graphics/graphics_driver.log create mode 100644 crates/core/src/diagnose/rules.d/samples/graphics/macos_display_port.log create mode 100644 crates/core/src/diagnose/rules.d/samples/graphics/no_graphics_backend.log create mode 100644 crates/core/src/diagnose/rules.d/samples/graphics/opengl_not_supported.log create mode 100644 crates/core/src/diagnose/rules.d/samples/graphics/resolution_too_high.log create mode 100644 crates/core/src/diagnose/rules.d/samples/graphics/rtss_sodium.log create mode 100644 crates/core/src/diagnose/rules.d/samples/graphics/vulkan_device_version_too_low.log create mode 100644 crates/core/src/diagnose/rules.d/samples/graphics/vulkan_loader_missing.log create mode 100644 crates/core/src/diagnose/rules.d/samples/graphics/vulkan_missing_extension.log create mode 100644 crates/core/src/diagnose/rules.d/samples/graphics/vulkan_missing_feature.log create mode 100644 crates/core/src/diagnose/rules.d/samples/hs_err/access_violation.log create mode 100644 crates/core/src/diagnose/rules.d/samples/hs_err/out_of_memory.log create mode 100644 crates/core/src/diagnose/rules.d/samples/integrity/file_already_exists.log create mode 100644 crates/core/src/diagnose/rules.d/samples/integrity/file_changed.log create mode 100644 crates/core/src/diagnose/rules.d/samples/integrity/illegal_access.log create mode 100644 crates/core/src/diagnose/rules.d/samples/integrity/incomplete_forge.log create mode 100644 crates/core/src/diagnose/rules.d/samples/integrity/mod_files_decompressed.log create mode 100644 crates/core/src/diagnose/rules.d/samples/integrity/no_class_def_found.log create mode 100644 crates/core/src/diagnose/rules.d/samples/integrity/no_such_method.log create mode 100644 crates/core/src/diagnose/rules.d/samples/integrity/unsatisfied_link.log create mode 100644 crates/core/src/diagnose/rules.d/samples/jvm/java_version_is_too_high.log create mode 100644 crates/core/src/diagnose/rules.d/samples/jvm/jdk_9.log create mode 100644 crates/core/src/diagnose/rules.d/samples/jvm/jvm_32bit.log create mode 100644 crates/core/src/diagnose/rules.d/samples/jvm/mac_jdk8u261.log create mode 100644 crates/core/src/diagnose/rules.d/samples/jvm/modlauncher_8.log create mode 100644 crates/core/src/diagnose/rules.d/samples/jvm/need_jdk11.log create mode 100644 crates/core/src/diagnose/rules.d/samples/jvm/openj9.log create mode 100644 crates/core/src/diagnose/rules.d/samples/jvm/too_old_java.log create mode 100644 crates/core/src/diagnose/rules.d/samples/memory/memory_exceeded.log create mode 100644 crates/core/src/diagnose/rules.d/samples/memory/out_of_memory.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_conflict/jade_optifine.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_conflict/multipart_optifine.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_conflict/neoforge_optifine.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_conflict/optifine_forge.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_conflict/optifine_world_load.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_conflict/performant_optifine.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_conflict/shaders_mod.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_conflict/twilight_optifine.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_runtime/config_failed.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_runtime/forge_error.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_runtime/install_mixinbootstrap.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_runtime/loading_crashed_fabric.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_runtime/loading_crashed_forge.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_runtime/mixin_apply_failed.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_runtime/mixin_failure.log create mode 100644 crates/core/src/diagnose/rules.d/samples/mod_runtime/night_config_fixes.log create mode 100644 crates/core/src/diagnose/rules.d/samples/other/debug_crash.log create mode 100644 crates/core/src/diagnose/rules.d/samples/world/block_model.log create mode 100644 crates/core/src/diagnose/rules.d/samples/world/entity_tick.log create mode 100644 crates/core/src/diagnose/rules.d/world.yaml create mode 100644 crates/core/src/diagnose/rules.rs create mode 100644 crates/core/src/diagnose/signals.rs create mode 100644 crates/core/src/diagnose/sources.rs create mode 100644 crates/core/src/diagnose/synthetic.rs create mode 100644 crates/core/src/download/adoptium.rs create mode 100644 crates/core/src/download/atomic.rs create mode 100644 crates/core/src/download/backoff.rs create mode 100644 crates/core/src/download/blocking.rs create mode 100644 crates/core/src/download/bytes.rs create mode 100644 crates/core/src/download/cache.rs create mode 100644 crates/core/src/download/chunked.rs create mode 100644 crates/core/src/download/config.rs create mode 100644 crates/core/src/download/engine.rs create mode 100644 crates/core/src/download/error.rs create mode 100644 crates/core/src/download/health.rs create mode 100644 crates/core/src/download/health_store.rs create mode 100644 crates/core/src/download/http.rs create mode 100644 crates/core/src/download/jdk_sources.rs create mode 100644 crates/core/src/download/mirrors.json create mode 100644 crates/core/src/download/mirrors.rs create mode 100644 crates/core/src/download/mod.rs create mode 100644 crates/core/src/download/progress.rs create mode 100644 crates/core/src/download/region.rs create mode 100644 crates/core/src/download/region_system.rs create mode 100644 crates/core/src/download/resume.rs create mode 100644 crates/core/src/download/transfer.rs create mode 100644 crates/core/src/download/transfer_chunk.rs create mode 100644 crates/core/src/download/types.rs create mode 100644 crates/core/src/download/verify.rs create mode 100644 crates/core/src/game/arguments.rs create mode 100644 crates/core/src/game/capability.rs create mode 100644 crates/core/src/game/data/unlisted-versions.json create mode 100644 crates/core/src/game/error.rs create mode 100644 crates/core/src/game/inherit.rs create mode 100644 crates/core/src/game/install/assets.rs create mode 100644 crates/core/src/game/install/execute.rs create mode 100644 crates/core/src/game/install/instance.rs create mode 100644 crates/core/src/game/install/libraries.rs create mode 100644 crates/core/src/game/install/mod.rs create mode 100644 crates/core/src/game/install/plan.rs create mode 100644 crates/core/src/game/install/plan_view.rs create mode 100644 crates/core/src/game/install/source.rs create mode 100644 crates/core/src/game/install/tests/execute.rs create mode 100644 crates/core/src/game/install/tests/instance.rs create mode 100644 crates/core/src/game/install/tests/mod.rs create mode 100644 crates/core/src/game/install/tests/plan.rs create mode 100644 crates/core/src/game/isolation.rs create mode 100644 crates/core/src/game/java/api.rs create mode 100644 crates/core/src/game/java/discover.rs create mode 100644 crates/core/src/game/java/error.rs create mode 100644 crates/core/src/game/java/extract.rs create mode 100644 crates/core/src/game/java/install.rs create mode 100644 crates/core/src/game/java/location.rs create mode 100644 crates/core/src/game/java/mapping.rs create mode 100644 crates/core/src/game/java/microsoft.rs create mode 100644 crates/core/src/game/java/mod.rs create mode 100644 crates/core/src/game/java/net.rs create mode 100644 crates/core/src/game/java/oracle.rs create mode 100644 crates/core/src/game/java/probe.rs create mode 100644 crates/core/src/game/java/release.rs create mode 100644 crates/core/src/game/java/select.rs create mode 100644 crates/core/src/game/java/source.rs create mode 100644 crates/core/src/game/java/target.rs create mode 100644 crates/core/src/game/java/vendor.rs create mode 100644 crates/core/src/game/java/zulu.rs create mode 100644 crates/core/src/game/layout.rs create mode 100644 crates/core/src/game/library.rs create mode 100644 crates/core/src/game/manifest.rs create mode 100644 crates/core/src/game/mod.rs create mode 100644 crates/core/src/game/profiles.rs create mode 100644 crates/core/src/game/snapshot.rs create mode 100644 crates/core/src/game/unlisted.rs create mode 100644 crates/core/src/game/version.rs create mode 100644 crates/core/src/launch/adapter.rs create mode 100644 crates/core/src/launch/args_table.rs create mode 100644 crates/core/src/launch/argument.rs create mode 100644 crates/core/src/launch/command_builder.rs create mode 100644 crates/core/src/launch/crash_scan.rs create mode 100644 crates/core/src/launch/defaults.rs create mode 100644 crates/core/src/launch/diagnosis_hook.rs create mode 100644 crates/core/src/launch/error.rs create mode 100644 crates/core/src/launch/exit_analysis.rs create mode 100644 crates/core/src/launch/game_args.rs create mode 100644 crates/core/src/launch/game_version.rs create mode 100644 crates/core/src/launch/jvm_args.rs create mode 100644 crates/core/src/launch/launch_session.rs create mode 100644 crates/core/src/launch/launcher.rs create mode 100644 crates/core/src/launch/log_level.rs create mode 100644 crates/core/src/launch/mod.rs create mode 100644 crates/core/src/launch/natives/jar.rs create mode 100644 crates/core/src/launch/natives/mod.rs create mode 100644 crates/core/src/launch/placeholders.rs create mode 100644 crates/core/src/launch/platform.rs create mode 100644 crates/core/src/launch/process.rs create mode 100644 crates/core/src/launch/pump.rs create mode 100644 crates/core/src/launch/redact.rs create mode 100644 crates/core/src/launch/request.rs create mode 100644 crates/core/src/launch/session.rs create mode 100644 crates/core/src/launch/session_index.rs create mode 100644 crates/core/src/launch/session_meta.rs create mode 100644 crates/core/src/launch/session_payload.rs create mode 100644 crates/core/src/launch/session_sink.rs create mode 100644 crates/core/src/launch/sink.rs create mode 100644 crates/core/src/launch/time.rs create mode 100644 crates/core/src/launch/tree_kill.rs create mode 100644 crates/core/src/launch/watchdog.rs create mode 100644 crates/core/src/launch/window_detect.rs create mode 100644 crates/core/src/lib.rs create mode 100644 crates/core/src/platform/atomic.rs create mode 100644 crates/core/src/platform/data_dir.rs create mode 100644 crates/core/src/platform/dir_migration.rs create mode 100644 crates/core/src/platform/error.rs create mode 100644 crates/core/src/platform/hash.rs create mode 100644 crates/core/src/platform/layout.rs create mode 100644 crates/core/src/platform/linux_caps.rs create mode 100644 crates/core/src/platform/macos_version.rs create mode 100644 crates/core/src/platform/mod.rs create mode 100644 crates/core/src/platform/os_info.rs create mode 100644 crates/core/src/platform/paths.rs create mode 100644 crates/core/src/platform/receipt.rs create mode 100644 crates/core/src/platform/redirect.rs create mode 100644 crates/core/src/platform/theme_probe.rs create mode 100644 crates/core/src/platform/vk_probe.rs create mode 100644 crates/core/src/platform/windows_version.rs create mode 100644 crates/core/src/setting/appearance.rs create mode 100644 crates/core/src/setting/catalog.rs create mode 100644 crates/core/src/setting/document.rs create mode 100644 crates/core/src/setting/error.rs create mode 100644 crates/core/src/setting/instance_keys.rs create mode 100644 crates/core/src/setting/json_path.rs create mode 100644 crates/core/src/setting/keys.rs create mode 100644 crates/core/src/setting/migrate.rs create mode 100644 crates/core/src/setting/mod.rs create mode 100644 crates/core/src/setting/policy.rs create mode 100644 crates/core/src/setting/save.rs create mode 100644 crates/core/src/setting/save_worker.rs create mode 100644 crates/core/src/setting/schema.rs create mode 100644 crates/core/src/setting/spec.rs create mode 100644 crates/core/src/setting/store.rs create mode 100644 crates/core/tests/auth/auth_failure_journal.rs create mode 100644 crates/core/tests/auth/authlib_api.rs create mode 100644 crates/core/tests/auth/main.rs create mode 100644 crates/core/tests/auth/microsoft_diagnostics.rs create mode 100644 crates/core/tests/auth/microsoft_flow.rs create mode 100644 crates/core/tests/auth/microsoft_refresh.rs create mode 100644 crates/core/tests/auth/offline_uuid.rs create mode 100644 crates/core/tests/auth/store_separation.rs create mode 100644 crates/core/tests/auth/support/microsoft.rs create mode 100644 crates/core/tests/auth/support/mod.rs create mode 100644 crates/core/tests/diagnose/collectors_probe.rs create mode 100644 crates/core/tests/diagnose/engine_rank.rs create mode 100644 crates/core/tests/diagnose/hs_err_parse.rs create mode 100644 crates/core/tests/diagnose/loader_reload.rs create mode 100644 crates/core/tests/diagnose/main.rs create mode 100644 crates/core/tests/diagnose/rules_samples.rs create mode 100644 crates/core/tests/download/backoff_test.rs create mode 100644 crates/core/tests/download/cache_test.rs create mode 100644 crates/core/tests/download/chunked_test.rs create mode 100644 crates/core/tests/download/engine_mock_test.rs create mode 100644 crates/core/tests/download/health_test.rs create mode 100644 crates/core/tests/download/mirrors_test.rs create mode 100644 crates/core/tests/download/partfile_test.rs create mode 100644 crates/core/tests/download/region_test.rs create mode 100644 crates/core/tests/download/resume_test.rs create mode 100644 crates/core/tests/download/support/mod.rs create mode 100644 crates/core/tests/game/arguments_rules.rs create mode 100644 crates/core/tests/game/helpers.rs create mode 100644 crates/core/tests/game/inherit_chain.rs create mode 100644 crates/core/tests/game/isolation.rs create mode 100644 crates/core/tests/game/isolation_switch.rs create mode 100644 crates/core/tests/game/java_discover.rs create mode 100644 crates/core/tests/game/java_install.rs create mode 100644 crates/core/tests/game/java_location.rs create mode 100644 crates/core/tests/game/java_mapping.rs create mode 100644 crates/core/tests/game/java_mirrors.rs create mode 100644 crates/core/tests/game/java_vendor_chain.rs create mode 100644 crates/core/tests/game/layout.rs create mode 100644 crates/core/tests/game/main.rs create mode 100644 crates/core/tests/game/manifest_parse.rs create mode 100644 crates/core/tests/game/profiles.rs create mode 100644 crates/core/tests/game/snapshot.rs create mode 100644 crates/core/tests/game/unlisted.rs create mode 100644 crates/core/tests/game/version_list.rs create mode 100644 crates/core/tests/game/vulkan_capability.rs create mode 100644 crates/core/tests/launch/adapter_cases.rs create mode 100644 crates/core/tests/launch/cmdline_snapshot.rs create mode 100644 crates/core/tests/launch/command_builder_cases.rs create mode 100644 crates/core/tests/launch/exit_cases.rs create mode 100644 crates/core/tests/launch/graphics_backend_cases.rs create mode 100644 crates/core/tests/launch/main.rs create mode 100644 crates/core/tests/launch/natives_args_cases.rs create mode 100644 crates/core/tests/launch/natives_cases.rs create mode 100644 crates/core/tests/launch/pipeline_cases.rs create mode 100644 crates/core/tests/launch/prelude.rs create mode 100644 crates/core/tests/launch/process_smoke.rs create mode 100644 crates/core/tests/launch/pump_buffer.rs create mode 100644 crates/core/tests/launch/session_events.rs create mode 100644 crates/core/tests/launch/sink_cases.rs create mode 100644 crates/core/tests/platform/linux_caps_cases.rs create mode 100644 crates/core/tests/platform/macos_cases.rs create mode 100644 crates/core/tests/platform/main.rs create mode 100644 crates/core/tests/platform/os_info_cases.rs create mode 100644 crates/core/tests/platform/prelude.rs create mode 100644 crates/core/tests/platform/rules_wiring.rs create mode 100644 crates/core/tests/platform/theme_cases.rs create mode 100644 crates/core/tests/platform/vk_cases.rs create mode 100644 crates/core/tests/platform/windows_version_cases.rs create mode 100644 crates/core/tests/setting/appearance_keys.rs create mode 100644 crates/core/tests/setting/atomic_write.rs create mode 100644 crates/core/tests/setting/common.rs create mode 100644 crates/core/tests/setting/debounce.rs create mode 100644 crates/core/tests/setting/deprecated_keys.rs create mode 100644 crates/core/tests/setting/dir_migration.rs create mode 100644 crates/core/tests/setting/dirs.rs create mode 100644 crates/core/tests/setting/document.rs create mode 100644 crates/core/tests/setting/isolation_keys.rs create mode 100644 crates/core/tests/setting/java_install_keys.rs create mode 100644 crates/core/tests/setting/layout.rs create mode 100644 crates/core/tests/setting/main.rs create mode 100644 crates/core/tests/setting/migration_chain.rs create mode 100644 crates/core/tests/setting/redirect.rs create mode 100644 crates/core/tests/setting/schema_compat.rs create mode 100644 crates/core/tests/setting/spec_ai.rs create mode 100644 crates/core/tests/setting/store.rs create mode 100644 deny.toml create mode 100644 rust-toolchain.toml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f3f1a1b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,34 @@ +# 换行策略:仓库内一律 LF(rustfmt 设 newline_style = "Unix",CI 在 Linux 上跑 fmt --check)。 +# eol=lf 对工作区同样生效 —— Windows 克隆也不会被转换成 CRLF,避免「本地 fmt 过、CI fmt 挂」。 +* text=auto eol=lf + +# 明确为文本(显式列出,防止被误判成二进制) +*.rs text eol=lf +*.toml text eol=lf +*.md text eol=lf +*.slint text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +Cargo.lock text eol=lf + +# 二进制:不做换行转换、不做文本 diff +*.png binary +*.ico binary +*.icns binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary +*.zip binary +*.7z binary +*.gz binary +*.tar binary +*.exe binary +*.dll binary +*.lib binary +*.pdb binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f472f4c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,92 @@ +name: CI + +# 主干门禁:fmt / clippy / test / build 双平台矩阵。 +# 架构守门(core 零 UI、文件 ≤400 行、依赖许可)见 guards.yml。 +# 平台基线 Win10+(标准工具链产物),平台矩阵见 AGENTS.md §7。 + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + fmt: + name: fmt (rustfmt --check) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all --check + + clippy: + name: clippy (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Linux 图形栈构建依赖(winit / femtovg 编译期需要) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \ + libwayland-dev libxcb1-dev + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --workspace --all-targets -- -D warnings + + test: + name: test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Linux 图形栈构建依赖 + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \ + libwayland-dev libxcb1-dev + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --workspace --all-targets + + build: + name: build release (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Linux 图形栈构建依赖 + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev \ + libwayland-dev libxcb1-dev + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo build --workspace --release diff --git a/.github/workflows/guards.yml b/.github/workflows/guards.yml new file mode 100644 index 0000000..4d3d580 --- /dev/null +++ b/.github/workflows/guards.yml @@ -0,0 +1,81 @@ +name: Guards + +# 架构与合规守门(与 fmt/clippy 同级,不允许 continue-on-error): +# 1. core-no-ui —— crates/core 零 UI 依赖(D3 铁律),依赖表 + cargo tree 双查 +# 2. file-size —— 全仓代码/文档单文件 ≤400 行(用户硬约束) +# 3. licenses —— cargo-deny:许可证白名单 + GPL 依赖拦截 + 供应链来源校验 + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + core-no-ui: + name: core 零 UI 守门 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: 依赖表直查(crates/core/Cargo.toml 不得出现 UI 框架) + run: | + set -uo pipefail + forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm' + # 只看非注释行:manifest 里说明「不许出现 slint」的注释本身不该触发守门 + if grep -vE '^[[:space:]]*#' crates/core/Cargo.toml | grep -nEi "$forbidden"; then + echo "::error file=crates/core/Cargo.toml::core 不得依赖 UI 框架(见 AGENTS.md 硬约束)" + exit 1 + fi + echo "OK: crates/core/Cargo.toml 无 UI 依赖" + - name: 依赖树泄漏检查(cargo tree -p unlucky-core,排除自身行) + run: | + set -uo pipefail + forbidden='slint|egui|eframe|iced|winit|gtk|javafx|tauri|fltk|druid|relm' + tree=$(cargo tree -p unlucky-core --edges normal --prefix none) + echo "$tree" + if echo "$tree" | grep -vE '^unlucky-core v' | grep -qiE "$forbidden"; then + echo "::error::core 的依赖树出现 UI 框架 —— 传递依赖也不允许" + exit 1 + fi + echo "OK: unlucky-core 依赖树零 UI" + + file-size: + name: 文件行数 ≤400 守门 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: 检查单文件行数 + run: | + set -uo pipefail + LIMIT=400 + failed=0 + while IFS= read -r f; do + case "$f" in + LICENSE|*/LICENSE|LICENSE-*|*/LICENSE-*) continue ;; + CHANGELOG*|*/CHANGELOG*) continue ;; + esac + n=$(wc -l < "$f") + if [ "$n" -gt "$LIMIT" ]; then + echo "::error file=$f::$f 有 $n 行,超过 $LIMIT 行上限,请按模块拆分" + failed=$((failed + 1)) + fi + done < <(git ls-files '*.rs' '*.md' '*.slint' '*.yml' '*.yaml' '*.toml' '*.json') + if [ "$failed" -ne 0 ]; then + echo "共 $failed 个文件超限" + exit 1 + fi + echo "OK: 全部受检文件 ≤$LIMIT 行" + + licenses: + name: 依赖许可与来源(cargo-deny) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + # 只跑许可/禁用/来源三类,advisories 由后续安全轮次单独接管 + command: check licenses bans sources diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f670c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Reference source repositories (local-only, git-ignored) +/HMCL/ +/PCL/ + +# Local development docs +/DevDocs/ + +# Agent workspace (local-only) +/.tower/ + +# Build outputs +build/ +dist/ +out/ +target/ + +# IDE +.idea/ +.vscode/ +*.iml + +# OS +Thumbs.db +.DS_Store diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..bdb5303 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,5 @@ +edition = "2021" +max_width = 100 +newline_style = "Unix" +use_field_init_shorthand = true +use_try_shorthand = true diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..09ae405 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7413 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_ios" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "750c4e9f6ce888dfe8a10c0f1b5ceb646a7854fd46579d74919219d1bb314083" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5b41e63a69f36d9f1f41e70464c7e5f72eee485ef26aab19f0b4f86e6c0a84c" +dependencies = [ + "accesskit", + "accesskit_ios", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "annotate-snippets" +version = "0.12.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a5b0a5ab8561c603f16c3b78c24a386b1f70e7fc1b1b1e8c15738828e4a176" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "arboard" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" +dependencies = [ + "clipboard-win", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.61.2", + "wl-clipboard-rs", + "x11rb", +] + +[[package]] +name = "arc-swap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.6", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.6", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.6", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "serde", + "unty", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.6", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] + +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-field-offset" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d535c69e75b4e3c69e17205b5b37a91936c54086cecd339b70071500786acb9c" +dependencies = [ + "const-field-offset-macro", + "field-offset", +] + +[[package]] +name = "const-field-offset-macro" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d5848843a221ac268cfdd6bfd14e68d5d3dfa79d52c880aafab93f9517fb74" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "drm" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +dependencies = [ + "drm-sys", + "rustix 1.1.6", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" +dependencies = [ + "libc", + "linux-raw-sys 0.9.4", +] + +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "femtovg" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31eebf3ab1b76359ccd5f850c07a7c6b2722c64d9127c40e02358e83f3ff9b1a" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "fnv", + "glow", + "imgref", + "itertools 0.15.0", + "log", + "rgb", + "slotmap", + "swash", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "log", + "slotmap", + "tinyvec", +] + +[[package]] +name = "fontique" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "parlance", + "read-fonts", + "roxmltree", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" +dependencies = [ + "gloo-timers", + "send_wrapper", +] + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gbm" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" +dependencies = [ + "bitflags 2.13.2", + "drm", + "drm-fourcc", + "gbm-sys", + "libc", +] + +[[package]] +name = "gbm-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f" +dependencies = [ + "libc", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.6", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "glow" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263218b0ba2b0715c3370fb04674f5f8aa331594b521c94ff0a8e4a8472d1386" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "htmlparser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48ce8546b993eaf241d69ded33b1be6d205dd9857ec879d9d18bd05d3676e144" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.11", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "i-slint-backend-linuxkms" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f519a8611a4982921e46e391015e5e40ceac5cc63813333ef91ada77df180d8" +dependencies = [ + "bytemuck", + "calloop 0.14.5", + "cfg_aliases", + "drm", + "gbm", + "glutin", + "i-slint-common", + "i-slint-core", + "i-slint-renderer-femtovg", + "i-slint-renderer-software", + "input", + "libseat", + "memmap2", + "nix", + "raw-window-handle", + "xkbcommon", +] + +[[package]] +name = "i-slint-backend-selector" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb974705d61ba56034f6129ff61f3e6282a2c0c07752f25e6cb417d81c0f0958" +dependencies = [ + "cfg-if", + "cfg_aliases", + "i-slint-backend-linuxkms", + "i-slint-backend-testing", + "i-slint-backend-winit", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "i-slint-renderer-femtovg", +] + +[[package]] +name = "i-slint-backend-testing" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01943d6c5ea5bc913d497bdaa423c309e7627afff47a6794bfd13be5c564209f" +dependencies = [ + "cfg_aliases", + "i-slint-common", + "i-slint-core", + "i-slint-renderer-software", + "vtable", +] + +[[package]] +name = "i-slint-backend-winit" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae4fcf1b19e9cb109984d2af87d2b9485388c619265bff71dd523133888bee2" +dependencies = [ + "accesskit", + "accesskit_winit", + "arboard", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "futures", + "glutin", + "glutin-winit", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "i-slint-renderer-femtovg", + "i-slint-renderer-skia", + "i-slint-renderer-software", + "imgref", + "lyon_path", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-ui-kit 0.3.2", + "pin-weak", + "raw-window-handle", + "rgb", + "scoped-tls-hkt", + "scopeguard", + "softbuffer", + "strum", + "vtable", + "wasm-bindgen", + "web-sys", + "webbrowser", + "windows", + "winit", + "zbus", +] + +[[package]] +name = "i-slint-common" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a578f3e4b681b2b9cbd326d3be19716e2fac51d00a8a5b00c0ef4ae923b5e6" +dependencies = [ + "derive_more", + "fontique", + "htmlparser", + "icu_decimal", + "icu_locale_core", + "icu_provider", + "pulldown-cmark", + "resvg", + "skrifa", +] + +[[package]] +name = "i-slint-compiler" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55d48ebef05e2bf09ba5a1fc67bcb11033b9e12a2a50ed3105c7d25e2b539d0" +dependencies = [ + "annotate-snippets", + "by_address", + "data-url", + "derive_more", + "i-slint-common", + "icu_normalizer", + "icu_properties", + "image", + "indexmap", + "itertools 0.15.0", + "lyon_extra", + "lyon_path", + "num_enum", + "proc-macro2", + "quote", + "rayon", + "resvg", + "rowan", + "rspolib", + "skrifa", + "smol_str 0.3.7", + "strum", + "swash", + "typed-index-collections", + "unicode-segmentation", + "url", +] + +[[package]] +name = "i-slint-core" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2ea15059d70bc31b1a8b1eb4c34f2c5df04cb7e53f36179b43c0261231d646" +dependencies = [ + "accesskit", + "async-channel", + "bitflags 2.13.2", + "cfg-if", + "chrono", + "clru", + "const-field-offset", + "derive_more", + "euclid", + "i-slint-common", + "i-slint-core-macros", + "icu_normalizer", + "image", + "js-sys", + "ksni", + "lyon_algorithms", + "lyon_extra", + "lyon_geom", + "lyon_path", + "num-traits", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "once_cell", + "parley", + "pin-project", + "pin-weak", + "portable-atomic", + "raw-window-handle", + "resvg", + "rgb", + "scopeguard", + "skrifa", + "slab", + "strum", + "swash", + "sys-locale", + "taffy", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", + "vtable", + "wasm-bindgen", + "web-sys", + "web-time", + "wgpu", + "windows", +] + +[[package]] +name = "i-slint-core-macros" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f5a8a076de52f53be80caa826599ed2107b54c9d4a421e66dc06cf893a984c" +dependencies = [ + "quote", + "serde_json", + "syn 3.0.9", +] + +[[package]] +name = "i-slint-renderer-femtovg" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb0de504d57cdadf80c2abf7bcb8b7135a3716aee1f41b6ebb90ea93e3649d7c" +dependencies = [ + "cfg-if", + "const-field-offset", + "femtovg", + "glow", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "imgref", + "lyon_path", + "pin-weak", + "rgb", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "i-slint-renderer-skia" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc9c1cf02385f1d26fc1f423544e52390a8e5c2d89420a8042aa94b4fe109d1f" +dependencies = [ + "bytemuck", + "cfg-if", + "cfg_aliases", + "clru", + "const-field-offset", + "glow", + "glutin", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "lyon_path", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "pin-weak", + "raw-window-handle", + "read-fonts", + "scoped-tls-hkt", + "skia-safe", + "softbuffer", + "spin_on", + "unicode-segmentation", + "vtable", + "wgpu", + "wgpu-hal", + "windows", + "windows-core", + "write-fonts", +] + +[[package]] +name = "i-slint-renderer-software" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f8c641e138a2cc96537cac4481f5c7c8bb8dcc66d09acaed269329c8738628a" +dependencies = [ + "bytemuck", + "clru", + "derive_more", + "euclid", + "i-slint-common", + "i-slint-core", + "lyon_path", + "num-traits", + "skrifa", + "swash", + "zeno", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_decimal" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1150ad5e272740670464934ee76facda43ef2e11f85c27b7ba76ccb33edf2d05" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_decimal_data", + "icu_locale_core", + "icu_locale_fallback", + "icu_plurals", + "icu_provider", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_decimal_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5595ab78370d48abd24a062f3db115d5108dd0b65634352036cae532aebb0df8" + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_plurals" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef046647b445acf95ce0f8083fa292467d3df649959fa9a20f3f8abfd957c5d" +dependencies = [ + "fixed_decimal", + "icu_locale_fallback", + "icu_plurals_data", + "icu_provider", + "zerovec", +] + +[[package]] +name = "icu_plurals_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43ff7df2c1a1d41588a6ddcba354d513d4a3e10eec22c06fe56d13c12a848ea" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "input" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9793345a65d71317763a33066b5d8351f8760dde8d4930fe9e39b5f14a7959d" +dependencies = [ + "bitflags 2.13.2", + "input-sys", + "libc", + "log", + "udev", +] + +[[package]] +name = "input-sys" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7" + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.2", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "ksni" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bcc1cb43797140f74b711f1c4a49f48a7a7429f211c5fb78264e7bcd1e99caf" +dependencies = [ + "arc-swap", + "async-executor", + "async-io", + "async-lock", + "crossbeam-queue", + "futures-channel", + "futures-lite", + "futures-util", + "pastey", + "serde", + "task-local", + "zbus", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.5", +] + +[[package]] +name = "libseat" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6656c69b6e0366ffb83faa232f3e4fda5fc91161722d140f2c95ce2e90b1ef31" +dependencies = [ + "errno", + "libseat-sys", + "log", +] + +[[package]] +name = "libseat-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a484fa48be5f62a8d3ffed767779d0ab808cfead91de98ef4362d469097dfb" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "lyon_algorithms" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a6b9d4e8b48acd531fbf1241ad9b270df6e60b6b92bdea1469f4f40899d805" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_extra" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7755f08423275157ad1680aaecc9ccb7e0cc633da3240fea2d1522935cc15c72" +dependencies = [ + "lyon_path", + "thiserror 2.0.21", +] + +[[package]] +name = "lyon_geom" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d82dd26aa4dc0b5b70b9115de59824842bcb43cf4a28e1b1e6d0ad148c93f93e" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", +] + +[[package]] +name = "natord" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parlance" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" + +[[package]] +name = "parley" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22d2ff88bd3f7d68d1d9b09c7e6209f9a8e8c05088295140a2bcf2e9b17038c5" +dependencies = [ + "accesskit", + "fontique", + "harfrust", + "hashbrown 0.17.1", + "icu_normalizer", + "icu_properties", + "icu_segmenter", + "linebender_resource_handle", + "parlance", + "parley_data", + "skrifa", +] + +[[package]] +name = "parley_data" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1567535334d6ba2d3cde19221ba9a7bd0fabb3cbd99046ddfb10ae061cfcc889" +dependencies = [ + "icu_properties", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" + +[[package]] +name = "pin-weak" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b330c9d1b92dfe68442ca20b009c717d5f0b1e3cf4965e62f704c3c6e95a1305" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.3", + "pin-project-lite", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +dependencies = [ + "critical-section", +] + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pulldown-cmark" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb" +dependencies = [ + "bitflags 2.13.2", + "memchr", + "unicase", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots 1.0.11", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia 0.12.0", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "rig-agent" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "856f21ab95b8b887df997fb34356aeb1ad5677504461f009c58cd7d48274ef74" +dependencies = [ + "async-stream", + "futures", + "http", + "indexmap", + "rig-core", + "rig-derive", + "rig-memory", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.21", + "tracing", + "tracing-futures", +] + +[[package]] +name = "rig-core" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d07ae8e85415d83f620eb4f519d03126227178ac67971b08368c087ce8f9052" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bytes", + "fastrand", + "futures", + "glob", + "http", + "httpdate", + "indexmap", + "mime", + "mime_guess", + "ordered-float", + "rig-derive", + "rig-http", + "rig-reqwest", + "rig-tungstenite", + "schemars", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 2.0.21", + "tracing", + "url", + "uuid", + "web-time", +] + +[[package]] +name = "rig-derive" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840ef7200097f7e7b7f757f09a80b2ffb494438fca12ad6c84a15ddffc9647d3" +dependencies = [ + "convert_case 0.11.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "rig-http" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "817c761f9949151fd099ec78e2fbacf7174f24c066ced84c9d28807a6b9d4cee" +dependencies = [ + "bytes", + "futures", + "futures-timer", + "http", + "mime", + "thiserror 2.0.21", + "tokio", + "url", +] + +[[package]] +name = "rig-memory" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25fcf3f3a478d3ac1c0329aca68fdcb94c87cc32e6f2161463fae65270d58ec" +dependencies = [ + "base64 0.22.1", + "memchr", + "rig-core", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.21", +] + +[[package]] +name = "rig-reqwest" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9f13e51c8cbe597ab4ed79f4a1211dab83ac3d9ceaa3826d69b5c770274b9fa" +dependencies = [ + "bytes", + "futures", + "http", + "reqwest 0.13.5", + "rig-http", + "thiserror 2.0.21", + "tracing", + "url", + "webpki-root-certs", +] + +[[package]] +name = "rig-tungstenite" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba7906f696aee0da3d24825330bb5ff2cf2af86d8e308120f81d9cf581d1e30" +dependencies = [ + "futures", + "rig-http", + "thiserror 2.0.21", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8be15815186873ea063bf005011d16c356804973489e40c39116ae3a2878d1f" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "indexmap", + "libc", + "pastey", + "pin-project-lite", + "rand 0.10.3", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b0748e054a5f5598b3213cf72099ae6b2745ba4d996c59ccf455150a7dbb839" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.9", +] + +[[package]] +name = "rowan" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b574c58582fa59fa43a2feb6608b8744184659f08a2e0117e4b8224d95ed61" +dependencies = [ + "countme", + "hashbrown 0.14.5", + "memoffset", + "rustc-hash 1.1.0", + "text-size", +] + +[[package]] +name = "roxmltree" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" +dependencies = [ + "memchr", +] + +[[package]] +name = "rspolib" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fda9a7796aff63a7b1b39ccc93fffaaf65e20042984b4843041a49ca4677535" +dependencies = [ + "lazy_static", + "natord", + "snafu", + "unicode-linebreak", + "unicode-width", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.9", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scoped-tls-hkt" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9603871ffe5df3ac39cb624790c296dbd47a400d202f56bf3e414045099524d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia 0.11.4", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash 0.2.0", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skia-bindings" +version = "0.153.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb325787c91b3f6c34a7c6f2b8a2297b20292d733ca53a08c233e5028aec49af" +dependencies = [ + "bindgen", + "cc", + "flate2", + "heck", + "pkg-config", + "regex", + "serde_json", + "tar", + "toml", +] + +[[package]] +name = "skia-safe" +version = "0.153.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61cb643dac43067f3eb76c866635a74377ac0abca0ff2590674e1e295514c3d" +dependencies = [ + "bitflags 2.13.2", + "skia-bindings", + "windows", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slint" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e477d5ff6fec20909100ff8f7062432ebfbda7a62a0f37d0252aa126f38c83" +dependencies = [ + "const-field-offset", + "i-slint-backend-linuxkms", + "i-slint-backend-selector", + "i-slint-common", + "i-slint-core", + "i-slint-core-macros", + "i-slint-renderer-femtovg", + "i-slint-renderer-software", + "num-traits", + "once_cell", + "pin-weak", + "slint-macros", + "unicode-segmentation", + "vtable", +] + +[[package]] +name = "slint-build" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea28d4e1ad47d829b91e227f17037fac07a8001668a93fee47ac04f88718ebc" +dependencies = [ + "derive_more", + "fontique", + "i-slint-compiler", + "spin_on", + "toml_edit", +] + +[[package]] +name = "slint-macros" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e74b13d20884dc480ce37d0fabbf02cb4bc0df2a595434feac96ca7c2a0f15" +dependencies = [ + "i-slint-compiler", + "proc-macro2", + "quote", + "spin_on", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "smol_str" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb7109d23f319619d4cc0de5464613c8b7b1a09db2774a58101459cf6be2f10" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "fastrand", + "js-sys", + "memmap2", + "ndk", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.6", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "spin_on" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076e103ed41b9864aa838287efe5f4e3a7a0362dd00671ae62a212e5e4612da2" +dependencies = [ + "pin-utils", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "taffy" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "task-local" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2972044a9e5e448a506a7ff6f0d03b566d8ef4cd6918a58fc59835a0f8666626" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path 0.11.4", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path 0.12.0", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor", + "libloading", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "futures", + "futures-task", + "pin-project", + "tracing", +] + +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.21", +] + +[[package]] +name = "typed-index-collections" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" +dependencies = [ + "bincode", + "serde", +] + +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "udev" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unlucky-agent" +version = "0.1.0" +dependencies = [ + "axum", + "futures-util", + "rig-agent", + "rig-core", + "rmcp", + "serde", + "serde_json", + "tokio", + "unlucky-core", +] + +[[package]] +name = "unlucky-app" +version = "0.1.0" +dependencies = [ + "slint", + "slint-build", + "unlucky-agent", + "unlucky-core", +] + +[[package]] +name = "unlucky-cli" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tokio", + "unlucky-agent", + "unlucky-core", +] + +[[package]] +name = "unlucky-core" +version = "0.1.0" +dependencies = [ + "flate2", + "futures-util", + "md-5", + "regex", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_yaml_ng", + "sha1", + "sha2", + "tar", + "tokio", + "zip", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" +dependencies = [ + "base64 0.23.1", + "data-url", + "flate2", + "fontdb", + "harfrust", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "skrifa", + "strict-num", + "svgtypes", + "tiny-skia-path 0.12.0", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtable" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "515a4b5b1399cc46fb0dc4081436cb40a3852a2d3e94fd1fa50bb68a334896eb" +dependencies = [ + "const-field-offset", + "portable-atomic", + "stable_deref_trait", + "vtable-macro", +] + +[[package]] +name = "vtable-macro" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8191d73ececf73fc94e65809bf0e667525a0bcff9aeeb8d042a4173a508332a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.6", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.6", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.6", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c8ddfbbf66e501434657400268cefe51b95140a0266236415cda7627732df7" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.11", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "log", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "gpu-allocator", + "hashbrown 0.17.1", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str 0.2.2", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "959c7ac3b8afde864f7a7448ac16f29b2b83350b7eccbf77fb781c3999aa6848" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.6", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + +[[package]] +name = "write-fonts" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cae90d1d9f6d1d36ef116b0b1e651a6d7c12ba925439d4d06ff9adc94662a3d8" +dependencies = [ + "font-types", + "indexmap", + "kurbo", + "log", + "read-fonts", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.6", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" +dependencies = [ + "libc", + "rustix 1.1.6", +] + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a974f48060a14e95705c01f24ad9c3345022f4d97441b8a36beb7ed5c4a02d" +dependencies = [ + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.6", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zip" +version = "9.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3b434e4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,88 @@ +[workspace] +resolver = "2" +members = ["crates/core", "crates/agent", "crates/app", "crates/cli"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +authors = ["AskaEth "] +repository = "https://github.com/AskaEth/Unlucky-Launcher" +rust-version = "1.92" +publish = false + +[workspace.dependencies] +# 共享依赖(版本在此集中,各 crate 用 `xxx.workspace = true` 引用) +# M15(core::diagnose)首批使用者:规则正则 / 规则 YAML 解析 / 诊断结果序列化。 +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# serde_yaml 上游已停维护(RUSTSEC-2024-0320),改用维护中的 API 兼容分支(tower 定案) +serde_yaml_ng = "0.10" + +# M13(core::download):HTTP + 异步运行时 + 流 + 校验哈希。 +# rustls(ring)而非 native-tls —— 免 OpenSSL/系统证书栈依赖,MinGW 与 Win7 都省事。 +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } +tokio = { version = "1", features = [ + "rt-multi-thread", + "fs", + "io-util", + "time", + "sync", + "net", + "macros", +] } +futures-util = "0.3" +sha1 = "0.10" +sha2 = "0.10" +# M31(core::auth):离线账户 UUID 必须与 Minecraft 服务端/HMCL 的 +# `UUID.nameUUIDFromBytes` 逐字节一致 = MD5 + v3 位改写,因此需要 MD5。 +# 与 sha1/sha2 同属 RustCrypto(Apache-2.0 OR MIT),版本对齐 digest 0.10。 +md-5 = "0.10" + +# M29(core::launch::natives):读 natives jar(zip 容器)解压原生库。 +# 只开 inflate(`deflate-flate2-zlib-rs`,纯 Rust zlib)——解压方向不需要 zopfli 压缩器。 +zip = { version = "9", default-features = false, features = ["deflate-flate2-zlib-rs"] } + +# M33(core::game::java):Temurin 的 Linux/macOS 产物只发 `.tar.gz`(Windows 是 `.zip`, +# 复用上面的 zip)。flate2/tar 都是纯 Rust + MIT OR Apache-2.0,白名单内。 +flate2 = "1" +tar = "0.4" + +# UI 层唯一入口(D2:Slint,RF 2.0 轨道)。 +# 默认特性已含 backend-default + renderer-femtovg(GL)+ renderer-software(CPU 软渲染兜底, +# Win7 老机无可用 GPU 时的降级路径,见 DevDocs/PlanDoc/01c-native-gui-options.md §6)。 +slint = "1.18" +# app 的 build script 用(把 ui/main.slint 编译成 Rust 代码),版本必须与 slint 主 crate 同步。 +slint-build = "1.18" + +# agent 层依赖(rig / rmcp)按 D4 在 M16 引入,脚手架期不引入以免版本冲突。 +# 版本锁定策略见 DevDocs/PlanDoc/02-ai-agent-design.md §0:rig 锁 =0.45.x,rmcp 3.8.x。 +# +# M39 的 MCP server(02 §0「传输定论」):rmcp 官方 SDK(Apache-2.0),**锁精确版本**—— +# pre-1.0 且 ~6 天一个版、3.x 内已多次改 server API。只开需要的传输 feature: +# - `server`(default 带)+ `transport-io`(stdio,给 llama.cpp 内置 MCP client) +# - `transport-streamable-http-server`(主力:127.0.0.1 上的 Streamable HTTP) +# `macros` 一并留在 default 里(`#[tool]` 宏不用,但 default 拆开易漏 base64 等隐式依赖)。 +rmcp = { version = "=3.8.0", features = [ + "transport-io", + "transport-streamable-http-server", +] } +# rmcp 的 Streamable HTTP server 是个 tower Service,官方示例用 axum 挂监听(MIT)。 +# 只吃 `nest_service` + `serve`,不引入任何 TLS/模板/DB 生态。 +axum = "0.8" + +[workspace.lints.rust] +unsafe_code = "deny" +missing_docs = "warn" +rust_2018_idioms = { level = "warn", priority = -1 } + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } + +[profile.release] +opt-level = "z" +lto = "thin" +codegen-units = 1 +panic = "abort" +strip = "symbols" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..97bdc46 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 AskaEth (YeiJ) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..23e5380 --- /dev/null +++ b/NOTICE @@ -0,0 +1,30 @@ +Unlucky-Launcher (ULL) +Copyright 2026 AskaEth (YeiJ) + +本产品采用 Apache License 2.0(见 LICENSE)。 + +-------------------------------------------------------------------------- +第三方组件声明 +-------------------------------------------------------------------------- + +1. Slint(https://slint.dev) + + 本产品的 GUI 层使用 Slint。Slint 以 Royalty-Free 2.0 许可(LicenseRef-Slint-Royalty-free-2.0) + 使用,未修改 Slint 本体源码。 + + Slint Royalty-Free 2.0 的唯一义务:应用「关于」页显示官方 AboutSlint 部件, + 或在公开下载页放置「Made with Slint」徽章。本项目的落地位置见 crates/app 的关于页(P2 排期) + 与 GitHub Release 说明页;在此之前由本文件的声明承担告知义务。 + + Slint 的其余许可分支(GPL-3.0-only / Slint Software License 3.0)本项目不适用。 + +2. Rust crates 生态 + + cargo-deny 白名单见 deny.toml:Apache-2.0 / MIT / BSD-2-Clause / BSD-3-Clause / Zlib / BSL-1.0 / ISC / + CC0-1.0 / MIT-0 / Unlicense / Unicode-3.0 / CDLA-Permissive-2.0 / Apache-2.0 WITH LLVM-exception。 + GPL 系(含 AGPL、LGPL 静态链接、SSPL)依赖不得进入主干。 + +3. 参考实现致谢 + + 本项目设计参考自 HMCL(https://github.com/HMCL-dev/HMCL)与 PCL2 + (https://github.com/Meloong-Git/PCL),两者均为 GPL-3.0,特此致谢。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..937b23e --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# Unlucky Launcher (ULL) + +**English** · [简体中文](README.zh-CN.md) + +Unlucky Launcher (ULL) is a free, open-source Minecraft: Java Edition launcher written in Rust +(Apache-2.0, non-commercial), with a Slint + Metro UI. It embeds an AI agent client (chat / +configuration / crash diagnosis) and integrates with the broader agent tooling ecosystem via MCP. +Minimum baseline: Windows 10. + +- Runtime: a single native Rust binary — no JVM, no WebView, no external runtime +- UI: Slint (Royalty-Free 2.0; GL preferred, CPU software-rendering fallback so older machines + without a usable GPU still get the full UI) +- License: Apache-2.0 © AskaEth (YeiJ) + +## Compliance statement + +Authentication uses only official Microsoft/Mojang endpoints — Microsoft OAuth 2.0 device code +flow, Xbox Live, XSTS, and api.minecraftservices.com — so players sign in with their own Microsoft +account and launch the game they already own. Account tokens are stored only on the user's local +machine and are never sent to any server operated by the project. ULL does not distribute, mirror, +proxy or modify any Minecraft game files. Online play always requires a Microsoft account with +valid entitlements. Local offline profiles never contact Microsoft or Mojang services and cannot +join online-mode servers. Not affiliated with Mojang or Microsoft. + +## Status + +The core engine is complete (workspace: 983+ tests passing): + +- **Microsoft sign-in**: OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services, with + entitlement verification +- **Account credentials are stored only on the local machine** — never uploaded, and inaccessible + to the AI features +- Mod loaders: Forge / NeoForge / Fabric / Quilt, verified on real machines +- Java management: Temurin / Oracle / Microsoft / Zulu with a regional fallback chain +- Multi-source download engine; YAML rule-based crash diagnosis +- Usable as an MCP server for external agent tooling (`ull agent serve`) +- Local offline profiles: for local / isolated-network testing only; they perform no Microsoft + authentication and cannot join servers with online-mode enabled + +**CLI (`ull`) is fully usable**: `install / launch / accounts / java / diagnose / agent`. + +**GUI in development**: `crates/app` is currently a Slint + Metro skeleton; the theme system and +the full interface ship with subsequent versions — use the CLI until then. + +## Module map + +``` +Unlucky-Launcher (cargo workspace) +├── crates/core # domain library, zero UI dependencies, eight modules +├── crates/agent # AI agent: ToolSpec registry + built-in client + MCP server (one shared core) +├── crates/app # Slint UI: the only crate allowed to depend on a UI framework +├── crates/cli # CLI (bin `ull`): headless, zero UI, depends on core + agent +└── packaging # single-file artifact matrix and packaging assets (planned) +``` + +The eight modules of `crates/core`: + +| Module | Responsibility | +|--------|----------------| +| `setting` | three data-directory modes, schema-based config, atomic writes, migrations | +| `download` | multi-source health scoring, mirrors, chunked downloads, hash-while-downloading | +| `game` | version / instance repository, snapshots and inheritance resolution | +| `launch` | 10-stage launch pipeline, processes, JSONL event stream | +| `auth` | Microsoft sign-in / local offline profiles | +| `addon` | mod loader installation, modpacks, CurseForge / Modrinth | +| `diagnose` | five signal collectors, YAML rule engine, hs_err parsing | +| `platform` | OS abstraction: paths, processes, file locks, capability probing, runtime OS detection | + +## Building + +Prerequisites: + +- Rust stable (`rust-toolchain.toml` selects the toolchain automatically; components `rustfmt` / + `clippy`) +- Windows: MSVC toolchain (used by CI) or MinGW-w64 (`x86_64-pc-windows-gnu`) +- Linux: `libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev` + (Slint winit backend + femtovg build dependencies) + +```bash +cargo fmt --all --check # formatting +cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate) +cargo test --workspace # tests +cargo build --release # artifact: target/release/unlucky-launcher[.exe] +``` + +Software-rendering switch (machines without a usable GPU / with broken drivers): +`unlucky-launcher --software-renderer` (equivalent to `SLINT_BACKEND=winit-software`). + +Dependency license check (optional locally, required in CI): +`cargo deny check licenses bans sources`. + +## CI gates + +| Workflow | Content | +|----------|---------| +| `.github/workflows/ci.yml` | fmt + clippy + test + build, Windows / Linux matrix | +| `.github/workflows/guards.yml` | zero-UI guard for core, 400-line-per-file guard, cargo-deny license guard | + +## Platform baseline + +Minimum baseline: **Windows 10**. Windows 8.1 / 8 / 7 are not supported: + +- Windows 10/11 builds use the **standard toolchain** (MSVC / GNU) — no custom target specs, no + `-Zbuild-std`, no import-table allowlists +- Other platforms: macOS 12+ first class; Linux x64 with X11 preferred, Wayland as the long-term + direction; Android Termux experimental, not committed +- UI fallback: Slint software rendering (fully usable on machines without a GPU or with broken + drivers) +- Platform independence is expressed through **separate test matrices**, not separate artifact + pipelines; capability differences use feature gates with runtime probing + +## License and acknowledgements + +- This project: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in [NOTICE](NOTICE) +- The GUI uses [Slint](https://slint.dev) (Royalty-Free 2.0 track). Per the RF 2.0 obligations an + `AboutSlint` widget will be placed on the about page (P2); until then NOTICE and this section + serve the attribution duty. + +Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and +[PCL2](https://github.com/Meloong-Git/PCL) (both GPL-3.0) — these two launchers have accompanied +the vast majority of Minecraft players for many years. ULL learned a great deal about design and +implementation from them, and this project is dedicated to those two pioneers. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..0d97afd --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,97 @@ +# Unlucky Launcher (ULL) + +[English](README.md) · **简体中文** + +Rust 原生单文件 Minecraft 启动器:**Slint + Metro 视觉**,内置 **AI Agent 客户端**(对话 / 改配置 / 崩溃诊断), +融合主流 Agent 工具(**MCP**),**最低基线 Win10+**。 + +- 运行时:纯 Rust 原生二进制——无 JVM、无 WebView、无外部运行时 +- UI:Slint(Royalty-Free 2.0;GL 优先,CPU 软渲染兜底,无可用 GPU 的老机也有完整 UI) +- 许可:Apache-2.0 © AskaEth (YeiJ) + +## 当前状态 + +**核心引擎已完成**(workspace 983+ 测试全绿): + +- **正版登录**:Microsoft OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services,校验游戏拥有状态 +- **账户凭据仅保存在本机**,不上传任何服务器,AI 功能无法访问 +- 加载器:Forge / NeoForge / Fabric / Quilt 四家真机验证 +- Java 管理:Temurin / Oracle / Microsoft / Zulu 四发行版回退链 +- 多源下载引擎、崩溃诊断规则引擎 +- 可作为 MCP server 接入外部 Agent 工具(`ull agent serve`) +- 本地离线档案:仅用于本地/隔离网络环境的测试,不经过 Microsoft 账户认证,无法连接启用正版验证的服务器 + +**CLI(`ull`)完整可用**:`install / launch / accounts / java / diagnose / agent` 等全量命令。 + +**图形界面(GUI)开发中**:`crates/app` 当前为 Slint + Metro 风格骨架,主题系统与完整界面 +随后续版本交付——在此之前请使用 CLI。 + +## 模块地图 + +``` +Unlucky-Launcher (cargo workspace) +├── crates/core # 领域库:零 UI 依赖,八模块 +├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核) +├── crates/app # Slint UI:唯一允许依赖 UI 框架的 crate +├── crates/cli # CLI(bin `ull`):headless,零 UI,只依赖 core + agent +└── packaging # 单文件产物矩阵与打包资源(规划中) +``` + +`crates/core` 的八个模块: + +| 模块 | 职责 | +|------|------| +| `setting` | 三模式数据目录、schema 配置、原子写、迁移 | +| `download` | 多源健康度、镜像、分块下载、边下边算 | +| `game` | 版本/实例仓库、快照与继承解析 | +| `launch` | 10 阶段启动管线、进程、JSONL 事件流 | +| `auth` | Microsoft 正版登录 / 本地离线档案 | +| `addon` | 加载器安装、整合包、CurseForge / Modrinth | +| `diagnose` | 五类信号采集、YAML 规则引擎、hs_err 解析 | +| `platform` | OS 抽象:路径、进程、文件锁、能力探测、运行期 OS 检测 | + +## 构建 + +前置: + +- Rust stable(`rust-toolchain.toml` 会自动选择工具链;组件 `rustfmt` / `clippy`) +- Windows:MSVC 工具链(CI 用)或 MinGW-w64(`x86_64-pc-windows-gnu`) +- Linux:`libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev` + (Slint winit 后端 + femtovg 编译依赖) + +```bash +cargo fmt --all --check # 格式 +cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁) +cargo test --workspace # 测试 +cargo build --release # 产物:target/release/unlucky-launcher[.exe] +``` + +软渲染开关(无可用 GPU / 烂驱动的老机):`unlucky-launcher --software-renderer` +(等价于 `SLINT_BACKEND=winit-software`)。 + +依赖许可检查(本地可选,CI 必跑):`cargo deny check licenses bans sources`。 + +## CI 门禁 + +| Workflow | 内容 | +|----------|------| +| `.github/workflows/ci.yml` | fmt + clippy + test + build,Windows / Linux 矩阵 | +| `.github/workflows/guards.yml` | core 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可守门 | + +## 平台基线 + +最低基线 **Windows 10**。Windows 8.1 / 8 / 7 不在支持范围内: + +- Windows 10/11 走**标准工具链**产物(MSVC / GNU),无自定义 target spec、无 `-Zbuild-std`、无导入表白名单 +- 其他平台:macOS 12+ 一等;Linux x64 X11 优先、Wayland 为**大方向**;Android Termux 试验级不承诺 +- UI 兜底:Slint 软渲染(无 GPU / 烂驱动的老机可完整使用) +- 平台独立性体现在**独立测试矩阵**,而非独立产物链;能力差异用 feature gate + 运行期探测表达 + +## 许可证与致谢 + +- 本项目:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE) +- GUI 使用 [Slint](https://slint.dev)(Royalty-Free 2.0 轨道)。按 RF 2.0 义务,关于页将放置 + `AboutSlint` 部件(P2),在此之前由 NOTICE 与本节承担告知义务 + +感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)(均 GPL-3.0)这两个启动器陪伴了绝大多数 MC 玩家好多年。 +ULL 的设计与实现从它们身上学到了太多,谨以此项目向两位前辈致敬。 diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..aca5e9c --- /dev/null +++ b/clippy.toml @@ -0,0 +1,7 @@ +# 与 Cargo.toml 的 rust-version 同步;改 MSRV 时两处一起改。 +msrv = "1.92" + +# core 的模块边界很薄,参数多于 8 个基本说明该拆结构体了。 +too-many-arguments-threshold = 8 +# 领域类型(版本号、哈希、URL)普遍是短名,不做最小名长惩罚。 +min-ident-chars-threshold = 2 diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml new file mode 100644 index 0000000..6d75121 --- /dev/null +++ b/crates/agent/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "unlucky-agent" +description = "Unlucky-Launcher AI Agent:工具注册表 + 内置客户端 + MCP server(双形态共用一个内核)" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true + +# 依赖方向:agent 依赖 core 的公共 API,core 不知道 agent 的存在(AGENTS.md §2)。 +# 端口层(src/port*.rs)让 agent 能在没有 core 实现时独立编译与测试;装配层用 core 实现 +# 填充 `Ports`(GUI 侧是 `crates/app`,headless 侧是 `crates/cli` 的 `ull agent serve`)。 +[dependencies] +unlucky-core = { path = "../core", version = "0.1" } + +# 序列化:工具 JSON Schema 是手写 serde_json::Value(不引 schemars), +# 工具结果 / 审计记录 / 事件流都用 serde derive。 +serde.workspace = true +serde_json.workspace = true + +# LLM Provider + agent 循环。**锁精确版本**:rig 12 周 5 个 minor、CHANGELOG 满是 breaking +# (DevDocs/PlanDoc/02-ai-agent-design.md §0 + research/02 §7 R1)。升级必须单独提交并跑回放回归。 +# 依赖面收口:整个 crate 只有 src/brain/rig.rs 允许 `use rig_*`。 +# feature `reqwest`:rig-core 0.45 的 default 只有 derive + rustls,HTTP 客户端要显式开;TLS 走 rustls +# (免 OpenSSL,对 Win7/MinGW 友好,见 research/02 §7 R3)。 +rig-core = { version = "=0.45.0", features = ["reqwest"] } +# TODO(M16-P1): 目前**声明但未使用**——对话循环/工具编排是自研的(要在中间插确认与审计)。 +# 保留是为了把版本按 02 §0 钉死;P1 接 rig 的 agent 运行时(工具服务句柄 / rig-cassette 回放 / +# rig-rmcp 让内置 agent 吃 MCP)时再真正消费。若 P1 决定彻底不用,删这一行即可。 +rig-agent = "=0.45.0" + +# 异步运行时(rig 的 HTTP/流式需要)与流工具(消费 rig 的 Stream)。 +tokio.workspace = true +futures-util.workspace = true + +# MCP server(M39):rmcp 3.8 投影 + stdio / Streamable HTTP 传输。 +# 依赖面收口纪律与 rig 相同——整个 crate 只有 src/mcp/ 允许 `use rmcp::`。 +rmcp.workspace = true +axum.workspace = true + +[lints] +workspace = true diff --git a/crates/agent/src/audit.rs b/crates/agent/src/audit.rs new file mode 100644 index 0000000..de0f7ec --- /dev/null +++ b/crates/agent/src/audit.rs @@ -0,0 +1,283 @@ +//! 审计日志:**每一次工具调用**都留痕(02 文档 §3.1 末条 / mission M16 任务 4)。 +//! +//! 落盘位置由装配处给(`/agent/audit/audit-<日期>.jsonl`,按天分文件, +//! 见 `PlanDoc/04-data-and-config.md` §2)。一行一条 JSON,只追加不修改。 +//! +//! 三条纪律: +//! 1. **参数先脱敏再落盘**([`crate::redact::redact_args`])——`set_config` 能改 AI 端点, +//! 参数里会有 api_key; +//! 2. 写失败**不打断**工具执行(审计是旁路),但要把失败打到 stderr 留痕; +//! 3. YOLO 与「是否经确认」分别记录(`yolo=true, confirmed=false` 与 +//! `yolo=false, confirmed=true` 是两件事,事故复盘的差别就在这)。 + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::clock::{date_of_unix_ms, format_unix_ms, SharedClock}; +use crate::redact::redact_args; + +/// 一次工具调用的结局(审计用)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditOutcome { + /// 成功返回。 + Ok, + /// 工具返回错误(含端口失败、未找到会话等)。 + ToolError, + /// 参数没过 schema 校验(模型给错了参数)。 + InvalidArgs, + /// 用户/策略拒绝。 + Denied, + /// 只发出了确认请求,尚未执行(编排层在把请求交给闸门之前先记一条, + /// 这样「AI 想改 X 但被拒」和「用户点了允许」在审计里是两条独立的证据)。 + AwaitingConfirmation, + /// 模型调了不存在的工具。 + UnknownTool, +} + +/// 一条审计记录。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AuditRecord { + /// RFC3339 UTC。 + pub ts: String, + /// 会话 id(agent 对话框的会话,不是游戏会话)。 + pub agent_session_id: String, + /// 工具名。 + pub tool: String, + /// 参数(已脱敏)。 + pub args: Value, + /// 结局。 + pub outcome: AuditOutcome, + /// 一句话结果摘要。 + pub summary: String, + /// 是否运行在 YOLO 模式。 + pub yolo: bool, + /// 是否经过用户确认(YOLO 下为 false——自动放行不是"确认")。 + pub confirmed: bool, + /// 工具执行耗时(毫秒)。 + pub duration_ms: u64, +} + +/// 审计出口。 +pub trait AuditSink: Send + Sync { + /// 记录一条(实现方不应阻塞工具执行链;失败自行吞掉并留痕)。 + fn record(&self, record: &AuditRecord); +} + +/// 按天滚动的 JSONL 审计(生产实现)。 +pub struct JsonlAudit { + dir: PathBuf, + clock: SharedClock, + guard: Mutex<()>, +} + +impl JsonlAudit { + /// `dir` 通常是 `/agent/audit`;目录按需创建。 + #[must_use] + pub fn new(dir: impl Into, clock: SharedClock) -> Self { + Self { + dir: dir.into(), + clock, + guard: Mutex::new(()), + } + } + + /// 给定时刻对应的文件路径(测试与 UI「查看审计」共用)。 + #[must_use] + pub fn path_for(&self, unix_ms: u64) -> PathBuf { + self.dir + .join(format!("audit-{}.jsonl", date_of_unix_ms(unix_ms))) + } + + /// 追加一行;失败返回 `std::io::Error`(由 [`AuditSink::record`] 吞掉并打日志)。 + fn append(&self, record: &AuditRecord) -> std::io::Result<()> { + let path = self.path_for(self.clock.now_unix_ms()); + let line = serde_json::to_string(record) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let _guard = self.guard.lock(); + let mut file = OpenOptions::new().create(true).append(true).open(&path)?; + file.write_all(line.as_bytes())?; + file.write_all(b"\n") + } +} + +impl AuditSink for JsonlAudit { + fn record(&self, record: &AuditRecord) { + if let Err(err) = self.append(record) { + // 审计是旁路:写不进去也不能让工具调用失败,但要留下痕迹。 + eprintln!("[unlucky-agent] 审计写入失败: {err}"); + } + } +} + +/// 内存审计(测试 + 「P0 不落盘」的降级路径)。 +#[derive(Debug, Default)] +pub struct MemoryAudit { + records: Mutex>, +} + +impl MemoryAudit { + /// 空审计。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 已记录条数。 + #[must_use] + pub fn len(&self) -> usize { + self.records.lock().map(|r| r.len()).unwrap_or(0) + } + + /// 是否一条都没有。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// 取快照。 + #[must_use] + pub fn records(&self) -> Vec { + self.records.lock().map(|r| r.clone()).unwrap_or_default() + } + + /// 按工具名筛出记录(测试断言用)。 + #[must_use] + pub fn records_for(&self, tool: &str) -> Vec { + self.records() + .into_iter() + .filter(|r| r.tool == tool) + .collect() + } +} + +impl AuditSink for MemoryAudit { + fn record(&self, record: &AuditRecord) { + if let Ok(mut records) = self.records.lock() { + records.push(record.clone()); + } + } +} + +/// 丢弃审计(不推荐,只给"连内存都不想留"的极端场景/基准测试)。 +#[derive(Debug, Default, Clone, Copy)] +pub struct NullAudit; + +impl AuditSink for NullAudit { + fn record(&self, _record: &AuditRecord) {} +} + +/// 组装一条记录时的"其余字段"(时间戳从时钟取,参数先脱敏)。 +#[derive(Debug, Clone, Copy)] +pub struct AuditDraft<'a> { + /// 工具名。 + pub tool: &'a str, + /// 原始参数(内部会脱敏)。 + pub args: &'a Value, + /// 结局。 + pub outcome: AuditOutcome, + /// 一句话结果摘要。 + pub summary: &'a str, + /// 是否 YOLO。 + pub yolo: bool, + /// 是否经用户确认。 + pub confirmed: bool, + /// 耗时(毫秒)。 + pub duration_ms: u64, +} + +/// 组装一条审计记录(时间戳从时钟取、参数脱敏,调用方不用自己拼)。 +#[must_use] +pub fn make_record( + clock: &dyn crate::clock::Clock, + agent_session_id: &str, + draft: AuditDraft<'_>, +) -> AuditRecord { + AuditRecord { + ts: format_unix_ms(clock.now_unix_ms()), + agent_session_id: agent_session_id.to_string(), + tool: draft.tool.to_string(), + args: redact_args(draft.args), + outcome: draft.outcome, + summary: draft.summary.to_string(), + yolo: draft.yolo, + confirmed: draft.confirmed, + duration_ms: draft.duration_ms, + } +} + +/// 便捷:给出数据根下的审计目录(`/agent/audit`)。 +#[must_use] +pub fn audit_dir(data_root: &Path) -> PathBuf { + data_root.join("agent").join("audit") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clock::FixedClock; + use serde_json::json; + use std::sync::Arc; + + #[test] + fn record_redacts_args_before_store() { + let audit = MemoryAudit::new(); + let clock = FixedClock::new(1_793_304_753_123); + let args = json!({ "path": "ai.api_key", "value": "sk-x" }); + let rec = make_record( + &clock, + "s1", + AuditDraft { + tool: "set_config", + args: &args, + outcome: AuditOutcome::Ok, + summary: "ok", + yolo: false, + confirmed: true, + duration_ms: 3, + }, + ); + audit.record(&rec); + let stored = &audit.records()[0]; + assert_eq!(stored.args["value"], json!("***"), "敏感路径的值必须掩码"); + assert_eq!(stored.ts, "2026-10-29T20:12:33.123Z"); + } + + #[test] + fn jsonl_audit_writes_one_line_per_record_per_day() { + let dir = std::env::temp_dir().join(format!("unlucky-agent-audit-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123)); + let audit = JsonlAudit::new(&dir, Arc::clone(&clock)); + let args = json!({}); + let rec = make_record( + clock.as_ref(), + "s1", + AuditDraft { + tool: "get_config", + args: &args, + outcome: AuditOutcome::Ok, + summary: "ok", + yolo: false, + confirmed: false, + duration_ms: 1, + }, + ); + audit.record(&rec); + audit.record(&rec); + let path = dir.join("audit-2026-10-29.jsonl"); + let content = std::fs::read_to_string(&path).expect("审计文件应存在"); + assert_eq!(content.lines().count(), 2); + let first: AuditRecord = serde_json::from_str(content.lines().next().unwrap()).unwrap(); + assert_eq!(first.tool, "get_config"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/agent/src/brain/chat.rs b/crates/agent/src/brain/chat.rs new file mode 100644 index 0000000..d3f0cf5 --- /dev/null +++ b/crates/agent/src/brain/chat.rs @@ -0,0 +1,267 @@ +//! 对话协议类型 + [`ChatProvider`] trait(四态端点配置在 [`super::provider`])。 +//! +//! 这里**没有协议方言**:OpenAI/Ollama/Anthropic 的报文差异全部由实现方 +//! (阶段 B 的 [`super::rig`])吃掉。本模块只定义循环需要的三样东西: +//! 消息、请求/回复、流式增量回调。 +//! +//! 一次工具调用的完整往返: +//! `Assistant { tool_calls }` → 编排层执行 → `Tool { call_id, name, content }` → 再次补全。 + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::provider::{ProviderConfig, ProviderError}; +use crate::event::{AgentEvent, AgentEventSink}; +use crate::registry::ToolDefinition; + +/// 一条工具调用请求(模型产出)。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolCall { + /// 调用 id(OpenAI 方言的 `tool_call_id`;Ollama 原生没有 id,由适配层生成)。 + pub id: String, + /// 工具名。 + pub name: String, + /// 参数(JSON 对象)。 + pub arguments: Value, +} + +/// 对话消息。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "role", rename_all = "snake_case")] +pub enum ChatMessage { + /// 系统提示词(每轮由 [`super::BrainConfig::system_prompt`] 重新注入,不进历史)。 + System { + /// 提示词正文。 + content: String, + }, + /// 用户输入。 + User { + /// 正文。 + content: String, + }, + /// 模型回复(可能带工具调用)。 + Assistant { + /// 文本正文(工具调用轮可能为空)。 + text: String, + /// 工具调用。 + tool_calls: Vec, + }, + /// 工具结果回灌。 + Tool { + /// 对应调用 id。 + call_id: String, + /// 工具名(Ollama 原生靠 `tool_name` 配对,不是 id)。 + name: String, + /// 结果 JSON 字符串。 + content: String, + }, +} + +impl ChatMessage { + /// 便捷:系统消息。 + #[must_use] + pub fn system(content: impl Into) -> Self { + Self::System { + content: content.into(), + } + } + + /// 便捷:用户消息。 + #[must_use] + pub fn user(content: impl Into) -> Self { + Self::User { + content: content.into(), + } + } + + /// 便捷:工具结果消息。 + #[must_use] + pub fn tool( + call_id: impl Into, + name: impl Into, + content: impl Into, + ) -> Self { + Self::Tool { + call_id: call_id.into(), + name: name.into(), + content: content.into(), + } + } +} + +/// 一次补全请求。 +#[derive(Debug, Clone, PartialEq)] +pub struct ChatRequest { + /// 模型名。 + pub model: String, + /// 消息(system 在首位)。 + pub messages: Vec, + /// 工具定义(双投影的 rig 侧载荷)。 + pub tools: Vec, + /// 采样温度。 + pub temperature: Option, + /// token 上限。 + pub max_tokens: Option, +} + +/// token 用量(本地模型可能不报)。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Usage { + /// 输入 token。 + pub prompt_tokens: Option, + /// 输出 token。 + pub completion_tokens: Option, +} + +/// 一次补全结果。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ChatReply { + /// 文本正文。 + pub text: String, + /// 工具调用(非空 ⇒ 循环继续)。 + pub tool_calls: Vec, + /// 用量。 + pub usage: Option, +} + +impl ChatReply { + /// 纯文本回复。 + #[must_use] + pub fn text(text: impl Into) -> Self { + Self { + text: text.into(), + tool_calls: Vec::new(), + usage: None, + } + } + + /// 带工具调用的回复。 + #[must_use] + pub fn with_tool_calls(tool_calls: Vec) -> Self { + Self { + text: String::new(), + tool_calls, + usage: None, + } + } +} + +/// 流式增量接收者。实现方必须**非阻塞**。 +pub trait DeltaObserver: Send + Sync { + /// 收到一段文本增量。 + fn on_delta(&self, text: &str); +} + +/// 丢弃增量(不关心流式的调用方)。 +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopObserver; + +impl DeltaObserver for NoopObserver { + fn on_delta(&self, _text: &str) {} +} + +/// 把增量转成 [`AgentEvent::TextDelta`](P1 聊天面板逐字渲染就靠它)。 +#[derive(Clone)] +pub struct EventDeltaObserver { + sink: Arc, +} + +impl EventDeltaObserver { + /// 用事件流构造。 + #[must_use] + pub fn new(sink: Arc) -> Self { + Self { sink } + } +} + +impl DeltaObserver for EventDeltaObserver { + fn on_delta(&self, text: &str) { + self.sink.emit(AgentEvent::TextDelta { + text: text.to_string(), + }); + } +} + +/// 收集增量(测试断言流式拼接)。 +#[derive(Debug, Default)] +pub struct CollectingObserver { + chunks: std::sync::Mutex>, +} + +impl CollectingObserver { + /// 收完的内容。 + #[must_use] + pub fn collected(&self) -> String { + self.chunks.lock().map(|c| c.concat()).unwrap_or_default() + } +} + +impl DeltaObserver for CollectingObserver { + fn on_delta(&self, text: &str) { + if let Ok(mut chunks) = self.chunks.lock() { + chunks.push(text.to_string()); + } + } +} + +/// 补全未来的箱子类型。 +pub type ProviderFuture<'a> = + Pin> + Send + 'a>>; + +/// LLM 端点抽象:**这是 agent 唯一的异步边界**。 +/// +/// P0 实现 = 测试里的 mock;阶段 B 实现 = [`super::rig`] 的 rig 适配器。 +/// 换实现(genai / 自写循环)只需要满足本 trait,[`super::Brain`] 不动。 +pub trait ChatProvider: Send + Sync { + /// 当前配置(循环用它取 model/temperature,也用它判断是否启用)。 + fn config(&self) -> &ProviderConfig; + /// 一次补全。流式增量通过 `observer` 回吐。 + fn complete<'a>( + &'a self, + request: &'a ChatRequest, + observer: &'a dyn DeltaObserver, + ) -> ProviderFuture<'a>; +} + +/// 装配期用的 provider 工厂:按配置造实现(阶段 B 在这里接 rig,测试接 mock)。 +pub type ProviderFactory = + Arc Result, ProviderError> + Send + Sync>; + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::MemoryEventSink; + + #[test] + fn collecting_observer_concatenates_chunks() { + let observer = CollectingObserver::default(); + observer.on_delta("正在"); + observer.on_delta("读取日志"); + assert_eq!(observer.collected(), "正在读取日志"); + } + + #[test] + fn event_observer_emits_text_delta() { + let sink = Arc::new(MemoryEventSink::new()); + let observer = EventDeltaObserver::new(sink.clone()); + observer.on_delta("嗨"); + assert_eq!(sink.streamed_text(), "嗨"); + } + + #[test] + fn message_helpers_round_trip_through_json() { + let messages = vec![ + ChatMessage::system("你是助手"), + ChatMessage::user("看看日志"), + ChatMessage::tool("call_1", "read_game_log", "{}"), + ]; + let json = serde_json::to_string(&messages).expect("可序列化"); + let back: Vec = serde_json::from_str(&json).expect("可反序列化"); + assert_eq!(back, messages); + assert!(json.contains("\"role\":\"tool\"")); + } +} diff --git a/crates/agent/src/brain/exec.rs b/crates/agent/src/brain/exec.rs new file mode 100644 index 0000000..2efbc59 --- /dev/null +++ b/crates/agent/src/brain/exec.rs @@ -0,0 +1,347 @@ +//! 工具执行编排:schema 校验 → 分级确认 → 执行 → 审计 → 事件。 +//! +//! **YOLO 只在这里**(mission M16 任务 3 的分工):工具自己不判断 policy, +//! 只声明「这次需要确认 + diff」;本模块按 [`ConfirmPolicy`] 决定问不问。 +//! +//! 一次工具调用最多两条审计记录: +//! 1. 需要确认时先记一条 `awaiting_confirmation`(「AI 想改 X」本身必须留痕); +//! 2. 结局记一条 `ok` / `denied` / `tool_error`(带 `yolo` 与 `confirmed` 标记)。 +//! +//! 这两条区分了三种在复盘里完全不同的事:用户点了拒绝、YOLO 自动放行、用户点了允许。 + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::audit::{make_record, AuditDraft, AuditOutcome, AuditSink}; +use crate::brain::chat::ToolCall; +use crate::clock::SharedClock; +use crate::confirm::{ConfirmDecision, ConfirmGate, ConfirmPolicy, ConfirmRequest, DenyGuidance}; +use crate::event::{AgentEvent, AgentEventSink}; +use crate::registry::validate::validate; +use crate::registry::{ToolCtx, ToolError, ToolRegistry}; + +/// 一次工具执行的结局(回灌给模型的内容 + 成败标记)。 +#[derive(Debug, Clone, PartialEq)] +pub struct ToolExecOutcome { + /// 回灌内容(结构化)。 + pub content: Value, + /// 是否成功(UI/事件用;模型看的是 `content`)。 + pub ok: bool, +} + +impl ToolExecOutcome { + /// 成功。 + #[must_use] + pub fn ok(content: Value) -> Self { + Self { content, ok: true } + } + + /// 失败(`code` 给模型看,帮助它改正后重试)。 + #[must_use] + pub fn error(code: &str, message: impl Into) -> Self { + Self { + content: json!({ "status": "error", "code": code, "message": message.into() }), + ok: false, + } + } + + /// 序列化成回灌给模型的字符串(工具消息的 `content`)。 + #[must_use] + pub fn to_model_content(&self) -> String { + serde_json::to_string(&self.content).unwrap_or_else(|_| { + json!({ "status": "error", "code": "internal", "message": "结果序列化失败" }) + .to_string() + }) + } +} + +/// 工具执行器(无状态,可跨轮复用)。 +pub struct ToolExecutor { + registry: Arc, + audit: Arc, + gate: Arc, + policy: ConfirmPolicy, + clock: SharedClock, + sink: Arc, + guidance: DenyGuidance, + confirm_seq: AtomicU64, +} + +impl ToolExecutor { + /// 装配(默认拒绝口径 = 内置对话:[`DenyGuidance::InApp`])。 + pub fn new( + registry: Arc, + audit: Arc, + gate: Arc, + policy: ConfirmPolicy, + clock: SharedClock, + sink: Arc, + ) -> Self { + Self { + registry, + audit, + gate, + policy, + clock, + sink, + guidance: DenyGuidance::InApp, + confirm_seq: AtomicU64::new(0), + } + } + + /// 换拒绝口径(外部 MCP 客户端用 [`DenyGuidance::External`]:没有弹窗可点)。 + /// + /// 只改**文案**:策略、闸门、审计标记一个字都不变——这正是「同一注册表、 + /// 同一安全策略、同一审计」在代码上的落点。 + #[must_use] + pub fn with_guidance(mut self, guidance: DenyGuidance) -> Self { + self.guidance = guidance; + self + } + + /// 执行一次工具调用(含确认与审计)。 + pub async fn execute( + &self, + agent_session_id: &str, + call: &ToolCall, + step: usize, + ) -> ToolExecOutcome { + let started_at = self.clock.now_unix_ms(); + self.sink.emit(AgentEvent::ToolCallStarted { + tool: call.name.clone(), + // 与审计同一套脱敏:事件(P1 会持久化)不能带出凭据明文。 + args: crate::redact::redact_args(&call.arguments), + step, + }); + + let Some(spec) = self.registry.get(&call.name).cloned() else { + let message = format!( + "没有名为 `{}` 的工具。可用工具:{}", + call.name, + self.registry.names().join("、") + ); + self.record( + agent_session_id, + &call.name, + &call.arguments, + AuditOutcome::UnknownTool, + &message, + false, + false, + started_at, + ); + self.finish_event(&call.name, false, &message, false); + return ToolExecOutcome::error("unknown_tool", message); + }; + + if let Err(errors) = validate(&spec.input_schema, &call.arguments) { + let message = format!("参数不合法:{}", errors.join(";")); + self.record( + agent_session_id, + &call.name, + &call.arguments, + AuditOutcome::InvalidArgs, + &message, + false, + false, + started_at, + ); + self.finish_event(&call.name, false, &message, false); + return ToolExecOutcome::error("invalid_args", message); + } + + let ctx = ToolCtx { + agent_session_id: agent_session_id.to_string(), + confirmed: false, + }; + let quoted = match (spec.handler)(&ctx, &call.arguments) { + Ok(output) => output, + Err(err) => return self.fail(agent_session_id, call, err, started_at), + }; + + let Some(confirmation) = quoted.confirmation.clone() else { + return self.succeed(agent_session_id, call, quoted, false, false, started_at); + }; + + // ── 需要确认:决定问不问(YOLO 只影响这里)── + let asked = self.policy.requires_confirmation(confirmation.sensitivity); + let yolo_auto = !asked && confirmation.sensitivity == crate::confirm::Sensitivity::High; + + if asked { + self.record( + agent_session_id, + &call.name, + &call.arguments, + AuditOutcome::AwaitingConfirmation, + &confirmation.summary, + false, + false, + started_at, + ); + let request = ConfirmRequest { + id: format!( + "confirm-{}", + self.confirm_seq.fetch_add(1, Ordering::Relaxed) + 1 + ), + tool: call.name.clone(), + summary: confirmation.summary.clone(), + details: confirmation.details.clone(), + sensitivity: confirmation.sensitivity, + }; + self.sink.emit(AgentEvent::ConfirmRequested { + request: Box::new(request.clone()), + }); + if self.gate.request(request).await == ConfirmDecision::Denied { + let message = self.guidance.message().to_string(); + self.record( + agent_session_id, + &call.name, + &call.arguments, + AuditOutcome::Denied, + &message, + false, + false, + started_at, + ); + self.sink.emit(AgentEvent::ToolDenied { + tool: call.name.clone(), + reason: message.clone(), + }); + self.finish_event(&call.name, false, &message, true); + return ToolExecOutcome::error("denied", message); + } + } + + // ── 已获授权(用户允许或 YOLO):带 confirmed 重放 ── + let confirmed_ctx = ToolCtx { + agent_session_id: agent_session_id.to_string(), + confirmed: true, + }; + match (spec.handler)(&confirmed_ctx, &call.arguments) { + Ok(output) => { + if let Some(second) = output.confirmation { + // 工具在"已确认"状态下仍要确认 = 实现写错了,明确报出来而不是死循环。 + let message = format!( + "工具 `{}` 在已确认状态下仍请求确认:{}", + call.name, second.summary + ); + self.record( + agent_session_id, + &call.name, + &call.arguments, + AuditOutcome::ToolError, + &message, + yolo_auto, + asked, + started_at, + ); + self.finish_event(&call.name, false, &message, true); + return ToolExecOutcome::error("internal", message); + } + self.succeed(agent_session_id, call, output, asked, yolo_auto, started_at) + } + Err(err) => { + self.fail_after_confirm(agent_session_id, call, err, asked, yolo_auto, started_at) + } + } + } + + fn succeed( + &self, + agent_session_id: &str, + call: &ToolCall, + output: crate::registry::ToolOutput, + confirmed: bool, + yolo: bool, + started_at: u64, + ) -> ToolExecOutcome { + self.record( + agent_session_id, + &call.name, + &call.arguments, + AuditOutcome::Ok, + &output.summary, + yolo, + confirmed, + started_at, + ); + self.finish_event( + &call.name, + true, + &output.summary, + output.confirmation.is_some(), + ); + ToolExecOutcome::ok(output.content) + } + + fn fail( + &self, + agent_session_id: &str, + call: &ToolCall, + err: ToolError, + started_at: u64, + ) -> ToolExecOutcome { + self.fail_after_confirm(agent_session_id, call, err, false, false, started_at) + } + + fn fail_after_confirm( + &self, + agent_session_id: &str, + call: &ToolCall, + err: ToolError, + confirmed: bool, + yolo: bool, + started_at: u64, + ) -> ToolExecOutcome { + let message = err.message().to_string(); + self.record( + agent_session_id, + &call.name, + &call.arguments, + AuditOutcome::ToolError, + &message, + yolo, + confirmed, + started_at, + ); + self.finish_event(&call.name, false, &message, false); + ToolExecOutcome::error(err.code(), message) + } + + #[allow(clippy::too_many_arguments)] + fn record( + &self, + agent_session_id: &str, + tool: &str, + args: &Value, + outcome: AuditOutcome, + summary: &str, + yolo: bool, + confirmed: bool, + started_at: u64, + ) { + let duration_ms = self.clock.now_unix_ms().saturating_sub(started_at); + let draft = AuditDraft { + tool, + args, + outcome, + summary, + yolo, + confirmed, + duration_ms, + }; + self.audit + .record(&make_record(self.clock.as_ref(), agent_session_id, draft)); + } + + fn finish_event(&self, tool: &str, ok: bool, summary: &str, requires_confirmation: bool) { + self.sink.emit(AgentEvent::ToolCallFinished { + tool: tool.to_string(), + ok, + summary: summary.to_string(), + requires_confirmation, + }); + } +} diff --git a/crates/agent/src/brain/mod.rs b/crates/agent/src/brain/mod.rs new file mode 100644 index 0000000..152fb18 --- /dev/null +++ b/crates/agent/src/brain/mod.rs @@ -0,0 +1,323 @@ +//! Agent 大脑:Provider 抽象 + 对话循环 + 流式事件。 +//! +//! 选型(M10 定稿):`rig-core` + `rig-agent`,**版本锁 `=0.45.x`**——上游 12 周 5 个 minor +//! 的 breaking 频率决定了必须锁死;依赖面只在本文件收口,其它模块不许直接碰 rig, +//! 以便随时换 genai / 自写循环逃生(`DevDocs/PlanDoc/02-ai-agent-design.md` §0)。 +//! +//! Provider 形态(02 §2):OpenAI 兼容(默认,含本地 llama.cpp `127.0.0.1:PORT/v1`)、 +//! Anthropic 原生、Ollama 原生、以及「禁用」——禁用时规则引擎仍可用。 +//! +//! 统一 trait(M16 落地):`stream_chat(messages, tools) -> Stream`,含 tool_call 事件。 +//! 本 crate 的落地形态是 [`chat::ChatProvider::complete`](`request + observer → ChatReply`): +//! rig 0.45 的流在 [`rig`] 适配层里展开成增量回调,循环侧只消费「文本增量 + 工具调用」两件事。 +//! **rig 依赖面收口在 [`rig`] 一个文件**,本文件不 `use rig_*`。 +//! +//! ## 对话循环(02 文档 §3.3) +//! +//! ```text +//! run_turn(user_input) +//! ├─ provider.complete(request) ← 流式增量经 EventDeltaObserver → UI +//! ├─ 有 tool_calls → ToolExecutor.execute(校验/确认/YOLO/审计)→ Tool 消息回灌 +//! └─ 没有 tool_calls → 结束,返回最终文本 +//! ``` +//! +//! 三条不变量: +//! 1. **system prompt 每轮重新注入**,不进历史(改提示词立刻生效,也不会被压缩逻辑吃掉); +//! 2. 工具结果**永远回灌**(包括失败),模型才有机会改正; +//! 3. 步数上限 [`DEFAULT_MAX_STEPS`] 兜底,防止模型在两个工具间打转烧 token。 + +pub mod chat; +pub mod exec; +pub mod provider; +pub mod rig; + +use std::fmt; +use std::sync::Arc; + +use crate::audit::AuditSink; +use crate::clock::SharedClock; +use crate::confirm::{ConfirmGate, ConfirmPolicy}; +use crate::event::{AgentEvent, AgentEventSink}; +use crate::registry::ToolRegistry; + +pub use chat::{ + ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, EventDeltaObserver, + NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage, +}; +pub use exec::{ToolExecOutcome, ToolExecutor}; +pub use provider::{ProviderConfig, ProviderError, ProviderKind}; + +/// 一轮对话最多几轮工具调用。 +pub const DEFAULT_MAX_STEPS: usize = 8; + +/// 内置系统提示词(P0 版本;P2 会按实例上下文按需注入)。 +#[must_use] +pub fn default_system_prompt() -> String { + [ + "你是 Unlucky-Launcher(ULL,Minecraft 启动器)的内置助手。", + "你能调用工具:读配置(get_config)、改配置(set_config)、列出游戏会话(list_game_sessions)、", + "读会话日志(read_game_log)、跑崩溃规则引擎(diagnose_crash)。", + "纪律:", + "1. 先查后改:改任何配置前先用 get_config 确认键名与现值。", + "2. 崩溃问题先看 diagnose_crash 的规则结论;规则未命中或需要更多信息时,才用 read_game_log", + " 取 stderr / exit / crash_found,不要拉全量日志。", + "3. 只能改 get_config 列出的可写键。敏感键会要求用户确认:被拒绝就换方案,不要重复尝试同一改动。", + "4. 用中文回答,给用户可以立即执行的下一步;不确定时直说不确定,不要编造规则或日志内容。", + ] + .join("\n") +} + +/// 循环参数。 +#[derive(Debug, Clone)] +pub struct BrainConfig { + /// 最多几轮工具调用。 + pub max_steps: usize, + /// 系统提示词。 + pub system_prompt: String, +} + +impl Default for BrainConfig { + fn default() -> Self { + Self { + max_steps: DEFAULT_MAX_STEPS, + system_prompt: default_system_prompt(), + } + } +} + +impl BrainConfig { + /// 自定义提示词(步数用默认)。 + #[must_use] + pub fn with_system_prompt(system_prompt: impl Into) -> Self { + Self { + max_steps: DEFAULT_MAX_STEPS, + system_prompt: system_prompt.into(), + } + } +} + +/// 循环的旁路依赖(审计/确认/时钟/事件)。 +pub struct BrainDeps { + /// 审计出口。 + pub audit: Arc, + /// 确认闸门(P0 = [`crate::confirm::NoUiGate`])。 + pub gate: Arc, + /// YOLO 等策略快照。 + pub policy: ConfirmPolicy, + /// 时间源。 + pub clock: SharedClock, + /// 事件出口。 + pub sink: Arc, +} + +impl BrainDeps { + /// 装配。 + pub fn new( + audit: Arc, + gate: Arc, + policy: ConfirmPolicy, + clock: SharedClock, + sink: Arc, + ) -> Self { + Self { + audit, + gate, + policy, + clock, + sink, + } + } +} + +/// 一轮对话的结局。 +#[derive(Debug, Clone, PartialEq)] +pub struct TurnOutcome { + /// 最终文本(可能是空的,例如撞上步数上限且模型最后一句只有工具调用)。 + pub text: String, + /// 用掉的轮次。 + pub steps: usize, + /// 工具调用总次数。 + pub tool_calls: usize, + /// 为什么停下来。 + pub stopped_reason: StopReason, +} + +/// 停止原因。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StopReason { + /// 模型给出了最终回答。 + Completed, + /// 撞上 [`BrainConfig::max_steps`]。 + StepLimit, +} + +/// 循环级错误。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BrainError { + /// AI 被禁用(规则引擎仍可用)。 + Disabled, + /// provider 报错。 + Provider(ProviderError), +} + +impl fmt::Display for BrainError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Disabled => f.write_str(DISABLED_MESSAGE), + Self::Provider(err) => write!(f, "LLM 调用失败:{err}"), + } + } +} + +impl std::error::Error for BrainError {} + +/// AI 被禁用时给用户的话(装配层直接展示,不用先构造 Brain)。 +pub const DISABLED_MESSAGE: &str = + "AI 已禁用:请在设置 → AI 中配置端点。规则引擎崩溃诊断不受影响。"; + +/// 对话循环。 +pub struct Brain { + provider: Arc, + registry: Arc, + executor: ToolExecutor, + config: BrainConfig, + sink: Arc, +} + +impl Brain { + /// 装配。 + pub fn new( + provider: Arc, + registry: Arc, + deps: BrainDeps, + config: BrainConfig, + ) -> Self { + let executor = ToolExecutor::new( + Arc::clone(®istry), + Arc::clone(&deps.audit), + Arc::clone(&deps.gate), + deps.policy, + Arc::clone(&deps.clock), + Arc::clone(&deps.sink), + ); + Self { + provider, + registry, + executor, + config, + sink: deps.sink, + } + } + + /// 当前 provider 配置(设置页/UI 展示用)。 + #[must_use] + pub fn provider_config(&self) -> &ProviderConfig { + self.provider.config() + } + + /// 跑一轮对话。`history` 由调用方持有(P1 落盘为 `agent/sessions/*.jsonl`)。 + /// + /// # Errors + /// AI 被禁用([`BrainError::Disabled`])或 provider 报错([`BrainError::Provider`])。 + pub async fn run_turn( + &self, + agent_session_id: &str, + history: &mut Vec, + user_input: &str, + ) -> Result { + if !self.provider.config().is_enabled() { + self.sink.emit(AgentEvent::Error { + message: DISABLED_MESSAGE.to_string(), + }); + return Err(BrainError::Disabled); + } + + self.sink.emit(AgentEvent::TurnStarted { + session_id: agent_session_id.to_string(), + user_text: user_input.to_string(), + }); + history.push(ChatMessage::user(user_input)); + + let observer = EventDeltaObserver::new(Arc::clone(&self.sink)); + let mut tool_calls = 0usize; + let mut last_text = String::new(); + let mut used_steps = 0usize; + + for step in 1..=self.config.max_steps { + used_steps = step; + let request = self.build_request(history); + let reply = match self.provider.complete(&request, &observer).await { + Ok(reply) => reply, + Err(err) => { + self.sink.emit(AgentEvent::Error { + message: err.to_string(), + }); + return Err(BrainError::Provider(err)); + } + }; + + if !reply.text.trim().is_empty() { + last_text = reply.text.clone(); + self.sink.emit(AgentEvent::AssistantText { + text: reply.text.clone(), + }); + } + + if reply.tool_calls.is_empty() { + history.push(ChatMessage::Assistant { + text: reply.text.clone(), + tool_calls: Vec::new(), + }); + self.sink.emit(AgentEvent::TurnFinished { + steps: step, + text: reply.text.clone(), + }); + return Ok(TurnOutcome { + text: reply.text, + steps: step, + tool_calls, + stopped_reason: StopReason::Completed, + }); + } + + history.push(ChatMessage::Assistant { + text: reply.text.clone(), + tool_calls: reply.tool_calls.clone(), + }); + for call in &reply.tool_calls { + let outcome = self.executor.execute(agent_session_id, call, step).await; + tool_calls += 1; + history.push(ChatMessage::tool( + call.id.clone(), + call.name.clone(), + outcome.to_model_content(), + )); + } + } + + self.sink.emit(AgentEvent::TurnFinished { + steps: used_steps, + text: last_text.clone(), + }); + Ok(TurnOutcome { + text: last_text, + steps: used_steps, + tool_calls, + stopped_reason: StopReason::StepLimit, + }) + } + + fn build_request(&self, history: &[ChatMessage]) -> ChatRequest { + let config = self.provider.config(); + let mut messages = Vec::with_capacity(history.len() + 1); + messages.push(ChatMessage::system(self.config.system_prompt.clone())); + messages.extend_from_slice(history); + ChatRequest { + model: config.model.clone(), + messages, + tools: self.registry.definitions(), + temperature: config.temperature, + max_tokens: config.max_tokens, + } + } +} diff --git a/crates/agent/src/brain/provider.rs b/crates/agent/src/brain/provider.rs new file mode 100644 index 0000000..804fa3d --- /dev/null +++ b/crates/agent/src/brain/provider.rs @@ -0,0 +1,340 @@ +//! Provider 四态端点配置(02 文档 §2)。 +//! +//! | 形态 | 端点 | 备注 | +//! |------|------|------| +//! | OpenAI 兼容(默认) | `base_url` + `api_key` + `model` | 覆盖 OpenAI/DeepSeek/Qwen/GLM/**本地 llama.cpp server**(`http://127.0.0.1:8080/v1`)/LM Studio | +//! | Anthropic 原生 | `api_key` + `model` | Claude 系列(没有可用的 Rust 单厂商 SDK,见 research/02 §3.6) | +//! | Ollama 原生 | `http://127.0.0.1:11434` | 本地另一种主流 | +//! | 禁用 | — | 完全关闭 agent,规则引擎(`diagnose_crash`)仍可用 | +//! +//! 本文件只放**配置与校验**;对话协议在 [`super::chat`]。这样 rig 的 pre-1.0 破坏面 +//! (research/02 §7 R1)被关在 [`super::rig`] 一个文件里,这里连 rig 的名字都不出现。 + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。 +pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1"; +/// Ollama 默认端点(research/02 §5.2)。 +pub const OLLAMA_DEFAULT_BASE_URL: &str = "http://127.0.0.1:11434"; +/// Anthropic 官方 API 端点。 +pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com"; + +/// Provider 四态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderKind { + /// OpenAI 兼容(含本地 llama.cpp / LM Studio / DeepSeek / Qwen…)。 + OpenAiCompatible, + /// Anthropic 原生。 + Anthropic, + /// Ollama 原生。 + Ollama, + /// 完全禁用 agent。 + Disabled, +} + +impl ProviderKind { + /// 该形态是否必须提供 API key(本地推理通常不需要)。 + #[must_use] + pub fn requires_api_key(self) -> bool { + matches!(self, Self::Anthropic) + } + + /// 该形态的默认端点(OpenAI 兼容没有统一默认值,必须显式填; + /// 本地 llama.cpp 走 [`ProviderConfig::llamacpp_local`] 带的显式默认)。 + #[must_use] + pub fn default_base_url(self) -> &'static str { + match self { + Self::OpenAiCompatible => "", + Self::Anthropic => ANTHROPIC_DEFAULT_BASE_URL, + Self::Ollama => OLLAMA_DEFAULT_BASE_URL, + Self::Disabled => "", + } + } + + /// 设置页展示名。 + #[must_use] + pub fn display_name(self) -> &'static str { + match self { + Self::OpenAiCompatible => "OpenAI 兼容", + Self::Anthropic => "Anthropic 原生", + Self::Ollama => "Ollama 原生", + Self::Disabled => "已禁用", + } + } +} + +/// Provider 配置(设置 → AI 的一份快照)。 +/// +/// `api_key` 只在进程内存在,**任何日志/审计/事件都不得带它**—— +/// `Debug` 是手写的(掩码 key),要连形态一起看用 [`ProviderConfig::redacted_debug`]。 +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct ProviderConfig { + /// 四态。 + pub kind: ProviderKind, + /// 端点;空串表示用 [`ProviderKind::default_base_url`]。 + pub base_url: String, + /// 模型名;`Disabled` 时可为空。 + pub model: String, + /// API key(本地推理可空)。 + pub api_key: Option, + /// 采样温度。 + pub temperature: Option, + /// 单次回复 token 上限。 + pub max_tokens: Option, +} + +impl Default for ProviderConfig { + fn default() -> Self { + Self::disabled() + } +} + +impl ProviderConfig { + fn base(kind: ProviderKind, base_url: &str, model: &str) -> Self { + Self { + kind, + base_url: base_url.to_string(), + model: model.to_string(), + api_key: None, + temperature: None, + max_tokens: None, + } + } + + /// OpenAI 兼容端点(云端或自建)。 + #[must_use] + pub fn openai_compatible(base_url: &str, model: &str) -> Self { + Self::base(ProviderKind::OpenAiCompatible, base_url, model) + } + + /// 本机 llama.cpp server(用户就在跑 llama-server 的默认场景)。 + #[must_use] + pub fn llamacpp_local(model: &str) -> Self { + Self::base( + ProviderKind::OpenAiCompatible, + LLAMACPP_DEFAULT_BASE_URL, + model, + ) + } + + /// Anthropic 原生。 + #[must_use] + pub fn anthropic(model: &str) -> Self { + Self::base(ProviderKind::Anthropic, ANTHROPIC_DEFAULT_BASE_URL, model) + } + + /// 本机 Ollama。 + #[must_use] + pub fn ollama(model: &str) -> Self { + Self::base(ProviderKind::Ollama, OLLAMA_DEFAULT_BASE_URL, model) + } + + /// 禁用 agent。 + #[must_use] + pub fn disabled() -> Self { + Self::base(ProviderKind::Disabled, "", "") + } + + /// 带上 API key。 + #[must_use] + pub fn with_api_key(mut self, api_key: impl Into) -> Self { + self.api_key = Some(api_key.into()); + self + } + + /// 带上温度。 + #[must_use] + pub fn with_temperature(mut self, temperature: f32) -> Self { + self.temperature = Some(temperature); + self + } + + /// agent 是否启用。 + #[must_use] + pub fn is_enabled(&self) -> bool { + self.kind != ProviderKind::Disabled + } + + /// 实际使用的端点(空则回落默认值)。 + #[must_use] + pub fn resolved_base_url(&self) -> &str { + if self.base_url.trim().is_empty() { + self.kind.default_base_url() + } else { + self.base_url.trim() + } + } + + /// 配置是否可用(装配期调用一次,错就进设置页提示而不是等到对话时炸)。 + /// + /// # Errors + /// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。 + pub fn validate(&self) -> Result<(), ProviderError> { + if !self.is_enabled() { + return Err(ProviderError::new( + "AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)", + )); + } + if self.model.trim().is_empty() { + return Err(ProviderError::new("没有配置模型名(model)")); + } + if self.resolved_base_url().trim().is_empty() { + return Err(ProviderError::new(format!( + "{} 需要填写端点(base_url)", + self.kind.display_name() + ))); + } + if self.kind.requires_api_key() + && self + .api_key + .as_deref() + .is_none_or(|key| key.trim().is_empty()) + { + return Err(ProviderError::new(format!( + "{} 需要 API key", + self.kind.display_name() + ))); + } + Ok(()) + } + + /// 可打印的形态描述(**key 已掩码**,给日志/审计/设置页用)。 + #[must_use] + pub fn redacted_debug(&self) -> String { + let key = match self.api_key.as_deref() { + None => "无".to_string(), + Some(k) if k.trim().is_empty() => "无".to_string(), + Some(_) => crate::redact::MASK.to_string(), + }; + format!( + "{} base_url={} model={} api_key={key}", + self.kind.display_name(), + self.resolved_base_url(), + if self.model.is_empty() { + "(未设置)" + } else { + &self.model + }, + ) + } +} + +impl fmt::Debug for ProviderConfig { + /// 手写 Debug:**key 掩码**(`api_key` 是唯一不能出现在日志里的字段)。 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ProviderConfig") + .field("kind", &self.kind) + .field("base_url", &self.resolved_base_url()) + .field("model", &self.model) + .field( + "api_key", + &self.api_key.as_deref().map(|_| crate::redact::MASK), + ) + .field("temperature", &self.temperature) + .field("max_tokens", &self.max_tokens) + .finish() + } +} + +/// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderError { + message: String, +} + +impl ProviderError { + /// 从可读文本构造。 + #[must_use] + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for ProviderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ProviderError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_defaults_match_research() { + assert_eq!( + ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(), + LLAMACPP_DEFAULT_BASE_URL + ); + assert_eq!( + ProviderConfig::ollama("llama3").resolved_base_url(), + OLLAMA_DEFAULT_BASE_URL + ); + assert_eq!( + ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(), + ANTHROPIC_DEFAULT_BASE_URL + ); + } + + #[test] + fn validate_requires_model_base_url_and_key() { + assert!(!ProviderConfig::disabled().is_enabled()); + assert!(ProviderConfig::disabled().validate().is_err()); + assert!(ProviderConfig::llamacpp_local("").validate().is_err()); + assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok()); + // OpenAI 兼容必须显式给端点 + assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini") + .validate() + .is_err()); + assert!( + ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat") + .validate() + .is_ok() + ); + assert!(ProviderConfig::anthropic("claude").validate().is_err()); + assert!(ProviderConfig::anthropic("claude") + .with_api_key("sk-ant") + .validate() + .is_ok()); + } + + #[test] + fn redacted_debug_never_leaks_key() { + let config = + ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat") + .with_api_key("sk-super-secret"); + let text = config.redacted_debug(); + assert!(!text.contains("sk-super-secret"), "{text}"); + assert!(text.contains(crate::redact::MASK)); + // `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。 + let debug = format!("{config:?}"); + assert!(!debug.contains("sk-super-secret"), "{debug}"); + assert!(debug.contains(crate::redact::MASK), "{debug}"); + } + + #[test] + fn empty_base_url_falls_back_to_default() { + let mut config = ProviderConfig::ollama("llama3"); + config.base_url = String::new(); + assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL); + } + + #[test] + fn four_states_serialize_as_snake_case() { + assert_eq!( + serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(), + "\"open_ai_compatible\"" + ); + assert_eq!( + serde_json::to_string(&ProviderConfig::disabled()).unwrap(), + "{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}" + ); + } +} diff --git a/crates/agent/src/brain/rig.rs b/crates/agent/src/brain/rig.rs new file mode 100644 index 0000000..3043564 --- /dev/null +++ b/crates/agent/src/brain/rig.rs @@ -0,0 +1,368 @@ +//! rig 适配层:[`ChatProvider`] 的**唯一**实现,也是整个 crate 唯一允许 `use rig_*` 的文件。 +//! +//! 依赖面收窄纪律(`DevDocs/PlanDoc/02-ai-agent-design.md` §0 + research/02 §7 R1): +//! rig 是 pre-1.0、12 周 5 个 minor、CHANGELOG 满是 `[**breaking**]`(0.43 把 MCP 拆出 rig-agent、 +//! 0.45 升 rmcp 3.5 并改 owned connection)。所以把它关在这一个文件里:它变了,只有这里编译失败, +//! [`super::ChatProvider`] 以上的循环/编排/审计一行不用动。 +//! +//! ## 0.45 的接线(实读源码,非照文档猜) +//! +//! ```text +//! ProviderConfig ─┬─ OpenAiCompatible → OpenAIConfig::new(key).with_base_url(url).client().chat(model) +//! ├─ Anthropic → AnthropicConfig::new(key).with_base_url(url).client().completion(model) +//! ├─ Ollama → OllamaConfig::new().with_base_url(url).client().completion(model) +//! └─ Disabled → 不构造(Brain::run_turn 已提前返回) +//! ↓ Model.erase() +//! DynModel(wire + transport 擦除,本文件只持有这一个类型) +//! ``` +//! +//! 循环侧只要两件事:流式文本增量、以及折好的 [`ChatReply`](文本 + 工具调用)。 +//! 所以这里用 `model.stream(request)` 边转发 [`StreamEvent::Text`] 增量给 [`DeltaObserver`], +//! 最后 `Streamed::finish()` 拿到完整 [`CompletionResponse`]——工具调用从响应里取, +//! 不自己拼分片 JSON(拼分片是 rig 的 `ReplyFold` 已经做过的事)。 +//! +//! ## 三个已确认的坑 +//! +//! 1. **llama.cpp 默认端点就是 `http://localhost:8080/v1`**(`providers/llamacpp/mod.rs`), +//! 与 [`LLAMACPP_DEFAULT_BASE_URL`] 一致。这里统一走 OpenAI 方言 + 显式 base_url: +//! llama.cpp / LM Studio / DeepSeek 都是 OpenAI 兼容面,方言差异主要在默认端点与特有位 +//! (`chat_template_kwargs`、`num_ctx`)——真要下发这些,再换成 `providers::llamacpp` 方言或 +//! Ollama 的 `native_completion`。 +//! 2. **Ollama 原生方言的工具结果用 `tool_name` 配对(没有 id)**,OpenAI 方言用 `tool_call_id`。 +//! 这里走 rig 的 `Ollama::completion`(OpenAI 兼容 `/v1/chat/completions`),由 rig 负责回灌形状; +//! 要下发 `num_ctx`(崩溃日志几千行,上下文必须可配)才需要 `native_completion`,留作 P1。 +//! 3. **取消**:`Streamed` 是普通 future/stream,丢弃即断开——P0 用「丢弃 future」实现停止生成, +//! P1 接 UI 的「停止」按钮时按 rig 的取消语义(drop stream)对齐即可,不需要 unsafe。 +//! +//! ## 逃生口(保留但不是 Plan A) +//! +//! rig 的破坏变更真伤到(或 Rust 1.92 MSRV 追不上 rig 的 1.97.1)时:换 `genai` +//! (provider + function calling,无 agent 循环)或自己写 OpenAI 方言(约 300~500 行)。 +//! **代价只是实现一个新的 [`ChatProvider`]**——这正是把它关在一个文件里的意义。 +//! +//! [`LLAMACPP_DEFAULT_BASE_URL`]: super::provider::LLAMACPP_DEFAULT_BASE_URL +//! [`StreamEvent::Text`]: rig_core::streaming::StreamEvent::Text + +use std::sync::Arc; + +use futures_util::StreamExt; +use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition}; +use rig_core::error::ProviderError as RigError; +use rig_core::message::{ + AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId, + Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction, + ToolName as RigToolName, ToolResult, ToolResultContent, UserContent, +}; +use rig_core::operation::Completion; +use rig_core::providers::anthropic::AnthropicConfig; +use rig_core::providers::ollama::OllamaConfig; +use rig_core::providers::openai::OpenAIConfig; +use rig_core::streaming::{Item, StreamEvent}; +use rig_core::DynModel; + +use super::chat::{ + ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory, + ProviderFuture, +}; +use super::provider::{ProviderConfig, ProviderError, ProviderKind}; + +/// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。 +pub const RIG_VERSION_PIN: &str = "=0.45.0"; + +/// 擦掉 wire / transport 之后的补全模型——本文件对外只暴露这一个 rig 类型之外的东西。 +type ErasedModel = DynModel; + +/// rig 实现的 [`ChatProvider`]:四态配置 + 一个擦除后的模型。 +pub struct RigProvider { + config: ProviderConfig, + model: ErasedModel, +} + +impl RigProvider { + /// 按配置造 provider。`Disabled`、缺模型名、缺 key 都在这里被拒(fail-fast)。 + /// + /// # Errors + /// AI 被禁用、配置不完整、或 rig 端点构造失败时返回可读错误。 + pub fn new(config: ProviderConfig) -> Result { + config.validate()?; + let model = build_model(&config)?; + Ok(Self { config, model }) + } + + /// 装配层用的工厂:`ProviderFactory` 直接塞给 `crates/app`。 + #[must_use] + pub fn factory() -> ProviderFactory { + Arc::new(|config: &ProviderConfig| { + Ok(Arc::new(Self::new(config.clone())?) as Arc) + }) + } +} + +impl ChatProvider for RigProvider { + fn config(&self) -> &ProviderConfig { + &self.config + } + + fn complete<'a>( + &'a self, + request: &'a ChatRequest, + observer: &'a dyn DeltaObserver, + ) -> ProviderFuture<'a> { + Box::pin(async move { + let rig_request = build_request(request)?; + let mut stream = self.model.stream(rig_request).map_err(map_error)?; + + // 先转发增量,再拿折好的完整响应(工具调用只从响应取,不拼分片)。 + let mut text = String::new(); + while let Some(item) = stream.next().await { + match item.map_err(map_error)? { + Item::Event(StreamEvent::Text { text: fragment, .. }) => { + text.push_str(&fragment); + observer.on_delta(&fragment); + } + Item::Event(_) | Item::Unknown(_) => {} + } + } + let response = stream.finish().await.map_err(map_error)?; + to_reply(&response, text) + }) + } +} + +/// 四态 → rig 模型。 +fn build_model(config: &ProviderConfig) -> Result { + let model = config.model.trim().to_string(); + let base_url = config.resolved_base_url().to_string(); + let api_key = config.api_key.clone().unwrap_or_default(); + let erased = match config.kind { + ProviderKind::OpenAiCompatible => OpenAIConfig::new(api_key) + .with_base_url(base_url) + .client() + .chat(model) + .erase(), + ProviderKind::Anthropic => AnthropicConfig::new(api_key) + .with_base_url(base_url) + .client() + .completion(model) + .erase(), + ProviderKind::Ollama => OllamaConfig::new() + .with_base_url(base_url) + .client() + .completion(model) + .erase(), + ProviderKind::Disabled => { + return Err(ProviderError::new( + "AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)", + )) + } + }; + Ok(erased) +} + +/// 本方消息 → rig 补全请求。 +fn build_request(request: &ChatRequest) -> Result { + let mut history = to_rig_messages(&request.messages)?; + // rig 把历史最后一条当 prompt,其余是上下文;空历史没有可发的 prompt。 + let prompt = history + .pop() + .ok_or_else(|| ProviderError::new("对话历史为空:至少需要一条用户消息"))?; + let prompt = Arc::unwrap_or_clone(prompt); + let tools = request + .tools + .iter() + .map(to_rig_tool) + .collect::, _>>()?; + + Ok(CompletionRequest::new(prompt) + .messages(history) + .tools(tools) + .temperature(request.temperature.map(f64::from)) + .max_tokens(request.max_tokens.map(u64::from))) +} + +/// 本方工具定义 → rig 工具定义(入参都是裸 JSON Schema,这正是双投影的支点)。 +fn to_rig_tool(tool: &crate::registry::ToolDefinition) -> Result { + let name = tool_name(&tool.name)?; + Ok(RigToolDefinition::new( + name, + tool.description.clone(), + tool.input_schema.clone(), + )) +} + +/// 本方消息 → rig 消息。 +fn to_rig_messages(messages: &[ChatMessage]) -> Result>, ProviderError> { + let mut out = Vec::with_capacity(messages.len()); + for message in messages { + let rig = match message { + ChatMessage::System { content } => RigMessage::system(content.clone()), + ChatMessage::User { content } => RigMessage::user(content.clone()), + ChatMessage::Assistant { text, tool_calls } => { + let mut parts = Vec::with_capacity(tool_calls.len() + 1); + if !text.trim().is_empty() { + parts.push(RigAssistantContent::Text(RigText::new(text.clone()))); + } + for call in tool_calls { + parts.push(RigAssistantContent::ToolCall(RigToolCall::new( + CallId::from_wire(call.id.clone()), + ToolFunction::new(tool_name(&call.name)?, call.arguments.clone()), + ))); + } + RigMessage::Assistant(RigAssistantMessage::new(parts)) + } + ChatMessage::Tool { + call_id, + name, + content, + } => RigMessage::User { + content: vec![UserContent::ToolResult(ToolResult { + call: CallId::from_wire(call_id.clone()), + name: tool_name(name)?, + content: vec![ToolResultContent::text(content.clone())], + is_error: false, + })], + }, + }; + out.push(Arc::new(rig)); + } + Ok(out) +} + +/// rig 响应 → 本方回复(文本 + 工具调用 + 用量)。 +fn to_reply( + response: &rig_core::completion::CompletionResponse, + streamed: String, +) -> Result { + let mut text = streamed; + let mut tool_calls = Vec::new(); + for part in &response.choice { + match part { + RigAssistantContent::Text(chunk) => { + if text.is_empty() { + text.push_str(&chunk.text); + } + } + RigAssistantContent::ToolCall(call) => { + if let Some(raw) = call.function.invalid_arguments.as_deref() { + return Err(ProviderError::new(format!( + "模型返回的工具调用参数不是 JSON 对象(工具 `{}`):{raw}", + call.function.name.as_str() + ))); + } + tool_calls.push(super::chat::ToolCall { + id: call.id.to_string(), + name: call.function.name.as_str().to_string(), + arguments: serde_json::Value::Object(call.function.arguments.clone()), + }); + } + // 推理链/图片/未知块不进对话循环(P0 只消费文本 + 工具调用)。 + _ => {} + } + } + Ok(ChatReply { + text, + tool_calls, + usage: Some(super::chat::Usage { + prompt_tokens: response + .usage + .input_tokens + .and_then(|v| u32::try_from(v).ok()), + completion_tokens: response + .usage + .output_tokens + .and_then(|v| u32::try_from(v).ok()), + }), + }) +} + +/// rig 错误 → 本方错误(文案面向用户)。 +fn map_error(error: RigError) -> ProviderError { + ProviderError::new(error.to_string()) +} + +/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。 +fn tool_name(name: &str) -> Result { + RigToolName::new(name) + .map_err(|_| ProviderError::new("工具名不能为空(ToolSpec 注册阶段应已拦截)")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tool(id: &str, name: &str) -> super::super::chat::ToolCall { + super::super::chat::ToolCall { + id: id.to_string(), + name: name.to_string(), + arguments: serde_json::json!({ "path": "appearance.theme_mode" }), + } + } + + #[test] + fn disabled_config_is_rejected_before_touching_the_network() { + let Err(err) = RigProvider::new(ProviderConfig::disabled()) else { + panic!("禁用态应在构造阶段被拒"); + }; + assert!(err.to_string().contains("禁用")); + } + + #[test] + fn anthropic_without_key_is_rejected() { + assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err()); + assert!(RigProvider::new( + ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant") + ) + .is_ok()); + } + + #[test] + fn local_states_build_offline() { + assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok()); + assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok()); + } + + #[test] + fn request_maps_messages_and_tools() { + let request = ChatRequest { + model: "mock".to_string(), + messages: vec![ + ChatMessage::system("你是助手"), + ChatMessage::user("把主题换成浅色"), + ChatMessage::Assistant { + text: String::new(), + tool_calls: vec![tool("call_1", "set_config")], + }, + ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"), + ], + tools: vec![crate::registry::ToolDefinition { + name: "set_config".to_string(), + description: "改配置".to_string(), + input_schema: serde_json::json!({ "type": "object" }), + }], + temperature: Some(0.5), + max_tokens: Some(512), + }; + let built = build_request(&request).expect("应能构造"); + assert_eq!(built.chat_history.len(), 4); + assert_eq!(built.tools.len(), 1); + assert_eq!(built.tools[0].name.as_str(), "set_config"); + assert_eq!(built.temperature, Some(0.5)); + assert_eq!(built.max_tokens, Some(512)); + // 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块 + let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化"); + assert!(json.contains("toolresult"), "{json}"); + } + + #[test] + fn empty_history_is_a_readable_error() { + let request = ChatRequest { + model: "m".to_string(), + messages: Vec::new(), + tools: Vec::new(), + temperature: None, + max_tokens: None, + }; + let err = build_request(&request).unwrap_err(); + assert!(err.to_string().contains("对话历史为空")); + } +} diff --git a/crates/agent/src/clock.rs b/crates/agent/src/clock.rs new file mode 100644 index 0000000..f24b042 --- /dev/null +++ b/crates/agent/src/clock.rs @@ -0,0 +1,139 @@ +//! 时间源:审计日志、事件流、工具耗时都要时间戳,但**测试不能依赖真实时钟** +//! (否则断言不稳定)。所以照既有做法注入 [`Clock`]。 +//! +//! 时间戳格式化自己实现([`format_unix_ms`]),不引 `chrono`:agent 的依赖面按 +//! `DevDocs/PlanDoc/02-ai-agent-design.md` §0 越窄越好,RFC3339 UTC 只需要一个 +//! 民用历换算(Howard Hinnant 的 `civil_from_days`)。 + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// 时间源。生产用 [`SystemClock`],测试用 [`FixedClock`]。 +pub trait Clock: Send + Sync { + /// 当前 Unix 毫秒时间戳。 + fn now_unix_ms(&self) -> u64; +} + +/// 真实系统时钟(单调性不保证,审计只要"大致有序")。 +#[derive(Debug, Default, Clone, Copy)] +pub struct SystemClock; + +impl Clock for SystemClock { + fn now_unix_ms(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64) + } +} + +/// 可控时钟:测试里固定起点、必要时手动推进。 +#[derive(Debug)] +pub struct FixedClock { + ms: AtomicU64, + step_ms: u64, +} + +impl FixedClock { + /// 从固定时间戳开始,每次读走一步 `step_ms`(0 = 不自动推进)。 + #[must_use] + pub fn new(start_ms: u64) -> Self { + Self { + ms: AtomicU64::new(start_ms), + step_ms: 0, + } + } + + /// 每次读取自动前进 `step_ms`,用来断言工具耗时字段。 + #[must_use] + pub fn with_step(start_ms: u64, step_ms: u64) -> Self { + Self { + ms: AtomicU64::new(start_ms), + step_ms, + } + } + + /// 手动推进。 + pub fn advance(&self, ms: u64) { + self.ms.fetch_add(ms, Ordering::Relaxed); + } +} + +impl Clock for FixedClock { + fn now_unix_ms(&self) -> u64 { + if self.step_ms == 0 { + return self.ms.load(Ordering::Relaxed); + } + self.ms.fetch_add(self.step_ms, Ordering::Relaxed) + self.step_ms + } +} + +/// `Arc` 的简写。 +pub type SharedClock = Arc; + +/// Unix 毫秒 → `YYYY-MM-DDTHH:MM:SS.mmmZ`(UTC)。 +#[must_use] +pub fn format_unix_ms(ms: u64) -> String { + let secs = (ms / 1000) as i64; + let millis = ms % 1000; + let days = secs.div_euclid(86_400); + let sod = secs.rem_euclid(86_400); + let (y, m, d) = civil_from_days(days); + let (h, mi, s) = (sod / 3600, (sod % 3600) / 60, sod % 60); + format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z") +} + +/// Unix 毫秒 → `YYYY-MM-DD`(UTC),审计落盘按天分文件用。 +#[must_use] +pub fn date_of_unix_ms(ms: u64) -> String { + let (y, m, d) = civil_from_days(((ms / 1000) as i64).div_euclid(86_400)); + format!("{y:04}-{m:02}-{d:02}") +} + +/// 1970-01-01 起的天数 → (年, 月, 日)。 +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_epoch_and_known_instants() { + assert_eq!(format_unix_ms(0), "1970-01-01T00:00:00.000Z"); + // 2026-10-29T20:12:33.123Z + assert_eq!( + format_unix_ms(1_793_304_753_123), + "2026-10-29T20:12:33.123Z" + ); + assert_eq!(date_of_unix_ms(1_793_304_753_123), "2026-10-29"); + } + + #[test] + fn handles_leap_day() { + // 2024-02-29T00:00:00Z + assert_eq!( + format_unix_ms(1_709_164_800_000), + "2024-02-29T00:00:00.000Z" + ); + } + + #[test] + fn fixed_clock_can_step() { + let clock = FixedClock::with_step(1_000, 5); + assert_eq!(clock.now_unix_ms(), 1_005); + assert_eq!(clock.now_unix_ms(), 1_010); + clock.advance(10); + assert_eq!(clock.now_unix_ms(), 1_025); + } +} diff --git a/crates/agent/src/confirm.rs b/crates/agent/src/confirm.rs new file mode 100644 index 0000000..8689c24 --- /dev/null +++ b/crates/agent/src/confirm.rs @@ -0,0 +1,235 @@ +//! 分级确认协议(02 文档 §3.1「分级确认」+ §4b「YOLO」)。 +//! +//! 分工: +//! - **工具**只声明「这次调用需要确认」并给出 diff([`ConfirmRequest`]),不自己做决定; +//! - **编排层**([`crate::brain::exec`])拿 [`ConfirmPolicy`] 决定是问人、还是走 YOLO; +//! - **UI** 在 P1 通过 [`ConfirmGate`] 接进来(P0 只有事件 + fail-closed 的默认实现)。 +//! +//! fail-closed 是刻意的:P0 没有确认弹窗,敏感键的写入**一律拒绝**而不是静默放行 +//! (research/02 §7 R9「写操作的破坏半径」)。 + +use std::collections::VecDeque; +use std::future::Future; +use std::pin::Pin; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// 敏感等级。**与 M12 `setting::spec::Sensitivity` 逐字一致** +/// (`DevDocs/SubAgentsTalk/setting-接口约定.md` §8): +/// +/// | 等级 | 语义 | +/// |------|------| +/// | `Low` | 静默生效 | +/// | `Medium` | 静默生效,审计里能查到(每次调用本来都审计,这里只是标记用途) | +/// | `High` | 必须用户确认(YOLO 可跳过确认,但**仍受白名单约束**) | +/// +/// 「AI 完全不能写」的键(`ai.api_key` 等凭据)由 M12 的 `writable=false` 表达, +/// 它们**不进白名单**,agent 侧走「不在白名单」的拒绝分支,不需要第四级。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Sensitivity { + /// 静默生效。 + #[default] + Low, + /// 静默生效但审计标记(下载源、语言、日志级别)。 + Medium, + /// 必须用户确认(数据目录、Java 路径、代理、AI 端点/YOLO 开关)。 + High, +} + +/// 一个待确认的操作:给用户看的措辞 + 结构化细节(UI 画 diff 用)。 +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ConfirmRequest { + /// 请求 id(编排层生成,UI 回填决定时原样带回)。 + pub id: String, + /// 工具名。 + pub tool: String, + /// 一句话请求(「AI 请求把 `instance.java.path` 改为 `D:\jdk17`」)。 + pub summary: String, + /// 结构化细节(`{"path":…, "old":…, "new":…}`)。 + pub details: Value, + /// 敏感等级。 + pub sensitivity: Sensitivity, +} + +/// 用户决定。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConfirmDecision { + /// 允许这一次。 + Approved, + /// 拒绝(工具调用返回「用户拒绝」给模型)。 + Denied, + /// 允许,且本会话后续不再询问(UI 的「全部允许」)。 + /// + /// 编排层当前**只当普通放行**处理(`brain/exec.rs` 不区分两者);「据此临时开 YOLO」 + /// 是 P1 的行为,落地前不要按已实现来看。 + ApprovedAll, +} + +impl ConfirmDecision { + /// 是否放行(`Approved` 与 `ApprovedAll` 都放行)。 + #[must_use] + pub fn is_approved(self) -> bool { + matches!(self, Self::Approved | Self::ApprovedAll) + } +} + +/// 确认闸门:P1 由 app 的 Slint 弹窗实现;测试用 [`ScriptedGate`]。 +pub trait ConfirmGate: Send + Sync { + /// 发起确认并等待决定(UI 未接时由实现方立即给出 fail-closed 结果)。 + fn request(&self, request: ConfirmRequest) -> ConfirmFuture; +} + +/// 确认未来的箱子类型。 +pub type ConfirmFuture = Pin + Send + 'static>>; + +/// 一律放行(YOLO 单测 / 未来"用户已信任该工具"场景)。 +#[derive(Debug, Default, Clone, Copy)] +pub struct AutoApproveGate; + +impl ConfirmGate for AutoApproveGate { + fn request(&self, _request: ConfirmRequest) -> ConfirmFuture { + Box::pin(async { ConfirmDecision::Approved }) + } +} + +/// 一律拒绝(fail-closed 的极简形态)。 +#[derive(Debug, Default, Clone, Copy)] +pub struct AutoDenyGate; + +impl ConfirmGate for AutoDenyGate { + fn request(&self, _request: ConfirmRequest) -> ConfirmFuture { + Box::pin(async { ConfirmDecision::Denied }) + } +} + +/// P0 默认闸门:**没有确认 UI** → 一律拒绝(fail-closed)。 +/// +/// 请求本身已经由编排层作为 [`crate::event::AgentEvent::ConfirmRequested`] 发出去了, +/// 所以 P0 的行为是「敏感操作既不静默执行,也不卡住对话」:事件流里留下了 +/// 「AI 想改 X」的完整记录,P1 接上弹窗后同一个事件直接变成可点的确认框。 +#[derive(Debug, Default, Clone, Copy)] +pub struct NoUiGate; + +impl ConfirmGate for NoUiGate { + fn request(&self, _request: ConfirmRequest) -> ConfirmFuture { + Box::pin(async { ConfirmDecision::Denied }) + } +} + +/// 脚本化闸门:按顺序返回预设决定,用完之后走 `default`(测试用)。 +pub struct ScriptedGate { + decisions: Mutex>, + default: ConfirmDecision, +} + +impl ScriptedGate { + /// 依次给出 `decisions`,耗尽后返回 `default`。 + #[must_use] + pub fn new( + decisions: impl IntoIterator, + default: ConfirmDecision, + ) -> Self { + Self { + decisions: Mutex::new(decisions.into_iter().collect()), + default, + } + } +} + +impl ConfirmGate for ScriptedGate { + fn request(&self, _request: ConfirmRequest) -> ConfirmFuture { + let decision = self + .decisions + .lock() + .ok() + .and_then(|mut q| q.pop_front()) + .unwrap_or(self.default); + Box::pin(async move { decision }) + } +} + +/// 确认策略(编排层用)。YOLO 的真实开关在设置里,这里只是当次运行的快照。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ConfirmPolicy { + /// 自动批准全部工具调用(设置 → AI → YOLO,默认关)。 + pub yolo: bool, +} + +/// 拒绝时的引导口径:**内置对话**有弹窗可点,**外部 MCP** 没有 UI——这条差别只在 +/// 「拒绝文案」上,策略本身完全相同(同一条 `ConfirmPolicy`、同一份审计)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DenyGuidance { + /// 内置对话:弹窗拒绝(或 P0 的 fail-closed)。 + InApp, + /// 外部 MCP 客户端:引导用户去设置页确认,或打开 YOLO。 + External, +} + +impl DenyGuidance { + /// 拒绝后回给模型/用户的一句话(区分「谁拒的」与「下一步怎么办」)。 + #[must_use] + pub fn message(self) -> &'static str { + match self { + Self::InApp => "用户拒绝了这次操作,未做任何修改", + Self::External => { + "这次操作需要用户确认,但当前是外部 MCP 客户端(没有确认弹窗),已按默认策略拒绝。\ + 请让用户在启动器设置页手动修改,或在设置 → AI 里打开 YOLO 模式后重试(审计照记)。" + } + } + } +} + +impl ConfirmPolicy { + /// 该等级是否需要**询问**用户:只有 `High` 会问;YOLO 下连 `High` 也不问 + /// (02 文档 §4b:YOLO 跳过分级确认,但**仍受白名单约束**——能改的键没变少)。 + #[must_use] + pub fn requires_confirmation(self, sensitivity: Sensitivity) -> bool { + !self.yolo && sensitivity == Sensitivity::High + } + + /// 该等级是否可以直接执行(不需要询问)。 + #[must_use] + pub fn auto_approves(self, sensitivity: Sensitivity) -> bool { + !self.requires_confirmation(sensitivity) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn low_and_medium_keys_never_ask() { + let policy = ConfirmPolicy { yolo: false }; + assert!(!policy.requires_confirmation(Sensitivity::Low)); + assert!(!policy.requires_confirmation(Sensitivity::Medium)); + assert!(policy.auto_approves(Sensitivity::Low)); + assert!(policy.auto_approves(Sensitivity::Medium)); + } + + #[test] + fn high_keys_ask_unless_yolo() { + assert!(ConfirmPolicy { yolo: false }.requires_confirmation(Sensitivity::High)); + assert!(!ConfirmPolicy { yolo: false }.auto_approves(Sensitivity::High)); + assert!(!ConfirmPolicy { yolo: true }.requires_confirmation(Sensitivity::High)); + assert!(ConfirmPolicy { yolo: true }.auto_approves(Sensitivity::High)); + } + + #[test] + fn sensitivity_matches_m12_levels() { + // 序列化名必须与 M12 `setting::spec::Sensitivity` 一致 + assert_eq!(serde_json::to_string(&Sensitivity::Low).unwrap(), "\"low\""); + assert_eq!( + serde_json::to_string(&Sensitivity::Medium).unwrap(), + "\"medium\"" + ); + assert_eq!( + serde_json::to_string(&Sensitivity::High).unwrap(), + "\"high\"" + ); + } +} diff --git a/crates/agent/src/event.rs b/crates/agent/src/event.rs new file mode 100644 index 0000000..20e75d8 --- /dev/null +++ b/crates/agent/src/event.rs @@ -0,0 +1,197 @@ +//! agent 事件流:**UI 与审计之外的观察者**都从这里接(02 文档 §1 的「event channel」)。 +//! +//! P0 只做输出([`AgentEventSink`]),P1 的 Slint 聊天面板订阅同一路事件把 +//! 「正在读取日志…」「请求修改设置:下载并发 64→16」画出来,确认弹窗直接复用 +//! [`AgentEvent::ConfirmRequested`]。 +//! +//! 实现方必须**非阻塞**:`emit` 在对话循环的同步路径上被调用。 + +use std::sync::mpsc::Sender; +use std::sync::Mutex; + +use serde::Serialize; +use serde_json::Value; + +use crate::confirm::ConfirmRequest; + +/// 一条 agent 事件(全部可序列化:P1 走 IPC 给 app 时不写两套类型)。 +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AgentEvent { + /// 一轮对话开始。 + TurnStarted { + /// agent 会话 id。 + session_id: String, + /// 用户输入。 + user_text: String, + }, + /// 流式文本增量(P1 聊天面板逐字渲染)。 + TextDelta { + /// 增量文本。 + text: String, + }, + /// 模型这一轮的非流式完整文本(流式之外的兜底,也是审计用的正文)。 + AssistantText { + /// 文本。 + text: String, + }, + /// 开始执行一个工具调用。 + ToolCallStarted { + /// 工具名。 + tool: String, + /// 模型给的参数。 + args: Value, + /// 第几轮工具循环(从 1 开始)。 + step: usize, + }, + /// 一个工具调用结束。 + ToolCallFinished { + /// 工具名。 + tool: String, + /// 是否成功。 + ok: bool, + /// 一句话结果摘要(UI 直接显示)。 + summary: String, + /// 这次是否触发了确认请求。 + requires_confirmation: bool, + }, + /// 需要用户确认(P0 只发事件,P1 变成可点的弹窗)。 + ConfirmRequested { + /// 待确认请求。 + request: Box, + }, + /// 工具调用被拒绝(策略或用户)。 + ToolDenied { + /// 工具名。 + tool: String, + /// 拒绝原因。 + reason: String, + }, + /// 一轮对话结束。 + TurnFinished { + /// 实际用掉的最大工具轮次。 + steps: usize, + /// 最终给用户看的文本。 + text: String, + }, + /// 循环级错误(provider 挂了、AI 被禁用…)。 + Error { + /// 人类可读原因。 + message: String, + }, +} + +/// 事件出口。 +pub trait AgentEventSink: Send + Sync { + /// 发一条事件。实现方不得阻塞。 + fn emit(&self, event: AgentEvent); +} + +/// 丢弃事件。 +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopSink; + +impl AgentEventSink for NoopSink { + fn emit(&self, _event: AgentEvent) {} +} + +/// 内存事件流(测试断言 + P0 的「暂不接 UI」)。 +#[derive(Debug, Default)] +pub struct MemoryEventSink { + events: Mutex>, +} + +impl MemoryEventSink { + /// 空 sink。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 取快照。 + #[must_use] + pub fn events(&self) -> Vec { + self.events.lock().map(|e| e.clone()).unwrap_or_default() + } + + /// 已发条数。 + #[must_use] + pub fn len(&self) -> usize { + self.events.lock().map(|e| e.len()).unwrap_or(0) + } + + /// 是否还没发过事件。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// 拼接所有文本增量(测试断言「流式内容拼起来等于完整文本」)。 + #[must_use] + pub fn streamed_text(&self) -> String { + self.events() + .into_iter() + .filter_map(|e| match e { + AgentEvent::TextDelta { text } => Some(text), + _ => None, + }) + .collect() + } +} + +impl AgentEventSink for MemoryEventSink { + fn emit(&self, event: AgentEvent) { + if let Ok(mut events) = self.events.lock() { + events.push(event); + } + } +} + +/// 标准库 channel 出口(P1 的 app 用一个线程收、喂给 Slint 的事件循环)。 +#[derive(Debug, Clone)] +pub struct MpscSink { + tx: Sender, +} + +impl MpscSink { + /// 包一个 `std::sync::mpsc::Sender`。 + #[must_use] + pub fn new(tx: Sender) -> Self { + Self { tx } + } +} + +impl AgentEventSink for MpscSink { + fn emit(&self, event: AgentEvent) { + // 接收端已关闭说明 UI 没了,事件直接丢弃(不能拖累对话)。 + let _ = self.tx.send(event); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn memory_sink_collects_and_concatenates_deltas() { + let sink = MemoryEventSink::new(); + sink.emit(AgentEvent::TextDelta { + text: "正在".into(), + }); + sink.emit(AgentEvent::TextDelta { + text: "读取日志".into(), + }); + assert_eq!(sink.streamed_text(), "正在读取日志"); + assert_eq!(sink.len(), 2); + } + + #[test] + fn mpsc_sink_forwards() { + let (tx, rx) = std::sync::mpsc::channel(); + let sink = MpscSink::new(tx); + sink.emit(AgentEvent::Error { + message: "boom".into(), + }); + assert!(matches!(rx.recv(), Ok(AgentEvent::Error { .. }))); + } +} diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs new file mode 100644 index 0000000..909f19b --- /dev/null +++ b/crates/agent/src/lib.rs @@ -0,0 +1,45 @@ +//! Unlucky-Launcher AI Agent(`unlucky_agent`)。 +//! +//! 形态定案(决策 D4):**内置 Agent 客户端 + 对外的 MCP server 双形态,共享同一个工具内核**。 +//! 设计定稿:`DevDocs/PlanDoc/02-ai-agent-design.md`。 +//! +//! 三条不能破的边界: +//! 1. 工具只定义一次([`registry`]),内置客户端与 MCP server 都把同一个 `ToolSpec` 投影出去; +//! 2. AI 是 Port 不是 Service——LLM 不可用时规则引擎仍能诊断; +//! 3. AI 永不接触账户凭据;写配置受 schema 白名单约束并留审计。 +//! +//! 与 core 的关系:agent 依赖 core 的公共 API,core 不知道 agent 的存在。 +//! 落地方式是把需要 core 的能力收成[`port`]里的五个 trait(配置读写 / 会话日志 / 崩溃诊断 / +//! 实例与 Java / 账户),由装配层用 core 的实现填充——这样 M12/M14/M15/M18/M31/M33 未合并时 +//! agent 也能独立编译、独立测试。装配处有两条:`crates/app`(GUI)与 `ull agent serve`(headless)。 +//! +//! 当前状态:工具内核 + 对话循环 + 审计(P0 定稿)+ **对外 MCP server**(M39)已落地。 +//! 两个外部依赖都收口在单文件:[`brain::rig`] 对 rig、[`mcp`] 对 rmcp(3.8.x)—— +//! 它们升 breaking 时只有那两个模块编译失败,上层的循环/编排/审计一行不用动。 +//! +//! 模块分工: +//! - [`registry`] 工具注册表(唯一真相源 + 双投影载荷); +//! - [`tools`] 工具 v1 实现(配置读写 / 会话日志 / 崩溃诊断); +//! - [`brain`] Provider 抽象 + 对话循环 + 工具编排; +//! - [`mcp`] 对外 MCP server(rmcp 双投影 + stdio / Streamable HTTP 传输); +//! - [`confirm`] 分级确认协议与 YOLO 策略; +//! - [`audit`] 每次工具调用的审计留痕; +//! - [`event`] 面向 UI 的事件流; +//! - [`port`] 与 core 的接触面(依赖倒置); +//! - [`redact`] 密钥/主目录脱敏; +//! - [`clock`] 可注入时间源(审计/事件用); +//! - [`testing`] 五个端口的 mock 实现(供本 crate、app 与 MCP 契约测试复用)。 + +#![forbid(unsafe_code)] + +pub mod audit; +pub mod brain; +pub mod clock; +pub mod confirm; +pub mod event; +pub mod mcp; +pub mod port; +pub mod redact; +pub mod registry; +pub mod testing; +pub mod tools; diff --git a/crates/agent/src/mcp/mod.rs b/crates/agent/src/mcp/mod.rs new file mode 100644 index 0000000..7687567 --- /dev/null +++ b/crates/agent/src/mcp/mod.rs @@ -0,0 +1,159 @@ +//! 对外 MCP server(02 文档 §1「双形态共享一个工具内核」的第二形态)。 +//! +//! 传输定论(§0):**Streamable HTTP on 127.0.0.1 是主力**(外部 agent 起 stdio 会变成 +//! 双实例抢数据目录锁),**stdio 形态保留**给 llama.cpp server 的内置 MCP client。 +//! +//! 与内置客户端的关系:两侧都只吃 [`crate::registry::ToolRegistry`] 的 `(name, description, +//! input_schema)`,并且**都必须走** [`crate::brain::exec::ToolExecutor`]——schema 校验、 +//! 分级确认、YOLO、审计一个字都不少。所以「用户在聊天里让 AI 改配置」与「Claude Code +//! 通过 MCP 改配置」是同一条路径(02 §1 的铁律)。 +//! +//! 安全边界(MCP 场景的差别只有一条): +//! - 敏感键(`Sensitivity::High`)默认**拒绝**而不是弹窗——外部客户端没有我们的 UI; +//! 拒绝文案引导用户去设置页确认,或在设置里打开 `ai.yolo`(YOLO 下照记 `yolo=true` 审计); +//! - 写工具默认不暴露(`agent.mcp.allow_write=false`):只读子集注册进注册表, +//! 调写工具会得到「没有这个工具 + 可用工具清单」(并留一条 `unknown_tool` 审计)。 +//! +//! 依赖面纪律:整个 crate 只有本模块允许 `use rmcp::`(与 `brain::rig` 对 rig 的规则同形)。 + +pub mod projection; +pub mod sample; +pub mod serve; +pub mod server; + +use std::fmt; + +pub use sample::{config_samples, ConfigSamples}; +pub use serve::{bind, serve_http, serve_http_on, serve_stdio}; +pub use server::RegistryServer; + +/// 一次 MCP server 运行的配置(由设置键 `agent.mcp.*` + 命令行覆盖合成)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpConfig { + /// 监听地址。**只允许回环地址**(默认 `127.0.0.1`):MCP 工具能改配置,不能对外开。 + pub host: String, + /// Streamable HTTP 端口(默认 47823,设置键 `agent.mcp.port`)。 + pub port: u16, + /// 访问令牌(`Authorization: Bearer `);`None` = 不校验(仅限回环监听时允许)。 + pub token: Option, + /// 是否只暴露只读工具(默认 **true**;`agent.mcp.allow_write=true` 才放开写侧)。 + pub read_only: bool, +} + +impl Default for McpConfig { + fn default() -> Self { + Self { + host: "127.0.0.1".to_string(), + port: 47823, + token: None, + read_only: true, + } + } +} + +impl McpConfig { + /// `host:port` 形态的监听地址(IPv6 字面量自动加方括号,`::1` 不会拼成 `::1:47823`)。 + #[must_use] + pub fn socket_addr(&self) -> String { + if self.host.contains(':') { + format!("[{}]:{}", self.host, self.port) + } else { + format!("{}:{}", self.host, self.port) + } + } + + /// MCP 端点 URL(客户端配置样例里用的那串)。 + #[must_use] + pub fn url(&self) -> String { + format!("http://{}/mcp", self.socket_addr()) + } + + /// 拒绝非回环监听——这是安全铁律的**代码化**,不是文档里的建议。 + /// + /// # Errors + /// `host` 不是回环地址时返回 [`McpServeError::NotLoopback`]。 + pub fn ensure_loopback(&self) -> Result<(), McpServeError> { + let host = self.host.trim(); + if host == "127.0.0.1" || host == "localhost" || host == "::1" { + return Ok(()); + } + Err(McpServeError::NotLoopback(host.to_string())) + } +} + +/// MCP server 启动/运行期错误(文案面向用户,可直接进 CLI 输出)。 +#[derive(Debug)] +pub enum McpServeError { + /// 监听地址不是回环地址。 + NotLoopback(String), + /// 端口被占用(最常见:另一个 `ull agent serve` 已经在跑)。 + PortInUse { + /// 监听地址(`127.0.0.1:47823`)。 + addr: String, + }, + /// 其他 IO 失败(权限、端口不足、传输中断)。 + Io(String), +} + +impl fmt::Display for McpServeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotLoopback(host) => write!( + f, + "拒绝在 `{host}` 上监听:MCP server 只允许回环地址(127.0.0.1 / localhost / ::1)" + ), + Self::PortInUse { addr } => write!( + f, + "端口已被占用:{addr}。可能另一个 `ull agent serve` 正在运行;\ + 用 `--port <端口>` 或设置键 `agent.mcp.port` 换一个" + ), + Self::Io(message) => write!(f, "MCP server 传输失败:{message}"), + } + } +} + +impl std::error::Error for McpServeError {} + +impl From for McpServeError { + fn from(error: std::io::Error) -> Self { + Self::Io(error.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_loopback_hosts_are_accepted() { + let mut cfg = McpConfig::default(); + assert!(cfg.ensure_loopback().is_ok()); + cfg.host = "localhost".to_string(); + assert!(cfg.ensure_loopback().is_ok()); + cfg.host = "::1".to_string(); + assert_eq!(cfg.url(), "http://[::1]:47823/mcp"); + assert!(cfg.ensure_loopback().is_ok()); + cfg.host = "0.0.0.0".to_string(); + let err = cfg.ensure_loopback().unwrap_err(); + assert!(matches!(err, McpServeError::NotLoopback(_))); + assert!(err.to_string().contains("回环地址")); + } + + #[test] + fn default_is_read_only_on_the_documented_port() { + let cfg = McpConfig::default(); + assert!(cfg.read_only); + assert_eq!(cfg.port, 47823); + assert_eq!(cfg.url(), "http://127.0.0.1:47823/mcp"); + } + + #[test] + fn port_in_use_message_is_actionable() { + let err = McpServeError::PortInUse { + addr: "127.0.0.1:47823".to_string(), + }; + let text = err.to_string(); + assert!(text.contains("--port"), "{text}"); + assert!(text.contains("agent.mcp.port"), "{text}"); + } +} diff --git a/crates/agent/src/mcp/projection.rs b/crates/agent/src/mcp/projection.rs new file mode 100644 index 0000000..ffb5ae5 --- /dev/null +++ b/crates/agent/src/mcp/projection.rs @@ -0,0 +1,106 @@ +//! 双投影的 **MCP 侧**:`ToolDefinition` → rmcp `Tool`(`ToolRoute::new_dyn` 的属性入参)。 +//! +//! rig 侧在 [`crate::brain::rig`](`ToolDefinition → RigToolDefinition`)。两侧都只吃裸 +//! `serde_json::Value` schema,所以「一致性」是可断言的:`tests/mcp_contract.rs` 遍历注册表, +//! 断言 MCP 侧的 `(name, description, input_schema)` 与注册表逐项相等——这就是 +//! research/02 §6.2 建议进 CI 的那条契约。 +//! +//! 额外一位信息:`annotations.read_only_hint`。MCP 客户端拿它决定「这个工具能不能 +//! 静默调用」,我们用它把 [`Access`] 语义透出去(只读提示,不是权限——权限在编排层)。 + +use std::sync::Arc; + +use rmcp::model::{JsonObject, Tool, ToolAnnotations}; +use serde_json::Value; + +use crate::registry::{Access, ToolDefinition, ToolRegistry}; + +/// 一个 `ToolDefinition` → rmcp `Tool`。 +/// +/// 注册表在注册阶段已保证 `input_schema` 根是 `object`,所以这里的兜底分支 +/// (非对象 → 空 schema)只是为了不 panic:真走到它说明注册校验被绕过了。 +#[must_use] +pub fn tool_attr(definition: &ToolDefinition, access: Access) -> Tool { + let schema: JsonObject = match &definition.input_schema { + Value::Object(map) => map.clone(), + _ => JsonObject::new(), + }; + let mut attr = Tool::new( + definition.name.clone(), + definition.description.clone(), + Arc::new(schema), + ); + let mut annotations = ToolAnnotations::default(); + annotations.read_only_hint = Some(access == Access::ReadOnly); + annotations.destructive_hint = Some(access == Access::Mutating); + attr.annotations = Some(annotations); + attr +} + +/// 注册表 → rmcp `Tool` 列表(顺序同注册顺序,便于两侧比对)。 +#[must_use] +pub fn tool_attrs(registry: &ToolRegistry) -> Vec { + registry + .specs() + .iter() + .map(|spec| tool_attr(&spec.definition(), spec.access)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::mock_ports; + use crate::tools::build_v1_registry; + use serde_json::json; + + fn registry() -> ToolRegistry { + let (ports, _settings) = mock_ports(); + build_v1_registry(&ports).expect("注册表应装配成功") + } + + #[test] + fn projection_keeps_name_description_and_schema() { + let registry = registry(); + let attrs = tool_attrs(®istry); + assert_eq!(attrs.len(), registry.len()); + + for (attr, spec) in attrs.iter().zip(registry.specs()) { + let definition = spec.definition(); + assert_eq!(attr.name.as_ref(), definition.name); + assert_eq!( + attr.description.as_deref(), + Some(definition.description.as_str()), + "{} 的说明必须逐字相等", + definition.name + ); + let projected = Value::Object((*attr.input_schema).clone()); + assert_eq!( + projected, definition.input_schema, + "{} 的 JSON Schema 必须逐字相等", + definition.name + ); + } + } + + #[test] + fn read_only_hint_matches_access() { + let registry = registry(); + for (attr, spec) in tool_attrs(®istry).iter().zip(registry.specs()) { + let hint = attr + .annotations + .as_ref() + .and_then(|a| a.read_only_hint) + .unwrap_or(false); + assert_eq!(hint, spec.access == Access::ReadOnly, "{}", attr.name); + } + } + + #[test] + fn schema_root_stays_an_object_on_the_wire() { + for attr in tool_attrs(®istry()) { + let value = Value::Object((*attr.input_schema).clone()); + assert_eq!(value["type"], json!("object"), "{}", attr.name); + } + } +} diff --git a/crates/agent/src/mcp/sample.rs b/crates/agent/src/mcp/sample.rs new file mode 100644 index 0000000..b533895 --- /dev/null +++ b/crates/agent/src/mcp/sample.rs @@ -0,0 +1,80 @@ +//! 对外客户端配置样例(用户把它们粘进 Claude Code / Cursor 就能连上)。 +//! +//! 为什么要生成而不是写在文档里:端口与令牌是**用户本机**的值,手抄必错。 +//! 两个客户端的形状不同(02 §0): +//! - **Claude Code**:`{"type":"http","url":…}`(`type` 必填,缺了会被当成 stdio) +//! - **Cursor**:`{"url":…}`(`mcp.json` 里只认 url;多写 type 会被忽略但不报错) +//! +//! 令牌只在设置里非空时出现——回环 + 无令牌是允许的(研究里“至少一个 token”是建议不是铁律, +//! 而我们用 `McpConfig::ensure_loopback` 硬性禁掉对外监听)。 + +use serde_json::{json, Value}; + +/// 客户端配置样例(三件套:端点 URL + 两份可直接粘的 JSON)。 +#[derive(Debug, Clone, PartialEq)] +pub struct ConfigSamples { + /// `http://127.0.0.1:/mcp`。 + pub url: String, + /// Claude Code(`claude mcp add` / `~/.claude.json` 的 `mcpServers` 片段)。 + pub claude_code: Value, + /// Cursor(`.cursor/mcp.json` 的 `mcpServers` 片段)。 + pub cursor: Value, +} + +/// 生成样例。`token` 非空时补 `headers.Authorization`。 +#[must_use] +pub fn config_samples(url: &str, token: Option<&str>) -> ConfigSamples { + let headers = token + .filter(|t| !t.trim().is_empty()) + .map(|t| json!({ "Authorization": format!("Bearer {t}") })); + + let mut claude_entry = json!({ "type": "http", "url": url }); + let mut cursor_entry = json!({ "url": url }); + if let Some(headers) = &headers { + claude_entry["headers"] = headers.clone(); + cursor_entry["headers"] = headers.clone(); + } + + ConfigSamples { + url: url.to_string(), + claude_code: json!({ "mcpServers": { "unlucky-launcher": claude_entry } }), + cursor: json!({ "mcpServers": { "unlucky-launcher": cursor_entry } }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claude_code_needs_type_http_and_cursor_does_not() { + let samples = config_samples("http://127.0.0.1:47823/mcp", None); + assert_eq!( + samples.claude_code["mcpServers"]["unlucky-launcher"]["type"], + json!("http") + ); + assert_eq!( + samples.claude_code["mcpServers"]["unlucky-launcher"]["url"], + json!("http://127.0.0.1:47823/mcp") + ); + assert!(samples.cursor["mcpServers"]["unlucky-launcher"] + .get("type") + .is_none()); + assert_eq!( + samples.cursor["mcpServers"]["unlucky-launcher"]["url"], + json!("http://127.0.0.1:47823/mcp") + ); + } + + #[test] + fn token_becomes_bearer_header_and_empty_token_is_skipped() { + let with = config_samples("http://127.0.0.1:1/mcp", Some("s3cret")); + let header = &with.cursor["mcpServers"]["unlucky-launcher"]["headers"]["Authorization"]; + assert_eq!(header, &json!("Bearer s3cret")); + + let without = config_samples("http://127.0.0.1:1/mcp", Some(" ")); + assert!(without.claude_code["mcpServers"]["unlucky-launcher"] + .get("headers") + .is_none()); + } +} diff --git a/crates/agent/src/mcp/serve.rs b/crates/agent/src/mcp/serve.rs new file mode 100644 index 0000000..e1ddcc5 --- /dev/null +++ b/crates/agent/src/mcp/serve.rs @@ -0,0 +1,144 @@ +//! 两种传输:stdio(llama.cpp 内置 MCP client 用)与 Streamable HTTP(主力)。 +//! +//! 传输定论(02 §0):**外部 MCP 主力是 127.0.0.1 上的 Streamable HTTP**, +//! 因为 stdio 形态要被外部 agent 拉起 = 同一份数据目录出现第二个实例, +//! 迟早撞上单写者锁(M18 的会话 jsonl、M12 的 launcher.json 都是单写者)。 +//! stdio 保留给**已经常驻的**本地模型服务(llama.cpp server 的 MCP client 由它自己拉子进程)。 +//! +//! 令牌:`Authorization: Bearer `。设置里为空 = 不校验(只允许回环监听, +//! `McpConfig::ensure_loopback` 已经拦掉对外监听)。research/02 §4.4 的原话是 +//! 「至少要有 headers 里的一枚随机 token」——我们做成可配、默认留空但样例里带。 + +use std::net::SocketAddr; + +use axum::extract::Request; +use axum::http::header::AUTHORIZATION; +use axum::http::StatusCode; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; +use rmcp::ServiceExt; +use tokio::net::TcpListener; + +use crate::mcp::server::RegistryServer; +use crate::mcp::{McpConfig, McpServeError}; + +/// stdio 形态:读 stdin 写 stdout,直到对端关闭。 +/// +/// 注意:**stdout 只能有 JSON-RPC**。任何日志都要走 stderr(否则客户端解析失败)。 +/// +/// # Errors +/// 传输中断或初始化失败时返回 [`McpServeError`]。 +pub async fn serve_stdio(server: RegistryServer) -> Result<(), McpServeError> { + let service = server + .serve(rmcp::transport::stdio()) + .await + .map_err(|error| McpServeError::Io(format!("stdio 初始化失败:{error}")))?; + service + .waiting() + .await + .map_err(|error| McpServeError::Io(format!("stdio 传输中断:{error}")))?; + Ok(()) +} + +/// 绑定 Streamable HTTP 监听(**只绑回环**),返回实际地址。 +/// +/// 拆出来是为了让调用方在进入 `axum::serve` 之前就能打印「监听在哪」—— +/// 端口被占用时也能拿到一条可操作的错误(tower 的提醒:撞端口要给出换端口的指引)。 +/// +/// # Errors +/// 非回环地址、端口被占用、或绑定失败时返回 [`McpServeError`]。 +pub async fn bind(cfg: &McpConfig) -> Result<(SocketAddr, TcpListener), McpServeError> { + cfg.ensure_loopback()?; + let addr = cfg.socket_addr(); + let listener = TcpListener::bind(&addr).await.map_err(|error| { + if error.kind() == std::io::ErrorKind::AddrInUse { + McpServeError::PortInUse { addr: addr.clone() } + } else { + McpServeError::Io(format!("绑定 {addr} 失败:{error}")) + } + })?; + let local = listener + .local_addr() + .map_err(|error| McpServeError::Io(error.to_string()))?; + Ok((local, listener)) +} + +/// Streamable HTTP 形态:`POST /mcp` 一条 JSON-RPC 请求 / 一条响应。 +/// +/// `json_response = true`:简单请求直接回 `application/json`(脚本与裸 JSON-RPC +/// 客户端最省事;需要服务端推送时 rmcp 会自动回落到 SSE)。 +/// +/// # Errors +/// 绑定失败(见 [`bind`])或服务运行期致命错误时返回 [`McpServeError`]。 +pub async fn serve_http(server: RegistryServer, cfg: McpConfig) -> Result<(), McpServeError> { + let (local, listener) = bind(&cfg).await?; + serve_http_on(server, cfg, local, listener).await +} + +/// 在已绑定的监听上跑(测试用临时端口,避免抢 47823)。 +/// +/// # Errors +/// 服务运行期致命错误时返回 [`McpServeError`]。 +pub async fn serve_http_on( + server: RegistryServer, + cfg: McpConfig, + local: SocketAddr, + listener: TcpListener, +) -> Result<(), McpServeError> { + let http_config = StreamableHttpServerConfig::default() + .with_json_response(true) + .with_legacy_session_mode(false); + let factory_server = server.clone(); + let service: StreamableHttpService = + StreamableHttpService::new( + move || Ok(factory_server.clone()), + std::sync::Arc::new(LocalSessionManager::default()), + http_config, + ); + + let token = cfg.token.clone(); + let app = axum::Router::new() + .nest_service("/mcp", service) + .layer(axum::middleware::from_fn( + move |request: Request, next: Next| { + let token = token.clone(); + async move { authorize(token, request, next).await } + }, + )); + + eprintln!( + "[unlucky-agent] MCP server 监听 http://{local}/mcp({} 个工具;令牌:{})", + server.tool_count(), + if cfg.token.is_some() { + "已启用" + } else { + "未设置(仅回环监听)" + } + ); + + axum::serve(listener, app) + .await + .map_err(|error| McpServeError::Io(error.to_string())) +} + +/// Bearer 令牌闸门:未配置 = 放行;配置了则严格比对。 +async fn authorize(token: Option, request: Request, next: Next) -> Response { + let Some(expected) = token else { + return next.run(request).await; + }; + let presented = request + .headers() + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + if presented == format!("Bearer {expected}") { + return next.run(request).await; + } + ( + StatusCode::UNAUTHORIZED, + "缺少或错误的 Authorization: Bearer ", + ) + .into_response() +} diff --git a/crates/agent/src/mcp/server.rs b/crates/agent/src/mcp/server.rs new file mode 100644 index 0000000..e0546f2 --- /dev/null +++ b/crates/agent/src/mcp/server.rs @@ -0,0 +1,243 @@ +//! rmcp `ServerHandler`:把同一份注册表挂成 MCP 工具路由。 +//! +//! 每次 `tools/call` 都走 [`ToolExecutor`]——schema 校验、确认闸门、YOLO、审计、 +//! 事件**与内置客户端完全同一条路径**(02 §1 铁律)。工具级失败返回 +//! `CallToolResult::error`(客户端能看到文案),协议级错误只留给「路由不到」的情况 +//! (research/02 §3.2:`Err(McpError)` 会被大多数客户端吞成 "internal error")。 + +use std::sync::Arc; + +use rmcp::handler::server::tool::{ToolCallContext, ToolRoute, ToolRouter}; +use rmcp::model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation, + ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, +}; +use rmcp::service::RequestContext; +use rmcp::{ErrorData as McpError, RoleServer, ServerHandler}; +use serde_json::Value; + +use crate::brain::chat::ToolCall; +use crate::brain::exec::ToolExecutor; +use crate::mcp::projection::tool_attr; +use crate::registry::{Access, ToolRegistry}; + +/// MCP server 的 `ServerHandler` 实现。 +/// +/// `Clone` 是 Streamable HTTP 要的:每个会话/请求由工厂造一份(共享同一批 `Arc`, +/// 因此审计与策略仍是同一份)。 +#[derive(Clone)] +pub struct RegistryServer { + router: ToolRouter, + visible: Arc>, + session_id: String, +} + +impl std::fmt::Debug for RegistryServer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RegistryServer") + .field("session_id", &self.session_id) + .field("tools", &self.visible.len()) + .finish() + } +} + +impl RegistryServer { + /// 用注册表 + 执行器装配。 + /// + /// **`router_registry` 必须是完整注册表**(含写工具),`executor` 才决定「哪些工具真能跑」: + /// - 只读模式:`-executor` 用 `read_only_subset()` 装配,`tools/list` 只列只读项, + /// 硬调写工具会路由到执行器 → `unknown_tool`(**tool-level 错误 + 审计**), + /// 而不是 rmcp 的协议级 `-32602 tool not found`(那个客户端只会显示 "internal error")。 + /// - 写模式:两者都是完整注册表。 + /// + /// `session_id` 进审计的 `agent_session_id` 字段(`mcp-http` / `mcp-stdio`), + /// 让复盘时能区分「内置对话改的」和「外部 MCP 改的」。 + #[must_use] + pub fn new( + router_registry: Arc, + executor: Arc, + session_id: impl Into, + read_only: bool, + ) -> Self { + let mut router = ToolRouter::new(); + for spec in router_registry.specs() { + let attr = tool_attr(&spec.definition(), spec.access); + router.add_route(route(attr, Arc::clone(&executor))); + } + let visible: Vec = router_registry + .specs() + .iter() + .filter(|spec| !read_only || spec.access == Access::ReadOnly) + .map(|spec| tool_attr(&spec.definition(), spec.access)) + .collect(); + Self { + router, + visible: Arc::new(visible), + session_id: session_id.into(), + } + } + + /// 本 server **对外可见**的工具(`tools/list` 的内容)。 + #[must_use] + pub fn tools(&self) -> Vec { + (*self.visible).clone() + } + + /// 审计用的会话标签。 + #[must_use] + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// 对外可见的工具数(启动日志/断言用)。 + #[must_use] + pub fn tool_count(&self) -> usize { + self.visible.len() + } +} + +fn route(attr: Tool, executor: Arc) -> ToolRoute { + ToolRoute::new_dyn(attr, move |context: ToolCallContext<'_, RegistryServer>| { + let executor = Arc::clone(&executor); + let session_id = context.service.session_id().to_string(); + let name = context.name().to_string(); + let arguments = context + .arguments + .clone() + .map_or_else(|| Value::Object(serde_json::Map::new()), Value::Object); + + Box::pin(async move { + let call = ToolCall { + id: format!("mcp-{name}"), + name, + arguments, + }; + let outcome = executor.execute(&session_id, &call, 0).await; + let text = outcome.to_model_content(); + let content = vec![ContentBlock::text(text)]; + let result = if outcome.ok { + CallToolResult::structured(outcome.content) + } else { + CallToolResult::error(content) + }; + Ok(result.into()) + }) + }) +} + +impl ServerHandler for RegistryServer { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new( + "unlucky-launcher", + env!("CARGO_PKG_VERSION"), + )) + .with_instructions( + "Unlucky-Launcher 的工具服务:读写启动器配置、看实例/版本/Java、读游戏日志、跑崩溃诊断。\ + 账户凭据永不经由本服务出口;写配置受 schema 白名单与敏感键确认约束。", + ) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListToolsResult::with_all_items(self.tools())) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + let call_context = ToolCallContext::new(self, request, context); + self.router.call(call_context).await + } + + fn get_tool(&self, name: &str) -> Option { + self.router.get(name).cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audit::MemoryAudit; + use crate::clock::{FixedClock, SharedClock}; + use crate::confirm::{AutoDenyGate, ConfirmPolicy}; + use crate::event::NoopSink; + use crate::testing::mock_ports; + use crate::tools::build_v1_registry; + use serde_json::json; + + fn server(registry: Arc, read_only: bool) -> (RegistryServer, Arc) { + let audit = Arc::new(MemoryAudit::new()); + let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123)); + let exposed = if read_only { + Arc::new(registry.read_only_subset()) + } else { + Arc::clone(®istry) + }; + let executor = Arc::new(ToolExecutor::new( + exposed, + audit.clone(), + Arc::new(AutoDenyGate), + ConfirmPolicy::default(), + clock, + Arc::new(NoopSink), + )); + ( + RegistryServer::new(registry, executor, "mcp-test", read_only), + audit, + ) + } + + fn registry() -> Arc { + let (ports, _settings) = mock_ports(); + Arc::new(build_v1_registry(&ports).expect("注册表应装配成功")) + } + + #[test] + fn lists_every_registered_tool_in_write_mode() { + let registry = registry(); + let (server, _audit) = server(Arc::clone(®istry), false); + let tools = server.tools(); + assert_eq!(tools.len(), registry.len()); + assert_eq!(server.tool_count(), registry.len()); + assert_eq!(server.session_id(), "mcp-test"); + assert!(tools.iter().any(|tool| tool.name == "set_config")); + } + + #[test] + fn read_only_mode_hides_the_writing_tool_but_keeps_it_routable() { + let (server, _audit) = server(registry(), true); + let names: Vec = server.tools().iter().map(|t| t.name.to_string()).collect(); + assert!(!names.iter().any(|n| n == "set_config"), "{names:?}"); + assert!(names.iter().any(|n| n == "get_config")); + assert_eq!(names.len(), 9); + // 路由仍然存在(否则 rmcp 会回协议级 -32602,客户端看到的是 "internal error"), + // 真正拦下来的是只读子集执行器 → tool-level 错误 + 审计。 + assert!(server.router.get("set_config").is_some()); + } + + #[test] + fn info_advertises_tools_and_a_stable_identity() { + let (server, _audit) = server(registry(), false); + let info = server.get_info(); + assert!(info.capabilities.tools.is_some()); + assert_eq!(info.server_info.name, "unlucky-launcher"); + } + + #[test] + fn get_tool_returns_the_projected_attr() { + let (server, _audit) = server(registry(), false); + let attr = server.get_tool("get_config").expect("工具存在"); + assert_eq!(attr.name, "get_config"); + assert_eq!( + Value::Object((*attr.input_schema).clone())["type"], + json!("object") + ); + assert!(server.get_tool("nope").is_none()); + } +} diff --git a/crates/agent/src/port.rs b/crates/agent/src/port.rs new file mode 100644 index 0000000..c95c3e1 --- /dev/null +++ b/crates/agent/src/port.rs @@ -0,0 +1,315 @@ +//! 端口层(port):agent 与 `ull-core` 各模块之间**唯一**的接触面。 +//! +//! 为什么不直接 `use ull_core::*`:M12(setting)/ M14(game)/ M15(diagnose)/ M18(launch) +//! 与本 crate 并行开发,直接依赖会把 agent 的编译期绑死在别人的 API 日期上。这里用 +//! **依赖倒置**——agent 只声明自己需要的形状(本文件的 trait + 结构体),由 `crates/app` +//! 在装配处用 `ull-core` 的实现去满足(`#![forbid(unsafe_code)]` 的纯适配层)。 +//! +//! | 端口 | 提供的工具 | 对方模块 / 数据源 | +//! |------|-----------|------------------| +//! | [`SettingPort`] | `get_config` / `set_config` | M12 · `config/*.json` schema | +//! | [`GameSessionPort`] | `list_game_sessions` / `read_game_log` | M18 · `agent/sessions/game/{index,}.jsonl` | +//! | [`DiagnosePort`] | `diagnose_crash` | M15 · 规则引擎 | +//! +//! 形状依据(以对方文件为准,改字段先改对方文件): +//! - `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5(事件行 + 索引行) +//! - `DevDocs/SubAgentsTalk/agent-ToolSpec约定.md`(本 crate 对外的对齐说明) +//! +//! 错误一律用 `Result`:错误文本会**原样进工具结果**给模型与用户看 +//! (rmcp 的 `CallToolResult::error` 同理——协议级错误不给模型看,见 research/02 §3.2)。 +//! 因此端口实现写中文、面向用户、可执行(「配置键 `x` 不存在」而不是 `ENOENT`)。 + +use std::collections::BTreeMap; +use std::sync::Arc; + +use serde::Serialize; +use serde_json::Value; + +use crate::confirm::Sensitivity; + +pub mod game; + +pub use game::{ + AccountPort, AccountSummary, GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus, + VersionSummary, +}; + +/// 端口调用结果的错误文本(面向用户可读,直接进工具结果)。 +pub type PortResult = Result; + +/// agent 需要的全部端口。装配处(`crates/app` / `ull agent serve`)用它一次性注入。 +#[derive(Clone)] +pub struct Ports { + /// 配置读写(M12)。 + pub setting: Arc, + /// 游戏会话与日志(M18)。 + pub sessions: Arc, + /// 崩溃规则引擎(M15)。 + pub diagnose: Arc, + /// 实例 / 版本 / Java(M14 + M33)。 + pub game: Arc, + /// 账户元数据(M31,**只有公开字段**,凭据永不出口)。 + pub accounts: Arc, +} + +// ───────────────────────────── 配置(M12) ───────────────────────────── + +/// AI 可写白名单里的一个键,带**敏感分级**(02 文档 §3.1 的「分级确认」依据)。 +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct SettingKey { + /// 点分路径,如 `download.concurrency`、`java.selected_path`(键名以 M12 的 schema 为准)。 + pub path: String, + /// 敏感等级(M12 `Low/Medium/High`):只有 `High` 需要用户确认。 + pub sensitivity: Sensitivity, + /// 值类型(`string` / `integer` / `boolean` / `enum` / `json`),用于错误提示。 + pub value_type: String, + /// 一句话说明,同时进 `get_config` 概要与模型上下文。 + pub description: String, +} + +/// 一次成功的写入,带旧值即「diff」(research/02 §6.4 的 dry-run 落点)。 +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct SettingChange { + /// 被修改的键路径。 + pub path: String, + /// 修改前的值;键此前不存在时为 `None`。 + pub old: Option, + /// 写入后的值。 + pub new: Value, +} + +/// 配置端口(M12 实现,`ull-core::setting` 的 schema API 直译)。 +/// +/// agent 侧只做**白名单 + 敏感分级 + 确认编排**,值本身的范围/类型校验归 setting 模块所有 +/// (单一真相源:配置 schema 在 M12,agent 不复制一份)。 +pub trait SettingPort: Send + Sync { + /// 全量概要(`get_config()` 无参时返回):键 → 当前值的扁平表,敏感键已脱敏。 + fn summary(&self) -> Value; + /// 读单个键。键不存在时返回人类可读错误。 + /// + /// **凭据契约**:`writable=false` 的凭据类键(`ai.api_key` 等)必须**拒绝或掩码**, + /// 不得返回明文——agent 侧还有一道 `redact::is_sensitive_key` 守卫,但端口是数据的 + /// 第一道闸(模型上下文与对话历史都会带上这里的返回值)。 + fn get(&self, path: &str) -> PortResult; + /// 写单个键(值校验由实现方负责),返回 diff。 + fn set(&self, path: &str, value: &Value) -> PortResult; + /// **AI 可写白名单**(含敏感分级)。不在表里的键 agent 一律拒绝。 + fn writable_keys(&self) -> Vec; + /// 配置 schema 的给 AI 版本(`get_config_schema` 工具):只含 AI 可写键与 + /// `x-ai-sensitivity` 标注,凭据类键天然不在其中(M12 `SettingSchema::to_json_schema`)。 + fn schema(&self) -> Value; +} + +// ──────────────────────── 游戏会话与日志(M18) ──────────────────────── + +/// `index.jsonl` 一行 → 一个会话摘要(同 id 多行由实现方取最后一行)。 +/// +/// 字段对齐 `launch-接口约定.md` §5;对方新增字段时这里补 `Option`,不破坏旧实现。 +#[derive(Debug, Clone, Default, Serialize, PartialEq)] +pub struct GameSessionSummary { + /// 会话 id,格式 `YYYYMMDDTHHMMSS-<6 位 hex>`(UTC)。 + pub session_id: String, + /// 实例 id。 + pub instance_id: Option, + /// 实例显示名。 + pub instance_name: Option, + /// MC 版本。 + pub game_version: Option, + /// 启动时间(RFC3339 UTC)。 + pub started_at: Option, + /// 结束时间;会话未结束时为 `None`。 + pub ended_at: Option, + /// 时长毫秒。 + pub duration_ms: u64, + /// 退出码;未正常结束时可能缺失。 + pub exit_code: Option, + /// `exit_type`:`normal|application_error|jvm_error|sigkill|sigterm|killed|interrupted`。 + pub exit_type: Option, + /// exit 0 但疑似静默失败(D8 升级 1)。 + pub silent_failure: bool, + /// 是否检测到游戏窗口。 + pub window_detected: bool, + /// 事件条数。 + pub event_count: u64, + /// 是否发现 crash-report / hs_err。 + pub crash_found: bool, + /// 规则引擎首选结论 id(`diagnosis` 事件的 top candidate)。 + pub diagnosis_top: Option, +} + +/// `read_game_log` 的查询条件:**按事件 seq 切片**,不是按行号 +/// (stdout/stderr 是一行一事件,但同一条日志行可能被切成多块,见 launch §1)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LogQuery { + /// 起始 `seq`(含)。 + pub from_seq: Option, + /// 结束 `seq`(含)。 + pub to_seq: Option, + /// 只要这些 `kind`(空 = 全部)。未知 kind 由实现方忽略而不是报错(前向兼容)。 + pub kinds: Vec, + /// 最多返回多少条事件(工具层已钳到 [`crate::tools::session::MAX_LOG_EVENTS`])。 + pub max_events: usize, +} + +/// 一条事件行(`{v,seq,ts,t_ms,kind,session_id,payload}`),落盘时已脱敏。 +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct LogEvent { + /// 会话内单调递增序号。 + pub seq: u64, + /// RFC3339 UTC。 + pub ts: String, + /// 距会话开始的毫秒数。 + pub t_ms: u64, + /// 事件类型(`stdout|stderr|exit|crash_found|diagnosis|…`)。 + pub kind: String, + /// 事件负载,原样透传(已脱敏)。 + pub payload: Value, +} + +/// 一页日志事件 + 全量统计(模型拿到摘要就不会盲目翻页,防 token 爆炸)。 +#[derive(Debug, Clone, Default, Serialize, PartialEq)] +pub struct LogPage { + /// 会话 id。 + pub session_id: String, + /// 文件内事件总数。 + pub total_events: u64, + /// 命中过滤条件的事件数。 + pub matched_events: u64, + /// 是否因 `max_events` 截断。 + pub truncated: bool, + /// 全量 kind 计数(不受过滤影响)。 + pub kind_counts: BTreeMap, + /// 首事件时间。 + pub first_ts: Option, + /// 末事件时间。 + pub last_ts: Option, + /// 本次返回的事件(已按 seq 升序)。 + pub events: Vec, +} + +/// 会话端口(M18 实现,`ull-core::launch::session_index` + `session` 的读侧)。 +/// +/// 读取**必须容忍最后一行不完整**(会话被 kill 时 jsonl 尾部可能截断)——这是实现方义务, +/// 由 `launch-接口约定.md` §1 规定,agent 只消费结果。 +pub trait GameSessionPort: Send + Sync { + /// 按 `started_at` 倒序、按 `session_id` 去重后的会话列表;`instance_id` 非空时过滤。 + fn list_sessions( + &self, + limit: usize, + instance_id: Option<&str>, + ) -> PortResult>; + /// 按 [`LogQuery`] 切片读取一个会话的事件流。 + fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult; +} + +// ───────────────────────── 崩溃诊断(M15) ───────────────────────── + +/// 六维修复动作(M15 `diagnose::rules::FixActionKind`,internally-tagged enum, +/// YAML 里写 `kind:`)。序列化名与 M15 逐字相同:`change_java|adjust_memory| +/// change_graphics|remove_mod|reinstall|seek_help`。 +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FixActionKind { + /// 换 Java(大版本上下界/指定实现)。 + ChangeJava, + /// 调 Xmx/Xms、64 位 Java、系统虚拟内存。 + AdjustMemory, + /// 驱动/渲染器/分辨率/覆盖层。 + ChangeGraphics, + /// 删/禁/换 mod。 + RemoveMod, + /// 重装/修组件(java|game_jar|loader|natives|libraries|assets|mods…)。 + Reinstall, + /// 导出诊断包 / 需要更多信息。 + SeekHelp, +} + +/// 一条修复建议(M15 的 `MaterializedFixAction { kind, tool, args }` + 一句人话)。 +/// +/// `tool` 是 M15 给的**语义动作 id**(`java.set` / `memory.set` / …)。P0 没有这些细分工具, +/// 所以 agent 侧按 [`FixActionKind`] 桥接到现有工具(见 [`crate::tools::diagnose`]), +/// `tool` 只原样透传给模型与审计,不用于分发。 +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct FixAction { + /// 六维动作类别。 + pub kind: FixActionKind, + /// 语义动作 id(如 `memory.set`);P1 落对应工具后可直接当工具名。 + pub tool: String, + /// 一句话建议(可为空,agent 会按 `kind` 补兜底文案)。 + pub label: String, + /// 已做模板替换与单位换算的参数(M15 `materialize()` 的产物)。 + pub args: std::collections::BTreeMap, +} + +/// 一条诊断候选(M15 `DiagnosisRecord::candidates[]`)。 +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct DiagnosisCandidate { + /// 规则 id(如 `jvm.32bit`)。 + pub id: String, + /// `critical|error|warn|info`。 + pub severity: String, + /// 分组(`jvm|memory|graphics|integrity|mod|…`)。 + pub category: String, + /// 置信度 0.0~1.0。 + pub confidence: f32, + /// i18n key(如 `diagnose.cause.jvm.32bit`)。 + pub message_key: String, + /// **渲染后**的人类可读结论(M15 保证非空;agent 侧只在极旧数据上回落 `message_key`)。 + pub message: String, + /// 更细的处理建议(M15 `detail`,可能为空)。 + pub detail: String, + /// 证据行(已脱敏)。 + pub evidence: Vec, + /// 该候选的修复动作。 + pub fix_actions: Vec, +} + +/// 退出摘要(M15 `DiagnosisRecord::exit`,与 launch 的 `exit` 事件同源)。 +#[derive(Debug, Clone, Default, Serialize, PartialEq)] +pub struct ExitSummary { + /// 退出码。 + pub code: Option, + /// `normal|application_error|jvm_error|sigkill|sigterm|killed|interrupted`。 + pub exit_type: Option, + /// exit 0 但疑似静默失败。 + pub silent_failure: bool, +} + +/// 规则引擎结论:**这就是给 LLM 的全部输入**(不是裸日志)。 +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct CrashDiagnosis { + /// 会话 id。 + pub session_id: String, + /// `rules|ai`(谁产出的)。 + pub source: String, + /// 规则拿不准、需要更多信息时置 true(此时才值得让 LLM 深挖)。 + pub needs_more_info: bool, + /// 主因候选,`candidates[0]` 即首因(M15 已按 priority→confidence 排序)。 + pub candidates: Vec, + /// 被抑制的次要候选。 + pub secondary: Vec, + /// 会话级关键片段(`crash_found.excerpt` / `hs_err` 摘要,已脱敏)。 + pub evidence: Vec, + /// 退出摘要。 + pub exit: ExitSummary, +} + +impl CrashDiagnosis { + /// 首选候选(主因)。 + #[must_use] + pub fn top(&self) -> Option<&DiagnosisCandidate> { + self.candidates.first() + } + + /// 最高置信度(没有候选时为 0)。 + #[must_use] + pub fn top_confidence(&self) -> f32 { + self.top().map_or(0.0, |c| c.confidence) + } +} + +/// 诊断端口(M15 实现)。agent 只做「调规则引擎 → 把结论 + 片段交给 LLM」。 +pub trait DiagnosePort: Send + Sync { + /// 对一个会话跑规则引擎。会话不存在/没有日志时返回人类可读错误。 + fn diagnose(&self, session_id: &str) -> PortResult; +} diff --git a/crates/agent/src/port/game.rs b/crates/agent/src/port/game.rs new file mode 100644 index 0000000..b5b312c --- /dev/null +++ b/crates/agent/src/port/game.rs @@ -0,0 +1,152 @@ +//! 实例 / 版本 / Java / 账户四个读侧端口(M39 的只读工具集用)。 +//! +//! 与 [`super`] 的既有三个端口同形:trait 由 agent 声明,实现在装配层用 `unlucky-core` 填充。 +//! 拆分出来的原因是 `port.rs` 挨着 400 行硬上限(`guards.yml` 的 file-size 门禁)—— +//! 「会话与日志」「崩溃诊断」留在父模块,「游戏与账户」在这里,语义上也是一刀干净的分界。 +//! +//! **凭据红线**(D9 / 02 文档 §3.1):账户端口只露 [`AccountSummary`] 的公开字段。 +//! `Credentials`(access token / 刷新令牌 / authlib 密码)在 core 的 Secret 体系里, +//! 根本不出现在本文件的类型里,因此不可能被工具结果带出去。 + +use serde::Serialize; + +use super::PortResult; + +// ───────────────────────── 实例 / 版本(M14) ───────────────────────── + +/// 一个实例(`profiles.json` 一行)的摘要:模型只需要「能定位、能判断载荷在不在」。 +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct InstanceSummary { + /// 实例 id。 + pub id: String, + /// 显示名。 + pub name: String, + /// 版本 id(如 `1.21.1` / `fabric-loader-0.16.9-1.21.1`)。 + pub version_id: String, + /// 该版本的载荷是否真的在 `versions/` 下(`false` = 装了档案但载荷缺失)。 + pub version_present: bool, + /// 独立游戏目录(未覆盖时为 `None`)。 + pub game_dir: Option, + /// 实例级 Java 覆盖路径(未覆盖时为 `None`)。 + pub java_path: Option, + /// 上次启动时刻(Unix 毫秒;从未启动为 `None`)。 + pub last_played_at: Option, +} + +/// 已安装版本的摘要(`versions//` 的存在性 + 可读的元信息)。 +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct VersionSummary { + /// 版本 id。 + pub id: String, + /// 版本 JSON 里声明的 `type`(`release` / `snapshot` / 加载器自报),读不到为 `None`。 + pub kind: Option, + /// 被几个实例引用。 + pub instance_count: usize, +} + +// ──────────────────────────── Java(M33) ──────────────────────────── + +/// 一个探测到的 Java 运行时。 +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct JavaRuntimeSummary { + /// `java` 可执行文件路径。 + pub binary: String, + /// 完整版本串(`21.0.2` / `1.8.0_402`)。 + pub version: Option, + /// 主版本号(8 / 17 / 21)。 + pub major: Option, + /// 发行方(temurin / oracle / microsoft / zulu …)。 + pub vendor: Option, + /// 来源类别(`instance` / `managed` / `env` / `system`)。 + pub origin: String, +} + +/// Java 现状(`get_java_status`)。 +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct JavaStatus { + /// 默认堆内存(MB,`java.default_memory_mb`)。 + pub default_memory_mb: Option, + /// 托管 JDK 的扫描根。 + pub managed_roots: Vec, + /// 探测到的候选(已去重,优先级从高到低)。 + pub candidates: Vec, +} + +// ──────────────────────────── 账户(M31) ──────────────────────────── + +/// 账户的**公开字段**摘要。凭据类字段刻意不存在(凭据红线)。 +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +pub struct AccountSummary { + /// 条目 id。 + pub id: String, + /// 轨道:`offline` / `microsoft` / `authlib`。 + pub kind: String, + /// Minecraft 档案 UUID(32 位无连字符)。 + pub profile_id: String, + /// 玩家名(可能为空串 = 有档案但名字待刷新)。 + pub profile_name: String, + /// 登录名(仅 authlib 轨有值)。 + pub login_name: Option, + /// 创建时刻(Unix 毫秒)。 + pub created_at_ms: u64, +} + +/// 游戏资产端口(M14 实例/版本 + M33 Java 探测)。 +pub trait GamePort: Send + Sync { + /// 全部实例(按上次启动时间倒序)。 + fn list_instances(&self) -> PortResult>; + /// 已安装版本清单。 + fn list_versions(&self) -> PortResult>; + /// Java 现状(候选 + 托管根 + 默认内存)。 + fn java_status(&self) -> PortResult; +} + +/// 账户端口(M31)。实现方只允许读 `AccountStore::accounts()`(`AccountMeta`), +/// **不得**调用 `credentials()`——那是凭据出口,agent 侧没有对应字段可以承接。 +pub trait AccountPort: Send + Sync { + /// 账户清单(公开字段)。 + fn list_accounts(&self) -> PortResult>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn account_summary_has_no_credential_fields() { + // 把摘要序列化成 JSON:任何 token / password / secret 形态的键都不许出现。 + // 这是凭据红线的**可执行断言**——将来有人往结构体里加字段,这条测试先炸。 + let summary = AccountSummary { + id: "a1".to_string(), + kind: "offline".to_string(), + profile_id: "0123456789abcdef0123456789abcdef".to_string(), + profile_name: "Steve".to_string(), + login_name: Some("steve@example.com".to_string()), + created_at_ms: 1_793_304_753_123, + }; + let json = serde_json::to_string(&summary).expect("可序列化"); + for forbidden in ["token", "password", "secret", "credential", "refresh"] { + assert!( + !json.to_lowercase().contains(forbidden), + "账户摘要不得包含 `{forbidden}`:{json}" + ); + } + } + + #[test] + fn java_status_round_trips() { + let status = JavaStatus { + default_memory_mb: Some(2048), + managed_roots: vec!["/data/java-managed".to_string()], + candidates: vec![JavaRuntimeSummary { + binary: "/jdk/bin/java".to_string(), + version: Some("21.0.2".to_string()), + major: Some(21), + vendor: Some("temurin".to_string()), + origin: "managed".to_string(), + }], + }; + assert_eq!(status.candidates.len(), 1); + assert_eq!(status.candidates[0].major, Some(21)); + } +} diff --git a/crates/agent/src/redact.rs b/crates/agent/src/redact.rs new file mode 100644 index 0000000..5ec81d3 --- /dev/null +++ b/crates/agent/src/redact.rs @@ -0,0 +1,217 @@ +//! 脱敏:审计日志的参数、以及日志工具返回文本的**第二道**防线。 +//! +//! 第一道在 launch 落盘时(`launch-接口约定.md` §4:`` / `` / +//! ``),所以这里不重复实现那套;本模块只兜两件事: +//! 1. 审计日志里的参数可能含 API key(`set_config` 能改 AI 端点)→ 按键名掩码; +//! 2. 日志正文万一含用户主目录绝对路径(旧会话、第三方 mod 输出)→ 掩码用户名段。 +//! +//! 幂等:对已脱敏文本再跑一遍结果不变(`` / `***` 原样保留)。 + +use serde_json::{Map, Value}; + +/// 掩码占位。 +pub const MASK: &str = "***"; + +/// 键名是否敏感(不区分大小写,含子串即算)。 +/// +/// 覆盖:`api_key` / `apikey` / `accessToken` / `session_token` / `password` / +/// `secret` / `authorization` / `credential`。 +#[must_use] +pub fn is_sensitive_key(key: &str) -> bool { + const NEEDLES: [&str; 8] = [ + "api_key", + "apikey", + "token", + "password", + "passwd", + "secret", + "authorization", + "credential", + ]; + let lower = key.to_ascii_lowercase(); + NEEDLES.iter().any(|n| lower.contains(n)) +} + +/// 审计用:递归把敏感键的值换成 [`MASK`](保留结构,读审计的人仍能看出改的是哪个键)。 +/// +/// 除了「键名本身敏感」之外,还认 `set_config {path, value}` 这种**值随路径敏感**的形状: +/// 当同级的 `path` 是敏感键名(`ai.api_key`)时,`value`/`old`/`new` 一律掩码—— +/// 否则审计日志会把 API key 原文写进磁盘。 +#[must_use] +pub fn redact_args(value: &Value) -> Value { + match value { + Value::Object(map) => { + let sensitive_path = map + .get("path") + .and_then(Value::as_str) + .is_some_and(is_sensitive_key); + let mut out = Map::with_capacity(map.len()); + for (k, v) in map { + let mask = is_sensitive_key(k) + || (sensitive_path && matches!(k.as_str(), "value" | "old" | "new")); + let masked = if mask { + Value::String(MASK.to_string()) + } else { + redact_args(v) + }; + out.insert(k.clone(), masked); + } + Value::Object(out) + } + Value::Array(items) => Value::Array(items.iter().map(redact_args).collect()), + other => other.clone(), + } +} + +/// 文本脱敏:用户主目录段 + `Bearer` 令牌。已脱敏文本原样返回(幂等)。 +#[must_use] +pub fn redact_text(text: &str) -> String { + let masked = mask_user_dirs(text); + mask_bearer(&masked) +} + +/// 任意 JSON 的每个字符串都过 [`redact_text`](工具结果出口的第二道防线)。 +/// +/// 与 [`redact_args`] 的分工:那个按键名掩码(审计参数),这个按**内容**掩码 +/// (日志正文、账户展示名之类可能夹带主目录/Bearer 的字符串)。 +#[must_use] +pub fn redact_texts(value: &Value) -> Value { + match value { + Value::String(text) => Value::String(redact_text(text)), + Value::Array(items) => Value::Array(items.iter().map(redact_texts).collect()), + Value::Object(map) => Value::Object( + map.iter() + .map(|(key, child)| (key.clone(), redact_texts(child))) + .collect(), + ), + other => other.clone(), + } +} + +/// `C:\Users\<名>` / `/home/<名>` / `/Users/<名>` → 用户名段替换为 [`MASK`]。 +fn mask_user_dirs(text: &str) -> String { + let lower = text.to_ascii_lowercase(); + let mut out = String::with_capacity(text.len()); + let mut cursor = 0usize; + while cursor < text.len() { + let Some((start, marker_len)) = find_marker(&lower, cursor) else { + out.push_str(&text[cursor..]); + break; + }; + out.push_str(&text[cursor..start + marker_len]); + let name_start = start + marker_len; + let name_end = segment_end(text, name_start); + let segment = &text[name_start..name_end]; + if segment.is_empty() || segment == MASK || segment.eq_ignore_ascii_case("") { + out.push_str(segment); + } else { + out.push_str(MASK); + } + cursor = name_end; + } + out +} + +/// 找最靠前的用户目录标记,返回 `(字节位置, 标记长度)`。标记须与 `lower` 同源。 +fn find_marker(lower: &str, from: usize) -> Option<(usize, usize)> { + const MARKERS: [&str; 3] = ["c:\\users\\", "/home/", "/users/"]; + MARKERS + .iter() + .filter_map(|m| lower[from..].find(m).map(|p| (from + p, m.len()))) + .min_by_key(|(start, _)| *start) +} + +/// 从 `from` 起取到下一个路径分隔符/空白/引号为止。 +fn segment_end(text: &str, from: usize) -> usize { + const STOPS: [char; 9] = ['\\', '/', '"', '\'', ' ', '\t', '\n', '\r', ',']; + text[from..] + .find(STOPS) + .map_or(text.len(), |offset| from + offset) +} + +/// `Bearer ` → `Bearer ***`。 +fn mask_bearer(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut cursor = 0usize; + while let Some(pos) = text[cursor..].find("Bearer ") { + let token_start = cursor + pos + "Bearer ".len(); + let token_end = segment_end(text, token_start); + out.push_str(&text[cursor..token_start]); + if token_end > token_start { + out.push_str(MASK); + } + cursor = token_end; + } + out.push_str(&text[cursor..]); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn masks_sensitive_arg_keys_only() { + let args = json!({ + "path": "launcher.theme", + "value": "light", + "nested": { "accessToken": "abc", "keep": 1 }, + "list": [{ "password": "p" }] + }); + let red = redact_args(&args); + // 非敏感路径:值原样保留(审计要能看出改成了什么) + assert_eq!(red["value"], json!("light")); + assert_eq!(red["path"], json!("launcher.theme")); + // 键名敏感:值掩码 + assert_eq!(red["nested"]["accessToken"], json!(MASK)); + assert_eq!(red["nested"]["keep"], json!(1)); + assert_eq!(red["list"][0]["password"], json!(MASK)); + } + + #[test] + fn masks_value_when_path_itself_is_sensitive() { + // `set_config {path: "ai.api_key", value: "sk-…"}` 是最容易漏的泄漏点 + let args = json!({ "path": "ai.api_key", "value": "sk-super-secret" }); + let red = redact_args(&args); + assert_eq!(red["value"], json!(MASK)); + let rendered = red.to_string(); + assert!(!rendered.contains("sk-super-secret"), "{rendered}"); + } + + #[test] + fn masks_windows_and_posix_home() { + assert_eq!( + redact_text(r"C:\Users\aska\.minecraft\logs\latest.log"), + r"C:\Users\***\.minecraft\logs\latest.log" + ); + assert_eq!( + redact_text("/home/aska/.minecraft/logs"), + "/home/***/.minecraft/logs" + ); + } + + #[test] + fn keeps_already_redacted_and_is_idempotent() { + let once = redact_text(r"C:\Users\aska\game\log.txt"); + assert_eq!(once, r"C:\Users\***\game\log.txt"); + assert_eq!(redact_text(&once), once); + assert_eq!(redact_text("/.minecraft"), "/.minecraft"); + } + + #[test] + fn masks_bearer_token() { + assert_eq!( + redact_text("Authorization: Bearer eyJhbGciOi.payload.sig"), + "Authorization: Bearer ***" + ); + } + + #[test] + fn detects_sensitive_key_names() { + assert!(is_sensitive_key("ai.api_key")); + assert!(is_sensitive_key("AccessToken")); + assert!(is_sensitive_key("proxy.password")); + assert!(!is_sensitive_key("download.concurrency")); + } +} diff --git a/crates/agent/src/registry/mod.rs b/crates/agent/src/registry/mod.rs new file mode 100644 index 0000000..98bb426 --- /dev/null +++ b/crates/agent/src/registry/mod.rs @@ -0,0 +1,260 @@ +//! 工具注册表:整个 agent 的**唯一真相源**。 +//! +//! 一个 `ToolSpec`(名称 + 描述 + serde JSON Schema)投影两份: +//! - 内置客户端的 rig `DynamicTool` 视图; +//! - 对外 MCP server 的 rmcp `ToolRouter` 视图。 +//! +//! 两份投影必须一致,用契约测试固定(同 name / 同 description / 同 schema)。 +//! 设计依据:`DevDocs/PlanDoc/02-ai-agent-design.md` §1。 +//! +//! 工具 v1 清单(M16 起,M39 扩到十个):`get_config` / `set_config` / `get_config_schema` / +//! `list_instances` / `list_versions` / `get_java_status` / `list_accounts` / +//! `list_game_sessions` / `read_game_log` / `diagnose_crash`。 +//! +//! 安全策略随工具走:敏感键分级确认、YOLO 模式标记、审计留痕(02 §3.1 / §4b)。 +//! +//! ## 落地形态 +//! +//! ```text +//! registry(Vec) +//! ├─ definitions() → rig `DynamicTool::new` (内置 agent,`brain::rig`) +//! └─ definitions() → rmcp `ToolRoute::new_dyn` (外部 MCP,`mcp::projection`) +//! ``` +//! +//! 防漂移的关键一招(research/02 §6.2 建议进 CI):**契约测试**遍历注册表,断言两侧 +//! 投影拿到的 `(name, description, schema)` 逐字节相等。因为两侧都只吃 +//! [`ToolDefinition`],这条约束在本 crate 里天然成立——测试只需要锁住 +//! 「definition 是纯投影」+「schema 根必须是 object」+「名字不重复」。 +//! +//! 注册表**不持有任何端口**:工具闭包在构造时就捕获了 `Arc`,注册表只是索引。 + +pub mod schema; +pub mod spec; +pub mod validate; + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::Arc; + +pub use spec::{ + Access, Confirmation, ToolCtx, ToolDefinition, ToolError, ToolFn, ToolOutput, ToolSpec, +}; + +/// 注册失败原因。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RegistryError { + /// 工具名为空或重复。 + DuplicateName(String), + /// schema 不合法(根必须是 `type: object`,否则 MCP 侧直接拒收)。 + InvalidSchema { + /// 工具名。 + tool: String, + /// 原因。 + reason: String, + }, + /// 说明为空(模型靠 description 选工具,空说明等于没有这个工具)。 + EmptyDescription(String), +} + +impl fmt::Display for RegistryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateName(name) => write!(f, "工具名重复或为空:`{name}`"), + Self::InvalidSchema { tool, reason } => { + write!(f, "工具 `{tool}` 的 schema 不合法:{reason}") + } + Self::EmptyDescription(name) => write!(f, "工具 `{name}` 缺少说明"), + } + } +} + +impl std::error::Error for RegistryError {} + +/// 工具注册表。 +#[derive(Debug, Default, Clone)] +pub struct ToolRegistry { + specs: Vec>, + index: BTreeMap, +} + +impl ToolRegistry { + /// 空注册表。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 注册一个工具。注册即校验名/schema/说明,**运行时不再做二次判断**。 + /// + /// # Errors + /// 名字重复或为空、schema 根不是 object、说明为空时返回 [`RegistryError`]。 + pub fn register(&mut self, spec: ToolSpec) -> Result<(), RegistryError> { + let name = spec.name.trim(); + if name.is_empty() || self.index.contains_key(name) { + return Err(RegistryError::DuplicateName(name.to_string())); + } + if spec.description.trim().is_empty() { + return Err(RegistryError::EmptyDescription(name.to_string())); + } + if spec.input_schema.get("type").and_then(|t| t.as_str()) != Some("object") { + return Err(RegistryError::InvalidSchema { + tool: name.to_string(), + reason: "根类型必须是 object(rig 与 rmcp 的入参都是对象 schema)".to_string(), + }); + } + self.index.insert(name.to_string(), self.specs.len()); + self.specs.push(Arc::new(spec)); + Ok(()) + } + + /// 按名查找。 + #[must_use] + pub fn get(&self, name: &str) -> Option<&Arc> { + self.index.get(name).and_then(|i| self.specs.get(*i)) + } + + /// 全部工具(按注册顺序,模型看到的顺序稳定)。 + #[must_use] + pub fn specs(&self) -> &[Arc] { + &self.specs + } + + /// 工具名列表(错误提示/审计用)。 + #[must_use] + pub fn names(&self) -> Vec<&str> { + self.specs.iter().map(|s| s.name).collect() + } + + /// 工具数量。 + #[must_use] + pub fn len(&self) -> usize { + self.specs.len() + } + + /// 是否没有工具。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.specs.is_empty() + } + + /// **投影 1/2 的公共载荷**:`(name, description, input_schema)`。 + #[must_use] + pub fn definitions(&self) -> Vec { + self.specs.iter().map(|s| s.definition()).collect() + } + + /// 只读子集(P1 的 `--readonly` 外部 MCP 模式用)。 + #[must_use] + pub fn read_only_definitions(&self) -> Vec { + self.specs + .iter() + .filter(|s| s.access == Access::ReadOnly) + .map(|s| s.definition()) + .collect() + } + + /// **只读模式**的注册表副本(外部 MCP 默认形态)。 + /// + /// 返回的是「物理上不含写工具」的新注册表:`tools/list` 里看不到 `set_config`, + /// 硬调它也会在编排层得到 `unknown_tool`(并留一条审计),而不是静默执行。 + /// 这比「列表过滤 + 调用拦截」两套逻辑更不容易漏——只有一份子集,两侧天然一致。 + #[must_use] + pub fn read_only_subset(&self) -> Self { + let mut subset = Self::new(); + for spec in self.specs.iter().filter(|s| s.access == Access::ReadOnly) { + let name = spec.name.to_string(); + if subset.index.insert(name, subset.specs.len()).is_none() { + subset.specs.push(Arc::clone(spec)); + } + } + subset + } + + /// 名 → access(装配处检查白名单/只读模式)。 + #[must_use] + pub fn access_of(&self, name: &str) -> Option { + self.get(name).map(|s| s.access) + } + + /// 去重后的名字集合(契约测试用)。 + #[must_use] + pub fn unique_names(&self) -> BTreeSet<&str> { + self.specs.iter().map(|s| s.name).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::schema::{non_empty_string, object}; + use serde_json::{json, Value}; + + fn echo(name: &'static str) -> ToolSpec { + ToolSpec::read_only( + name, + "回显", + object(&[("text", non_empty_string("文本"))], &["text"]), + Arc::new(|_ctx: &ToolCtx, args: &Value| Ok(ToolOutput::ok(args.clone(), "echo"))), + ) + } + + #[test] + fn register_and_lookup() { + let mut reg = ToolRegistry::new(); + reg.register(echo("echo")).unwrap(); + assert_eq!(reg.len(), 1); + assert_eq!(reg.names(), vec!["echo"]); + assert!(reg.get("echo").is_some()); + assert!(reg.get("nope").is_none()); + assert_eq!(reg.access_of("echo"), Some(Access::ReadOnly)); + } + + #[test] + fn rejects_duplicate_empty_and_non_object_schema() { + let mut reg = ToolRegistry::new(); + reg.register(echo("echo")).unwrap(); + assert_eq!( + reg.register(echo("echo")), + Err(RegistryError::DuplicateName("echo".into())) + ); + + let bad = ToolSpec::read_only( + "bad", + "x", + json!({ "type": "array" }), + Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "x"))), + ); + assert!(matches!( + reg.register(bad), + Err(RegistryError::InvalidSchema { .. }) + )); + + let no_desc = ToolSpec::read_only( + "nodesc", + " ", + json!({ "type": "object" }), + Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "x"))), + ); + assert!(matches!( + reg.register(no_desc), + Err(RegistryError::EmptyDescription(_)) + )); + } + + #[test] + fn definitions_are_stable_and_filterable() { + let mut reg = ToolRegistry::new(); + reg.register(echo("a")).unwrap(); + reg.register(ToolSpec::mutating( + "b", + "写", + json!({ "type": "object" }), + Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "b"))), + )) + .unwrap(); + assert_eq!(reg.definitions().len(), 2); + assert_eq!(reg.read_only_definitions().len(), 1); + assert_eq!(reg.definitions()[0].name, "a"); + assert_eq!(reg.unique_names().len(), 2); + } +} diff --git a/crates/agent/src/registry/schema.rs b/crates/agent/src/registry/schema.rs new file mode 100644 index 0000000..1fe57dc --- /dev/null +++ b/crates/agent/src/registry/schema.rs @@ -0,0 +1,116 @@ +//! JSON Schema 构造helper。 +//! +//! 工具参数 schema 一律**手写 JSON**(research/02 §6.2:`rig` 的 `DynamicTool::new` 与 +//! rmcp 的 `ToolRoute::new_dyn` 吃的都是裸 `serde_json::Value`),所以不需要 schemars 派生。 +//! 手写的好处是同一份 schema 直接喂两方,坏处是啰嗦——这个模块把啰嗦收敛到几个函数。 +//! +//! 生成的 schema 一律按 **JSON Schema 2020-12** 的写法(rmcp 支持该版本; +//! `additionalProperties: false` 让模型少给野参数,[`super::validate`] 会执行它)。 + +use serde_json::{json, Map, Value}; + +/// 对象 schema。`properties` 按给定顺序写入(JSON 对象保序,UI 展示稳定)。 +#[must_use] +pub fn object(properties: &[(&str, Value)], required: &[&str]) -> Value { + let mut props = Map::new(); + for (name, schema) in properties { + props.insert((*name).to_string(), schema.clone()); + } + json!({ + "type": "object", + "properties": Value::Object(props), + "required": required, + "additionalProperties": false, + }) +} + +/// 任意 JSON 值(不校验类型)。 +#[must_use] +pub fn any() -> Value { + json!({}) +} + +/// 字符串。 +#[must_use] +pub fn string(description: &str) -> Value { + json!({ "type": "string", "description": description }) +} + +/// 非空字符串(`minLength: 1`,省掉一堆「路径不能为空」的运行时判断)。 +#[must_use] +pub fn non_empty_string(description: &str) -> Value { + json!({ "type": "string", "description": description, "minLength": 1 }) +} + +/// 枚举字符串。 +#[must_use] +pub fn string_enum(values: &[&str], description: &str) -> Value { + json!({ "type": "string", "description": description, "enum": values }) +} + +/// 布尔。 +#[must_use] +pub fn boolean(description: &str) -> Value { + json!({ "type": "boolean", "description": description }) +} + +/// 带范围约束的整数。 +#[must_use] +pub fn integer(description: &str, minimum: i64, maximum: i64) -> Value { + json!({ + "type": "integer", + "description": description, + "minimum": minimum, + "maximum": maximum, + }) +} + +/// 字符串数组(可选条数上限)。 +#[must_use] +pub fn string_array(description: &str, max_items: u64) -> Value { + json!({ + "type": "array", + "description": description, + "items": { "type": "string" }, + "maxItems": max_items, + }) +} + +/// 枚举字符串数组(如 `kinds: ["stderr","exit"]`)。 +#[must_use] +pub fn enum_string_array(values: &[&str], description: &str, max_items: u64) -> Value { + json!({ + "type": "array", + "description": description, + "items": { "type": "string", "enum": values }, + "maxItems": max_items, + "uniqueItems": true, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::validate::validate; + + #[test] + fn object_records_required_and_forbids_extras() { + let schema = object( + &[("path", non_empty_string("键路径")), ("value", any())], + &["path", "value"], + ); + assert_eq!(schema["type"], json!("object")); + assert_eq!(schema["additionalProperties"], json!(false)); + assert_eq!(schema["required"], json!(["path", "value"])); + assert!(validate(&schema, &json!({ "path": "a", "value": 1 })).is_ok()); + } + + #[test] + fn integer_builder_carries_bounds() { + let schema = integer("条数", 1, 500); + assert_eq!(schema["minimum"], json!(1)); + assert_eq!(schema["maximum"], json!(500)); + assert!(validate(&schema, &json!(0)).is_err()); + assert!(validate(&schema, &json!(500)).is_ok()); + } +} diff --git a/crates/agent/src/registry/spec.rs b/crates/agent/src/registry/spec.rs new file mode 100644 index 0000000..4f03cb0 --- /dev/null +++ b/crates/agent/src/registry/spec.rs @@ -0,0 +1,262 @@ +//! 工具的**唯一定义类型**([`ToolSpec`])与它的两份投影载荷([`ToolDefinition`])。 +//! +//! 双投影的支点在 research/02 §6.2:`rig::tool::DynamicTool::new(name, description, +//! parameters: serde_json::Value, ..)` 与 `rmcp::ToolRoute::new_dyn(name, description, +//! schema, handler)` 的入参**都是裸 JSON Schema**。所以本 crate 只维护一份 +//! [`ToolSpec`],由 [`ToolSpec::definition`] 生成两侧共用的 `(name, description, schema)`。 +//! +//! 工具实现是**同步**的:端口调用全是本地文件/内存,没有网络 IO;异步只存在于 +//! provider 边界([`crate::brain::provider::ChatProvider`])。P1 若出现网络类工具 +//! (mod 搜索/版本安装)再升成 `Fn -> BoxFuture`,不影响调用方语义。 + +use std::fmt; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::confirm::Sensitivity; + +/// 工具的副作用等级(外部 MCP 只读模式、P1 的 `--readonly` 都按它筛)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Access { + /// 只读:可以给外部 agent、可以在无确认的情况下调用。 + ReadOnly, + /// 会改用户数据(写配置),受白名单 + 分级确认约束。 + Mutating, +} + +/// 工具定义的三元组:**双投影就投这个**。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolDefinition { + /// 工具名(模型可见,`snake_case`)。 + pub name: String, + /// 一句话说明(模型可见)。 + pub description: String, + /// 参数 JSON Schema(2020-12,根类型必须 `object`)。 + pub input_schema: Value, +} + +/// 工具调用上下文:编排层注入,工具只读。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolCtx { + /// agent 对话会话 id(审计用;与游戏 session_id 不是一回事)。 + pub agent_session_id: String, + /// 是否已获确认。**只有编排层能置 true**(YOLO 或用户点了允许), + /// 工具据此决定「先报价再执行」还是「直接执行」。 + pub confirmed: bool, +} + +/// 工具的返回。 +#[derive(Debug, Clone, PartialEq)] +pub struct ToolOutput { + /// 给模型/审计的结构化结果(编排层序列化成 JSON 字符串回灌)。 + pub content: Value, + /// 一句话摘要(UI 展示 + 审计 summary)。 + pub summary: String, + /// 非空表示「这次没执行,先要确认」([`crate::confirm::ConfirmRequest`] 的原料)。 + pub confirmation: Option, +} + +/// 待确认的操作详情(dry-run 的产物)。 +#[derive(Debug, Clone, PartialEq)] +pub struct Confirmation { + /// 一句话请求(「AI 请求把 X 从 A 改为 B」)。 + pub summary: String, + /// 结构化 diff(`{"path":…,"old":…,"new":…}`)。 + pub details: Value, + /// 敏感等级(决定 YOLO 能不能跳过)。 + pub sensitivity: Sensitivity, +} + +impl ToolOutput { + /// 成功返回。 + #[must_use] + pub fn ok(content: Value, summary: impl Into) -> Self { + Self { + content, + summary: summary.into(), + confirmation: None, + } + } + + /// 需要确认(本次**没有**产生副作用)。 + #[must_use] + pub fn needs_confirmation( + summary: impl Into, + details: Value, + sensitivity: Sensitivity, + ) -> Self { + let summary = summary.into(); + Self { + content: serde_json::json!({ + "status": "needs_confirmation", + "summary": summary, + }), + confirmation: Some(Confirmation { + summary: summary.clone(), + details, + sensitivity, + }), + summary, + } + } +} + +/// 工具错误。**全部面向用户可读**——它们会作为工具结果回到模型, +/// 由模型决定重试还是换方案(rmcp 的 `Ok(CallToolResult::error(..))` 同义, +/// 见 research/02 §3.2:协议级 `Err` 会被客户端吞掉,不能用来报业务错误)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToolError { + /// 参数语义不合法(schema 校验之外的规则,如路径不在白名单)。 + NotAllowed(String), + /// 找不到对象(会话/键/文件)。 + NotFound(String), + /// 端口(M12/M15/M18)报错,文本原样透传。 + Port(String), + /// 工具自身内部错误(不该发生)。 + Internal(String), +} + +impl ToolError { + /// 一句话文案(写日志/审计用)。 + #[must_use] + pub fn message(&self) -> &str { + match self { + Self::NotAllowed(m) | Self::NotFound(m) | Self::Port(m) | Self::Internal(m) => m, + } + } + + /// 错误码(审计与 UI 分类用)。 + #[must_use] + pub fn code(&self) -> &'static str { + match self { + Self::NotAllowed(_) => "not_allowed", + Self::NotFound(_) => "not_found", + Self::Port(_) => "port_error", + Self::Internal(_) => "internal", + } + } +} + +impl fmt::Display for ToolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.message()) + } +} + +impl std::error::Error for ToolError {} + +/// 工具处理函数。 +pub type ToolFn = Arc Result + Send + Sync>; + +/// 一个工具的完整定义 + 实现。 +#[derive(Clone)] +pub struct ToolSpec { + /// 工具名(`snake_case`,同时是 MCP 侧的工具名)。 + pub name: &'static str, + /// 说明。 + pub description: &'static str, + /// 参数 schema(根必须 object)。 + pub input_schema: Value, + /// 副作用等级。 + pub access: Access, + /// 实现。 + pub handler: ToolFn, +} + +impl fmt::Debug for ToolSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ToolSpec") + .field("name", &self.name) + .field("access", &self.access) + .finish_non_exhaustive() + } +} + +impl ToolSpec { + /// 建一个只读工具。 + #[must_use] + pub fn read_only( + name: &'static str, + description: &'static str, + input_schema: Value, + handler: ToolFn, + ) -> Self { + Self { + name, + description, + input_schema, + access: Access::ReadOnly, + handler, + } + } + + /// 建一个会改数据的工具。 + #[must_use] + pub fn mutating( + name: &'static str, + description: &'static str, + input_schema: Value, + handler: ToolFn, + ) -> Self { + Self { + name, + description, + input_schema, + access: Access::Mutating, + handler, + } + } + + /// 投影成两侧共用的 `(name, description, schema)`。 + #[must_use] + pub fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: self.name.to_string(), + description: self.description.to_string(), + input_schema: self.input_schema.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::schema::{non_empty_string, object}; + use serde_json::json; + + fn echo_handler() -> ToolFn { + Arc::new(|_ctx: &ToolCtx, args: &Value| Ok(ToolOutput::ok(args.clone(), "echo"))) + } + + #[test] + fn definition_is_a_pure_projection() { + let spec = ToolSpec::read_only( + "echo", + "回显", + object(&[("text", non_empty_string("文本"))], &["text"]), + echo_handler(), + ); + let def = spec.definition(); + assert_eq!(def.name, "echo"); + assert_eq!(def.description, "回显"); + assert_eq!(def.input_schema, spec.input_schema); + assert_eq!(spec.access, Access::ReadOnly); + } + + #[test] + fn confirmation_carries_details() { + let out = ToolOutput::needs_confirmation( + "AI 请求修改 x", + json!({ "path": "x" }), + Sensitivity::High, + ); + assert_eq!(out.content["status"], json!("needs_confirmation")); + let confirmation = out.confirmation.expect("必须有待确认详情"); + assert_eq!(confirmation.sensitivity, Sensitivity::High); + assert_eq!(confirmation.summary, out.summary); + assert_eq!(confirmation.details, json!({ "path": "x" })); + } +} diff --git a/crates/agent/src/registry/validate.rs b/crates/agent/src/registry/validate.rs new file mode 100644 index 0000000..d3f32d2 --- /dev/null +++ b/crates/agent/src/registry/validate.rs @@ -0,0 +1,332 @@ +//! 参数校验:JSON Schema 2020-12 的**最小子集**实现。 +//! +//! 为什么不引 `jsonschema` crate:agent 的 schema 全是自己手写的([`super::schema`]), +//! 用到的关键字只有 `type/required/properties/additionalProperties/enum/minimum/maximum/ +//! minLength/minItems/maxItems/uniqueItems/items`。三百行的校验器换掉一棵依赖树, +//! 对 Win7 单文件形态(D12)值得。 +//! +//! 校验失败的文案是给**模型**看的(它会据此改正参数重试),所以要具体: +//! 「参数 `limit` 必须是 1~500 的整数,实际是 2000」,不是 `schema validation failed`。 + +use serde_json::Value; + +/// 校验入口。返回全部错误(一次给全,省模型来回试错)。 +/// +/// # Errors +/// 参数不符合 schema 时返回人类可读的错误列表。 +pub fn validate(schema: &Value, args: &Value) -> Result<(), Vec> { + let mut errors = Vec::new(); + validate_at(schema, args, "", &mut errors); + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } +} + +/// 只取第一条错误(日志/审计摘要用)。 +#[must_use] +pub fn first_error(schema: &Value, args: &Value) -> Option { + validate(schema, args) + .err() + .and_then(|errors| errors.into_iter().next()) +} + +fn validate_at(schema: &Value, value: &Value, path: &str, errors: &mut Vec) { + let Some(schema) = schema.as_object() else { + return; // `{}` = 任意值 + }; + + if let Some(expected) = schema.get("type").and_then(Value::as_str) { + if !type_matches(expected, value) { + errors.push(format!( + "参数 {} 应为 {},实际是 {}", + label(path), + expected, + type_name(value) + )); + return; // 类型都不对,后面的范围检查没有意义 + } + } + + if let Some(allowed) = schema.get("enum").and_then(Value::as_array) { + if !allowed.iter().any(|v| v == value) { + let list: Vec = allowed.iter().map(render).collect(); + errors.push(format!( + "参数 {} 只能取 [{}],实际是 {}", + label(path), + list.join(", "), + render(value) + )); + } + } + + check_bounds(schema, value, path, errors); + + match value { + Value::Object(map) => { + let required = schema.get("required").and_then(Value::as_array); + for name in required.into_iter().flatten().filter_map(Value::as_str) { + if !map.contains_key(name) { + errors.push(format!("缺少必填参数 {}", label(&join(path, name)))); + } + } + let properties = schema.get("properties").and_then(Value::as_object); + for (key, entry) in map { + match properties.and_then(|p| p.get(key)) { + Some(sub) => validate_at(sub, entry, &join(path, key), errors), + None => { + if schema.get("additionalProperties").and_then(Value::as_bool) + == Some(false) + { + let known: Vec<&str> = properties + .map(|p| p.keys().map(String::as_str).collect()) + .unwrap_or_default(); + errors.push(format!( + "参数 {} 不是可接受的参数名(可用:{})", + label(&join(path, key)), + if known.is_empty() { + "无".to_string() + } else { + known.join(", ") + } + )); + } + } + } + } + } + Value::Array(items) => { + if let Some(sub) = schema.get("items") { + for (i, item) in items.iter().enumerate() { + validate_at(sub, item, &format!("{path}[{i}]"), errors); + } + } + } + _ => {} + } +} + +fn check_bounds( + schema: &serde_json::Map, + value: &Value, + path: &str, + errors: &mut Vec, +) { + let number = value.as_f64(); + let minimum = schema.get("minimum").and_then(Value::as_f64); + let maximum = schema.get("maximum").and_then(Value::as_f64); + if let (Some(n), Some(min), Some(max)) = (number, minimum, maximum) { + if n < min || n > max { + errors.push(format!( + "参数 {} 必须在 {}~{} 之间,实际是 {}", + label(path), + render_number(min), + render_number(max), + render_number(n) + )); + return; + } + } else if let (Some(n), Some(min)) = (number, minimum) { + if n < min { + errors.push(format!( + "参数 {} 不能小于 {},实际是 {}", + label(path), + render_number(min), + render_number(n) + )); + } + } else if let (Some(n), Some(max)) = (number, maximum) { + if n > max { + errors.push(format!( + "参数 {} 不能大于 {},实际是 {}", + label(path), + render_number(max), + render_number(n) + )); + } + } + + if let Some(text) = value.as_str() { + if let Some(min_len) = schema.get("minLength").and_then(Value::as_u64) { + if (text.chars().count() as u64) < min_len { + errors.push(format!( + "参数 {} 不能为空(最少 {} 个字符)", + label(path), + min_len + )); + } + } + } + + if let Some(items) = value.as_array() { + if let Some(max_items) = schema.get("maxItems").and_then(Value::as_u64) { + if items.len() as u64 > max_items { + errors.push(format!( + "参数 {} 最多 {} 项,实际 {} 项", + label(path), + max_items, + items.len() + )); + } + } + if let Some(min_items) = schema.get("minItems").and_then(Value::as_u64) { + if (items.len() as u64) < min_items { + errors.push(format!( + "参数 {} 至少 {} 项,实际 {} 项", + label(path), + min_items, + items.len() + )); + } + } + if schema.get("uniqueItems").and_then(Value::as_bool) == Some(true) { + if let Some(duplicate) = first_duplicate(items) { + errors.push(format!( + "参数 {} 不能有重复项({} 出现了多次)", + label(path), + render(duplicate) + )); + } + } + } +} + +/// 数组里第一个重复出现的元素(`uniqueItems` 用;`null` 也算一个值)。 +fn first_duplicate(items: &[Value]) -> Option<&Value> { + items + .iter() + .enumerate() + .find(|(index, item)| items.iter().take(*index).any(|prev| prev == *item)) + .map(|(_, item)| item) +} + +fn type_matches(expected: &str, value: &Value) -> bool { + match expected { + "object" => value.is_object(), + "array" => value.is_array(), + "string" => value.is_string(), + "boolean" => value.is_boolean(), + "null" => value.is_null(), + "integer" => value.as_f64().is_some_and(|n| n.fract() == 0.0), + "number" => value.is_number(), + _ => true, // 未知关键字不拦(前向兼容) + } +} + +fn type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(n) if n.as_f64().is_some_and(|f| f.fract() == 0.0) => "integer", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn label(path: &str) -> String { + if path.is_empty() { + "(根)".to_string() + } else { + format!("`{path}`") + } +} + +fn join(path: &str, key: &str) -> String { + if path.is_empty() { + key.to_string() + } else { + format!("{path}.{key}") + } +} + +fn render(value: &Value) -> String { + match value { + Value::String(s) => format!("\"{s}\""), + other => other.to_string(), + } +} + +fn render_number(n: f64) -> String { + if n.fract() == 0.0 { + format!("{}", n as i64) + } else { + n.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::schema::{ + enum_string_array, integer, non_empty_string, object, string_array, string_enum, + }; + use serde_json::json; + + fn errors(schema: &Value, args: &Value) -> Vec { + validate(schema, args).unwrap_err() + } + + #[test] + fn accepts_valid_and_reports_missing_required() { + let schema = object( + &[("session_id", non_empty_string("会话 id"))], + &["session_id"], + ); + assert!(validate(&schema, &json!({ "session_id": "20261029T124501-9f3c2a" })).is_ok()); + let errs = errors(&schema, &json!({})); + assert_eq!(errs.len(), 1); + assert!(errs[0].contains("缺少必填参数 `session_id`"), "{errs:?}"); + } + + #[test] + fn rejects_empty_string_and_wrong_type() { + let schema = object( + &[("session_id", non_empty_string("会话 id"))], + &["session_id"], + ); + assert!(errors(&schema, &json!({ "session_id": "" }))[0].contains("不能为空")); + assert!(errors(&schema, &json!({ "session_id": 1 }))[0].contains("应为 string")); + } + + #[test] + fn rejects_out_of_range_and_unknown_keys() { + let schema = object( + &[ + ("limit", integer("条数", 1, 500)), + ("kinds", string_array("类型", 8)), + ], + &[], + ); + let errs = errors(&schema, &json!({ "limit": 2000 })); + assert!(errs[0].contains("1~500"), "{errs:?}"); + let errs = errors(&schema, &json!({ "limit": 1, "evil": true })); + assert!(errs[0].contains("不是可接受的参数名"), "{errs:?}"); + } + + #[test] + fn checks_enum_and_nested_objects() { + let schema = object(&[("level", string_enum(&["error", "warn"], "级别"))], &[]); + assert!(validate(&schema, &json!({ "level": "error" })).is_ok()); + assert!(errors(&schema, &json!({ "level": "fatal" }))[0].contains("只能取")); + } + + #[test] + fn rejects_duplicate_items_when_unique() { + let schema = object( + &[("kinds", enum_string_array(&["stderr", "exit"], "类型", 4))], + &[], + ); + let errs = errors(&schema, &json!({ "kinds": ["stderr", "exit", "stderr"] })); + assert!(errs[0].contains("不能有重复项"), "{errs:?}"); + assert!(validate(&schema, &json!({ "kinds": ["stderr", "exit"] })).is_ok()); + } + + #[test] + fn integer_type_rejects_fraction() { + assert!(validate(&json!({ "type": "integer" }), &json!(1.5)).is_err()); + assert!(validate(&json!({ "type": "integer" }), &json!(2.0)).is_ok()); + } +} diff --git a/crates/agent/src/testing.rs b/crates/agent/src/testing.rs new file mode 100644 index 0000000..1c899a2 --- /dev/null +++ b/crates/agent/src/testing.rs @@ -0,0 +1,190 @@ +//! 测试替身:五个端口的 **mock 实现**(无盘、无网络、无时间依赖)。 +//! +//! 放在库内(而不是只在 `#[cfg(test)]`)是刻意的:`crates/app` 装配时也要能给 +//! 自己的单测造一套「无盘」环境,且 P1/M39 的 MCP 契约测试要复用同一批假数据。 +//! +//! 按端口拆文件(`port.rs` 挨 400 行上限的同款理由):`settings` 配置、`sessions` +//! 会话日志在下面,`game` 实例/Java/账户与 `diagnose` 诊断各一个子模块。 +//! 对外名字不变,`unlucky_agent::testing::MockSettings` 之类照旧可用(走 re-export)。 + +pub mod diagnose; +pub mod game; +pub mod settings; + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{json, Value}; + +use crate::port::{GameSessionPort, GameSessionSummary, LogEvent, LogPage, LogQuery, PortResult}; + +pub use diagnose::MockDiagnose; +pub use game::{MockAccounts, MockGame}; +pub use settings::MockSettings; + +/// 内存会话端口。 +#[derive(Debug, Default)] +pub struct MockSessions { + sessions: Vec, + events: BTreeMap>, +} + +impl MockSessions { + /// 空数据。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 加一个会话及其事件流。 + pub fn add(&mut self, summary: GameSessionSummary, events: Vec) { + self.events.insert(summary.session_id.clone(), events); + self.sessions.push(summary); + } + + /// 便捷:造一个崩溃会话(`exit_code=1` + stderr + crash_found)。 + #[must_use] + pub fn crashing_session(session_id: &str) -> (GameSessionSummary, Vec) { + let summary = GameSessionSummary { + session_id: session_id.to_string(), + instance_id: Some("inst-1".to_string()), + instance_name: Some("1.20.1 Forge".to_string()), + game_version: Some("1.20.1".to_string()), + started_at: Some("2026-10-29T12:45:01.123Z".to_string()), + ended_at: Some("2026-10-29T12:46:31.123Z".to_string()), + duration_ms: 90_000, + exit_code: Some(1), + exit_type: Some("application_error".to_string()), + silent_failure: false, + window_detected: true, + event_count: 5, + crash_found: true, + diagnosis_top: Some("forge_missing_library".to_string()), + }; + let events = vec![ + event( + 0, + "stdout", + json!({ "line": "Starting Minecraft 1.20.1", "level": "info" }), + ), + event( + 1, + "stderr", + json!({ "line": "java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher", "level": "error" }), + ), + event( + 2, + "crash_found", + json!({ "source": "crash_report", "path": r"C:\Users\aska\.minecraft\crash-reports\crash-1.txt", "excerpt": "Description: Mod loading failure" }), + ), + event( + 3, + "exit", + json!({ "code": 1, "exit_type": "application_error", "silent_failure": false }), + ), + event( + 4, + "session_end", + json!({ "reason": "exit", "duration_ms": 90000 }), + ), + ]; + (summary, events) + } +} + +fn event(seq: u64, kind: &str, payload: Value) -> LogEvent { + LogEvent { + seq, + ts: format!("2026-10-29T12:45:0{seq}.000Z"), + t_ms: seq * 1_000, + kind: kind.to_string(), + payload, + } +} + +impl GameSessionPort for MockSessions { + fn list_sessions( + &self, + limit: usize, + instance_id: Option<&str>, + ) -> PortResult> { + let mut out: Vec = self + .sessions + .iter() + .filter(|s| instance_id.is_none_or(|id| s.instance_id.as_deref() == Some(id))) + .cloned() + .collect(); + out.sort_by(|a, b| b.started_at.cmp(&a.started_at)); + out.truncate(limit); + Ok(out) + } + + fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult { + let events = self + .events + .get(session_id) + .ok_or_else(|| format!("会话 `{session_id}` 不存在"))?; + let mut kind_counts = BTreeMap::new(); + for event in events { + *kind_counts.entry(event.kind.clone()).or_insert(0u64) += 1; + } + let matched: Vec<&LogEvent> = events + .iter() + .filter(|e| query.from_seq.is_none_or(|from| e.seq >= from)) + .filter(|e| query.to_seq.is_none_or(|to| e.seq <= to)) + .filter(|e| query.kinds.is_empty() || query.kinds.contains(&e.kind)) + .collect(); + let max = if query.max_events == 0 { + matched.len() + } else { + query.max_events + }; + let truncated = matched.len() > max; + Ok(LogPage { + session_id: session_id.to_string(), + total_events: events.len() as u64, + matched_events: matched.len() as u64, + truncated, + kind_counts, + first_ts: events.first().map(|e| e.ts.clone()), + last_ts: events.last().map(|e| e.ts.clone()), + events: matched.into_iter().take(max).cloned().collect(), + }) + } +} + +/// 一次性造好全部端口的 [`crate::port::Ports`](含一个崩溃会话,够跑通对话与日志工具)。 +#[must_use] +pub fn mock_ports() -> (crate::port::Ports, std::sync::Arc) { + use std::sync::Arc; + let settings = Arc::new(MockSettings::new()); + let (summary, events) = MockSessions::crashing_session("20261029T124501-9f3c2a"); + let mut sessions = MockSessions::new(); + sessions.add(summary, events); + let ports = crate::port::Ports { + setting: settings.clone(), + sessions: Arc::new(sessions), + diagnose: Arc::new(MockDiagnose::new()), + game: Arc::new(MockGame::new()), + accounts: Arc::new(MockAccounts::new()), + }; + (ports, settings) +} + +/// 测试里常用的「全部工具名」集合(契约测试与 MCP 投影比对用)。 +#[must_use] +pub fn expected_v1_tool_names() -> BTreeSet<&'static str> { + [ + "get_config", + "get_config_schema", + "set_config", + "list_game_sessions", + "read_game_log", + "diagnose_crash", + "list_instances", + "list_versions", + "get_java_status", + "list_accounts", + ] + .into_iter() + .collect() +} diff --git a/crates/agent/src/testing/diagnose.rs b/crates/agent/src/testing/diagnose.rs new file mode 100644 index 0000000..7f914d2 --- /dev/null +++ b/crates/agent/src/testing/diagnose.rs @@ -0,0 +1,87 @@ +//! 内存诊断端口(`DiagnosePort` 的测试替身)。 + +use crate::port::{ + CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind, + PortResult, +}; + +/// 内存诊断端口(固定返回一份规则结论)。 +#[derive(Debug, Clone)] +pub struct MockDiagnose { + report: Option, +} + +impl Default for MockDiagnose { + fn default() -> Self { + Self { + report: Some(Self::forge_missing_library()), + } + } +} + +impl MockDiagnose { + /// 构造使用默认结论的端口。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 让端口对所有会话返回「会话不存在」。 + #[must_use] + pub fn unavailable() -> Self { + Self { report: None } + } + + /// 默认结论:Forge 缺库(对应 analyse/02 的高发场景)。 + #[must_use] + pub fn forge_missing_library() -> CrashDiagnosis { + CrashDiagnosis { + session_id: String::new(), + source: "rules".to_string(), + needs_more_info: false, + candidates: vec![DiagnosisCandidate { + id: "integrity.forge_missing_library".to_string(), + severity: "error".to_string(), + category: "integrity".to_string(), + confidence: 0.86, + message_key: "diagnose.cause.integrity.forge_missing_library".to_string(), + message: "Forge 启动缺少 bootstraplauncher 库,通常是版本组件未下载完整" + .to_string(), + detail: "在实例上重装 Forge 组件(或先用官方启动器补全一次再回来)".to_string(), + evidence: vec![ + "java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher" + .to_string(), + ], + fix_actions: vec![FixAction { + kind: FixActionKind::Reinstall, + tool: "component.reinstall".to_string(), + label: "重装该版本的 Forge 组件".to_string(), + args: [("component".to_string(), "forge".to_string())] + .into_iter() + .collect(), + }], + }], + secondary: Vec::new(), + evidence: vec!["Description: Mod loading failure".to_string()], + exit: ExitSummary { + code: Some(1), + exit_type: Some("application_error".to_string()), + silent_failure: false, + }, + } + } +} + +impl DiagnosePort for MockDiagnose { + fn diagnose(&self, session_id: &str) -> PortResult { + self.report.clone().map_or_else( + || Err(format!("会话 `{session_id}` 不存在")), + |report| { + Ok(CrashDiagnosis { + session_id: session_id.to_string(), + ..report + }) + }, + ) + } +} diff --git a/crates/agent/src/testing/game.rs b/crates/agent/src/testing/game.rs new file mode 100644 index 0000000..821b751 --- /dev/null +++ b/crates/agent/src/testing/game.rs @@ -0,0 +1,158 @@ +//! 实例 / 版本 / Java / 账户四个只读端口的测试替身。 + +use crate::port::game::{ + AccountPort, AccountSummary, GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus, + VersionSummary, +}; +use crate::port::PortResult; + +/// 内存游戏端口:一个 1.20.1 Forge 实例 + 两个已安装版本 + 两个 Java 候选。 +#[derive(Debug, Clone)] +pub struct MockGame { + instances: Vec, + versions: Vec, + java: JavaStatus, + fail: Option, +} + +impl Default for MockGame { + fn default() -> Self { + Self::new() + } +} + +impl MockGame { + /// 默认假数据(够跑通 `list_instances` / `list_versions` / `get_java_status`)。 + #[must_use] + pub fn new() -> Self { + Self { + instances: vec![InstanceSummary { + id: "inst-1".to_string(), + name: "1.20.1 Forge".to_string(), + version_id: "1.20.1-forge-47.2.20".to_string(), + version_present: true, + game_dir: None, + java_path: Some(r"D:\jdk17\bin\java.exe".to_string()), + last_played_at: Some(1_793_304_753_123), + }], + versions: vec![ + VersionSummary { + id: "1.20.1-forge-47.2.20".to_string(), + kind: Some("release".to_string()), + instance_count: 1, + }, + VersionSummary { + id: "1.21.1".to_string(), + kind: Some("release".to_string()), + instance_count: 0, + }, + ], + java: JavaStatus { + default_memory_mb: Some(2048), + managed_roots: vec![r"D:\ULL\.ULL\data\java-managed".to_string()], + candidates: vec![ + JavaRuntimeSummary { + binary: r"D:\ULL\.ULL\data\java-managed\jdk-17-temurin\bin\java.exe" + .to_string(), + version: Some("17.0.13".to_string()), + major: Some(17), + vendor: Some("temurin".to_string()), + origin: "managed".to_string(), + }, + JavaRuntimeSummary { + binary: "/usr/bin/java".to_string(), + version: Some("21.0.2".to_string()), + major: Some(21), + vendor: None, + origin: "PATH".to_string(), + }, + ], + }, + fail: None, + } + } + + /// 让三个查询全部返回同一个错误(测端口错误透传)。 + #[must_use] + pub fn failing(message: &str) -> Self { + Self { + fail: Some(message.to_string()), + ..Self::new() + } + } + + fn fail(&self) -> Option { + self.fail.clone() + } +} + +impl GamePort for MockGame { + fn list_instances(&self) -> PortResult> { + self.fail().map_or_else(|| Ok(self.instances.clone()), Err) + } + + fn list_versions(&self) -> PortResult> { + self.fail().map_or_else(|| Ok(self.versions.clone()), Err) + } + + fn java_status(&self) -> PortResult { + self.fail().map_or_else(|| Ok(self.java.clone()), Err) + } +} + +/// 内存账户端口:一个离线账户。**只有公开字段**,凭据无处可放。 +#[derive(Debug, Clone)] +pub struct MockAccounts { + accounts: Vec, + fail: Option, +} + +impl Default for MockAccounts { + fn default() -> Self { + Self::new() + } +} + +impl MockAccounts { + /// 一个离线账户(Steve)。 + #[must_use] + pub fn new() -> Self { + Self { + accounts: vec![AccountSummary { + id: "acc-1".to_string(), + kind: "offline".to_string(), + profile_id: "8667ba71b85a4004af54457a9734eed7".to_string(), + profile_name: "Steve".to_string(), + login_name: None, + created_at_ms: 1_793_304_753_123, + }], + fail: None, + } + } + + /// 空账户列表(测「没有账户」的提示文案)。 + #[must_use] + pub fn empty() -> Self { + Self { + accounts: Vec::new(), + fail: None, + } + } + + /// 让查询失败(测端口错误透传)。 + #[must_use] + pub fn failing(message: &str) -> Self { + Self { + fail: Some(message.to_string()), + ..Self::new() + } + } +} + +impl AccountPort for MockAccounts { + fn list_accounts(&self) -> PortResult> { + self.fail + .clone() + .map_or_else(|| Ok(self.accounts.clone()), Err) + } +} diff --git a/crates/agent/src/testing/settings.rs b/crates/agent/src/testing/settings.rs new file mode 100644 index 0000000..d72c518 --- /dev/null +++ b/crates/agent/src/testing/settings.rs @@ -0,0 +1,201 @@ +//! 内存配置端口(`SettingPort` 的测试替身)。 + +use std::collections::BTreeMap; +use std::sync::Mutex; + +use serde_json::{json, Map, Value}; + +use crate::confirm::Sensitivity; +use crate::port::{PortResult, SettingChange, SettingKey, SettingPort}; + +/// 内存配置端口。 +#[derive(Debug, Default)] +pub struct MockSettings { + keys: Vec, + values: Mutex>, + fail_set: Mutex>, + set_calls: Mutex, +} + +/// 与 `PlanDoc/04` §3 / M12 `setting-接口约定.md` §8 的键表一致的默认白名单 +/// (覆盖三档敏感度;`ai.api_key` 这类凭据是 `writable=false`,**故意不在这里**)。 +fn default_keys() -> Vec { + fn key( + path: &str, + sensitivity: Sensitivity, + value_type: &str, + description: &str, + ) -> SettingKey { + SettingKey { + path: path.to_string(), + sensitivity, + value_type: value_type.to_string(), + description: description.to_string(), + } + } + vec![ + key( + "appearance.theme_mode", + Sensitivity::Low, + "enum", + "界面主题", + ), + key( + "download.concurrency", + Sensitivity::Low, + "integer", + "下载并发(1~256)", + ), + key( + "java.default_memory_mb", + Sensitivity::Low, + "integer", + "最大内存(512~65536 MB)", + ), + key("download.region", Sensitivity::Medium, "enum", "下载源区域"), + key("ai.model", Sensitivity::Medium, "text", "AI 模型名"), + key( + "java.selected_path", + Sensitivity::High, + "path", + "Java 可执行文件路径", + ), + key("download.proxy_url", Sensitivity::High, "text", "HTTP 代理"), + key("ai.base_url", Sensitivity::High, "text", "AI 端点"), + key( + "core.data_mode", + Sensitivity::High, + "enum", + "数据目录模式(portable/standard/custom)", + ), + ] +} + +impl MockSettings { + /// 用默认白名单 + 一批初值造一个。 + #[must_use] + pub fn new() -> Self { + let mut values = BTreeMap::new(); + values.insert("appearance.theme_mode".to_string(), json!("metro_dark")); + values.insert("download.concurrency".to_string(), json!(8)); + values.insert("java.default_memory_mb".to_string(), json!(2048)); + Self { + keys: default_keys(), + values: Mutex::new(values), + fail_set: Mutex::new(None), + set_calls: Mutex::new(0), + } + } + + /// 让接下来的 `set` 全部失败(测错误透传)。 + pub fn fail_set_with(&self, message: &str) { + if let Ok(mut slot) = self.fail_set.lock() { + *slot = Some(message.to_string()); + } + } + + /// 当前值(断言「未确认时没有写进去」)。 + #[must_use] + pub fn value_of(&self, path: &str) -> Option { + self.values.lock().ok().and_then(|v| v.get(path).cloned()) + } + + /// 直接塞一个值,模拟 M12 真实实现里存在的**任意键**(含凭据键)。 + /// + /// 白名单以外的键也能塞——测「`get_config` 不把凭据读给模型」需要它 + /// (`ai.api_key` 是 `writable=false`,故意不在白名单里)。 + pub fn seed_value(&self, path: &str, value: Value) { + if let Ok(mut values) = self.values.lock() { + values.insert(path.to_string(), value); + } + } + + /// 成功写入次数。 + #[must_use] + pub fn set_calls(&self) -> usize { + self.set_calls.lock().map(|n| *n).unwrap_or(0) + } +} + +impl SettingPort for MockSettings { + fn summary(&self) -> Value { + let values = self.values.lock().map(|v| v.clone()).unwrap_or_default(); + let mut map = serde_json::Map::new(); + for key in &self.keys { + let value = values.get(&key.path).cloned().unwrap_or(Value::Null); + map.insert(key.path.clone(), value); + } + Value::Object(map) + } + + fn get(&self, path: &str) -> PortResult { + self.values + .lock() + .ok() + .and_then(|v| v.get(path).cloned()) + .ok_or_else(|| format!("配置键 `{path}` 没有值")) + } + + fn set(&self, path: &str, value: &Value) -> PortResult { + if let Ok(slot) = self.fail_set.lock() { + if let Some(message) = slot.as_ref() { + return Err(message.clone()); + } + } + let old = self.get(path).ok(); + if let Ok(mut values) = self.values.lock() { + values.insert(path.to_string(), value.clone()); + } + if let Ok(mut calls) = self.set_calls.lock() { + *calls += 1; + } + Ok(SettingChange { + path: path.to_string(), + old, + new: value.clone(), + }) + } + + fn writable_keys(&self) -> Vec { + self.keys.clone() + } + + fn schema(&self) -> Value { + // 与 `core::setting::catalog::SettingSchema::to_json_schema` 同形: + // 只含白名单键 + `x-ai-sensitivity`,凭据键天然缺席。 + let mut properties = Map::new(); + for key in &self.keys { + properties.insert( + key.path.clone(), + json!({ + "type": key.value_type, + "description": key.description, + "x-ai-sensitivity": key.sensitivity, + }), + ); + } + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ull.launcher", + "type": "object", + "additionalProperties": false, + "properties": properties, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_excludes_credential_keys() { + let schema = MockSettings::new().schema(); + let json = schema.to_string(); + assert!( + !json.contains("ai.api_key"), + "schema 不得包含凭据键:{json}" + ); + assert!(json.contains("download.concurrency")); + } +} diff --git a/crates/agent/src/tools/accounts.rs b/crates/agent/src/tools/accounts.rs new file mode 100644 index 0000000..40f0661 --- /dev/null +++ b/crates/agent/src/tools/accounts.rs @@ -0,0 +1,110 @@ +//! `list_accounts`:账户清单(**凭据永不出口**)。 +//! +//! 这个工具存在的理由很具体:用户常问「我有哪几个号 / 这局用的是哪个号」。 +//! 但 D4/D9 的凭据红线是硬的——工具结果会回灌模型、也可能被发往远端 LLM, +//! 所以: +//! 1. 端口只回 [`crate::port::AccountSummary`](`AccountMeta` 的公开字段); +//! 2. 本文件里**没有任何**能表达 token / 密码的类型(连字段名都不出现); +//! 3. 工具结果再过一遍 [`crate::redact`],兜住实现方把凭据塞进 `profile_name` 的意外。 + +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::port::AccountPort; +use crate::registry::schema::object; +use crate::registry::{ + RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec, +}; + +/// 工具名。 +pub const LIST_ACCOUNTS: &str = "list_accounts"; + +/// 注册 `list_accounts`。 +/// +/// # Errors +/// 工具名重复/schema 非法时返回 [`RegistryError`]。 +pub fn register( + registry: &mut ToolRegistry, + port: Arc, +) -> Result<(), RegistryError> { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| { + let accounts = port.list_accounts().map_err(ToolError::Port)?; + let count = accounts.len(); + let display: Vec = accounts + .iter() + .map(|a| { + if a.profile_name.trim().is_empty() { + format!("{}({})", a.id, a.kind) + } else { + format!("{}({})", a.profile_name, a.kind) + } + }) + .collect(); + let mut content = json!({ + "count": count, + "accounts": accounts, + "credential_policy": "账户凭据(access token / 刷新令牌 / authlib 密码)永不经由 AI 工具出口", + }); + // 兜底脱敏:端口实现若把敏感串塞进了公开字段,这里也不会带出去。 + content = crate::redact::redact_texts(&content); + Ok(ToolOutput::ok( + content, + if count == 0 { + "还没有任何账户".to_string() + } else { + format!("账户 {count} 个:{}", display.join("、")) + }, + )) + }); + + registry.register(ToolSpec::read_only( + LIST_ACCOUNTS, + "列出启动器里已登录的账户(展示名 / 轨道 offline|microsoft|authlib / 档案 UUID / 创建时间)。**永不返回任何凭据**:令牌与密码不在工具结果的类型里,需要登录态请让用户在启动器界面查看。", + object(&[], &[]), + handler, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::MockAccounts; + + fn registry(port: Arc) -> ToolRegistry { + let mut registry = ToolRegistry::new(); + register(&mut registry, port).expect("注册应成功"); + registry + } + + fn call(registry: &ToolRegistry) -> Value { + let spec = registry.get(LIST_ACCOUNTS).expect("工具存在").clone(); + let ctx = ToolCtx { + agent_session_id: "s1".to_string(), + confirmed: false, + }; + (spec.handler)(&ctx, &json!({})).expect("应成功").content + } + + #[test] + fn lists_accounts_without_credentials() { + let content = call(®istry(Arc::new(MockAccounts::new()))); + assert_eq!(content["count"], json!(1)); + assert_eq!(content["accounts"][0]["profile_name"], json!("Steve")); + // 只扫账户数据本身:`credential_policy` 那句说明文字里本来就写着 "token"。 + let fields = content["accounts"].to_string().to_lowercase(); + for forbidden in ["token", "password", "refresh", "secret"] { + assert!( + !fields.contains(forbidden), + "账户字段含 `{forbidden}`:{fields}" + ); + } + } + + #[test] + fn empty_list_is_not_an_error() { + let content = call(®istry(Arc::new(MockAccounts::empty()))); + assert_eq!(content["count"], json!(0)); + assert_eq!(content["accounts"], json!([])); + } +} diff --git a/crates/agent/src/tools/config.rs b/crates/agent/src/tools/config.rs new file mode 100644 index 0000000..728771e --- /dev/null +++ b/crates/agent/src/tools/config.rs @@ -0,0 +1,345 @@ +//! `get_config` / `set_config`(02 文档 §3.1「有权限修改 ULL 各项配置」)。 +//! +//! 安全边界都落在这里,但**分级确认的"要不要问"不在这里**: +//! 工具只负责「白名单过滤 → 生成 diff → 需要确认时只报价不执行」, +//! 问不问由编排层按 [`crate::confirm::ConfirmPolicy`] 决定(YOLO 也只改那一处)。 +//! +//! 值校验(类型/范围/schema)归 M12 的配置 schema 所有:agent 不复制一份, +//! 否则两处规则迟早漂移(02 文档 §3.1「schema 强校验」)。 + +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::confirm::Sensitivity; +use crate::port::{SettingKey, SettingPort}; +use crate::registry::schema::{any, non_empty_string, object, string}; +use crate::registry::{ + RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec, +}; + +/// 读配置。 +pub const GET_CONFIG: &str = "get_config"; +/// 写配置。 +pub const SET_CONFIG: &str = "set_config"; +/// 错误提示里最多列多少个可写键(防"把白名单灌给模型")。 +pub const MAX_LISTED_KEYS: usize = 16; +/// 摘要里值的最大展示长度。 +const BRIEF_CHARS: usize = 60; + +/// 注册两个配置工具。 +/// +/// # Errors +/// 工具名重复/schema 非法时返回 [`RegistryError`]。 +pub fn register( + registry: &mut ToolRegistry, + port: Arc, +) -> Result<(), RegistryError> { + registry.register(get_config_spec(Arc::clone(&port)))?; + registry.register(set_config_spec(port)) +} + +fn get_config_spec(port: Arc) -> ToolSpec { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| { + let path = args + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|p| !p.is_empty()); + match path { + None => { + let summary = port.summary(); + let writable = port.writable_keys(); + Ok(ToolOutput::ok( + json!({ + "scope": "all", + "values": summary, + "writable_keys": writable.iter().map(describe_key).collect::>(), + }), + format!( + "读取全部配置概要({} 个键,其中 {} 个 AI 可写)", + summary.as_object().map_or(0, serde_json::Map::len), + writable.len() + ), + )) + } + Some(path) => { + // 凭据类键一律不读:值会进 ToolOutput.content → 回灌模型 → 发往远端 LLM, + // 违反本 crate 的硬边界(AI 永不接触账户凭据,见 `lib.rs`)。 + // 这是 agent 侧的第二道防线:第一道是 M12 不把凭据放白名单/概要。 + if crate::redact::is_sensitive_key(path) { + return Err(ToolError::NotAllowed(format!( + "`{path}` 是凭据类配置,AI 不可读取也不可写入;请让用户自己在设置页查看。" + ))); + } + let value = port.get(path).map_err(ToolError::Port)?; + Ok(ToolOutput::ok( + json!({ "scope": "key", "path": path, "value": value }), + format!("读取配置 `{path}` = {}", brief(&value)), + )) + } + } + }); + + ToolSpec::read_only( + GET_CONFIG, + "读取启动器配置。不带 path 返回全部配置概要(含 AI 可写键及其敏感等级);带 path 返回单个键的值。", + object( + &[( + "path", + string("配置键点分路径,如 `download.concurrency`;省略则返回全部概要"), + )], + &[], + ), + handler, + ) +} + +fn set_config_spec(port: Arc) -> ToolSpec { + let handler: ToolFn = + Arc::new(move |ctx: &ToolCtx, args: &Value| apply_set_config(port.as_ref(), ctx, args)); + + ToolSpec::mutating( + SET_CONFIG, + "修改启动器配置(仅限白名单内的键)。普通键立即生效;敏感键(Java 路径/数据目录/代理/AI 端点)会先返回 needs_confirmation,等用户确认后才会真正写入。值必须符合该键的类型与范围。", + object( + &[ + ("path", non_empty_string("配置键点分路径,必须是 get_config 列出的可写键")), + ("value", any()), + ], + &["path", "value"], + ), + handler, + ) +} + +/// `set_config` 的实现(独立成函数便于单测直接调用)。 +/// +/// # Errors +/// 键不在白名单、值缺失、端口写入失败时返回 [`ToolError`]。 +pub fn apply_set_config( + port: &dyn SettingPort, + ctx: &ToolCtx, + args: &Value, +) -> Result { + let path = args + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|p| !p.is_empty()) + .ok_or_else(|| ToolError::NotAllowed("缺少参数 `path`".to_string()))? + .to_string(); + let value = args + .get("value") + .cloned() + .ok_or_else(|| ToolError::NotAllowed("缺少参数 `value`".to_string()))?; + + let keys = port.writable_keys(); + let key = keys + .iter() + .find(|k| k.path == path) + .ok_or_else(|| ToolError::NotAllowed(not_writable_message(&keys, &path)))?; + + // 旧值读不到不影响写(首次设置),只是 diff 少一半。 + let old = port.get(&path).ok(); + + if !ctx.confirmed && key.sensitivity == Sensitivity::High { + let diff = json!({ + "path": path, + "old": old, + "new": value, + "sensitivity": key.sensitivity, + "description": key.description, + }); + return Ok(ToolOutput::needs_confirmation( + format!( + "AI 请求把 `{path}` 从 {} 改为 {}({})", + brief_opt(&old), + brief(&value), + sensitivity_label(key.sensitivity) + ), + diff, + key.sensitivity, + )); + } + + let change = port.set(&path, &value).map_err(ToolError::Port)?; + Ok(ToolOutput::ok( + json!({ + "status": "written", + "path": change.path, + "old": change.old, + "new": change.new, + "sensitivity": key.sensitivity, + }), + format!( + "已把 `{}` 从 {} 改为 {}", + change.path, + brief_opt(&change.old), + brief(&change.new) + ), + )) +} + +/// 白名单拒绝的文案:说清"不能写"并给出可写键(模型会据此改用正确路径)。 +fn not_writable_message(keys: &[SettingKey], path: &str) -> String { + let listed: Vec<&str> = keys + .iter() + .take(MAX_LISTED_KEYS) + .map(|k| k.path.as_str()) + .collect(); + let rest = keys.len().saturating_sub(listed.len()); + let tail = if rest > 0 { + format!(",另有 {rest} 个键见 get_config 概要") + } else { + String::new() + }; + format!( + "配置键 `{path}` 不在 AI 可写白名单内。可写键({} 个):{}{tail}", + keys.len(), + listed.join("、") + ) +} + +fn describe_key(key: &SettingKey) -> Value { + json!({ + "path": key.path, + "sensitivity": key.sensitivity, + "value_type": key.value_type, + "description": key.description, + }) +} + +fn sensitivity_label(sensitivity: Sensitivity) -> &'static str { + match sensitivity { + Sensitivity::Low => "普通", + Sensitivity::Medium => "中等(静默但审计)", + Sensitivity::High => "敏感,需确认", + } +} + +fn brief(value: &Value) -> String { + let rendered = match value { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + truncate(&rendered) +} + +fn brief_opt(value: &Option) -> String { + value + .as_ref() + .map_or_else(|| "(未设置)".to_string(), brief) +} + +fn truncate(text: &str) -> String { + if text.chars().count() <= BRIEF_CHARS { + return text.to_string(); + } + let head: String = text.chars().take(BRIEF_CHARS).collect(); + format!("{head}…") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::MockSettings; + + fn ctx(confirmed: bool) -> ToolCtx { + ToolCtx { + agent_session_id: "s1".to_string(), + confirmed, + } + } + + #[test] + fn rejects_keys_outside_whitelist() { + let port = MockSettings::new(); + let err = apply_set_config( + &port, + &ctx(true), + &json!({ "path": "java.random.thing", "value": 1 }), + ) + .unwrap_err(); + assert!(matches!(err, ToolError::NotAllowed(_))); + assert!(err.message().contains("不在 AI 可写白名单内")); + assert!(err.message().contains("download.concurrency")); + assert_eq!(port.set_calls(), 0); + } + + #[test] + fn normal_key_writes_without_confirmation() { + let port = MockSettings::new(); + let out = apply_set_config( + &port, + &ctx(false), + &json!({ "path": "download.concurrency", "value": 16 }), + ) + .unwrap(); + assert!(out.confirmation.is_none()); + assert_eq!(out.content["status"], json!("written")); + assert_eq!(port.value_of("download.concurrency"), Some(json!(16))); + } + + #[test] + fn medium_key_writes_silently_too() { + // M12:Medium = 静默生效但审计标记,不弹窗 + let port = MockSettings::new(); + let out = apply_set_config( + &port, + &ctx(false), + &json!({ "path": "download.region", "value": "cn" }), + ) + .unwrap(); + assert!(out.confirmation.is_none()); + assert_eq!(port.set_calls(), 1); + } + + #[test] + fn high_key_quotes_first_then_writes_after_confirm() { + let port = MockSettings::new(); + let args = json!({ "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" }); + + let quoted = apply_set_config(&port, &ctx(false), &args).unwrap(); + let confirmation = quoted.confirmation.expect("High 键应要求确认"); + assert_eq!(confirmation.sensitivity, Sensitivity::High); + assert_eq!(confirmation.details["new"], json!("D:/jdk17/bin/java.exe")); + assert_eq!(port.set_calls(), 0, "未确认前不得写入"); + + let written = apply_set_config(&port, &ctx(true), &args).unwrap(); + assert!(written.confirmation.is_none()); + assert_eq!(port.set_calls(), 1); + assert_eq!( + port.value_of("java.selected_path"), + Some(json!("D:/jdk17/bin/java.exe")) + ); + } + + #[test] + fn locked_credential_key_is_not_writable() { + // M12:ai.api_key 是 writable=false,不在白名单里 → 任何情况下都拒绝 + let port = MockSettings::new(); + let err = apply_set_config( + &port, + &ctx(true), + &json!({ "path": "ai.api_key", "value": "sk-x" }), + ) + .unwrap_err(); + assert!(matches!(err, ToolError::NotAllowed(_))); + assert_eq!(port.set_calls(), 0); + } + + #[test] + fn port_error_is_passed_through() { + let port = MockSettings::new(); + port.fail_set_with("磁盘只读"); + let err = apply_set_config( + &port, + &ctx(true), + &json!({ "path": "appearance.theme_mode", "value": "metro_light" }), + ) + .unwrap_err(); + assert_eq!(err.code(), "port_error"); + assert!(err.message().contains("磁盘只读")); + } +} diff --git a/crates/agent/src/tools/diagnose.rs b/crates/agent/src/tools/diagnose.rs new file mode 100644 index 0000000..273797e --- /dev/null +++ b/crates/agent/src/tools/diagnose.rs @@ -0,0 +1,335 @@ +//! `diagnose_crash`(02 文档 §3.2 第三条)。 +//! +//! 流水线是「**规则在前、模型补位**」(analyse/02 §8.3):本工具只跑 M15 的规则引擎, +//! 把**结构化结论 + 关键片段**交给模型,绝不让模型读裸日志。规则未命中 +//! (`candidates` 空 / `needs_more_info`)时,模型再去用 `read_game_log` 自己找证据。 +//! +//! P0 与 M15 的桥:M15 的修复动作指向 `java.set` / `memory.set` 这些 **P1 才有的**语义工具 id。 +//! P0 只有 `set_config`,所以这里按 [`FixActionKind`] 生成 `p0_next_steps` +//! (`adjust_memory` → `set_config {"path":"java.default_memory_mb"}`,`change_java` → +//! `set_config {"path":"java.selected_path"}`,见 [`MEMORY_KEY`] / [`JAVA_KEY`]), +//! 剩下的动作明确标成「P0 无工具,解释给用户」。 + +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::port::{CrashDiagnosis, DiagnosePort, DiagnosisCandidate, FixAction, FixActionKind}; +use crate::redact::redact_text; +use crate::registry::schema::{integer, non_empty_string, object}; +use crate::registry::{ + RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec, +}; + +/// 诊断工具名。 +pub const DIAGNOSE_CRASH: &str = "diagnose_crash"; +/// 单次返回的证据行上限(防 token 爆炸)。 +pub const MAX_EVIDENCE_LINES: usize = 40; +/// 证据行默认条数。 +pub const DEFAULT_EVIDENCE_LINES: usize = 20; +/// 单条证据最大字符数。 +pub const MAX_EVIDENCE_CHARS: usize = 400; +/// 内存建议的配置键(M12 白名单里的键名,见 `setting-接口约定.md` §8)。 +pub const MEMORY_KEY: &str = "java.default_memory_mb"; +/// Java 路径的配置键。 +pub const JAVA_KEY: &str = "java.selected_path"; + +/// 注册诊断工具。 +/// +/// # Errors +/// 工具名重复/schema 非法时返回 [`RegistryError`]。 +pub fn register( + registry: &mut ToolRegistry, + port: Arc, +) -> Result<(), RegistryError> { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| { + let session_id = args + .get("session_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ToolError::NotAllowed("缺少参数 `session_id`".to_string()))?; + let max_lines = args + .get("max_evidence_lines") + .and_then(Value::as_u64) + .map_or(DEFAULT_EVIDENCE_LINES, |n| n as usize) + .clamp(1, MAX_EVIDENCE_LINES); + let report = port.diagnose(session_id).map_err(ToolError::Port)?; + Ok(render(&report, max_lines)) + }); + + registry.register(ToolSpec::read_only( + DIAGNOSE_CRASH, + "对某次游戏会话跑规则引擎(59 条崩溃规则)并返回结构化结论:主因、置信度、证据行、修复动作。规则未命中时会提示需要更多信息——此时再用 read_game_log 取 stderr/exit 片段。", + object( + &[ + ("session_id", non_empty_string("会话 id,来自 list_game_sessions")), + ( + "max_evidence_lines", + integer("返回多少条证据行", 1, MAX_EVIDENCE_LINES as i64), + ), + ], + &["session_id"], + ), + handler, + )) +} + +/// 把规则引擎结论压成工具结果。 +fn render(report: &CrashDiagnosis, max_evidence_lines: usize) -> ToolOutput { + let candidates: Vec = report + .candidates + .iter() + .map(|c| render_candidate(c, max_evidence_lines)) + .collect(); + let secondary: Vec = report + .secondary + .iter() + .take(5) + .map(|c| { + json!({ + "id": c.id, + "severity": c.severity, + "category": c.category, + "confidence": c.confidence, + "message": message_of(c), + }) + }) + .collect(); + let next_steps: Vec = report + .candidates + .iter() + .flat_map(|c| c.fix_actions.iter()) + .map(bridge_to_p0) + .collect(); + + let content = json!({ + "session_id": report.session_id, + "source": report.source, + "needs_more_info": report.needs_more_info, + "exit": report.exit, + "top_confidence": report.top_confidence(), + "candidates": candidates, + "secondary": secondary, + "evidence": cap_lines(&report.evidence, max_evidence_lines), + "p0_next_steps": next_steps, + }); + + ToolOutput::ok(content, summarize(report)) +} + +fn render_candidate(candidate: &DiagnosisCandidate, max_evidence_lines: usize) -> Value { + json!({ + "id": candidate.id, + "severity": candidate.severity, + "category": candidate.category, + "confidence": candidate.confidence, + "message": message_of(candidate), + "detail": candidate.detail, + "evidence": cap_lines(&candidate.evidence, max_evidence_lines), + "fix_actions": candidate.fix_actions, + }) +} + +/// `message` 为空时回落显示 i18n key(装配层没渲染时至少不丢信息)。 +fn message_of(candidate: &DiagnosisCandidate) -> String { + let text = candidate.message.trim(); + if text.is_empty() { + candidate.message_key.clone() + } else { + text.to_string() + } +} + +fn summarize(report: &CrashDiagnosis) -> String { + match report.top() { + Some(top) => { + let label = top + .fix_actions + .first() + .map(action_label) + .unwrap_or_else(|| "没有可执行的修复动作".to_string()); + format!( + "规则引擎命中 {} 条候选,主因 `{}`(置信度 {:.2});建议:{label}", + report.candidates.len(), + top.id, + top.confidence + ) + } + None if report.needs_more_info => { + "规则引擎未命中,且需要更多信息(用 read_game_log 取 stderr/exit 片段后再判断)" + .to_string() + } + None => "规则引擎未命中(59 条规则都不匹配),需要结合日志片段人工/AI 判断".to_string(), + } +} + +/// M15 的语义动作 → P0 现有工具。返回给模型看的"下一步"。 +fn bridge_to_p0(action: &FixAction) -> Value { + let label = action_label(action); + match action.kind { + FixActionKind::AdjustMemory => { + let value = pick_memory_mb(action).map_or(Value::Null, |mb| json!(mb)); + json!({ + "action": action.tool, + "kind": action.kind, + "label": label, + "suggested_tool": "set_config", + "suggested_args": { "path": MEMORY_KEY, "value": value }, + }) + } + FixActionKind::ChangeJava => { + let value = pick_arg(action, &["path", "java_path", "binary", "value"]) + .map_or(Value::Null, |p| json!(p)); + json!({ + "action": action.tool, + "kind": action.kind, + "label": label, + "suggested_tool": "set_config", + "suggested_args": { "path": JAVA_KEY, "value": value }, + }) + } + other => json!({ + "action": action.tool, + "kind": other, + "label": label, + "suggested_tool": Value::Null, + "unavailable_reason": "P0 没有对应工具(P1 落地),请把这个建议解释给用户", + }), + } +} + +/// 动作的人话:优先 M15 给的 `label`,否则按 kind 兜底。 +fn action_label(action: &FixAction) -> String { + let label = action.label.trim(); + if !label.is_empty() { + return label.to_string(); + } + match action.kind { + FixActionKind::ChangeJava => "换一个匹配的 Java 版本".to_string(), + FixActionKind::AdjustMemory => "调整最大内存".to_string(), + FixActionKind::ChangeGraphics => "调整图形设置/驱动".to_string(), + FixActionKind::RemoveMod => "移除或替换冲突的 Mod".to_string(), + FixActionKind::Reinstall => "重装/修复对应组件".to_string(), + FixActionKind::SeekHelp => "导出诊断包并反馈".to_string(), + } +} + +/// 从动作参数里挑内存上限:先看常见键,再退化成"第一个带数字的值"。 +fn pick_memory_mb(action: &FixAction) -> Option { + if let Some(value) = pick_arg( + action, + &["value", "suggest", "mb", "max_mb", "xmx", "xmx_mb"], + ) { + if let Some(mb) = parse_mb(&value) { + return Some(mb); + } + } + action.args.values().find_map(|v| parse_mb(v)) +} + +fn pick_arg(action: &FixAction, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| action.args.get(*key).cloned()) + .filter(|v| !v.trim().is_empty()) +} + +/// `"4096"` / `"4G"` / `"6144M"` → MB。 +fn parse_mb(raw: &str) -> Option { + let text = raw.trim().to_ascii_lowercase(); + let (number, scale) = if let Some(rest) = text.strip_suffix('g') { + (rest, 1024) + } else if let Some(rest) = text.strip_suffix("gb") { + (rest, 1024) + } else if let Some(rest) = text.strip_suffix('m') { + (rest, 1) + } else if let Some(rest) = text.strip_suffix("mb") { + (rest, 1) + } else { + (text.as_str(), 1) + }; + number.trim().parse::().ok().map(|n| n * scale) +} + +fn cap_lines(lines: &[String], max_lines: usize) -> Vec { + lines + .iter() + .take(max_lines) + .map(|line| { + let masked = redact_text(line); + if masked.chars().count() <= MAX_EVIDENCE_CHARS { + masked + } else { + let head: String = masked.chars().take(MAX_EVIDENCE_CHARS).collect(); + format!("{head}…") + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::MockDiagnose; + + fn call(port: &MockDiagnose, session_id: &str) -> ToolOutput { + let report = port.diagnose(session_id).expect("mock 应返回结论"); + render(&report, DEFAULT_EVIDENCE_LINES) + } + + #[test] + fn renders_candidates_with_p0_bridge_for_reinstall() { + let out = call(&MockDiagnose::new(), "s-1"); + assert_eq!(out.content["candidates"].as_array().unwrap().len(), 1); + let confidence = out.content["top_confidence"].as_f64().unwrap(); + assert!((confidence - 0.86).abs() < 1e-6, "{confidence}"); + let steps = out.content["p0_next_steps"].as_array().unwrap(); + assert_eq!(steps[0]["kind"], json!("reinstall")); + assert_eq!(steps[0]["suggested_tool"], Value::Null); + assert!(out.summary.contains("建议:重装该版本的 Forge 组件")); + } + + #[test] + fn bridges_memory_action_to_set_config() { + let mut report = MockDiagnose::forge_missing_library(); + if let Some(candidate) = report.candidates.first_mut() { + candidate.fix_actions = vec![FixAction { + kind: FixActionKind::AdjustMemory, + tool: "memory.set".to_string(), + label: String::new(), + args: [("suggest".to_string(), "4G".to_string())] + .into_iter() + .collect(), + }]; + } + let out = render(&report, 5); + let steps = out.content["p0_next_steps"].as_array().unwrap(); + assert_eq!(steps[0]["suggested_tool"], json!("set_config")); + assert_eq!(steps[0]["suggested_args"]["path"], json!(MEMORY_KEY)); + assert_eq!(steps[0]["suggested_args"]["value"], json!(4096)); + assert_eq!(steps[0]["label"], json!("调整最大内存")); + } + + #[test] + fn parses_memory_units() { + assert_eq!(parse_mb("4096"), Some(4096)); + assert_eq!(parse_mb("4G"), Some(4096)); + assert_eq!(parse_mb("6144mb"), Some(6144)); + assert_eq!(parse_mb("abc"), None); + } + + #[test] + fn empty_candidates_says_need_more_info() { + let report = CrashDiagnosis { + session_id: "s-2".into(), + source: "rules".into(), + needs_more_info: true, + candidates: vec![], + secondary: vec![], + evidence: vec![], + exit: crate::port::ExitSummary::default(), + }; + let out = render(&report, 5); + assert!(out.summary.contains("需要更多信息")); + } +} diff --git a/crates/agent/src/tools/game.rs b/crates/agent/src/tools/game.rs new file mode 100644 index 0000000..6e5fcfd --- /dev/null +++ b/crates/agent/src/tools/game.rs @@ -0,0 +1,199 @@ +//! 实例 / 版本 / Java 三个只读工具(M39 任务 3 的读侧)。 +//! +//! 全部只读、全部走 [`crate::port::GamePort`]:agent 不自己去翻 `versions/` 目录, +//! 也不自己扫 JDK——那两件事的真相源在 M14(game)与 M33(java),重复一份必然漂。 +//! +//! 输出形状刻意做成「摘要 + 上限」:实例可能几十个、Java 候选可能十几个, +//! 一次全塞给模型等于烧 token(02 §3.2 的「单次返回上限防 token 爆炸」同理)。 + +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::port::GamePort; +use crate::registry::schema::{integer, object}; +use crate::registry::{ + RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec, +}; + +/// 实例列表工具名。 +pub const LIST_INSTANCES: &str = "list_instances"; +/// 版本列表工具名。 +pub const LIST_VERSIONS: &str = "list_versions"; +/// Java 现状工具名。 +pub const GET_JAVA_STATUS: &str = "get_java_status"; +/// 单次最多返回多少个实例/版本。 +pub const MAX_ROWS: usize = 50; + +/// 注册三个只读工具。 +/// +/// # Errors +/// 工具名重复/schema 非法时返回 [`RegistryError`]。 +pub fn register(registry: &mut ToolRegistry, port: Arc) -> Result<(), RegistryError> { + registry.register(list_instances_spec(Arc::clone(&port)))?; + registry.register(list_versions_spec(Arc::clone(&port)))?; + registry.register(java_status_spec(port)) +} + +fn limit_arg(args: &Value) -> usize { + args.get("limit") + .and_then(Value::as_u64) + .map_or(MAX_ROWS, |n| usize::try_from(n).unwrap_or(MAX_ROWS)) + .clamp(1, MAX_ROWS) +} + +fn limit_schema() -> Value { + integer( + &format!("最多返回多少条(1~{MAX_ROWS},默认 {MAX_ROWS})"), + 1, + MAX_ROWS as i64, + ) +} + +fn list_instances_spec(port: Arc) -> ToolSpec { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| { + let limit = limit_arg(args); + let mut instances = port.list_instances().map_err(ToolError::Port)?; + let total = instances.len(); + instances.truncate(limit); + Ok(ToolOutput::ok( + json!({ + "total": total, + "shown": instances.len(), + "truncated": total > instances.len(), + "instances": instances, + }), + format!("列出 {}/{} 个实例", instances.len(), total), + )) + }); + + ToolSpec::read_only( + LIST_INSTANCES, + "列出启动器里的游戏实例(id / 名称 / 版本 id / 载荷是否已安装 / 独立游戏目录 / 实例级 Java)。要看某个实例的日志先用 list_game_sessions。", + object(&[("limit", limit_schema())], &[]), + handler, + ) +} + +fn list_versions_spec(port: Arc) -> ToolSpec { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| { + let limit = limit_arg(args); + let mut versions = port.list_versions().map_err(ToolError::Port)?; + let total = versions.len(); + versions.truncate(limit); + Ok(ToolOutput::ok( + json!({ + "total": total, + "shown": versions.len(), + "truncated": total > versions.len(), + "versions": versions, + }), + format!("列出 {}/{} 个已安装版本", versions.len(), total), + )) + }); + + ToolSpec::read_only( + LIST_VERSIONS, + "列出已安装的游戏版本(`versions//` 下真实存在的载荷),带类型与被几个实例引用。判断「实例的载荷缺没缺」时配合 list_instances 的 version_present 一起看。", + object(&[("limit", limit_schema())], &[]), + handler, + ) +} + +fn java_status_spec(port: Arc) -> ToolSpec { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| { + let status = port.java_status().map_err(ToolError::Port)?; + let majors: Vec = status + .candidates + .iter() + .filter_map(|c| c.major.map(|m| m.to_string())) + .collect(); + Ok(ToolOutput::ok( + json!({ + "default_memory_mb": status.default_memory_mb, + "managed_roots": status.managed_roots, + "candidate_count": status.candidates.len(), + "majors": majors, + "candidates": status.candidates, + }), + format!( + "Java 候选 {} 个(主版本 {})", + status.candidates.len(), + if majors.is_empty() { + "无".to_string() + } else { + majors.join("/") + } + ), + )) + }); + + ToolSpec::read_only( + GET_JAVA_STATUS, + "读取 Java 现状:托管扫描根、默认堆内存、探测到的全部运行时(路径 / 版本 / 主版本 / 发行方 / 来源)。启动报「不支持的 class 版本」「找不到 Java」时先看这里。", + object(&[], &[]), + handler, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::MockGame; + + fn registry() -> ToolRegistry { + let mut registry = ToolRegistry::new(); + register(&mut registry, Arc::new(MockGame::new())).expect("注册应成功"); + registry + } + + fn call(registry: &ToolRegistry, name: &str, args: Value) -> Value { + let spec = registry.get(name).expect("工具存在").clone(); + let ctx = ToolCtx { + agent_session_id: "s1".to_string(), + confirmed: false, + }; + (spec.handler)(&ctx, &args).expect("应成功").content + } + + #[test] + fn lists_instances_with_limit() { + let content = call(®istry(), LIST_INSTANCES, json!({ "limit": 1 })); + assert_eq!(content["total"], json!(1)); + assert_eq!(content["instances"][0]["id"], json!("inst-1")); + assert_eq!(content["truncated"], json!(false)); + } + + #[test] + fn lists_versions() { + let content = call(®istry(), LIST_VERSIONS, json!({})); + assert_eq!(content["total"], json!(2)); + assert_eq!(content["versions"][0]["id"], json!("1.20.1-forge-47.2.20")); + } + + #[test] + fn java_status_summarises_majors() { + let content = call(®istry(), GET_JAVA_STATUS, json!({})); + assert_eq!(content["candidate_count"], json!(2)); + assert_eq!(content["majors"], json!(["17", "21"])); + assert_eq!(content["default_memory_mb"], json!(2048)); + } + + #[test] + fn port_errors_are_passed_through() { + let mut registry = ToolRegistry::new(); + register( + &mut registry, + Arc::new(MockGame::failing("versions 目录不可读")), + ) + .expect("注册应成功"); + let spec = registry.get(LIST_VERSIONS).expect("工具存在").clone(); + let ctx = ToolCtx { + agent_session_id: "s1".to_string(), + confirmed: false, + }; + let err = (spec.handler)(&ctx, &json!({})).unwrap_err(); + assert_eq!(err.code(), "port_error"); + assert!(err.message().contains("versions 目录不可读")); + } +} diff --git a/crates/agent/src/tools/mod.rs b/crates/agent/src/tools/mod.rs new file mode 100644 index 0000000..87b9094 --- /dev/null +++ b/crates/agent/src/tools/mod.rs @@ -0,0 +1,39 @@ +//! 工具集 v1(02 文档 §3):配置读写 / 会话与日志 / 崩溃诊断 / 实例与 Java / 账户。 +//! +//! 分工边界(重要,评审时按这条看): +//! - **工具**:参数语义 → 端口调用 → 结构化结果;需要确认时**只报价不执行**。 +//! - **编排层**([`crate::brain::exec`]):schema 校验、确认闸门、YOLO 判定、审计、事件。 +//! - **端口**([`crate::port`]):真正的 IO 与领域校验(M12/M14/M15/M18/M31/M33 拥有)。 +//! +//! 这样「YOLO」只在一个地方(编排层),工具的测试不需要关心 policy。 +//! +//! 双投影:这里的注册表是**唯一真相源**,内置客户端投成 rig `DynamicTool`, +//! 对外 MCP server 投成 rmcp `ToolRoute`([`crate::mcp`])。工具数量与顺序对外一致。 + +pub mod accounts; +pub mod config; +pub mod diagnose; +pub mod game; +pub mod schema_tool; +pub mod session; + +use crate::port::Ports; +use crate::registry::{RegistryError, ToolRegistry}; + +/// 组装 v1 的全部工具。注册顺序 = 模型看到的顺序(只读在前,写在后)。 +/// +/// 顺序不是随便排的:模型在长工具表里更容易选中靠前的项,所以「先看再改」 +/// (`get_config*` 在 `set_config` 之前)是刻意的提示。 +/// +/// # Errors +/// 工具名重复 / schema 非法时返回 [`RegistryError`](属于编码错误,装配期就该炸)。 +pub fn build_v1_registry(ports: &Ports) -> Result { + let mut registry = ToolRegistry::new(); + config::register(&mut registry, ports.setting.clone())?; + schema_tool::register(&mut registry, ports.setting.clone())?; + game::register(&mut registry, ports.game.clone())?; + accounts::register(&mut registry, ports.accounts.clone())?; + session::register(&mut registry, ports.sessions.clone())?; + diagnose::register(&mut registry, ports.diagnose.clone())?; + Ok(registry) +} diff --git a/crates/agent/src/tools/schema_tool.rs b/crates/agent/src/tools/schema_tool.rs new file mode 100644 index 0000000..a746ac4 --- /dev/null +++ b/crates/agent/src/tools/schema_tool.rs @@ -0,0 +1,84 @@ +//! `get_config_schema`:把 M12 的配置 schema(**只含 AI 可写键**)投给模型。 +//! +//! 为什么单独一个工具而不是塞进 `get_config`:模型要能「先看有哪些键、类型是什么、 +//! 哪些是敏感键」,再决定要不要写——这是一次性的静态信息,和读当前值不是同一件事。 +//! 拆开还能让 token 预算更可控(schema 比概要大一个量级)。 +//! +//! 凭据边界:schema 由 M12 的 `SettingSchema::to_json_schema()` 生成,只遍历 +//! `ai.writable && !deprecated` 的键,`ai.api_key`(`locked`)天然不在其中。 + +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::port::SettingPort; +use crate::registry::schema::object; +use crate::registry::{ + RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec, +}; + +/// 工具名。 +pub const GET_CONFIG_SCHEMA: &str = "get_config_schema"; + +/// 注册 `get_config_schema`。 +/// +/// # Errors +/// 工具名重复/schema 非法时返回 [`RegistryError`]。 +pub fn register( + registry: &mut ToolRegistry, + port: Arc, +) -> Result<(), RegistryError> { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| { + let schema = port.schema(); + if schema.get("properties").is_none() { + return Err(ToolError::Port( + "配置 schema 不可用:装配层没有提供 SettingPort::schema()".to_string(), + )); + } + let writable = schema + .get("properties") + .and_then(Value::as_object) + .map_or(0, serde_json::Map::len); + Ok(ToolOutput::ok( + json!({ + "scope": "ai_writable_schema", + "writable_key_count": writable, + "schema": schema, + }), + format!("读取配置 schema({writable} 个 AI 可写键)"), + )) + }); + + registry.register(ToolSpec::read_only( + GET_CONFIG_SCHEMA, + "读取启动器配置的 JSON Schema(只含 AI 可写键)。每个键带类型、取值范围、说明与 `x-ai-sensitivity` 敏感等级;凭据类键不会出现。写配置前先看它。", + object(&[], &[]), + handler, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::MockSettings; + + fn ctx() -> ToolCtx { + ToolCtx { + agent_session_id: "s1".to_string(), + confirmed: false, + } + } + + #[test] + fn returns_schema_without_credentials() { + let port: Arc = Arc::new(MockSettings::new()); + let mut registry = ToolRegistry::new(); + register(&mut registry, port).expect("注册应成功"); + let spec = registry.get(GET_CONFIG_SCHEMA).expect("工具存在").clone(); + let out = (spec.handler)(&ctx(), &json!({})).expect("应返回 schema"); + let text = out.content.to_string(); + assert!(out.content["writable_key_count"].as_u64().unwrap_or(0) > 0); + assert!(text.contains("x-ai-sensitivity")); + assert!(!text.contains("ai.api_key"), "凭据键不得进 schema:{text}"); + } +} diff --git a/crates/agent/src/tools/session.rs b/crates/agent/src/tools/session.rs new file mode 100644 index 0000000..2d1c5aa --- /dev/null +++ b/crates/agent/src/tools/session.rs @@ -0,0 +1,388 @@ +//! `list_game_sessions` / `read_game_log`(02 文档 §3.2「能读取某次游戏崩溃日志」)。 +//! +//! 数据源是 M18 的会话事件流(`DevDocs/SubAgentsTalk/launch-接口约定.md`): +//! - 列表读 `index.jsonl`(每会话一行摘要,同 id 取最后一行); +//! - 日志读 `.jsonl`,**按 `seq` 切片 + 按 `kind` 过滤**,不是按行号。 +//! +//! 两条防 token 爆炸的硬约束: +//! 1. 单次最多 [`MAX_LOG_EVENTS`] 条事件(默认 [`DEFAULT_LOG_EVENTS`]); +//! 2. 单条 payload 里的字符串超过 [`MAX_PAYLOAD_CHARS`] 截断。 +//! +//! 脱敏:launch 落盘前已经做过一遍(`` / ``),本工具再跑一遍 +//! [`crate::redact::redact_text`] 作为第二道防线(幂等,旧会话/第三方 mod 输出可能漏网)。 + +use std::sync::Arc; + +use serde_json::{json, Map, Value}; + +use crate::port::{GameSessionPort, LogEvent, LogPage, LogQuery}; +use crate::redact::redact_text; +use crate::registry::schema::{enum_string_array, integer, non_empty_string, object, string}; +use crate::registry::{ + RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec, +}; + +/// 列出游戏会话。 +pub const LIST_GAME_SESSIONS: &str = "list_game_sessions"; +/// 读一个会话的日志。 +pub const READ_GAME_LOG: &str = "read_game_log"; + +/// 单次返回的事件上限(防 token 爆炸的硬闸)。 +pub const MAX_LOG_EVENTS: usize = 500; +/// 默认返回条数。 +pub const DEFAULT_LOG_EVENTS: usize = 100; +/// 单条 payload 字符串的展示上限(字符)。 +pub const MAX_PAYLOAD_CHARS: usize = 800; +/// 列表默认/最大条数。 +pub const DEFAULT_SESSION_LIMIT: usize = 10; +/// 列表最大条数。 +pub const MAX_SESSION_LIMIT: usize = 50; + +/// `launch-接口约定.md` §3 的事件类型(schema 里给模型做枚举;未知 kind 仍可透传)。 +pub const KNOWN_KINDS: [&str; 13] = [ + "session_start", + "java_selected", + "cmd_built", + "natives_extracted", + "proc_start", + "stdout", + "stderr", + "window_detected", + "exit", + "crash_found", + "diagnosis", + "killed", + "session_end", +]; + +/// 注册两个日志工具。 +/// +/// # Errors +/// 工具名重复/schema 非法时返回 [`RegistryError`]。 +pub fn register( + registry: &mut ToolRegistry, + port: Arc, +) -> Result<(), RegistryError> { + registry.register(list_sessions_spec(Arc::clone(&port)))?; + registry.register(read_log_spec(port)) +} + +fn list_sessions_spec(port: Arc) -> ToolSpec { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| { + let limit = args + .get("limit") + .and_then(Value::as_u64) + .map_or(DEFAULT_SESSION_LIMIT, |n| n as usize) + .clamp(1, MAX_SESSION_LIMIT); + let instance_id = args.get("instance_id").and_then(Value::as_str); + let sessions = port + .list_sessions(limit, instance_id) + .map_err(ToolError::Port)?; + + if sessions.is_empty() { + return Ok(ToolOutput::ok( + json!({ "sessions": [], "count": 0 }), + "没有游戏会话记录(还没启动过游戏,或数据目录不对)".to_string(), + )); + } + + let crashed = sessions + .iter() + .filter(|s| s.crash_found || s.silent_failure || s.exit_code.is_some_and(|c| c != 0)) + .count(); + let summary = format!( + "最近 {} 个游戏会话,其中 {crashed} 个崩溃/异常退出;最近一次 {}({})", + sessions.len(), + sessions[0].session_id, + sessions[0].started_at.as_deref().unwrap_or("启动时间未知") + ); + Ok(ToolOutput::ok( + json!({ "sessions": sessions, "count": sessions.len(), "problem_count": crashed }), + summary, + )) + }); + + ToolSpec::read_only( + LIST_GAME_SESSIONS, + "列出最近的游戏会话(启动时间、退出码、是否崩溃、规则引擎首选结论)。用于定位要读哪一次会话的日志。", + object( + &[ + ("limit", integer("返回条数", 1, MAX_SESSION_LIMIT as i64)), + ("instance_id", string("只看某个实例的会话;省略则全部实例")), + ], + &[], + ), + handler, + ) +} + +fn read_log_spec(port: Arc) -> ToolSpec { + let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| { + let session_id = args + .get("session_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ToolError::NotAllowed("缺少参数 `session_id`".to_string()))? + .to_string(); + + let kinds: Vec = args + .get("kinds") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + + let max_events = args + .get("max_events") + .and_then(Value::as_u64) + .map_or(DEFAULT_LOG_EVENTS, |n| n as usize) + .clamp(1, MAX_LOG_EVENTS); + + let query = LogQuery { + from_seq: args.get("from_seq").and_then(Value::as_u64), + to_seq: args.get("to_seq").and_then(Value::as_u64), + kinds, + max_events, + }; + let page = port + .read_log(&session_id, &query) + .map_err(ToolError::Port)?; + Ok(render_page(&page)) + }); + + ToolSpec::read_only( + READ_GAME_LOG, + "读取某次游戏会话的事件流(stdout/stderr 按行、退出事件、crash-report 摘录)。可按 seq 区间和事件类型过滤;单次最多 500 条、单条最长 800 字符,优先用 kinds 过滤 stderr/exit/crash_found 而不是拉全量。", + object( + &[ + ("session_id", non_empty_string("会话 id,来自 list_game_sessions")), + ("from_seq", integer("起始 seq(含)", 0, 9_007_199_254_740_991)), + ("to_seq", integer("结束 seq(含)", 0, 9_007_199_254_740_991)), + ( + "kinds", + enum_string_array(&KNOWN_KINDS, "只要这些事件类型;省略则全部", 13), + ), + ("max_events", integer("最多返回多少条", 1, MAX_LOG_EVENTS as i64)), + ], + &["session_id"], + ), + handler, + ) +} + +/// 把端口返回的一页日志压成工具结果(含摘要,模型据此决定要不要继续翻页)。 +fn render_page(page: &LogPage) -> ToolOutput { + let events: Vec = page.events.iter().map(render_event).collect(); + let kind_summary = page + .kind_counts + .iter() + .map(|(kind, count)| format!("{kind}×{count}")) + .collect::>() + .join(", "); + + let (content, summary) = if events.is_empty() { + ( + json!({ + "session_id": page.session_id, + "total_events": page.total_events, + "matched_events": 0, + "truncated": false, + "kind_counts": page.kind_counts, + "first_ts": page.first_ts, + "last_ts": page.last_ts, + "events": [], + "hint": "没有符合过滤条件的事件;把 kinds/from_seq/to_seq 放宽再试", + }), + format!( + "会话 {} 没有匹配的事件(共 {} 条)", + page.session_id, page.total_events + ), + ) + } else { + let truncated_note = if page.truncated { + "(已截断)" + } else { + "" + }; + ( + json!({ + "session_id": page.session_id, + "total_events": page.total_events, + "matched_events": page.matched_events, + "truncated": page.truncated, + "kind_counts": page.kind_counts, + "first_ts": page.first_ts, + "last_ts": page.last_ts, + "events": events, + }), + format!( + "会话 {}:共 {} 条事件,返回 {} 条{truncated_note};类型分布 {kind_summary}", + page.session_id, + page.total_events, + events.len(), + ), + ) + }; + ToolOutput::ok(content, summary) +} + +fn render_event(event: &LogEvent) -> Value { + json!({ + "seq": event.seq, + "ts": event.ts, + "t_ms": event.t_ms, + "kind": event.kind, + "payload": shrink(&event.payload), + }) +} + +/// 递归脱敏 + 截断(第二道脱敏防线)。 +fn shrink(value: &Value) -> Value { + match value { + Value::String(text) => Value::String(cap(&redact_text(text))), + Value::Array(items) => Value::Array(items.iter().map(shrink).collect()), + Value::Object(map) => { + let mut out = Map::with_capacity(map.len()); + for (key, item) in map { + out.insert(key.clone(), shrink(item)); + } + Value::Object(out) + } + other => other.clone(), + } +} + +fn cap(text: &str) -> String { + let length = text.chars().count(); + if length <= MAX_PAYLOAD_CHARS { + return text.to_string(); + } + let head: String = text.chars().take(MAX_PAYLOAD_CHARS).collect(); + format!("{head}…(已截断 {} 字符)", length - MAX_PAYLOAD_CHARS) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::MockSessions; + use serde_json::json; + + const SESSION: &str = "20261029T124501-9f3c2a"; + + fn mock() -> MockSessions { + let (summary, events) = MockSessions::crashing_session(SESSION); + let (older, older_events) = MockSessions::crashing_session("20261028T100000-aaaaaa"); + let mut sessions = MockSessions::new(); + sessions.add(summary, events); + sessions.add(older, older_events); + sessions + } + + fn registry() -> ToolRegistry { + let mut registry = ToolRegistry::new(); + register(&mut registry, Arc::new(mock())).expect("注册应成功"); + registry + } + + /// 走**真实注册表 → 真实 handler**,不复制实现(否则测的是复制品)。 + fn call(tool: &str, args: Value) -> ToolOutput { + let registry = registry(); + let spec = registry.get(tool).expect("工具应存在").clone(); + (spec.handler)( + &ToolCtx { + agent_session_id: "s1".to_string(), + confirmed: false, + }, + &args, + ) + .expect("工具应成功") + } + + #[test] + fn lists_sessions_newest_first() { + let out = call(LIST_GAME_SESSIONS, json!({ "limit": 5 })); + assert_eq!(out.content["count"], json!(2)); + assert_eq!(out.content["sessions"][0]["session_id"], json!(SESSION)); + assert_eq!(out.content["problem_count"], json!(2)); + assert!(out.summary.contains("2 个崩溃/异常退出")); + } + + #[test] + fn list_respects_limit() { + let out = call(LIST_GAME_SESSIONS, json!({ "limit": 1 })); + assert_eq!(out.content["count"], json!(1)); + } + + #[test] + fn filters_by_kind_and_keeps_summary() { + let out = call( + READ_GAME_LOG, + json!({ "session_id": SESSION, "kinds": ["stderr"] }), + ); + assert_eq!(out.content["events"].as_array().unwrap().len(), 1); + assert_eq!(out.content["kind_counts"]["stdout"], json!(1)); + assert!(out.summary.contains("共 5 条事件")); + } + + #[test] + fn truncates_when_more_events_than_cap() { + let out = call( + READ_GAME_LOG, + json!({ "session_id": SESSION, "max_events": 2 }), + ); + assert_eq!(out.content["events"].as_array().unwrap().len(), 2); + assert_eq!(out.content["truncated"], json!(true)); + assert_eq!(out.content["matched_events"], json!(5)); + } + + #[test] + fn masks_user_dir_in_payload() { + let out = call( + READ_GAME_LOG, + json!({ "session_id": SESSION, "kinds": ["crash_found"] }), + ); + let payload = &out.content["events"][0]["payload"]; + assert_eq!( + payload["path"], + json!(r"C:\Users\***\.minecraft\crash-reports\crash-1.txt") + ); + } + + #[test] + fn reports_empty_result_with_hint() { + let out = call( + READ_GAME_LOG, + json!({ "session_id": SESSION, "kinds": ["window_detected"] }), + ); + assert_eq!(out.content["events"], json!([])); + assert!(out.content["hint"].is_string()); + } + + #[test] + fn caps_long_payload_text() { + let long = "x".repeat(MAX_PAYLOAD_CHARS + 10); + assert!(cap(&long).contains("已截断 10 字符")); + } + + #[test] + fn unknown_session_is_a_port_error() { + let registry = registry(); + let spec = registry.get(READ_GAME_LOG).expect("工具应存在").clone(); + let err = (spec.handler)( + &ToolCtx { + agent_session_id: "s1".to_string(), + confirmed: false, + }, + &json!({ "session_id": "nope" }), + ) + .unwrap_err(); + assert_eq!(err.code(), "port_error"); + assert!(err.message().contains("不存在")); + } +} diff --git a/crates/agent/tests/brain_loop.rs b/crates/agent/tests/brain_loop.rs new file mode 100644 index 0000000..de43011 --- /dev/null +++ b/crates/agent/tests/brain_loop.rs @@ -0,0 +1,248 @@ +//! 对话循环集成测试:mock provider 驱动**完整链路**(provider → 工具编排 → 审计 → 事件)。 +//! +//! 这里测"循环的骨架":多轮工具调用、步数上限、流式增量、禁用态、provider 失败。 +//! 安全边界(白名单/确认/YOLO/审计标记)在 `tools_security.rs`。 + +mod support; + +use std::sync::Arc; + +use serde_json::json; +use support::{ + block_on, list_args, tool_call_reply, Harness, MockProvider, AGENT_SESSION, SESSION, +}; +use unlucky_agent::audit::AuditOutcome; +use unlucky_agent::brain::{BrainError, ChatMessage, ChatReply, StopReason, ToolCall}; +use unlucky_agent::event::AgentEvent; + +#[test] +fn single_turn_without_tools_returns_text() { + let harness = Harness::new(vec![ChatReply::text("你好,需要我看看最近的崩溃吗?")]); + let mut history = Vec::new(); + + let outcome = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap(); + + assert_eq!(outcome.text, "你好,需要我看看最近的崩溃吗?"); + assert_eq!(outcome.steps, 1); + assert_eq!(outcome.tool_calls, 0); + assert_eq!(outcome.stopped_reason, StopReason::Completed); + assert_eq!(history.len(), 2, "应只有 user + assistant"); + assert!(harness.audit.is_empty(), "没调工具就不该有审计记录"); +} + +#[test] +fn system_prompt_leads_every_request_and_tools_are_projected() { + let harness = Harness::new(vec![ChatReply::text("好的")]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "看看配置"), + ) + .unwrap(); + + let requests = harness.provider.requests(); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + assert!( + matches!(&request.messages[0], ChatMessage::System { content } if content.contains("Unlucky-Launcher")), + "system prompt 必须在首位" + ); + assert_eq!(request.model, "mock-model"); + let names: Vec<&str> = request.tools.iter().map(|t| t.name.as_str()).collect(); + assert_eq!( + names, + vec![ + "get_config", + "set_config", + "get_config_schema", + "list_instances", + "list_versions", + "get_java_status", + "list_accounts", + "list_game_sessions", + "read_game_log", + "diagnose_crash", + ], + "注册表的双投影载荷必须原样交给 provider" + ); + // 双投影契约:provider 收到的 schema 与注册表定义逐字节相等。 + assert_eq!(request.tools, harness.registry.definitions()); +} + +#[test] +fn tool_call_round_trip_feeds_result_back_and_finishes() { + let harness = Harness::new(vec![ + tool_call_reply("call_1", "list_game_sessions", list_args()), + ChatReply::text("最近一次会话崩溃了,要不要跑诊断?"), + ]); + let mut history = Vec::new(); + + let outcome = block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "最近崩了吗"), + ) + .unwrap(); + + assert_eq!(outcome.tool_calls, 1); + assert_eq!(outcome.steps, 2); + assert_eq!(outcome.stopped_reason, StopReason::Completed); + + let requests = harness.provider.requests(); + assert_eq!(requests.len(), 2); + let tool_message = requests[1] + .messages + .iter() + .find_map(|m| match m { + ChatMessage::Tool { + call_id, + name, + content, + } => Some((call_id.clone(), name.clone(), content.clone())), + _ => None, + }) + .expect("必须有工具结果消息"); + assert_eq!(tool_message.0, "call_1"); + assert_eq!(tool_message.1, "list_game_sessions"); + assert!( + tool_message.2.contains(SESSION), + "工具结果应包含会话 id:{}", + tool_message.2 + ); + + let audits = harness.audit.records(); + assert_eq!(audits.len(), 1); + assert_eq!(audits[0].tool, "list_game_sessions"); + assert_eq!(audits[0].outcome, AuditOutcome::Ok); + assert!(!audits[0].yolo); + assert!(!audits[0].confirmed); +} + +#[test] +fn multiple_tool_calls_in_one_step_all_execute() { + let reply = ChatReply::with_tool_calls(vec![ + ToolCall { + id: "c1".to_string(), + name: "list_game_sessions".to_string(), + arguments: list_args(), + }, + ToolCall { + id: "c2".to_string(), + name: "diagnose_crash".to_string(), + arguments: json!({ "session_id": SESSION }), + }, + ]); + let harness = Harness::new(vec![reply, ChatReply::text("诊断完成")]); + let mut history = Vec::new(); + + let outcome = block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "查一下"), + ) + .unwrap(); + assert_eq!(outcome.tool_calls, 2); + assert_eq!(harness.audit.len(), 2); + assert_eq!( + harness + .audit + .records() + .iter() + .map(|r| r.tool.as_str()) + .collect::>(), + vec!["list_game_sessions", "diagnose_crash"] + ); +} + +#[test] +fn step_limit_stops_runaway_loop() { + // 每一步都要求调工具,永远不会给最终回答。 + let script: Vec = (0..16) + .map(|i| tool_call_reply(&format!("c{i}"), "get_config", json!({}))) + .collect(); + let harness = Harness::new(script); + let mut history = Vec::new(); + + let outcome = block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "死循环"), + ) + .unwrap(); + assert_eq!(outcome.stopped_reason, StopReason::StepLimit); + assert_eq!(outcome.steps, unlucky_agent::brain::DEFAULT_MAX_STEPS); + assert_eq!(outcome.tool_calls, unlucky_agent::brain::DEFAULT_MAX_STEPS); +} + +#[test] +fn streaming_deltas_are_forwarded_as_events() { + let harness = Harness::new(vec![ChatReply::text("日志里是 NoClassDefFoundError")]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "什么错误"), + ) + .unwrap(); + + assert_eq!( + harness.events.streamed_text(), + "日志里是 NoClassDefFoundError" + ); + let events = harness.events.events(); + assert!(events + .iter() + .any(|e| matches!(e, AgentEvent::TurnStarted { .. }))); + assert!(events + .iter() + .any(|e| matches!(e, AgentEvent::AssistantText { .. }))); + assert!(events + .iter() + .any(|e| matches!(e, AgentEvent::TurnFinished { .. }))); +} + +#[test] +fn disabled_provider_fails_fast() { + let harness = Harness::from_provider(Arc::new(MockProvider::disabled())); + let mut history = Vec::new(); + + let err = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap_err(); + assert_eq!(err, BrainError::Disabled); + assert!(err.to_string().contains("规则引擎"), "要提示降级能力"); + assert!( + harness.provider.requests().is_empty(), + "禁用态不该发起任何请求" + ); + assert!(history.is_empty(), "禁用态不该改动历史"); +} + +#[test] +fn provider_error_surfaces_as_brain_error_and_event() { + let harness = Harness::from_provider(Arc::new(MockProvider::failing( + "连接 http://127.0.0.1:8080 失败", + ))); + let mut history = Vec::new(); + + let err = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap_err(); + assert_eq!( + err, + BrainError::Provider(unlucky_agent::brain::ProviderError::new( + "连接 http://127.0.0.1:8080 失败" + )) + ); + assert!(harness + .events + .events() + .iter() + .any(|e| matches!(e, AgentEvent::Error { message } if message.contains("8080")))); +} + +#[test] +fn empty_script_yields_empty_completion_turn() { + let harness = Harness::new(vec![]); + let mut history = Vec::new(); + let outcome = block_on(harness.brain.run_turn(AGENT_SESSION, &mut history, "你好")).unwrap(); + assert_eq!(outcome.text, ""); + assert_eq!(outcome.stopped_reason, StopReason::Completed); +} diff --git a/crates/agent/tests/mcp_contract.rs b/crates/agent/tests/mcp_contract.rs new file mode 100644 index 0000000..6dd5306 --- /dev/null +++ b/crates/agent/tests/mcp_contract.rs @@ -0,0 +1,132 @@ +//! **双投影契约测试(MCP 侧)**:注册表 → rmcp `Tool` 必须与 rig 侧逐项相等。 +//! +//! research/02 §6.2 建议进 CI 的那条断言,M16 落的是「注册表自身是纯投影」+「rig 适配层 +//! 只做直投」,M39 补上 rmcp 侧: +//! +//! 1. `mcp::projection::tool_attrs(®istry)` 的 `(name, description, input_schema)` +//! 与 `registry.definitions()` **逐项相等**(JSON 层面,不是"看起来差不多"); +//! 2. `tools/list` 的顺序与注册顺序一致,数量等于注册表长度; +//! 3. 每个 schema 根是 `object`(rig 与 rmcp 的共同前提); +//! 4. `read_only_hint` 与 `Access` 一致(外部客户端据此决定能不能静默调用); +//! 5. 只读子集不会漏出写工具(`agent.mcp.allow_write=false` 的默认形态)。 + +use serde_json::{json, Value}; +use unlucky_agent::mcp::projection::{tool_attr, tool_attrs}; +use unlucky_agent::port::Ports; +use unlucky_agent::registry::{Access, ToolDefinition, ToolRegistry}; +use unlucky_agent::testing::mock_ports; +use unlucky_agent::tools::build_v1_registry; + +fn registry() -> ToolRegistry { + let (ports, _settings) = mock_ports(); + build_v1_registry(&ports).expect("注册表应装配成功") +} + +fn ports() -> Ports { + mock_ports().0 +} + +#[test] +fn mcp_projection_equals_registry_definition_item_by_item() { + let registry = registry(); + let definitions = registry.definitions(); + let attrs = tool_attrs(®istry); + assert_eq!(attrs.len(), definitions.len()); + + for (attr, definition) in attrs.iter().zip(definitions.iter()) { + assert_eq!(attr.name.as_ref(), definition.name, "工具名必须相等"); + assert_eq!( + attr.description.as_deref(), + Some(definition.description.as_str()), + "{} 的 description 必须逐字相等", + definition.name + ); + assert_eq!( + Value::Object((*attr.input_schema).clone()), + definition.input_schema, + "{} 的 JSON Schema 必须逐项相等", + definition.name + ); + } +} + +#[test] +fn projection_order_matches_registration_order() { + let registry = registry(); + let projected: Vec = tool_attrs(®istry) + .iter() + .map(|attr| attr.name.to_string()) + .collect(); + assert_eq!(projected, registry.names(), "两侧顺序必须一致"); +} + +#[test] +fn every_projected_schema_is_an_object_and_round_trips() { + for attr in tool_attrs(®istry()) { + let schema = Value::Object((*attr.input_schema).clone()); + assert_eq!( + schema["type"], + json!("object"), + "{} 的 schema 根必须是 object", + attr.name + ); + // rmcp 会把 Tool 序列化进 tools/list 的响应体:必须能反序列化回来。 + let json = serde_json::to_string(&attr).expect("Tool 可序列化"); + let back: rmcp::model::Tool = serde_json::from_str(&json).expect("Tool 可反序列化"); + assert_eq!(back, attr); + } +} + +#[test] +fn read_only_hint_tracks_access() { + let registry = registry(); + for (attr, spec) in tool_attrs(®istry).iter().zip(registry.specs()) { + let hint = attr + .annotations + .as_ref() + .and_then(|annotations| annotations.read_only_hint) + .expect("每个工具都应有 read_only_hint"); + assert_eq!(hint, spec.access == Access::ReadOnly, "{}", attr.name); + } +} + +#[test] +fn read_only_subset_projects_without_the_writing_tool() { + let subset = registry().read_only_subset(); + assert_eq!(subset.len(), 9); + let names: Vec = tool_attrs(&subset) + .iter() + .map(|attr| attr.name.to_string()) + .collect(); + assert!(!names.iter().any(|name| name == "set_config"), "{names:?}"); + assert!(subset.access_of("set_config").is_none()); +} + +#[test] +fn projected_attrs_are_pure_functions_of_the_definition() { + let definition = ToolDefinition { + name: "demo".to_string(), + description: "演示".to_string(), + input_schema: json!({ "type": "object", "properties": {}, "required": [] }), + }; + let first = tool_attr(&definition, Access::ReadOnly); + let second = tool_attr(&definition, Access::ReadOnly); + assert_eq!(first, second); + + // 副作用等级换一下,只有 annotations 变,name/description/schema 不变。 + let mutating = tool_attr(&definition, Access::Mutating); + assert_eq!(first.name, mutating.name); + assert_eq!(first.input_schema, mutating.input_schema); + assert_ne!(first.annotations, mutating.annotations); +} + +#[test] +fn mock_ports_cover_every_port_the_registry_needs() { + // 装配完整性:五个端口都在,注册表才有这十个工具(少一个端口 = 少工具)。 + let ports = ports(); + let registry = build_v1_registry(&ports).expect("注册表应装配成功"); + assert_eq!(registry.len(), 10); + assert!(unlucky_agent::testing::expected_v1_tool_names() + .iter() + .all(|name| registry.get(name).is_some())); +} diff --git a/crates/agent/tests/mcp_http.rs b/crates/agent/tests/mcp_http.rs new file mode 100644 index 0000000..44f62fe --- /dev/null +++ b/crates/agent/tests/mcp_http.rs @@ -0,0 +1,336 @@ +//! MCP over Streamable HTTP 的端到端测试:真起监听、打裸 JSON-RPC,断言工具返回与审计。 +//! +//! 为什么不用 MCP 客户端 crate:客户端依赖会再拖一棵 reqwest 树(且 3.8 的客户端 feature +//! 与我们的服务端 feature 不是一组)。裸 HTTP 反而更贴近「用户用 Claude Code 连上来」 +//! 的实际形状——握手、会话头、`tools/list`、`tools/call` 一个都不省。 +//! +//! 覆盖的安全语义(02 §3.1 / §4b): +//! - 只读模式下 `set_config` 根本不在工具表里,硬调 → tool-level 错误 + `unknown_tool` 审计; +//! - 普通键(`download.concurrency`,Low)直接写入,审计 `outcome=ok`; +//! - 敏感键(`java.selected_path`,High)在**没有弹窗**的外部场景被拒,文案引导去设置页/YOLO; +//! - 打开 YOLO 后同一个写入成功,审计 `yolo=true`。 + +use std::net::SocketAddr; +use std::sync::Arc; + +use serde_json::{json, Value}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use unlucky_agent::audit::{AuditOutcome, AuditRecord, JsonlAudit}; +use unlucky_agent::brain::exec::ToolExecutor; +use unlucky_agent::clock::{FixedClock, SharedClock}; +use unlucky_agent::confirm::{ConfirmPolicy, DenyGuidance, NoUiGate}; +use unlucky_agent::event::NoopSink; +use unlucky_agent::mcp::{bind, serve_http_on, McpConfig, RegistryServer}; +use unlucky_agent::testing::mock_ports; +use unlucky_agent::tools::build_v1_registry; + +const INITIALIZE: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"ull-test","version":"0.1"}}}"#; + +struct Harness { + addr: SocketAddr, + audit_dir: std::path::PathBuf, +} + +fn audit_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("ull-mcp-http-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +fn server(read_only: bool, yolo: bool, audit: Arc) -> RegistryServer { + let (ports, _settings) = mock_ports(); + let registry = Arc::new(build_v1_registry(&ports).expect("注册表应装配成功")); + let exposed = if read_only { + Arc::new(registry.read_only_subset()) + } else { + Arc::clone(®istry) + }; + let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123)); + let executor = Arc::new( + ToolExecutor::new( + exposed, + audit, + Arc::new(NoUiGate), + ConfirmPolicy { yolo }, + clock, + Arc::new(NoopSink), + ) + .with_guidance(DenyGuidance::External), + ); + RegistryServer::new(registry, executor, "mcp-http", read_only) +} + +async fn spawn(tag: &str, read_only: bool, yolo: bool) -> Harness { + let dir = audit_dir(tag); + let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123)); + let audit = Arc::new(JsonlAudit::new(&dir, clock)); + let server = server(read_only, yolo, audit); + + let cfg = McpConfig { + port: 0, + ..McpConfig::default() + }; + let (addr, listener) = bind(&cfg).await.expect("应能绑定临时端口"); + tokio::spawn(async move { + let _ = serve_http_on(server, cfg, addr, listener).await; + }); + Harness { + addr, + audit_dir: dir, + } +} + +/// 发一条 HTTP POST(`Connection: close` 让读取变成 read_to_end)。 +async fn post( + addr: SocketAddr, + body: &str, + session: Option<&str>, + token: Option<&str>, +) -> (u16, Option, String) { + let mut stream = TcpStream::connect(addr).await.expect("连接失败"); + let mut head = format!( + "POST /mcp HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\n\ + Accept: application/json, text/event-stream\r\nConnection: close\r\n\ + Content-Length: {}\r\n", + body.len() + ); + if let Some(session) = session { + head.push_str(&format!("Mcp-Session-Id: {session}\r\n")); + } + if let Some(token) = token { + head.push_str(&format!("Authorization: Bearer {token}\r\n")); + } + head.push_str("\r\n"); + stream + .write_all(format!("{head}{body}").as_bytes()) + .await + .expect("写请求失败"); + stream.flush().await.expect("刷新失败"); + + let mut raw = Vec::new(); + stream.read_to_end(&mut raw).await.expect("读响应失败"); + let text = String::from_utf8_lossy(&raw).to_string(); + let (head, body) = text.split_once("\r\n\r\n").unwrap_or((text.as_str(), "")); + let status = head + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|code| code.parse::().ok()) + .unwrap_or(0); + let session = head.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("mcp-session-id") + .then(|| value.trim().to_string()) + }); + (status, session, body.to_string()) +} + +/// 响应体可能是裸 JSON,也可能是 SSE(`data: {...}`);两种都解出第一条 JSON-RPC 响应。 +fn parse_rpc(body: &str) -> Value { + if let Ok(value) = serde_json::from_str::(body.trim()) { + return value; + } + for line in body.lines() { + let Some(data) = line.strip_prefix("data: ") else { + continue; + }; + if let Ok(value) = serde_json::from_str::(data.trim()) { + return value; + } + } + panic!("响应体既不是 JSON 也不是 SSE:{body}"); +} + +async fn initialize(addr: SocketAddr) -> Option { + let (status, session, body) = post(addr, INITIALIZE, None, None).await; + assert_eq!(status, 200, "initialize 应成功:{body}"); + let response = parse_rpc(&body); + assert_eq!( + response["result"]["serverInfo"]["name"], + json!("unlucky-launcher") + ); + // 按 MCP 规范补一条 initialized 通知(无 id、无响应体)。 + let _ = post( + addr, + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, + session.as_deref(), + None, + ) + .await; + session +} + +async fn rpc( + addr: SocketAddr, + session: Option<&str>, + id: u32, + method: &str, + params: Value, +) -> Value { + let body = + json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string(); + let (status, _session, text) = post(addr, &body, session, None).await; + assert_eq!(status, 200, "{method} 应成功:{text}"); + parse_rpc(&text) +} + +fn audit_records(dir: &std::path::Path) -> Vec { + let mut out = Vec::new(); + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let text = std::fs::read_to_string(entry.path()).unwrap_or_default(); + for line in text.lines() { + if let Ok(record) = serde_json::from_str::(line) { + out.push(record); + } + } + } + } + out +} + +#[tokio::test(flavor = "multi_thread")] +async fn readonly_server_lists_and_serves_only_read_tools() { + let harness = spawn("readonly", true, false).await; + let session = initialize(harness.addr).await; + + let list = rpc(harness.addr, session.as_deref(), 2, "tools/list", json!({})).await; + let tools = list["result"]["tools"].as_array().expect("应有 tools 数组"); + let names: Vec<&str> = tools + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect(); + assert_eq!(names.len(), 9, "{names:?}"); + assert!(!names.contains(&"set_config"), "只读模式不得暴露写工具"); + assert!(names.contains(&"get_config")); + + // 真调一个只读工具:返回真实配置概要(mock 端口)。 + let call = rpc( + harness.addr, + session.as_deref(), + 3, + "tools/call", + json!({ "name": "get_config", "arguments": {} }), + ) + .await; + assert_ne!(call["result"]["isError"], json!(true), "{call}"); + let text = call["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default(); + assert!(text.contains("download.concurrency"), "{text}"); + + // 硬调写工具:tool-level 错误(客户端能看见文案)+ unknown_tool 审计。 + let denied = rpc( + harness.addr, + session.as_deref(), + 4, + "tools/call", + json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }), + ) + .await; + assert_eq!(denied["result"]["isError"], json!(true), "{denied}"); + + let records = audit_records(&harness.audit_dir); + assert!(records.iter().any(|r| r.tool == "get_config" + && r.outcome == AuditOutcome::Ok + && r.agent_session_id == "mcp-http")); + assert!(records + .iter() + .any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::UnknownTool)); + let _ = std::fs::remove_dir_all(&harness.audit_dir); +} + +#[tokio::test(flavor = "multi_thread")] +async fn write_server_enforces_sensitive_key_confirmation() { + let harness = spawn("write", false, false).await; + let session = initialize(harness.addr).await; + + // 普通键:直接写。 + let ok = rpc( + harness.addr, + session.as_deref(), + 2, + "tools/call", + json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }), + ) + .await; + assert_ne!(ok["result"]["isError"], json!(true), "{ok}"); + + // 敏感键:外部客户端没有弹窗 → 拒绝,文案要能指导用户下一步。 + let denied = rpc( + harness.addr, + session.as_deref(), + 3, + "tools/call", + json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }), + ) + .await; + assert_eq!(denied["result"]["isError"], json!(true), "{denied}"); + let text = denied["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default(); + assert!( + text.contains("YOLO"), + "拒绝文案应引导到 YOLO/设置页:{text}" + ); + assert!(text.contains("设置页"), "{text}"); + + let records = audit_records(&harness.audit_dir); + assert!(records + .iter() + .any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::AwaitingConfirmation)); + assert!(records + .iter() + .any(|r| r.tool == "set_config" && r.outcome == AuditOutcome::Denied && !r.yolo)); + let _ = std::fs::remove_dir_all(&harness.audit_dir); +} + +#[tokio::test(flavor = "multi_thread")] +async fn yolo_lets_the_same_write_through_and_audits_the_flag() { + let harness = spawn("yolo", false, true).await; + let session = initialize(harness.addr).await; + + let written = rpc( + harness.addr, + session.as_deref(), + 2, + "tools/call", + json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }), + ) + .await; + assert_ne!(written["result"]["isError"], json!(true), "{written}"); + + let records = audit_records(&harness.audit_dir); + let yolo_ok = records + .iter() + .find(|r| r.tool == "set_config" && r.outcome == AuditOutcome::Ok); + let record = yolo_ok.expect("应有一条 ok 审计"); + assert!(record.yolo, "YOLO 放行必须标记 yolo=true"); + assert!(!record.confirmed, "YOLO 自动放行不算用户确认"); + let _ = std::fs::remove_dir_all(&harness.audit_dir); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bearer_token_is_enforced_when_configured() { + let dir = audit_dir("token"); + let clock: SharedClock = Arc::new(FixedClock::new(1_793_304_753_123)); + let audit = Arc::new(JsonlAudit::new(&dir, clock)); + let server = server(true, false, audit); + let cfg = McpConfig { + port: 0, + token: Some("s3cret".to_string()), + ..McpConfig::default() + }; + let (addr, listener) = bind(&cfg).await.expect("应能绑定"); + tokio::spawn(async move { + let _ = serve_http_on(server, cfg, addr, listener).await; + }); + + let (status, _, _) = post(addr, INITIALIZE, None, None).await; + assert_eq!(status, 401, "没带令牌必须 401"); + + let (status, _, body) = post(addr, INITIALIZE, None, Some("s3cret")).await; + assert_eq!(status, 200, "带对令牌应通过:{body}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/agent/tests/schema_contract.rs b/crates/agent/tests/schema_contract.rs new file mode 100644 index 0000000..d6f31e4 --- /dev/null +++ b/crates/agent/tests/schema_contract.rs @@ -0,0 +1,163 @@ +//! **双投影契约测试**(research/02 §6.2 建议进 CI 的那一条)。 +//! +//! rig 侧(`DynamicTool::new`)与 rmcp 侧(`ToolRoute::new_dyn`)吃的都是 +//! `(name, description, serde_json::Value schema)`。本 crate 只维护一份 `ToolSpec`, +//! 所以「两侧相等」在这里等价于三条可断言的性质: +//! 1. [`unlucky_agent::registry::ToolRegistry::definitions`] 是纯投影(多次调用逐字节相等); +//! 2. 每个 schema 根是 `type: object`(两侧都要求对象入参)且拒绝多余参数; +//! 3. 名字唯一,`required` 真的被执行(缺参能被校验器抓到)。 +//! +//! 本文件只锁**投影载荷本身**;两侧的一致性各有专测: +//! - rig 侧:`tests/brain_loop.rs` 断言 `provider` 收到的 `request.tools` 与 +//! `registry.definitions()` 逐字节相等(`brain/rig.rs` 只做直投); +//! - MCP 侧:`tests/mcp_contract.rs` 断言 rmcp `Tool` 的 `(name, description, schema)` 与 +//! 注册表逐项相等(M39 补的那条)。 + +use serde_json::json; +use unlucky_agent::registry::validate::validate; +use unlucky_agent::registry::{Access, ToolDefinition}; +use unlucky_agent::testing::mock_ports; + +fn registry() -> unlucky_agent::registry::ToolRegistry { + let (ports, _settings) = mock_ports(); + unlucky_agent::tools::build_v1_registry(&ports).expect("注册表应装配成功") +} + +#[test] +fn ships_exactly_the_v1_tools() { + let registry = registry(); + assert_eq!( + registry.names(), + vec![ + "get_config", + "set_config", + "get_config_schema", + "list_instances", + "list_versions", + "get_java_status", + "list_accounts", + "list_game_sessions", + "read_game_log", + "diagnose_crash", + ] + ); + assert_eq!(registry.len(), 10); +} + +#[test] +fn definitions_are_pure_projections() { + let registry = registry(); + let first = registry.definitions(); + let second = registry.definitions(); + assert_eq!(first, second, "投影必须是纯函数"); + + for definition in &first { + // 序列化后再反序列化必须完全一样(rig 与 rmcp 都走 JSON)。 + let json = serde_json::to_string(definition).expect("可序列化"); + let round_trip: ToolDefinition = serde_json::from_str(&json).expect("可反序列化"); + assert_eq!(&round_trip, definition); + assert!(!definition.description.trim().is_empty()); + assert_eq!( + definition.input_schema["type"], + json!("object"), + "{} 的 schema 根必须是 object", + definition.name + ); + } +} + +#[test] +fn only_set_config_is_mutating() { + let registry = registry(); + for name in registry.names() { + let expected = if name == "set_config" { + Access::Mutating + } else { + Access::ReadOnly + }; + assert_eq!(registry.access_of(name), Some(expected), "{name}"); + } + let read_only: Vec = registry + .read_only_definitions() + .into_iter() + .map(|d| d.name) + .collect(); + assert_eq!( + read_only, + vec![ + "get_config", + "get_config_schema", + "list_instances", + "list_versions", + "get_java_status", + "list_accounts", + "list_game_sessions", + "read_game_log", + "diagnose_crash", + ], + "只读 MCP 模式只暴露这九个(写侧只有 set_config)" + ); +} + +#[test] +fn every_schema_rejects_unknown_arguments() { + for definition in registry().definitions() { + let err = validate(&definition.input_schema, &json!({ "__unexpected": 1 })) + .expect_err(&format!("{} 应拒绝野参数", definition.name)); + assert!( + err.iter().any(|e| e.contains("不是可接受的参数名")), + "{}: {err:?}", + definition.name + ); + } +} + +#[test] +fn required_fields_are_actually_enforced() { + for definition in registry().definitions() { + let required = definition + .input_schema + .get("required") + .and_then(|r| r.as_array()) + .cloned() + .unwrap_or_default(); + if required.is_empty() { + continue; + } + let err = validate(&definition.input_schema, &json!({})) + .expect_err(&format!("{} 应有必填参数", definition.name)); + for field in required { + let name = field.as_str().unwrap_or_default(); + assert!( + err.iter().any(|e| e.contains(name)), + "{} 的必填 `{name}` 没被校验:{err:?}", + definition.name + ); + } + } +} + +#[test] +fn integer_bounds_are_declared_and_enforced() { + let registry = registry(); + let read_log = registry.get("read_game_log").expect("工具存在"); + let max_events = &read_log.input_schema["properties"]["max_events"]; + assert_eq!(max_events["minimum"], json!(1)); + assert_eq!(max_events["maximum"], json!(500)); + + assert!(validate( + &read_log.input_schema, + &json!({ "session_id": "s", "max_events": 0 }) + ) + .is_err()); + assert!(validate( + &read_log.input_schema, + &json!({ "session_id": "s", "max_events": 501 }) + ) + .is_err()); + assert!(validate( + &read_log.input_schema, + &json!({ "session_id": "s", "max_events": 500 }) + ) + .is_ok()); +} diff --git a/crates/agent/tests/support/mod.rs b/crates/agent/tests/support/mod.rs new file mode 100644 index 0000000..ce25225 --- /dev/null +++ b/crates/agent/tests/support/mod.rs @@ -0,0 +1,225 @@ +//! 集成测试的公共脚手架:**mock provider** + 迷你 `block_on` + 装配好的 harness。 +//! +//! `block_on` 是手写的 20 行:mock provider 的 future 永远立刻 Ready,所以不需要运行时。 +//! 这样 agent 的循环测试**零 tokio 依赖**,阶段 B 换成 rig 后才需要真运行时。 + +#![allow(dead_code)] + +use std::collections::VecDeque; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Waker}; + +use serde_json::{json, Value}; +use unlucky_agent::audit::{AuditSink, MemoryAudit}; +use unlucky_agent::brain::{Brain, BrainConfig, BrainDeps}; +use unlucky_agent::brain::{ + ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderConfig, ProviderError, + ProviderFuture, ToolCall, +}; +use unlucky_agent::clock::{FixedClock, SharedClock}; +use unlucky_agent::confirm::{AutoApproveGate, ConfirmGate, ConfirmPolicy, NoUiGate}; +use unlucky_agent::event::{AgentEventSink, MemoryEventSink}; +use unlucky_agent::registry::ToolRegistry; +use unlucky_agent::testing::{mock_ports, MockSettings}; + +/// 立刻完成的 future 直接 poll;挂起就是测试写错了。 +pub fn block_on(future: F) -> F::Output { + let mut future: Pin> = Box::pin(future); + let waker = Waker::noop(); + let mut context = Context::from_waker(waker); + match future.as_mut().poll(&mut context) { + Poll::Ready(value) => value, + Poll::Pending => panic!("mock provider 不应挂起:测试里没有执行器"), + } +} + +/// 脚本化 provider:按顺序回放 [`ChatReply`],并记录收到的请求。 +pub struct MockProvider { + config: ProviderConfig, + script: Mutex>, + requests: Mutex>, + fail_with: Option, +} + +impl MockProvider { + /// 用默认模型名(本地 llama.cpp 形态)回放脚本。 + #[must_use] + pub fn new(script: Vec) -> Self { + Self { + config: ProviderConfig::llamacpp_local("mock-model"), + script: Mutex::new(script.into()), + requests: Mutex::new(Vec::new()), + fail_with: None, + } + } + + /// 禁用态(测 `BrainError::Disabled`)。 + #[must_use] + pub fn disabled() -> Self { + Self { + config: ProviderConfig::disabled(), + script: Mutex::new(VecDeque::new()), + requests: Mutex::new(Vec::new()), + fail_with: None, + } + } + + /// 每次补全都失败(测 provider 错误透传)。 + #[must_use] + pub fn failing(message: &str) -> Self { + Self { + fail_with: Some(message.to_string()), + ..Self::new(Vec::new()) + } + } + + /// 收到的请求(断言 system prompt / 工具定义 / 回灌的工具消息)。 + #[must_use] + pub fn requests(&self) -> Vec { + self.requests.lock().map(|r| r.clone()).unwrap_or_default() + } +} + +impl ChatProvider for MockProvider { + fn config(&self) -> &ProviderConfig { + &self.config + } + + fn complete<'a>( + &'a self, + request: &'a ChatRequest, + observer: &'a dyn DeltaObserver, + ) -> ProviderFuture<'a> { + Box::pin(async move { + if let Ok(mut requests) = self.requests.lock() { + requests.push(request.clone()); + } + if let Some(message) = &self.fail_with { + return Err(ProviderError::new(message.clone())); + } + let reply = self + .script + .lock() + .ok() + .and_then(|mut script| script.pop_front()) + .unwrap_or_default(); + // 按 4 字符切块吐增量,模拟流式。 + if !reply.text.is_empty() { + for chunk in reply.text.chars().collect::>().chunks(4) { + observer.on_delta(&chunk.iter().collect::()); + } + } + Ok(reply) + }) + } +} + +/// 装配好的测试环境。 +pub struct Harness { + /// 对话循环。 + pub brain: Brain, + /// 审计出口(内存)。 + pub audit: Arc, + /// 事件出口(内存)。 + pub events: Arc, + /// mock provider(查请求)。 + pub provider: Arc, + /// mock 配置端口(查是否真的写入)。 + pub settings: Arc, + /// 工具注册表。 + pub registry: Arc, +} + +impl Harness { + /// P0 默认形态:无确认 UI(`NoUiGate`,敏感操作 fail-closed)+ 非 YOLO。 + /// 确认请求本身仍会作为 [`unlucky_agent::event::AgentEvent::ConfirmRequested`] 发出。 + #[must_use] + pub fn new(script: Vec) -> Self { + Self::with_gate(script, ConfirmPolicy::default(), |_sink| Arc::new(NoUiGate)) + } + + /// YOLO:自动放行全部(除破坏性)。 + #[must_use] + pub fn yolo(script: Vec) -> Self { + Self::with_gate(script, ConfirmPolicy { yolo: true }, |_sink| { + Arc::new(AutoApproveGate) + }) + } + + /// 用指定的(可能失效/禁用的)provider 装配,其余同 [`Harness::new`]。 + #[must_use] + pub fn from_provider(provider: Arc) -> Self { + Self::build(provider, ConfirmPolicy::default(), |_sink| { + Arc::new(NoUiGate) + }) + } + + /// 自定义闸门 + 策略。 + #[must_use] + pub fn with_gate( + script: Vec, + policy: ConfirmPolicy, + make_gate: impl FnOnce(Arc) -> Arc, + ) -> Self { + Self::build(Arc::new(MockProvider::new(script)), policy, make_gate) + } + + fn build( + provider: Arc, + policy: ConfirmPolicy, + make_gate: impl FnOnce(Arc) -> Arc, + ) -> Self { + let (ports, settings) = mock_ports(); + let registry = + Arc::new(unlucky_agent::tools::build_v1_registry(&ports).expect("注册表应装配成功")); + let audit = Arc::new(MemoryAudit::new()); + let events = Arc::new(MemoryEventSink::new()); + let clock: SharedClock = Arc::new(FixedClock::with_step(1_793_314_353_123, 3)); + let gate = make_gate(events.clone()); + let deps = BrainDeps::new( + audit.clone() as Arc, + gate, + policy, + clock, + events.clone() as Arc, + ); + let brain = Brain::new( + provider.clone(), + Arc::clone(®istry), + deps, + BrainConfig::default(), + ); + Self { + brain, + audit, + events, + provider, + settings, + registry, + } + } +} + +/// 构造一条工具调用回复。 +#[must_use] +pub fn tool_call_reply(id: &str, name: &str, arguments: Value) -> ChatReply { + ChatReply::with_tool_calls(vec![ToolCall { + id: id.to_string(), + name: name.to_string(), + arguments, + }]) +} + +/// 便捷:游戏会话 id(与 `MockSessions::crashing_session` 一致)。 +pub const SESSION: &str = "20261029T124501-9f3c2a"; + +/// 便捷:agent 对话会话 id。 +pub const AGENT_SESSION: &str = "chat-1"; + +/// 便捷:`list_game_sessions` 的最小参数。 +#[must_use] +pub fn list_args() -> Value { + json!({ "limit": 5 }) +} diff --git a/crates/agent/tests/tools_security.rs b/crates/agent/tests/tools_security.rs new file mode 100644 index 0000000..daa4d1a --- /dev/null +++ b/crates/agent/tests/tools_security.rs @@ -0,0 +1,365 @@ +//! 安全边界集成测试:白名单拦截、分级确认、YOLO、审计标记。 +//! +//! 这些是 02 文档 §3.1「安全边界」+ §4b「YOLO」与 M12 `setting-接口约定.md` §8 +//! (`Low/Medium/High` 三档 + `writable=false` 凭据)的可执行版本; +//! 判断口径对照 research/02 §7 R9(写操作的破坏半径)。 + +mod support; + +use std::sync::Arc; + +use serde_json::{json, Value}; +use support::{block_on, tool_call_reply, Harness, AGENT_SESSION}; +use unlucky_agent::audit::AuditOutcome; +use unlucky_agent::brain::ChatReply; +use unlucky_agent::confirm::{ConfirmDecision, ConfirmPolicy, ScriptedGate}; +use unlucky_agent::event::AgentEvent; + +fn set_config_reply(path: &str, value: Value) -> ChatReply { + tool_call_reply("c1", "set_config", json!({ "path": path, "value": value })) +} + +fn tool_results(harness: &Harness) -> Vec { + harness + .provider + .requests() + .last() + .map(|request| { + request + .messages + .iter() + .filter_map(|m| match m { + unlucky_agent::brain::ChatMessage::Tool { content, .. } => { + Some(content.clone()) + } + _ => None, + }) + .collect() + }) + .unwrap_or_default() +} + +fn confirm_events(harness: &Harness) -> usize { + harness + .events + .events() + .iter() + .filter(|e| matches!(e, AgentEvent::ConfirmRequested { .. })) + .count() +} + +#[test] +fn low_key_writes_silently_without_confirmation() { + let harness = Harness::new(vec![ + set_config_reply("appearance.theme_mode", json!("metro_light")), + ChatReply::text("已切换主题"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "换成浅色"), + ) + .unwrap(); + + assert_eq!(harness.settings.set_calls(), 1); + assert_eq!( + harness.settings.value_of("appearance.theme_mode"), + Some(json!("metro_light")) + ); + assert_eq!(confirm_events(&harness), 0, "Low 键不该打扰用户"); + + let records = harness.audit.records_for("set_config"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].outcome, AuditOutcome::Ok); + assert!(!records[0].confirmed, "静默生效不是「用户确认」"); + assert!(!records[0].yolo); +} + +#[test] +fn medium_key_also_writes_silently() { + // M12:Medium = 静默生效但审计标记(不弹窗) + let harness = Harness::new(vec![ + set_config_reply("download.region", json!("cn")), + ChatReply::text("已切到国内源"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "换下载源"), + ) + .unwrap(); + + assert_eq!(harness.settings.set_calls(), 1); + assert_eq!(confirm_events(&harness), 0); + assert_eq!( + harness.audit.records_for("set_config")[0].outcome, + AuditOutcome::Ok + ); +} + +#[test] +fn high_key_is_fail_closed_without_ui() { + let harness = Harness::new(vec![ + set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")), + ChatReply::text("需要你确认"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "换 Java"), + ) + .unwrap(); + + assert_eq!( + harness.settings.set_calls(), + 0, + "P0 无 UI 时必须 fail-closed" + ); + assert_eq!(harness.settings.value_of("java.selected_path"), None); + assert_eq!(confirm_events(&harness), 1, "确认请求必须作为事件发出"); + + let outcomes: Vec = harness + .audit + .records_for("set_config") + .iter() + .map(|r| r.outcome) + .collect(); + assert_eq!( + outcomes, + vec![AuditOutcome::AwaitingConfirmation, AuditOutcome::Denied], + "先记「AI 想改」,再记「被拒绝」" + ); + assert!(tool_results(&harness) + .iter() + .any(|content| content.contains("denied"))); + assert!(harness + .events + .events() + .iter() + .any(|e| matches!(e, AgentEvent::ToolDenied { .. }))); +} + +#[test] +fn user_approval_writes_and_marks_confirmed() { + let harness = Harness::with_gate( + vec![ + set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")), + ChatReply::text("已更新"), + ], + ConfirmPolicy::default(), + |_sink| { + Arc::new(ScriptedGate::new( + [ConfirmDecision::Approved], + ConfirmDecision::Denied, + )) + }, + ); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "换 Java"), + ) + .unwrap(); + + assert_eq!(harness.settings.set_calls(), 1, "确认后只写一次"); + let records = harness.audit.records_for("set_config"); + let last = records.last().expect("必须有审计记录"); + assert_eq!(last.outcome, AuditOutcome::Ok); + assert!(last.confirmed, "用户点了允许"); + assert!(!last.yolo); +} + +#[test] +fn yolo_skips_confirm_but_audits_flag() { + let harness = Harness::yolo(vec![ + set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")), + ChatReply::text("已更新"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "换 Java"), + ) + .unwrap(); + + assert_eq!(harness.settings.set_calls(), 1); + assert_eq!(confirm_events(&harness), 0, "YOLO 不打扰用户"); + let records = harness.audit.records_for("set_config"); + assert_eq!(records.len(), 1, "YOLO 只留结局记录"); + assert!(records[0].yolo, "审计必须标记 yolo"); + assert!(!records[0].confirmed, "自动放行不是用户确认"); + assert_eq!(records[0].outcome, AuditOutcome::Ok); +} + +#[test] +fn whitelist_violation_is_rejected_and_audited() { + let harness = Harness::new(vec![ + set_config_reply("java.home.mystery", json!("x")), + ChatReply::text("换个方案"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "改个不存在的键"), + ) + .unwrap(); + + assert_eq!(harness.settings.set_calls(), 0); + let records = harness.audit.records_for("set_config"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].outcome, AuditOutcome::ToolError); + let results = tool_results(&harness); + assert!( + results.iter().any(|c| c.contains("白名单")), + "模型需要知道为什么被拒:{results:?}" + ); +} + +#[test] +fn invalid_args_are_rejected_before_any_side_effect() { + // 缺 value(schema required) + let harness = Harness::new(vec![ + tool_call_reply( + "c1", + "set_config", + json!({ "path": "appearance.theme_mode" }), + ), + ChatReply::text("参数补上"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "改主题"), + ) + .unwrap(); + + assert_eq!(harness.settings.set_calls(), 0); + let records = harness.audit.records_for("set_config"); + assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs); + assert!(tool_results(&harness) + .iter() + .any(|c| c.contains("缺少必填参数"))); + + // 多余参数(additionalProperties: false) + let harness = Harness::new(vec![ + tool_call_reply( + "c2", + "read_game_log", + json!({ "session_id": "x", "path": "evil" }), + ), + ChatReply::text("好"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "读日志"), + ) + .unwrap(); + let records = harness.audit.records_for("read_game_log"); + assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs); +} + +#[test] +fn unknown_tool_lists_available_names() { + let harness = Harness::new(vec![ + tool_call_reply("c1", "delete_everything", json!({})), + ChatReply::text("抱歉"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "删库跑路"), + ) + .unwrap(); + + let records = harness.audit.records_for("delete_everything"); + assert_eq!(records[0].outcome, AuditOutcome::UnknownTool); + let results = tool_results(&harness); + assert!( + results.iter().any(|c| c.contains("get_config")), + "{results:?}" + ); +} + +#[test] +fn locked_credential_key_is_refused_even_under_yolo() { + // M12:ai.api_key 是 writable=false(不在白名单)→ YOLO 也不能放宽可写范围 + let harness = Harness::yolo(vec![ + set_config_reply("ai.api_key", json!("sk-super-secret-value")), + ChatReply::text("换不了"), + ]); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "换 key"), + ) + .unwrap(); + + assert_eq!(harness.settings.set_calls(), 0); + let records = harness.audit.records_for("set_config"); + assert_eq!(records[0].outcome, AuditOutcome::ToolError); + + let args = serde_json::to_string(&records[0].args).unwrap(); + assert!( + !args.contains("sk-super-secret-value"), + "审计绝不能落明文密钥:{args}" + ); + assert!(args.contains("***"), "{args}"); +} + +#[test] +fn get_config_refuses_credential_keys() { + // F1:模型可自由给 path,凭据键必须读不到 —— 否则值会进模型上下文发往远端 LLM。 + let harness = Harness::new(vec![ + tool_call_reply("c1", "get_config", json!({ "path": "ai.api_key" })), + ChatReply::text("读不到"), + ]); + // 模拟 M12 真实实现:凭据键在文件里确实有值(writable=false,不在白名单也要能防住读) + harness + .settings + .seed_value("ai.api_key", json!("sk-live-credential-value")); + let mut history = Vec::new(); + block_on( + harness + .brain + .run_turn(AGENT_SESSION, &mut history, "看看我的 key"), + ) + .unwrap(); + + // 1) 工具报错并被审计 + let records = harness.audit.records_for("get_config"); + assert_eq!(records[0].outcome, AuditOutcome::ToolError); + // 2) 模型拿到的是拒绝文案,不是值 + let results = tool_results(&harness); + assert!( + results.iter().any(|c| c.contains("凭据")), + "应给出可读拒绝:{results:?}" + ); + assert!( + !results + .iter() + .any(|c| c.contains("sk-live-credential-value")), + "工具结果绝不能带凭据:{results:?}" + ); + // 3) 事件流与对话历史里也不能出现(P1 事件会落盘 / 历史会持久化) + let leaked_in_events = harness + .events + .events() + .iter() + .any(|e| format!("{e:?}").contains("sk-live-credential-value")); + assert!(!leaked_in_events, "事件流不能带凭据明文"); + let leaked_in_history = history + .iter() + .any(|m| format!("{m:?}").contains("sk-live-credential-value")); + assert!(!leaked_in_history, "对话历史不能带凭据明文"); +} diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml new file mode 100644 index 0000000..2d13068 --- /dev/null +++ b/crates/app/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "unlucky-app" +description = "Unlucky-Launcher 桌面壳:Slint + Metro 视觉(唯一允许依赖 UI 框架的 crate)" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true + +[[bin]] +name = "unlucky-launcher" +path = "src/main.rs" + +[dependencies] +slint.workspace = true + +# 边界(01c §5 / D3):app 只经 DTO 消费 core/agent,**不 import 内部类型**。 +# P0 的 host 是 src/mock.rs 的假后端;这两个依赖在这里登记,是为了后续把真实 host +# (core 的快照/事件流翻译成 src/dto 的类型)落位时不必再动 workspace 契约。 +unlucky-core = { path = "../core", version = "0.1" } +unlucky-agent = { path = "../agent", version = "0.1" } + +[build-dependencies] +slint-build.workspace = true + +[lints] +workspace = true diff --git a/crates/app/build.rs b/crates/app/build.rs new file mode 100644 index 0000000..a67a158 --- /dev/null +++ b/crates/app/build.rs @@ -0,0 +1,7 @@ +//! Slint 编译入口:把 ui/main.slint 编译成 Rust 代码,供 src/main.rs 的 `slint::include_modules!()` 引入。 +//! 保持极简——UI 结构演进(多文件拆分、主题文件)在 M17 阶段 B 里扩展本脚本。 + +/// 编译 `ui/main.slint`;失败时让构建直接报错(UI 语法错误不该被静默忽略)。 +fn main() { + slint_build::compile("ui/main.slint").expect("Slint UI 编译失败(ui/main.slint)"); +} diff --git a/crates/app/src/appearance_bind.rs b/crates/app/src/appearance_bind.rs new file mode 100644 index 0000000..1e47787 --- /dev/null +++ b/crates/app/src/appearance_bind.rs @@ -0,0 +1,124 @@ +//! 「外观(高级)」四件的回调接线(从 `callbacks.rs` 拆出,守住单文件 ≤400 行)。 +//! +//! 与 `wire_settings` 的分工:这个文件只负责 09 §101-104 的四个高级特性 +//! (背景图 / 面板半透明度 / 玻璃拟态 / 强调色)+ 主题三态。 +//! 每个回调都是同一套动作:改 `state.prefs` → 下发到 Slint(`apply_*`)→ 落盘(`persist_settings`)→ +//! `sync()`。出厂全部关闭,所以「不碰设置页」时这里一次都不会触发。 + +use std::rc::Weak; + +use crate::conv; +use crate::ui_bind::Inner; + +/// 接主题三态与外观四件的回调。 +pub(crate) fn wire(ui: &crate::MainWindow, weak: &Weak) { + { + let weak = weak.clone(); + ui.on_theme_changed(move |mode| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.theme = conv::theme_mode_from(mode); + this.apply_theme(); + this.persist_settings(); + this.sync(); + }); + } + // ── 09 §101-104「外观(高级)」四件(出厂全关,改动即刻落盘)──────── + { + let weak = weak.clone(); + ui.on_bg_image_enabled_changed(move |enabled| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.appearance.bg_image_enabled = enabled; + this.apply_appearance(); + this.persist_settings(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_bg_image_changed(move |path| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.appearance.bg_image = path.to_string(); + this.apply_appearance(); + this.persist_settings(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_bg_fill_changed(move |fill| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.appearance.bg_fill = conv::bg_fill_from(fill); + this.apply_appearance(); + this.persist_settings(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_bg_dim_changed(move |value| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.appearance.bg_dim = value; + this.apply_appearance(); + this.persist_settings(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_panel_alpha_changed(move |value| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.appearance.panel_alpha = value; + this.apply_appearance(); + this.persist_settings(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_glass_changed(move |enabled| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.appearance.glass = enabled; + this.apply_appearance(); + this.persist_settings(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_accent_color_changed(move |text| { + let Some(this) = weak.upgrade() else { return }; + let accepted = this.state.borrow_mut().prefs.appearance.set_accent(&text); + // 非法输入:只亮设置页的 warn 行,不改现状、不落盘 + this.ui.set_accent_invalid(!accepted); + if accepted { + this.apply_appearance(); + this.persist_settings(); + } + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_accent_swatch_picked(move |index| { + let Some(this) = weak.upgrade() else { return }; + let Ok(index) = usize::try_from(index) else { + return; + }; + let accepted = this + .state + .borrow_mut() + .prefs + .appearance + .set_accent_preset(index); + if !accepted { + return; + } + let hex = this.state.borrow().prefs.appearance.accent_color.clone(); + this.ui.set_accent_color(conv::accent_color(&hex)); + this.ui.set_accent_invalid(false); + this.apply_appearance(); + this.persist_settings(); + this.sync(); + }); + } +} diff --git a/crates/app/src/bg_image.rs b/crates/app/src/bg_image.rs new file mode 100644 index 0000000..0dca80e --- /dev/null +++ b/crates/app/src/bg_image.rs @@ -0,0 +1,370 @@ +//! 背景图与玻璃拟态模糊层的准备(09 §101/§103 的「预生成」手法,纯 Rust、无新依赖)。 +//! +//! 为什么要自己算:Slint 没有 `backdrop-filter`,也无法在渲染管线里做高斯模糊。可用的只有 +//! `Image::load_from_path` + 像素缓冲(`to_rgba8` / `from_rgba8`)。于是: +//! +//! - **模糊层** = 把原图按面积平均降到极小的尺寸(默认宽 48px),渲染时被平滑放大 → 天然模糊 +//! (面积平均本身就是低通滤波,比在满分辨率上跑 box blur 快几个数量级,且一次只在设置变更时算); +//! - **tile 模式** = Slint 的 `image-fit` 没有 `repeat`,所以在 Rust 侧按窗口尺寸平铺成一张画布, +//! 交给 `image-fit: cover`(画布与窗口同尺寸,等价于 1:1 铺设)。 +//! +//! 解码格式:Slint 默认只编译 PNG 解码器(`image-default-formats` 特性未开),因此这里只保证 PNG; +//! 其它格式 `load_from_path` 直接失败,UI 侧给出提示而不是静默无效果。 + +use slint::{Image, Rgba8Pixel, SharedPixelBuffer}; + +use crate::prefs::BgFill; + +/// 模糊层目标宽度(像素)。越小越糊;96 在 1180px 宽窗口上 ≈ 24px 半径高斯的效果。 +const BLUR_WIDTH: u32 = 96; +/// 小图上再跑几轮 box blur(3×3):把降采样的块状边缘抹平。 +const BLUR_PASSES: usize = 3; +/// tile 画布上限(防超大窗口吃爆内存:4096×2560×4 ≈ 40MB)。 +const TILE_MAX_WIDTH: u32 = 4096; +const TILE_MAX_HEIGHT: u32 = 2560; + +/// 一次准备出来的两张图:原图(按填充模式处理)+ 模糊层。 +#[derive(Debug, Clone, Default)] +pub struct BackgroundLayers { + /// 铺在窗口底的图(tile 模式下已平铺成整窗画布) + pub image: Image, + /// 玻璃拟态用的模糊层 + pub blur: Image, +} + +/// 载入并加工背景图。 +/// +/// `viewport` 是窗口的**物理像素**尺寸(tile 模式平铺用;拿不到时传 `(0, 0)` 退回只载入)。 +/// 返回 `None` 表示路径无效 / 解码失败 / 读了像素但为空——调用方据此提示用户。 +#[must_use] +pub fn prepare(path: &str, fill: BgFill, viewport: (u32, u32)) -> Option { + let path = path.trim(); + if path.is_empty() { + return None; + } + let loaded = Image::load_from_path(std::path::Path::new(path)).ok()?; + let source = loaded.to_rgba8()?; + if source.width() == 0 || source.height() == 0 { + return None; + } + + let image = match fill { + BgFill::Tile => { + let (width, height) = tile_canvas_size(source.width(), source.height(), viewport); + Image::from_rgba8(tile(&source, width, height)) + } + // cover / contain 交给 Slint 的 image-fit,这里保留原图 + BgFill::Cover | BgFill::Contain => loaded, + }; + + let blurred = box_blur(&downscale(&source, BLUR_WIDTH), BLUR_PASSES); + // 放大回窗口物理尺寸再做 1:1 贴图:软件渲染器对图片缩放不做平滑插值(最近邻), + // 直接把 96px 的小图丢给它会出现明显方块;这里自己双线性放大,观感才是「磨砂」。 + let blur = match viewport { + (0, 0) => Image::from_rgba8(blurred), + (width, height) => Image::from_rgba8(upscale(&blurred, width, height)), + }; + + Some(BackgroundLayers { image, blur }) +} + +/// 3×3 均值模糊,`passes` 轮(小图上跑,代价可忽略)。 +fn box_blur( + source: &SharedPixelBuffer, + passes: usize, +) -> SharedPixelBuffer { + let width = source.width() as usize; + let height = source.height() as usize; + if width < 3 || height < 3 || passes == 0 { + return source.clone(); + } + let mut current: Vec = source.as_slice().to_vec(); + let mut next = current.clone(); + for _ in 0..passes { + for y in 0..height { + for x in 0..width { + let mut sum = [0u32; 4]; + let mut count = 0u32; + for dy in -1i32..=1 { + for dx in -1i32..=1 { + let sy = (y as i32 + dy).clamp(0, height as i32 - 1) as usize; + let sx = (x as i32 + dx).clamp(0, width as i32 - 1) as usize; + let pixel = current[sy * width + sx]; + sum[0] += u32::from(pixel.r); + sum[1] += u32::from(pixel.g); + sum[2] += u32::from(pixel.b); + sum[3] += u32::from(pixel.a); + count += 1; + } + } + next[y * width + x] = Rgba8Pixel { + r: (sum[0] / count) as u8, + g: (sum[1] / count) as u8, + b: (sum[2] / count) as u8, + a: (sum[3] / count) as u8, + }; + } + } + std::mem::swap(&mut current, &mut next); + } + let mut out = SharedPixelBuffer::::new(source.width(), source.height()); + out.make_mut_slice().copy_from_slice(¤t); + out +} + +/// 双线性放大到目标尺寸。 +fn upscale( + source: &SharedPixelBuffer, + width: u32, + height: u32, +) -> SharedPixelBuffer { + let src_w = source.width(); + let src_h = source.height(); + if src_w == 0 || src_h == 0 || width == 0 || height == 0 { + return SharedPixelBuffer::new(width.max(1), height.max(1)); + } + let pixels = source.as_slice(); + let mut out = SharedPixelBuffer::::new(width, height); + let dst = out.make_mut_slice(); + let sample = |fx: f32, fy: f32| -> Rgba8Pixel { + let x = (fx * (src_w - 1) as f32).clamp(0.0, (src_w - 1) as f32); + let y = (fy * (src_h - 1) as f32).clamp(0.0, (src_h - 1) as f32); + let x0 = x.floor() as usize; + let y0 = y.floor() as usize; + let x1 = (x0 + 1).min(src_w as usize - 1); + let y1 = (y0 + 1).min(src_h as usize - 1); + let tx = x - x0 as f32; + let ty = y - y0 as f32; + let at = |px: usize, py: usize| pixels[py * src_w as usize + px]; + let blend = |a: u8, b: u8, c: u8, d: u8| -> u8 { + let top = f32::from(a) * (1.0 - tx) + f32::from(b) * tx; + let bottom = f32::from(c) * (1.0 - tx) + f32::from(d) * tx; + (top * (1.0 - ty) + bottom * ty).round().clamp(0.0, 255.0) as u8 + }; + let (p00, p10, p01, p11) = (at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1)); + Rgba8Pixel { + r: blend(p00.r, p10.r, p01.r, p11.r), + g: blend(p00.g, p10.g, p01.g, p11.g), + b: blend(p00.b, p10.b, p01.b, p11.b), + a: blend(p00.a, p10.a, p01.a, p11.a), + } + }; + for y in 0..height as usize { + let fy = y as f32 / height as f32; + for x in 0..width as usize { + dst[y * width as usize + x] = sample(x as f32 / width as f32, fy); + } + } + out +} + +/// 平铺画布尺寸:覆盖整个视口、且是原图尺寸的整数倍(超上限就截断)。 +fn tile_canvas_size(source_width: u32, source_height: u32, viewport: (u32, u32)) -> (u32, u32) { + if viewport.0 == 0 || viewport.1 == 0 { + return (source_width, source_height); + } + let repeat_x = viewport.0.div_ceil(source_width).max(1); + let repeat_y = viewport.1.div_ceil(source_height).max(1); + let width = (repeat_x * source_width).min(TILE_MAX_WIDTH); + let height = (repeat_y * source_height).min(TILE_MAX_HEIGHT); + (width.max(source_width), height.max(source_height)) +} + +/// 按整数格平铺(源图超出画布的部分裁掉)。 +fn tile( + source: &SharedPixelBuffer, + width: u32, + height: u32, +) -> SharedPixelBuffer { + let mut canvas = SharedPixelBuffer::::new(width, height); + let src_w = source.width() as usize; + let src_h = source.height() as usize; + let src = source.as_slice(); + let dst = canvas.make_mut_slice(); + for y in 0..height as usize { + let src_row = (y % src_h) * src_w; + let dst_row = y * width as usize; + for x in 0..width as usize { + dst[dst_row + x] = src[src_row + (x % src_w)]; + } + } + canvas +} + +/// 面积平均降采样(保留宽高比,最窄 1px)。 +fn downscale( + source: &SharedPixelBuffer, + target_width: u32, +) -> SharedPixelBuffer { + let src_w = source.width(); + let src_h = source.height(); + let target_width = target_width.clamp(1, src_w); + let target_height = ((src_h as u64 * target_width as u64) / src_w as u64).max(1) as u32; + + let mut out = SharedPixelBuffer::::new(target_width, target_height); + let src = source.as_slice(); + let dst = out.make_mut_slice(); + + for ty in 0..target_height as usize { + // 每个目标像素覆盖的源矩形(按比例分块,块内取平均) + let y0 = ty * src_h as usize / target_height as usize; + let y1 = ((ty + 1) * src_h as usize / target_height as usize).max(y0 + 1); + for tx in 0..target_width as usize { + let x0 = tx * src_w as usize / target_width as usize; + let x1 = ((tx + 1) * src_w as usize / target_width as usize).max(x0 + 1); + let mut sum = [0u32; 4]; + let mut count = 0u32; + for y in y0..y1.min(src_h as usize) { + for x in x0..x1.min(src_w as usize) { + let pixel = src[y * src_w as usize + x]; + sum[0] += u32::from(pixel.r); + sum[1] += u32::from(pixel.g); + sum[2] += u32::from(pixel.b); + sum[3] += u32::from(pixel.a); + count += 1; + } + } + let count = count.max(1); + dst[ty * target_width as usize + tx] = Rgba8Pixel { + r: (sum[0] / count) as u8, + g: (sum[1] / count) as u8, + b: (sum[2] / count) as u8, + a: (sum[3] / count) as u8, + }; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solid(width: u32, height: u32, pixel: Rgba8Pixel) -> SharedPixelBuffer { + let mut buffer = SharedPixelBuffer::::new(width, height); + buffer.make_mut_slice().fill(pixel); + buffer + } + + #[test] + fn downscale_keeps_aspect_and_averages_area() { + let mut source = SharedPixelBuffer::::new(96, 48); + // 左半黑、右半白:降采样后左右两侧应各自保持纯色 + for y in 0..48 { + for x in 0..96 { + let value = if x < 48 { 0 } else { 255 }; + source.make_mut_slice()[y * 96 + x] = Rgba8Pixel { + r: value, + g: value, + b: value, + a: 255, + }; + } + } + let small = downscale(&source, 24); + assert_eq!(small.width(), 24); + assert_eq!(small.height(), 12, "宽高比保持 2:1"); + let pixels = small.as_slice(); + assert_eq!(pixels[0].r, 0, "左侧仍是黑"); + assert_eq!(pixels[23].r, 255, "右侧仍是白"); + } + + #[test] + fn downscale_never_produces_zero_height() { + let source = solid( + 400, + 3, + Rgba8Pixel { + r: 1, + g: 2, + b: 3, + a: 255, + }, + ); + let small = downscale(&source, 2); + assert_eq!(small.width(), 2); + assert!(small.height() >= 1); + } + + #[test] + fn tile_canvas_covers_viewport_in_whole_repeats() { + assert_eq!(tile_canvas_size(120, 100, (1180, 760)), (1200, 800)); + // 视口未知(取不到窗口尺寸)时退回单块 + assert_eq!(tile_canvas_size(120, 100, (0, 0)), (120, 100)); + // 超大视口被上限截断 + let (width, height) = tile_canvas_size(64, 64, (9000, 9000)); + assert!(width <= TILE_MAX_WIDTH && height <= TILE_MAX_HEIGHT); + } + + #[test] + fn tile_repeats_source_pixels() { + let source = solid( + 2, + 2, + Rgba8Pixel { + r: 9, + g: 9, + b: 9, + a: 255, + }, + ); + let canvas = tile(&source, 5, 3); + assert_eq!(canvas.width(), 5); + assert_eq!(canvas.height(), 3); + assert!(canvas.as_slice().iter().all(|pixel| pixel.r == 9)); + } + + #[test] + fn prepare_rejects_bad_input() { + assert!(prepare("", BgFill::Cover, (100, 100)).is_none()); + assert!(prepare("D:/definitely/not/here.png", BgFill::Cover, (100, 100)).is_none()); + } + + #[test] + fn box_blur_smooths_a_hard_edge() { + // 左黑右白:模糊后交界处应出现中间灰(硬边被抹平) + let mut source = SharedPixelBuffer::::new(9, 3); + for y in 0..3 { + for x in 0..9 { + let value = if x < 4 { 0 } else { 255 }; + source.make_mut_slice()[y * 9 + x] = Rgba8Pixel { + r: value, + g: value, + b: value, + a: 255, + }; + } + } + let blurred = box_blur(&source, 2); + let middle = blurred.as_slice()[9 + 4]; // 第 1 行(共 9 列)的第 4 个像素 + assert!( + middle.r > 20 && middle.r < 235, + "交界处应被抹成中间灰,实际 {}", + middle.r + ); + } + + #[test] + fn upscale_matches_requested_size_and_interpolates() { + let mut source = SharedPixelBuffer::::new(2, 1); + let pixels = source.make_mut_slice(); + pixels[0] = Rgba8Pixel { + r: 0, + g: 0, + b: 0, + a: 255, + }; + pixels[1] = Rgba8Pixel { + r: 255, + g: 255, + b: 255, + a: 255, + }; + let big = upscale(&source, 8, 2); + assert_eq!(big.width(), 8); + assert_eq!(big.height(), 2); + let row = big.as_slice(); + // 双线性:中间像素应是灰阶过渡而不是 0/255 二值 + assert!(row[3].r > 60 && row[3].r < 200, "实际 {}", row[3].r); + } +} diff --git a/crates/app/src/callbacks.rs b/crates/app/src/callbacks.rs new file mode 100644 index 0000000..35e7e92 --- /dev/null +++ b/crates/app/src/callbacks.rs @@ -0,0 +1,354 @@ +//! 回调接线:把 `ui/main.slint` 暴露的每个回调翻译成「状态变更 + AppCommand + 同步」。 +//! +//! 逐条列出而不做通用转发,是为了让「用户能做的每一件事」在这里一目了然; +//! 新增交互时只改这一处 + `.slint` 的 callback 声明。 + +use std::rc::Weak; + +use slint::ComponentHandle; + +use crate::conv; +use crate::dto::AppCommand; +use crate::router::Route; +use crate::toast::ToastKind; +use crate::ui_bind::Inner; + +/// 接全部回调;闭包持弱引用,避免 `MainWindow` 与闭包互相持有导致窗口永不释放。 +pub(crate) fn wire(weak: Weak) { + let Some(inner) = weak.upgrade() else { return }; + // 只在注册期间持有强引用;闭包内一律持弱引用(否则窗口与闭包互相持有、永不释放)。 + let ui = &inner.ui; + + wire_window(ui, &weak); + wire_navigation(ui, &weak); + wire_instances(ui, &weak); + wire_download(ui, &weak); + wire_logs(ui, &weak); + wire_settings(ui, &weak); + wire_overlays(ui, &weak); +} + +fn wire_window(ui: &crate::MainWindow, weak: &Weak) { + { + let weak = weak.clone(); + ui.on_toggle_maximize(move || { + let Some(this) = weak.upgrade() else { return }; + let window = this.ui.window(); + window.set_maximized(!window.is_maximized()); + }); + } + { + let weak = weak.clone(); + ui.on_minimize(move || { + if let Some(this) = weak.upgrade() { + this.ui.window().set_minimized(true); + } + }); + } + { + let weak = weak.clone(); + ui.on_request_close(move || { + if let Some(this) = weak.upgrade() { + let _ = this.ui.hide(); + } + let _ = slint::quit_event_loop(); + }); + } +} + +fn wire_navigation(ui: &crate::MainWindow, weak: &Weak) { + { + let weak = weak.clone(); + ui.on_navigate(move |route| { + let Some(this) = weak.upgrade() else { return }; + if let Some(route) = conv::route_from(route) { + this.state.borrow_mut().navigate(route); + this.sync(); + } + }); + } + { + let weak = weak.clone(); + ui.on_toggle_nav(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().toggle_nav(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_toggle_ai(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().toggle_ai(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_expand_ai(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().navigate(Route::Agent); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_dock_ai(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().back(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_toggle_yolo(move || { + let Some(this) = weak.upgrade() else { return }; + let enabled = { + let mut state = this.state.borrow_mut(); + state.ai.yolo = !state.ai.yolo; + state.ai.yolo + }; + this.dispatch(AppCommand::SetYolo(enabled)); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_ai_send(move |text| { + let Some(this) = weak.upgrade() else { return }; + let text = text.to_string(); + if text.trim().is_empty() { + return; + } + // 不做本地回显:user 消息由 host 的 MessageAppended 事件带回(契约 §3) + this.state.borrow_mut().ai.draft.clear(); + this.ui.set_ai_draft("".into()); + this.ask_agent(&text); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_ai_stop(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().ai.finish_stream(); + this.dispatch(AppCommand::StopAgent); + this.sync(); + }); + } +} + +fn wire_instances(ui: &crate::MainWindow, weak: &Weak) { + { + let weak = weak.clone(); + ui.on_instance_launch(move |id| { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::LaunchInstance(id.to_string())); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_instance_configure(move |id| { + let Some(this) = weak.upgrade() else { return }; + // 齿轮键进实例详情(G4 占位页),实例级设置后续挂在该页 tab 上 + this.state + .borrow_mut() + .navigate(Route::Detail(id.to_string())); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_instance_stop(move |id| { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::StopInstance(id.to_string())); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_instance_open(move |id| { + let Some(this) = weak.upgrade() else { return }; + this.state + .borrow_mut() + .navigate(Route::Detail(id.to_string())); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_new_instance(move || { + let Some(this) = weak.upgrade() else { return }; + this.state + .borrow_mut() + .toasts + .push(ToastKind::Info, "新建实例向导(G5)在 P1 落地"); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_instance_view_changed(move |view| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.instance_view = conv::instance_view_from(view); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_tile_size_changed(move |size| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().prefs.tile_size = conv::tile_size_from(size); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_instance_query_changed(move |query| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().instance_query = query.to_string(); + this.sync(); + }); + } +} + +fn wire_download(ui: &crate::MainWindow, weak: &Weak) { + { + let weak = weak.clone(); + ui.on_download_category_changed(move |category| { + let Some(this) = weak.upgrade() else { return }; + let category = conv::version_category(category); + this.state.borrow_mut().prefs.download_category = category; + this.dispatch(AppCommand::ListVersions(category)); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_download_install(move |id| { + let Some(this) = weak.upgrade() else { return }; + this.dispatch(AppCommand::InstallVersion(id.to_string())); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_installed_only_changed(move |only| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().installed_only = only; + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_download_query_changed(move |query| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().download_query = query.to_string(); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_ancient_help(move || { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().toasts.push( + ToastKind::Info, + "远古版(classic/alpha/beta)需旧版 Java 与旧启动参数;完整说明页 P1 接入", + ); + this.sync(); + }); + } +} + +fn wire_logs(ui: &crate::MainWindow, weak: &Weak) { + { + let weak = weak.clone(); + ui.on_log_select(move |id| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().selected_session = Some(id.to_string()); + this.dispatch(AppCommand::OpenLogSession(id.to_string())); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_log_ask_ai(move |id| { + let Some(this) = weak.upgrade() else { return }; + let question = format!("帮我看下会话 {id} 为什么崩溃"); + this.state.borrow_mut().navigate(Route::Agent); + this.ask_agent(&question); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_log_apply_fix(move || { + let Some(this) = weak.upgrade() else { return }; + this.state + .borrow_mut() + .toasts + .push(ToastKind::Warn, "一键修复在 P1 接 diagnose 的 fix_action"); + this.sync(); + }); + } +} + +fn wire_settings(ui: &crate::MainWindow, weak: &Weak) { + // 主题三态 + 外观四件在 appearance_bind.rs(拆文件守 400 行) + crate::appearance_bind::wire(ui, weak); + { + let weak = weak.clone(); + ui.on_yolo_changed(move |enabled| { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().ai.yolo = enabled; + this.dispatch(AppCommand::SetYolo(enabled)); + this.sync(); + }); + } + { + let weak = weak.clone(); + ui.on_mcp_changed(move |_enabled| { + let Some(this) = weak.upgrade() else { return }; + this.state + .borrow_mut() + .toasts + .push(ToastKind::Info, "MCP server 开关在 P1 接 agent"); + this.sync(); + }); + } + // P0 只保留入口:设置分组与 AI 端点暂不落状态(P1 接 setting schema)。 + ui.on_settings_section_changed(|_section| {}); + ui.on_ai_endpoint_changed(|_index| {}); +} + +fn wire_overlays(ui: &crate::MainWindow, weak: &Weak) { + { + let weak = weak.clone(); + ui.on_dialog_primary(move || close_current_dialog(&weak)); + } + { + let weak = weak.clone(); + ui.on_dialog_secondary(move || close_current_dialog(&weak)); + } + { + let weak = weak.clone(); + ui.on_dialog_dismissed(move || close_current_dialog(&weak)); + } + { + let weak = weak.clone(); + ui.on_toast_dismissed(move |id| { + let Some(this) = weak.upgrade() else { return }; + if let Ok(id) = id.parse::() { + this.state.borrow_mut().toasts.dismiss(id); + this.sync(); + } + }); + } +} + +fn close_current_dialog(weak: &Weak) { + let Some(this) = weak.upgrade() else { return }; + this.state.borrow_mut().dialogs.close_current(); + this.sync(); +} diff --git a/crates/app/src/conv/agent.rs b/crates/app/src/conv/agent.rs new file mode 100644 index 0000000..bb1ed4a --- /dev/null +++ b/crates/app/src/conv/agent.rs @@ -0,0 +1,103 @@ +//! AI 页与覆盖层:会话消息、对话框(一次一条)、Toast。 + +use crate::dialogs as dlg; +use crate::dto::ChatRole as DtoChatRole; +use crate::state::AppState; +use crate::toast as toast_mod; +use crate::{ChatRole, DialogKind, UiChatMessage, UiDialog, UiToast}; + +/// AI 会话消息。 +pub fn chat(state: &AppState) -> Vec { + state + .ai + .messages + .iter() + .map(|message| UiChatMessage { + role: match message.role { + DtoChatRole::User => ChatRole::User, + DtoChatRole::Assistant => ChatRole::Assistant, + DtoChatRole::Tool => ChatRole::Tool, + DtoChatRole::System => ChatRole::System, + }, + text: message.text.clone().into(), + tool_name: message + .tool + .as_ref() + .map(|tool| tool.name.clone()) + .unwrap_or_default() + .into(), + tool_ok: message.tool.as_ref().is_none_or(|tool| tool.ok), + streaming: message.streaming, + }) + .collect() +} + +/// 当前对话框(一次一条)+ 是否显示 + 队列剩余条数。 +pub fn dialog(state: &AppState) -> (UiDialog, bool, i32) { + let queued = i32::try_from(state.dialogs.pending_len()).unwrap_or(0); + match state.dialogs.current() { + None => (UiDialog::default(), false, queued), + Some(request) => { + let (kind, primary, secondary) = match &request.kind { + dlg::DialogKind::Confirm { primary, secondary } => { + (DialogKind::Confirm, primary.clone(), secondary.clone()) + } + dlg::DialogKind::Input { primary, .. } => { + (DialogKind::Input, primary.clone(), String::new()) + } + dlg::DialogKind::Progress { .. } => { + (DialogKind::Progress, "取消".to_string(), String::new()) + } + dlg::DialogKind::Error { primary, .. } => { + (DialogKind::Error, primary.clone(), "忽略".to_string()) + } + }; + let progress = match &request.kind { + dlg::DialogKind::Progress { progress, .. } => *progress, + _ => 0.0, + }; + let input_label = match &request.kind { + dlg::DialogKind::Input { label, .. } => label.clone(), + _ => String::new(), + }; + let input_value = match &request.kind { + dlg::DialogKind::Input { value, .. } => value.clone(), + _ => String::new(), + }; + ( + UiDialog { + id: request.id.to_string().into(), + kind, + title: request.title.clone().into(), + body: request.body.clone().into(), + primary: primary.into(), + secondary: secondary.into(), + progress, + input_label: input_label.into(), + input_value: input_value.into(), + }, + true, + queued, + ) + } + } +} + +/// Toast 列表。 +pub fn toasts(state: &AppState) -> Vec { + state + .toasts + .items() + .iter() + .map(|toast| UiToast { + id: toast.id.to_string().into(), + kind: match toast.kind { + toast_mod::ToastKind::Info => crate::ToastKind::Info, + toast_mod::ToastKind::Success => crate::ToastKind::Success, + toast_mod::ToastKind::Warn => crate::ToastKind::Warn, + toast_mod::ToastKind::Error => crate::ToastKind::Error, + }, + text: toast.text.clone().into(), + }) + .collect() +} diff --git a/crates/app/src/conv/instances.rs b/crates/app/src/conv/instances.rs new file mode 100644 index 0000000..1d47a93 --- /dev/null +++ b/crates/app/src/conv/instances.rs @@ -0,0 +1,162 @@ +//! 实例相关:磁贴外观(相位/进度/徽标/占位色)、运行中列表。 + +use slint::Color; + +use crate::dto::{self, InstanceRuntime}; +use crate::state::{AppState, TileCell}; +use crate::{LoaderKind, RuntimePhase, UiTile}; + +/// 头像/图标占位的底色池(Metro 强调色系,按 id 哈希稳定取色)。 +const HUES: [u32; 8] = [ + 0x0078D4, 0x8764B0, 0xD13438, 0xCA5010, 0x498205, 0x038387, 0x5C2D91, 0x0050EF, +]; + +/// 加载器枚举。 +pub fn loader(kind: dto::LoaderKind) -> LoaderKind { + match kind { + dto::LoaderKind::Vanilla => LoaderKind::Vanilla, + dto::LoaderKind::Fabric => LoaderKind::Fabric, + dto::LoaderKind::Quilt => LoaderKind::Quilt, + dto::LoaderKind::Forge => LoaderKind::Forge, + dto::LoaderKind::NeoForge => LoaderKind::Neoforge, + dto::LoaderKind::OptiFine => LoaderKind::Optifine, + dto::LoaderKind::LiteLoader => LoaderKind::Liteloader, + } +} + +/// 运行时状态 → (磁贴外观相位, 进度 0~1, 阶段文案)。 +pub fn phase(runtime: &InstanceRuntime) -> (RuntimePhase, f32, String) { + match runtime { + InstanceRuntime::Idle => (RuntimePhase::Idle, 0.0, String::new()), + InstanceRuntime::Busy { stage, progress } => { + let phase = match stage { + dto::LaunchStage::Download => RuntimePhase::Downloading, + dto::LaunchStage::Verify => RuntimePhase::Verifying, + dto::LaunchStage::Launch => RuntimePhase::Launching, + }; + (phase, progress.ratio(), stage.label().to_string()) + } + InstanceRuntime::Running { .. } => (RuntimePhase::Running, 1.0, "运行中".to_string()), + InstanceRuntime::Failed { reason } => (RuntimePhase::Failed, 0.0, reason.clone()), + } +} + +/// 名字首字(无图标时的占位字母)。 +pub fn initial_of(name: &str) -> String { + name.chars() + .next() + .map(|c| c.to_string()) + .unwrap_or_else(|| "?".to_string()) +} + +/// id → 稳定底色(同一实例每次启动同色)。 +pub fn hue_of(id: &str) -> Color { + let hash = id.bytes().fold(0u32, |acc, byte| { + acc.wrapping_mul(31).wrapping_add(u32::from(byte)) + }); + Color::from_argb_encoded(0xFF00_0000 | HUES[(hash as usize) % HUES.len()]) +} + +/// ISO-8601 UTC → 逗号少的本地展示串(`2026-10-29 19:22`);None/空 → 从未启动。 +pub fn last_played(raw: &Option) -> String { + match raw { + None => "从未启动".to_string(), + Some(value) if value.is_empty() => "从未启动".to_string(), + Some(value) => { + let trimmed = value.trim_end_matches('Z'); + let mut out = String::with_capacity(trimmed.len()); + let mut seen_time = false; + for ch in trimmed.chars() { + match ch { + 'T' => { + seen_time = true; + out.push(' '); + } + ':' if seen_time && out.ends_with(|c: char| c.is_ascii_digit()) => { + // 只保留到分钟:秒(第三段冒号之后)直接截断 + if out.matches(':').count() >= 1 { + break; + } + out.push(ch); + } + _ => out.push(ch), + } + } + out + } + } +} + +/// 单个磁贴。 +pub fn tile(cell: &TileCell<'_>) -> UiTile { + let instance = cell.instance; + let (phase, progress, label) = phase(&instance.runtime); + UiTile { + id: instance.id.clone().into(), + name: instance.name.clone().into(), + mc_version: instance.mc_version.clone().into(), + loader: loader(instance.loader.kind), + loader_version: instance.loader.version.clone().into(), + loader_label: instance.loader.badge().into(), + last_played: last_played(&instance.last_played).into(), + phase, + phase_label: label.into(), + progress, + icon: slint::Image::default(), + hue: hue_of(&instance.id), + initial: initial_of(&instance.name).into(), + row: i32::try_from(cell.row).unwrap_or(0), + col: i32::try_from(cell.col).unwrap_or(0), + } +} + +/// 磁贴网格(已按当前尺寸算好 row/col)。 +pub fn tiles(state: &AppState) -> Vec { + state.tiles().iter().map(tile).collect() +} + +/// 运行中实例(顶部迷你状态条)。 +pub fn running_tiles(state: &AppState) -> Vec { + state + .running() + .iter() + .enumerate() + .map(|(index, instance)| { + let cell = TileCell { + instance, + row: 0, + col: index, + }; + tile(&cell) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{hue_of, initial_of, last_played}; + + #[test] + fn initial_is_first_grapheme_or_question_mark() { + assert_eq!(initial_of("生存整合包"), "生"); + assert_eq!(initial_of("Vanilla"), "V"); + assert_eq!(initial_of(""), "?"); + } + + #[test] + fn hue_is_stable_per_id() { + assert_eq!(hue_of("survival"), hue_of("survival")); + // 不同 id 大体不同色(哈希池 8 色,这里只验证确定性 + 不 panic) + let _ = hue_of("another-instance"); + } + + #[test] + fn last_played_formats_iso_to_minutes() { + assert_eq!( + last_played(&Some("2026-10-29T19:22:45Z".to_string())), + "2026-10-29 19:22" + ); + assert_eq!(last_played(&None), "从未启动"); + assert_eq!(last_played(&Some(String::new())), "从未启动"); + } +} diff --git a/crates/app/src/conv/logs.rs b/crates/app/src/conv/logs.rs new file mode 100644 index 0000000..ba72410 --- /dev/null +++ b/crates/app/src/conv/logs.rs @@ -0,0 +1,84 @@ +//! 日志页:会话列表、日志行、D8 诊断卡片。 + +use crate::dto::{LogLevel as DtoLogLevel, LogOutcome as DtoLogOutcome}; +use crate::state::AppState; +use crate::{LogLevel, LogOutcome, UiDiagnosis, UiLogLine, UiLogSession}; + +/// 日志会话列表。 +pub fn log_sessions(state: &AppState) -> Vec { + state + .log_sessions + .iter() + .map(|session| UiLogSession { + id: session.id.clone().into(), + when: session.when.clone().into(), + instance: session.instance.clone().into(), + outcome: match session.outcome { + DtoLogOutcome::Ok => LogOutcome::Ok, + DtoLogOutcome::Crashed => LogOutcome::Crashed, + DtoLogOutcome::SilentFail => LogOutcome::SilentFail, + }, + duration: duration(session.duration_secs).into(), + }) + .collect() +} + +/// 秒 → `1h03m` / `62s`。 +pub fn duration(secs: u64) -> String { + if secs >= 3_600 { + format!("{}h{:02}m", secs / 3_600, (secs % 3_600) / 60) + } else if secs >= 60 { + format!("{}m{:02}s", secs / 60, secs % 60) + } else { + format!("{secs}s") + } +} + +/// 当前会话日志行。 +pub fn log_lines(state: &AppState) -> Vec { + state + .log_lines + .iter() + .map(|line| UiLogLine { + text: line.text.clone().into(), + level: match line.level { + DtoLogLevel::Debug => LogLevel::Debug, + DtoLogLevel::Info => LogLevel::Info, + DtoLogLevel::Warn => LogLevel::Warn, + DtoLogLevel::Error => LogLevel::Error, + }, + }) + .collect() +} + +/// 诊断卡片内容(无结论时返回默认值 + has=false)。 +pub fn diagnosis(state: &AppState) -> (UiDiagnosis, bool) { + match &state.diagnosis { + None => (UiDiagnosis::default(), false), + Some(report) => { + let diagnosis = &report.diagnosis; + ( + UiDiagnosis { + rule: diagnosis.rule.clone().into(), + title: diagnosis.title.clone().into(), + confidence: i32::from(diagnosis.confidence), + hint: diagnosis.hint.clone().into(), + has_fix: diagnosis.fix.is_some(), + }, + true, + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::duration; + + #[test] + fn duration_is_human_readable() { + assert_eq!(duration(8), "8s"); + assert_eq!(duration(62), "1m02s"); + assert_eq!(duration(3_780), "1h03m"); + } +} diff --git a/crates/app/src/conv/mod.rs b/crates/app/src/conv/mod.rs new file mode 100644 index 0000000..d556a50 --- /dev/null +++ b/crates/app/src/conv/mod.rs @@ -0,0 +1,21 @@ +//! 状态层 → Slint 视图模型的单向转换(DTO 值 → `models.slint` 结构体)。 +//! +//! 只做「领域值 → 展示值」的搬运与格式化,不含业务判断; +//! 这样 `ui_bind.rs` 里的同步逻辑就是纯粹的「状态灌进属性」。 +//! 按领域拆子模块:instances / versions / logs / agent / ui,全量 `pub use`, +//! 调用方统一写 `crate::conv::tiles(...)` 这类路径。 + +mod agent; +mod instances; +mod logs; +mod ui; +mod versions; + +pub use agent::{chat, dialog, toasts}; +pub use instances::{running_tiles, tiles}; +pub use logs::{diagnosis, log_lines, log_sessions}; +pub use ui::{ + accent_color, bg_fill, bg_fill_from, category_from, instance_view, instance_view_from, route, + route_from, theme_mode, theme_mode_from, tile_size, tile_size_from, version_category, +}; +pub use versions::versions; diff --git a/crates/app/src/conv/ui.rs b/crates/app/src/conv/ui.rs new file mode 100644 index 0000000..22c6d20 --- /dev/null +++ b/crates/app/src/conv/ui.rs @@ -0,0 +1,136 @@ +//! UI 偏好/路由枚举的双向映射(Slint 枚举 ↔ 状态层枚举)。 + +use crate::dto::{self, VersionCategory}; +use crate::prefs; +use crate::router; +use crate::{BgFillKind, DownloadCategory, InstanceView, RouteKind, ThemeMode, TileSize}; + +/// 路由 → 导航高亮/页面选择。 +pub fn route(route: &router::Route) -> RouteKind { + match route { + router::Route::Instances => RouteKind::Instances, + router::Route::Download => RouteKind::Download, + router::Route::Logs => RouteKind::Logs, + router::Route::Settings => RouteKind::Settings, + router::Route::Detail(_) => RouteKind::Detail, + router::Route::Agent => RouteKind::Agent, + } +} + +/// Slint 侧导航点击 → 路由(详情页保持当前实例,不在导航里触发)。 +pub fn route_from(kind: RouteKind) -> Option { + match kind { + RouteKind::Instances => Some(router::Route::Instances), + RouteKind::Download => Some(router::Route::Download), + RouteKind::Logs => Some(router::Route::Logs), + RouteKind::Settings => Some(router::Route::Settings), + RouteKind::Agent => Some(router::Route::Agent), + RouteKind::Detail => None, + } +} + +/// 磁贴尺寸。 +pub fn tile_size(size: prefs::TileSize) -> TileSize { + match size { + prefs::TileSize::Small => TileSize::Small, + prefs::TileSize::Medium => TileSize::Medium, + prefs::TileSize::Large => TileSize::Large, + } +} + +/// 视图模式。 +pub fn instance_view(view: prefs::InstanceView) -> InstanceView { + match view { + prefs::InstanceView::Tiles => InstanceView::Tiles, + prefs::InstanceView::List => InstanceView::List, + } +} + +/// 主题三态。 +pub fn theme_mode(mode: prefs::ThemeMode) -> ThemeMode { + match mode { + prefs::ThemeMode::Auto => ThemeMode::Auto, + prefs::ThemeMode::Light => ThemeMode::Light, + prefs::ThemeMode::Dark => ThemeMode::Dark, + } +} + +/// Slint 主题三态 → 偏好枚举。 +pub fn theme_mode_from(mode: ThemeMode) -> prefs::ThemeMode { + match mode { + ThemeMode::Auto => prefs::ThemeMode::Auto, + ThemeMode::Light => prefs::ThemeMode::Light, + ThemeMode::Dark => prefs::ThemeMode::Dark, + } +} + +/// Slint 磁贴尺寸 → 偏好枚举。 +pub fn tile_size_from(size: TileSize) -> prefs::TileSize { + match size { + TileSize::Small => prefs::TileSize::Small, + TileSize::Medium => prefs::TileSize::Medium, + TileSize::Large => prefs::TileSize::Large, + } +} + +/// Slint 视图模式 → 偏好枚举。 +pub fn instance_view_from(view: InstanceView) -> prefs::InstanceView { + match view { + InstanceView::Tiles => prefs::InstanceView::Tiles, + InstanceView::List => prefs::InstanceView::List, + } +} + +/// 下载分类 → core 侧分类枚举。 +pub fn version_category(category: DownloadCategory) -> VersionCategory { + match category { + DownloadCategory::Release => VersionCategory::Release, + DownloadCategory::Snapshot => VersionCategory::Snapshot, + DownloadCategory::AncientClassic => VersionCategory::Ancient(dto::AncientKind::Classic), + DownloadCategory::AncientAlpha => VersionCategory::Ancient(dto::AncientKind::OldAlpha), + DownloadCategory::AncientBeta => VersionCategory::Ancient(dto::AncientKind::OldBeta), + DownloadCategory::AncientUnlisted => VersionCategory::Ancient(dto::AncientKind::Unlisted), + DownloadCategory::Modpack => VersionCategory::Modpack, + DownloadCategory::Mod => VersionCategory::Mod, + } +} + +/// 下载分类(偏好 → Slint)——供启动时回填。 +pub fn category_from(category: VersionCategory) -> DownloadCategory { + match category { + VersionCategory::Release => DownloadCategory::Release, + VersionCategory::Snapshot => DownloadCategory::Snapshot, + VersionCategory::Ancient(dto::AncientKind::Classic) => DownloadCategory::AncientClassic, + VersionCategory::Ancient(dto::AncientKind::OldAlpha) => DownloadCategory::AncientAlpha, + VersionCategory::Ancient(dto::AncientKind::OldBeta) => DownloadCategory::AncientBeta, + VersionCategory::Ancient(dto::AncientKind::Unlisted) => DownloadCategory::AncientUnlisted, + VersionCategory::Modpack => DownloadCategory::Modpack, + VersionCategory::Mod => DownloadCategory::Mod, + } +} + +/// 背景图填充模式(core 侧枚举 → Slint)。 +pub fn bg_fill(fill: unlucky_core::setting::appearance::BgFill) -> BgFillKind { + match fill { + unlucky_core::setting::appearance::BgFill::Cover => BgFillKind::Cover, + unlucky_core::setting::appearance::BgFill::Contain => BgFillKind::Contain, + unlucky_core::setting::appearance::BgFill::Tile => BgFillKind::Tile, + } +} + +/// Slint 填充模式 → core 侧枚举。 +pub fn bg_fill_from(fill: BgFillKind) -> unlucky_core::setting::appearance::BgFill { + match fill { + BgFillKind::Cover => unlucky_core::setting::appearance::BgFill::Cover, + BgFillKind::Contain => unlucky_core::setting::appearance::BgFill::Contain, + BgFillKind::Tile => unlucky_core::setting::appearance::BgFill::Tile, + } +} + +/// 强调色十六进制串 → Slint 颜色;空串/非法值给透明(= 用出厂 accent)。 +pub fn accent_color(hex: &str) -> slint::Color { + match unlucky_core::setting::appearance::hex_to_rgb(hex) { + Some((r, g, b)) => slint::Color::from_rgb_u8(r, g, b), + None => slint::Color::from_argb_u8(0, 0, 0, 0), + } +} diff --git a/crates/app/src/conv/versions.rs b/crates/app/src/conv/versions.rs new file mode 100644 index 0000000..35e5667 --- /dev/null +++ b/crates/app/src/conv/versions.rs @@ -0,0 +1,81 @@ +//! 版本列表与下载分类映射(下载页)。 + +use crate::dto::{AncientKind, VersionKind as DtoVersionKind}; +use crate::state::AppState; +use crate::{UiVersion, VersionKind}; + +/// 下载页版本行(已按「仅已装」+ 版本号筛选词过滤)。 +pub fn versions(state: &AppState) -> Vec { + let query = state.download_query.trim().to_lowercase(); + state + .versions + .iter() + .filter(|version| !state.installed_only || version.installed) + .filter(|version| query.is_empty() || version.id.to_lowercase().contains(&query)) + .map(|version| UiVersion { + id: version.id.clone().into(), + kind: version_kind(version.kind), + released: version.released.clone().into(), + installed: version.installed, + ancient: version.kind.is_ancient(), + }) + .collect() +} + +fn version_kind(kind: DtoVersionKind) -> VersionKind { + match kind { + DtoVersionKind::Release => VersionKind::Release, + DtoVersionKind::Snapshot => VersionKind::Snapshot, + DtoVersionKind::Ancient(AncientKind::Classic) => VersionKind::AncientClassic, + DtoVersionKind::Ancient(AncientKind::OldAlpha) => VersionKind::AncientAlpha, + DtoVersionKind::Ancient(AncientKind::OldBeta) => VersionKind::AncientBeta, + DtoVersionKind::Ancient(AncientKind::Unlisted) => VersionKind::AncientUnlisted, + } +} + +#[cfg(test)] +mod tests { + use super::versions; + use crate::dto::{VersionKind, VersionSummary}; + use crate::state::AppState; + + fn state_with_versions() -> AppState { + let mut state = AppState::new(); + let row = |id: &str, installed: bool| VersionSummary { + id: id.to_string(), + kind: VersionKind::Release, + released: "2026-01-01".to_string(), + installed, + }; + state.versions = vec![ + row("1.21.9", false), + row("1.20.4", true), + row("1.20.1", true), + ]; + state + } + + #[test] + fn installed_only_filter_hides_uninstalled_versions() { + let mut state = state_with_versions(); + assert_eq!(versions(&state).len(), 3); + + state.installed_only = true; + let filtered = versions(&state); + assert_eq!(filtered.len(), 2); + assert!(filtered.iter().all(|version| version.installed)); + } + + #[test] + fn query_filters_by_version_id_substring() { + let mut state = state_with_versions(); + state.download_query = "1.20".to_string(); + let filtered = versions(&state); + assert_eq!(filtered.len(), 2); + + state.download_query = "1.21".to_string(); + state.installed_only = true; + // 「仅已装」优先:1.21.9 未安装 → 空列表 + assert!(versions(&state).is_empty()); + } +} diff --git a/crates/app/src/dialogs.rs b/crates/app/src/dialogs.rs new file mode 100644 index 0000000..7097d57 --- /dev/null +++ b/crates/app/src/dialogs.rs @@ -0,0 +1,303 @@ +//! 对话框队列(HMCL 队列语义 + Metro 化,09 §3)。 +//! +//! 语义:**一次只显示一条**。新请求按来源插队(用户操作 > 后台任务 > 提示),同来源 FIFO; +//! 当前条被确认/取消后由 [`DialogQueue::advance`] 推进到下一条,UI 只渲染 `current`。 +//! 非阻塞提示走 [`crate::toast`],不占模态队列。 + +/// 对话框标识。 +pub type DialogId = u64; + +/// 对话框来源:决定插队优先级。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum DialogSource { + /// 提示类(教程、首次使用说明) + Hint = 0, + /// 后台任务(下载完成、需要选择镜像源) + BackgroundTask = 1, + /// 用户操作触发(AI 敏感写操作的二次确认) + UserAction = 2, +} + +impl DialogSource { + /// 数值越大越优先。 + pub fn priority(self) -> u8 { + self as u8 + } +} + +/// 对话框类型(09 §3:确认 / 进度 / 输入 / 错误)。 +#[derive(Debug, Clone, PartialEq)] +pub enum DialogKind { + /// 确认:主按钮 + 次按钮 + Confirm { + /// 主按钮文字(如「允许」) + primary: String, + /// 次按钮文字(如「拒绝」) + secondary: String, + }, + /// 输入:单行文本 + Input { + /// 输入框标签 + label: String, + /// 初始值 + value: String, + /// 主按钮文字 + primary: String, + }, + /// 进度:不可点主按钮(任务结束后由 [`crate::dto::AppEvent::DialogClosed`] 收起) + Progress { + /// 阶段文案 + label: String, + /// 0.0~1.0 + progress: f32, + }, + /// 错误:含诊断入口提示 + Error { + /// 主按钮文字 + primary: String, + /// 诊断/修复提示 + hint: Option, + }, +} + +impl DialogKind { + /// 主按钮文字。 + pub fn primary_label(&self) -> &str { + match self { + Self::Confirm { primary, .. } + | Self::Input { primary, .. } + | Self::Error { primary, .. } => primary, + Self::Progress { .. } => "取消", + } + } + + /// 次按钮文字(None 表示不显示次按钮)。 + pub fn secondary_label(&self) -> Option<&str> { + match self { + Self::Confirm { secondary, .. } => Some(secondary), + Self::Error { .. } => Some("忽略"), + Self::Input { .. } | Self::Progress { .. } => None, + } + } + + /// 是否可交互(进度条不可点主按钮)。 + pub fn is_blocking_progress(&self) -> bool { + matches!(self, Self::Progress { .. }) + } +} + +/// 一条对话框请求。 +#[derive(Debug, Clone, PartialEq)] +pub struct DialogRequest { + /// 队列内唯一 id(由 [`DialogQueue::push_request`] 重新编号) + pub id: DialogId, + /// 来源(决定插队优先级) + pub source: DialogSource, + /// 标题 + pub title: String, + /// 正文 + pub body: String, + /// 类型与按钮 + pub kind: DialogKind, +} + +/// 模态队列:一次一条 + 来源插队。 +#[derive(Debug, Default)] +pub struct DialogQueue { + current: Option, + pending: Vec, + next_id: DialogId, +} + +impl DialogQueue { + /// 新建空队列。 + pub fn new() -> Self { + Self::default() + } + + /// 当前应显示的对话框。 + pub fn current(&self) -> Option<&DialogRequest> { + self.current.as_ref() + } + + /// 排队中(不含当前)条数。 + pub fn pending_len(&self) -> usize { + self.pending.len() + } + + /// 是否完全没有对话框(当前 + 排队)。 + pub fn is_empty(&self) -> bool { + self.current.is_none() && self.pending.is_empty() + } + + /// 入队一条请求(UI 内部调用方用,id 由队列自增分配)。没有当前条时立即成为当前条。 + /// + /// 返回值用于后续 [`Self::close`] / 进度更新。 + pub fn push( + &mut self, + source: DialogSource, + title: impl Into, + body: impl Into, + kind: DialogKind, + ) -> DialogId { + self.next_id += 1; + self.push_request(DialogRequest { + id: self.next_id, + source, + title: title.into(), + body: body.into(), + kind, + }) + } + + /// 入队一条已经组装好的请求(事件通道用)。 + /// + /// **id 归生产者**:这里原样保留 `request.id`(不重编号),因为关闭事件 + /// [`crate::dto::AppEvent::DialogClosed`] 携带的是生产者 id;重编号会让 + /// 「任务完成 → 自动收起进度框」在多来源弹框时静默失配。生产者负责自身 id 唯一。 + pub fn push_request(&mut self, request: DialogRequest) -> DialogId { + let id = request.id; + let source = request.source; + if self.current.is_none() { + self.current = Some(request); + } else { + let at = self + .pending + .iter() + .position(|queued| queued.source < source) + .unwrap_or(self.pending.len()); + self.pending.insert(at, request); + } + id + } + + /// 关闭指定对话框:若是当前条则推进到下一条,否则从队列移除。 + /// + /// 返回 `true` 表示确实移除了。 + pub fn close(&mut self, id: DialogId) -> bool { + if self + .current + .as_ref() + .is_some_and(|current| current.id == id) + { + self.advance(); + return true; + } + if let Some(at) = self.pending.iter().position(|queued| queued.id == id) { + self.pending.remove(at); + return true; + } + false + } + + /// 推进到下一条排队请求(当前条已被处理)。 + pub fn advance(&mut self) { + self.current = if self.pending.is_empty() { + None + } else { + Some(self.pending.remove(0)) + }; + } + + /// 关闭当前条并推进到下一条(确认 / 取消 / 点遮罩都走这里)。 + /// + /// 返回 `false` 表示本来就没有当前条(幂等,双击不会误推进队列)。 + pub fn close_current(&mut self) -> bool { + if self.current.is_none() { + return false; + } + self.advance(); + true + } + + /// 更新进度对话框的进度(任务回调直接喂进来)。 + pub fn update_progress(&mut self, id: DialogId, progress: f32) { + let Some(current) = &mut self.current else { + return; + }; + if current.id != id { + return; + } + if let DialogKind::Progress { + progress: value, .. + } = &mut current.kind + { + *value = progress.clamp(0.0, 1.0); + } + } +} + +#[cfg(test)] +mod tests { + use super::{DialogKind, DialogQueue, DialogSource}; + + fn confirm() -> DialogKind { + DialogKind::Confirm { + primary: "确定".into(), + secondary: "取消".into(), + } + } + + #[test] + fn shows_one_dialog_at_a_time() { + let mut queue = DialogQueue::new(); + let first = queue.push(DialogSource::BackgroundTask, "A", "", confirm()); + let second = queue.push(DialogSource::BackgroundTask, "B", "", confirm()); + + assert_eq!(queue.current().map(|d| d.id), Some(first)); + assert_eq!(queue.pending_len(), 1); + + queue.advance(); + assert_eq!(queue.current().map(|d| d.id), Some(second)); + assert_eq!(queue.pending_len(), 0); + + queue.advance(); + assert!(queue.is_empty()); + } + + #[test] + fn user_action_jumps_the_queue() { + let mut queue = DialogQueue::new(); + queue.push(DialogSource::Hint, "提示", "", confirm()); + queue.push(DialogSource::Hint, "提示2", "", confirm()); + queue.push(DialogSource::BackgroundTask, "后台", "", confirm()); + let urgent = queue.push(DialogSource::UserAction, "确认删除", "", confirm()); + + // 当前仍是队首(一次一条),但排队里用户操作插到最前 → 关掉当前条后立刻轮到它 + queue.advance(); + assert_eq!(queue.current().map(|d| d.id), Some(urgent)); + } + + #[test] + fn closing_non_current_dialog_removes_it_from_pending() { + let mut queue = DialogQueue::new(); + let first = queue.push(DialogSource::Hint, "A", "", confirm()); + let second = queue.push(DialogSource::Hint, "B", "", confirm()); + + assert!(queue.close(second)); + assert_eq!(queue.pending_len(), 0); + assert_eq!(queue.current().map(|d| d.id), Some(first)); + assert!(!queue.close(999)); + } + + #[test] + fn progress_dialog_can_be_updated() { + let mut queue = DialogQueue::new(); + let id = queue.push( + DialogSource::BackgroundTask, + "下载中", + "", + DialogKind::Progress { + label: "资源".into(), + progress: 0.0, + }, + ); + queue.update_progress(id, 1.7); + match queue.current().map(|d| &d.kind) { + Some(DialogKind::Progress { progress, .. }) => { + assert!((*progress - 1.0).abs() < f32::EPSILON); + } + other => panic!("unexpected dialog kind: {other:?}"), + } + } +} diff --git a/crates/app/src/dto/agent.rs b/crates/app/src/dto/agent.rs new file mode 100644 index 0000000..1ecfd29 --- /dev/null +++ b/crates/app/src/dto/agent.rs @@ -0,0 +1,95 @@ +//! AI 会话 DTO:消息、工具调用可视化与 agent 事件。 + +/// 会话消息角色。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatRole { + /// 用户 + User, + /// 助手(可流式) + Assistant, + /// 工具调用(会话里可见,D4 要求) + Tool, + /// 系统提示(弱化展示) + System, +} + +/// 工具调用可视化条目。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolCallView { + /// 工具名(与 ToolSpec 注册表同名) + pub name: String, + /// 是否成功 + pub ok: bool, + /// 一行结果摘要 + pub summary: String, +} + +/// 一条会话消息。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatMessage { + /// 角色 + pub role: ChatRole, + /// 正文(流式时持续追加) + pub text: String, + /// role == Tool 时带上工具调用详情 + pub tool: Option, + /// 流式未收敛 + pub streaming: bool, +} + +impl ChatMessage { + /// 用户消息。 + pub fn user(text: impl Into) -> Self { + Self { + role: ChatRole::User, + text: text.into(), + tool: None, + streaming: false, + } + } + + /// 助手流式消息起点(空文本 + streaming)。 + pub fn assistant_stream() -> Self { + Self { + role: ChatRole::Assistant, + text: String::new(), + tool: None, + streaming: true, + } + } +} + +/// agent 侧事件。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentEvent { + /// 追加一条完整消息 + MessageAppended(ChatMessage), + /// 给指定序号的流式消息追加增量 + MessageDelta { + /// 目标消息在会话里的序号 + index: usize, + /// 增量文本 + delta: String, + }, + /// 流式收敛 + MessageFinished { + /// 目标消息序号 + index: usize, + }, + /// 开始一次工具调用(会话里插入工具条目) + ToolCallStarted { + /// 工具名 + name: String, + /// 调用参数摘要 + summary: String, + }, + /// 工具调用结束 + ToolCallFinished { + /// 工具名 + name: String, + /// 是否成功 + ok: bool, + /// 结果摘要 + summary: String, + }, +} diff --git a/crates/app/src/dto/events.rs b/crates/app/src/dto/events.rs new file mode 100644 index 0000000..2de323e --- /dev/null +++ b/crates/app/src/dto/events.rs @@ -0,0 +1,86 @@ +//! 事件与指令:core/agent → app 的事件队列,app → core/agent 的用户意图。 + +use crate::dialogs::{DialogId, DialogRequest}; +use crate::dto::{AgentEvent, DiagnosisReport, InstanceId, InstanceRuntime, InstanceSummary}; +use crate::dto::{LogLine, LogSession, SessionId, VersionCategory, VersionSummary}; +use crate::toast::Toast; + +/// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。 +#[derive(Debug, Clone, PartialEq)] +pub enum CoreEvent { + /// 实例列表整体刷新 + InstancesChanged(Vec), + /// 单个实例运行时状态变化(启动进度/运行中/失败) + InstanceRuntimeChanged { + /// 实例 id + id: InstanceId, + /// 新状态 + runtime: InstanceRuntime, + }, + /// 下载页版本列表刷新 + VersionListChanged(Vec), + /// 日志会话列表刷新 + LogSessionsChanged(Vec), + /// 当前会话日志行刷新(P0 整批替换;P1 改增量追加) + LogLinesChanged(Vec), + /// 诊断结论产出 + DiagnosisReady(DiagnosisReport), +} + +/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路, +/// 这样 [`crate::state::AppState::apply`] 是唯一的状态变更入口。 +#[derive(Debug, Clone, PartialEq)] +pub enum AppEvent { + /// 来自 core + Core(CoreEvent), + /// 来自 agent + Agent(AgentEvent), + /// core/agent 请求弹对话框(会进队列,一次一条) + DialogRequested(DialogRequest), + /// 关闭指定对话框(任务完成收起进度条) + DialogClosed(DialogId), + /// 右下角 Toast + Toast(Toast), +} + +/// app → core/agent 的用户意图。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppCommand { + /// 重新扫描实例目录 + RefreshInstances, + /// 启动实例 + LaunchInstance(InstanceId), + /// 停止实例(含取消启动中的管线) + StopInstance(InstanceId), + /// 拉取某分类的版本列表 + ListVersions(VersionCategory), + /// 安装指定版本 + InstallVersion(String), + /// 刷新日志会话列表 + RefreshLogSessions, + /// 打开某个会话(拉取日志行 + 触发诊断) + OpenLogSession(SessionId), + /// 向 AI 提问(会话状态在 app 侧,agent 只回事件) + AskAgent(String), + /// 中断当前 AI 流 + StopAgent, + /// YOLO 开关(跳过确认,审计照记) + SetYolo(bool), +} + +/// 后端通道:core/agent host 实现它;P0 用 [`crate::mock::MockBackend`] 验证通道语义。 +/// +/// 轮询模型(而非回调)是为了让 Slint 的定时器成为唯一调度者, +/// 避免跨线程回调里碰 Slint 模型。 +pub trait Backend { + /// 取走自上次调用以来积压的事件(FIFO,不可丢序号)。 + fn drain_events(&mut self) -> Vec; + + /// 下发一条用户意图。 + fn dispatch(&mut self, command: AppCommand); + + /// 时间推进(mock 用脚本推进进度;真实后端可以是空实现)。 + fn tick(&mut self, elapsed: std::time::Duration) { + let _ = elapsed; + } +} diff --git a/crates/app/src/dto/instances.rs b/crates/app/src/dto/instances.rs new file mode 100644 index 0000000..41e0ec8 --- /dev/null +++ b/crates/app/src/dto/instances.rs @@ -0,0 +1,151 @@ +//! 实例相关 DTO:磁贴与列表视图的唯一数据源。 + +use std::path::PathBuf; + +/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。 +pub type InstanceId = String; + +/// 加载器种类(磁贴徽标 + 详情页版本链)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoaderKind { + /// 原版(无加载器) + Vanilla, + /// Fabric + Fabric, + /// Quilt + Quilt, + /// Forge + Forge, + /// NeoForge + NeoForge, + /// OptiFine + OptiFine, + /// LiteLoader + LiteLoader, +} + +impl LoaderKind { + /// 徽标显示文字。 + pub fn label(self) -> &'static str { + match self { + Self::Vanilla => "原版", + Self::Fabric => "Fabric", + Self::Quilt => "Quilt", + Self::Forge => "Forge", + Self::NeoForge => "NeoForge", + Self::OptiFine => "OptiFine", + Self::LiteLoader => "LiteLoader", + } + } +} + +/// 加载器 + 版本(例如 Fabric 0.16.5)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoaderInfo { + /// 加载器种类 + pub kind: LoaderKind, + /// 加载器版本号;原版为空串 + pub version: String, +} + +impl LoaderInfo { + /// 徽标文字:原版只写「原版」,装加载器时写「Fabric 0.16.5」。 + pub fn badge(&self) -> String { + if self.kind == LoaderKind::Vanilla || self.version.is_empty() { + self.kind.label().to_string() + } else { + format!("{} {}", self.kind.label(), self.version) + } + } +} + +/// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Progress { + /// 已完成量 + pub done: u64, + /// 总量;0 = 未知 + pub total: u64, +} + +impl Progress { + /// 0.0~1.0;未知总量返回 0.0。 + pub fn ratio(self) -> f32 { + if self.total == 0 { + 0.0 + } else { + (self.done as f32 / self.total as f32).clamp(0.0, 1.0) + } + } + + /// 总量是否已知。 + pub fn is_known(self) -> bool { + self.total > 0 + } +} + +/// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LaunchStage { + /// 下载资源 + Download, + /// 校验文件 + Verify, + /// 组装命令行并拉起进程 + Launch, +} + +impl LaunchStage { + /// 磁贴上的阶段文案。 + pub fn label(self) -> &'static str { + match self { + Self::Download => "下载资源", + Self::Verify => "校验文件", + Self::Launch => "启动中", + } + } +} + +/// 实例运行时状态(磁贴的四种外观)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum InstanceRuntime { + /// 空闲 + #[default] + Idle, + /// 启动管线进行中 + Busy { + /// 当前阶段 + stage: LaunchStage, + /// 该阶段进度 + progress: Progress, + }, + /// 游戏进程存活 + Running { + /// 进程号(停止/诊断用) + pid: u32, + }, + /// 上次启动失败(磁贴显示「重试」) + Failed { + /// 失败原因(用户可见文案) + reason: String, + }, +} + +/// 实例摘要:磁贴/列表视图唯一数据源。 +#[derive(Debug, Clone, PartialEq)] +pub struct InstanceSummary { + /// 稳定 id + pub id: InstanceId, + /// 显示名 + pub name: String, + /// 游戏版本(如 1.20.4) + pub mc_version: String, + /// 加载器信息 + pub loader: LoaderInfo, + /// ISO-8601 UTC 最后游玩时间;None = 从未启动 + pub last_played: Option, + /// 运行时状态 + pub runtime: InstanceRuntime, + /// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄) + pub icon: Option, +} diff --git a/crates/app/src/dto/logs.rs b/crates/app/src/dto/logs.rs new file mode 100644 index 0000000..f73a75f --- /dev/null +++ b/crates/app/src/dto/logs.rs @@ -0,0 +1,76 @@ +//! 日志页 DTO:会话列表、日志行与 D8 诊断结论。 + +/// 一次启动会话(日志页左栏单位)标识。 +pub type SessionId = String; + +/// 一次运行的结局(G3 左栏图标:✓ 正常 / ✗ 崩溃 / ⚠ 静默失败)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogOutcome { + /// 正常退出 + Ok, + /// 崩溃(非零退出码或 hs_err_pid) + Crashed, + /// exit0 但游戏没起来(D8 新增检测) + SilentFail, +} + +/// 会话摘要。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogSession { + /// 会话 id + pub id: SessionId, + /// 实例名(会话属于哪个实例) + pub instance: String, + /// 展示用时间(`10-29 19:22`) + pub when: String, + /// 结局 + pub outcome: LogOutcome, + /// 时长(秒) + pub duration_secs: u64, +} + +/// 日志行等级(错误行高亮)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogLevel { + /// debug + Debug, + /// info + Info, + /// warn + Warn, + /// error + Error, +} + +/// 一行日志。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogLine { + /// 原始文本(含时间戳前缀) + pub text: String, + /// 等级 + pub level: LogLevel, +} + +/// D8 规则引擎结论(日志页崩溃诊断卡片)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Diagnosis { + /// 命中规则 id(`gpu.driver_old`) + pub rule: String, + /// 结论标题 + pub title: String, + /// 0~100 置信度 + pub confidence: u8, + /// 建议文案 + pub hint: String, + /// 可一键执行的修复动作(无则只给建议) + pub fix: Option, +} + +/// 诊断结论 + 归属会话(UI 用它决定卡片显隐)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiagnosisReport { + /// 归属会话 + pub session: SessionId, + /// 诊断内容 + pub diagnosis: Diagnosis, +} diff --git a/crates/app/src/dto/mod.rs b/crates/app/src/dto/mod.rs new file mode 100644 index 0000000..28c9c4a --- /dev/null +++ b/crates/app/src/dto/mod.rs @@ -0,0 +1,23 @@ +//! app ↔ core/agent 的 IPC 边界(01c §5 纪律)。 +//! +//! 这里只允许出现「扁平 DTO / 事件 / 指令」:`crates/app` 任何模块都不得 `use unlucky_core::*` +//! 或 `use unlucky_agent::*` 的内部类型。core/agent 侧的 host 层负责把自身类型翻译成本模块的类型, +//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架。 +//! +//! 结构:**按领域拆子模块**(instances / versions / logs / agent / events), +//! 全量 `pub use` 出去,调用方依旧写 `crate::dto::InstanceSummary` 这类路径。 +//! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`。 + +mod agent; +mod events; +mod instances; +mod logs; +mod versions; + +pub use agent::{AgentEvent, ChatMessage, ChatRole, ToolCallView}; +pub use events::{AppCommand, AppEvent, Backend, CoreEvent}; +pub use instances::{ + InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo, LoaderKind, Progress, +}; +pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId}; +pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary}; diff --git a/crates/app/src/dto/versions.rs b/crates/app/src/dto/versions.rs new file mode 100644 index 0000000..eb2218f --- /dev/null +++ b/crates/app/src/dto/versions.rs @@ -0,0 +1,60 @@ +//! 下载页 DTO:版本列表与分类(含远古版子类,D16/Q24-4)。 + +/// 版本类型(远古版按 unlisted-versions 的分类拆开)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VersionKind { + /// 正式版 + Release, + /// 预览版(snapshot) + Snapshot, + /// 远古版(带子类) + Ancient(AncientKind), +} + +/// 远古版子类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AncientKind { + /// classic(rd-*) + Classic, + /// old_alpha(a1.*) + OldAlpha, + /// old_beta(b1.*) + OldBeta, + /// 未列入官方清单的远古版汇总 + Unlisted, +} + +impl VersionKind { + /// 是否属于「远古版」——列表行要加警示徽标。 + pub fn is_ancient(self) -> bool { + matches!(self, Self::Ancient(_)) + } +} + +/// 下载页版本行。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionSummary { + /// 版本 id(如 `1.20.4` / `rd-132211`) + pub id: String, + /// 版本类型 + pub kind: VersionKind, + /// 发布日期(ISO-8601 日期部分) + pub released: String, + /// 本地是否已安装(行尾显示「已装」) + pub installed: bool, +} + +/// 下载页左侧分类(页内常驻侧栏的选中项)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VersionCategory { + /// 正式版 + Release, + /// 预览版 + Snapshot, + /// 远古版某子类 + Ancient(AncientKind), + /// 整合包(P1) + Modpack, + /// Mod(P1) + Mod, +} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs new file mode 100644 index 0000000..bea5596 --- /dev/null +++ b/crates/app/src/main.rs @@ -0,0 +1,94 @@ +//! Unlucky-Launcher 桌面壳(`unlucky_app`):Slint UI + Metro 视觉。 +//! +//! 边界(决策 D3 / D16):本 crate 是**唯一**允许依赖 UI 框架的 crate; +//! 它只经「快照 + 事件流 + 注入的显示服务」消费 core/agent,不直接触碰其内部类型 +//! (P0 的 host 是 `mock` 假后端,`crate::dto` 是唯一跨界类型集合)。 +//! +//! 渲染策略(`DevDocs/PlanDoc/01c-native-gui-options.md` §6): +//! GL 优先(femtovg),启动参数 `--software-renderer` 或 Win7 老机探测失败时切 CPU 软渲染。 +//! +//! 主题与外观(D17 / 09 §101-113): +//! - 主题三态 `appearance.theme_mode ∈ {auto, light, dark}`(出厂 auto); +//! - 「外观(高级)」四件(背景图 / 面板半透明度 / 玻璃拟态 / 强调色)出厂全关; +//! - 全部走 `core::setting` 的 schema(`crate::settings_bridge`),AI 可写并且**热切** +//! (tick 里 1s 一次 mtime 比对);系统主题由 `core::platform::theme_probe` 对齐 + 兜底轮询。 +//! +//! 架构分层(本文件只有一个职责:把窗口拉起来): +//! - `state.rs` / `prefs.rs` / `router.rs` / `dialogs.rs` / `toast.rs`:纯 Rust 状态(可单测,无 Slint) +//! - `dto/`:跨边界 DTO 与事件/指令 +//! - `conv.rs`:状态 → Slint 视图模型 +//! - `ui_bind.rs` + `callbacks.rs`:窗口/状态/后端缝合 + 回调接线 +//! - `settings_bridge.rs`:`config/launcher.json` 的读写与热切(core::setting 的 schema API) +//! - `bg_image.rs` + `png.rs`:背景图/模糊层加工与截图编码(09 §101/§103 的预生成手法) +//! - `mock.rs` + `mock_data.rs`:P0 假后端(脚本化事件) +//! +//! 关于 unsafe:本 crate 不写 unsafe,workspace lint 已设 `unsafe_code = "deny"`。 +//! 这里不能升级成 `#![forbid(unsafe_code)]`——Slint 生成的代码自带 `#[allow(unsafe_code)]`, +//! 而 forbid 无法被内层 allow 覆盖(会直接编译失败)。同理 Slint 生成类型不带文档注释, +//! 只能在 crate 级放开 `missing_docs`(库 crate 仍由 workspace lint 保持 warn)。 +// 关于 dead_code:P0 骨架里有一批「已定义、等接线」的公共 API——事件通道 / 偏好键登记 / +// 诊断文案 / mock 数据构造器等,它们被单测覆盖但业务侧要 P1 才用到。逐条 `#[allow]` +// 会把噪音藏进实现细节,这里统一放开并在 P1 接线时逐个转正(届时删掉本行即可暴露真死码)。 +#![allow(missing_docs, dead_code)] + +mod appearance_bind; +mod bg_image; +mod callbacks; +mod conv; +mod dialogs; +mod dto; +mod mock; +mod mock_data; +mod png; +mod prefs; +mod router; +mod settings_bridge; +mod state; +mod theme_runtime; +mod toast; +mod ui_bind; + +slint::include_modules!(); + +fn main() -> Result<(), slint::PlatformError> { + apply_renderer_flag(); + + let app = ui_bind::AppUi::new(MainWindow::new()?); + + // 截图验收通道(M41 视觉验收;无交互桌面/无 GPU 环境下的唯一可靠取图方式: + // 不截屏幕,而是让 Slint 把当前场景渲染进内存像素缓冲再自己编码 PNG)。 + if let Some(path) = screenshot_path() { + if std::env::args().any(|arg| arg == "--screenshot-settings") { + app.open_settings_appearance(); + } + app.render_screenshot(&path)?; + return Ok(()); + } + + app.start_timer(); + app.run() +} + +/// Win7 老机 / 无可用 OpenGL 驱动时的软渲染兜底开关(01c §6 的 Slint 核心卖点)。 +fn apply_renderer_flag() { + let software = std::env::args() + .skip(1) + .any(|arg| arg == "--software-renderer"); + if software { + std::env::set_var("SLINT_BACKEND", "winit-software"); + } +} + +/// `--screenshot ` / `--screenshot=`:渲染一帧并写 PNG 后退出。 +fn screenshot_path() -> Option { + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + if let Some(value) = arg.strip_prefix("--screenshot=") { + return Some(value.to_owned()); + } + if arg == "--screenshot" { + return args.next(); + } + } + None +} diff --git a/crates/app/src/mock.rs b/crates/app/src/mock.rs new file mode 100644 index 0000000..1d66b10 --- /dev/null +++ b/crates/app/src/mock.rs @@ -0,0 +1,366 @@ +//! P0 假后端:用脚本化事件驱动骨架,验证 app ↔ core/agent 通道语义。 +//! +//! 覆盖四条通道:实例运行状态(磁贴进度环)、AI 流式会话(含工具调用)、 +//! 对话框队列(进度对话框)、Toast。阶段 B 里由真正的 core/agent host 替换 +//! [`MockBackend`],[`crate::dto::Backend`] 之上的 state/reducer 不动。 +//! 假数据本体在 [`crate::mock_data`]。 + +use std::collections::VecDeque; +use std::time::Duration; + +use crate::dialogs::{DialogKind, DialogRequest, DialogSource}; +use crate::dto::{ + AgentEvent, AppCommand, AppEvent, Backend, ChatMessage, ChatRole, CoreEvent, InstanceId, + InstanceRuntime, LaunchStage, Progress, +}; +use crate::mock_data::{mock_instances, mock_log_lines, mock_log_sessions, mock_versions}; +use crate::toast::{Toast, ToastKind}; + +/// 启动脚本:按时间推进 download → verify → launch → running。 +#[derive(Debug)] +struct LaunchScript { + id: InstanceId, + elapsed_ms: u64, +} + +/// AI 流式脚本:每条提问回一段可切分的话 + 一次工具调用。 +#[derive(Debug)] +struct ChatScript { + elapsed_ms: u64, + chunks: VecDeque, + /// 流式助手消息在 app 会话里的序号 + message_index: usize, +} + +/// 假后端。 +#[derive(Debug, Default)] +pub struct MockBackend { + events: VecDeque, + launch: Option, + chat: Option, + /// 已投递给 app 的会话消息条数(用来给 MessageDelta 算序号) + emitted_messages: usize, + next_dialog: u64, +} + +impl MockBackend { + /// 预置一批假数据事件(实例/版本/日志会话)。 + pub fn new() -> Self { + let mut backend = Self::default(); + backend.seed(); + backend + } + + fn seed(&mut self) { + self.events.push_back(AppEvent::Core( + CoreEvent::InstancesChanged(mock_instances()), + )); + self.events + .push_back(AppEvent::Core(CoreEvent::VersionListChanged( + mock_versions(), + ))); + self.events + .push_back(AppEvent::Core(CoreEvent::LogSessionsChanged( + mock_log_sessions(), + ))); + self.events + .push_back(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines()))); + } + + fn emit(&mut self, event: AppEvent) { + self.events.push_back(event); + } + + fn next_dialog_id(&mut self) -> u64 { + self.next_dialog += 1; + self.next_dialog + } + + fn script_launch(&mut self, id: InstanceId) { + self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged { + id: id.clone(), + runtime: InstanceRuntime::Busy { + stage: LaunchStage::Download, + progress: Progress { + done: 0, + total: 1_500, + }, + }, + })); + self.launch = Some(LaunchScript { id, elapsed_ms: 0 }); + } + + fn step_launch(&mut self, elapsed: Duration) { + let Some((id, elapsed_ms)) = self.launch.as_mut().map(|script| { + script.elapsed_ms += u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX); + (script.id.clone(), script.elapsed_ms) + }) else { + return; + }; + + // 0~1.5s 下载、1.5~2.5s 校验、2.5~3s 启动、3s 后转为运行中 + let runtime = if elapsed_ms < 1_500 { + InstanceRuntime::Busy { + stage: LaunchStage::Download, + progress: Progress { + done: elapsed_ms, + total: 1_500, + }, + } + } else if elapsed_ms < 2_500 { + InstanceRuntime::Busy { + stage: LaunchStage::Verify, + progress: Progress { + done: elapsed_ms - 1_500, + total: 1_000, + }, + } + } else if elapsed_ms < 3_000 { + InstanceRuntime::Busy { + stage: LaunchStage::Launch, + progress: Progress { + done: elapsed_ms - 2_500, + total: 500, + }, + } + } else { + self.launch = None; + self.emit(AppEvent::Toast(Toast { + id: 0, + kind: ToastKind::Success, + text: "游戏已启动".to_string(), + remaining_ms: 0, + })); + InstanceRuntime::Running { pid: 12_345 } + }; + + self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged { + id, + runtime, + })); + } + + fn script_chat(&mut self, text: String) { + let user_index = self.emitted_messages; + self.emit(AppEvent::Agent(AgentEvent::MessageAppended( + ChatMessage::user(text), + ))); + self.emit(AppEvent::Agent(AgentEvent::MessageAppended( + ChatMessage::assistant_stream(), + ))); + self.emitted_messages += 2; + self.chat = Some(ChatScript { + elapsed_ms: 0, + chunks: VecDeque::from(vec![ + "先确认版本:".to_string(), + "官方 1.20.4 是正式版,".to_string(), + "装完再用 Fabric 加加载器。".to_string(), + ]), + message_index: user_index + 1, + }); + } + + fn step_chat(&mut self, elapsed: Duration) { + let Some(chat) = self.chat.as_mut() else { + return; + }; + chat.elapsed_ms += u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX); + if chat.elapsed_ms < 300 { + return; + } + chat.elapsed_ms = 0; + let index = chat.message_index; + let chunk = chat.chunks.pop_front(); + + if let Some(delta) = chunk { + self.emit(AppEvent::Agent(AgentEvent::MessageDelta { index, delta })); + return; + } + + // 流结束后补一次工具调用(工具执行在会话里可见)+ 收尾消息 + self.emit(AppEvent::Agent(AgentEvent::ToolCallStarted { + name: "read_game_log".to_string(), + summary: "读取最近一次崩溃日志".to_string(), + })); + self.emit(AppEvent::Agent(AgentEvent::ToolCallFinished { + name: "read_game_log".to_string(), + ok: true, + summary: "读到 128 行,无 ERROR".to_string(), + })); + self.emit(AppEvent::Agent(AgentEvent::MessageFinished { index })); + self.emit(AppEvent::Agent(AgentEvent::MessageAppended(ChatMessage { + role: ChatRole::Assistant, + text: "日志没有 ERROR,问题更可能在 Java 版本。".to_string(), + tool: None, + streaming: false, + }))); + self.emitted_messages += 2; + self.chat = None; + } + + fn script_install(&mut self, version: String) { + let id = self.next_dialog_id(); + self.emit(AppEvent::DialogRequested(DialogRequest { + id, + source: DialogSource::BackgroundTask, + title: format!("正在安装 {version}"), + body: String::new(), + kind: DialogKind::Progress { + label: "下载资源".to_string(), + progress: 0.0, + }, + })); + self.emit(AppEvent::DialogClosed(id)); + self.emit(AppEvent::Toast(Toast { + id: 0, + kind: ToastKind::Success, + text: format!("{version} 安装完成"), + remaining_ms: 0, + })); + } +} + +impl Backend for MockBackend { + fn drain_events(&mut self) -> Vec { + self.events.drain(..).collect() + } + + fn dispatch(&mut self, command: AppCommand) { + match command { + AppCommand::LaunchInstance(id) => self.script_launch(id), + AppCommand::StopInstance(id) => { + self.launch = None; + self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged { + id, + runtime: InstanceRuntime::Idle, + })); + } + AppCommand::AskAgent(text) => self.script_chat(text), + AppCommand::InstallVersion(version) => self.script_install(version), + AppCommand::SetYolo(enabled) => self.emit(AppEvent::Toast(Toast { + id: 0, + kind: ToastKind::Info, + text: if enabled { + "YOLO 已开启(审计照记)" + } else { + "YOLO 已关闭" + } + .to_string(), + remaining_ms: 0, + })), + AppCommand::ListVersions(category) => { + self.emit(AppEvent::Core(CoreEvent::VersionListChanged( + crate::mock_data::mock_versions_for(category), + ))); + } + AppCommand::OpenLogSession(id) => { + // P0:换会话 = 换一批假日志行 + 一条诊断结论(日志页诊断卡片可见) + self.emit(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines()))); + self.emit(AppEvent::Core(CoreEvent::DiagnosisReady( + crate::mock_data::mock_diagnosis(id), + ))); + } + AppCommand::RefreshInstances + | AppCommand::RefreshLogSessions + | AppCommand::StopAgent => {} + } + } + + fn tick(&mut self, elapsed: Duration) { + self.step_launch(elapsed); + self.step_chat(elapsed); + } +} + +#[cfg(test)] +mod tests { + use super::MockBackend; + use crate::dto::{AgentEvent, AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime}; + + #[test] + fn seeds_instances_and_versions() { + let mut backend = MockBackend::new(); + let events = backend.drain_events(); + assert_eq!(events.len(), 4); + assert!(events + .iter() + .all(|event| matches!(event, AppEvent::Core(_)))); + assert!(backend.drain_events().is_empty()); + } + + #[test] + fn launching_emits_progress_then_running() { + let mut backend = MockBackend::new(); + backend.drain_events(); + backend.dispatch(AppCommand::LaunchInstance("survival".to_string())); + + let first = backend.drain_events(); + assert!(matches!( + first[0], + AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. }) + )); + + backend.tick(std::time::Duration::from_millis(1_600)); + let verify = backend.drain_events(); + match &verify[0] { + AppEvent::Core(CoreEvent::InstanceRuntimeChanged { + runtime: InstanceRuntime::Busy { stage, .. }, + .. + }) => assert_eq!(*stage, crate::dto::LaunchStage::Verify), + other => panic!("unexpected event: {other:?}"), + } + + backend.tick(std::time::Duration::from_millis(2_000)); + let done = backend.drain_events(); + assert!(done.iter().any(|event| matches!( + event, + AppEvent::Core(CoreEvent::InstanceRuntimeChanged { + runtime: InstanceRuntime::Running { .. }, + .. + }) + ))); + } + + #[test] + fn stopping_cancels_the_launch_script() { + let mut backend = MockBackend::new(); + backend.drain_events(); + backend.dispatch(AppCommand::LaunchInstance("a".to_string())); + backend.drain_events(); + + backend.dispatch(AppCommand::StopInstance("a".to_string())); + let events = backend.drain_events(); + assert!(matches!( + events[0], + AppEvent::Core(CoreEvent::InstanceRuntimeChanged { + runtime: InstanceRuntime::Idle, + .. + }) + )); + + // 脚本已取消:再推进时间也不会再产出事件 + backend.tick(std::time::Duration::from_millis(5_000)); + assert!(backend.drain_events().is_empty()); + } + + #[test] + fn asking_the_agent_streams_and_finishes() { + let mut backend = MockBackend::new(); + backend.drain_events(); + backend.dispatch(AppCommand::AskAgent("装哪个版本".to_string())); + + let start = backend.drain_events(); + assert_eq!(start.len(), 2); + + let mut deltas = 0; + for _ in 0..6 { + backend.tick(std::time::Duration::from_millis(400)); + let events = backend.drain_events(); + deltas += events + .iter() + .filter(|event| matches!(event, AppEvent::Agent(AgentEvent::MessageDelta { .. }))) + .count(); + } + assert_eq!(deltas, 3); + } +} diff --git a/crates/app/src/mock_data.rs b/crates/app/src/mock_data.rs new file mode 100644 index 0000000..9257f0a --- /dev/null +++ b/crates/app/src/mock_data.rs @@ -0,0 +1,226 @@ +//! P0 假数据:实例 / 版本 / 日志 / 诊断 / 工具调用样例。 +//! +//! 只被 [`crate::mock`] 与阶段 B 的 main.rs 使用;真实后端接入后整文件可删。 + +use crate::dto::{ + AncientKind, ChatMessage, ChatRole, Diagnosis, DiagnosisReport, InstanceRuntime, + InstanceSummary, LoaderInfo, LoaderKind, LogLevel, LogLine, LogOutcome, LogSession, SessionId, + ToolCallView, VersionCategory, VersionKind, VersionSummary, +}; + +/// P0 演示实例:覆盖空闲 / 失败两种磁贴状态(运行中由启动脚本产生)。 +pub fn mock_instances() -> Vec { + vec![ + InstanceSummary { + id: "survival".to_string(), + name: "生存整合包".to_string(), + mc_version: "1.20.4".to_string(), + loader: LoaderInfo { + kind: LoaderKind::Fabric, + version: "0.16.5".to_string(), + }, + last_played: Some("2026-10-29T19:22:00Z".to_string()), + runtime: InstanceRuntime::Idle, + icon: None, + }, + InstanceSummary { + id: "vanilla-latest".to_string(), + name: "原版最新".to_string(), + mc_version: "1.21.9".to_string(), + loader: LoaderInfo { + kind: LoaderKind::Vanilla, + version: String::new(), + }, + last_played: Some("2026-10-28T21:03:00Z".to_string()), + runtime: InstanceRuntime::Idle, + icon: None, + }, + InstanceSummary { + id: "ancient-1.7.10".to_string(), + name: "远古 1.7.10".to_string(), + mc_version: "1.7.10".to_string(), + loader: LoaderInfo { + kind: LoaderKind::Forge, + version: "10.13.4.1614".to_string(), + }, + last_played: None, + runtime: InstanceRuntime::Idle, + icon: None, + }, + InstanceSummary { + id: "modded-dev".to_string(), + name: "开发测试".to_string(), + mc_version: "1.21.1".to_string(), + loader: LoaderInfo { + kind: LoaderKind::NeoForge, + version: "21.1.72".to_string(), + }, + last_played: Some("2026-10-27T12:40:00Z".to_string()), + runtime: InstanceRuntime::Failed { + reason: "exit0 静默失败".to_string(), + }, + icon: None, + }, + ] +} + +/// P0 演示版本列表(含远古版三子类,验证分类侧栏与警示徽标)。 +pub fn mock_versions() -> Vec { + fn row(id: &str, kind: VersionKind, released: &str, installed: bool) -> VersionSummary { + VersionSummary { + id: id.to_string(), + kind, + released: released.to_string(), + installed, + } + } + vec![ + row("1.21.9", VersionKind::Release, "2026-09-20", false), + row("1.21.8", VersionKind::Release, "2026-07-11", true), + row("1.20.4", VersionKind::Release, "2023-12-07", true), + row("1.21.9-pre3", VersionKind::Snapshot, "2026-09-01", false), + row( + "rd-132211", + VersionKind::Ancient(AncientKind::Classic), + "2009-05-13", + false, + ), + row( + "a1.2.6", + VersionKind::Ancient(AncientKind::OldAlpha), + "2010-08-11", + false, + ), + row( + "b1.7.3", + VersionKind::Ancient(AncientKind::OldBeta), + "2011-07-08", + false, + ), + ] +} + +/// 日志会话样例(✓ / ✗ / ⚠ 三种结局各一条)。 +pub fn mock_log_sessions() -> Vec { + vec![ + LogSession { + id: "s-1029-1922".to_string(), + instance: "生存整合包".to_string(), + when: "10-29 19:22".to_string(), + outcome: LogOutcome::Ok, + duration_secs: 3_780, + }, + LogSession { + id: "s-1029-1840".to_string(), + instance: "开发测试".to_string(), + when: "10-29 18:40".to_string(), + outcome: LogOutcome::Crashed, + duration_secs: 62, + }, + LogSession { + id: "s-1029-1755".to_string(), + instance: "原版最新".to_string(), + when: "10-29 17:55".to_string(), + outcome: LogOutcome::SilentFail, + duration_secs: 8, + }, + ] +} + +/// 日志行样例(含一条 ERROR 供高亮验证)。 +pub fn mock_log_lines() -> Vec { + fn line(text: &str, level: LogLevel) -> LogLine { + LogLine { + text: text.to_string(), + level, + } + } + vec![ + line( + "[19:22:01] [main/INFO] Launching Minecraft 1.20.4", + LogLevel::Info, + ), + line("[19:22:02] [main/INFO] Loading 42 mods", LogLevel::Info), + line( + "[19:22:03] [Render thread/WARN] GL vendor: unknown", + LogLevel::Warn, + ), + line( + "[19:22:05] [Render thread/ERROR] OpenGL 2.1 not supported", + LogLevel::Error, + ), + line("[19:22:05] [main/INFO] Stopping!", LogLevel::Info), + ] +} + +/// 崩溃诊断样例(验证日志页诊断卡片 + 「让 AI 详解」入口)。 +pub fn mock_diagnosis(session: SessionId) -> DiagnosisReport { + DiagnosisReport { + session, + diagnosis: Diagnosis { + rule: "gpu.driver_old".to_string(), + title: "显卡驱动过旧(OpenGL 版本不足)".to_string(), + confidence: 92, + hint: "升级显卡驱动,或在启动参数里改用软件渲染".to_string(), + fix: Some("switch_software_renderer".to_string()), + }, + } +} + +/// 工具调用条目样例(AI 面板静态填充用)。 +pub fn mock_tool_message() -> ChatMessage { + ChatMessage { + role: ChatRole::Tool, + text: "读到 128 行,无 ERROR".to_string(), + tool: Some(ToolCallView { + name: "read_game_log".to_string(), + ok: true, + summary: "读到 128 行,无 ERROR".to_string(), + }), + streaming: false, + } +} + +/// 分类 → 该分类下的假版本(下载页切换分类时用)。 +pub fn mock_versions_for(category: VersionCategory) -> Vec { + mock_versions() + .into_iter() + .filter(|version| match category { + VersionCategory::Release => version.kind == VersionKind::Release, + VersionCategory::Snapshot => version.kind == VersionKind::Snapshot, + VersionCategory::Ancient(kind) => version.kind == VersionKind::Ancient(kind), + VersionCategory::Modpack | VersionCategory::Mod => false, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use crate::dto::{AncientKind, VersionCategory, VersionKind}; + use crate::mock_data::{mock_instances, mock_versions_for}; + + #[test] + fn mock_instances_cover_the_tile_states() { + let instances = mock_instances(); + assert_eq!(instances.len(), 4); + assert!(instances.iter().any(|instance| matches!( + instance.runtime, + crate::dto::InstanceRuntime::Failed { .. } + ))); + } + + #[test] + fn versions_are_filtered_by_category() { + let release = mock_versions_for(VersionCategory::Release); + assert!(release + .iter() + .all(|version| version.kind == VersionKind::Release)); + assert_eq!(release.len(), 3); + + let classic = mock_versions_for(VersionCategory::Ancient(AncientKind::Classic)); + assert_eq!(classic.len(), 1); + assert_eq!(classic[0].id, "rd-132211"); + + assert!(mock_versions_for(VersionCategory::Mod).is_empty()); + } +} diff --git a/crates/app/src/png.rs b/crates/app/src/png.rs new file mode 100644 index 0000000..94aff5b --- /dev/null +++ b/crates/app/src/png.rs @@ -0,0 +1,140 @@ +//! 极简 PNG 编码器(只做「无压缩 stored deflate」),给截图验收用。 +//! +//! 为什么不引 `image` / `png` crate:截屏只是开发期验收工具(`--screenshot`), +//! 为它加一个依赖违反「新依赖过 deny.toml 白名单」的成本收益。PNG 规范里 zlib 流允许 +//! **stored(未压缩)块**,于是我们只需要 CRC32 + Adler32 两个校验和就能产出合法 PNG—— +//! 文件大一些,但任何看图工具都认。 +//! +//! 输出:8bit RGBA(color type 6),逐行 filter=0(None)。 + +use slint::{Rgba8Pixel, SharedPixelBuffer}; + +/// 把 RGBA8 像素缓冲写成 PNG 字节流。 +#[must_use] +pub fn encode_rgba8(buffer: &SharedPixelBuffer) -> Vec { + let width = buffer.width(); + let height = buffer.height(); + let pixels = buffer.as_slice(); + + // 原始扫描线:每行前面加一个 filter 字节 0 + let stride = width as usize * 4; + let mut raw = Vec::with_capacity((stride + 1) * height as usize); + for y in 0..height as usize { + raw.push(0u8); + for x in 0..width as usize { + let pixel = pixels[y * width as usize + x]; + raw.extend_from_slice(&[pixel.r, pixel.g, pixel.b, pixel.a]); + } + } + + let mut out = Vec::new(); + out.extend_from_slice(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]); + + let mut ihdr = Vec::with_capacity(13); + ihdr.extend_from_slice(&width.to_be_bytes()); + ihdr.extend_from_slice(&height.to_be_bytes()); + ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); // 位深 8、RGBA、deflate、无交错 + push_chunk(&mut out, b"IHDR", &ihdr); + push_chunk(&mut out, b"IDAT", &zlib_stored(&raw)); + push_chunk(&mut out, b"IEND", &[]); + out +} + +fn push_chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(data); + let mut crc_input = Vec::with_capacity(4 + data.len()); + crc_input.extend_from_slice(kind); + crc_input.extend_from_slice(data); + out.extend_from_slice(&crc32(&crc_input).to_be_bytes()); +} + +/// zlib 容器 + 全 stored deflate 块(每块上限 65535 字节)。 +fn zlib_stored(raw: &[u8]) -> Vec { + let mut out = Vec::with_capacity(raw.len() + raw.len() / 65_535 * 5 + 16); + out.extend_from_slice(&[0x78, 0x01]); // CMF/FLG:deflate、32K 窗口、无字典、最快 + let mut offset = 0usize; + while offset < raw.len() { + let len = (raw.len() - offset).min(65_535); + let last = offset + len >= raw.len(); + out.push(u8::from(last)); // BFINAL + BTYPE=00 + let len_u16 = len as u16; + out.extend_from_slice(&len_u16.to_le_bytes()); + out.extend_from_slice(&(!len_u16).to_le_bytes()); + out.extend_from_slice(&raw[offset..offset + len]); + offset += len; + } + if raw.is_empty() { + out.push(1); + out.extend_from_slice(&[0, 0, 0xff, 0xff]); + } + out.extend_from_slice(&adler32(raw).to_be_bytes()); + out +} + +fn crc32(data: &[u8]) -> u32 { + let mut crc = 0xffff_ffffu32; + for byte in data { + crc ^= u32::from(*byte); + for _ in 0..8 { + let mask = (crc & 1).wrapping_neg(); + crc = (crc >> 1) ^ (0xedb8_8320 & mask); + } + } + !crc +} + +fn adler32(data: &[u8]) -> u32 { + let mut a: u32 = 1; + let mut b: u32 = 0; + for byte in data { + a = (a + u32::from(*byte)) % 65_521; + b = (b + a) % 65_521; + } + (b << 16) | a +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pixel(r: u8, g: u8, b: u8) -> Rgba8Pixel { + Rgba8Pixel { r, g, b, a: 255 } + } + + #[test] + fn header_and_chunks_are_well_formed() { + let mut buffer = SharedPixelBuffer::::new(3, 2); + buffer.make_mut_slice().fill(pixel(1, 2, 3)); + let bytes = encode_rgba8(&buffer); + assert_eq!( + &bytes[..8], + &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a] + ); + // IHDR:宽 3 高 2、8bit、RGBA(6) + assert_eq!(&bytes[8..12], &13u32.to_be_bytes()); + assert_eq!(&bytes[12..16], b"IHDR"); + assert_eq!(&bytes[16..20], &3u32.to_be_bytes()); + assert_eq!(&bytes[20..24], &2u32.to_be_bytes()); + assert_eq!(bytes[24], 8); + assert_eq!(bytes[25], 6); + // 结尾是 IEND 块 + assert_eq!(&bytes[bytes.len() - 8..bytes.len() - 4], b"IEND"); + } + + #[test] + fn checksums_match_known_vectors() { + // CRC32("IEND") 是公认值 0xAE426082(写进 IEND 块尾) + assert_eq!(crc32(b"IEND"), 0xae42_6082); + // Adler32("Wikipedia") = 0x11E60398 + assert_eq!(adler32(b"Wikipedia"), 0x11e6_0398); + } + + #[test] + fn empty_buffer_still_produces_valid_stream() { + let buffer = SharedPixelBuffer::::new(0, 0); + let bytes = encode_rgba8(&buffer); + assert!(bytes.windows(4).any(|window| window == b"IDAT")); + } +} diff --git a/crates/app/src/prefs.rs b/crates/app/src/prefs.rs new file mode 100644 index 0000000..2649907 --- /dev/null +++ b/crates/app/src/prefs.rs @@ -0,0 +1,288 @@ +//! UI 偏好项与持久化键登记(导航折叠、AI 栏折叠、磁贴尺寸、视图、主题三态与外观四件、下载分类)。 +//! +//! 偏好不自己写盘:core::setting 的 schema 按 [`keys::ALL`] 登记这些键, +//! app 启动时读一次、变更时写回(跨模块约定见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`)。 +//! 落盘实现是 [`crate::settings_bridge`](同一份 `config/launcher.json`,与 `ull config set` 同源), +//! 主题三态 + `appearance.*` 高级键已接(M41);其余 UI 偏好键仍只在内存里(后续 mission 接)。 + +/// 背景图填充模式(09 §101)。直接复用 core 的枚举:取值与落盘词表只有一处真相。 +pub use unlucky_core::setting::appearance::BgFill; + +use crate::dto::VersionCategory; + +/// 预设强调色板(09 §104)。**必须**与 `ui/pages/settings-appearance.slint` 的 `swatch-colors` +/// 同序同值:Slint 侧只回传索引,十六进制串由这张表给出(一处真相)。 +pub const ACCENT_PRESETS: [&str; 8] = [ + "#0078D4", "#0F6CBD", "#107C10", "#9D5D00", "#C42B1C", "#8764B8", "#C239B3", "#00B7C3", +]; + +/// 磁贴尺寸(Q24-3:小 6 列 / 中 4 列 / 大 3 列)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TileSize { + /// 小:6 列,只留图标/名字/启动键 + Small, + /// 中:4 列(D16 默认) + #[default] + Medium, + /// 大:3 列,显示完整徽标与最后游玩时间 + Large, +} + +impl TileSize { + /// 该尺寸对应的网格列数。 + pub fn columns(self) -> usize { + match self { + Self::Small => 6, + Self::Medium => 4, + Self::Large => 3, + } + } + + /// 设置页/实例页分段控件的文案。 + pub fn label(self) -> &'static str { + match self { + Self::Small => "小", + Self::Medium => "中", + Self::Large => "大", + } + } +} + +/// 实例页视图模式。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InstanceView { + /// 磁贴网格(默认) + #[default] + Tiles, + /// 紧凑列表 + List, +} + +/// 主题三态(D17,P0 交付):`appearance.theme_mode ∈ {light, dark, auto}`,出厂 auto。 +/// +/// auto 由 `theme/tokens.slint` 读 Slint 原生 `Palette.color-scheme` 跟随系统(后端随 OS 热更); +/// light/dark 由 `Theme.theme-mode` 钉死**自绘体系**;std-widgets 内部件(ListView 滚动条 / +/// ProgressIndicator / AboutSlint)目前仍跟随系统色,`Palette.color-scheme` 的钉死留 P1。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ThemeMode { + /// 跟随系统(默认) + #[default] + Auto, + /// 强制浅色 + Light, + /// 强制深色 + Dark, +} + +impl ThemeMode { + /// 落盘取值(`appearance.theme_mode`):`auto` / `light` / `dark`。 + pub fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Light => "light", + Self::Dark => "dark", + } + } + + /// 从落盘取值解析;未知值按 `auto` 兜底(配置被手改坏也不崩)。 + pub fn from_key_value(value: &str) -> Self { + match value { + "light" => Self::Light, + "dark" => Self::Dark, + _ => Self::Auto, + } + } +} + +/// 09 §101-104「外观(高级)」四件的取值快照(出厂全部关闭)。 +/// +/// 与 `unlucky_core::setting::appearance::Appearance` 一一对应,但字段类型是 UI 侧好用的 +/// `f32`/`String`;落盘由 [`crate::settings_bridge`] 转成 schema 键。 +#[derive(Debug, Clone, PartialEq, Default)] +pub struct AppearancePrefs { + /// 背景图总开关 + pub bg_image_enabled: bool, + /// 背景图路径(PNG;空 = 未设置) + pub bg_image: String, + /// 填充模式 + pub bg_fill: BgFill, + /// 暗化层强度 0.0~0.9 + pub bg_dim: f32, + /// 面板半透明度 0.4~1.0 + pub panel_alpha: f32, + /// 玻璃拟态卡片 + pub glass: bool, + /// 自定义强调色 `#RRGGBB`;空 = 出厂 [#0078D4] + pub accent_color: String, +} + +impl AppearancePrefs { + /// 出厂态:Metro 纯色,四个高级特性全关。 + pub fn factory() -> Self { + Self { + bg_image_enabled: false, + bg_image: String::new(), + bg_fill: BgFill::Cover, + bg_dim: 0.35, + panel_alpha: 1.0, + glass: false, + accent_color: String::new(), + } + } + + /// 背景图是否真的生效(开关打开且路径非空)。 + pub fn bg_active(&self) -> bool { + self.bg_image_enabled && !self.bg_image.trim().is_empty() + } + + /// 规范化写入强调色:`#RRGGBB` 才接受(空串 = 恢复出厂)。 + /// + /// 返回 `false` 表示输入不合法(调用方据此亮设置页的 warn 行,不改现状)。 + pub fn set_accent(&mut self, text: &str) -> bool { + let trimmed = text.trim(); + if trimmed.is_empty() { + self.accent_color.clear(); + return true; + } + match unlucky_core::setting::appearance::normalize_hex(trimmed) { + Some(hex) => { + self.accent_color = hex; + true + } + None => false, + } + } + + /// 用预设色板第 `index` 个颜色(越界忽略)。 + pub fn set_accent_preset(&mut self, index: usize) -> bool { + match ACCENT_PRESETS.get(index) { + Some(hex) => self.set_accent(hex), + None => false, + } + } +} + +/// 需要跨会话记忆的 UI 偏好。 +/// +/// 注意:`Eq` 不成立([`AppearancePrefs`] 带 `f32` 滑条值),用到的地方按 `PartialEq` 比。 +#[derive(Debug, Clone, PartialEq)] +pub struct UiPrefs { + /// 左侧导航栏折叠成图标条 + pub nav_collapsed: bool, + /// 右侧 AI 栏折叠成 icon 轨 + pub ai_collapsed: bool, + /// 磁贴尺寸 + pub tile_size: TileSize, + /// 实例页视图 + pub instance_view: InstanceView, + /// 主题模式 + pub theme: ThemeMode, + /// 外观(高级)四件(09 §101-104) + pub appearance: AppearancePrefs, + /// 下载页选中的分类 + pub download_category: VersionCategory, +} + +impl Default for UiPrefs { + fn default() -> Self { + Self { + nav_collapsed: false, + ai_collapsed: false, + // D16 默认中磁贴(4 列) + tile_size: TileSize::Medium, + instance_view: InstanceView::Tiles, + theme: ThemeMode::Auto, + appearance: AppearancePrefs::factory(), + download_category: VersionCategory::Release, + } + } +} + +/// 持久化键名登记(core::setting schema 按此注册;改名等于迁移,慎动)。 +pub mod keys { + /// 导航栏折叠 + pub const NAV_COLLAPSED: &str = "ui.nav.collapsed"; + /// AI 栏折叠 + pub const AI_COLLAPSED: &str = "ui.ai.collapsed"; + /// 磁贴尺寸 + pub const TILE_SIZE: &str = "ui.instances.tile_size"; + /// 实例页视图(磁贴/列表) + pub const INSTANCE_VIEW: &str = "ui.instances.view"; + /// 主题三态(D17;与设置页「外观」分组同名,取值见 [`super::ThemeMode::as_str`]) + pub const THEME_MODE: &str = "appearance.theme_mode"; + /// 背景图开关(09 §101) + pub const BG_IMAGE_ENABLED: &str = "appearance.bg_image_enabled"; + /// 背景图路径 + pub const BG_IMAGE: &str = "appearance.bg_image"; + /// 背景图填充模式 + pub const BG_FILL: &str = "appearance.bg_fill"; + /// 暗化层强度 + pub const BG_DIM: &str = "appearance.bg_dim"; + /// 面板半透明度(09 §102) + pub const PANEL_ALPHA: &str = "appearance.panel_alpha"; + /// 玻璃拟态卡片(09 §103) + pub const GLASS: &str = "appearance.glass"; + /// 自定义强调色(09 §104) + pub const ACCENT_COLOR: &str = "appearance.accent_color"; + /// 下载页分类 + pub const DOWNLOAD_CATEGORY: &str = "ui.download.category"; + + /// 本模块登记的全部键(自检/迁移用)。 + pub const ALL: [&str; 13] = [ + NAV_COLLAPSED, + AI_COLLAPSED, + TILE_SIZE, + INSTANCE_VIEW, + THEME_MODE, + BG_IMAGE_ENABLED, + BG_IMAGE, + BG_FILL, + BG_DIM, + PANEL_ALPHA, + GLASS, + ACCENT_COLOR, + DOWNLOAD_CATEGORY, + ]; +} + +#[cfg(test)] +mod tests { + use super::{keys, InstanceView, ThemeMode, TileSize, UiPrefs}; + + #[test] + fn defaults_match_d16() { + let prefs = UiPrefs::default(); + assert_eq!(prefs.tile_size, TileSize::Medium); + assert_eq!(prefs.tile_size.columns(), 4); + assert_eq!(prefs.instance_view, InstanceView::Tiles); + assert_eq!(prefs.theme, ThemeMode::Auto); + assert_eq!(prefs.theme.as_str(), "auto"); + assert!(!prefs.nav_collapsed); + assert!(!prefs.ai_collapsed); + } + + #[test] + fn tile_size_columns_follow_q24() { + assert_eq!(TileSize::Small.columns(), 6); + assert_eq!(TileSize::Medium.columns(), 4); + assert_eq!(TileSize::Large.columns(), 3); + } + + #[test] + fn theme_mode_round_trips_through_key_values() { + for mode in [ThemeMode::Auto, ThemeMode::Light, ThemeMode::Dark] { + assert_eq!(ThemeMode::from_key_value(mode.as_str()), mode); + } + // 坏值兜底 + assert_eq!(ThemeMode::from_key_value("solarized"), ThemeMode::Auto); + } + + #[test] + fn pref_keys_are_unique() { + let mut keys = keys::ALL.to_vec(); + let total = keys.len(); + keys.sort_unstable(); + keys.dedup(); + assert_eq!(keys.len(), total); + } +} diff --git a/crates/app/src/router.rs b/crates/app/src/router.rs new file mode 100644 index 0000000..b65f6d7 --- /dev/null +++ b/crates/app/src/router.rs @@ -0,0 +1,184 @@ +//! 页面路由与 AI 助手栏形态(09 §1 / D16)。 +//! +//! 栈语义抄 HMCL 的导航栈:**顶层页面互相替换**(实例/下载/日志/设置), +//! 子页面(实例详情、AI 独立页)压栈,`back()` 逐层回退。 +//! 本模块刻意不依赖 Slint 类型,便于单元测试;UI 侧枚举映射在 main.rs 的转换层做。 + +use crate::dto::InstanceId; + +/// 一级导航项(左侧导航栏高亮单位)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavSection { + /// 实例 + Instances, + /// 下载 + Download, + /// 日志 + Logs, + /// 设置 + Settings, +} + +/// 页面路由。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Route { + /// G1 实例页(主页) + Instances, + /// G2 下载页 + Download, + /// G3 日志页 + Logs, + /// G6 设置页 + Settings, + /// 实例详情(G4):从磁贴点进来,返回时回到实例页 + Detail(InstanceId), + /// AI 助手独立页(D16 双形态之一) + Agent, +} + +impl Route { + /// 是否是一级导航页(互相替换,不进栈)。 + pub fn is_top_level(&self) -> bool { + matches!( + self, + Self::Instances | Self::Download | Self::Logs | Self::Settings + ) + } + + /// 该路由高亮哪个导航项(None = 没有导航项高亮,如 AI 独立页)。 + pub fn nav(&self) -> Option { + match self { + Self::Instances | Self::Detail(_) => Some(NavSection::Instances), + Self::Download => Some(NavSection::Download), + Self::Logs => Some(NavSection::Logs), + Self::Settings => Some(NavSection::Settings), + Self::Agent => None, + } + } +} + +/// AI 栏形态:右侧栏 / 独立全宽页(两形态共享同一份会话状态)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AiPanelMode { + /// 右侧常驻栏 + Docked, + /// 拆出的独立全宽页 + Page, +} + +/// 导航栈。 +#[derive(Debug, Clone)] +pub struct Router { + stack: Vec, +} + +impl Default for Router { + fn default() -> Self { + Self::new() + } +} + +impl Router { + /// 启动时停在实例页。 + pub fn new() -> Self { + Self { + stack: vec![Route::Instances], + } + } + + /// 当前页面。 + pub fn route(&self) -> &Route { + // 栈不变量:构造后永远非空(navigate/back 都不允许清空) + self.stack.last().expect("router stack is never empty") + } + + /// 栈深度(1 表示没有可回退的子页面)。 + pub fn depth(&self) -> usize { + self.stack.len() + } + + /// 是否能回退。 + pub fn can_go_back(&self) -> bool { + self.stack.len() > 1 + } + + /// 跳转到某路由:一级页替换整栈,子页面压栈(重复目标不重复压栈)。 + pub fn navigate(&mut self, route: Route) { + if route.is_top_level() { + self.stack.clear(); + self.stack.push(route); + return; + } + if self.stack.last() == Some(&route) { + return; + } + self.stack.push(route); + } + + /// 回退一层;已在根页面时返回 `false`。 + pub fn back(&mut self) -> bool { + if self.stack.len() <= 1 { + return false; + } + self.stack.pop(); + true + } + + /// 当前 AI 栏形态(栈顶是 Agent 页即为独立页形态)。 + pub fn ai_mode(&self) -> AiPanelMode { + if self.route() == &Route::Agent { + AiPanelMode::Page + } else { + AiPanelMode::Docked + } + } +} + +#[cfg(test)] +mod tests { + use super::{AiPanelMode, NavSection, Route, Router}; + + #[test] + fn top_level_navigation_replaces_the_stack() { + let mut router = Router::new(); + router.navigate(Route::Detail("a".into())); + assert_eq!(router.depth(), 2); + + router.navigate(Route::Download); + assert_eq!(router.depth(), 1); + assert_eq!(router.route(), &Route::Download); + } + + #[test] + fn detail_pushes_and_back_pops_to_instances() { + let mut router = Router::new(); + router.navigate(Route::Detail("survival".into())); + assert!(router.can_go_back()); + assert_eq!(router.route().nav(), Some(NavSection::Instances)); + + assert!(router.back()); + assert_eq!(router.route(), &Route::Instances); + assert!(!router.back()); + } + + #[test] + fn agent_page_switches_ai_panel_mode() { + let mut router = Router::new(); + assert_eq!(router.ai_mode(), AiPanelMode::Docked); + + router.navigate(Route::Agent); + assert_eq!(router.ai_mode(), AiPanelMode::Page); + + router.back(); + assert_eq!(router.ai_mode(), AiPanelMode::Docked); + } + + #[test] + fn navigating_to_the_same_sub_route_does_not_grow_the_stack() { + let mut router = Router::new(); + router.navigate(Route::Agent); + router.navigate(Route::Agent); + assert_eq!(router.depth(), 2); + assert_eq!(router.route().nav(), None); + } +} diff --git a/crates/app/src/settings_bridge.rs b/crates/app/src/settings_bridge.rs new file mode 100644 index 0000000..644d060 --- /dev/null +++ b/crates/app/src/settings_bridge.rs @@ -0,0 +1,294 @@ +//! 设置持久化桥:把 UI 偏好落到 `core::setting` 的 schema 文件里,并支持**外部热改热切**。 +//! +//! 三条纪律(`DevDocs/SubAgentsTalk/app-DTO通道约定.md` §6 / AGENTS §6): +//! +//! 1. **不另立配置文件**:读写的就是 `ull config set/get` 与 AI `set_config` 用的那一份 +//! `/config/launcher.json`(`core::platform::data_dir` 解析数据根、`layout` 给路径); +//! 2. **不自己拼 JSON**:一律过 `core::setting::store::SettingStore`(schema 校验 + 原子写); +//! 3. **不 import 领域类型**:跨边界只看「键名 + 取值」,领域模型留在 core。 +//! +//! 热切:文件 `mtime` 变了就整份重读(AI / CLI 改完下一拍即生效),见 [`SettingsBridge::reload_if_changed`]。 + +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, Resolver}; +use unlucky_core::platform::layout::LauncherLayout; +use unlucky_core::platform::paths::Platform; +use unlucky_core::setting::appearance::Appearance; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +use crate::prefs::{AppearancePrefs, ThemeMode}; + +/// 数据根配置键(决定 `launcher.json` 落在哪个数据目录;与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。 +const DATA_ROOT_KEYS: [&str; 4] = [ + "core.data_mode", + "core.data_custom_root", + "core.launcher_home", + "core.game_home", +]; + +/// 设置文件桥(一个进程一份)。 +#[derive(Debug)] +pub struct SettingsBridge { + /// `config/launcher.json`;`None` = 数据根解析不出来(内存态运行,不落盘) + path: Option, + store: Option, + /// 上次读/写之后的文件 `mtime`(热切探测用) + last_mtime: Option, +} + +impl SettingsBridge { + /// 按三模式数据根打开真实配置文件(启动时调用一次)。 + #[must_use] + pub fn open() -> Self { + match resolve_config_path() { + Some(path) => Self::with_path(path), + None => Self::memory_only(), + } + } + + /// 指定路径打开(测试与特殊数据根用)。 + #[must_use] + pub fn with_path(path: impl Into) -> Self { + let path = path.into(); + let store = SettingStore::load(&path, launcher_schema()); + let last_mtime = mtime(&path); + Self { + path: Some(path), + store: Some(store), + last_mtime, + } + } + + /// 不落盘(数据根解析失败 / 单测)。 + #[must_use] + pub fn memory_only() -> Self { + Self { + path: None, + store: None, + last_mtime: None, + } + } + + /// 配置文件路径(诊断/日志用)。 + #[must_use] + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } + + /// 当前生效的外观配置与主题模式。 + #[must_use] + pub fn load(&self) -> (AppearancePrefs, ThemeMode) { + let Some(store) = &self.store else { + return (AppearancePrefs::factory(), ThemeMode::Auto); + }; + let appearance = Appearance::from_store(store); + let theme = ThemeMode::from_key_value(&appearance.theme_mode); + ( + AppearancePrefs { + bg_image_enabled: appearance.bg_image_enabled, + bg_image: appearance.bg_image, + bg_fill: appearance.bg_fill, + bg_dim: appearance.bg_dim as f32, + panel_alpha: appearance.panel_alpha as f32, + glass: appearance.glass, + accent_color: appearance.accent_color, + }, + theme, + ) + } + + /// 落盘(只写出现过的键;失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。 + pub fn persist(&mut self, appearance: &AppearancePrefs, theme: ThemeMode) { + let Some(store) = self.store.as_mut() else { + return; + }; + let mut snapshot = Appearance::from_store(store); + snapshot.theme_mode = theme.as_str().to_owned(); + snapshot.bg_image_enabled = appearance.bg_image_enabled; + snapshot.bg_image = appearance.bg_image.clone(); + snapshot.bg_fill = appearance.bg_fill; + snapshot.bg_dim = f64::from(appearance.bg_dim); + snapshot.panel_alpha = f64::from(appearance.panel_alpha); + snapshot.glass = appearance.glass; + snapshot.accent_color = appearance.accent_color.clone(); + if snapshot.write(store).is_err() { + return; + } + let _ = store.save(); + if let Some(path) = self.path() { + self.last_mtime = mtime(path); + } + } + + /// 文件被外部(AI / `ull config set`)改过就重读;返回新的生效值。 + /// + /// `mtime` 采样不到(某些文件系统精度/权限)时返回 `None`,退化为「只在启动时读一次」。 + pub fn reload_if_changed(&mut self) -> Option<(AppearancePrefs, ThemeMode)> { + let path = self.path.clone()?; + let current = mtime(&path); + if current.is_none() || current == self.last_mtime { + return None; + } + self.last_mtime = current; + let store = self.store.as_mut()?; + store.reload(); + Some(self.load()) + } +} + +/// 数据根解析 → `config/launcher.json`。 +/// +/// 与 CLI 同一套语义:先读便携目录那份 `launcher.json` 的 `core.*`(数据根决定键), +/// 再交 `Resolver` 解析(含 `redirect.json` 跟随)。任何一步失败都退回内存态。 +fn resolve_config_path() -> Option { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)); + let exe_dir = exe_dir?; + let request = request_from_portable(&exe_dir); + let resolver = Resolver::new(Platform::current(), Some(exe_dir.clone())); + match resolver.resolve_default(&request) { + Ok(resolved) => Some(resolved.layout().launcher_config()), + // 连 exe 目录都解析不了(极端情况):仍旧用便携落点,至少设置能存下来 + Err(_) => Some(LauncherLayout::new(exe_dir.join(".ULL")).launcher_config()), + } +} + +/// 便携目录那份 `launcher.json` → 三模式请求(只读 `core.*` 四个键)。 +fn request_from_portable(exe_dir: &Path) -> DataDirRequest { + let mut request = DataDirRequest::default(); + let path = portable_config_path(exe_dir); + if !path.is_file() { + return request; + } + let store = SettingStore::load(&path, launcher_schema()); + let text = |key: &str| store.doc().effective_str(key).map(str::to_string); + if let Some(mode) = text(DATA_ROOT_KEYS[0]).as_deref().and_then(DataMode::parse) { + request.mode = mode; + } + if let Some(root) = text(DATA_ROOT_KEYS[1]) { + request.custom_root = Some(PathBuf::from(root)); + } + if let Some(home) = text(DATA_ROOT_KEYS[2]) { + request.launcher_home = Some(PathBuf::from(home)); + } + if let Some(home) = text(DATA_ROOT_KEYS[3]) { + request.game_home = Some(PathBuf::from(home)); + } + request +} + +/// `/.ULL/config/launcher.json`。 +fn portable_config_path(exe_dir: &Path) -> PathBuf { + LauncherLayout::new(exe_dir.join(".ULL")).launcher_config() +} + +fn mtime(path: &Path) -> Option { + std::fs::metadata(path).ok()?.modified().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use unlucky_core::setting::appearance::BgFill; + + fn temp_config(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-bridge-{label}-{}-{}", + std::process::id(), + line!() + )); + std::fs::create_dir_all(dir.join("config")).expect("临时目录"); + let path = dir.join("config/launcher.json"); + let _ = std::fs::remove_file(&path); + path + } + + #[test] + fn factory_defaults_when_file_missing() { + let path = temp_config("factory"); + let bridge = SettingsBridge::with_path(&path); + let (appearance, theme) = bridge.load(); + assert_eq!(appearance, AppearancePrefs::factory()); + assert_eq!(theme, ThemeMode::Auto); + assert!(!path.exists(), "只读不写:缺文件不该被创建"); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); + } + + #[test] + fn persist_then_reload_round_trips() { + let path = temp_config("roundtrip"); + let mut bridge = SettingsBridge::with_path(&path); + let mut appearance = AppearancePrefs::factory(); + appearance.bg_image_enabled = true; + appearance.bg_image = "D:/wall/p.png".to_owned(); + appearance.bg_fill = BgFill::Contain; + appearance.bg_dim = 0.5; + appearance.panel_alpha = 0.8; + appearance.glass = true; + assert!(appearance.set_accent("#0f6cbd")); + bridge.persist(&appearance, ThemeMode::Dark); + + let reloaded = SettingsBridge::with_path(&path); + let (value, theme) = reloaded.load(); + assert_eq!(value, appearance); + assert_eq!(theme, ThemeMode::Dark); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); + } + + #[test] + fn external_edit_is_picked_up_by_reload() { + let path = temp_config("hotreload"); + let mut bridge = SettingsBridge::with_path(&path); + // 先落一份(建立 mtime 基线) + bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light); + assert!(bridge.reload_if_changed().is_none(), "自己写完不该报告变更"); + + // 模拟外部写入(AI / `ull config set`):改主题 + 打开背景图 + let mut external = SettingsBridge::with_path(&path); + let mut appearance = AppearancePrefs::factory(); + appearance.bg_image_enabled = true; + appearance.bg_image = "D:/wall/x.png".to_owned(); + external.persist(&appearance, ThemeMode::Dark); + + // mtime 精度:有的文件系统 1s 粒度,这里显式等到时间戳变化 + for _ in 0..50 { + if let Some((value, theme)) = bridge.reload_if_changed() { + assert_eq!(theme, ThemeMode::Dark); + assert!(value.bg_image_enabled); + assert_eq!(value.bg_image, "D:/wall/x.png"); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); + return; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("外部修改没有被 mtime 热切捕获"); + } + + #[test] + fn accent_preset_and_validation() { + let mut appearance = AppearancePrefs::factory(); + assert_eq!(appearance.accent_color, ""); + assert!(appearance.set_accent_preset(1)); + assert_eq!(appearance.accent_color, "#0F6CBD"); + assert!(!appearance.set_accent("not-a-color"), "非法输入不改现状"); + assert_eq!(appearance.accent_color, "#0F6CBD"); + assert!(appearance.set_accent(""), "空串恢复出厂"); + assert_eq!(appearance.accent_color, ""); + assert!(!appearance.set_accent_preset(99)); + } + + #[test] + fn bg_active_needs_both_switch_and_path() { + let mut appearance = AppearancePrefs::factory(); + appearance.bg_image = "D:/wall/p.png".to_owned(); + assert!(!appearance.bg_active(), "开关没开不算生效"); + appearance.bg_image_enabled = true; + assert!(appearance.bg_active()); + appearance.bg_image.clear(); + assert!(!appearance.bg_active(), "路径空也不算生效"); + } +} diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs new file mode 100644 index 0000000..3294358 --- /dev/null +++ b/crates/app/src/state.rs @@ -0,0 +1,304 @@ +//! UI 状态与 reducer:Slint 只负责渲染,所有状态变更都汇聚到 [`AppState::apply`]。 +//! +//! 数据流向:mock/host 后端 → [`crate::dto::AppEvent`] 队列 → `apply` → 状态字段 → Slint 属性。 +//! 偏好项在 [`crate::prefs`] 里登记了持久化键名,core::setting 的 schema 落地后按这些键读写。 + +use crate::dialogs::DialogQueue; +use crate::dto::{ + AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DiagnosisReport, InstanceRuntime, + InstanceSummary, LogLine, LogSession, Progress, ToolCallView, VersionSummary, +}; +use crate::prefs::UiPrefs; +use crate::router::{AiPanelMode, Route, Router}; +use crate::toast::ToastCenter; + +/// AI 会话状态(右侧栏与独立页共享同一份,切换不丢)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AiSession { + /// 会话消息(user / assistant / tool / system) + pub messages: Vec, + /// 是否有流式消息在跑 + pub streaming: bool, + /// YOLO 开关(跳过确认,审计照记) + pub yolo: bool, + /// 输入框草稿(切形态不丢) + pub draft: String, + /// 端点标签(显示在栏顶) + pub endpoint_label: String, +} + +impl Default for AiSession { + fn default() -> Self { + Self::new() + } +} + +impl AiSession { + /// 默认端点标签(P0 是本地 llama.cpp;P1 由设置页决定)。 + pub fn new() -> Self { + Self { + messages: Vec::new(), + streaming: false, + yolo: false, + draft: String::new(), + endpoint_label: "llama.cpp · 本地".to_string(), + } + } + + /// 流式收敛:清掉全部流式标记。 + pub fn finish_stream(&mut self) { + self.streaming = false; + for message in &mut self.messages { + message.streaming = false; + } + } +} + +/// 磁贴网格单元:位置由 [`grid_positions`] 算好,Slint 只消费 row/col。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TileCell<'a> { + /// 实例数据 + pub instance: &'a InstanceSummary, + /// 网格行号(0 起) + pub row: usize, + /// 网格列号(0 起) + pub col: usize, +} + +/// 纯函数:把 n 个磁贴摊到 `columns` 列的网格上,返回 `(row, col)`。 +/// +/// 特意放在 Rust 侧:Slint DSL 里做整数除法会引入隐式浮点,且这段逻辑可以单测。 +pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> { + let columns = columns.max(1); + (0..count) + .map(|index| (index / columns, index % columns)) + .collect() +} + +/// 应用全局状态。 +#[derive(Debug)] +pub struct AppState { + /// UI 偏好(记忆项) + pub prefs: UiPrefs, + /// 导航栈 + pub router: Router, + /// 实例列表快照 + pub instances: Vec, + /// 当前分类的版本列表 + pub versions: Vec, + /// 日志会话列表 + pub log_sessions: Vec, + /// 当前会话日志行 + pub log_lines: Vec, + /// 最近一次诊断结论 + pub diagnosis: Option, + /// 选中的日志会话 + pub selected_session: Option, + /// AI 会话状态 + pub ai: AiSession, + /// 对话框队列(一次一条) + pub dialogs: DialogQueue, + /// Toast 中心 + pub toasts: ToastCenter, + /// 实例页搜索词 + pub instance_query: String, + /// 下载页「仅已装」过滤 + pub installed_only: bool, + /// 下载页版本号筛选词 + pub download_query: String, +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +impl AppState { + /// 空状态(数据由后端事件填充,P0 是 mock)。 + pub fn new() -> Self { + Self { + prefs: UiPrefs::default(), + router: Router::new(), + instances: Vec::new(), + versions: Vec::new(), + log_sessions: Vec::new(), + log_lines: Vec::new(), + diagnosis: None, + selected_session: None, + ai: AiSession::new(), + dialogs: DialogQueue::new(), + toasts: ToastCenter::new(), + instance_query: String::new(), + installed_only: false, + download_query: String::new(), + } + } + + /// 当前页面。 + pub fn route(&self) -> &Route { + self.router.route() + } + + /// 跳转(一级页替换、子页面压栈;去 AI 独立页时自动展开 AI 栏偏好)。 + pub fn navigate(&mut self, route: Route) { + if route == Route::Agent { + self.prefs.ai_collapsed = false; + } + self.router.navigate(route); + } + + /// 回退一层。 + pub fn back(&mut self) -> bool { + self.router.back() + } + + /// AI 栏当前形态(跟着路由走)。 + pub fn ai_mode(&self) -> AiPanelMode { + self.router.ai_mode() + } + + /// 应用一条事件(唯一状态变更入口)。 + pub fn apply(&mut self, event: AppEvent) { + match event { + AppEvent::Core(core) => self.apply_core(core), + AppEvent::Agent(agent) => self.apply_agent(agent), + AppEvent::DialogRequested(request) => { + self.dialogs.push_request(request); + } + AppEvent::DialogClosed(id) => { + self.dialogs.close(id); + } + AppEvent::Toast(toast) => { + self.toasts.push_request(toast); + } + } + } + + /// core 事件。 + pub fn apply_core(&mut self, event: CoreEvent) { + match event { + CoreEvent::InstancesChanged(instances) => self.instances = instances, + CoreEvent::InstanceRuntimeChanged { id, runtime } => { + if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) { + instance.runtime = runtime; + } + } + CoreEvent::VersionListChanged(versions) => self.versions = versions, + CoreEvent::LogSessionsChanged(sessions) => self.log_sessions = sessions, + CoreEvent::LogLinesChanged(lines) => self.log_lines = lines, + CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report), + } + } + + /// agent 事件。 + pub fn apply_agent(&mut self, event: AgentEvent) { + match event { + AgentEvent::MessageAppended(message) => { + // 流式占位到达即视为「一轮回复进行中」:UI 的发送键/停止键据此切换 + if message.streaming { + self.ai.streaming = true; + } + self.ai.messages.push(message); + } + AgentEvent::MessageDelta { index, delta } => { + if let Some(message) = self.ai.messages.get_mut(index) { + message.text.push_str(&delta); + } + } + AgentEvent::MessageFinished { index } => { + if let Some(message) = self.ai.messages.get_mut(index) { + message.streaming = false; + } + self.ai.streaming = false; + } + AgentEvent::ToolCallStarted { name, summary } => { + self.ai.streaming = true; + self.ai.messages.push(ChatMessage { + role: ChatRole::Tool, + text: summary.clone(), + tool: Some(ToolCallView { + name, + ok: false, + summary, + }), + streaming: true, + }); + } + AgentEvent::ToolCallFinished { name, ok, summary } => { + let target = + self.ai.messages.iter_mut().rev().find(|message| { + message.tool.as_ref().is_some_and(|tool| tool.name == name) + }); + if let Some(message) = target { + message.text.clone_from(&summary); + message.streaming = false; + if let Some(tool) = &mut message.tool { + tool.ok = ok; + tool.summary = summary; + } + } + } + } + } + + /// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。 + pub fn tiles(&self) -> Vec> { + let query = self.instance_query.trim().to_lowercase(); + let filtered: Vec<&InstanceSummary> = self + .instances + .iter() + .filter(|instance| query.is_empty() || instance.name.to_lowercase().contains(&query)) + .collect(); + let positions = grid_positions(filtered.len(), self.prefs.tile_size.columns()); + filtered + .into_iter() + .zip(positions) + .map(|(instance, (row, col))| TileCell { instance, row, col }) + .collect() + } + + /// 运行中的实例(实例页顶部迷你状态条)。 + pub fn running(&self) -> Vec<&InstanceSummary> { + self.instances + .iter() + .filter(|instance| { + matches!( + instance.runtime, + InstanceRuntime::Running { .. } | InstanceRuntime::Busy { .. } + ) + }) + .collect() + } + + /// 覆盖某实例的启动进度(mock / 真实后端共用同一入口)。 + pub fn set_progress(&mut self, id: &str, progress: Progress) { + if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) { + if let InstanceRuntime::Busy { + progress: current, .. + } = &mut instance.runtime + { + *current = progress; + } + } + } + + /// 折叠/展开导航栏。 + pub fn toggle_nav(&mut self) { + self.prefs.nav_collapsed = !self.prefs.nav_collapsed; + } + + /// 折叠/展开 AI 栏;若正处在独立页形态则先收回侧栏。 + pub fn toggle_ai(&mut self) { + if self.route() == &Route::Agent { + self.router.back(); + self.prefs.ai_collapsed = false; + return; + } + self.prefs.ai_collapsed = !self.prefs.ai_collapsed; + } +} + +#[cfg(test)] +#[path = "state_tests.rs"] +mod tests; diff --git a/crates/app/src/state_tests.rs b/crates/app/src/state_tests.rs new file mode 100644 index 0000000..9928b12 --- /dev/null +++ b/crates/app/src/state_tests.rs @@ -0,0 +1,262 @@ +//! state.rs 的单元测试(拆文件保持单文件 ≤400 行)。 + +use super::{grid_positions, AiSession, AppState}; +use crate::dialogs::{DialogKind, DialogRequest, DialogSource}; +use crate::dto::{ + AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, Diagnosis, DiagnosisReport, + InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind, +}; +use crate::prefs::TileSize; +use crate::router::Route; +use crate::toast::Toast; +use crate::toast::ToastKind; + +fn instance(id: &str, name: &str) -> InstanceSummary { + InstanceSummary { + id: id.to_string(), + name: name.to_string(), + mc_version: "1.20.4".to_string(), + loader: LoaderInfo { + kind: LoaderKind::Fabric, + version: "0.16.5".to_string(), + }, + last_played: None, + runtime: InstanceRuntime::Idle, + icon: None, + } +} + +#[test] +fn grid_positions_wrap_by_column_count() { + assert_eq!(grid_positions(0, 4), vec![]); + assert_eq!(grid_positions(3, 2), vec![(0, 0), (0, 1), (1, 0)]); + // columns == 0 时按 1 列兜底,不允许除零 + assert_eq!(grid_positions(2, 0), vec![(0, 0), (1, 0)]); +} + +#[test] +fn tiles_follow_tile_size_and_search() { + let mut state = AppState::new(); + state.instances = vec![ + instance("a", "生存"), + instance("b", "创造"), + instance("c", "Demo"), + ]; + + assert_eq!(state.tiles().len(), 3); + assert_eq!((state.tiles()[2].row, state.tiles()[2].col), (0, 2)); + + state.prefs.tile_size = TileSize::Large; + assert_eq!((state.tiles()[2].row, state.tiles()[2].col), (0, 2)); + + state.prefs.tile_size = TileSize::Small; + assert_eq!(state.tiles().len(), 3); + + state.instance_query = "demo".to_string(); + let tiles = state.tiles(); + assert_eq!(tiles.len(), 1); + assert_eq!(tiles[0].instance.id, "c"); + assert_eq!((tiles[0].row, tiles[0].col), (0, 0)); +} + +#[test] +fn tiles_reflow_when_tile_size_shrinks() { + let mut state = AppState::new(); + state.instances = (0..8) + .map(|i| instance(&format!("i{i}"), &format!("实例{i}"))) + .collect(); + + state.prefs.tile_size = TileSize::Medium; + let medium_rows = state.tiles().iter().map(|cell| cell.row).max(); + + state.prefs.tile_size = TileSize::Small; + let small_rows = state.tiles().iter().map(|cell| cell.row).max(); + + assert_eq!(medium_rows, Some(1)); // 8 / 4 列 = 2 行 + assert_eq!(small_rows, Some(1)); // 8 / 6 列 = 2 行 +} + +#[test] +fn runtime_events_update_the_matching_instance() { + let mut state = AppState::new(); + state.instances = vec![instance("a", "生存"), instance("b", "创造")]; + + state.apply(AppEvent::Core(CoreEvent::InstanceRuntimeChanged { + id: "b".to_string(), + runtime: InstanceRuntime::Running { pid: 42 }, + })); + + assert_eq!(state.instances[0].runtime, InstanceRuntime::Idle); + assert_eq!( + state.instances[1].runtime, + InstanceRuntime::Running { pid: 42 } + ); + assert_eq!(state.running().len(), 1); + assert_eq!(state.running()[0].id, "b"); +} + +#[test] +fn progress_events_only_touch_busy_instances() { + let mut state = AppState::new(); + state.instances = vec![instance("a", "生存")]; + state.instances[0].runtime = InstanceRuntime::Busy { + stage: crate::dto::LaunchStage::Download, + progress: crate::dto::Progress { + done: 0, + total: 100, + }, + }; + + state.set_progress( + "a", + crate::dto::Progress { + done: 50, + total: 100, + }, + ); + match &state.instances[0].runtime { + InstanceRuntime::Busy { progress, .. } => { + assert!((progress.ratio() - 0.5).abs() < f32::EPSILON); + } + other => panic!("unexpected runtime: {other:?}"), + } + + // 不存在的 id / 非 Busy 状态不改变任何东西 + state.set_progress("missing", crate::dto::Progress { done: 1, total: 1 }); + assert_eq!(state.instances.len(), 1); +} + +#[test] +fn ai_stream_deltas_accumulate_and_finish() { + let mut state = AppState::new(); + state.apply(AppEvent::Agent(AgentEvent::MessageAppended( + ChatMessage::user("怎么装 1.20.4"), + ))); + state.apply(AppEvent::Agent(AgentEvent::MessageAppended( + ChatMessage::assistant_stream(), + ))); + assert_eq!(state.ai.messages.len(), 2); + assert!(state.ai.streaming); + assert_eq!(state.ai.messages[1].role, ChatRole::Assistant); + + state.apply(AppEvent::Agent(AgentEvent::MessageDelta { + index: 1, + delta: "先".to_string(), + })); + state.apply(AppEvent::Agent(AgentEvent::MessageDelta { + index: 1, + delta: "下载".to_string(), + })); + state.apply(AppEvent::Agent(AgentEvent::MessageFinished { index: 1 })); + + assert_eq!(state.ai.messages[1].text, "先下载"); + assert!(!state.ai.messages[1].streaming); + assert!(!state.ai.streaming); +} + +#[test] +fn ai_session_finish_stream_clears_every_flag() { + let mut session = AiSession::new(); + session.messages.push(ChatMessage::user("hi")); + session.messages.push(ChatMessage::assistant_stream()); + session.finish_stream(); + assert!(session.messages.iter().all(|message| !message.streaming)); + assert!(!session.streaming); +} + +#[test] +fn tool_call_finish_updates_the_last_matching_message() { + let mut state = AppState::new(); + state.apply(AppEvent::Agent(AgentEvent::ToolCallStarted { + name: "get_config".to_string(), + summary: "读取设置".to_string(), + })); + state.apply(AppEvent::Agent(AgentEvent::ToolCallFinished { + name: "get_config".to_string(), + ok: true, + summary: "读取到 3 项".to_string(), + })); + + let tool = &state.ai.messages[0]; + assert_eq!(tool.role, ChatRole::Tool); + assert_eq!(tool.text, "读取到 3 项"); + assert!(!tool.streaming); + assert!(tool.tool.as_ref().is_some_and(|call| call.ok)); +} + +#[test] +fn dialogs_and_toasts_flow_through_apply() { + let mut state = AppState::new(); + state.apply(AppEvent::DialogRequested(DialogRequest { + id: 42, + source: DialogSource::UserAction, + title: "AI 想修改内存".to_string(), + body: "改为 8G".to_string(), + kind: DialogKind::Confirm { + primary: "允许".to_string(), + secondary: "拒绝".to_string(), + }, + })); + state.apply(AppEvent::Toast(Toast { + id: 0, + kind: ToastKind::Success, + text: "下载完成".to_string(), + remaining_ms: 0, + })); + + assert_eq!(state.dialogs.pending_len(), 0); + // id 归生产者:队列原样保留,DialogClosed(42) 才能命中同一条 + assert_eq!(state.dialogs.current().map(|dialog| dialog.id), Some(42)); + assert_eq!(state.toasts.len(), 1); + + state.apply(AppEvent::DialogClosed(42)); + assert!(state.dialogs.is_empty()); +} + +#[test] +fn navigate_to_agent_page_expands_the_ai_panel() { + let mut state = AppState::new(); + state.prefs.ai_collapsed = true; + state.navigate(Route::Agent); + assert_eq!(state.route(), &Route::Agent); + assert_eq!(state.ai_mode(), crate::router::AiPanelMode::Page); + assert!(!state.prefs.ai_collapsed); +} + +#[test] +fn toggling_ai_from_the_page_form_docks_it_again() { + let mut state = AppState::new(); + state.navigate(Route::Agent); + state.toggle_ai(); + assert_eq!(state.route(), &Route::Instances); + assert_eq!(state.ai_mode(), crate::router::AiPanelMode::Docked); + assert!(!state.prefs.ai_collapsed); + + state.toggle_ai(); + assert!(state.prefs.ai_collapsed); +} + +#[test] +fn toggle_nav_flips_the_pref() { + let mut state = AppState::new(); + state.toggle_nav(); + assert!(state.prefs.nav_collapsed); + state.toggle_nav(); + assert!(!state.prefs.nav_collapsed); +} + +#[test] +fn diagnosis_report_lands_on_state() { + let mut state = AppState::new(); + state.apply(AppEvent::Core(CoreEvent::DiagnosisReady(DiagnosisReport { + session: "s1".to_string(), + diagnosis: Diagnosis { + rule: "gpu.driver_old".to_string(), + title: "显卡驱动过旧".to_string(), + confidence: 92, + hint: "升级驱动".to_string(), + fix: Some("open_driver_page".to_string()), + }, + }))); + assert!(state.diagnosis.is_some()); +} diff --git a/crates/app/src/theme_runtime.rs b/crates/app/src/theme_runtime.rs new file mode 100644 index 0000000..851e612 --- /dev/null +++ b/crates/app/src/theme_runtime.rs @@ -0,0 +1,152 @@ +//! 主题与外观的运行期逻辑(从 `ui_bind.rs` 拆出,守住单文件 ≤400 行)。 +//! +//! 全是 `impl Inner` 的方法:配置热切轮询、系统主题对齐、外观令牌下发、背景图/模糊层重建。 +//! 拆出来的边界是「时间驱动的状态维护」——`ui_bind.rs` 只留「建窗口 + 每拍驱动 + 灌属性」。 + +use slint::ComponentHandle; + +use crate::bg_image::{self, BackgroundLayers}; +use crate::conv; +use crate::prefs::{AppearancePrefs, BgFill, ThemeMode}; +use crate::toast::ToastKind; +use crate::ui_bind::{ + BackgroundKey, Inner, CONFIG_POLL_TICKS, SYSTEM_THEME_POLL_TICKS, TILE_REGENERATE_STEP, +}; + +impl Inner { + pub(crate) fn poll_settings(&self) { + if !self.ticks.get().is_multiple_of(CONFIG_POLL_TICKS) { + return; + } + let Some((appearance, theme)) = self.bridge.borrow_mut().reload_if_changed() else { + return; + }; + { + let mut state = self.state.borrow_mut(); + state.prefs.appearance = appearance; + state.prefs.theme = theme; + } + self.apply_theme(); + self.apply_appearance(); + } + + /// 系统主题对齐:启动时先读一次真实系统主题(避免 auto 首帧按浅色画再翻黑); + /// 之后仅在「auto + 后端不认识系统主题」(`Theme.system-theme-known == false`)时兜底轮询。 + pub(crate) fn poll_system_theme(&self) { + let auto = matches!(self.state.borrow().prefs.theme, ThemeMode::Auto); + let backend_known = self.ui.get_system_theme_known(); + let due = !self.system_theme_probed.get() + || (auto && !backend_known && self.ticks.get().is_multiple_of(SYSTEM_THEME_POLL_TICKS)); + if !due { + return; + } + self.system_theme_probed.set(true); + let value = match unlucky_core::platform::theme_probe::probe() { + unlucky_core::platform::theme_probe::SystemTheme::Light => 1, + unlucky_core::platform::theme_probe::SystemTheme::Dark => 2, + unlucky_core::platform::theme_probe::SystemTheme::Unknown => 0, + }; + if self.ui.get_system_theme() != value { + self.ui.set_system_theme(value); + } + } + + /// 启动时的系统主题对齐(首帧前调用一次)。 + pub(crate) fn align_system_theme(&self) { + self.poll_system_theme(); + } + + /// 主题三态 → 根组件属性;`ui/main.slint` 里的 changed 处理器再赋给 `Theme` 全局 + /// (auto 时 Theme 内部读系统的 `Palette.color-scheme` 与本进程探测到的系统主题)。 + pub(crate) fn apply_theme(&self) { + let mode = self.state.borrow().prefs.theme; + self.ui.set_theme_mode(conv::theme_mode(mode)); + } + + /// 外观(高级)四件 → 根组件属性(进而写进 `Theme` 全局)。 + /// + /// 背景图只在「开关打开 + 路径非空 + 换了输入」时才重新解码/生成模糊层。 + pub(crate) fn apply_appearance(&self) { + let appearance = self.state.borrow().prefs.appearance.clone(); + self.ui.set_bg_fill(conv::bg_fill(appearance.bg_fill)); + self.ui.set_bg_dim(appearance.bg_dim.clamp(0.0, 0.9)); + self.ui + .set_panel_alpha(appearance.panel_alpha.clamp(0.4, 1.0)); + self.ui.set_glass(appearance.glass); + self.ui + .set_accent_color(conv::accent_color(&appearance.accent_color)); + self.ui + .set_accent_hex(appearance.accent_color.clone().into()); + self.refresh_background(&appearance); + } + + /// 按需重建背景图 + 模糊层。 + fn refresh_background(&self, appearance: &AppearancePrefs) { + if !appearance.bg_active() { + let had = self.background_key.borrow_mut().take().is_some(); + if had { + self.background.replace(BackgroundLayers::default()); + self.ui.set_background_image(slint::Image::default()); + self.ui.set_background_blur(slint::Image::default()); + } + return; + } + + let key = BackgroundKey { + path: appearance.bg_image.clone(), + fill: appearance.bg_fill, + viewport: self.tile_viewport(appearance.bg_fill), + }; + if self.background_key.borrow().as_ref() == Some(&key) { + return; + } + self.background_key.replace(Some(key.clone())); + + // 模糊层按窗口物理尺寸放大(软件渲染器不做图片缩放插值,见 bg_image::prepare); + // tile 之外的模式窗口缩放不重建画布,模糊层被拉伸也无感。 + let real_viewport = self.window_physical_size(); + match bg_image::prepare(&key.path, key.fill, real_viewport) { + Some(layers) => { + self.ui.set_background_image(layers.image.clone()); + self.ui.set_background_blur(layers.blur.clone()); + self.background.replace(layers); + } + None => { + // 载入失败(路径不存在 / 不是 PNG):清空图层并提示一次,别留上一张图的残影 + self.background.replace(BackgroundLayers::default()); + self.ui.set_background_image(slint::Image::default()); + self.ui.set_background_blur(slint::Image::default()); + self.state.borrow_mut().toasts.push( + ToastKind::Warn, + "背景图载入失败(当前只支持 PNG,且路径需存在)", + ); + } + } + } + + /// tile 画布的窗口尺寸(量化到 [`TILE_REGENERATE_STEP`],避免拖动窗口时反复重算)。 + fn tile_viewport(&self, fill: BgFill) -> (u32, u32) { + if fill != BgFill::Tile { + return (0, 0); + } + let (width, height) = self.window_physical_size(); + ( + width / TILE_REGENERATE_STEP * TILE_REGENERATE_STEP, + height / TILE_REGENERATE_STEP * TILE_REGENERATE_STEP, + ) + } + + /// 窗口物理像素尺寸(取不到时 (0, 0),tile 退化为单块)。 + fn window_physical_size(&self) -> (u32, u32) { + let size = self.ui.window().size(); + (size.width, size.height) + } + + /// 落盘当前偏好(主题 + 外观四件)。 + pub(crate) fn persist_settings(&self) { + let state = self.state.borrow(); + self.bridge + .borrow_mut() + .persist(&state.prefs.appearance, state.prefs.theme); + } +} diff --git a/crates/app/src/toast.rs b/crates/app/src/toast.rs new file mode 100644 index 0000000..5d8ef8e --- /dev/null +++ b/crates/app/src/toast.rs @@ -0,0 +1,152 @@ +//! Toast 中心:右下角非阻塞提示(下载完成 / 复制成功 / 后台任务结论)。 +//! +//! 与模态对话框队列([`crate::dialogs`])分开:Toast 不阻塞、不排序、可叠加, +//! 只做「先进先出 + 上限裁剪 + TTL 到期」。UI 侧由 Rust 定时器每帧调用 [`ToastCenter::expire`]。 + +/// Toast 标识。 +pub type ToastId = u64; + +/// Toast 语义色。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastKind { + /// 一般信息 + Info, + /// 成功 + Success, + /// 警告 + Warn, + /// 错误 + Error, +} + +/// 一条 Toast;`remaining_ms` 由 [`ToastCenter::expire`] 递减。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Toast { + /// 队列内唯一 id(由 [`ToastCenter::push_request`] 重新编号) + pub id: ToastId, + /// 语义色 + pub kind: ToastKind, + /// 提示文案 + pub text: String, + /// 剩余存活毫秒(0 = 用默认 TTL,入队时补) + pub remaining_ms: u64, +} + +/// Toast 中心:先进先出,超出上限丢最旧的。 +#[derive(Debug)] +pub struct ToastCenter { + items: Vec, + next_id: ToastId, + ttl_ms: u64, +} + +impl Default for ToastCenter { + fn default() -> Self { + Self::new() + } +} + +impl ToastCenter { + /// 默认 4s 后自动消失。 + pub fn new() -> Self { + Self { + items: Vec::new(), + next_id: 0, + ttl_ms: 4_000, + } + } + + /// 同时最多显示的条数。 + pub const MAX_VISIBLE: usize = 4; + + /// 入队一条 Toast。 + pub fn push(&mut self, kind: ToastKind, text: impl Into) -> ToastId { + self.push_request(Toast { + id: 0, + kind, + text: text.into(), + remaining_ms: 0, + }) + } + + /// 入队一条已组装好的 Toast(事件通道用);id 重新编号,`remaining_ms == 0` 视为用默认 TTL。 + pub fn push_request(&mut self, mut toast: Toast) -> ToastId { + self.next_id += 1; + toast.id = self.next_id; + if toast.remaining_ms == 0 { + toast.remaining_ms = self.ttl_ms; + } + let id = toast.id; + self.items.push(toast); + while self.items.len() > Self::MAX_VISIBLE { + self.items.remove(0); + } + id + } + + /// 当前显示列表(UI 模型来源)。 + pub fn items(&self) -> &[Toast] { + &self.items + } + + /// 手动关闭(点了关闭按钮)。 + pub fn dismiss(&mut self, id: ToastId) -> bool { + if let Some(at) = self.items.iter().position(|toast| toast.id == id) { + self.items.remove(at); + true + } else { + false + } + } + + /// 时间推进:移除到期 Toast。 + pub fn expire(&mut self, elapsed_ms: u64) { + for toast in &mut self.items { + toast.remaining_ms = toast.remaining_ms.saturating_sub(elapsed_ms); + } + self.items.retain(|toast| toast.remaining_ms > 0); + } + + /// 当前条数。 + pub fn len(&self) -> usize { + self.items.len() + } + + /// 是否没有 Toast。 + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::{ToastCenter, ToastKind}; + + #[test] + fn toasts_expire_and_cap_the_stack() { + let mut center = ToastCenter::new(); + center.push(ToastKind::Info, "a"); + center.push(ToastKind::Info, "b"); + assert_eq!(center.len(), 2); + + for i in 0..10 { + center.push(ToastKind::Success, format!("t{i}")); + } + assert_eq!(center.len(), ToastCenter::MAX_VISIBLE); + + center.expire(3_000); + assert_eq!(center.len(), ToastCenter::MAX_VISIBLE); + center.expire(1_001); + assert!(center.is_empty()); + } + + #[test] + fn dismiss_removes_one_toast() { + let mut center = ToastCenter::new(); + let first = center.push(ToastKind::Info, "a"); + center.push(ToastKind::Warn, "b"); + assert!(center.dismiss(first)); + assert!(!center.dismiss(first)); + assert_eq!(center.len(), 1); + } +} diff --git a/crates/app/src/ui_bind.rs b/crates/app/src/ui_bind.rs new file mode 100644 index 0000000..3352b0c --- /dev/null +++ b/crates/app/src/ui_bind.rs @@ -0,0 +1,333 @@ +//! 绑定层(核心):窗口/状态/后端的持有与同步;回调接线在 [`crate::callbacks`]。 +//! +//! P0 的“后端”是 [`MockBackend`](脚本化假数据);把 `Box` 换成真实的 core/agent +//! host 时,除本文件、`conv.rs`、`callbacks.rs` 外都不需要改 +//! (通道语义见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`)。 +//! +//! 主题与外观(D17 / 09 §101-113):落盘配置由 [`SettingsBridge`] 读写(`config/launcher.json`, +//! 与 `ull config set` 同一份),tick 里 1s 一次 `mtime` 热切;系统主题由 +//! `core::platform::theme_probe` 做启动对齐 + 后端不支持时的兜底轮询。 + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::time::Duration; + +use slint::{ComponentHandle, ModelRc, VecModel}; + +use crate::bg_image::BackgroundLayers; +use crate::conv; +use crate::dto::{AppCommand, Backend, InstanceId}; +use crate::mock::MockBackend; +use crate::prefs::BgFill; +use crate::settings_bridge::SettingsBridge; +use crate::state::AppState; +use crate::{MainWindow, SettingSection}; + +/// 定时器节拍:33ms ≈ 30fps(磁贴进度环 / 流式文本 / Toast TTL 的刷新下限)。 +const TICK_MS: u64 = 33; + +/// 配置热切轮询间隔(拍数):约 1s 一次 `mtime` 比对,AI/CLI 改完下一拍即生效。 +pub(crate) const CONFIG_POLL_TICKS: u64 = 30; + +/// 系统主题兜底轮询间隔(拍数):只在 `auto` 且后端不认识系统主题时启动(约 3s)。 +pub(crate) const SYSTEM_THEME_POLL_TICKS: u64 = 90; + +/// tile 模式重建画布的窗口尺寸粒度(像素):避免拖动窗口时反复重算平铺画布。 +pub(crate) const TILE_REGENERATE_STEP: u32 = 128; + +/// 应用外壳:持有窗口、状态与后端,把三者缝在一起。 +pub struct AppUi { + inner: Rc, + timer: slint::Timer, +} + +pub(crate) struct Inner { + pub(crate) ui: MainWindow, + pub(crate) state: RefCell, + pub(crate) backend: RefCell>, + /// `config/launcher.json` 读写桥(app 不自己写盘:一律走 core::setting 的 schema API) + pub(crate) bridge: RefCell, + /// 当前背景图 / 模糊层(避免每次同步都重新解码) + pub(crate) background: RefCell, + /// 生成 `background` 时的输入(路径 / 填充模式 / 窗口粒度尺寸) + pub(crate) background_key: RefCell>, + /// 拍数(轮询节拍) + pub(crate) ticks: Cell, + /// 是否已经做过启动时的系统主题对齐 + pub(crate) system_theme_probed: Cell, +} + +/// 背景图加工输入(值相同就不重新解码)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BackgroundKey { + pub(crate) path: String, + pub(crate) fill: BgFill, + /// tile 用的窗口尺寸(按 [`TILE_REGENERATE_STEP`] 量化;cover/contain 恒为 (0, 0)) + pub(crate) viewport: (u32, u32), +} + +impl AppUi { + /// 建状态 + 假后端、读配置、接全部回调、灌初始事件并做第一次同步。 + pub fn new(ui: MainWindow) -> Self { + // 先读一次落盘配置:主题三态 + 外观四件必须在首帧前就位(否则会闪一下出厂外观) + let bridge = SettingsBridge::open(); + let (appearance, theme) = bridge.load(); + let mut state = AppState::new(); + state.prefs.appearance = appearance; + state.prefs.theme = theme; + + let inner = Rc::new(Inner { + ui, + state: RefCell::new(state), + backend: RefCell::new(Box::new(MockBackend::new())), + bridge: RefCell::new(bridge), + background: RefCell::new(BackgroundLayers::default()), + background_key: RefCell::new(None), + ticks: Cell::new(0), + system_theme_probed: Cell::new(false), + }); + + crate::callbacks::wire(Rc::downgrade(&inner)); + inner.drain_events(); + inner.align_system_theme(); + inner.apply_theme(); + inner.apply_appearance(); + inner.sync(); + + Self { + inner, + timer: slint::Timer::default(), + } + } + + /// 进入 Slint 事件循环(窗口句柄由本结构持有,直到循环退出)。 + pub fn run(&self) -> Result<(), slint::PlatformError> { + self.inner.ui.run() + } + + /// 启动 33ms 节拍(后端 tick → 事件 → 状态 → UI)。 + pub fn start_timer(&self) { + let inner = Rc::clone(&self.inner); + self.timer.start( + slint::TimerMode::Repeated, + Duration::from_millis(TICK_MS), + move || inner.tick(), + ); + } + + /// 推进一拍(截图通道用:没有事件循环时手动把 tick 走起来,让 mock 事件灌进状态)。 + pub fn tick_once(&self) { + self.inner.tick(); + slint::platform::update_timers_and_animations(); + } + + /// 直接跳到「设置 → 外观」页(截图验收用:证明外观分组的四组控件真的接线了)。 + pub fn open_settings_appearance(&self) { + self.inner + .state + .borrow_mut() + .navigate(crate::router::Route::Settings); + self.inner.sync(); + self.inner + .ui + .set_settings_section(SettingSection::Appearance); + slint::platform::update_timers_and_animations(); + } + + /// 渲染当前场景并存成 PNG(`--screenshot` 通道;见 `crate::png`)。 + /// + /// 为什么要这条通道:验收环境可能没有交互桌面(`CopyFromScreen` 直接失败), + /// 而 Slint 的软件渲染器可以把场景渲染进内存像素缓冲(`Window::take_snapshot`), + /// 结果与屏幕上看到的逐像素一致,且不需要 GPU。 + /// + /// # Errors + /// 窗口显示失败、快照失败或写文件失败时返回错误。 + pub fn render_screenshot(&self, path: &str) -> Result<(), slint::PlatformError> { + let ui = &self.inner.ui; + ui.show()?; + // 固定逻辑尺寸 = main.slint 的默认窗口口径,保证验收图可比 + ui.window() + .set_size(slint::WindowSize::Logical(slint::LogicalSize::new( + 1180.0, 760.0, + ))); + // 先走几拍:把 mock 事件(实例/版本/日志)与 33ms 节拍的效果灌进去 + for _ in 0..4 { + self.tick_once(); + std::thread::sleep(Duration::from_millis(TICK_MS)); + } + let buffer = ui.window().take_snapshot()?; + let bytes = crate::png::encode_rgba8(&buffer); + std::fs::write(path, bytes) + .map_err(|error| slint::PlatformError::Other(format!("写截图失败 {path}: {error}")))?; + Ok(()) + } +} + +/// 发送一条 AI 提问:**只发指令 + 回流事件**,绝不做本地乐观回显。 +/// +/// 契约 §3 规定 host 先发 `MessageAppended(user)`:本地再 push 一次用户消息就会重复; +/// 且流式气泡的收敛依赖事件里的 `MessageFinished{index}`,本地多建的占位会变成 +/// 永不收敛的「幽灵气泡」(review P1-1)。callbacks 与单测都走这一个入口。 +pub(crate) fn send_agent_message(state: &mut AppState, backend: &mut dyn Backend, text: &str) { + backend.dispatch(AppCommand::AskAgent(text.to_string())); + for event in backend.drain_events() { + state.apply(event); + } +} + +impl Inner { + /// 走一次完整的「提问 → 事件回流」。 + pub(crate) fn ask_agent(&self, text: &str) { + let mut state = self.state.borrow_mut(); + let mut backend = self.backend.borrow_mut(); + send_agent_message(&mut state, backend.as_mut(), text); + } + + pub(crate) fn dispatch(&self, command: AppCommand) { + self.backend.borrow_mut().dispatch(command); + self.drain_events(); + } + + pub(crate) fn drain_events(&self) { + let events = self.backend.borrow_mut().drain_events(); + if events.is_empty() { + return; + } + let mut state = self.state.borrow_mut(); + for event in events { + state.apply(event); + } + } + + fn tick(&self) { + self.backend + .borrow_mut() + .tick(Duration::from_millis(TICK_MS)); + self.drain_events(); + self.state.borrow_mut().toasts.expire(TICK_MS); + self.ticks.set(self.ticks.get() + 1); + self.poll_settings(); + self.poll_system_theme(); + self.sync(); + } + + pub(crate) fn sync(&self) { + let state = self.state.borrow(); + let ui = &self.ui; + + ui.set_route(conv::route(state.route())); + ui.set_nav_collapsed(state.prefs.nav_collapsed); + ui.set_ai_collapsed(state.prefs.ai_collapsed); + ui.set_tile_size(conv::tile_size(state.prefs.tile_size)); + ui.set_instance_view(conv::instance_view(state.prefs.instance_view)); + ui.set_download_category(conv::category_from(state.prefs.download_category)); + ui.set_download_installed_only(state.installed_only); + ui.set_theme_mode(conv::theme_mode(state.prefs.theme)); + + ui.set_tiles(model(conv::tiles(&state))); + ui.set_running_tiles(model(conv::running_tiles(&state))); + ui.set_versions(model(conv::versions(&state))); + ui.set_log_sessions(model(conv::log_sessions(&state))); + ui.set_log_lines(model(conv::log_lines(&state))); + ui.set_toasts(model(conv::toasts(&state))); + + let (diagnosis, has_diagnosis) = conv::diagnosis(&state); + ui.set_diagnosis(diagnosis); + ui.set_has_diagnosis(has_diagnosis); + ui.set_selected_session(state.selected_session.clone().unwrap_or_default().into()); + + ui.set_ai_messages(model(conv::chat(&state))); + ui.set_ai_streaming(state.ai.streaming); + ui.set_yolo(state.ai.yolo); + ui.set_ai_endpoint_label(state.ai.endpoint_label.clone().into()); + + let (dialog, open, queued) = conv::dialog(&state); + ui.set_dialog(dialog); + ui.set_dialog_open(open); + ui.set_dialog_queued(queued); + } +} + +pub(crate) fn model(items: Vec) -> ModelRc { + ModelRc::new(VecModel::from(items)) +} + +/// 停止实例的指令构造(阶段 C 的磁贴「停止」键用;P0 只保留 API)。 +#[allow(dead_code)] +pub fn stop_instance(id: &InstanceId) -> AppCommand { + AppCommand::StopInstance(id.clone()) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::send_agent_message; + use crate::dto::{Backend, ChatRole}; + use crate::mock::MockBackend; + use crate::state::AppState; + + /// 集成测试(callbacks→state→mock 全链路):发一次提问后 + /// ①用户消息恰出现一次(无本地回显重复)②所有 assistant 气泡收敛(无幽灵气泡)。 + /// + /// 分层单测漏掉这条正是 review P1-1 的根因,故这里用真实 mock 后端跑完整事件流。 + #[test] + fn send_path_has_no_duplicate_user_message_and_no_ghost_stream() { + let mut state = AppState::new(); + let mut backend = MockBackend::new(); + // 先灌入种子事件(实例/版本/日志快照) + for event in backend.drain_events() { + state.apply(event); + } + + send_agent_message(&mut state, &mut backend, "装哪个版本"); + + fn user_count(state: &AppState) -> usize { + state + .ai + .messages + .iter() + .filter(|message| message.role == ChatRole::User) + .count() + } + assert_eq!( + user_count(&state), + 1, + "用户消息必须恰好一条(本地回显会造成两条)" + ); + assert!(state.ai.streaming); + + // 推进到流式收敛(mock 300ms/片 + 工具调用 + 收尾消息) + for _ in 0..12 { + backend.tick(Duration::from_millis(400)); + for event in backend.drain_events() { + state.apply(event); + } + } + + assert_eq!(user_count(&state), 1, "事件回流后用户消息仍应只有一条"); + assert!( + state.ai.messages.iter().all(|message| !message.streaming), + "所有气泡必须收敛(幽灵气泡会永久 streaming)" + ); + assert!(!state.ai.streaming); + assert!( + state + .ai + .messages + .iter() + .any(|message| message.role == ChatRole::Assistant && !message.text.is_empty()), + "增量应累积到 assistant 正文" + ); + assert_eq!( + state + .ai + .messages + .iter() + .filter(|message| message.role == ChatRole::Assistant) + .count(), + 2, + "一轮对话 = 流式回复 + 收尾回复" + ); + } +} diff --git a/crates/app/ui/components/ai-panel.slint b/crates/app/ui/components/ai-panel.slint new file mode 100644 index 0000000..a9982f1 --- /dev/null +++ b/crates/app/ui/components/ai-panel.slint @@ -0,0 +1,283 @@ +// 右侧 AI 常驻栏(D16 §1):默认 320px 可折叠;可「展开为页面」变成全宽独立页, +// 两形态共享同一份会话模型(由 Rust 侧 AiSession 持有,这里只渲染,切换不丢状态)。 +// 栏顶承载 YOLO 快捷开关(D4 补充)与端点标签。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiChatMessage, ChatRole } from "../models.slint"; +import { UiText, TextRole } from "label.slint"; +import { AppIcon, IconKind } from "icons.slint"; +import { MetroButton, ButtonKind } from "../widgets/button.slint"; +import { PlainInput } from "../widgets/inputs.slint"; +import { GlassBase } from "glass.slint"; +import { ListView } from "std-widgets.slint"; + +// 一条会话消息:user 靠右(accent-soft 块)/ assistant 靠左 / tool 用状态边框 / system 居中弱化 +component ChatBubble inherits Rectangle { + in property message; + + property is-tool: message.role == ChatRole.tool; + property is-system: message.role == ChatRole.system; + property text-width: root.width - 2 * Metrics.gap-2; + property tool-row: is-tool ? 16px : 0px; + + height: text-node.preferred-height + 2 * Metrics.gap-2 + tool-row; + background: message.role == ChatRole.user + ? Theme.accent-soft + : is-tool ? Theme.bg-canvas + : is-system ? transparent + : Theme.bg-elevated; + border-radius: Metrics.radius-md; + border-width: is-tool ? 1px : 0px; + border-color: message.tool_ok ? Theme.state-success : Theme.state-danger; + + // 工具调用可视化(D4 要求:工具执行在会话里可见) + if is-tool : AppIcon { + x: Metrics.gap-2; + y: Metrics.gap-2 + 1px; + icon-size: 12px; + kind: message.tool_ok ? IconKind.check : IconKind.error; + tint: message.tool_ok ? Theme.state-success : Theme.state-danger; + } + if is-tool : UiText { + x: Metrics.gap-2 + 16px; + y: Metrics.gap-2; + width: text-width - 16px; + height: 14px; + role: TextRole.caption; + text: "工具 · " + message.tool_name; + color: message.tool_ok ? Theme.state-success : Theme.state-danger; + } + + text-node := UiText { + x: Metrics.gap-2; + y: Metrics.gap-2 + tool-row; + width: text-width; + height: text-node.preferred-height; + role: TextRole.body; + text: message.text; + wrap: word-wrap; + overflow: elide; + vertical-alignment: top; + horizontal-alignment: message.role == ChatRole.user ? right : left; + color: is-system ? Theme.fg-secondary : Theme.fg-primary; + } + + // 流式未收敛:末尾的 accent 光标块 + if message.streaming : Rectangle { + x: 0; + y: root.height - 10px; + width: 8px; + height: 10px; + background: Theme.accent; + } +} + +export component AiPanel inherits Rectangle { + in property <[UiChatMessage]> messages; + in-out property collapsed: false; + /// true = 停在右侧栏形态;false = 已拆成独立全宽页 + in property docked: true; + in property yolo: false; + in property streaming: false; + in property endpoint-label: "llama.cpp · 本地"; + in-out property draft: ""; + + callback toggle-collapsed(); + callback expand(); + callback dock(); + callback toggle-yolo(); + callback send(string); + callback stop(); + + // 栏形态用 min/max 钉死宽度(布局里等价于固定宽度),页形态交给父容器铺满 + min-width: docked ? (collapsed ? Metrics.ai-rail-width : Metrics.ai-panel-width) : 0px; + max-width: docked ? (collapsed ? Metrics.ai-rail-width : Metrics.ai-panel-width) : 100000px; + horizontal-stretch: docked ? 0 : 1; + height: 100%; + background: Theme.bg-surface; + + // 玻璃拟态底(D17 高级特性;关闭态不画东西)——排在内容之前,压在最底层 + GlassBase { card-radius: 0px; } + + // 左边界线 + Rectangle { + x: 0; + y: 0; + width: 1px; + height: root.height; + background: Theme.stroke; + } + + // ── 折叠态:icon 轨 ───────────────────────────────────────── + if docked && collapsed : VerticalLayout { + x: 1px; + width: root.width - 1px; + height: root.height; + padding-top: Metrics.gap-2; + padding-bottom: Metrics.gap-2; + spacing: Metrics.gap-2; + + AppIcon { + kind: IconKind.ai; + icon-size: 18px; + tint: Theme.accent; + + TouchArea { + mouse-cursor: pointer; + clicked => { root.toggle-collapsed(); } + } + } + + AppIcon { + kind: IconKind.expand; + icon-size: 14px; + tint: Theme.fg-secondary; + + TouchArea { + mouse-cursor: pointer; + clicked => { root.expand(); } + } + } + + Rectangle { vertical-stretch: 1; background: transparent; } + + // YOLO 指示点:开启时实心 accent + Rectangle { + width: 6px; + height: 6px; + border-radius: 3px; + background: root.yolo ? Theme.accent : Theme.stroke-strong; + } + } + + // ── 展开态:栏内 / 独立页共用 ──────────────────────────────── + if !(docked && collapsed) : VerticalLayout { + x: 1px; + width: root.width - 1px; + height: root.height; + + // 头部 + Rectangle { + height: 52px; + background: transparent; + + HorizontalLayout { + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-2; + spacing: Metrics.gap-2; + + AppIcon { kind: IconKind.ai; icon-size: 16px; tint: Theme.accent; } + + VerticalLayout { + spacing: 0px; + vertical-stretch: 1; + + UiText { + role: TextRole.bodyStrong; + text: "AI 助手"; + horizontal-stretch: 1; + } + UiText { + role: TextRole.caption; + text: root.endpoint-label; + horizontal-stretch: 1; + } + } + + MetroButton { + kind: root.yolo ? ButtonKind.accent : ButtonKind.subtle; + text: "YOLO"; + button-height: 24px; + clicked => { root.toggle-yolo(); } + } + + if root.docked : MetroButton { + kind: ButtonKind.subtle; + icon: IconKind.expand; + icon-only: true; + button-height: 24px; + clicked => { root.expand(); } + } + if !root.docked : MetroButton { + kind: ButtonKind.subtle; + icon: IconKind.sidebar; + icon-only: true; + button-height: 24px; + clicked => { root.dock(); } + } + if root.docked : MetroButton { + kind: ButtonKind.subtle; + icon: IconKind.chevronRight; + icon-only: true; + button-height: 24px; + clicked => { root.toggle-collapsed(); } + } + } + } + + Rectangle { height: 1px; background: Theme.stroke; } + + // 对话流(ListView 行虚拟化,research/01 已确认) + ListView { + vertical-stretch: 1; + + for m in root.messages : ChatBubble { + message: m; + width: root.width - 2 * Metrics.gap-3; + } + } + + if root.messages.length == 0 : UiText { + role: TextRole.caption; + text: "问点什么:装哪个版本、崩溃怎么修、帮我改设置…"; + horizontal-alignment: center; + height: 40px; + } + + Rectangle { height: 1px; background: Theme.stroke; } + + // 输入区 + Rectangle { + height: 56px; + background: transparent; + + HorizontalLayout { + padding: Metrics.gap-2; + spacing: Metrics.gap-2; + + input-box := Rectangle { + horizontal-stretch: 1; + vertical-stretch: 1; + background: Theme.bg-input; + border-radius: Metrics.radius-sm; + border-width: 1px; + border-color: input-field.focused ? Theme.accent : Theme.stroke; + + input-field := PlainInput { + text-padding: Metrics.gap-2; + placeholder: "输入消息,Enter 发送"; + text <=> root.draft; + accepted(t) => { + if (t != "") { + root.send(t); + } + } + } + } + + MetroButton { + kind: ButtonKind.accent; + icon: root.streaming ? IconKind.stop : IconKind.send; + icon-only: true; + clicked => { + if (root.streaming) { + root.stop(); + } else if (root.draft != "") { + root.send(root.draft); + } + } + } + } + } + } +} diff --git a/crates/app/ui/components/badge.slint b/crates/app/ui/components/badge.slint new file mode 100644 index 0000000..4d68f57 --- /dev/null +++ b/crates/app/ui/components/badge.slint @@ -0,0 +1,27 @@ +// 徽标:MC 版本 / 加载器 / 已装 / 远古版 等短标记。Metro 做法是细边框小方块,不用药丸圆角。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiText, TextRole } from "../components/label.slint"; + +export component Badge inherits Rectangle { + in property text: ""; + in property tint: Theme.fg-secondary; + in property fill: transparent; + + height: 18px; + width: label.preferred-width + 2 * Metrics.gap-1 + 2px; + background: fill; + border-width: 1px; + border-color: tint; + border-radius: Metrics.radius-sm; + + label := UiText { + role: TextRole.caption; + text: root.text; + color: root.tint; + font-size: 11px; + horizontal-alignment: center; + x: Metrics.gap-1 + 1px; + width: label.preferred-width; + } +} diff --git a/crates/app/ui/components/glass.slint b/crates/app/ui/components/glass.slint new file mode 100644 index 0000000..79fc043 --- /dev/null +++ b/crates/app/ui/components/glass.slint @@ -0,0 +1,59 @@ +// 玻璃拟态卡片的公共底座(09 §103:Slint 没有 backdrop-filter,用「预生成模糊背景层」替代)。 +// +// 手法:Rust 侧把背景图降采样成一张极小的图(`Theme.bg-blur`),渲染时被平滑放大 → 天然模糊。 +// 卡片里把它按 `absolute-position` **反向偏移**画出来,于是卡片显示的是「自己这一块屏幕区域的 +// 模糊影像」,视觉上就是毛玻璃;再叠一层半透明面板色保证文字可读(对比度优先)。 +// +// 关闭态(`Theme.glass == false` 或无背景图)不画任何东西:卡片保持 Metro 纯色观感。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; + +export component GlassBase inherits Rectangle { + /// 卡片圆角(跟随宿主卡片) + in property card-radius: Metrics.radius-md; + /// 叠在模糊层上的半透明面板色(保证前景对比度) + in property tint: Theme.surface-solid; + + out property active: Theme.glass && Theme.bg-blur.width > 0 && Theme.bg-image.width > 0; + + x: 0; + y: 0; + width: 100%; + height: 100%; + border-radius: root.card-radius; + clip: true; + background: transparent; + + if root.active : Image { + // 整窗模糊层 + 反向偏移 = 只露出「卡片所在那一块」。 + // 必须用 `parent`(本组件自身)的绝对位置:用 `self` 会与自身 x 形成绑定环 + // (absolute-position 依赖 x,x 又依赖 absolute-position)。 + source: Theme.bg-blur; + x: -parent.absolute-position.x; + y: -parent.absolute-position.y; + width: Theme.window-width; + height: Theme.window-height; + image-fit: cover; + } + + if root.active : Rectangle { + // 暗化层要**重画一次**:模糊层在卡片内部、盖在窗口的暗化层之上, + // 不补这一层的话玻璃卡片会比周围亮一大截(05 截图验收时的真实问题)。 + background: rgba(0, 0, 0, Theme.bg-dim); + border-radius: root.card-radius; + } + + if root.active : Rectangle { + // 玻璃染色:面板底色 55% 不透明,字压得住背景(对比度优先于「更透」) + background: root.tint.with-alpha(0.55); + border-radius: root.card-radius; + } + + if root.active : Rectangle { + // 上缘一道细高光,玻璃的「厚度感」 + y: 0; + height: 1px; + width: root.width; + background: Theme.stroke.with-alpha(0.35); + } +} diff --git a/crates/app/ui/components/icons.slint b/crates/app/ui/components/icons.slint new file mode 100644 index 0000000..c1a6e61 --- /dev/null +++ b/crates/app/ui/components/icons.slint @@ -0,0 +1,192 @@ +// 图标:18×18 viewbox 的矢量绘制(Metro 要求线性几何图标,不用 emoji/位图)。 +// 只用 M/L/Q/Z 命令 + 直线,避免依赖字体字形;描边图标 stroke,实心图标 fill。 +import { Theme } from "../theme/tokens.slint"; + +export enum IconKind { + instances, + download, + logs, + settings, + ai, + play, + stop, + search, + plus, + close, + minimize, + maximize, + chevronLeft, + chevronRight, + chevronDown, + expand, + send, + check, + warn, + error, + more, + sidebar, + restore, +} + +export component AppIcon inherits Rectangle { + in property kind: IconKind.instances; + in property tint: Theme.fg-secondary; + in property icon-size: 18px; + + width: icon-size; + height: icon-size; + background: transparent; + + if kind == IconKind.instances : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 2 2 L 8 2 L 8 8 L 2 8 Z M 10 2 L 16 2 L 16 8 L 10 8 Z M 2 10 L 8 10 L 8 16 L 2 16 Z M 10 10 L 16 10 L 16 16 L 10 16 Z"; + fill: transparent; stroke: root.tint; stroke-width: 1.4px; + } + if kind == IconKind.download : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 9 2 L 9 11 M 4 7 L 9 12 L 14 7 M 3 15 L 15 15"; + fill: transparent; stroke: root.tint; stroke-width: 1.6px; + } + if kind == IconKind.logs : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 3 4 L 15 4 M 3 9 L 15 9 M 3 14 L 11 14"; + fill: transparent; stroke: root.tint; stroke-width: 1.6px; + } + if kind == IconKind.settings : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 3 5 L 15 5 M 3 9 L 15 9 M 3 13 L 15 13"; + fill: transparent; stroke: root.tint; stroke-width: 1.4px; + } + if kind == IconKind.settings : Rectangle { + x: 5px; + width: 5px; height: 5px; y: 3px; + border-radius: 2.5px; background: root.tint; + } + if kind == IconKind.settings : Rectangle { + x: 10px; y: 7px; width: 5px; height: 5px; + border-radius: 2.5px; background: root.tint; + } + if kind == IconKind.settings : Rectangle { + x: 3px; y: 11px; width: 5px; height: 5px; + border-radius: 2.5px; background: root.tint; + } + if kind == IconKind.ai : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 9 1.5 L 10.7 7.3 L 16.5 9 L 10.7 10.7 L 9 16.5 L 7.3 10.7 L 1.5 9 L 7.3 7.3 Z"; + fill: root.tint; stroke: transparent; stroke-width: 1px; + } + if kind == IconKind.play : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 5 3 L 15 9 L 5 15 Z"; + fill: root.tint; stroke: transparent; stroke-width: 1px; + } + if kind == IconKind.stop : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 5 5 L 13 5 L 13 13 L 5 13 Z"; + fill: root.tint; stroke: transparent; stroke-width: 1px; + } + if kind == IconKind.search : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 8 3 Q 13 3 13 8 Q 13 13 8 13 Q 3 13 3 8 Q 3 3 8 3 Z M 11.5 11.5 L 16 16"; + fill: transparent; stroke: root.tint; stroke-width: 1.5px; + } + if kind == IconKind.plus : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 9 3 L 9 15 M 3 9 L 15 9"; + fill: transparent; stroke: root.tint; stroke-width: 1.8px; + } + if kind == IconKind.close : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 4 4 L 14 14 M 14 4 L 4 14"; + fill: transparent; stroke: root.tint; stroke-width: 1.4px; + } + if kind == IconKind.minimize : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 4 9 L 14 9"; + fill: transparent; stroke: root.tint; stroke-width: 1.4px; + } + if kind == IconKind.maximize : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 4.5 4.5 L 13.5 4.5 L 13.5 13.5 L 4.5 13.5 Z"; + fill: transparent; stroke: root.tint; stroke-width: 1.2px; + } + if kind == IconKind.chevronLeft : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 11 3 L 6 9 L 11 15"; + fill: transparent; stroke: root.tint; stroke-width: 1.6px; + } + if kind == IconKind.chevronRight : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 7 3 L 12 9 L 7 15"; + fill: transparent; stroke: root.tint; stroke-width: 1.6px; + } + if kind == IconKind.chevronDown : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 3 7 L 9 13 L 15 7"; + fill: transparent; stroke: root.tint; stroke-width: 1.6px; + } + if kind == IconKind.expand : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 3 7 L 3 3 L 7 3 M 11 3 L 15 3 L 15 7 M 15 11 L 15 15 L 11 15 M 7 15 L 3 15 L 3 11"; + fill: transparent; stroke: root.tint; stroke-width: 1.4px; + } + if kind == IconKind.send : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 2 9 L 16 9 M 11 4 L 16 9 L 11 14"; + fill: transparent; stroke: root.tint; stroke-width: 1.6px; + } + if kind == IconKind.check : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 3 9 L 7 13 L 15 4"; + fill: transparent; stroke: root.tint; stroke-width: 1.8px; + } + if kind == IconKind.warn : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 9 2 L 16 15 L 2 15 Z M 9 6.5 L 9 10.5"; + fill: transparent; stroke: root.tint; stroke-width: 1.4px; + } + if kind == IconKind.error : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 9 2 Q 16 2 16 9 Q 16 16 9 16 Q 2 16 2 9 Q 2 2 9 2 Z M 9 5 L 9 10"; + fill: transparent; stroke: root.tint; stroke-width: 1.5px; + } + if kind == IconKind.more : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 4 7 L 6 7 L 6 9 L 4 9 Z M 8 7 L 10 7 L 10 9 L 8 9 Z M 12 7 L 14 7 L 14 9 L 12 9 Z"; + fill: root.tint; stroke: transparent; stroke-width: 1px; + } + if kind == IconKind.sidebar : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 3 4 L 15 4 L 15 14 L 3 14 Z M 7 4 L 7 14"; + fill: transparent; stroke: root.tint; stroke-width: 1.4px; + } + // 还原(已最大化时的标题栏按钮):两层叠放方框 + if kind == IconKind.restore : Path { + width: root.width; height: root.height; + viewbox-width: 18; viewbox-height: 18; + commands: "M 6 3 L 15 3 L 15 12 M 3 6 L 12 6 L 12 15 L 3 15 Z"; + fill: transparent; stroke: root.tint; stroke-width: 1.2px; + } +} diff --git a/crates/app/ui/components/label.slint b/crates/app/ui/components/label.slint new file mode 100644 index 0000000..0dd42ae --- /dev/null +++ b/crates/app/ui/components/label.slint @@ -0,0 +1,34 @@ +// 排版角色封装:所有文字都走 UiText,不在页面里散落 font-size/font-family 字面量。 +// 角色 → 字号/字重/默认前景色由 tokens.slint 的 ramp 决定;外部赋值可覆盖(例如强调色标题)。 +import { Theme } from "../theme/tokens.slint"; + +export enum TextRole { + display, + title, + subtitle, + body, + bodyStrong, + caption, + mono, + monoSmall, +} + +export component UiText inherits Text { + in property role: TextRole.body; + + color: role == TextRole.caption ? Theme.fg-secondary : Theme.fg-primary; + font-family: (role == TextRole.mono || role == TextRole.monoSmall) + ? Theme.font-family-mono + : Theme.font-family; + font-size: (role == TextRole.display) ? Theme.font-size-display + : (role == TextRole.title) ? Theme.font-size-title + : (role == TextRole.subtitle) ? Theme.font-size-subtitle + : (role == TextRole.caption || role == TextRole.monoSmall) ? Theme.font-size-caption + : Theme.font-size-body; + font-weight: (role == TextRole.bodyStrong || role == TextRole.title || role == TextRole.display) + ? 600 + : 400; + vertical-alignment: center; + wrap: no-wrap; + overflow: elide; +} diff --git a/crates/app/ui/components/nav.slint b/crates/app/ui/components/nav.slint new file mode 100644 index 0000000..739f308 --- /dev/null +++ b/crates/app/ui/components/nav.slint @@ -0,0 +1,182 @@ +// 左侧主导航(D16 §1):图标+文字,选中项 accent 色块;可折叠成纯图标窄条(偏好记忆)。 +// 折叠宽度/展开宽度取 Metrics,动画时长取 Theme.motion-normal。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { RouteKind } from "../models.slint"; +import { UiText, TextRole } from "label.slint"; +import { AppIcon, IconKind } from "icons.slint"; + +component NavItem inherits Rectangle { + in property icon: IconKind.instances; + in property label: ""; + in property active: false; + in property collapsed: false; + callback clicked(); + + height: root.collapsed ? 48px : 64px; + background: root.active + ? Theme.accent-soft + : touch.has-hover ? Theme.bg-hover : transparent; + animate background { duration: Theme.motion-fast; } + + // 选中态:左侧 accent 竖条(Metro 侧栏语义) + Rectangle { + visible: root.active; + x: 0; + y: 0; + width: 3px; + height: root.height; + background: Theme.accent; + } + + AppIcon { + kind: root.icon; + icon-size: 20px; + x: (root.width - self.width) / 2; + y: root.collapsed ? (root.height - self.height) / 2 : 12px; + tint: root.active ? Theme.accent : Theme.fg-secondary; + } + + UiText { + visible: !root.collapsed; + role: TextRole.caption; + text: root.label; + color: root.active ? Theme.accent : Theme.fg-secondary; + horizontal-alignment: center; + x: 0; + y: 38px; + width: root.width; + } + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } + + accessible-role: button; + accessible-label: root.label; +} + +export component Nav inherits Rectangle { + in property active: RouteKind.instances; + in-out property collapsed: false; + in property account-name: "未登录"; + in property account-hue: #5C2D91; + in property account-initial: "?"; + callback navigate(RouteKind); + callback toggle-collapsed(); + callback account-clicked(); + + width: collapsed ? Metrics.nav-collapsed-width : Metrics.nav-expanded-width; + height: 100%; + background: Theme.bg-surface; + animate width { duration: Theme.motion-normal; easing: ease-in-out; } + + // 右边界线 + Rectangle { + x: root.width - 1px; + y: 0; + width: 1px; + height: root.height; + background: Theme.stroke; + } + + VerticalLayout { + x: 0; + y: Metrics.gap-2; + width: root.width; + height: root.height - Metrics.gap-2 - Metrics.gap-2; + + NavItem { + icon: IconKind.instances; + label: "实例"; + active: root.active == RouteKind.instances || root.active == RouteKind.detail; + collapsed: root.collapsed; + clicked => { root.navigate(RouteKind.instances); } + } + NavItem { + icon: IconKind.download; + label: "下载"; + active: root.active == RouteKind.download; + collapsed: root.collapsed; + clicked => { root.navigate(RouteKind.download); } + } + NavItem { + icon: IconKind.logs; + label: "日志"; + active: root.active == RouteKind.logs; + collapsed: root.collapsed; + clicked => { root.navigate(RouteKind.logs); } + } + NavItem { + icon: IconKind.settings; + label: "设置"; + active: root.active == RouteKind.settings; + collapsed: root.collapsed; + clicked => { root.navigate(RouteKind.settings); } + } + + Rectangle { vertical-stretch: 1; background: transparent; } + + // 底部:账户头像(点击进设置·账户) + Rectangle { + height: 56px; + background: account-touch.has-hover ? Theme.bg-hover : transparent; + + Rectangle { + width: 28px; + height: 28px; + border-radius: 14px; + background: root.account-hue; + x: (root.width - self.width) / 2; + y: 8px; + + UiText { + text: root.account-initial; + font-size: 12px; + font-weight: 600; + color: Theme.fg-on-accent; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + UiText { + visible: !root.collapsed; + role: TextRole.caption; + text: root.account-name; + horizontal-alignment: center; + x: 0; + y: 38px; + width: root.width; + } + + account-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.account-clicked(); } + } + } + + // 折叠开关(偏好由 Rust 侧持久化到 ui.nav.collapsed) + Rectangle { + height: 40px; + background: toggle-touch.has-hover ? Theme.bg-hover : transparent; + + AppIcon { + kind: root.collapsed ? IconKind.chevronRight : IconKind.chevronLeft; + icon-size: 16px; + x: (root.width - self.width) / 2; + y: (root.height - self.height) / 2; + tint: Theme.fg-secondary; + } + + toggle-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.toggle-collapsed(); } + } + + accessible-role: button; + accessible-label: root.collapsed ? "展开导航栏" : "折叠导航栏"; + } + } +} diff --git a/crates/app/ui/components/progress-ring.slint b/crates/app/ui/components/progress-ring.slint new file mode 100644 index 0000000..4bb82d4 --- /dev/null +++ b/crates/app/ui/components/progress-ring.slint @@ -0,0 +1,22 @@ +// 启动进度环:磁贴「启动中」状态用(下载/校验/启动三个阶段共用一个环)。 +// 用 std-widgets 的 ProgressIndicator:progress <= 0 时转为不确定态(转圈)。 +// P1 若要完全贴合 Metro 强调色,需自绘弧线(Slint 无 arc 命令,得用旋转遮罩法)。 +import { Theme } from "../theme/tokens.slint"; +import { ProgressIndicator } from "std-widgets.slint"; + +export component ProgressRing inherits Rectangle { + /// 0.0~1.0;<= 0 表示未知进度 + in property progress: 0; + in property ring-size: 28px; + + width: ring-size; + height: ring-size; + background: transparent; + + ProgressIndicator { + width: root.ring-size; + height: root.ring-size; + progress: root.progress; + indeterminate: root.progress <= 0; + } +} diff --git a/crates/app/ui/components/tile.slint b/crates/app/ui/components/tile.slint new file mode 100644 index 0000000..16e455c --- /dev/null +++ b/crates/app/ui/components/tile.slint @@ -0,0 +1,198 @@ +// 实例磁贴(G1):图标 / 名字 / MC 版本徽标 / 加载器徽标 / 最后游玩 / 大启动键 + 设置键。 +// 三档尺寸由 Metrics.tile-* 决定(小磁贴藏徽标与时间,只留名字+启动); +// 启动中把底部按钮行替换成「进度环 + 阶段文案 + 停止」。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiTile, RuntimePhase, LoaderKind } from "../models.slint"; +import { UiText, TextRole } from "label.slint"; +import { AppIcon, IconKind } from "icons.slint"; +import { Badge } from "badge.slint"; +import { ProgressRing } from "progress-ring.slint"; +import { GlassBase } from "glass.slint"; +import { MetroButton, ButtonKind } from "../widgets/button.slint"; + +component TileIcon inherits Rectangle { + in property data; + in property box-size: 44px; + + width: box-size; + height: box-size; + border-radius: Metrics.radius-md; + background: root.data.hue; + + if root.data.icon.width > 0 : Image { + x: 0; + y: 0; + width: root.width; + height: root.height; + source: root.data.icon; + image-fit: cover; + } + if root.data.icon.width <= 0 : UiText { + text: root.data.initial; + font-size: 18px; + font-weight: 600; + color: Theme.fg-on-accent; + horizontal-alignment: center; + vertical-alignment: center; + } +} + +export component InstanceTile inherits Rectangle { + in property data; + in property compact: false; + callback open(); + callback launch(); + callback configure(); + /// 启动中/运行中的停止键(dispatch StopInstance) + callback stop(); + + height: Metrics.tile-height; + background: touch.has-hover ? Theme.bg-elevated : Theme.bg-surface; + border-radius: Metrics.radius-md; + border-width: 1px; + border-color: root.data.phase == RuntimePhase.running ? Theme.accent : Theme.stroke; + drop-shadow-blur: touch.has-hover ? 12px : 0px; + drop-shadow-color: Theme.shadow; + drop-shadow-offset-y: 2px; + animate background { duration: Theme.motion-fast; } + animate drop-shadow-blur { duration: Theme.motion-normal; } + + // 玻璃拟态底(关闭态什么都不画,卡片就是 Metro 纯色);必须排在最前,否则盖住 accent 细线 + GlassBase { + card-radius: root.border-radius; + } + + // 运行中:顶部 accent 细线(Metro 色块语义) + Rectangle { + visible: root.data.phase == RuntimePhase.running; + x: 0; + y: 0; + width: root.width; + height: 2px; + background: Theme.accent; + } + + // 磁贴主体点击 → 实例详情(G4);按钮的 TouchArea 后声明,命中优先 + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.open(); } + } + + VerticalLayout { + padding: Metrics.gap-3; + spacing: Metrics.gap-2; + + HorizontalLayout { + spacing: Metrics.gap-2; + + TileIcon { + data: root.data; + box-size: Metrics.tile-icon-size; + } + + VerticalLayout { + spacing: 2px; + vertical-stretch: 1; + + UiText { + role: TextRole.bodyStrong; + text: root.data.name; + horizontal-stretch: 1; + } + + UiText { + role: TextRole.caption; + text: root.data.last_played; + visible: !root.compact; + } + } + } + + HorizontalLayout { + visible: !root.compact; + spacing: Metrics.gap-1; + + Badge { + text: root.data.mc_version; + tint: Theme.fg-secondary; + } + Badge { + text: root.data.loader_label; + tint: root.data.loader == LoaderKind.vanilla ? Theme.fg-secondary : Theme.accent; + } + } + + Rectangle { vertical-stretch: 1; background: transparent; } + + // 空闲/失败:大启动键 + 设置键 + HorizontalLayout { + visible: root.data.phase == RuntimePhase.idle || root.data.phase == RuntimePhase.failed; + spacing: Metrics.gap-2; + + MetroButton { + kind: ButtonKind.accent; + text: root.data.phase == RuntimePhase.failed ? "重试" : "启动"; + show-icon: true; + icon: IconKind.play; + grow: true; + clicked => { root.launch(); } + } + MetroButton { + kind: ButtonKind.subtle; + icon: IconKind.settings; + icon-only: true; + clicked => { root.configure(); } + } + } + + // 启动中:进度环 + 阶段文案 + 停止 + HorizontalLayout { + visible: root.data.phase == RuntimePhase.downloading + || root.data.phase == RuntimePhase.verifying + || root.data.phase == RuntimePhase.launching; + spacing: Metrics.gap-2; + + ProgressRing { + progress: root.data.progress; + ring-size: 22px; + } + UiText { + role: TextRole.caption; + text: root.data.phase_label; + horizontal-stretch: 1; + } + MetroButton { + kind: ButtonKind.subtle; + icon: IconKind.stop; + icon-only: true; + clicked => { root.stop(); } + } + } + + // 运行中:状态点 + 停止 + HorizontalLayout { + visible: root.data.phase == RuntimePhase.running; + spacing: Metrics.gap-2; + + Rectangle { + width: 8px; + height: 8px; + border-radius: 4px; + background: Theme.state-success; + } + UiText { + role: TextRole.caption; + text: "运行中"; + color: Theme.state-success; + horizontal-stretch: 1; + } + MetroButton { + kind: ButtonKind.subtle; + icon: IconKind.stop; + icon-only: true; + clicked => { root.stop(); } + } + } + } +} diff --git a/crates/app/ui/components/titlebar.slint b/crates/app/ui/components/titlebar.slint new file mode 100644 index 0000000..6fc46e2 --- /dev/null +++ b/crates/app/ui/components/titlebar.slint @@ -0,0 +1,126 @@ +// 自绘无边框标题栏(D16 §1):Metro 一体感 + accent 顶线。 +// 拖拽/最大化/最小化/关闭只发回调,原生窗口操作由 main.rs 接到 Slint Window 平台上 +// (Slint DSL 内无法移动原生窗口)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiText, TextRole } from "label.slint"; +import { AppIcon, IconKind } from "icons.slint"; + +component WindowButton inherits Rectangle { + in property icon: IconKind.minimize; + /// 关闭键:hover 用 Metro 红底白字 + in property danger: false; + callback clicked(); + + width: Metrics.titlebar-button-width; + height: Metrics.titlebar-height; + background: touch.pressed + ? (danger ? Theme.state-danger-hover : Theme.bg-pressed) + : touch.has-hover ? (danger ? Theme.state-danger : Theme.bg-hover) : transparent; + animate background { duration: Theme.motion-fast; } + + AppIcon { + kind: root.icon; + icon-size: 12px; + x: (root.width - self.width) / 2; + y: (root.height - self.height) / 2; + tint: root.danger && touch.has-hover ? Theme.fg-on-accent : Theme.fg-primary; + } + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +export component TitleBar inherits Rectangle { + in property title: "Unlucky Launcher"; + in property subtitle: ""; + in property maximized: false; + + callback toggle-maximize(); + callback minimize(); + callback request-close(); + + height: Metrics.titlebar-height; + background: Theme.bg-titlebar; + + // accent 顶线(Metro 强调色贯穿) + Rectangle { + x: 0; + y: 0; + width: root.width; + height: Metrics.accent-line; + background: Theme.accent; + } + + // 拖拽区:交给原生 `WindowMoveArea`(Slint 1.18 内置,拖过阈值才移动窗口, + // 并保持子元素可交互)。只覆盖左侧,右侧三个窗口按钮留出来。 + // 平台不支持「系统级窗口移动」时该元素退化为无操作(Qt/部分 Wayland 组合), + // winit on Windows/macOS/X11 可用——Win7 落在这条路上。 + WindowMoveArea { + x: 0; + y: Metrics.accent-line; + width: root.width - 3 * Metrics.titlebar-button-width; + height: root.height - Metrics.accent-line; + } + + // 应用标记 + 标题 + HorizontalLayout { + x: Metrics.gap-3; + y: Metrics.accent-line; + width: root.width - 3 * Metrics.titlebar-button-width - 2 * Metrics.gap-3; + height: root.height - Metrics.accent-line; + spacing: Metrics.gap-2; + + Rectangle { + width: 14px; + height: 14px; + border-radius: Metrics.radius-sm; + background: Theme.accent; + UiText { + text: "U"; + font-size: 10px; + font-weight: 700; + color: Theme.fg-on-accent; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + UiText { + role: TextRole.caption; + text: root.title; + font-weight: 600; + horizontal-stretch: 1; + } + + UiText { + role: TextRole.caption; + text: root.subtitle; + color: Theme.fg-secondary; + } + } + + HorizontalLayout { + x: root.width - 3 * Metrics.titlebar-button-width; + y: Metrics.accent-line; + width: 3 * Metrics.titlebar-button-width; + height: root.height - Metrics.accent-line; + spacing: 0px; + + WindowButton { + icon: IconKind.minimize; + clicked => { root.minimize(); } + } + WindowButton { + icon: root.maximized ? IconKind.restore : IconKind.maximize; + clicked => { root.toggle-maximize(); } + } + WindowButton { + icon: IconKind.close; + danger: true; + clicked => { root.request-close(); } + } + } +} diff --git a/crates/app/ui/main.slint b/crates/app/ui/main.slint new file mode 100644 index 0000000..7e2e84a --- /dev/null +++ b/crates/app/ui/main.slint @@ -0,0 +1,303 @@ +// Unlucky-Launcher · 主入口窗口(P0 阶段 B 接线) +// +// 布局真相源:DevDocs/PlanDoc/09-ui-layout.md(D16:左侧固定导航 + 右侧 AI 常驻栏 + 磁贴实例页)。 +// 本文件只做三件事:①无边框窗口与字体/缩放口径 ②实例化 `AppShell` ③把 AppShell 的全部 +// 属性/回调原样上抛给 src/main.rs(Slint 只能从根组件访问属性与回调,所以这层转发是必需的)。 +// +// 与 M11 骨架的关系:M11 的 `Metro` palette global(accent/surface/sidebar/tile/text/muted/pad/gap) +// 已由 `theme/tokens.slint` 的 `Theme` 继承并扩展——同一「palette global 令牌化」思路,但多了 +// 亮/暗双套色板(LightPalette/DarkPalette)、主题三态解析、排版 ramp 与字体栈;M11 的占位 +// NavItem/Tile 组件由 `components/nav.slint` 与 `components/tile.slint` 取代,占位内核不再保留。 + +import { AppShell } from "shell.slint"; +import { Theme } from "theme/tokens.slint"; +import { Metrics } from "theme/metrics.slint"; +import { + RouteKind, TileSize, InstanceView, SettingSection, DownloadCategory, ThemeMode, BgFillKind, + UiTile, UiVersion, UiLogSession, UiLogLine, UiDiagnosis, UiChatMessage, UiDialog, UiToast, +} from "models.slint"; + +export component MainWindow inherits Window { + title: root.app-title; + // 默认尺寸取 D16 线框口径;最小尺寸保证 5 列磁贴 + 导航 + AI 栏不挤压 + width: 1180px; + height: 760px; + // Metro 一体感:自绘标题栏(components/titlebar.slint) + no-frame: true; + // 无边框窗口必须显式给边缘缩放热区,否则用户无法拖拽改窗口大小 + resize-border-width: 4px; + default-font-family: Theme.font-family; + default-font-size: Theme.font-size-body; + // 窗口自身底色必须**不透明**:面板半透明度(panel-alpha)只该透出背景图,不该透到桌面 + background: Theme.canvas-solid; + + // ── 从 main.rs 灌入的状态 ─────────────────────────────────── + in-out property app-title: "Unlucky Launcher"; + in property route: RouteKind.instances; + + in property nav-collapsed: false; + in property account-name: "未登录"; + in property account-initial: "?"; + in property account-hue: #5C2D91; + + in-out property ai-collapsed: false; + in property <[UiChatMessage]> ai-messages; + in property ai-streaming: false; + in-out property yolo: false; + in property ai-endpoint-label: "llama.cpp · 本地"; + in-out property ai-draft: ""; + + in property <[UiTile]> tiles; + in property <[UiTile]> running-tiles; + in property <[UiVersion]> versions; + in property <[UiLogSession]> log-sessions; + in property <[UiLogLine]> log-lines; + in property diagnosis; + in property has-diagnosis: false; + in property selected-session: ""; + + in-out property tile-size: TileSize.medium; + in-out property instance-view: InstanceView.tiles; + in-out property instance-query: ""; + in-out property settings-section: SettingSection.general; + in-out property theme-mode: ThemeMode.auto; + // 系统主题(M41):0 = 未知(回落 Slint 后端),1 = 浅色,2 = 深色 + in property system-theme: 0; + /// 后端是否认得系统主题(`false` 时 Rust 侧才需要轮询探测兜底) + out property system-theme-known: Theme.system-theme-known; + + // ── 外观(高级)P1 四件(09 §101-104,出厂全部关闭)────────────── + // 图片由 Rust 灌入(`in`);其余是 in-out:设置页控件会写回,回调里再原样上抛给 Rust。 + in property background-image; + in property background-blur; + in-out property bg-image-enabled: false; + in-out property bg-image: ""; + in-out property bg-fill: BgFillKind.cover; + in-out property bg-dim: 0.35; + in-out property panel-alpha: 1.0; + in-out property glass: false; + in-out property accent-color: transparent; + /// 设置页「强调色(十六进制)」输入框的内容(Rust 写入规范化后的值) + in-out property accent-hex: ""; + /// 强调色输入是否非法(设置页亮 warn 行) + in-out property accent-invalid: false; + in-out property download-category: DownloadCategory.release; + in-out property download-installed-only: false; + in-out property download-query: ""; + + in property dialog; + in property dialog-open: false; + in property dialog-queued: 0; + in property <[UiToast]> toasts; + + // 主题/外观:handler 里整体赋值给 `Theme` 全局(Slint 不允许在组件体内给全局属性做绑定, + // 但允许在回调/init 里赋值)。auto 分支由 Theme 自己读系统的 Palette.color-scheme。 + init => { + Theme.theme-mode = root.theme-mode; + Theme.system-theme = root.system-theme; + Theme.bg-image = root.background-image; + Theme.bg-blur = root.background-blur; + Theme.bg-dim = root.bg-dim; + Theme.panel-alpha = root.panel-alpha; + Theme.glass = root.glass; + Theme.accent-override = root.accent-color; + } + changed theme-mode => { Theme.theme-mode = root.theme-mode; } + changed system-theme => { Theme.system-theme = root.system-theme; } + changed background-image => { Theme.bg-image = root.background-image; } + changed background-blur => { Theme.bg-blur = root.background-blur; } + changed bg-dim => { Theme.bg-dim = root.bg-dim; } + changed panel-alpha => { Theme.panel-alpha = root.panel-alpha; } + changed glass => { Theme.glass = root.glass; } + changed accent-color => { Theme.accent-override = root.accent-color; } + + // ── 用户操作(main.rs 转成 AppCommand 或纯 UI 偏好)────────────── + callback toggle-maximize(); + callback minimize(); + callback request-close(); + + callback navigate(RouteKind); + callback toggle-nav(); + callback toggle-ai(); + callback expand-ai(); + callback dock-ai(); + callback toggle-yolo(); + callback ai-send(string); + callback ai-stop(); + + callback instance-launch(string); + callback instance-configure(string); + callback instance-stop(string); + callback instance-open(string); + callback new-instance(); + callback instance-view-changed(InstanceView); + callback tile-size-changed(TileSize); + callback instance-query-changed(string); + + callback download-category-changed(DownloadCategory); + callback download-install(string); + callback installed-only-changed(bool); + callback download-query-changed(string); + callback ancient-help(); + + callback log-select(string); + callback log-ask-ai(string); + callback log-apply-fix(); + + callback settings-section-changed(SettingSection); + callback theme-changed(ThemeMode); + callback bg-image-enabled-changed(bool); + callback bg-image-changed(string); + callback bg-fill-changed(BgFillKind); + callback bg-dim-changed(float); + callback panel-alpha-changed(float); + callback glass-changed(bool); + callback accent-color-changed(string); + callback accent-swatch-picked(int); + callback yolo-changed(bool); + callback mcp-changed(bool); + callback ai-endpoint-changed(int); + + callback dialog-primary(); + callback dialog-secondary(); + callback dialog-dismissed(); + callback toast-dismissed(string); + + AppShell { + app-title: root.app-title; + // 直接复用 Window 的内建 `maximized`(后端随窗口状态更新,无需自建属性) + maximized: root.maximized; + route: root.route; + nav-collapsed: root.nav-collapsed; + account-name: root.account-name; + account-initial: root.account-initial; + account-hue: root.account-hue; + ai-collapsed: root.ai-collapsed; + ai-messages: root.ai-messages; + ai-streaming: root.ai-streaming; + yolo: root.yolo; + ai-endpoint-label: root.ai-endpoint-label; + ai-draft: root.ai-draft; + tiles: root.tiles; + running-tiles: root.running-tiles; + versions: root.versions; + log-sessions: root.log-sessions; + log-lines: root.log-lines; + diagnosis: root.diagnosis; + has-diagnosis: root.has-diagnosis; + selected-session: root.selected-session; + tile-size: root.tile-size; + instance-view: root.instance-view; + instance-query: root.instance-query; + settings-section: root.settings-section; + theme-mode: root.theme-mode; + bg-image-enabled: root.bg-image-enabled; + bg-image: root.bg-image; + bg-fill: root.bg-fill; + bg-dim: root.bg-dim; + panel-alpha: root.panel-alpha; + glass: root.glass; + accent-hex: root.accent-hex; + accent-invalid: root.accent-invalid; + download-category: root.download-category; + download-installed-only: root.download-installed-only; + download-query: root.download-query; + dialog: root.dialog; + dialog-open: root.dialog-open; + dialog-queued: root.dialog-queued; + toasts: root.toasts; + + toggle-maximize => { root.toggle-maximize(); } + minimize => { root.minimize(); } + request-close => { root.request-close(); } + + navigate(r) => { root.navigate(r); } + toggle-nav => { root.toggle-nav(); } + toggle-ai => { root.toggle-ai(); } + expand-ai => { root.expand-ai(); } + dock-ai => { root.dock-ai(); } + toggle-yolo => { root.toggle-yolo(); } + ai-send(text) => { root.ai-send(text); } + ai-stop => { root.ai-stop(); } + + instance-launch(id) => { root.instance-launch(id); } + instance-configure(id) => { root.instance-configure(id); } + instance-stop(id) => { root.instance-stop(id); } + instance-open(id) => { root.instance-open(id); } + new-instance => { root.new-instance(); } + instance-view-changed(view) => { + root.instance-view = view; + root.instance-view-changed(view); + } + tile-size-changed(size) => { + root.tile-size = size; + root.tile-size-changed(size); + } + instance-query-changed(query) => { root.instance-query-changed(query); } + + download-category-changed(category) => { + root.download-category = category; + root.download-category-changed(category); + } + download-install(id) => { root.download-install(id); } + download-query-changed(query) => { + root.download-query = query; + root.download-query-changed(query); + } + ancient-help => { root.ancient-help(); } + installed-only-changed(only) => { + root.download-installed-only = only; + root.installed-only-changed(only); + } + + log-select(id) => { root.log-select(id); } + log-ask-ai(id) => { root.log-ask-ai(id); } + log-apply-fix => { root.log-apply-fix(); } + + settings-section-changed(section) => { + root.settings-section = section; + root.settings-section-changed(section); + } + theme-changed(mode) => { + root.theme-mode = mode; + root.theme-changed(mode); + } + bg-image-enabled-changed(v) => { + root.bg-image-enabled = v; + root.bg-image-enabled-changed(v); + } + bg-image-changed(path) => { + root.bg-image = path; + root.bg-image-changed(path); + } + bg-fill-changed(fill) => { + root.bg-fill = fill; + root.bg-fill-changed(fill); + } + bg-dim-changed(v) => { + root.bg-dim = v; + root.bg-dim-changed(v); + } + panel-alpha-changed(v) => { + root.panel-alpha = v; + root.panel-alpha-changed(v); + } + glass-changed(v) => { + root.glass = v; + root.glass-changed(v); + } + accent-color-changed(text) => { + root.accent-hex = text; + root.accent-color-changed(text); + } + accent-swatch-picked(index) => { root.accent-swatch-picked(index); } + yolo-changed(enabled) => { root.yolo-changed(enabled); } + mcp-changed(enabled) => { root.mcp-changed(enabled); } + ai-endpoint-changed(index) => { root.ai-endpoint-changed(index); } + + dialog-primary => { root.dialog-primary(); } + dialog-secondary => { root.dialog-secondary(); } + dialog-dismissed => { root.dialog-dismissed(); } + toast-dismissed(id) => { root.toast-dismissed(id); } + } +} diff --git a/crates/app/ui/models.slint b/crates/app/ui/models.slint new file mode 100644 index 0000000..f1704a3 --- /dev/null +++ b/crates/app/ui/models.slint @@ -0,0 +1,218 @@ +// UI 视图模型:Rust 的 dto.rs 做单向转换后喂给 Slint(app 不 import core 内部类型,01c §5)。 +// 命名一律 kebab-case,Rust 侧 slint-build 会生成 snake_case 字段。 +// 约定:这里只放「界面要显示什么」,不放领域语义(版本继承链、下载源健康度等留在 core)。 + +// ── 共享枚举 ───────────────────────────────────────────────────── +export enum RouteKind { + instances, + download, + logs, + settings, + detail, + agent, +} + +export enum TileSize { + small, + medium, + large, +} + +export enum InstanceView { + tiles, + list, +} + +/// D17 主题三态:auto 跟随系统(Slint Palette.color-scheme 热更),light/dark 钉死。 +/// 键值 `appearance.theme_mode ∈ {light, dark, auto}`,出厂 auto。 +export enum ThemeMode { + auto, + light, + dark, +} + +/// 背景图填充模式(09 §101:cover / contain / tile)。与 `core::setting::appearance::BgFill` 同序。 +export enum BgFillKind { + cover, + contain, + tile, +} + +export enum LoaderKind { + vanilla, + fabric, + quilt, + forge, + neoforge, + optifine, + liteloader, +} + +export enum RuntimePhase { + idle, + downloading, + verifying, + launching, + running, + failed, +} + +export enum ChatRole { + user, + assistant, + tool, + system, +} + +export enum DialogKind { + confirm, + input, + progress, + error, +} + +export enum ToastKind { + info, + success, + warn, + error, +} + +export enum VersionKind { + release, + snapshot, + ancientClassic, + ancientAlpha, + ancientBeta, + ancientUnlisted, + modpack, + mod, +} + +// 下载页分类侧栏的选中项(远古版把四个子类提升为平级枚值,便于单选高亮) +export enum DownloadCategory { + release, + snapshot, + ancientClassic, + ancientAlpha, + ancientBeta, + ancientUnlisted, + modpack, + mod, +} + +export enum LogOutcome { + ok, + crashed, + silentFail, +} + +export enum LogLevel { + debug, + info, + warn, + error, +} + +export enum SettingSection { + general, + /// D17「外观(高级)」:主题三态 / 背景图 / 半透明度 / 玻璃拟态 / 强调色 / 动画强度 + appearance, + data, + download, + java, + account, + ai, + proxy, + about, +} + +// ── 磁贴 ──────────────────────────────────────────────────────── +// row/col 由 Rust state.rs 的 tile_grid() 按当前磁贴尺寸算好(小 6 / 中 4 / 大 3 列), +// 这样 Slint 侧 GridLayout 只消费整数位置,不必在 DSL 里做除法。 +export struct UiTile { + id: string, + name: string, + mc_version: string, + loader: LoaderKind, + loader_version: string, + /// 加载器徽标文字(「原版」/「Fabric 0.16.5」):由 Rust 侧用 `LoaderInfo::badge()` 拼好, + /// DSL 不做枚举→字符串映射(少一处双份真相)。 + loader_label: string, + last_played: string, + phase: RuntimePhase, + /// 启动中阶段文案(「下载资源」/「校验文件」/「启动中」),idle 时为空。 + phase_label: string, + /// 0.0~1.0;<= 0 表示进度未知(进度环转为不确定态)。 + progress: float, + /// 实例图标;空 image 时退回「名字首字 + hue 底色」占位。 + icon: image, + hue: color, + /// 名字首字,无图标时的占位字母(Rust 侧取首个字素)。 + initial: string, + row: int, + col: int, +} + +// ── 下载页 ────────────────────────────────────────────────────── +export struct UiVersion { + id: string, + kind: VersionKind, + released: string, + installed: bool, + /// 远古版(classic/alpha/beta/unlisted)——列表行带徽标 + 页顶兼容性提示。 + ancient: bool, +} + +// ── 日志页 ────────────────────────────────────────────────────── +export struct UiLogSession { + id: string, + when: string, + instance: string, + outcome: LogOutcome, + duration: string, +} + +export struct UiLogLine { + text: string, + level: LogLevel, +} + +/// D8 规则引擎结论(core::diagnose 的 DTO,app 只负责展示)。 +export struct UiDiagnosis { + rule: string, + title: string, + confidence: int, + hint: string, + has_fix: bool, +} + +// ── AI 栏 ─────────────────────────────────────────────────────── +export struct UiChatMessage { + role: ChatRole, + text: string, + /// role == tool 时的工具名。 + tool_name: string, + tool_ok: bool, + /// 流式未完成时 true(末尾显示光标块)。 + streaming: bool, +} + +// ── 对话框 / Toast ────────────────────────────────────────────── +export struct UiDialog { + id: string, + kind: DialogKind, + title: string, + body: string, + primary: string, + secondary: string, + progress: float, + input_label: string, + input_value: string, +} + +export struct UiToast { + id: string, + kind: ToastKind, + text: string, +} diff --git a/crates/app/ui/pages/download.slint b/crates/app/ui/pages/download.slint new file mode 100644 index 0000000..d3425b2 --- /dev/null +++ b/crates/app/ui/pages/download.slint @@ -0,0 +1,270 @@ +// G2 下载页(09 §2 P0·G2 / Q24-4 定案):页内独立左侧分类栏(常驻,非 tab)+ 右侧版本列表。 +// 远古版入口显式可达(ULL 对 HMCL「隐藏」行为的纠偏),下挂 classic/alpha/beta/unlisted 子类树, +// 进入远古版分类时顶部提示兼容性 + 每行带「远古版」徽标(防误装)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiVersion, VersionKind, DownloadCategory } from "../models.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; +import { Badge } from "../components/badge.slint"; +import { MetroButton, ButtonKind } from "../widgets/button.slint"; +import { SearchField, CheckBox } from "../widgets/inputs.slint"; +import { ListView } from "std-widgets.slint"; + +component CategoryRow inherits Rectangle { + in property label: ""; + in property active: false; + in property sub: false; + in property disabled: false; + callback clicked(); + + height: 34px; + background: root.active ? Theme.accent-soft : (row-touch.has-hover ? Theme.bg-hover : transparent); + + if root.active : Rectangle { + x: 0; + y: 0; + width: 3px; + height: root.height; + background: Theme.accent; + } + + UiText { + x: root.sub ? Metrics.gap-5 : Metrics.gap-3; + width: root.width - (root.sub ? Metrics.gap-5 : Metrics.gap-3) - Metrics.gap-2; + height: root.height; + role: TextRole.body; + text: root.label; + color: root.disabled ? Theme.fg-disabled + : root.active ? Theme.accent + : Theme.fg-primary; + } + + row-touch := TouchArea { + mouse-cursor: root.disabled ? default : pointer; + clicked => { + if (!root.disabled) { + root.clicked(); + } + } + } +} + +component VersionRow inherits Rectangle { + in property data; + callback install(); + + height: Metrics.row-height + 8px; + background: row-touch.has-hover ? Theme.bg-hover : transparent; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-2; + + UiText { + role: TextRole.bodyStrong; + text: root.data.id; + width: 160px; + } + + Badge { + text: root.data.kind == VersionKind.release ? "正式版" + : root.data.kind == VersionKind.snapshot ? "预览版" + : "远古版"; + tint: root.data.ancient ? Theme.state-warn : Theme.fg-secondary; + } + + UiText { + role: TextRole.caption; + text: root.data.released; + horizontal-stretch: 1; + } + + if root.data.installed : HorizontalLayout { + spacing: Metrics.gap-1; + AppIcon { kind: IconKind.check; icon-size: 12px; tint: Theme.state-success; } + UiText { + role: TextRole.caption; + text: "已装"; + color: Theme.state-success; + } + } + + MetroButton { + kind: ButtonKind.standard; + text: root.data.installed ? "重装" : "安装"; + button-height: 26px; + clicked => { root.install(); } + } + } + + row-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.install(); } + } +} + +export component DownloadPage inherits Rectangle { + in property <[UiVersion]> versions; + in-out property category: DownloadCategory.release; + in-out property installed-only: false; + in-out property query: ""; + /// 远古版子类树是否展开(默认展开,显式可达) + in-out property ancient-expanded: true; + + callback category-changed(DownloadCategory); + callback install(string); + callback installed-only-changed(bool); + callback query-changed(string); + callback toggle-ancient(); + /// 远古版兼容性说明(P1 出说明页;当前由 Rust 侧弹 Toast 占位) + callback ancient-help(); + + background: Theme.bg-canvas; + + HorizontalLayout { + // ── 页内分类侧栏(常驻)─────────────────────────────────── + Rectangle { + width: 200px; + background: Theme.bg-surface; + border-width: 1px; + border-color: Theme.stroke; + + VerticalLayout { + padding-top: Metrics.gap-2; + + CategoryRow { + label: "正式版 release"; + active: root.category == DownloadCategory.release; + clicked => { root.category-changed(DownloadCategory.release); } + } + CategoryRow { + label: "预览版 snapshot"; + active: root.category == DownloadCategory.snapshot; + clicked => { root.category-changed(DownloadCategory.snapshot); } + } + + // 远古版:可展开的父节点 + 子类树 + CategoryRow { + label: root.ancient-expanded ? "远古版 ▾" : "远古版 ▸"; + active: root.category == DownloadCategory.ancientClassic + || root.category == DownloadCategory.ancientAlpha + || root.category == DownloadCategory.ancientBeta + || root.category == DownloadCategory.ancientUnlisted; + clicked => { root.toggle-ancient(); } + } + if root.ancient-expanded : CategoryRow { + label: "classic(rd-*)"; + sub: true; + active: root.category == DownloadCategory.ancientClassic; + clicked => { root.category-changed(DownloadCategory.ancientClassic); } + } + if root.ancient-expanded : CategoryRow { + label: "old_alpha(a1.*)"; + sub: true; + active: root.category == DownloadCategory.ancientAlpha; + clicked => { root.category-changed(DownloadCategory.ancientAlpha); } + } + if root.ancient-expanded : CategoryRow { + label: "old_beta(b1.*)"; + sub: true; + active: root.category == DownloadCategory.ancientBeta; + clicked => { root.category-changed(DownloadCategory.ancientBeta); } + } + if root.ancient-expanded : CategoryRow { + label: "unlisted 汇总"; + sub: true; + active: root.category == DownloadCategory.ancientUnlisted; + clicked => { root.category-changed(DownloadCategory.ancientUnlisted); } + } + + CategoryRow { + label: "整合包(P1)"; + disabled: true; + } + CategoryRow { + label: "Mod(P1)"; + disabled: true; + } + + Rectangle { vertical-stretch: 1; background: transparent; } + } + } + + // ── 右侧版本列表 ────────────────────────────────────────── + VerticalLayout { + vertical-stretch: 1; + + Rectangle { + height: Metrics.page-header-height; + background: Theme.bg-canvas; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-3; + + UiText { + role: TextRole.subtitle; + text: "版本获取"; + width: 96px; + } + SearchField { + width: 220px; + placeholder: "筛选版本号"; + text <=> root.query; + edited(t) => { root.query-changed(t); } + } + CheckBox { + text: "仅已装"; + checked: root.installed-only; + toggled(v) => { root.installed-only-changed(v); } + } + Rectangle { horizontal-stretch: 1; background: transparent; } + } + } + + // 远古版兼容性提示条(首次进入显示,显式可达的代价告知) + Rectangle { + height: 44px; + visible: root.category == DownloadCategory.ancientClassic + || root.category == DownloadCategory.ancientAlpha + || root.category == DownloadCategory.ancientBeta + || root.category == DownloadCategory.ancientUnlisted; + background: Theme.state-warn-soft; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-2; + + AppIcon { kind: IconKind.warn; icon-size: 16px; tint: Theme.state-warn; } + UiText { + role: TextRole.caption; + text: "远古版(classic/alpha/beta)需要旧版 Java 与旧启动参数,装完请先看兼容性说明。"; + wrap: word-wrap; + horizontal-stretch: 1; + } + MetroButton { + kind: ButtonKind.subtle; + text: "说明"; + button-height: 26px; + clicked => { root.ancient-help(); } + } + } + } + + Rectangle { height: 1px; background: Theme.stroke; } + + ListView { + vertical-stretch: 1; + + for v in root.versions : VersionRow { + data: v; + install => { root.install(v.id); } + } + } + } + } +} diff --git a/crates/app/ui/pages/instances.slint b/crates/app/ui/pages/instances.slint new file mode 100644 index 0000000..3495bd5 --- /dev/null +++ b/crates/app/ui/pages/instances.slint @@ -0,0 +1,260 @@ +// G1 实例页(09 §2 P0·G1):顶栏(搜索/新建/视图切换/磁贴尺寸)+ 磁贴网格 + 运行中迷你状态条。 +// 网格用 GridLayout:列数由 Rust 按磁贴尺寸算好(小 6 / 中 4 / 大 3),row/col 随 UiTile 一起给。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiTile, InstanceView, TileSize, RuntimePhase } from "../models.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; +import { MetroButton, ButtonKind } from "../widgets/button.slint"; +import { SearchField, SegmentedControl } from "../widgets/inputs.slint"; +import { InstanceTile } from "../components/tile.slint"; +import { ListView } from "std-widgets.slint"; + +// 列表视图的一行(与磁贴共用同一份 UiTile 数据) +component InstanceRow inherits Rectangle { + in property data; + callback open(); + callback launch(); + + height: Metrics.row-height + 8px; + background: row-touch.has-hover ? Theme.bg-hover : transparent; + + // 行点击区先声明,按钮后声明 → 按钮命中优先 + row-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.open(); } + } + + HorizontalLayout { + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-3; + spacing: Metrics.gap-3; + + Rectangle { + width: 28px; + height: 28px; + border-radius: Metrics.radius-sm; + background: root.data.hue; + + UiText { + text: root.data.initial; + font-size: 13px; + font-weight: 600; + color: Theme.fg-on-accent; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + UiText { + role: TextRole.body; + text: root.data.name; + horizontal-stretch: 1; + } + UiText { + role: TextRole.caption; + text: root.data.mc_version; + width: 88px; + } + UiText { + role: TextRole.caption; + text: root.data.loader_label; + width: 88px; + } + UiText { + role: TextRole.caption; + text: root.data.last_played; + width: 120px; + } + + MetroButton { + kind: ButtonKind.subtle; + icon: IconKind.play; + icon-only: true; + enabled: root.data.phase == RuntimePhase.idle + || root.data.phase == RuntimePhase.failed; + clicked => { root.launch(); } + } + } +} + +export component InstancesPage inherits Rectangle { + in property <[UiTile]> tiles; + in property <[UiTile]> running; + in property view: InstanceView.tiles; + in-out property tile-size: TileSize.medium; + in-out property query: ""; + + callback launch(string); + callback configure(string); + callback stop(string); + callback open(string); + callback new-instance(); + callback view-changed(InstanceView); + callback tile-size-changed(TileSize); + callback query-changed(string); + + background: Theme.bg-canvas; + + VerticalLayout { + // ── 顶栏 ──────────────────────────────────────────────── + Rectangle { + height: Metrics.page-header-height; + background: Theme.bg-surface; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-2; + + UiText { + role: TextRole.subtitle; + text: "实例"; + width: 72px; + } + + SearchField { + width: 240px; + placeholder: "搜索实例"; + text <=> root.query; + edited(t) => { root.query-changed(t); } + } + + Rectangle { horizontal-stretch: 1; background: transparent; } + + SegmentedControl { + width: 120px; + options: ["磁贴", "列表"]; + selected: root.view == InstanceView.tiles ? 0 : 1; + changed(i) => { + root.view-changed(i == 0 ? InstanceView.tiles : InstanceView.list); + } + } + + SegmentedControl { + width: 132px; + visible: root.view == InstanceView.tiles; + options: ["小", "中", "大"]; + selected: root.tile-size == TileSize.small ? 0 + : root.tile-size == TileSize.large ? 2 : 1; + changed(i) => { + root.tile-size-changed( + i == 0 ? TileSize.small : i == 2 ? TileSize.large : TileSize.medium); + } + } + + MetroButton { + kind: ButtonKind.accent; + text: "新建实例"; + show-icon: true; + icon: IconKind.plus; + clicked => { root.new-instance(); } + } + } + } + + Rectangle { height: 1px; background: Theme.stroke; } + + // ── 运行中迷你状态条 ────────────────────────────────────── + Rectangle { + height: Metrics.running-strip-height; + visible: root.running.length > 0; + background: Theme.state-info-soft; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-2; + + Rectangle { + width: 8px; + height: 8px; + border-radius: 4px; + background: Theme.state-success; + } + UiText { + role: TextRole.caption; + text: root.running.length + " 个实例运行中"; + color: Theme.state-success; + horizontal-stretch: 1; + } + for r in root.running : UiText { + role: TextRole.caption; + text: r.name; + } + } + } + + // ── 磁贴网格 ───────────────────────────────────────────── + body := Flickable { + vertical-stretch: 1; + visible: root.view == InstanceView.tiles && root.tiles.length > 0; + content-width: self.width; + content-height: grid-content.height; + + grid-content := Rectangle { + width: body.width; + height: grid.preferred-height; + background: transparent; + + grid := GridLayout { + padding: Metrics.gap-4; + spacing: Metrics.gap-3; + + for t in root.tiles : InstanceTile { + row: t.row; + col: t.col; + data: t; + compact: root.tile-size == TileSize.small; + open => { root.open(t.id); } + launch => { root.launch(t.id); } + configure => { root.configure(t.id); } + stop => { root.stop(t.id); } + } + } + } + } + + // ── 列表视图 ───────────────────────────────────────────── + ListView { + vertical-stretch: 1; + visible: root.view == InstanceView.list && root.tiles.length > 0; + + for t in root.tiles : InstanceRow { + data: t; + open => { root.open(t.id); } + launch => { root.launch(t.id); } + } + } + + // ── 空态 ──────────────────────────────────────────────── + VerticalLayout { + vertical-stretch: 1; + visible: root.tiles.length == 0; + alignment: center; + spacing: Metrics.gap-3; + + UiText { + role: TextRole.title; + text: "还没有实例"; + horizontal-alignment: center; + color: Theme.fg-secondary; + } + UiText { + role: TextRole.caption; + text: "新建一个实例,或者先去下载页装个版本"; + horizontal-alignment: center; + } + HorizontalLayout { + alignment: center; + MetroButton { + kind: ButtonKind.accent; + text: "新建实例"; + show-icon: true; + icon: IconKind.plus; + clicked => { root.new-instance(); } + } + } + } + } +} diff --git a/crates/app/ui/pages/logs.slint b/crates/app/ui/pages/logs.slint new file mode 100644 index 0000000..26f2c42 --- /dev/null +++ b/crates/app/ui/pages/logs.slint @@ -0,0 +1,259 @@ +// G3 日志页(09 §2 P0·G3):左侧会话列表(✓正常 / ✗崩溃 / ⚠静默失败)+ 右侧日志流 + 崩溃诊断卡片。 +// 诊断卡片 = D8 规则引擎结论(命中规则 + 置信度 + 修复动作 + 跳 AI 栏带上下文)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiLogSession, UiLogLine, UiDiagnosis, LogOutcome, LogLevel } from "../models.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; +import { Badge } from "../components/badge.slint"; +import { MetroButton, ButtonKind } from "../widgets/button.slint"; +import { SearchField } from "../widgets/inputs.slint"; +import { ListView } from "std-widgets.slint"; + +component SessionRow inherits Rectangle { + in property data; + in property active: false; + callback clicked(); + + height: 52px; + background: root.active ? Theme.accent-soft : (row-touch.has-hover ? Theme.bg-hover : transparent); + + if root.active : Rectangle { + x: 0; + y: 0; + width: 3px; + height: root.height; + background: Theme.accent; + } + + HorizontalLayout { + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-2; + spacing: Metrics.gap-2; + + AppIcon { + icon-size: 14px; + kind: root.data.outcome == LogOutcome.ok ? IconKind.check + : root.data.outcome == LogOutcome.crashed ? IconKind.error + : IconKind.warn; + tint: root.data.outcome == LogOutcome.ok ? Theme.state-success + : root.data.outcome == LogOutcome.crashed ? Theme.state-danger + : Theme.state-warn; + } + + VerticalLayout { + spacing: 0px; + vertical-stretch: 1; + + UiText { + role: TextRole.body; + text: root.data.instance; + horizontal-stretch: 1; + } + UiText { + role: TextRole.caption; + text: root.data.when; + horizontal-stretch: 1; + } + } + + UiText { + role: TextRole.caption; + text: root.data.duration; + width: 56px; + horizontal-alignment: right; + } + } + + row-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +component LogRow inherits Rectangle { + in property line; + + height: 18px; + background: root.line.level == LogLevel.error + ? Theme.state-danger-soft + : transparent; + + UiText { + x: Metrics.gap-2; + y: 0; + width: root.width - 2 * Metrics.gap-2; + height: root.height; + role: TextRole.monoSmall; + text: root.line.text; + color: root.line.level == LogLevel.error ? Theme.state-danger + : root.line.level == LogLevel.warn ? Theme.state-warn + : root.line.level == LogLevel.debug ? Theme.fg-disabled + : Theme.fg-primary; + } +} + +export component LogsPage inherits Rectangle { + in property <[UiLogSession]> sessions; + in property <[UiLogLine]> lines; + in property diagnosis; + in property has-diagnosis: false; + in property live: false; + in-out property selected: ""; + in-out property query: ""; + + callback select(string); + callback ask-ai(string); + callback apply-fix(); + callback query-changed(string); + + background: Theme.bg-canvas; + + HorizontalLayout { + // ── 左侧会话列表 ───────────────────────────────────────── + Rectangle { + width: 248px; + background: Theme.bg-surface; + + VerticalLayout { + Rectangle { + height: 44px; + background: transparent; + + HorizontalLayout { + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-2; + spacing: Metrics.gap-2; + + UiText { + role: TextRole.bodyStrong; + text: "会话"; + horizontal-stretch: 1; + } + if root.live : Rectangle { + width: 6px; + height: 6px; + border-radius: 3px; + background: Theme.state-success; + } + } + } + + ListView { + vertical-stretch: 1; + + for s in root.sessions : SessionRow { + data: s; + active: root.selected == s.id; + clicked => { root.select(s.id); } + } + } + } + } + + Rectangle { width: 1px; background: Theme.stroke; } + + // ── 右侧日志流 + 诊断卡片 ──────────────────────────────── + VerticalLayout { + vertical-stretch: 1; + + Rectangle { + height: 44px; + background: Theme.bg-surface; + + HorizontalLayout { + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-3; + spacing: Metrics.gap-2; + + UiText { + role: TextRole.bodyStrong; + text: root.selected == "" ? "未选择会话" : root.selected; + horizontal-stretch: 1; + } + SearchField { + width: 200px; + placeholder: "筛选日志行"; + text <=> root.query; + edited(t) => { root.query-changed(t); } + } + MetroButton { + kind: ButtonKind.subtle; + text: "打开目录"; + button-height: 26px; + } + } + } + + Rectangle { height: 1px; background: Theme.stroke; } + + // 崩溃诊断卡片(D8) + Rectangle { + height: root.has-diagnosis ? 76px : 0px; + visible: root.has-diagnosis; + background: Theme.state-danger-soft; + + HorizontalLayout { + padding: Metrics.gap-3; + spacing: Metrics.gap-3; + + AppIcon { kind: IconKind.error; icon-size: 20px; tint: Theme.state-danger; } + + VerticalLayout { + spacing: 2px; + vertical-stretch: 1; + + HorizontalLayout { + spacing: Metrics.gap-2; + UiText { + role: TextRole.bodyStrong; + text: root.diagnosis.title; + horizontal-stretch: 1; + } + Badge { + text: "置信度 " + root.diagnosis.confidence + "%"; + tint: Theme.state-danger; + } + } + UiText { + role: TextRole.caption; + text: root.diagnosis.hint; + horizontal-stretch: 1; + } + } + + VerticalLayout { + spacing: Metrics.gap-1; + alignment: center; + + if root.diagnosis.has_fix : MetroButton { + kind: ButtonKind.standard; + text: "修复建议"; + button-height: 26px; + clicked => { root.apply-fix(); } + } + MetroButton { + kind: ButtonKind.subtle; + text: "让 AI 详解"; + button-height: 26px; + clicked => { root.ask-ai(root.selected); } + } + } + } + } + + // 日志流(行虚拟化) + Rectangle { + vertical-stretch: 1; + background: Theme.bg-canvas; + + ListView { + for l in root.lines : LogRow { + line: l; + width: root.width; + } + } + } + } + } +} diff --git a/crates/app/ui/pages/settings-appearance.slint b/crates/app/ui/pages/settings-appearance.slint new file mode 100644 index 0000000..a7ef123 --- /dev/null +++ b/crates/app/ui/pages/settings-appearance.slint @@ -0,0 +1,146 @@ +// 设置页「外观」分组(09 §2 G6 + §113 D17 定案):主题三态 + 「外观(高级)」P1 四件。 +// +// 从 pages/settings.slint 拆出来只为守住 400 行门槛;控件语义与设置键一一对应: +// theme-mode → appearance.theme_mode / bg-* → appearance.bg_image* / panel-alpha → +// appearance.panel_alpha / glass → appearance.glass / accent-color → appearance.accent_color。 +// 09 §111 硬约束:高级特性全部默认关闭,开启背景图时这里会给出对比度提示(warn 行)。 +import { Theme } from "../theme/tokens.slint"; +import { ThemeMode, BgFillKind } from "../models.slint"; +import { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.slint"; + +export component AppearanceSection inherits VerticalLayout { + in-out property theme-mode: ThemeMode.auto; + in-out property bg-image-enabled: false; + in-out property bg-image: ""; + in-out property bg-fill: BgFillKind.cover; + in-out property bg-dim: 0.35; + in-out property panel-alpha: 1.0; + in-out property glass: false; + in-out property accent-color: ""; + in property accent-invalid: false; + + callback theme-changed(ThemeMode); + callback bg-image-enabled-changed(bool); + callback bg-image-changed(string); + callback bg-fill-changed(BgFillKind); + callback bg-dim-changed(float); + callback panel-alpha-changed(float); + callback glass-changed(bool); + callback accent-color-changed(string); + callback accent-swatch-picked(int); + +// ── 外观(D17:主题三态 + 09 §101-104 高级四件)────── +SettingGroup { title: "主题"; } +SettingItem { + label: "主题模式"; + description: "跟随系统(Auto,随 OS 热切)/ 浅色 / 深色"; + kind: ControlKind.segmented; + options: ["跟随系统", "浅色", "深色"]; + selected: root.theme-mode == ThemeMode.auto ? 0 + : root.theme-mode == ThemeMode.light ? 1 : 2; + changed(i) => { + root.theme-mode = i == 0 ? ThemeMode.auto + : i == 1 ? ThemeMode.light : ThemeMode.dark; + root.theme-changed(root.theme-mode); + } +} + +SettingGroup { + title: "高级特性(出厂全部关闭,开启后请确认前景可读)"; +} +// ① 自定义窗口背景图(09 §101) +SettingItem { + label: "自定义窗口背景图"; + description: "PNG 图片铺满窗口;开启后所有面板半透明处都会透出它"; + kind: ControlKind.toggle; + checked: root.bg-image-enabled; + toggled(v) => { + root.bg-image-enabled = v; + root.bg-image-enabled-changed(v); + } +} +SettingItem { + label: "背景图路径"; + description: root.bg-image == "" ? "尚未选择(绝对路径,例:D:/wall/p.png)" : root.bg-image; + kind: ControlKind.text; + button-text: "D:/wall/p.png"; + value <=> root.bg-image; + edited(t) => { root.bg-image-changed(t); } +} +SettingItem { + label: "填充模式"; + description: "铺满(裁切)/ 完整(留底色)/ 平铺"; + kind: ControlKind.segmented; + options: ["铺满", "完整", "平铺"]; + selected: root.bg-fill == BgFillKind.cover ? 0 + : root.bg-fill == BgFillKind.contain ? 1 : 2; + changed(i) => { + root.bg-fill = i == 0 ? BgFillKind.cover + : i == 1 ? BgFillKind.contain : BgFillKind.tile; + root.bg-fill-changed(root.bg-fill); + } +} +SettingItem { + label: "暗化层强度"; + // 09 §111:开启背景图时自动给对比度提示——暗化太低 + 面板太透会伤可读性 + description: (root.bg-image-enabled && (root.bg-dim < 0.25 || root.panel-alpha < 0.8)) + ? "⚠ 对比度偏低:建议暗化 ≥ 25% 或面板不透明度 ≥ 80%" + : "越大背景越暗、前景越清晰(0% = 不压暗)"; + warn: root.bg-image-enabled && (root.bg-dim < 0.25 || root.panel-alpha < 0.8); + kind: ControlKind.slider; + min-value: 0.0; + max-value: 0.9; + slider-value: root.bg-dim; + slid(v) => { + root.bg-dim = v; + root.bg-dim-changed(v); + } +} +// ② 全局面板半透明度(09 §102) +SettingItem { + label: "全局面板半透明度"; + description: "卡片 / 导航 / AI 栏统一透明度(100% = 出厂 Metro 纯色)"; + kind: ControlKind.slider; + min-value: 0.4; + max-value: 1.0; + slider-value: root.panel-alpha; + slid(v) => { + root.panel-alpha = v; + root.panel-alpha-changed(v); + } +} +// ③ 玻璃拟态卡片(09 §103) +SettingItem { + label: "玻璃拟态卡片"; + description: "卡片底铺背景图的预生成模糊层(需先开启背景图)"; + kind: ControlKind.toggle; + checked: root.glass; + toggled(v) => { + root.glass = v; + root.glass-changed(v); + } +} +// ④ 自定义强调色(09 §104) +SettingItem { + label: "预设强调色"; + description: root.accent-color == "" ? "当前:出厂 Metro 蓝" : "当前:" + root.accent-color; + kind: ControlKind.swatches; + swatch-colors: [ + #0078D4, #0F6CBD, #107C10, #9D5D00, + #C42B1C, #8764B8, #C239B3, #00B7C3, + ]; + swatch-current: root.accent-color == "" ? #0078D4 : Theme.accent; + swatch-picked(i) => { root.accent-swatch-picked(i); } +} +SettingItem { + label: "强调色(十六进制)"; + description: root.accent-invalid + ? "⚠ 需要 #RRGGBB 形式,留空恢复出厂蓝" + : "留空 = 出厂 #0078D4;影响顶线 / 选中态 / 按钮 / 徽标"; + warn: root.accent-invalid; + kind: ControlKind.text; + button-text: "#0078D4"; + value <=> root.accent-color; + edited(t) => { root.accent-color-changed(t); } +} +} diff --git a/crates/app/ui/pages/settings.slint b/crates/app/ui/pages/settings.slint new file mode 100644 index 0000000..ed176a7 --- /dev/null +++ b/crates/app/ui/pages/settings.slint @@ -0,0 +1,363 @@ +// G6 设置页(09 §2 P0·G6):左子导航 + 右表单 + 搜索框(设置项多,防迷路)。 +// 「通用 / 外观(D17 三态 + 09 §101-104 高级四件,实现在 settings-appearance.slint)/ 数据 / AI / +// 关于」是真实控件,Java / 账户 / 代理分组仍是入口按钮(等各自 mission 接实现)。 +// 关于页内嵌 AboutSlint —— Slint RF 2.0 许可义务(01c §8:关于页放官方部件)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { SettingSection, TileSize, ThemeMode, BgFillKind } from "../models.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.slint"; +import { AppearanceSection } from "settings-appearance.slint"; +import { SearchField } from "../widgets/inputs.slint"; +import { AboutSlint, ScrollView } from "std-widgets.slint"; + +component SectionRow inherits Rectangle { + in property label: ""; + in property section: SettingSection.general; + in property active: false; + callback clicked(); + + height: 36px; + background: root.active ? Theme.accent-soft : (row-touch.has-hover ? Theme.bg-hover : transparent); + + if root.active : Rectangle { + x: 0; + y: 0; + width: 3px; + height: root.height; + background: Theme.accent; + } + + UiText { + x: Metrics.gap-3; + y: 0; + width: root.width - Metrics.gap-3 - Metrics.gap-2; + height: root.height; + role: TextRole.body; + text: root.label; + color: root.active ? Theme.accent : Theme.fg-primary; + } + + row-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +export component SettingsPage inherits Rectangle { + in-out property section: SettingSection.general; + in-out property query: ""; + + // 外观(D17:主题三态 + 09 §101-104 高级特性四件) + in-out property theme-mode: ThemeMode.auto; + in-out property bg-image-enabled: false; + in-out property bg-image: ""; + in-out property bg-fill: BgFillKind.cover; + in-out property bg-dim: 0.35; + in-out property panel-alpha: 1.0; + in-out property glass: false; + in-out property accent-color: ""; + in property accent-invalid: false; + // 通用 + in-out property tile-size: TileSize.medium; + in-out property close-to-tray: true; + + // 数据 + in-out property data-mode: 0; + in property data-path: ""; + // 下载 + in-out property download-concurrency: 1; + in property region-label: "未知"; + // 账户 + in property account-label: "未登录"; + // AI + in-out property ai-endpoint: 0; + in-out property yolo: false; + in-out property mcp-enabled: false; + // 代理 + in-out property proxy: ""; + // 关于 + in property version: ""; + + callback section-changed(SettingSection); + callback query-changed(string); + callback theme-changed(ThemeMode); + callback bg-image-enabled-changed(bool); + callback bg-image-changed(string); + callback bg-fill-changed(BgFillKind); + callback bg-dim-changed(float); + callback panel-alpha-changed(float); + callback glass-changed(bool); + callback accent-color-changed(string); + callback accent-swatch-picked(int); + callback tile-size-changed(TileSize); + callback close-to-tray-changed(bool); + callback data-mode-changed(int); + callback download-concurrency-changed(int); + callback ai-endpoint-changed(int); + callback yolo-changed(bool); + callback mcp-changed(bool); + callback proxy-changed(string); + callback migrate-data(); + callback login(); + callback manage-java(); + callback open-audit-log(); + callback detect-region(); + + background: Theme.bg-canvas; + + HorizontalLayout { + // ── 左子导航 ────────────────────────────────────────────── + Rectangle { + width: 200px; + background: Theme.bg-surface; + + VerticalLayout { + padding-top: Metrics.gap-2; + + SectionRow { label: "通用"; section: SettingSection.general; active: root.section == SettingSection.general; clicked => { root.section-changed(SettingSection.general); } } + SectionRow { label: "外观"; section: SettingSection.appearance; active: root.section == SettingSection.appearance; clicked => { root.section-changed(SettingSection.appearance); } } + SectionRow { label: "数据"; section: SettingSection.data; active: root.section == SettingSection.data; clicked => { root.section-changed(SettingSection.data); } } + SectionRow { label: "下载"; section: SettingSection.download; active: root.section == SettingSection.download; clicked => { root.section-changed(SettingSection.download); } } + SectionRow { label: "Java"; section: SettingSection.java; active: root.section == SettingSection.java; clicked => { root.section-changed(SettingSection.java); } } + SectionRow { label: "账户"; section: SettingSection.account; active: root.section == SettingSection.account; clicked => { root.section-changed(SettingSection.account); } } + SectionRow { label: "AI"; section: SettingSection.ai; active: root.section == SettingSection.ai; clicked => { root.section-changed(SettingSection.ai); } } + SectionRow { label: "代理"; section: SettingSection.proxy; active: root.section == SettingSection.proxy; clicked => { root.section-changed(SettingSection.proxy); } } + SectionRow { label: "关于"; section: SettingSection.about; active: root.section == SettingSection.about; clicked => { root.section-changed(SettingSection.about); } } + + Rectangle { vertical-stretch: 1; background: transparent; } + } + } + + Rectangle { width: 1px; background: Theme.stroke; } + + // ── 右表单 ──────────────────────────────────────────────── + VerticalLayout { + vertical-stretch: 1; + + Rectangle { + height: Metrics.page-header-height; + background: Theme.bg-surface; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-3; + + UiText { + role: TextRole.subtitle; + text: "设置"; + width: 72px; + } + SearchField { + width: 240px; + placeholder: "搜索设置项"; + text <=> root.query; + edited(t) => { root.query-changed(t); } + } + Rectangle { horizontal-stretch: 1; background: transparent; } + } + } + + Rectangle { height: 1px; background: Theme.stroke; } + + ScrollView { + vertical-stretch: 1; + + VerticalLayout { + padding-bottom: Metrics.gap-5; + + // 外观(D17:主题三态 + 09 §101-104 高级四件;实现在 settings-appearance.slint) + if root.section == SettingSection.appearance : AppearanceSection { + theme-mode: root.theme-mode; + bg-image-enabled: root.bg-image-enabled; + bg-image: root.bg-image; + bg-fill: root.bg-fill; + bg-dim: root.bg-dim; + panel-alpha: root.panel-alpha; + glass: root.glass; + accent-color: root.accent-color; + accent-invalid: root.accent-invalid; + theme-changed(m) => { + root.theme-mode = m; + root.theme-changed(m); + } + bg-image-enabled-changed(v) => { + root.bg-image-enabled = v; + root.bg-image-enabled-changed(v); + } + bg-image-changed(t) => { + root.bg-image = t; + root.bg-image-changed(t); + } + bg-fill-changed(f) => { + root.bg-fill = f; + root.bg-fill-changed(f); + } + bg-dim-changed(v) => { + root.bg-dim = v; + root.bg-dim-changed(v); + } + panel-alpha-changed(v) => { + root.panel-alpha = v; + root.panel-alpha-changed(v); + } + glass-changed(v) => { + root.glass = v; + root.glass-changed(v); + } + accent-color-changed(t) => { + root.accent-color = t; + root.accent-color-changed(t); + } + accent-swatch-picked(i) => { root.accent-swatch-picked(i); } + } + + // ── 通用 ──────────────────────────────────────── + if root.section == SettingSection.general : SettingGroup { title: "外观"; } + if root.section == SettingSection.general : SettingItem { + label: "磁贴尺寸"; + description: "小 6 列 / 中 4 列(默认)/ 大 3 列"; + kind: ControlKind.segmented; + options: ["小", "中", "大"]; + selected: root.tile-size == TileSize.small ? 0 + : root.tile-size == TileSize.large ? 2 : 1; + changed(i) => { + root.tile-size-changed( + i == 0 ? TileSize.small : i == 2 ? TileSize.large : TileSize.medium); + } + } + if root.section == SettingSection.general : SettingItem { + label: "关闭窗口时最小化到托盘"; + description: "避免误关正在运行的游戏"; + kind: ControlKind.toggle; + checked: root.close-to-tray; + toggled(v) => { root.close-to-tray-changed(v); } + } + + // ── 数据 ──────────────────────────────────────── + if root.section == SettingSection.data : SettingGroup { title: "数据目录(D6 三模式)"; } + if root.section == SettingSection.data : SettingItem { + label: "存储模式"; + description: "便携(运行目录 .ULL/.minecraft)/ 标准(系统应用数据目录)/ 自定义"; + kind: ControlKind.segmented; + options: ["便携", "标准", "自定义"]; + selected: root.data-mode; + changed(i) => { + root.data-mode = i; + root.data-mode-changed(i); + } + } + if root.section == SettingSection.data : SettingItem { + label: "当前路径"; + description: root.data-path; + kind: ControlKind.button; + button-text: "迁移…"; + clicked => { root.migrate-data(); } + } + + // ── 下载 ──────────────────────────────────────── + if root.section == SettingSection.download : SettingGroup { title: "下载(D7)"; } + if root.section == SettingSection.download : SettingItem { + label: "并发数"; + kind: ControlKind.segmented; + options: ["4", "8", "16"]; + selected: root.download-concurrency; + changed(i) => { + root.download-concurrency = i; + root.download-concurrency-changed(i); + } + } + if root.section == SettingSection.download : SettingItem { + label: "地区判定"; + description: "当前:" + root.region-label + "(决定是否走国内镜像)"; + kind: ControlKind.button; + button-text: "重新检测"; + clicked => { root.detect-region(); } + } + + // ── Java ──────────────────────────────────────── + if root.section == SettingSection.java : SettingGroup { title: "Java(D13)"; } + if root.section == SettingSection.java : SettingItem { + label: "Java 管理"; + description: "基准 JDK17 自动下载 · 多版本可选 · 不匹配时黄条提示"; + kind: ControlKind.button; + button-text: "管理多版本"; + clicked => { root.manage-java(); } + } + + // ── 账户 ──────────────────────────────────────── + if root.section == SettingSection.account : SettingGroup { title: "账户(D9)"; } + if root.section == SettingSection.account : SettingItem { + label: "当前账户"; + description: root.account-label; + kind: ControlKind.button; + button-text: "登录 / 切换"; + clicked => { root.login(); } + } + + // ── AI ────────────────────────────────────────── + if root.section == SettingSection.ai : SettingGroup { title: "AI 助手(D4)"; } + if root.section == SettingSection.ai : SettingItem { + label: "模型端点"; + description: "OpenAI 兼容 / 本地 llama.cpp / Anthropic / 自定义"; + kind: ControlKind.segmented; + options: ["OpenAI", "llama.cpp", "Anthropic", "自定义"]; + selected: root.ai-endpoint; + changed(i) => { + root.ai-endpoint = i; + root.ai-endpoint-changed(i); + } + } + if root.section == SettingSection.ai : SettingItem { + label: "YOLO 模式"; + description: "跳过全部确认弹窗(审计照记),账户/凭据操作永不 YOLO"; + kind: ControlKind.toggle; + checked: root.yolo; + toggled(v) => { root.yolo-changed(v); } + } + if root.section == SettingSection.ai : SettingItem { + label: "MCP server"; + description: "允许外部客户端(如 Claude Desktop)调用工具集"; + kind: ControlKind.toggle; + checked: root.mcp-enabled; + toggled(v) => { root.mcp-changed(v); } + } + if root.section == SettingSection.ai : SettingItem { + label: "审计日志"; + description: "AI 的所有写操作都有记录"; + kind: ControlKind.button; + button-text: "查看"; + clicked => { root.open-audit-log(); } + } + + // ── 代理 ──────────────────────────────────────── + if root.section == SettingSection.proxy : SettingGroup { title: "代理"; } + if root.section == SettingSection.proxy : SettingItem { + label: "HTTP 代理"; + description: "留空则使用系统代理"; + kind: ControlKind.text; + button-text: "http://127.0.0.1:7890"; + value <=> root.proxy; + edited(t) => { root.proxy-changed(t); } + } + + // ── 关于 ──────────────────────────────────────── + if root.section == SettingSection.about : SettingGroup { title: "关于"; } + if root.section == SettingSection.about : SettingItem { + label: "版本"; + description: root.version; + } + if root.section == SettingSection.about : SettingItem { + label: "许可"; + description: "Apache-2.0(Slint 走 RF 2.0 轨道)"; + } + if root.section == SettingSection.about : Rectangle { + height: 120px; + AboutSlint { } + } + } + } + } + } +} diff --git a/crates/app/ui/shell.slint b/crates/app/ui/shell.slint new file mode 100644 index 0000000..7d5e9ce --- /dev/null +++ b/crates/app/ui/shell.slint @@ -0,0 +1,373 @@ +// 主窗口框架(D16 §1):无边框标题栏 + 左侧可折叠导航 + 内容区路由 + 右侧 AI 常驻栏 + 对话框/Toast 覆盖层。 +// 这里只做「组装 + 路由转发」,状态全在 Rust 侧(state.rs),Slint 不持有业务真相。 +// main.slint(阶段 B 接线)只负责实例化 AppShell 并把 Window 的窗口事件接上。 +import { Theme } from "theme/tokens.slint"; +import { Metrics } from "theme/metrics.slint"; +import { + RouteKind, TileSize, InstanceView, SettingSection, DownloadCategory, ThemeMode, BgFillKind, + UiTile, UiVersion, UiLogSession, UiLogLine, UiDiagnosis, UiChatMessage, UiDialog, UiToast, +} from "models.slint"; +import { TitleBar } from "components/titlebar.slint"; +import { Nav } from "components/nav.slint"; +import { AiPanel } from "components/ai-panel.slint"; +import { UiText, TextRole } from "components/label.slint"; +import { MetroButton, ButtonKind } from "widgets/button.slint"; +import { DialogHost } from "widgets/dialog.slint"; +import { ToastHost } from "widgets/toast.slint"; +import { InstancesPage } from "pages/instances.slint"; +import { DownloadPage } from "pages/download.slint"; +import { LogsPage } from "pages/logs.slint"; +import { SettingsPage } from "pages/settings.slint"; + +export component AppShell inherits Rectangle { + // ── 窗口 ──────────────────────────────────────────────────── + in property app-title: "Unlucky Launcher"; + in property maximized: false; + + // ── 路由 ──────────────────────────────────────────────────── + in property route: RouteKind.instances; + + // ── 导航 / 账户 ────────────────────────────────────────────── + in property nav-collapsed: false; + in property account-name: "未登录"; + in property account-initial: "?"; + in property account-hue: #5C2D91; + + // ── AI 栏(双形态共享同一份会话)────────────────────────────── + in property ai-collapsed: false; + in property <[UiChatMessage]> ai-messages; + in property ai-streaming: false; + in property yolo: false; + in property ai-endpoint-label: "llama.cpp · 本地"; + in-out property ai-draft: ""; + + // ── 页面数据 ──────────────────────────────────────────────── + in property <[UiTile]> tiles; + in property <[UiTile]> running-tiles; + in property <[UiVersion]> versions; + in property <[UiLogSession]> log-sessions; + in property <[UiLogLine]> log-lines; + in property diagnosis; + in property has-diagnosis: false; + in property selected-session: ""; + + // ── 偏好(持久化键见 SubAgentsTalk/app-DTO通道约定.md)────────── + in-out property tile-size: TileSize.medium; + in-out property instance-view: InstanceView.tiles; + in-out property instance-query: ""; + in-out property settings-section: SettingSection.general; + /// D17 主题三态(auto 跟随系统;light/dark 钉死)。Rust 侧同时写 Theme 全局。 + in-out property theme-mode: ThemeMode.auto; + /// 外观(高级)四件的 in-out 通道:MainWindow ↔ SettingsPage(图片本身走 `Theme.bg-image`) + in-out property bg-image-enabled: false; + in-out property bg-image: ""; + in-out property bg-fill: BgFillKind.cover; + in-out property bg-dim: 0.35; + in-out property panel-alpha: 1.0; + in-out property glass: false; + /// 设置页编辑的十六进制串(`accent-color` 是解析后的颜色,只给 Theme 用) + in-out property accent-hex: ""; + /// 强调色输入是否非法(设置页亮 warn 行) + in-out property accent-invalid: false; + in-out property download-category: DownloadCategory.release; + in-out property download-installed-only: false; + /// 下载页版本号筛选词(输入由页面写回,state.download_query 参与过滤) + in-out property download-query: ""; + + // ── 对话框队列 / Toast ─────────────────────────────────────── + in property dialog; + in property dialog-open: false; + in property dialog-queued: 0; + in property <[UiToast]> toasts; + + // ── 窗口控制(拖拽走 WindowMoveArea,不需要 Rust 参与)──────── + callback toggle-maximize(); + callback minimize(); + callback request-close(); + + // ── 导航 / AI ─────────────────────────────────────────────── + callback navigate(RouteKind); + callback toggle-nav(); + callback toggle-ai(); + callback expand-ai(); + callback dock-ai(); + callback toggle-yolo(); + callback ai-send(string); + callback ai-stop(); + + // ── 实例页 ────────────────────────────────────────────────── + callback instance-launch(string); + callback instance-configure(string); + callback instance-stop(string); + callback instance-open(string); + callback new-instance(); + callback instance-view-changed(InstanceView); + callback tile-size-changed(TileSize); + callback instance-query-changed(string); + + // ── 下载页 ────────────────────────────────────────────────── + callback download-category-changed(DownloadCategory); + callback download-install(string); + callback installed-only-changed(bool); + callback download-query-changed(string); + callback ancient-help(); + + // ── 日志页 ────────────────────────────────────────────────── + callback log-select(string); + callback log-ask-ai(string); + callback log-apply-fix(); + + // ── 设置页 ────────────────────────────────────────────────── + callback settings-section-changed(SettingSection); + callback theme-changed(ThemeMode); + callback bg-image-enabled-changed(bool); + callback bg-image-changed(string); + callback bg-fill-changed(BgFillKind); + callback bg-dim-changed(float); + callback panel-alpha-changed(float); + callback glass-changed(bool); + callback accent-color-changed(string); + callback accent-swatch-picked(int); + callback yolo-changed(bool); + callback mcp-changed(bool); + callback ai-endpoint-changed(int); + + // ── 对话框 / Toast ────────────────────────────────────────── + callback dialog-primary(); + callback dialog-secondary(); + callback dialog-dismissed(); + callback toast-dismissed(string); + + // 窗口底色交给 main.slint 的 Window(不透明 `canvas-solid`),这里只铺背景图层 + background: transparent; + + // 玻璃拟态的对齐基准:整窗逻辑尺寸(components/glass.slint 反向偏移用) + init => { + Theme.window-width = root.width; + Theme.window-height = root.height; + } + changed width => { Theme.window-width = root.width; } + changed height => { Theme.window-height = root.height; } + + // ── ① 自定义窗口背景图(09 §101)──────────────────────────── + // 只在「开关打开且图片已加载」时才画:空 image 的 width 为 0,出厂态零开销。 + if Theme.bg-image.width > 0 : Rectangle { + x: 0; + y: 0; + width: root.width; + height: root.height; + clip: true; + background: Theme.canvas-solid; + + Image { + source: Theme.bg-image; + x: 0; + y: 0; + width: parent.width; + height: parent.height; + // tile 模式由 Rust 预平铺成整窗画布,故这里等同 cover + image-fit: root.bg-fill == BgFillKind.contain ? contain : cover; + } + } + + // ── ② 暗化层(09 §111:保证前景可读;0 = 不压暗)───────────── + if Theme.bg-image.width > 0 && Theme.bg-dim > 0 : Rectangle { + x: 0; + y: 0; + width: root.width; + height: root.height; + background: rgba(0, 0, 0, Theme.bg-dim); + } + + VerticalLayout { + TitleBar { + title: root.app-title; + subtitle: "P0 骨架 · Metro"; + maximized: root.maximized; + toggle-maximize => { root.toggle-maximize(); } + minimize => { root.minimize(); } + request-close => { root.request-close(); } + } + + HorizontalLayout { + // 左侧主导航 + Nav { + active: root.route; + collapsed: root.nav-collapsed; + account-name: root.account-name; + account-initial: root.account-initial; + account-hue: root.account-hue; + navigate(r) => { root.navigate(r); } + toggle-collapsed => { root.toggle-nav(); } + account-clicked => { root.navigate(RouteKind.settings); root.settings-section-changed(SettingSection.account); } + } + + // 内容区(每页独立滚动,路由切换栈语义在 Rust Router) + Rectangle { + horizontal-stretch: 1; + background: Theme.bg-canvas; + + if root.route == RouteKind.instances : InstancesPage { + tiles: root.tiles; + running: root.running-tiles; + view: root.instance-view; + tile-size: root.tile-size; + query: root.instance-query; + launch(id) => { root.instance-launch(id); } + configure(id) => { root.instance-configure(id); } + stop(id) => { root.instance-stop(id); } + open(id) => { root.instance-open(id); } + new-instance => { root.new-instance(); } + view-changed(v) => { + root.instance-view = v; + root.instance-view-changed(v); + } + tile-size-changed(s) => { + root.tile-size = s; + root.tile-size-changed(s); + } + query-changed(q) => { root.instance-query-changed(q); } + } + + if root.route == RouteKind.download : DownloadPage { + versions: root.versions; + category: root.download-category; + installed-only: root.download-installed-only; + query: root.download-query; + install(id) => { root.download-install(id); } + query-changed(q) => { + root.download-query = q; + root.download-query-changed(q); + } + ancient-help => { root.ancient-help(); } + category-changed(c) => { + root.download-category = c; + root.download-category-changed(c); + } + installed-only-changed(v) => { + root.download-installed-only = v; + root.installed-only-changed(v); + } + } + + if root.route == RouteKind.logs : LogsPage { + sessions: root.log-sessions; + lines: root.log-lines; + diagnosis: root.diagnosis; + has-diagnosis: root.has-diagnosis; + selected: root.selected-session; + select(id) => { root.log-select(id); } + ask-ai(id) => { root.log-ask-ai(id); } + apply-fix => { root.log-apply-fix(); } + } + + if root.route == RouteKind.settings : SettingsPage { + section: root.settings-section; + theme-mode: root.theme-mode; + bg-image-enabled: root.bg-image-enabled; + bg-image: root.bg-image; + bg-fill: root.bg-fill; + bg-dim: root.bg-dim; + panel-alpha: root.panel-alpha; + glass: root.glass; + accent-color: root.accent-hex; + accent-invalid: root.accent-invalid; + section-changed(s) => { + root.settings-section = s; + root.settings-section-changed(s); + } + theme-changed(mode) => { + root.theme-mode = mode; + root.theme-changed(mode); + } + bg-image-enabled-changed(v) => { root.bg-image-enabled-changed(v); } + bg-image-changed(path) => { root.bg-image-changed(path); } + bg-fill-changed(fill) => { root.bg-fill-changed(fill); } + bg-dim-changed(v) => { root.bg-dim-changed(v); } + panel-alpha-changed(v) => { root.panel-alpha-changed(v); } + glass-changed(v) => { root.glass-changed(v); } + accent-color-changed(text) => { + root.accent-hex = text; + root.accent-color-changed(text); + } + accent-swatch-picked(index) => { root.accent-swatch-picked(index); } + yolo-changed(v) => { root.yolo-changed(v); } + mcp-changed(v) => { root.mcp-changed(v); } + ai-endpoint-changed(i) => { root.ai-endpoint-changed(i); } + } + + // AI 拆成独立全宽页:与右侧栏共用同一份 ai-messages(切换无损) + if root.route == RouteKind.agent : AiPanel { + docked: false; + messages: root.ai-messages; + collapsed: false; + yolo: root.yolo; + streaming: root.ai-streaming; + endpoint-label: root.ai-endpoint-label; + draft <=> root.ai-draft; + dock => { root.dock-ai(); } + toggle-yolo => { root.toggle-yolo(); } + send(t) => { root.ai-send(t); } + stop => { root.ai-stop(); } + } + + // 实例详情 G4:P0 只留占位(点击磁贴进入) + if root.route == RouteKind.detail : VerticalLayout { + alignment: center; + spacing: Metrics.gap-3; + + UiText { + role: TextRole.title; + text: "实例详情(G4)"; + horizontal-alignment: center; + } + UiText { + role: TextRole.caption; + text: "P0 骨架未实现:概要与设置 tab 在 G4 补齐"; + horizontal-alignment: center; + } + HorizontalLayout { + alignment: center; + MetroButton { + kind: ButtonKind.standard; + text: "返回实例页"; + clicked => { root.navigate(RouteKind.instances); } + } + } + } + } + + // 右侧 AI 常驻栏(拆成独立页时让位给内容区) + AiPanel { + visible: root.route != RouteKind.agent; + docked: true; + collapsed: root.ai-collapsed; + messages: root.ai-messages; + yolo: root.yolo; + streaming: root.ai-streaming; + endpoint-label: root.ai-endpoint-label; + draft <=> root.ai-draft; + toggle-collapsed => { root.toggle-ai(); } + expand => { root.expand-ai(); } + toggle-yolo => { root.toggle-yolo(); } + send(t) => { root.ai-send(t); } + stop => { root.ai-stop(); } + } + } + } + + // 覆盖层:模态队列(一次一条)+ 右下 Toast + DialogHost { + current: root.dialog; + open: root.dialog-open; + queued: root.dialog-queued; + primary => { root.dialog-primary(); } + secondary => { root.dialog-secondary(); } + dismissed => { root.dialog-dismissed(); } + } + + ToastHost { + toasts: root.toasts; + dismissed(id) => { root.toast-dismissed(id); } + } +} diff --git a/crates/app/ui/theme/metrics.slint b/crates/app/ui/theme/metrics.slint new file mode 100644 index 0000000..c0483fa --- /dev/null +++ b/crates/app/ui/theme/metrics.slint @@ -0,0 +1,48 @@ +// 布局度量令牌(D16 定稿的尺寸口径集中在此): +// 导航栏折叠宽度、AI 栏宽度、标题栏高度、磁贴三档尺寸与列数。 +import { TileSize } from "../models.slint"; + +export global Metrics { + // 间距刻度(4 的倍数,Metro 栅格) + out property gap-1: 4px; + out property gap-2: 8px; + out property gap-3: 12px; + out property gap-4: 16px; + out property gap-5: 24px; + out property gap-6: 32px; + + out property radius-sm: 2px; + out property radius-md: 4px; + out property radius-lg: 8px; + + // 主窗口骨架 + out property titlebar-height: 40px; + out property titlebar-button-width: 46px; + out property accent-line: 2px; + out property nav-expanded-width: 88px; + out property nav-collapsed-width: 48px; + out property ai-panel-width: 320px; + out property ai-rail-width: 44px; + out property page-header-height: 56px; + out property running-strip-height: 32px; + out property row-height: 44px; + out property control-height: 32px; + out property touch-target: 32px; + + // 磁贴三档(Q24-3:默认中磁贴 4 列,设置可调小 6 / 大 3) + in-out property tile-size: TileSize.medium; + out property tile-columns: + tile-size == TileSize.small ? 6 + : tile-size == TileSize.large ? 3 + : 4; + out property tile-height: + tile-size == TileSize.small ? 104px + : tile-size == TileSize.large ? 176px + : 136px; + out property tile-icon-size: + tile-size == TileSize.small ? 32px + : tile-size == TileSize.large ? 56px + : 44px; + // 小磁贴只留图标+名字+启动键,藏起徽标与最后游玩时间(空间不足) + out property tile-compact: tile-size == TileSize.small; +} diff --git a/crates/app/ui/theme/tokens.slint b/crates/app/ui/theme/tokens.slint new file mode 100644 index 0000000..1314af1 --- /dev/null +++ b/crates/app/ui/theme/tokens.slint @@ -0,0 +1,208 @@ +// Metro 主题令牌(D14 + D17 主题三态 + 09 §101-104「外观(高级)」):palette + 排版层级 + 字体栈 + +// 动效时长 + 外观令牌(背景图 / 面板半透明度 / 玻璃拟态 / 强调色)。 +// +// 结构约定: +// - `LightPalette` / `DarkPalette` = 两套完整色板(**不是**一套加条件),换肤只改这两处。 +// - `Theme` = 解析器:按主题三态(auto/light/dark)挑一套,并持有**共享**的 accent 与字体/排版。 +// - auto 跟随系统:`Theme.system-theme` 由 Rust 的 `core::platform::theme_probe` 灌入(启动对齐 + +// 未知时兜底轮询),后端支持时另有 `Palette.color-scheme` 实时热切;两者都不认识就按浅色。 +// - 「外观(高级)」四件全部默认关闭:`panel-alpha` 1.0 / `glass` false / `bg-image` 空 / +// `accent-override` 透明——出厂观感与 M17 骨架逐像素一致(09 §111 硬约束)。 +// - 组件只准用 `Theme.*`,不得出现裸色字面量(背景图暗化层是唯一例外,它是「遮罩」而非配色)。 + +import { ThemeMode } from "../models.slint"; +import { Palette } from "std-widgets.slint"; + +// ── 亮色色板 ──────────────────────────────────────────────────── +export global LightPalette { + out property bg-canvas: #F3F3F3; + out property bg-surface: #FFFFFF; + out property bg-elevated: #FFFFFF; + out property bg-titlebar: #EDEDED; + out property bg-hover: #E9E9E9; + out property bg-pressed: #DEDEDE; + out property bg-input: #FFFFFF; + + out property fg-primary: #1A1A1A; + out property fg-secondary: #5F5F5F; + out property fg-disabled: #9A9A9A; + + out property stroke: #E1E1E1; + out property stroke-strong: #C8C8C8; + out property shadow: #00000026; + out property scrim: #00000099; + + out property accent-soft: #E5F1FB; + out property accent-hover: #106EBE; + out property accent-pressed: #005A9E; + + out property state-success: #107C10; + out property state-success-soft: #DFF6DD; + out property state-warn: #9D5D00; + out property state-warn-soft: #FFF4CE; + out property state-danger: #C42B1C; + out property state-danger-hover: #A82214; + out property state-danger-soft: #FDE7E9; + out property state-info-soft: #E7F1FA; +} + +// ── 暗色色板 ──────────────────────────────────────────────────── +export global DarkPalette { + out property bg-canvas: #1B1B1B; + out property bg-surface: #232323; + out property bg-elevated: #2B2B2B; + out property bg-titlebar: #161616; + out property bg-hover: #2F2F2F; + out property bg-pressed: #383838; + out property bg-input: #1E1E1E; + + out property fg-primary: #F2F2F2; + out property fg-secondary: #A6A6A6; + out property fg-disabled: #6B6B6B; + + out property stroke: #3A3A3A; + out property stroke-strong: #4A4A4A; + out property shadow: #000000CC; + out property scrim: #00000099; + + out property accent-soft: #0F2A3F; + out property accent-hover: #2B8FE0; + out property accent-pressed: #005A9E; + + out property state-success: #4CCB4C; + out property state-success-soft: #16321A; + out property state-warn: #FFD24C; + out property state-warn-soft: #3A2E0F; + out property state-danger: #FF6B6B; + out property state-danger-hover: #FF8A8A; + out property state-danger-soft: #3A1A1A; + out property state-info-soft: #10263A; +} + +// ── 解析器 ────────────────────────────────────────────────────── +export global Theme { + // ── 主题三态(D17 设置页「外观」分组,出厂 auto)────────────────── + // D17 主题三态(设置页「外观」分组,出厂 auto) + in-out property theme-mode: ThemeMode.auto; + // 系统主题来源(M41):0 = 未知(此时回落到 Slint 后端 `Palette.color-scheme`), + // 1 = 浅色 / 2 = 深色。由 Rust 的 `core::platform::theme_probe` 探测后灌入: + // 启动时给首帧对齐,之后按需轮询兜底(后端不支持系统主题时 auto 仍可用)。 + in-out property system-theme: 0; + // 后端是否认得系统主题(`unknown` 表示不支持/无 portal)。 + out property system-theme-known: Palette.color-scheme != ColorScheme.unknown; + // 是否深色:显式钉死(light/dark)优先;auto 时用探测值,探测未知才问后端。 + out property dark: theme-mode == ThemeMode.dark + || (theme-mode == ThemeMode.auto + && (system-theme == 2 + || (system-theme != 1 && Palette.color-scheme == ColorScheme.dark))); + + // ── 外观(高级)令牌(09 §101-104 / §111:出厂全部关闭)───────── + // 全局面板半透明度:`bg-*` 令牌统一按 `panel-alpha` 重建(见下方「面板底色」段), + // 组件侧零改动即透出背景图。出厂 1.0 = 完全不透明,Metro 纯色外观与 M17 骨架一致。 + in-out property panel-alpha: 1.0; + // 自定义窗口背景图(空 image = 纯色背景,出厂态)。 + in-out property bg-image; + // 背景图暗化层强度(0~0.95,越大前景越清晰)。Rust 侧夹到 schema 范围。 + in-out property bg-dim: 0.35; + // 玻璃拟态:预生成模糊层(Rust 侧降采样 + box blur),卡片内按 `absolute-position` + // 反向偏移对齐整窗,得到 Slint 版「毛玻璃」(无 backdrop-filter 的替代手法)。 + in-out property glass: false; + in-out property bg-blur; + // 玻璃层对齐用的窗口逻辑尺寸(AppShell 在 init/changed 里灌入)。 + in-out property window-width: 1180px; + in-out property window-height: 760px; + + // ── 强调色(accent 令牌全域生效;两套色板共享)────────────────── + // `accent-override` 的 alpha == 0 表示「未自定义」,走 D14 出厂 Metro 蓝。 + // (color 的 `.alpha` 字段是 0~255 整数,不是 0~1 浮点。) + in-out property accent-override: transparent; + out property accent-custom: accent-override.alpha > 1; + out property accent: accent-custom ? accent-override : #0078D4; + // 自定义时由覆盖色派生 hover/pressed/soft(未自定义仍走双色板常量,出场观感不变)。 + out property accent-hover: accent-custom + ? (dark ? accent.brighter(0.18) : accent.darker(0.12)) + : (dark ? DarkPalette.accent-hover : LightPalette.accent-hover); + out property accent-pressed: accent-custom + ? (dark ? accent.brighter(0.28) : accent.darker(0.24)) + : (dark ? DarkPalette.accent-pressed : LightPalette.accent-pressed); + out property accent-soft: accent-custom + ? accent.mix(surface-solid, 0.82) + : (dark ? DarkPalette.accent-soft : LightPalette.accent-soft); + out property accent-contrast: #FFFFFF; + // accent 上的前景色按相对亮度自动选黑/白(自定义亮黄、浅青时白字会糊)。 + // 注意:Slint 的 color 通道字段(`.red` 等)是 0~255 的**整数**。 + out property accent-luma: accent.red * 0.299 + accent.green * 0.587 + accent.blue * 0.114; + out property fg-on-accent: accent-luma > 160 ? #1A1A1A : #FFFFFF; + + // ── 面板底色(`panel-alpha` 统一透出背景图)──────────────────── + // `*-solid` 是不透明原件(窗口自身底色 / accent.mix 的混合基准必须不透明, + // 否则浮点叠加会让 accent-soft 随 panel-alpha 一起变淡)。 + // 半透明色用 `rgba(r, g, b, a)` 重建而不是 `.with-alpha()`:后者返回 Brush, + // 赋给 `` 属性会类型不符(Slint 无 Brush → color 隐式回退)。 + out property surface-solid: dark ? DarkPalette.bg-surface : LightPalette.bg-surface; + out property canvas-solid: dark ? DarkPalette.bg-canvas : LightPalette.bg-canvas; + out property surface-elevated-solid: dark ? DarkPalette.bg-elevated : LightPalette.bg-elevated; + out property titlebar-solid: dark ? DarkPalette.bg-titlebar : LightPalette.bg-titlebar; + out property hover-solid: dark ? DarkPalette.bg-hover : LightPalette.bg-hover; + out property pressed-solid: dark ? DarkPalette.bg-pressed : LightPalette.bg-pressed; + out property input-solid: dark ? DarkPalette.bg-input : LightPalette.bg-input; + + out property bg-canvas: rgba( + canvas-solid.red, canvas-solid.green, canvas-solid.blue, panel-alpha); + out property bg-surface: rgba( + surface-solid.red, surface-solid.green, surface-solid.blue, panel-alpha); + out property bg-elevated: rgba( + surface-elevated-solid.red, surface-elevated-solid.green, surface-elevated-solid.blue, panel-alpha); + out property bg-titlebar: rgba( + titlebar-solid.red, titlebar-solid.green, titlebar-solid.blue, panel-alpha); + out property bg-hover: rgba( + hover-solid.red, hover-solid.green, hover-solid.blue, panel-alpha); + out property bg-pressed: rgba( + pressed-solid.red, pressed-solid.green, pressed-solid.blue, panel-alpha); + out property bg-input: rgba( + input-solid.red, input-solid.green, input-solid.blue, panel-alpha); + + out property fg-primary: dark ? DarkPalette.fg-primary : LightPalette.fg-primary; + out property fg-secondary: dark ? DarkPalette.fg-secondary : LightPalette.fg-secondary; + out property fg-disabled: dark ? DarkPalette.fg-disabled : LightPalette.fg-disabled; + + out property stroke: dark ? DarkPalette.stroke : LightPalette.stroke; + out property stroke-strong: dark ? DarkPalette.stroke-strong : LightPalette.stroke-strong; + out property shadow: dark ? DarkPalette.shadow : LightPalette.shadow; + out property scrim: dark ? DarkPalette.scrim : LightPalette.scrim; + + out property state-success: dark ? DarkPalette.state-success : LightPalette.state-success; + out property state-success-soft: dark ? DarkPalette.state-success-soft : LightPalette.state-success-soft; + out property state-warn: dark ? DarkPalette.state-warn : LightPalette.state-warn; + out property state-warn-soft: dark ? DarkPalette.state-warn-soft : LightPalette.state-warn-soft; + out property state-danger: dark ? DarkPalette.state-danger : LightPalette.state-danger; + out property state-danger-hover: dark ? DarkPalette.state-danger-hover : LightPalette.state-danger-hover; + out property state-danger-soft: dark ? DarkPalette.state-danger-soft : LightPalette.state-danger-soft; + out property state-info-soft: dark ? DarkPalette.state-info-soft : LightPalette.state-info-soft; + + // ── 字体 ──────────────────────────────────────────────────── + // Segoe UI 不可再分发(微软许可)→ Windows 用系统自带,其他平台用 Selawik + // (微软官方开源、Segoe UI 度量兼容)+ Open Sans 回退栈(01c §7 第 5 条)。 + // Slint 的 font-family 只吃单个家族名,回退链由 Rust 侧启动时按平台选择 + 注册字体族实现: + // windows → "Segoe UI",macos/linux → "Selawik"(随包)+ "Open Sans" 兜底。 + in-out property font-family: "Selawik"; + in-out property font-family-mono: "Cascadia Mono"; + // 中文回退(思源黑体/微软雅黑由系统字体匹配,这里仅作显式声明) + in-out property font-family-cjk: "Microsoft YaHei UI"; + + // 排版层级(Metro typography ramp) + out property font-size-display: 24px; + out property font-size-title: 20px; + out property font-size-subtitle: 16px; + out property font-size-body: 14px; + out property font-size-caption: 12px; + out property line-height-body: 20px; + out property line-height-caption: 16px; + + // ── 动效(Metro 快速、克制)─────────────────────────────────── + out property motion-fast: 90ms; + out property motion-normal: 160ms; + out property motion-slow: 240ms; + // D17 动画强度倍率(P0 占位,P1 接设置页滑条与「减少动画」无障碍开关) + in-out property motion-scale: 1.0; +} diff --git a/crates/app/ui/widgets/button.slint b/crates/app/ui/widgets/button.slint new file mode 100644 index 0000000..7185f87 --- /dev/null +++ b/crates/app/ui/widgets/button.slint @@ -0,0 +1,83 @@ +// Metro 按钮:扁平 + accent 填充,无圆角渐变,hover 只变底色(D14)。 +// 四种语义:accent(主操作)/ standard(次要)/ subtle(工具条)/ danger(危险确认)。 +// grow = true 时占满布局剩余宽度(磁贴的启动键),否则保持内容宽度(工具按钮)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; +import { UiText, TextRole } from "../components/label.slint"; + +export enum ButtonKind { + accent, + standard, + subtle, + danger, +} + +export component MetroButton inherits Rectangle { + in property kind: ButtonKind.standard; + in property text: ""; + in property icon: IconKind.plus; + /// 是否显示图标(纯文字按钮置 false) + in property show-icon: false; + in property enabled: true; + /// 图标按钮:不画文字,尺寸压到正方形 + in property icon-only: false; + /// 在布局中占据剩余宽度 + in property grow: false; + in property button-height: Metrics.control-height; + callback clicked(); + + min-width: icon-only ? button-height : text-layout.preferred-width; + height: button-height; + horizontal-stretch: grow ? 1 : 0; + border-radius: Metrics.radius-sm; + border-width: kind == ButtonKind.standard ? 1px : 0px; + border-color: Theme.stroke-strong; + opacity: enabled ? 1.0 : 0.45; + + background: kind == ButtonKind.accent + ? (touch.has-hover ? Theme.accent-hover : Theme.accent) + : kind == ButtonKind.danger + ? (touch.has-hover ? Theme.state-danger-hover : Theme.state-danger) + : kind == ButtonKind.subtle + ? (touch.pressed ? Theme.bg-pressed : touch.has-hover ? Theme.bg-hover : transparent) + : (touch.pressed ? Theme.bg-pressed : touch.has-hover ? Theme.bg-hover : Theme.bg-surface); + animate background { duration: Theme.motion-fast; } + + text-layout := HorizontalLayout { + alignment: center; + padding-left: icon-only ? 0px : Metrics.gap-2; + padding-right: icon-only ? 0px : Metrics.gap-2; + spacing: Metrics.gap-1; + + AppIcon { + icon-size: Metrics.touch-target / 2; + kind: root.icon; + visible: root.show-icon || root.icon-only; + tint: kind == ButtonKind.accent || kind == ButtonKind.danger + ? Theme.fg-on-accent + : Theme.fg-primary; + } + UiText { + role: TextRole.bodyStrong; + text: root.text; + visible: !root.icon-only; + color: kind == ButtonKind.accent || kind == ButtonKind.danger + ? Theme.fg-on-accent + : Theme.fg-primary; + horizontal-alignment: center; + } + } + + touch := TouchArea { + mouse-cursor: root.enabled ? pointer : default; + clicked => { + if (root.enabled) { + root.clicked(); + } + } + } + + accessible-role: button; + accessible-label: root.text; +} diff --git a/crates/app/ui/widgets/dialog.slint b/crates/app/ui/widgets/dialog.slint new file mode 100644 index 0000000..a8522fa --- /dev/null +++ b/crates/app/ui/widgets/dialog.slint @@ -0,0 +1,122 @@ +// 对话框宿主:HMCL 队列语义(一次只显示一条,来源排序由 Rust DialogQueue 决定)+ Metro 化外观。 +// 类型:确认 / 输入 / 进度 / 错误(含诊断入口)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiDialog, DialogKind } from "../models.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; +import { MetroButton, ButtonKind } from "button.slint"; +import { TextField } from "inputs.slint"; + +export component DialogHost inherits Rectangle { + in property current; + in property open: false; + /// 队列中剩余条数(>0 时卡片底部提示「还有 N 条待处理」,语义上是队列而非堆叠) + in property queued: 0; + in-out property input-text: ""; + callback primary(); + callback secondary(); + callback dismissed(); + + width: 100%; + height: 100%; + background: Theme.scrim; + visible: open; + + // 吞掉遮罩上的点击,避免误触下层页面 + TouchArea { + clicked => { root.dismissed(); } + } + + card := Rectangle { + x: (root.width - card.width) / 2; + y: (root.height - card.height) / 2; + width: 440px; + height: content.preferred-height; + background: Theme.bg-elevated; + border-radius: Metrics.radius-lg; + border-width: 1px; + border-color: Theme.stroke; + drop-shadow-blur: 24px; + drop-shadow-color: Theme.shadow; + drop-shadow-offset-y: 6px; + + content := VerticalLayout { + padding: Metrics.gap-5; + spacing: Metrics.gap-3; + + HorizontalLayout { + spacing: Metrics.gap-2; + + if root.current.kind == DialogKind.error : AppIcon { + kind: IconKind.error; + icon-size: 20px; + tint: Theme.state-danger; + } + if root.current.kind == DialogKind.confirm : AppIcon { + kind: IconKind.warn; + icon-size: 20px; + tint: Theme.state-warn; + } + UiText { + role: TextRole.title; + text: root.current.title; + horizontal-stretch: 1; + } + } + + body-text := UiText { + role: TextRole.body; + text: root.current.body; + wrap: word-wrap; + overflow: elide; + color: Theme.fg-secondary; + vertical-alignment: top; + height: body-text.preferred-height; + } + + if root.current.kind == DialogKind.input : TextField { + placeholder: root.current.input_label; + text <=> root.input-text; + } + + if root.current.kind == DialogKind.progress : track := Rectangle { + height: 6px; + border-radius: 3px; + background: Theme.bg-pressed; + + Rectangle { + width: track.width * Math.min(Math.max(root.current.progress, 0), 1); + height: track.height; + border-radius: 3px; + background: Theme.accent; + } + } + + HorizontalLayout { + spacing: Metrics.gap-2; + alignment: end; + padding-top: Metrics.gap-2; + + UiText { + role: TextRole.caption; + text: root.queued > 0 ? "还有 " + root.queued + " 条待处理" : ""; + horizontal-stretch: 1; + horizontal-alignment: left; + } + + if root.current.secondary != "" : MetroButton { + kind: ButtonKind.subtle; + text: root.current.secondary; + clicked => { root.secondary(); } + } + MetroButton { + kind: root.current.kind == DialogKind.error ? ButtonKind.danger : ButtonKind.accent; + text: root.current.primary; + enabled: root.current.kind != DialogKind.progress; + clicked => { root.primary(); } + } + } + } + } +} diff --git a/crates/app/ui/widgets/inputs.slint b/crates/app/ui/widgets/inputs.slint new file mode 100644 index 0000000..b522a00 --- /dev/null +++ b/crates/app/ui/widgets/inputs.slint @@ -0,0 +1,300 @@ +// 基础输入件:单行输入(含占位文本)、搜索框、开关、分段控件、复选框。 +// 一律自绘(Metro 需要方角 + accent 高亮)。占位文本手绘:内置 TextInput 没有 +// placeholder-text(那是 std-widgets 的 LineEdit,外观不可控)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; +import { UiText, TextRole } from "../components/label.slint"; + +/// 单行输入:内置 TextInput + 占位文本层(Focus 态由外部读 `focused`)。 +export component PlainInput inherits Rectangle { + in-out property text: ""; + in property placeholder: ""; + in property text-color: Theme.fg-primary; + in property text-padding: 0px; + out property focused: input.has-focus; + callback edited(string); + callback accepted(string); + + background: transparent; + + input := TextInput { + x: root.text-padding; + y: 0; + width: root.width - 2 * root.text-padding; + height: root.height; + color: root.text-color; + font-family: Theme.font-family; + font-size: Theme.font-size-body; + vertical-alignment: center; + single-line: true; + text <=> root.text; + edited => { root.edited(self.text); } + accepted => { root.accepted(self.text); } + } + + if root.text == "" && root.placeholder != "" : UiText { + x: root.text-padding; + y: 0; + width: root.width - 2 * root.text-padding; + height: root.height; + role: TextRole.body; + text: root.placeholder; + color: Theme.fg-disabled; + } +} + +export component SearchField inherits Rectangle { + in-out property text: ""; + in property placeholder: "搜索"; + callback edited(string); + callback accepted(string); + + height: Metrics.control-height; + background: Theme.bg-input; + border-radius: Metrics.radius-sm; + border-width: 1px; + border-color: field.focused ? Theme.accent : Theme.stroke; + + HorizontalLayout { + padding-left: Metrics.gap-2; + padding-right: Metrics.gap-2; + spacing: Metrics.gap-2; + + AppIcon { kind: IconKind.search; icon-size: 14px; } + + field := PlainInput { + placeholder: root.placeholder; + text <=> root.text; + edited(t) => { root.edited(t); } + accepted(t) => { root.accepted(t); } + } + } +} + +export component TextField inherits Rectangle { + in-out property text: ""; + in property placeholder: ""; + callback edited(string); + + height: Metrics.control-height; + background: Theme.bg-input; + border-radius: Metrics.radius-sm; + border-width: 1px; + border-color: field.focused ? Theme.accent : Theme.stroke; + + field := PlainInput { + text-padding: Metrics.gap-2; + placeholder: root.placeholder; + text <=> root.text; + edited(t) => { root.edited(t); } + } +} + +export component ToggleSwitch inherits Rectangle { + in-out property checked: false; + callback toggled(bool); + + width: 40px; + height: 20px; + border-radius: 10px; + background: checked ? Theme.accent : Theme.stroke-strong; + animate background { duration: Theme.motion-normal; } + + Rectangle { + width: 14px; + height: 14px; + y: 3px; + x: root.checked ? root.width - self.width - 3px : 3px; + border-radius: 7px; + background: Theme.fg-on-accent; + animate x { duration: Theme.motion-normal; easing: ease-in-out; } + } + + TouchArea { + mouse-cursor: pointer; + clicked => { + root.checked = !root.checked; + root.toggled(root.checked); + } + } +} + +/// 分段控件(视图切换、磁贴尺寸、主题三态共用) +export component SegmentedControl inherits Rectangle { + in property <[string]> options; + in-out property selected: 0; + callback changed(int); + + height: Metrics.control-height; + background: Theme.bg-canvas; + border-radius: Metrics.radius-sm; + border-width: 1px; + border-color: Theme.stroke; + + HorizontalLayout { + padding: 2px; + spacing: 2px; + for o[i] in options : Rectangle { + border-radius: Metrics.radius-sm; + background: i == root.selected + ? Theme.accent + : (seg.has-hover ? Theme.bg-hover : transparent); + animate background { duration: Theme.motion-fast; } + + UiText { + role: TextRole.caption; + text: o; + horizontal-alignment: center; + color: i == root.selected ? Theme.fg-on-accent : Theme.fg-primary; + } + + seg := TouchArea { + mouse-cursor: pointer; + clicked => { + root.selected = i; + root.changed(i); + } + } + } + } +} + +export component CheckBox inherits Rectangle { in-out property checked: false; + in property text: ""; + callback toggled(bool); + + height: Metrics.touch-target; + background: transparent; + + HorizontalLayout { + spacing: Metrics.gap-2; + + Rectangle { + width: 16px; + height: 16px; + border-radius: Metrics.radius-sm; + border-width: 1px; + border-color: root.checked ? Theme.accent : Theme.stroke-strong; + background: root.checked ? Theme.accent : Theme.bg-input; + + if root.checked : AppIcon { + kind: IconKind.check; + icon-size: 12px; + x: 2px; + y: 2px; + tint: Theme.fg-on-accent; + } + } + + UiText { role: TextRole.body; text: root.text; } + + TouchArea { + mouse-cursor: pointer; + clicked => { + root.checked = !root.checked; + root.toggled(root.checked); + } + } + } +} + +/// 滑条(09 §102/§101 的「强度滑条」:暗化层强度、面板半透明度共用)。 +/// 自绘而非 std-widgets 的 Slider:Metro 需要方头轨道 + accent 填充,且要跟主题令牌一起换肤。 +export component MetroSlider inherits Rectangle { + in property minimum: 0.0; + in property maximum: 1.0; + in-out property value: 1.0; + callback changed(float); + + out property ratio: root.maximum > root.minimum + ? Math.max(0.0, Math.min(1.0, (root.value - root.minimum) / (root.maximum - root.minimum))) + : 0.0; + + width: 200px; + height: Metrics.touch-target; + background: transparent; + + /// 鼠标位置 → 取值(Slint 的 function 不能声明局部变量,所以拆成纯函数; + /// 函数名不用 kebab-case:`value-at(...)` 会被当成减法解析) + pure function valueForX(x: length) -> float { + return root.minimum + + Math.max(0.0, Math.min(1.0, x / root.width)) * (root.maximum - root.minimum); + } + + // 轨道 + 已填充段 + Rectangle { + x: 0; + y: (root.height - 4px) / 2; + width: root.width; + height: 4px; + border-radius: 2px; + background: Theme.stroke-strong; + } + Rectangle { + x: 0; + y: (root.height - 4px) / 2; + width: root.width * root.ratio; + height: 4px; + border-radius: 2px; + background: Theme.accent; + } + // 滑块 + Rectangle { + x: (root.width - 14px) * root.ratio; + y: (root.height - 14px) / 2; + width: 14px; + height: 14px; + border-radius: 7px; + background: Theme.accent; + border-width: 2px; + border-color: Theme.surface-solid; + } + + area := TouchArea { + mouse-cursor: pointer; + moved => { + root.value = root.valueForX(self.mouse-x); + root.changed(root.value); + } + clicked => { + root.value = root.valueForX(self.mouse-x); + root.changed(root.value); + } + } + + accessible-role: slider; +} + +/// 预设色板(09 §104 自定义强调色:预设色板 + 取色器,取色器由十六进制输入承担)。 +export component ColorSwatches inherits Rectangle { + in property <[color]> palette; + in property current; + /// 传**索引**而不是 color:Slint 没有「颜色 → #RRGGBB」的格式化, + /// 预设色板的十六进制串由 Rust 侧同一张表给出(一处真相)。 + callback picked(int); + + width: root.palette.length * 28px; + height: 28px; + background: transparent; + + HorizontalLayout { + spacing: 4px; + + for shade[i] in root.palette : Rectangle { + width: 24px; + height: 24px; + border-radius: Metrics.radius-sm; + background: shade; + border-width: shade == root.current ? 2px : 1px; + border-color: shade == root.current ? Theme.fg-primary : Theme.stroke; + + swatch-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.picked(i); } + } + + accessible-role: button; + } + } +} diff --git a/crates/app/ui/widgets/setting-item.slint b/crates/app/ui/widgets/setting-item.slint new file mode 100644 index 0000000..e2f1f57 --- /dev/null +++ b/crates/app/ui/widgets/setting-item.slint @@ -0,0 +1,155 @@ +// 设置项行:左标签+说明,右控件。控件类型用 ControlKind 收敛,避免为每个设置项写一遍布局。 +// (Slint 没有 slot 机制,所以用 kind 枚举 + 通用属性把控件种类收在一处。) +// +// M41 增加两种控件:`slider`(强度/透明度滑条)与 `swatches`(预设色板 + 取色器入口), +// 它们服务 09 §101-104 的「外观(高级)」四件——高级特性默认关闭,控件本身常显(关着也能调)。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { MetroButton, ButtonKind } from "button.slint"; +import { + ToggleSwitch, SegmentedControl, TextField, MetroSlider, ColorSwatches, +} from "inputs.slint"; + +export enum ControlKind { + none, + toggle, + segmented, + button, + text, + slider, + swatches, +} + +export component SettingItem inherits Rectangle { + in property label: ""; + in property description: ""; + in property kind: ControlKind.none; + in property <[string]> options; + in property button-text: ""; + in-out property selected: 0; + in-out property checked: false; + in-out property value: ""; + // 滑条 + in property min-value: 0.0; + in property max-value: 1.0; + in-out property slider-value: 1.0; + // 色板 + in property <[color]> swatch-colors; + in property swatch-current: transparent; + // 危险/提示色描述(如「对比度偏低」) + in property warn: false; + + callback toggled(bool); + callback changed(int); + callback clicked(); + callback edited(string); + callback slid(float); + callback swatch-picked(int); + + height: root.description == "" ? 44px : 60px; + background: transparent; + + HorizontalLayout { + padding-left: Metrics.gap-4; + padding-right: Metrics.gap-4; + spacing: Metrics.gap-4; + + VerticalLayout { + spacing: 0px; + vertical-stretch: 1; + + UiText { + role: TextRole.body; + text: root.label; + horizontal-stretch: 1; + } + UiText { + role: TextRole.caption; + text: root.description; + visible: root.description != ""; + color: root.warn ? Theme.state-warn : Theme.fg-secondary; + horizontal-stretch: 1; + } + } + + if root.kind == ControlKind.toggle : ToggleSwitch { + checked: root.checked; + toggled(v) => { + root.checked = v; + root.toggled(v); + } + } + + if root.kind == ControlKind.segmented : SegmentedControl { + width: root.options.length * 64px; + options: root.options; + selected: root.selected; + changed(i) => { + root.selected = i; + root.changed(i); + } + } + + if root.kind == ControlKind.button : MetroButton { + kind: ButtonKind.standard; + text: root.button-text; + button-height: 28px; + clicked => { root.clicked(); } + } + + if root.kind == ControlKind.text : TextField { + width: 240px; + placeholder: root.button-text; + text <=> root.value; + edited(t) => { root.edited(t); } + } + + if root.kind == ControlKind.slider : HorizontalLayout { + spacing: Metrics.gap-2; + alignment: end; + + UiText { + role: TextRole.caption; + text: (root.slider-value * 100).round() + "%"; + width: 40px; + horizontal-alignment: right; + color: Theme.fg-secondary; + } + MetroSlider { + minimum: root.min-value; + maximum: root.max-value; + value: root.slider-value; + changed(v) => { + root.slider-value = v; + root.slid(v); + } + } + } + + if root.kind == ControlKind.swatches : ColorSwatches { + palette: root.swatch-colors; + current: root.swatch-current; + picked(i) => { root.swatch-picked(i); } + } + } +} + +/// 分组标题 +export component SettingGroup inherits Rectangle { + in property title: ""; + + height: 36px; + background: transparent; + + UiText { + x: Metrics.gap-4; + y: 0; + width: root.width - 2 * Metrics.gap-4; + height: root.height; + role: TextRole.caption; + text: root.title; + color: Theme.fg-secondary; + font-weight: 600; + } +} diff --git a/crates/app/ui/widgets/toast.slint b/crates/app/ui/widgets/toast.slint new file mode 100644 index 0000000..d3a43c6 --- /dev/null +++ b/crates/app/ui/widgets/toast.slint @@ -0,0 +1,87 @@ +// Toast:右下角非阻塞提示(下载完成 / 复制成功 / 后台任务结论)。 +// 队列与自动消失时长由 Rust ToastCenter 管理(P0 默认 4s),这里只负责渲染与手动关闭。 +import { Theme } from "../theme/tokens.slint"; +import { Metrics } from "../theme/metrics.slint"; +import { UiToast, ToastKind } from "../models.slint"; +import { UiText, TextRole } from "../components/label.slint"; +import { AppIcon, IconKind } from "../components/icons.slint"; + +component ToastCard inherits Rectangle { + in property data; + callback dismissed(string); + + width: 300px; + height: body-layout.preferred-height; + background: Theme.bg-elevated; + border-radius: Metrics.radius-md; + border-width: 1px; + border-color: Theme.stroke; + drop-shadow-blur: 16px; + drop-shadow-color: Theme.shadow; + drop-shadow-offset-y: 3px; + + // 左侧语义色条:Metro 用色块表达状态,不用图标喧宾夺主 + Rectangle { + x: 0px; + y: 0px; + width: 3px; + height: root.height; + background: root.data.kind == ToastKind.success ? Theme.state-success + : root.data.kind == ToastKind.warn ? Theme.state-warn + : root.data.kind == ToastKind.error ? Theme.state-danger + : Theme.accent; + } + + body-layout := HorizontalLayout { + padding-left: Metrics.gap-3; + padding-right: Metrics.gap-2; + padding-top: Metrics.gap-2; + padding-bottom: Metrics.gap-2; + spacing: Metrics.gap-2; + + UiText { + role: TextRole.body; + text: root.data.text; + wrap: word-wrap; + horizontal-stretch: 1; + } + + AppIcon { + kind: IconKind.close; + icon-size: 14px; + tint: Theme.fg-secondary; + + TouchArea { + mouse-cursor: pointer; + clicked => { root.dismissed(root.data.id); } + } + } + } +} + +export component ToastHost inherits Rectangle { + in property <[UiToast]> toasts; + callback dismissed(string); + + width: 100%; + height: 100%; + background: transparent; + + stack-box := Rectangle { + x: root.width - stack-box.width - Metrics.gap-4; + y: root.height - stack-box.height - Metrics.gap-4; + width: 300px; + height: stack.preferred-height; + background: transparent; + + stack := VerticalLayout { + width: stack-box.width; + spacing: Metrics.gap-2; + + for t in root.toasts : ToastCard { + data: t; + dismissed(id) => { root.dismissed(id); } + } + } + } +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..dc84ef3 --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "unlucky-cli" +description = "Unlucky-Launcher 命令行(bin `ull`):headless 消费者,零 UI 框架依赖(D18)" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true + +# 产物名钉死 `ull`(D18 定案):单文件 CLI,与 GUI 的 `unlucky-launcher` 并列。 +[[bin]] +name = "ull" +path = "src/main.rs" + +[lib] +name = "unlucky_cli" +path = "src/lib.rs" + +# 依赖纪律(10-cli-design.md §2):core + agent,**不引 Slint**。 +# 参数解析手写(不引 clap):子命令集小,没必要为它多引一棵依赖树(Win10+ 单基线, +# 但 CLI 是 headless/服务器场景的产物,体积与启动开销仍是关注项)。 +[dependencies] +unlucky-core = { path = "../core", version = "0.1" } +# agent:`redact`(输出脱敏)+ `ull agent serve`(M39 的 MCP server:注册表 → rmcp 双投影, +# core 侧五个端口在 src/commands/agent/ports/ 装配)。 +unlucky-agent = { path = "../agent", version = "0.1" } +# `ull agent serve` 要长驻跑 rmcp 的 stdio / Streamable HTTP 传输(tokio 运行时)。 +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs new file mode 100644 index 0000000..5f8b5a0 --- /dev/null +++ b/crates/cli/src/cli/help.rs @@ -0,0 +1,155 @@ +//! 帮助文案(`ull --help` / `ull help <命令>`)。 +//! +//! 命令集与 `10-cli-design.md` §3 对齐;`agent serve|config`(M39 的 MCP server)已列, +//! `agent ask`(内置对话的 headless 形态)仍未列。 + +/// 总帮助。 +#[must_use] +pub const fn general() -> &'static str { + "\ +ull · Unlucky-Launcher 命令行(D18) + +用法: + ull [全局选项] <命令> [参数] + +命令: + instances list 列出实例与已安装版本 + install [--loader X] [--loader-version V] [--name N] [--dry-run] + 下载并安装版本本体(--loader fabric|quilt|neoforge|forge + 时再叠一层加载器)→ 登记实例;--dry-run 只出计划 + launch [--dry-run] [--account ] [--java ] [--username ] + [--max-memory ] 启动实例;--dry-run 只打印组装后的命令行 + accounts list 列出账户(名称 / 轨道 / UUID,**不含凭据**) + accounts add offline 添加离线账户(HMCL 兼容 UUID) + accounts add msa 添加微软账户(设备码交互式登录) + accounts remove 删除账户(连同凭据) + sessions list [--limit N] 列出会话记录(倒序) + logs [--tail N] 打印会话事件流末尾 + diag [--tail N] 对会话跑规则引擎诊断 + config get [] [--show-secret] 读配置(生效值;无 key 时输出整份) + config set 写配置(走 setting schema 校验) + java list 列出可用 Java(含每个 JDK 的实际落点) + java install [--vendor X] 安装托管 JDK(缺省按地区回退链选发行版; + --vendor temurin|oracle|microsoft|zulu 钉死一家) + java vendors 列出四家发行版的镜像/支持版本/当前优先级 + agent serve [--stdio] [--port N] 起对外 MCP server(默认 127.0.0.1 的 Streamable HTTP; + [--allow-write] --stdio 给 llama.cpp 内置 client 用;默认只读) + agent config [--port N] 打印 Claude Code / Cursor 的配置样例 + version 版本 + 系统摘要(同 --version) + help [命令] 本帮助 + +全局选项: + --json 机器可读输出:成功 {\"ok\":true,\"data\":…},失败 {\"ok\":false,\"error\":…} + --data-dir P 覆盖数据根(等价 custom 模式的根目录,即含 .ULL/ 与 .minecraft/ 的父目录) + --no-color 关闭颜色(v1 输出不含 ANSI,参数保留以兼容脚本) + --quiet 静默人类可读输出(错误与提示仍进 stderr) + --help, -h 帮助(有位置参数时按该命令给帮助) + --version, -V 版本 + 系统摘要 + +退出码: 0 成功 / 1 用户错误 / 2 内部错误" +} + +/// 单个命令的帮助;未知主题返回 `None`(调用方退回总帮助)。 +#[must_use] +pub fn topic(name: &str) -> Option<&'static str> { + match name { + "instances" => Some( + "\ +ull instances list + +列出 profiles.json 里的实例与 versions/ 下已安装的版本。 + --json 输出 {\"data\":{\"instances\":[…],\"installed_versions\":[…],\"data_root\":…}}", + ), + "install" => Some( + "\ +ull install [--loader X] [--loader-version V] [--name N] [--dry-run] + +解析版本清单与继承链 → 组下载计划 → 落盘(versions/ assets/ libraries/)→ 登记实例 +(profiles.json + config/instances/.json)。已存在且校验通过的文件会跳过, +重复装同一版本不重复下载、不重复建实例。 +--dry-run:只输出计划(文件清单 + 总大小 + 每类资源的源选择),不下载、不登记。 +--loader:vanilla(缺省)/ fabric / quilt / neoforge / forge。装加载器时先生成 +versions/<原版>-<加载器>-<版本>/<…>.json(inheritsFrom 原版 + 主类 + loader 库), +原版没装会先装原版,实例的 versionId 指向 loader 版本。 +--loader-version:加载器版本;缺省取官方默认(Fabric 系首个稳定版,maven 系最新)。 + Forge 可写 47.2.0 或 1.20.1-47.2.0 两种形态。 + NeoForge/Forge 里需要跑官方 installer processors 的版本会真跑一遍(M35 已实装): + 缺 Java 时按 D23 的回退链自动装一个托管 JDK;--dry-run 只列出要跑的 processor 与依赖库。 +--json:stdout 是单个结果信封 {\"data\":{\"plan\":…,\"result\":…,\"instance\":…}}, +实时进度改走 stderr 的 JSONL(一行一条 {\"event\":\"progress\"|\"file_failed\"}); +--quiet 时连进度也不打。", + ), + "launch" => Some( + "\ +ull launch [--dry-run] [--account ] [--java ] [--username ] + [--max-memory ] + + 可以是实例 id、实例名,或直接是版本 id。 +--dry-run(D18):只组装命令行并打印,不 spawn 进程、不写会话文件、不刷新令牌。 +账户:--account 指定账户(玩家名或条目 id);不给时用唯一账户,一个都没有则回落 +离线占位(--username 或 Player),有多个账户会报错并列出候选。 +accessToken 一律脱敏(输出里出现的是 )。", + ), + "accounts" => Some( + "\ +ull accounts list +ull accounts add offline +ull accounts add msa +ull accounts remove + +账户存在 <数据根>/.ULL/data/auth/:accounts.json(可公开元数据)与 credentials.dat(凭据) +**分文件**存放,先写凭据后写元数据、原子写、凭据文件 POSIX 侧 0600。 +凭据永不打印:list 只输出名称/轨道/UUID。 +add msa 走微软 device-code(用户码打在 stderr,去 https://www.microsoft.com/link 输码), +需要先配置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)。 +authlib-injector 账户的密码登录留给 GUI(密码进 argv 会泄露,CLI 不开这条通道)。", + ), + "sessions" => Some("ull sessions list [--limit N] 列出会话索引(按开始时间倒序,缺省 20 条)"), + "logs" => Some("ull logs [--tail N] 允许唯一前缀;输出会话 JSONL 末尾 N 条事件(缺省 200)"), + "diag" => Some( + "ull diag [--tail N] 把会话的退出信号与日志喂给规则引擎(内嵌规则集),输出结构化诊断", + ), + "config" => Some( + "\ +ull config get [] [--show-secret] +ull config set + +读写 <数据根>/.ULL/config/launcher.json,走 core::setting 的 schema(类型/枚举校验、 +原子写)。敏感键 get 时脱敏,--show-secret 才给明文;值先按 JSON 解析,失败按字符串。", + ), + "java" => Some( + "\ +ull java list 探测 JAVA_HOME / PATH / 托管目录 / 实例覆盖里的 Java + 并打印每个 JDK 的实际落点(managed 项带目录) +ull java install [--vendor X] 安装托管 JDK(D13/D23):回退链查版本 → 下载 → 解压 → 验证 +ull java vendors 列出四家发行版的镜像/支持版本/当前优先级顺序 + +落点(D23 三模式,随 core.data_mode 与设置键 java.installRoot 走): + portable(默认) <数据根>/.ULL/data/java-managed/jdk--/ + standard Windows %APPDATA%\\Java\\<发行方>\\<版本>\\;macOS ~/Library/Application Support/Java/… + Linux $XDG_DATA_HOME/java/… + custom 设置 java.installRoot 后按 /<发行方>/<版本>/(覆盖前两者) + +发行版回退链(D23):国内 temurin(TUNA) → oracle(华为云→南大) → microsoft(aka.ms) +→ zulu;国外 temurin → microsoft → oracle → zulu。设置 java.vendorPriority 可改顺序 +(只写几家也行,其余按默认序补齐);--vendor 钉死一家、不自动降级。 +每家失败的原因(版本不存在 / 镜像全 4xx / 校验失败)进 --json 的 chain 块与人读的「降级:」行。", + ), + "agent" => Some( + "\ +ull agent serve [--stdio] [--port N] [--host H] [--allow-write|--readonly] +ull agent config [--port N] + +把 agent 的工具注册表挂成 **MCP server**(02 文档 §1 的第二形态:同一注册表、同一安全策略、 +同一审计)。默认走 Streamable HTTP,只绑回环地址(127.0.0.1,默认端口 47823); +--stdio 是给 llama.cpp server 的内置 MCP client 用的形态。 + +写权限默认**关**(只读子集):要放开需设置键 agent.mcp.allow_write=true 或 --allow-write。 +敏感键(High 分级)在 MCP 场景**默认拒绝**(外部客户端没有确认弹窗):让用户去设置页改, +或在设置 → AI 里打开 YOLO(审计照记 yolo=true)。 +令牌:设置键 agent.mcp.token;非空时要求 Authorization: Bearer 。 +`agent config` 直接给出 Claude Code {\"type\":\"http\",\"url\":…} 与 Cursor {\"url\":…} 的样例。", + ), + _ => None, + } +} diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs new file mode 100644 index 0000000..aee3416 --- /dev/null +++ b/crates/cli/src/cli/mod.rs @@ -0,0 +1,17 @@ +//! 命令行解析(手写,不引 clap —— 子命令集小,省一棵依赖树)。 +//! +//! 语法:`ull [全局选项] <命令> [子命令/参数]`。全局选项可以出现在任意位置 +//! (脚本里 `ull instances list --json` 与 `ull --json instances list` 等价)。 +//! +//! 帮助与版本是「提前返回」的伪命令:`--help` / `-h` 出现在任何位置都立即给帮助, +//! 有位置参数时按该主题给帮助。 + +pub mod help; +pub mod model; +pub mod parse; + +pub use model::{ + AccountsAddArgs, AccountsAddKind, AgentServeArgs, Command, ConfigGetArgs, ConfigSetArgs, + DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, LogsArgs, +}; +pub use parse::parse; diff --git a/crates/cli/src/cli/model.rs b/crates/cli/src/cli/model.rs new file mode 100644 index 0000000..702889d --- /dev/null +++ b/crates/cli/src/cli/model.rs @@ -0,0 +1,196 @@ +//! 命令模型(纯数据):解析结果就是这里的一个 [`Invocation`]。 + +use std::path::PathBuf; + +/// 全局选项(所有命令共享)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Globals { + /// `--json`:机器可读输出。 + pub json: bool, + /// `--no-color`:关闭颜色。v1 输出本身不含 ANSI,参数保留以兼容脚本。 + pub no_color: bool, + /// `--quiet`:静默人类可读输出(错误仍进 stderr)。 + pub quiet: bool, + /// `--data-dir `:覆盖数据根(等价 custom 模式的 `custom_root`)。 + pub data_dir: Option, +} + +/// 一条命令。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + /// 无子命令:版本摘要 + 帮助(用户原话「无子命令时 --version + --help」)。 + Welcome, + /// 只打印版本 + 系统摘要。 + Version, + /// 帮助(`Some(topic)` 给该子命令的帮助)。 + Help(Option), + /// `instances list`。 + InstancesList, + /// `install [--loader X] [--name N]`。 + Install(InstallArgs), + /// `launch [--dry-run]`。 + Launch(LaunchArgs), + /// `sessions list [--limit N]`。 + SessionsList { + /// 最多返回几条。 + limit: usize, + }, + /// `logs [--tail N]`。 + Logs(LogsArgs), + /// `diag `。 + Diag(DiagArgs), + /// `config get []`。 + ConfigGet(ConfigGetArgs), + /// `config set `。 + ConfigSet(ConfigSetArgs), + /// `java list`。 + JavaList, + /// `java install [--vendor X]`。 + JavaInstall { + /// 目标 JDK 主版本(8 / 17 / 21)。 + major: u32, + /// 钉死的发行版(缺省走 D23 的回退链)。 + vendor: Option, + }, + /// `java vendors`:列出各发行版的可用性 / 镜像 / 当前优先级。 + JavaVendors, + /// `accounts list`。 + AccountsList, + /// `accounts add []`。 + AccountsAdd(AccountsAddArgs), + /// `accounts remove `。 + AccountsRemove { + /// 账户名(或条目 id)。 + name: String, + }, + /// `agent serve`:对外 MCP server(长驻)。 + AgentServe(AgentServeArgs), + /// `agent config`:打印 Claude Code / Cursor 的客户端配置样例。 + AgentConfig { + /// 覆盖端点端口(默认取设置键 `agent.mcp.port`)。 + port: Option, + }, +} + +/// `agent serve` 的参数(命令行只做覆盖,默认值来自设置键 `agent.mcp.*`)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AgentServeArgs { + /// 走 stdio 形态(llama.cpp 内置 MCP client 用);缺省是 Streamable HTTP。 + pub stdio: bool, + /// 覆盖端口。 + pub port: Option, + /// 覆盖监听地址(只接受回环地址,非回环会被 server 拒绝)。 + pub host: Option, + /// 本次运行放开写工具(仍需设置键 `agent.mcp.allow_write` 或本 flag 之一)。 + pub allow_write: bool, +} + +/// `accounts add` 的轨道。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccountsAddKind { + /// 离线(`add offline `)。 + Offline, + /// 微软 device-code(`add msa`,交互式)。 + Microsoft, +} + +impl AccountsAddKind { + /// 稳定名(帮助文案与 JSON 输出用)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Offline => "offline", + Self::Microsoft => "msa", + } + } +} + +/// `accounts add` 的参数。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountsAddArgs { + /// 轨道。 + pub kind: AccountsAddKind, + /// 离线轨的玩家名(微软轨不需要)。 + pub name: Option, +} + +/// `install` 的参数。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallArgs { + /// 目标版本 id(`1.20.1` / `1.20.1-forge-47.2.0`)。 + pub version: String, + /// 加载器(`vanilla` 缺省 / `fabric` / `quilt` / `neoforge` / `forge`)。 + pub loader: Option, + /// 加载器版本(缺省取官方默认:Fabric 系首个稳定版,maven 系该原版下最新)。 + pub loader_version: Option, + /// 实例显示名(缺省用版本 id)。 + pub name: Option, + /// 只出安装计划(列清单 + 总大小 + 源选择),不下载、不登记。 + pub dry_run: bool, + /// 是否往 stderr 打实时进度(`--quiet` 下关掉)。 + pub progress: bool, + /// 实时进度用 JSONL 形态(`--json` 时开,stderr 一行一条,stdout 仍是单个信封)。 + pub json_progress: bool, +} + +/// `launch` 的参数。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchArgs { + /// 实例 id 或实例名;也可以直接给版本 id。 + pub instance: String, + /// 只组装命令行,不 spawn(D18 的 `--dry-run`)。 + pub dry_run: bool, + /// Java 可执行文件覆盖。 + pub java: Option, + /// 离线账户名(缺省 `Player`)。 + pub username: Option, + /// 账户名(`accounts` 里的玩家名或条目 id);缺省用唯一账户。 + pub account: Option, + /// 最大堆(MB)。 + pub max_memory: Option, +} + +/// `logs` 的参数。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogsArgs { + /// 会话 id(允许唯一前缀)。 + pub session_id: String, + /// 只打印末尾 N 条事件(缺省 200)。 + pub tail: usize, +} + +/// `diag` 的参数。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiagArgs { + /// 会话 id(允许唯一前缀)。 + pub session_id: String, + /// 送入规则引擎的日志行上限(缺省 2000)。 + pub tail: usize, +} + +/// `config get` 的参数。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigGetArgs { + /// 点分键路径;`None` 表示整份生效配置。 + pub key: Option, + /// 敏感键也输出明文(默认脱敏)。 + pub show_secret: bool, +} + +/// `config set` 的参数。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigSetArgs { + /// 点分键路径。 + pub key: String, + /// 值的原始文本(先按 JSON 解析,失败按字符串)。 + pub value: String, +} + +/// 一次调用的完整输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Invocation { + /// 全局选项。 + pub globals: Globals, + /// 要执行的命令。 + pub command: Command, +} diff --git a/crates/cli/src/cli/parse.rs b/crates/cli/src/cli/parse.rs new file mode 100644 index 0000000..1c1b4d6 --- /dev/null +++ b/crates/cli/src/cli/parse.rs @@ -0,0 +1,365 @@ +//! 手写解析器:全局选项 → 子命令 → 子命令内的选项。 + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use unlucky_core::game::java::JdkVendor; + +use crate::error::CliError; + +mod accounts; +mod agent; + +use super::model::{ + Command, ConfigGetArgs, ConfigSetArgs, DiagArgs, Globals, InstallArgs, Invocation, LaunchArgs, + LogsArgs, +}; + +/// `logs` 缺省尾巴行数。 +const DEFAULT_LOG_TAIL: usize = 200; +/// `diag` 缺省送入规则引擎的行数。 +const DEFAULT_DIAG_TAIL: usize = 2000; +/// `sessions list` 缺省条数。 +const DEFAULT_SESSION_LIMIT: usize = 20; + +/// 解析整条命令行(不含 argv[0])。 +/// +/// # Errors +/// +/// 用法错误(未知命令/选项、缺参数、数字解析失败)返回 [`CliError`](退出码 1)。 +pub fn parse(args: &[String]) -> Result { + let mut globals = Globals::default(); + let mut rest: Vec = Vec::new(); + let mut help_requested = false; + let mut version_requested = false; + let mut index = 0; + + while index < args.len() { + let arg = args[index].as_str(); + match arg { + "--json" => globals.json = true, + "--no-color" => globals.no_color = true, + "--quiet" | "-q" => globals.quiet = true, + // 帮助/版本是「伪命令」,但不提前返回:`--help` 出现在命令前面时, + // 主题要从后面收完的位置参数里取(`ull --help launch` 与 `ull launch --help` 等价)。 + "--help" | "-h" => help_requested = true, + "--version" | "-V" => version_requested = true, + "--data-dir" => { + index += 1; + globals.data_dir = Some(PathBuf::from(take_value(args, index, "data-dir")?)); + } + _ => match arg.strip_prefix("--data-dir=") { + Some(value) => globals.data_dir = Some(PathBuf::from(value)), + None => rest.push(arg.to_string()), + }, + } + index += 1; + } + + if help_requested { + return Ok(Invocation { + globals, + command: Command::Help(rest.first().cloned()), + }); + } + if version_requested { + return Ok(Invocation { + globals, + command: Command::Version, + }); + } + + let command = parse_command(&rest, &globals)?; + Ok(Invocation { globals, command }) +} + +fn parse_command(rest: &[String], globals: &Globals) -> Result { + let Some(name) = rest.first() else { + return Ok(Command::Welcome); + }; + let tail = &rest[1..]; + match name.as_str() { + "version" => Ok(Command::Version), + "help" => Ok(Command::Help(tail.first().cloned())), + "instances" => parse_instances(tail), + "install" => parse_install(tail, globals), + "launch" => parse_launch(tail), + "sessions" => parse_sessions(tail), + "logs" => parse_logs(tail), + "diag" => parse_diag(tail), + "config" => parse_config(tail), + "java" => parse_java(tail), + "accounts" => accounts::parse_accounts(tail), + "agent" => agent::parse_agent(tail), + other => { + Err(CliError::usage(format!("未知命令 `{other}`")).with_hint("ull --help 查看命令列表")) + } + } +} + +fn parse_instances(tail: &[String]) -> Result { + let args = match tail.first().map(String::as_str) { + None | Some("list") => tail.get(1..).unwrap_or_default(), + Some(other) => { + return Err(CliError::usage(format!( + "instances 的子命令只有 list,收到 `{other}`" + ))); + } + }; + let parsed = parse_flags(args, &[], &[])?; + ensure_no_positionals(&parsed, "instances list")?; + Ok(Command::InstancesList) +} + +fn parse_install(tail: &[String], globals: &Globals) -> Result { + let parsed = parse_flags(tail, &["loader", "loader-version", "name"], &["dry-run"])?; + let version = require_single_positional(&parsed, "install ")?; + Ok(Command::Install(InstallArgs { + version, + loader: parsed.value("loader"), + loader_version: parsed.value("loader-version"), + name: parsed.value("name"), + dry_run: parsed.has("dry-run"), + // 实时进度进 stderr:人类模式是文本行,`--json` 模式是 JSONL(stdout 保持干净), + // `--quiet` 一律不打。 + progress: !globals.quiet, + json_progress: globals.json, + })) +} + +fn parse_launch(tail: &[String]) -> Result { + let parsed = parse_flags( + tail, + &["java", "username", "account", "max-memory"], + &["dry-run"], + )?; + let instance = require_single_positional(&parsed, "launch ")?; + let max_memory = match parsed.value("max-memory") { + Some(raw) => Some( + raw.parse::() + .map_err(|_| CliError::usage(format!("--max-memory 需要整数 MB,收到 `{raw}`")))?, + ), + None => None, + }; + Ok(Command::Launch(LaunchArgs { + instance, + dry_run: parsed.has("dry-run"), + java: parsed.value("java").map(PathBuf::from), + username: parsed.value("username"), + account: parsed.value("account"), + max_memory, + })) +} + +fn parse_sessions(tail: &[String]) -> Result { + let args = match tail.first().map(String::as_str) { + None | Some("list") => tail.get(1..).unwrap_or_default(), + Some(other) => { + return Err(CliError::usage(format!( + "sessions 的子命令只有 list,收到 `{other}`" + ))); + } + }; + let parsed = parse_flags(args, &["limit"], &[])?; + ensure_no_positionals(&parsed, "sessions list")?; + let limit = match parsed.value("limit") { + Some(raw) => raw + .parse::() + .map_err(|_| CliError::usage(format!("--limit 需要整数,收到 `{raw}`")))?, + None => DEFAULT_SESSION_LIMIT, + }; + Ok(Command::SessionsList { limit }) +} + +fn parse_logs(tail: &[String]) -> Result { + let parsed = parse_flags(tail, &["tail"], &[])?; + let session_id = require_single_positional(&parsed, "logs ")?; + let tail_lines = optional_count(&parsed, "tail", DEFAULT_LOG_TAIL)?; + Ok(Command::Logs(LogsArgs { + session_id, + tail: tail_lines, + })) +} + +fn parse_diag(tail: &[String]) -> Result { + let parsed = parse_flags(tail, &["tail"], &[])?; + let session_id = require_single_positional(&parsed, "diag ")?; + let tail_lines = optional_count(&parsed, "tail", DEFAULT_DIAG_TAIL)?; + Ok(Command::Diag(DiagArgs { + session_id, + tail: tail_lines, + })) +} + +fn parse_config(tail: &[String]) -> Result { + let Some(sub) = tail.first().map(String::as_str) else { + return Err(CliError::usage("config 需要子命令 get|set") + .with_hint("ull config get / ull config set ")); + }; + let rest = tail.get(1..).unwrap_or_default(); + match sub { + "get" => { + let parsed = parse_flags(rest, &[], &["show-secret"])?; + if parsed.positionals.len() > 1 { + return Err(CliError::usage("config get 最多接受一个键路径")); + } + Ok(Command::ConfigGet(ConfigGetArgs { + key: parsed.positionals.first().cloned(), + show_secret: parsed.has("show-secret"), + })) + } + "set" => { + let parsed = parse_flags(rest, &[], &[])?; + if parsed.positionals.len() != 2 { + return Err(CliError::usage( + "config set 需要键路径与值:ull config set ", + )); + } + Ok(Command::ConfigSet(ConfigSetArgs { + key: parsed.positionals[0].clone(), + value: parsed.positionals[1].clone(), + })) + } + other => Err(CliError::usage(format!( + "config 的子命令只有 get|set,收到 `{other}`" + ))), + } +} + +fn parse_java(tail: &[String]) -> Result { + match tail.first().map(String::as_str) { + None | Some("list") => { + ensure_no_extra(tail.get(1..).unwrap_or_default(), "java list")?; + Ok(Command::JavaList) + } + Some("vendors") => { + ensure_no_extra(tail.get(1..).unwrap_or_default(), "java vendors")?; + Ok(Command::JavaVendors) + } + Some("install") => { + let parsed = parse_flags(tail.get(1..).unwrap_or_default(), &["vendor"], &[])?; + let raw = require_single_positional(&parsed, "java install ")?; + let major = raw + .parse::() + .map_err(|_| CliError::usage(format!("JDK 主版本需要整数,收到 `{raw}`")))?; + let vendor = match parsed.value("vendor") { + Some(text) => Some(JdkVendor::parse(&text).ok_or_else(|| { + CliError::usage(format!( + "--vendor 只有 temurin|oracle|microsoft|zulu,收到 `{text}`" + )) + })?), + None => None, + }; + Ok(Command::JavaInstall { major, vendor }) + } + Some(other) => Err(CliError::usage(format!( + "java 的子命令只有 list|install|vendors,收到 `{other}`" + ))), + } +} + +/// 单个位置参数(多一个少一个都算用法错误)。 +fn require_single_positional(parsed: &ParsedArgs, usage: &str) -> Result { + match parsed.positionals.as_slice() { + [only] => Ok(only.clone()), + [] => Err(CliError::usage(format!("缺少参数:ull {usage}"))), + _ => Err(CliError::usage(format!("参数过多:ull {usage}"))), + } +} + +fn optional_count(parsed: &ParsedArgs, name: &str, default: usize) -> Result { + match parsed.value(name) { + Some(raw) => raw + .parse::() + .map_err(|_| CliError::usage(format!("--{name} 需要整数,收到 `{raw}`"))), + None => Ok(default), + } +} + +fn ensure_no_extra(args: &[String], usage: &str) -> Result<(), CliError> { + if args.is_empty() { + Ok(()) + } else { + Err(CliError::usage(format!( + "{usage} 不接受参数:{}", + args.join(" ") + ))) + } +} + +fn ensure_no_positionals(parsed: &ParsedArgs, usage: &str) -> Result<(), CliError> { + ensure_no_extra(&parsed.positionals, usage) +} + +/// 解析后的一条子命令参数。 +struct ParsedArgs { + positionals: Vec, + flags: BTreeMap>, +} + +impl ParsedArgs { + fn value(&self, name: &str) -> Option { + self.flags.get(name).cloned().flatten() + } + + fn has(&self, name: &str) -> bool { + self.flags.contains_key(name) + } +} + +/// 取值形 flag 的下一 token。 +/// +/// 下一个 token 若以 `--` 开头,说明用户漏了值(`ull launch x --java --dry-run`), +/// 此时报用法错误,而不是把 flag 当值吞掉后在下游报出费解的「找不到 Java」。 +/// 负数(`-1`)不以 `--` 开头,不会被误伤。 +fn take_value(args: &[String], index: usize, name: &str) -> Result { + let value = args + .get(index) + .ok_or_else(|| CliError::usage(format!("--{name} 需要一个值")))?; + if value.starts_with("--") { + return Err(CliError::usage(format!( + "--{name} 需要一个值,但收到选项 `{value}`" + ))); + } + Ok(value.clone()) +} + +/// `--flag` / `--flag=value` / `--flag value` + 位置参数。 +fn parse_flags(args: &[String], valued: &[&str], flags: &[&str]) -> Result { + let mut parsed = ParsedArgs { + positionals: Vec::new(), + flags: BTreeMap::new(), + }; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + let Some(body) = arg.strip_prefix("--") else { + parsed.positionals.push(arg.clone()); + index += 1; + continue; + }; + let (name, inline) = match body.split_once('=') { + Some((name, value)) => (name, Some(value.to_string())), + None => (body, None), + }; + if valued.contains(&name) { + let value = match inline { + Some(value) => value, + None => { + index += 1; + take_value(args, index, name)? + } + }; + parsed.flags.insert(name.to_string(), Some(value)); + } else if flags.contains(&name) { + if inline.is_some() { + return Err(CliError::usage(format!("--{name} 不接受值"))); + } + parsed.flags.insert(name.to_string(), None); + } else { + return Err(CliError::usage(format!("未知选项 --{name}"))); + } + index += 1; + } + Ok(parsed) +} diff --git a/crates/cli/src/cli/parse/accounts.rs b/crates/cli/src/cli/parse/accounts.rs new file mode 100644 index 0000000..c24b96f --- /dev/null +++ b/crates/cli/src/cli/parse/accounts.rs @@ -0,0 +1,68 @@ +//! `ull accounts` 子命令的解析(从 `parse.rs` 拆出,避免解析器主文件顶到 400 行)。 +//! +//! 语法: +//! - `ull accounts [list]` +//! - `ull accounts add offline ` +//! - `ull accounts add msa`(别名 `microsoft`) +//! - `ull accounts remove `(别名 `rm`) +//! +//! 只开这三条轨道入口:authlib 的密码登录不走 CLI(密码进 argv 会泄露,见 +//! `DevDocs/SubAgentsTalk/auth-接口约定.md` §4)。 + +use super::{ensure_no_positionals, parse_flags, require_single_positional}; +use crate::cli::model::{AccountsAddArgs, AccountsAddKind, Command}; +use crate::error::CliError; + +/// 解析 `accounts ...`。 +pub(super) fn parse_accounts(tail: &[String]) -> Result { + let Some(sub) = tail.first().map(String::as_str) else { + return Ok(Command::AccountsList); + }; + let rest = tail.get(1..).unwrap_or_default(); + match sub { + "list" => { + let parsed = parse_flags(rest, &[], &[])?; + ensure_no_positionals(&parsed, "accounts list")?; + Ok(Command::AccountsList) + } + "add" => parse_add(rest), + "remove" | "rm" => { + let parsed = parse_flags(rest, &[], &[])?; + let name = require_single_positional(&parsed, "accounts remove ")?; + Ok(Command::AccountsRemove { name }) + } + other => Err(CliError::usage(format!( + "accounts 的子命令只有 list|add|remove,收到 `{other}`" + ))), + } +} + +fn parse_add(rest: &[String]) -> Result { + let Some(kind) = rest.first().map(String::as_str) else { + return Err( + CliError::usage("accounts add 需要轨道:offline|msa").with_hint( + "ull accounts add offline <玩家名> / ull accounts add msa(设备码交互式登录)", + ), + ); + }; + let parsed = parse_flags(rest.get(1..).unwrap_or_default(), &[], &[])?; + match kind { + "offline" => { + let name = require_single_positional(&parsed, "accounts add offline ")?; + Ok(Command::AccountsAdd(AccountsAddArgs { + kind: AccountsAddKind::Offline, + name: Some(name), + })) + } + "msa" | "microsoft" => { + ensure_no_positionals(&parsed, "accounts add msa")?; + Ok(Command::AccountsAdd(AccountsAddArgs { + kind: AccountsAddKind::Microsoft, + name: None, + })) + } + other => Err(CliError::usage(format!( + "accounts add 的轨道只有 offline|msa,收到 `{other}`" + ))), + } +} diff --git a/crates/cli/src/cli/parse/agent.rs b/crates/cli/src/cli/parse/agent.rs new file mode 100644 index 0000000..74df2f4 --- /dev/null +++ b/crates/cli/src/cli/parse/agent.rs @@ -0,0 +1,66 @@ +//! `agent` 子命令解析(`serve` / `config`)。 +//! +//! 与 `parse::accounts` 同样的拆分理由:`parse.rs` 挨着 400 行硬上限, +//! 而且「MCP server 的参数」本来就是一撮独立的东西,混在主解析器里迟早长成一片。 + +use crate::cli::model::{AgentServeArgs, Command}; +use crate::cli::parse::ParsedArgs; +use crate::error::CliError; + +use super::{ensure_no_positionals, parse_flags}; + +/// 解析 `ull agent `。 +/// +/// # Errors +/// 缺子命令、未知子命令、未知选项、端口不是 1~65535 时返回用法错误。 +pub fn parse_agent(tail: &[String]) -> Result { + let Some(sub) = tail.first().map(String::as_str) else { + return Err(CliError::usage("agent 需要子命令 serve|config") + .with_hint("ull agent serve / ull agent config")); + }; + let rest = tail.get(1..).unwrap_or_default(); + match sub { + "serve" => parse_serve(rest), + "config" => parse_config(rest), + other => Err(CliError::usage(format!( + "agent 的子命令只有 serve|config,收到 `{other}`" + ))), + } +} + +fn parse_serve(rest: &[String]) -> Result { + let parsed = parse_flags( + rest, + &["port", "host"], + &["stdio", "allow-write", "readonly"], + )?; + ensure_no_positionals(&parsed, "agent serve")?; + if parsed.has("allow-write") && parsed.has("readonly") { + return Err(CliError::usage( + "--allow-write 与 --readonly 不能同时给(二选一)", + )); + } + Ok(Command::AgentServe(AgentServeArgs { + stdio: parsed.has("stdio"), + port: optional_port(&parsed)?, + host: parsed.value("host"), + allow_write: parsed.has("allow-write"), + })) +} + +fn parse_config(rest: &[String]) -> Result { + let parsed = parse_flags(rest, &["port"], &[])?; + ensure_no_positionals(&parsed, "agent config")?; + Ok(Command::AgentConfig { + port: optional_port(&parsed)?, + }) +} + +fn optional_port(parsed: &ParsedArgs) -> Result, CliError> { + match parsed.value("port") { + Some(raw) => Ok(Some(raw.parse::().map_err(|_| { + CliError::usage(format!("--port 需要 1~65535,收到 `{raw}`")) + })?)), + None => Ok(None), + } +} diff --git a/crates/cli/src/commands/accounts.rs b/crates/cli/src/commands/accounts.rs new file mode 100644 index 0000000..9f133d6 --- /dev/null +++ b/crates/cli/src/commands/accounts.rs @@ -0,0 +1,198 @@ +//! `ull accounts list|add|remove`:账户元数据操作,**凭据永不打印**。 +//! +//! 设计约束(D9/Q21 + D4 凭据红线): +//! - 只开四条路径:`list` / `add offline ` / `add msa` / `remove `; +//! authlib 的密码登录**故意不开 CLI 通道**(密码进 argv 会直接进 shell 历史与 +//! `ps` 输出,破坏红线;留给 GUI 输入框); +//! - 输出只经 `AccountMeta`(可公开字段),`Report`/`--json` 里没有任何 token 字段; +//! - device-code 的 `user_code`/URL 打到 **stderr**(不管 `--quiet` 都要打:不打用户没法完成登录)。 + +use serde_json::json; +use unlucky_core::auth::account::{AccountKind, AccountMeta, Credentials}; +use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth}; +use unlucky_core::auth::offline::offline_meta; +use unlucky_core::auth::{AccountStore, AuthJournal}; + +use crate::cli::{AccountsAddArgs, AccountsAddKind}; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::{progress_line, Report}; + +/// `accounts list`:列出账户(名称/轨道/UUID/创建时间)。 +/// +/// # Errors +/// +/// 数据根解析失败(在 `Ctx`)或读盘 IO 失败。 +pub fn list(ctx: &Ctx) -> Result { + let store = open_store(ctx); + let mut human = Vec::new(); + if store.is_empty() { + human.push("还没有账户。".to_string()); + human.push(" 离线:ull accounts add offline <玩家名>".to_string()); + human.push(" 微软:ull accounts add msa(设备码交互式登录)".to_string()); + } else { + human.push(format!("账户({} 个):", store.accounts().len())); + for meta in store.accounts() { + human.push(format!( + " {} · {} · {}", + meta.display_name(), + meta.kind.label(), + meta.profile_id + )); + } + } + for warning in store.warnings() { + human.push(format!("提示: {warning}")); + } + let accounts: Vec = store.accounts().iter().map(account_json).collect(); + let data = json!({ + "accounts": accounts, + "count": accounts.len(), + "auth_dir": store.paths().root().display().to_string(), + "warnings": store.warnings(), + }); + Ok(Report::new(data, human)) +} + +/// `accounts add offline `。 +/// +/// # Errors +/// +/// 名字不合法、同名账户已存在、落盘失败。 +pub fn add(ctx: &Ctx, args: &AccountsAddArgs) -> Result { + match args.kind { + AccountsAddKind::Offline => add_offline(ctx, args.name.as_deref().unwrap_or_default()), + AccountsAddKind::Microsoft => add_microsoft(ctx), + } +} + +fn add_offline(ctx: &Ctx, name: &str) -> Result { + let meta = offline_meta(name, now_ms()).map_err(super::map_auth_error)?; + let mut store = open_store(ctx); + store + .insert(meta.clone(), Credentials::Offline) + .map_err(super::map_auth_error)?; + store.save().map_err(super::map_auth_error)?; + let human = vec![ + format!( + "已添加离线账户 {}(HMCL 兼容 UUID {})", + meta.display_name(), + meta.profile_id + ), + format!("账户文件:{}", store.paths().accounts_file().display()), + ]; + Ok(Report::new(added_json(&meta), human)) +} + +/// `accounts add msa`:device-code 交互式登录。 +fn add_microsoft(ctx: &Ctx) -> Result { + // 失败日志落**本次命令的实际数据根**(`ctx.layout().logs()`):`MicrosoftAuth::from_env()` + // 默认挂的是便携根(exe 同级 `.ULL/logs`),`--data-dir` / launcher.json 的 standard 模式下 + // 那是别人的目录,排障时找不到 auth.jsonl(M38 遗留 finding)。 + let auth = MicrosoftAuth::from_env() + .map_err(super::map_auth_error)? + .with_journal(Some(AuthJournal::in_logs_dir(ctx.layout().logs()))); + // 设备码要先给用户看,再开始等:core 的 `present` 回调正好卡在这个时间点。 + let session = unlucky_core::download::blocking::block_on(auth.device_code_login(|prompt| { + let (headline, code) = prompt.lines(); + progress_line(&headline); + progress_line(&code); + })) + .map_err(super::map_auth_error)?; + + let meta = AccountMeta::new( + AccountKind::Microsoft, + session.profile_id.clone(), + session.profile_name.clone(), + now_ms(), + ); + let mut store = open_store(ctx); + store + .insert(meta.clone(), Credentials::Microsoft(session)) + .map_err(super::map_auth_error)?; + store.save().map_err(super::map_auth_error)?; + let human = vec![ + format!( + "已添加微软账户 {}(档案 UUID {})", + meta.display_name(), + meta.profile_id + ), + format!("账户文件:{}", store.paths().accounts_file().display()), + ]; + Ok(Report::new(added_json(&meta), human)) +} + +/// `accounts remove `。 +/// +/// # Errors +/// +/// 找不到该账户、落盘失败。 +pub fn remove(ctx: &Ctx, name: &str) -> Result { + let mut store = open_store(ctx); + let meta = store.remove(name).map_err(super::map_auth_error)?; + store.save().map_err(super::map_auth_error)?; + let human = vec![format!( + "已删除账户 {}({})", + meta.display_name(), + meta.kind.label() + )]; + let data = json!({ + "removed": account_json(&meta), + "count": store.accounts().len(), + }); + Ok(Report::new(data, human)) +} + +fn open_store(ctx: &Ctx) -> AccountStore { + AccountStore::open(ctx.auth_data()) +} + +fn added_json(meta: &AccountMeta) -> serde_json::Value { + json!({ + "account": account_json(meta), + "account_id": meta.id.as_str(), + }) +} + +/// 账户元数据 → JSON(**只含可公开字段**)。 +fn account_json(meta: &AccountMeta) -> serde_json::Value { + json!({ + "name": meta.display_name(), + "profile_name": meta.profile_name, + "id": meta.id.as_str(), + "kind": meta.kind.as_str(), + "profile_id": meta.profile_id, + "login_name": meta.login_name, + "server_url": meta.server_url, + "created_at_ms": meta.created_at_ms, + }) +} + +#[cfg(test)] +mod tests { + use super::account_json; + use unlucky_core::auth::account::{AccountKind, AccountMeta}; + + #[test] + fn account_json_has_no_credential_fields() { + let mut meta = AccountMeta::new( + AccountKind::Microsoft, + "0f8fad5bd9cb469fa16570867728950e", + "Steve", + 7, + ); + meta.login_name = Some("me@example.com".to_string()); + let value = account_json(&meta); + let keys: Vec<&str> = value + .as_object() + .expect("对象") + .keys() + .map(String::as_str) + .collect(); + for forbidden in ["access_token", "refresh_token", "token", "password"] { + assert!(!keys.contains(&forbidden), "不该出现 {forbidden}"); + } + assert_eq!(value["name"], "Steve"); + assert_eq!(value["kind"], "microsoft"); + } +} diff --git a/crates/cli/src/commands/agent/mod.rs b/crates/cli/src/commands/agent/mod.rs new file mode 100644 index 0000000..59edf29 --- /dev/null +++ b/crates/cli/src/commands/agent/mod.rs @@ -0,0 +1,135 @@ +//! `ull agent`:对外 MCP server 的 headless 入口(02 文档 §1 的第二形态)。 +//! +//! 两个子命令: +//! - `agent serve`:长驻服务(`--stdio` 或 Streamable HTTP on 127.0.0.1); +//! - `agent config`:打印 Claude Code / Cursor 的配置样例(含当前端口与令牌状态)。 +//! +//! 为什么 `serve` 不能走 `commands::run` 的「返回 Report 再打印」套路:stdio 形态下 +//! **stdout 就是 JSON-RPC 流**,任何一行多余输出都会让客户端解析失败。所以 runner +//! 里对 `Command::AgentServe` 提前分流,不走 `output::print_report`。 + +pub mod ports; +pub mod serve; + +use serde_json::{json, Value}; +use unlucky_agent::mcp::config_samples; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +use crate::cli::AgentServeArgs; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +/// `ull agent serve`:起服务(阻塞)。 +/// +/// # Errors +/// 见 [`serve::run`]。 +pub fn run_serve(ctx: &Ctx, args: &AgentServeArgs) -> Result { + serve::run(ctx, args) +} + +/// runner 的分流入口:解析数据根 → 起服务。错误由 runner 统一打印。 +/// +/// # Errors +/// 数据根缺失、平台不达基线、端口占用、非回环监听等返回用户错误。 +pub fn run_serve_with( + globals: &crate::cli::Globals, + args: &AgentServeArgs, + err: &mut dyn std::io::Write, +) -> Result { + let ctx = Ctx::resolve(globals)?; + ctx.ensure_supported()?; + for note in ctx.notes() { + let _ = writeln!(err, "提示: {note}"); + } + run_serve(&ctx, args) +} + +/// `ull agent config`:输出两份可粘贴的客户端配置 + 当前设置状态。 +/// +/// # Errors +/// 配置不可读时返回用户错误(schema 不认识的键不影响)。 +pub fn config_report(ctx: &Ctx, port: Option) -> Result { + let store = SettingStore::load(ctx.launcher_config(), launcher_schema()); + let doc = store.doc(); + let enabled = doc + .effective("agent.mcp.enabled") + .and_then(Value::as_bool) + .unwrap_or(false); + let allow_write = doc + .effective("agent.mcp.allow_write") + .and_then(Value::as_bool) + .unwrap_or(false); + let yolo = doc + .effective("ai.yolo") + .and_then(Value::as_bool) + .unwrap_or(false); + let token = doc + .effective_str("agent.mcp.token") + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_string); + let configured_port = doc + .effective("agent.mcp.port") + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .unwrap_or(47823); + let port = port.unwrap_or(configured_port); + let url = format!("http://127.0.0.1:{port}/mcp"); + let samples = config_samples(&url, token.as_deref()); + let registry = unlucky_agent::tools::build_v1_registry(&ports::build(ctx)) + .map_err(|error| CliError::internal("agent_registry", error.to_string()))?; + let read_only_tools = registry.read_only_definitions().len(); + let all_tools = registry.len(); + + let data = json!({ + "url": url, + "enabled": enabled, + "allow_write": allow_write, + "yolo": yolo, + "token_configured": token.is_some(), + "read_only_tools": read_only_tools, + "all_tools": all_tools, + "claude_code": samples.claude_code, + "cursor": samples.cursor, + }); + + let mut human = vec![ + format!("MCP 端点:{url}"), + format!( + "开关:{} · 写权限:{} · YOLO:{} · 令牌:{}", + if enabled { + "已启用" + } else { + "未启用(serve 仍可手动起)" + }, + if allow_write { + "开" + } else { + "关(只读)" + }, + if yolo { "开" } else { "关" }, + if token.is_some() { + "已设置" + } else { + "未设置" + } + ), + "Claude Code(~/.claude.json 或 `claude mcp add --json`):".to_string(), + ]; + human.extend(pretty_lines(&samples.claude_code)); + human.push("Cursor(.cursor/mcp.json):".to_string()); + human.extend(pretty_lines(&samples.cursor)); + human.push( + "启动:ull agent serve(默认 Streamable HTTP) / ull agent serve --stdio".to_string(), + ); + Ok(Report::new(data, human)) +} + +fn pretty_lines(value: &Value) -> Vec { + match serde_json::to_string_pretty(value) { + Ok(text) => text.lines().map(|line| format!(" {line}")).collect(), + Err(_) => vec![" (序列化失败)".to_string()], + } +} diff --git a/crates/cli/src/commands/agent/ports/accounts.rs b/crates/cli/src/commands/agent/ports/accounts.rs new file mode 100644 index 0000000..39673d4 --- /dev/null +++ b/crates/cli/src/commands/agent/ports/accounts.rs @@ -0,0 +1,49 @@ +//! 账户端口:只读 `AccountStore::accounts()`(公开字段)。**凭据红线在这里落闸**。 +//! +//! `AccountStore::credentials()` 存在,但本文件**不调用**它——agent 侧也没有能承接 +//! `Credentials` 的类型([`unlucky_agent::port::AccountSummary`])。这条界线不是靠自觉: +//! 只要有人想从工具结果里带出 token,就必须先改 `AccountSummary`,而 `port::game` 的 +//! 单测会先炸(序列化结果里不许出现 token/password/secret)。 + +use unlucky_agent::port::game::{AccountPort, AccountSummary}; +use unlucky_agent::port::PortResult; +use unlucky_core::auth::AccountStore; + +/// M31 账户库的只读端口。 +#[derive(Debug, Clone)] +pub struct CoreAccounts { + root: std::path::PathBuf, +} + +impl CoreAccounts { + /// `root` = `<数据根>/.ULL/data/auth`。 + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } +} + +impl AccountPort for CoreAccounts { + fn list_accounts(&self) -> PortResult> { + let store = AccountStore::open(self.root.clone()); + if !store.warnings().is_empty() { + // 账户文件有坏条目:不阻断(用户还能看到其余账户),但要留痕到 stderr。 + eprintln!( + "[unlucky-agent] 账户库有 {} 条提示(只读本工具不受影响)", + store.warnings().len() + ); + } + Ok(store + .accounts() + .iter() + .map(|meta| AccountSummary { + id: meta.id.as_str().to_string(), + kind: meta.kind.as_str().to_string(), + profile_id: meta.profile_id.clone(), + profile_name: meta.profile_name.clone(), + login_name: meta.login_name.clone(), + created_at_ms: meta.created_at_ms, + }) + .collect()) + } +} diff --git a/crates/cli/src/commands/agent/ports/diagnose.rs b/crates/cli/src/commands/agent/ports/diagnose.rs new file mode 100644 index 0000000..0143f0e --- /dev/null +++ b/crates/cli/src/commands/agent/ports/diagnose.rs @@ -0,0 +1,187 @@ +//! 诊断端口:调用 M15 的规则引擎(**规则先跑,模型补位**,02 §3.2)。 +//! +//! 工具层只把「规则结论 + 相关片段」交给模型,不把裸日志倒进去——这是 token 预算 +//! 与结论质量的双重考虑,也是 analyse/02 §8.3 的分层思路。 + +use std::collections::BTreeMap; + +use unlucky_agent::port::{ + CrashDiagnosis, DiagnosePort, DiagnosisCandidate, ExitSummary, FixAction, FixActionKind, + PortResult, +}; +use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin}; +use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput, Finding}; +use unlucky_core::diagnose::loader::RuleLoader; +use unlucky_core::diagnose::rules::FixActionKind as CoreFixKind; + +use super::events::{tail_join, SessionSource}; + +/// 送进规则引擎的默认尾巴行数(与 CLI `diag` 同值,避免两条路看到不同证据)。 +pub const DEFAULT_TAIL: usize = 2000; + +/// M15 规则引擎的端口实现。 +#[derive(Debug, Clone)] +pub struct CoreDiagnose { + source: SessionSource, + tail: usize, +} + +impl CoreDiagnose { + /// `root` = `<数据根>/.ULL/data/agent/sessions`。 + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { + source: SessionSource::new(root), + tail: DEFAULT_TAIL, + } + } +} + +fn map_kind(kind: CoreFixKind) -> FixActionKind { + match kind { + CoreFixKind::ChangeJava => FixActionKind::ChangeJava, + CoreFixKind::AdjustMemory => FixActionKind::AdjustMemory, + CoreFixKind::ChangeGraphics => FixActionKind::ChangeGraphics, + CoreFixKind::RemoveMod => FixActionKind::RemoveMod, + CoreFixKind::Reinstall => FixActionKind::Reinstall, + CoreFixKind::SeekHelp => FixActionKind::SeekHelp, + } +} + +fn candidate_of(finding: &Finding) -> DiagnosisCandidate { + let core_candidate = finding.to_candidate(); + DiagnosisCandidate { + id: core_candidate.id, + severity: core_candidate.severity.to_string(), + category: core_candidate.category.to_string(), + confidence: core_candidate.confidence, + message_key: core_candidate.message_key, + message: core_candidate.message, + detail: core_candidate.detail.unwrap_or_default(), + evidence: core_candidate.evidence, + fix_actions: core_candidate + .fix_actions + .into_iter() + .map(|action| FixAction { + kind: map_kind(action.kind), + tool: action.tool.to_string(), + label: String::new(), + args: action + .args + .into_iter() + .collect::>(), + }) + .collect(), + } +} + +impl DiagnosePort for CoreDiagnose { + fn diagnose(&self, session_id: &str) -> PortResult { + let entry = self.source.find(session_id)?; + let events = self.source.events(&entry)?; + let (stdout, stderr, error_lines) = SessionSource::split_text(&events); + + let exit = ExitSignal::classify( + entry.exit_code, + Some(entry.duration_ms), + error_lines, + &stderr.join("\n"), + ); + let mut input = DiagnosisInput::new().with_exit(exit); + let stdout_text = tail_join(&stdout, self.tail); + if !stdout_text.is_empty() { + input.push_text(SignalOrigin::Stdout, stdout_text); + } + let stderr_text = tail_join(&stderr, self.tail); + if !stderr_text.is_empty() { + input.push_text(SignalOrigin::Stderr, stderr_text); + } + + let loader = RuleLoader::embedded(); + let diagnoser = Diagnoser::new(loader.rule_set().rules().to_vec()); + let report = diagnoser.analyze(&input); + + Ok(CrashDiagnosis { + session_id: entry.session_id.clone(), + source: "rules".to_string(), + needs_more_info: report.needs_more_info(), + candidates: report.findings.iter().map(candidate_of).collect(), + secondary: report.secondary.iter().map(candidate_of).collect(), + evidence: stderr.iter().rev().take(3).cloned().collect(), + exit: ExitSummary { + code: entry.exit_code, + exit_type: entry.exit_type.clone(), + silent_failure: entry.silent_failure, + }, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn seed(root: &std::path::Path, stderr_line: &str) { + let game = root.join("game"); + std::fs::create_dir_all(&game).expect("建目录"); + let entry = json!({ + "v": 1, + "session_id": "20261029T124501-9f3c2a", + "instance_id": "inst-1", + "instance_name": "1.20.1 Forge", + "game_version": "1.20.1", + "started_at": "2026-10-29T12:45:01.123Z", + "ended_at": "2026-10-29T12:45:05.123Z", + "duration_ms": 4000, + "exit_code": 1, + "exit_type": "application_error", + "silent_failure": false, + "window_detected": false, + "event_count": 1, + "log_path": "20261029T124501-9f3c2a.jsonl", + "crash_found": true, + "diagnosis_top": null + }); + std::fs::write(game.join("index.jsonl"), format!("{entry}\n")).expect("写索引"); + let event = json!({ + "seq": 0, "ts": "2026-10-29T12:45:01.123Z", "t_ms": 0, + "kind": "stderr", + "payload": { "line": stderr_line, "level": "error" } + }); + std::fs::write( + game.join("20261029T124501-9f3c2a.jsonl"), + format!("{event}\n"), + ) + .expect("写事件"); + } + + #[test] + fn runs_the_rule_engine_over_a_session() { + let root = std::env::temp_dir().join(format!("ull-mcp-diagnose-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + seed( + &root, + "java.lang.NoClassDefFoundError: cpw/mods/bootstraplauncher/BootstrapLauncher", + ); + + let port = CoreDiagnose::new(root.clone()); + let report = port.diagnose("20261029T124501-9f3c2a").expect("应能诊断"); + assert_eq!(report.session_id, "20261029T124501-9f3c2a"); + assert_eq!(report.source, "rules"); + assert_eq!(report.exit.code, Some(1)); + // 规则集里应有 JVM/完整性类结论命中这条 stderr + assert!(!report.candidates.is_empty(), "应至少命中一条规则"); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn unknown_session_is_a_readable_error() { + let root = + std::env::temp_dir().join(format!("ull-mcp-diagnose-none-{}", std::process::id())); + let port = CoreDiagnose::new(root); + let err = port.diagnose("nope").unwrap_err(); + assert!(err.contains("找不到会话"), "{err}"); + } +} diff --git a/crates/cli/src/commands/agent/ports/events.rs b/crates/cli/src/commands/agent/ports/events.rs new file mode 100644 index 0000000..d313200 --- /dev/null +++ b/crates/cli/src/commands/agent/ports/events.rs @@ -0,0 +1,136 @@ +//! 会话数据的读取底座:索引(`index.jsonl`)+ 事件流(`.jsonl`)。 +//! +//! 三个端口([`super::session::CoreSessions`] 的列表/切片、[`super::diagnose::CoreDiagnose`] +//! 的规则跑批)共用这一份读取逻辑——「同一份 jsonl 两种读法」若各写一遍, +//! 迟早出现「列表说有 5 条事件、诊断只看到 3 条」这种对不上的账。 +//! +//! 形状依据 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5。 + +use std::path::{Path, PathBuf}; + +use serde_json::Value; +use unlucky_core::launch::session_index::{self, SessionIndexEntry}; + +use unlucky_agent::port::PortResult; + +/// 一个数据根下的会话索引 + 事件流。 +#[derive(Debug, Clone)] +pub struct SessionSource { + root: PathBuf, +} + +impl SessionSource { + /// `root` = `<数据根>/.ULL/data/agent/sessions`。 + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + /// 索引文件路径。 + #[must_use] + pub fn index(&self) -> PathBuf { + session_index::index_path(&self.root) + } + + /// 全部会话(按 `started_at` 倒序,同 id 取最后一行)。 + /// + /// # Errors + /// 索引读不了(权限/IO)时返回用户可读错误;不存在视为空列表。 + pub fn entries(&self) -> PortResult> { + let index = self.index(); + let mut entries = session_index::read_all(&index) + .map_err(|error| format!("读会话索引 {} 失败:{error}", index.display()))?; + entries.sort_by(|a, b| b.started_at.cmp(&a.started_at)); + Ok(entries) + } + + /// 按完整 id 或唯一前缀找会话。 + /// + /// # Errors + /// 找不到 / 前缀歧义时返回用户可读错误。 + pub fn find(&self, key: &str) -> PortResult { + let entries = self.entries()?; + if let Some(entry) = entries.iter().find(|entry| entry.session_id == key) { + return Ok(entry.clone()); + } + let matches: Vec<&SessionIndexEntry> = entries + .iter() + .filter(|entry| entry.session_id.starts_with(key)) + .collect(); + match matches.as_slice() { + [only] => Ok((*only).clone()), + [] => Err(format!( + "找不到会话 `{key}`(索引:{})", + self.index().display() + )), + _ => Err(format!( + "`{key}` 匹配到 {} 个会话,请给更长的前缀", + matches.len() + )), + } + } + + /// 一个会话的原始事件行(解析失败的行跳过,容忍被 kill 时截断的尾行)。 + /// + /// # Errors + /// 事件流文件读不了时返回用户可读错误。 + pub fn events(&self, entry: &SessionIndexEntry) -> PortResult> { + let path = self.event_path(entry); + let text = std::fs::read_to_string(&path) + .map_err(|error| format!("读会话事件流 {} 失败:{error}", path.display()))?; + Ok(text + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect()) + } + + /// 事件流文件路径。 + #[must_use] + pub fn event_path(&self, entry: &SessionIndexEntry) -> PathBuf { + self.root.join("game").join(&entry.log_path) + } + + /// 事件里的 stdout / stderr 文本(诊断与日志阅读共用)。 + /// + /// 返回 `(stdout 行, stderr 行, stderr 里 level=error 的条数)`。 + #[must_use] + pub fn split_text(events: &[Value]) -> (Vec, Vec, u32) { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut error_lines = 0u32; + for event in events { + let Some(line) = event.pointer("/payload/line").and_then(Value::as_str) else { + continue; + }; + match event.get("kind").and_then(Value::as_str) { + Some("stdout") => stdout.push(line.to_string()), + Some("stderr") => { + stderr.push(line.to_string()); + if event.pointer("/payload/level").and_then(Value::as_str) == Some("error") { + error_lines += 1; + } + } + _ => {} + } + } + (stdout, stderr, error_lines) + } +} + +/// 取末尾 `tail` 行并拼成文本(诊断输入用)。 +#[must_use] +pub fn tail_join(lines: &[String], tail: usize) -> String { + let start = lines.len().saturating_sub(tail.max(1)); + let mut text = lines[start..].join("\n"); + if !text.is_empty() { + text.push('\n'); + } + text +} + +/// 索引里有没有这个会话(存在性判断,不读事件流)。 +#[must_use] +pub fn index_exists(root: &Path) -> bool { + session_index::index_path(root).is_file() +} diff --git a/crates/cli/src/commands/agent/ports/game.rs b/crates/cli/src/commands/agent/ports/game.rs new file mode 100644 index 0000000..cd845e6 --- /dev/null +++ b/crates/cli/src/commands/agent/ports/game.rs @@ -0,0 +1,204 @@ +//! 实例 / 版本 / Java 端口(M14 + M33)。 + +use std::path::PathBuf; + +use unlucky_agent::port::game::{ + GamePort, InstanceSummary, JavaRuntimeSummary, JavaStatus, VersionSummary, +}; +use unlucky_agent::port::PortResult; +use unlucky_core::game::java::{self, JavaOrigin}; +use unlucky_core::game::{install::instance::read_profiles, GameLayout}; +use unlucky_core::platform::data_dir::DataMode; +use unlucky_core::platform::layout::LauncherLayout; +use unlucky_core::platform::paths::{Platform, ProcessEnv}; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +/// 实例 / 版本 / Java 的只读端口。 +#[derive(Debug, Clone)] +pub struct CoreGame { + layout: LauncherLayout, + game: GameLayout, + launcher_config: PathBuf, + mode: DataMode, + platform: Platform, +} + +impl CoreGame { + /// 装配:`layout` 是 `.ULL/`,`game` 是 `.minecraft/`。 + #[must_use] + pub fn new( + layout: LauncherLayout, + game: GameLayout, + mode: DataMode, + platform: Platform, + ) -> Self { + let launcher_config = layout.launcher_config(); + Self { + layout, + game, + launcher_config, + mode, + platform, + } + } + + fn store(&self) -> SettingStore { + SettingStore::load(&self.launcher_config, launcher_schema()) + } + + fn managed_roots(&self) -> Vec { + let store = self.store(); + let install_root = store + .doc() + .effective_str("java.installRoot") + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(PathBuf::from); + java::location::scan_roots( + self.mode, + &self.layout, + install_root.as_deref(), + self.platform, + &ProcessEnv, + ) + } +} + +impl GamePort for CoreGame { + fn list_instances(&self) -> PortResult> { + let profiles = + read_profiles(&self.layout).map_err(|error| format!("读实例清单失败:{error}"))?; + let mut out: Vec = profiles + .profiles + .iter() + .map(|profile| InstanceSummary { + id: profile.id.as_str().to_string(), + name: profile.name.clone(), + version_id: profile.version_id.clone(), + version_present: self.game.is_version_installed(&profile.version_id), + game_dir: profile.game_dir.as_ref().map(|p| p.display().to_string()), + java_path: profile.java_path.as_ref().map(|p| p.display().to_string()), + last_played_at: profile.last_played_at.as_deref().and_then(parse_rfc3339_ms), + }) + .collect(); + // `profiles.json` 的顺序是 UI 顺序;这里按最近启动时间倒序(最近的先看)。 + out.sort_by_key(|instance| std::cmp::Reverse(instance.last_played_at)); + Ok(out) + } + + fn list_versions(&self) -> PortResult> { + let versions = self + .game + .installed_versions() + .map_err(|error| format!("读已安装版本失败:{error}"))?; + let profiles = read_profiles(&self.layout).map_err(|error| error.to_string())?; + Ok(versions + .into_iter() + .map(|id| { + let instance_count = profiles + .profiles + .iter() + .filter(|profile| profile.version_id == id) + .count(); + VersionSummary { + id, + kind: None, + instance_count, + } + }) + .collect()) + } + + fn java_status(&self) -> PortResult { + let roots = self.managed_roots(); + let mut candidates = java::candidates_in_roots(&roots); + if let Ok(profiles) = read_profiles(&self.layout) { + for profile in &profiles.profiles { + if let Some(path) = &profile.java_path { + candidates.push(java::probe(path, JavaOrigin::Instance)); + } + } + } + let candidates = java::discover::dedupe(candidates); + let summary: Vec = candidates + .iter() + .map(|runtime| JavaRuntimeSummary { + binary: runtime.binary.display().to_string(), + version: runtime.version.clone(), + major: runtime.major, + vendor: runtime.vendor.clone(), + origin: runtime.origin.category().to_string(), + }) + .collect(); + + let default_memory = self + .store() + .effective("java.default_memory_mb") + .and_then(serde_json::Value::as_u64); + + Ok(JavaStatus { + default_memory_mb: default_memory, + managed_roots: roots + .iter() + .map(|root| root.display().to_string()) + .collect(), + candidates: summary, + }) + } +} + +/// RFC3339 → Unix 毫秒(解析失败返回 `None`,不让一个坏时间戳毁掉整个列表)。 +/// +/// 只处理 launch 写出来的那一种形态(总是 UTC、总是 `Z` 结尾)。不引第三方日期库: +/// agent 侧只需要「排序用的毫秒」,多一棵依赖树不值当。 +fn parse_rfc3339_ms(text: &str) -> Option { + let body = text.trim().trim_end_matches('Z'); + let (date, time) = body.split_once('T')?; + let mut date_parts = date.split('-'); + let year: i64 = date_parts.next()?.parse().ok()?; + let month: i64 = date_parts.next()?.parse().ok()?; + let day: i64 = date_parts.next()?.parse().ok()?; + let mut time_parts = time.split(':'); + let hour: i64 = time_parts.next()?.parse().ok()?; + let minute: i64 = time_parts.next()?.parse().ok()?; + let seconds_text = time_parts.next()?; + let (secs, millis) = match seconds_text.split_once('.') { + Some((secs, frac)) => (secs.parse::().ok()?, { + let padded = format!("{frac:0<3}"); + padded.get(..3)?.parse::().ok()? + }), + None => (seconds_text.parse::().ok()?, 0), + }; + + let days = days_from_civil(year, month, day); + let total_ms = (((days * 24 + hour) * 60 + minute) * 60 + secs) * 1000 + millis; + u64::try_from(total_ms).ok() +} + +/// Howard Hinnant 的 `days_from_civil`(1970-01-01 为 0)。 +fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { + let y = if month <= 2 { year - 1 } else { year }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = (month + 9) % 12; + let doy = (153 * mp + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_launch_timestamps() { + assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:00.000Z"), Some(0)); + // 与既有审计测试同源:1_793_304_753_123 = 2026-10-29T20:12:33.123Z + assert_eq!( + parse_rfc3339_ms("2026-10-29T20:12:33.123Z"), + Some(1_793_304_753_123) + ); + assert_eq!(parse_rfc3339_ms("not-a-time"), None); + } +} diff --git a/crates/cli/src/commands/agent/ports/mod.rs b/crates/cli/src/commands/agent/ports/mod.rs new file mode 100644 index 0000000..195b067 --- /dev/null +++ b/crates/cli/src/commands/agent/ports/mod.rs @@ -0,0 +1,58 @@ +//! 端口装配:把 `unlucky-core` 的实现塞进 agent 的五个端口。 +//! +//! 为什么这一层在 CLI 而不在 agent:agent 只知道 trait(`#!` 依赖倒置), +//! `crates/app` 是 GUI 的装配处,而 `ull agent serve` 是**headless 的另一个装配入口**。 +//! 放这里让 CLI 自洽,也让 GUI 后续复用同一批适配器(把本目录上移到 app 是一次纯搬家)。 +//! +//! 每个文件一个端口,与 `crates/agent/src/port*.rs` 一一对应,读起来不用跳来跳去。 + +pub mod accounts; +pub mod diagnose; +pub mod events; +pub mod game; +pub mod session; +pub mod setting; + +use std::sync::Arc; + +use unlucky_agent::port::Ports; +use unlucky_core::platform::data_dir::DataMode; +use unlucky_core::platform::paths::Platform; + +use crate::context::Ctx; + +pub use accounts::CoreAccounts; +pub use diagnose::CoreDiagnose; +pub use game::CoreGame; +pub use session::CoreSessions; +pub use setting::CoreSetting; + +/// 按一次 CLI 上下文装配五个端口。 +#[must_use] +pub fn build(ctx: &Ctx) -> Ports { + Ports { + // 数据根键(`core.*`)落便携目录那份,与 `Ctx::resolve` 同源——CLI `config set` + // 与 AI `set_config` 共用 `Ctx` 给出的两个落点(finding …-agent-settingport-core-ai)。 + setting: Arc::new(CoreSetting::split( + ctx.launcher_config(), + ctx.portable_launcher_config(), + )), + sessions: Arc::new(CoreSessions::new(ctx.sessions_root())), + diagnose: Arc::new(CoreDiagnose::new(ctx.sessions_root())), + game: Arc::new(CoreGame::new( + ctx.layout(), + ctx.game(), + data_mode(ctx), + platform(ctx), + )), + accounts: Arc::new(CoreAccounts::new(ctx.auth_data())), + } +} + +pub(crate) fn data_mode(ctx: &Ctx) -> DataMode { + ctx.data.mode +} + +pub(crate) fn platform(ctx: &Ctx) -> Platform { + ctx.platform +} diff --git a/crates/cli/src/commands/agent/ports/session.rs b/crates/cli/src/commands/agent/ports/session.rs new file mode 100644 index 0000000..773fc85 --- /dev/null +++ b/crates/cli/src/commands/agent/ports/session.rs @@ -0,0 +1,201 @@ +//! 会话端口:索引 → `list_game_sessions`,事件流切片 → `read_game_log`。 + +use serde_json::{json, Value}; + +use unlucky_agent::port::{ + GameSessionPort, GameSessionSummary, LogEvent, LogPage, LogQuery, PortResult, +}; + +use super::events::SessionSource; + +/// M18 的会话索引 + 事件流。 +#[derive(Debug, Clone)] +pub struct CoreSessions { + source: SessionSource, +} + +impl CoreSessions { + /// `root` = `<数据根>/.ULL/data/agent/sessions`。 + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { + source: SessionSource::new(root), + } + } +} + +fn summary_of( + entry: &unlucky_core::launch::session_index::SessionIndexEntry, +) -> GameSessionSummary { + GameSessionSummary { + session_id: entry.session_id.clone(), + instance_id: Some(entry.instance_id.clone()).filter(|id| !id.is_empty()), + instance_name: Some(entry.instance_name.clone()).filter(|name| !name.is_empty()), + game_version: Some(entry.game_version.clone()).filter(|v| !v.is_empty()), + started_at: Some(entry.started_at.clone()).filter(|v| !v.is_empty()), + ended_at: Some(entry.ended_at.clone()).filter(|v| !v.is_empty()), + duration_ms: entry.duration_ms, + exit_code: entry.exit_code, + exit_type: entry.exit_type.clone(), + silent_failure: entry.silent_failure, + window_detected: entry.window_detected, + event_count: entry.event_count, + crash_found: entry.crash_found, + diagnosis_top: entry.diagnosis_top.clone(), + } +} + +fn to_log_event(event: &Value, fallback_seq: u64) -> LogEvent { + LogEvent { + seq: event + .get("seq") + .and_then(Value::as_u64) + .unwrap_or(fallback_seq), + ts: event + .get("ts") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + t_ms: event.get("t_ms").and_then(Value::as_u64).unwrap_or(0), + kind: event + .get("kind") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + payload: event.get("payload").cloned().unwrap_or(Value::Null), + } +} + +impl GameSessionPort for CoreSessions { + fn list_sessions( + &self, + limit: usize, + instance_id: Option<&str>, + ) -> PortResult> { + let mut entries = self.source.entries()?; + if let Some(instance) = instance_id { + entries.retain(|entry| entry.instance_id == instance); + } + entries.truncate(limit); + Ok(entries.iter().map(summary_of).collect()) + } + + fn read_log(&self, session_id: &str, query: &LogQuery) -> PortResult { + let entry = self.source.find(session_id)?; + let raw = self.source.events(&entry)?; + let events: Vec = raw + .iter() + .enumerate() + .map(|(index, event)| to_log_event(event, index as u64)) + .collect(); + + let mut kind_counts = std::collections::BTreeMap::new(); + for event in &events { + *kind_counts.entry(event.kind.clone()).or_insert(0u64) += 1; + } + let matched: Vec<&LogEvent> = events + .iter() + .filter(|e| query.from_seq.is_none_or(|from| e.seq >= from)) + .filter(|e| query.to_seq.is_none_or(|to| e.seq <= to)) + .filter(|e| query.kinds.is_empty() || query.kinds.contains(&e.kind)) + .collect(); + let max = if query.max_events == 0 { + matched.len() + } else { + query.max_events + }; + let truncated = matched.len() > max; + + Ok(LogPage { + session_id: entry.session_id, + total_events: events.len() as u64, + matched_events: matched.len() as u64, + truncated, + kind_counts, + first_ts: events.first().map(|e| e.ts.clone()), + last_ts: events.last().map(|e| e.ts.clone()), + events: matched.into_iter().take(max).cloned().collect(), + }) + } +} + +/// 会话摘要 + 事件流 → JSON 里用得到的稳定形状(`read_game_log` 的 payload 直接透传)。 +#[must_use] +pub fn log_event_json(event: &LogEvent) -> Value { + json!({ + "seq": event.seq, + "ts": event.ts, + "t_ms": event.t_ms, + "kind": event.kind, + "payload": event.payload, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn seed_session(root: &Path) { + let game_dir = root.join("game"); + std::fs::create_dir_all(&game_dir).expect("建目录"); + let entry = json!({ + "v": 1, + "session_id": "20261029T124501-9f3c2a", + "instance_id": "inst-1", + "instance_name": "1.20.1 Forge", + "game_version": "1.20.1", + "started_at": "2026-10-29T12:45:01.123Z", + "ended_at": "2026-10-29T12:46:31.123Z", + "duration_ms": 90000, + "exit_code": 1, + "exit_type": "application_error", + "silent_failure": false, + "window_detected": true, + "event_count": 2, + "log_path": "20261029T124501-9f3c2a.jsonl", + "crash_found": true, + "diagnosis_top": "integrity.forge_missing_library" + }); + std::fs::write(game_dir.join("index.jsonl"), format!("{entry}\n")).expect("写索引"); + let events = [ + json!({"seq":0,"ts":"2026-10-29T12:45:01.123Z","t_ms":0,"kind":"stdout","payload":{"line":"hi","level":"info"}}), + json!({"seq":1,"ts":"2026-10-29T12:45:02.123Z","t_ms":1000,"kind":"stderr","payload":{"line":"boom","level":"error"}}), + ]; + let text: String = events + .iter() + .map(|event| format!("{event}\n")) + .collect::(); + std::fs::write(game_dir.join("20261029T124501-9f3c2a.jsonl"), text).expect("写事件"); + } + + #[test] + fn lists_and_slices_a_session() { + let root = std::env::temp_dir().join(format!("ull-mcp-sessions-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + seed_session(&root); + + let port = CoreSessions::new(root.clone()); + let sessions = port.list_sessions(10, None).expect("应能列出"); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].exit_code, Some(1)); + assert!(sessions[0].crash_found); + + let page = port + .read_log( + "20261029T124501", + &LogQuery { + max_events: 1, + kinds: vec!["stderr".to_string()], + ..LogQuery::default() + }, + ) + .expect("应能读"); + assert_eq!(page.total_events, 2); + assert_eq!(page.events.len(), 1); + assert_eq!(page.events[0].kind, "stderr"); + assert!(page.kind_counts.contains_key("stdout")); + + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/crates/cli/src/commands/agent/ports/setting.rs b/crates/cli/src/commands/agent/ports/setting.rs new file mode 100644 index 0000000..46f846b --- /dev/null +++ b/crates/cli/src/commands/agent/ports/setting.rs @@ -0,0 +1,211 @@ +//! 配置端口:`core::setting` 的直译(**唯一真相源**在 core,这里不复制校验规则)。 +//! +//! 三条纪律: +//! 1. 每次调用都重新 `SettingStore::load`——外部 MCP 客户端可能刚改了配置, +//! 缓存一份快照会让审计里的 `old` 骗人; +//! 2. **敏感值一律掩码**(M12 `Sensitivity::High` + agent 的凭据词表两道): +//! 工具结果会回灌模型、也可能发往远端 LLM,AI 只需要知道「这个键是什么、怎么改」; +//! 3. **落点与 `ull config` 同源**(分流规则仍只在 `crate::context` 一处):数据根键 +//! 读写便携目录那份 `launcher.json`,其余键读写解析后的数据根。只持一份路径时 +//! standard / `--data-dir` 模式下 AI 改 `core.data_mode` 会「写了没反应」 +//! (finding 20261031-worker-chore40-improve-agent-settingport-core-ai)。 + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Map, Value}; +use unlucky_agent::confirm::Sensitivity; +use unlucky_agent::port::{PortResult, SettingChange, SettingKey, SettingPort}; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::spec::{Sensitivity as CoreSensitivity, ValueKind}; +use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason}; + +use crate::context::is_data_root_key; + +/// 掩码占位(与 CLI `config get` 同一串,读输出的人不用学两套)。 +pub const MASK: &str = ""; + +/// 把 `core::setting` 包成 agent 的 [`SettingPort`]。 +#[derive(Debug, Clone)] +pub struct CoreSetting { + /// 解析后的数据根那份 `launcher.json`(非数据根键的落点)。 + path: PathBuf, + /// 便携目录那份 `launcher.json`(数据根键的落点);与 `path` 重合或拿不到 + /// exe 目录时为 `None`。 + core_path: Option, +} + +impl CoreSetting { + /// 只指向数据根那份 `<数据根>/.ULL/config/launcher.json`(两份落点重合时够用)。 + #[must_use] + pub fn new(path: impl Into) -> Self { + Self::split(path, None) + } + + /// 数据根那份 + 便携目录那份(`Ctx::portable_launcher_config`,与数据根解析同源)。 + /// + /// 两份路径重合(便携模式)时只留一份,免得同一个文件被加载两次。 + #[must_use] + pub fn split(path: impl Into, core_path: Option) -> Self { + let path = path.into(); + let core_path = core_path.filter(|core| core.as_path() != path.as_path()); + Self { path, core_path } + } + + /// 某个键的落点文件(数据根键 → 便携目录那份,其余 → 数据根那份)。 + fn path_for(&self, key: &str) -> &Path { + match &self.core_path { + Some(core) if is_data_root_key(key) => core.as_path(), + _ => self.path.as_path(), + } + } + + /// 按落点加载 store(读写都用它,`get`/`set` 不会各看一份文件)。 + fn store_for(&self, key: &str) -> SettingStore { + SettingStore::load(self.path_for(key), launcher_schema()) + } + + /// 数据根那份(schema / 白名单等与落点无关的元数据都从它出)。 + fn store(&self) -> SettingStore { + SettingStore::load(&self.path, launcher_schema()) + } +} + +/// `summary` 里某个键的**原始**取值来源(掩码在 `summary` 里做):数据根键取便携目录 +/// 那份——也就是 `Ctx::resolve` 读的同一份,否则概要和「真正生效值」是两套真相。 +fn raw_value<'a>( + data: &'a SettingStore, + core: Option<&'a SettingStore>, + key: &str, +) -> Option<&'a Value> { + match core { + Some(core) if is_data_root_key(key) => core.effective(key), + _ => data.effective(key), + } +} + +fn sensitivity_of(value: CoreSensitivity) -> Sensitivity { + match value { + CoreSensitivity::Low => Sensitivity::Low, + CoreSensitivity::Medium => Sensitivity::Medium, + CoreSensitivity::High => Sensitivity::High, + } +} + +fn kind_label(kind: &ValueKind) -> &'static str { + match kind { + ValueKind::Bool => "boolean", + ValueKind::Int { .. } => "integer", + ValueKind::Float { .. } => "number", + ValueKind::Text { .. } => "string", + ValueKind::Path => "path", + ValueKind::Enum(_) => "enum", + ValueKind::TextList => "string[]", + ValueKind::EnumList(_) => "enum[]", + ValueKind::Any => "json", + } +} + +/// 该键的值要不要在 AI 侧掩码:凭据词表命中,或 schema 标 `High`。 +fn mask(value: Value, sensitive: bool) -> Value { + if sensitive { + json!(MASK) + } else { + value + } +} + +impl SettingPort for CoreSetting { + /// 全量概要:每个键的生效值都取自**它自己的落点文件**(数据根键取便携目录那份)。 + fn summary(&self) -> Value { + let store = self.store(); + let core = self + .core_path + .as_ref() + .map(|path| SettingStore::load(path, launcher_schema())); + let schema = store.doc().schema().clone(); + let mut map = Map::new(); + for spec in schema.specs() { + if spec.is_deprecated() { + continue; + } + let sensitive = spec.ai.sensitivity == CoreSensitivity::High + || unlucky_agent::redact::is_sensitive_key(spec.key); + let value = raw_value(&store, core.as_ref(), spec.key) + .cloned() + .unwrap_or(Value::Null); + map.insert(spec.key.to_string(), mask(value, sensitive)); + } + Value::Object(map) + } + + fn get(&self, path: &str) -> PortResult { + if unlucky_agent::redact::is_sensitive_key(path) { + return Err(format!( + "`{path}` 是凭据类配置,AI 不可读取也不可写入;请让用户自己在设置页查看" + )); + } + let store = self.store_for(path); + let schema = store.doc().schema().clone(); + let Some(value) = store.effective(path).cloned() else { + return Err(format!("配置键 `{path}` 不在 schema 里")); + }; + let sensitive = schema.sensitivity(path) == Some(CoreSensitivity::High); + Ok(mask(value, sensitive)) + } + + fn set(&self, path: &str, value: &Value) -> PortResult { + let file = self.path_for(path); + let mut store = self.store_for(path); + let schema = store.doc().schema().clone(); + let sensitive = schema.sensitivity(path) == Some(CoreSensitivity::High); + let old = store + .effective(path) + .cloned() + .map(|old| mask(old, sensitive)); + store + .set(path, value.clone()) + .map_err(|error| error.to_string())?; + match store.save() { + Ok(SaveOutcome::Saved(_)) | Ok(SaveOutcome::Skipped(SkipReason::NotDirty)) => {} + // 只读/不可信:**不能**报成功——外部客户端会以为改完了。 + Ok(SaveOutcome::Skipped(reason)) => { + return Err(format!( + "{} 当前不可写({});请让用户检查文件权限或内容", + file.display(), + reason.as_str() + )) + } + Err(error) => return Err(format!("写 {} 失败:{error}", file.display())), + } + Ok(SettingChange { + path: path.to_string(), + old, + new: mask(value.clone(), sensitive), + }) + } + + fn writable_keys(&self) -> Vec { + let store = self.store(); + let schema = store.doc().schema().clone(); + schema + .specs() + .iter() + .filter(|spec| spec.ai.writable && !spec.is_deprecated()) + .map(|spec| SettingKey { + path: spec.key.to_string(), + sensitivity: sensitivity_of(spec.ai.sensitivity), + value_type: kind_label(&spec.kind).to_string(), + description: spec.doc.to_string(), + }) + .collect() + } + + fn schema(&self) -> Value { + // core 的 `to_json_schema()` 只遍历 `ai.writable && !deprecated`, + // 凭据键(`locked`)天然不在其中——这正是 `get_config_schema` 要的东西。 + self.store().doc().schema().to_json_schema() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/cli/src/commands/agent/ports/setting/tests.rs b/crates/cli/src/commands/agent/ports/setting/tests.rs new file mode 100644 index 0000000..2e84077 --- /dev/null +++ b/crates/cli/src/commands/agent/ports/setting/tests.rs @@ -0,0 +1,226 @@ +//! [`super`] 的单测:数据根键与其余键的落点分流、AI 侧写入真的生效。 +//! +//! 与 `commands/config/tests.rs` 同一套造场手法:临时目录 + `Ctx::resolve_in` +//! 把便携目录钉死在临时路径里,不动真实数据根。 + +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::*; +use crate::cli::Globals; +use crate::commands::agent::ports::build; +use crate::context::Ctx; + +fn temp_config() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-core-setting-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir.join("launcher.json") +} + +#[test] +fn schema_omits_credential_keys_and_writable_keys_match_policy() { + let port = CoreSetting::new(temp_config()); + let schema = port.schema(); + let text = schema.to_string(); + assert!(!text.contains("ai.api_key"), "凭据键不得进 AI schema"); + assert!(text.contains("agent.mcp.port"), "M39 的新键应可写:{text}"); + + let paths: Vec = port.writable_keys().into_iter().map(|k| k.path).collect(); + assert!(paths.contains(&"download.concurrency".to_string())); + assert!(!paths.contains(&"ai.api_key".to_string())); +} + +#[test] +fn summary_masks_high_sensitivity_but_keeps_low() { + let port = CoreSetting::new(temp_config()); + let summary = port.summary(); + assert_eq!(summary["ai.base_url"], json!(MASK), "High 键要掩码"); + assert_ne!( + summary["download.concurrency"], + json!(MASK), + "Low 键不必掩码" + ); +} + +#[test] +fn credential_key_get_is_refused() { + let port = CoreSetting::new(temp_config()); + let err = port.get("ai.api_key").unwrap_err(); + assert!(err.contains("凭据类配置"), "{err}"); +} + +#[test] +fn set_then_get_round_trips_a_low_key() { + let path = temp_config(); + let port = CoreSetting::new(path.clone()); + let change = port + .set("download.concurrency", &json!(16)) + .expect("应写入"); + assert_eq!(change.new, json!(16)); + assert_eq!(port.get("download.concurrency").unwrap(), json!(16)); + assert!(path.is_file(), "应落盘 {}", path.display()); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。 +struct TempDir(PathBuf); + +impl TempDir { + fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("ull-ai-setting-{tag}-{}-{seq}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("建临时目录"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// 正确的 `$schema`(认不出的前缀会让加载降级成只读)。 +fn schema_url() -> String { + use unlucky_core::setting::keys::launcher_version; + use unlucky_core::setting::schema::{SchemaId, SchemaRef}; + SchemaRef::new(SchemaId::LAUNCHER, launcher_version()).url() +} + +fn write_json(path: &Path, value: &Value) { + std::fs::create_dir_all(path.parent().expect("有父目录")).expect("建目录"); + std::fs::write(path, value.to_string()).expect("写配置"); +} + +fn read_json(path: &Path) -> Value { + let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("读 {} 失败:{e}", path.display())); + serde_json::from_slice(&bytes).expect("合法 JSON") +} + +/// standard 模式现场:便携目录那份说 standard + 自定义 launcher home(数据根留在临时目录里)。 +fn standard_fixture(temp: &TempDir) -> (PathBuf, PathBuf, PathBuf) { + let exe = temp.path().join("exe"); + let launcher_home = temp.path().join("lhome"); + let portable = exe.join(".ULL").join("config").join("launcher.json"); + write_json( + &portable, + &json!({ + "$schema": schema_url(), + "schemaVersion": 1, + "core": { + "data_mode": "standard", + "launcher_home": launcher_home.display().to_string(), + }, + }), + ); + (exe, portable, launcher_home) +} + +/// **AI 面改 `core.data_mode` 真的生效**:写便携目录那份,下一次解析换模式。 +/// +/// 走的是 `ports::build` 装出来的同一个端口(`set_config` 工具背后就是它)。 +#[test] +fn ai_set_of_core_data_mode_takes_effect_on_the_next_resolve() { + let temp = TempDir::new("core-ai"); + let (exe, portable, launcher_home) = standard_fixture(&temp); + let ctx = Ctx::resolve_in(&Globals::default(), Some(exe.clone())).expect("解析数据根"); + assert_eq!(ctx.data.mode.as_str(), "standard"); + let ports = build(&ctx); + + ports + .setting + .set("core.data_mode", &json!("portable")) + .expect("AI 侧写 core.data_mode"); + + assert_eq!( + read_json(&portable)["core"]["data_mode"], + json!("portable"), + "数据根键要落在便携目录那份" + ); + let data_file = launcher_home.join("config").join("launcher.json"); + assert!( + !data_file.exists() || read_json(&data_file)["core"].get("data_mode").is_none(), + "数据根那份不该被写入 core.data_mode" + ); + + let next = Ctx::resolve_in(&Globals::default(), Some(exe)).expect("重新解析"); + assert_eq!( + next.data.mode.as_str(), + "portable", + "改了没反应到此为止:下一次解析必须换模式" + ); + + // 其余键仍走数据根那份,且不污染便携目录那份。 + ports + .setting + .set("download.concurrency", &json!(16)) + .expect("AI 侧写普通键"); + assert_eq!(read_json(&data_file)["download"]["concurrency"], json!(16)); + assert!( + read_json(&portable)["download"] + .get("concurrency") + .is_none(), + "普通键不该写进便携目录那份" + ); +} + +/// 概要的取值来源与解析同源:数据根键取便携那份(哪怕数据根那份留着陈旧值)。 +#[test] +fn summary_takes_data_root_keys_from_the_portable_file() { + let temp = TempDir::new("core-summary"); + let (exe, portable, launcher_home) = standard_fixture(&temp); + // 老版本 AI 写错文件留下的现场:数据根那份有个陈旧的 core.data_mode。 + let data_file = launcher_home.join("config").join("launcher.json"); + write_json( + &data_file, + &json!({ + "$schema": schema_url(), + "schemaVersion": 1, + "core": { "data_mode": "custom" }, + "download": { "concurrency": 4 }, + }), + ); + + let ctx = Ctx::resolve_in(&Globals::default(), Some(exe)).expect("解析数据根"); + let port = CoreSetting::split(ctx.launcher_config(), ctx.portable_launcher_config()); + let data = port.store(); + let core = SettingStore::load(&portable, launcher_schema()); + + assert_eq!( + raw_value(&data, Some(&core), "core.data_mode"), + Some(&json!("standard")), + "数据根键取便携那份 = 解析读到的那份" + ); + assert_eq!( + raw_value(&data, Some(&core), "download.concurrency"), + Some(&json!(4)), + "其余键仍取数据根那份" + ); + // `core.*` 是 M12 的 High 键,概要里照旧掩码(值不可见,但来源必须对)。 + assert_eq!(port.summary()["core.data_mode"], json!(MASK)); +} + +/// 便携模式下两份落点本就是同一份文件:不留第二份,免得重复加载。 +#[test] +fn portable_mode_keeps_a_single_landing_file() { + let temp = TempDir::new("core-portable"); + let ctx = + Ctx::resolve_in(&Globals::default(), Some(temp.path().to_path_buf())).expect("解析数据根"); + assert_eq!(ctx.data.mode.as_str(), "portable"); + let port = CoreSetting::split(ctx.launcher_config(), ctx.portable_launcher_config()); + assert_eq!( + port.path_for("core.data_mode"), + ctx.launcher_config().as_path() + ); +} diff --git a/crates/cli/src/commands/agent/serve.rs b/crates/cli/src/commands/agent/serve.rs new file mode 100644 index 0000000..397b7b3 --- /dev/null +++ b/crates/cli/src/commands/agent/serve.rs @@ -0,0 +1,218 @@ +//! `ull agent serve`:把注册表挂成 MCP server,长驻运行。 +//! +//! 装配顺序(每一步都对应 02 文档的一条约束): +//! 1. 设置 → `McpConfig`(`agent.mcp.*`,命令行可覆盖); +//! 2. 端口([`super::ports`])→ 注册表(`tools::build_v1_registry`); +//! 3. 只读模式 → `read_only_subset()`(物理上不含写工具,列表/调用天然一致); +//! 4. 执行器:`JsonlAudit`(落盘 `/agent/audit`)+ fail-closed 闸门 + +//! `ConfirmPolicy { yolo }`(读 `ai.yolo`)+ `DenyGuidance::External`(没有弹窗可点); +//! 5. 传输:stdio 或 Streamable HTTP(127.0.0.1)。 + +use std::sync::Arc; + +use serde_json::Value; +use unlucky_agent::audit::{audit_dir, JsonlAudit}; +use unlucky_agent::brain::exec::ToolExecutor; +use unlucky_agent::clock::SystemClock; +use unlucky_agent::confirm::{ConfirmPolicy, DenyGuidance, NoUiGate}; +use unlucky_agent::event::NoopSink; +use unlucky_agent::mcp::{serve_http, serve_stdio, McpConfig, RegistryServer}; +use unlucky_agent::tools::build_v1_registry; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +use crate::cli::AgentServeArgs; +use crate::context::Ctx; +use crate::error::CliError; + +/// 起一次 server(阻塞直到进程被杀/stdio 对端关闭)。 +/// +/// # Errors +/// 注册表装配失败、端口被占用、非回环监听等返回用户错误。 +pub fn run(ctx: &Ctx, args: &AgentServeArgs) -> Result { + let settings = Settings::load(ctx); + let config = settings.mcp_config(args); + let yolo = settings.yolo(); + let read_only = config.read_only; + if !settings.enabled { + eprintln!( + "[unlucky-agent] 提示:设置键 agent.mcp.enabled=false,本次是手动启动(GUI 不会自动起)" + ); + } + + let ports = super::ports::build(ctx); + let registry = Arc::new( + build_v1_registry(&ports) + .map_err(|error| CliError::internal("agent_registry", error.to_string()))?, + ); + // 路由注册表始终是完整的那份;「哪些工具真能跑」由执行器持有的注册表决定 + // (只读模式下是 read_only_subset):硬调写工具会得到可读的 tool-level 错误 + 审计, + // 而不是 rmcp 的协议级 -32602(客户端只会显示 "internal error")。 + let exposed = if read_only { + Arc::new(registry.read_only_subset()) + } else { + Arc::clone(®istry) + }; + + let audit = Arc::new(JsonlAudit::new( + audit_dir(&ctx.layout().data()), + Arc::new(SystemClock), + )); + let executor = Arc::new( + ToolExecutor::new( + Arc::clone(&exposed), + audit, + Arc::new(NoUiGate), + ConfirmPolicy { yolo }, + Arc::new(SystemClock), + Arc::new(NoopSink), + ) + .with_guidance(DenyGuidance::External), + ); + + let server = RegistryServer::new(registry, executor, transport_label(args), read_only); + if args.stdio { + // stdio:stdout 是协议流,人类提示一律走 stderr。 + eprintln!( + "[unlucky-agent] MCP server 走 stdio({} 个工具;写权限:{};YOLO:{})", + server.tool_count(), + if read_only { "关(只读)" } else { "开" }, + yolo + ); + return block_on(async move { serve_stdio(server).await }) + .map(|()| crate::error::EXIT_OK) + .map_err(|error| CliError::user("mcp_stdio", error.to_string())); + } + + let url = config.url(); + eprintln!("[unlucky-agent] MCP 端点:{url}"); + block_on(async move { serve_http(server, config).await }) + .map(|()| crate::error::EXIT_OK) + .map_err(|error| CliError::user("mcp_http", error.to_string())) +} + +fn transport_label(args: &AgentServeArgs) -> &'static str { + if args.stdio { + "mcp-stdio" + } else { + "mcp-http" + } +} + +/// 在独立线程里跑一个 tokio 运行时(stdio 形态要独占 stdin/stdout,不能被主线程抢)。 +fn block_on(future: F) -> Result +where + F: std::future::Future> + Send + 'static, + T: Send + 'static, +{ + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| unlucky_agent::mcp::McpServeError::Io(error.to_string()))?; + runtime.block_on(future) +} + +/// 从 `launcher.json` 读 MCP 相关设置(坏文件退化成默认值,不阻断启动)。 +struct Settings { + enabled: bool, + port: u16, + allow_write: bool, + token: Option, + yolo: bool, +} + +impl Settings { + fn load(ctx: &Ctx) -> Self { + let store = SettingStore::load(ctx.launcher_config(), launcher_schema()); + let doc = store.doc(); + let port = doc + .effective("agent.mcp.port") + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .unwrap_or(47823); + Self { + enabled: doc + .effective("agent.mcp.enabled") + .and_then(Value::as_bool) + .unwrap_or(false), + port, + allow_write: doc + .effective("agent.mcp.allow_write") + .and_then(Value::as_bool) + .unwrap_or(false), + token: doc + .effective_str("agent.mcp.token") + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_string), + yolo: doc + .effective("ai.yolo") + .and_then(Value::as_bool) + .unwrap_or(false), + } + } + + fn mcp_config(&self, args: &AgentServeArgs) -> McpConfig { + McpConfig { + host: args.host.clone().unwrap_or_else(|| "127.0.0.1".to_string()), + port: args.port.unwrap_or(self.port), + token: self.token.clone(), + // 命令行 --allow-write 只能**收紧或放开到设置允许的程度**: + // 设置里没开写权限时,命令行也不放开(设置是用户的意思表示,命令行是运行参数)。 + read_only: !(self.allow_write || args.allow_write), + } + } + + fn yolo(&self) -> bool { + self.yolo + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args() -> AgentServeArgs { + AgentServeArgs::default() + } + + #[test] + fn settings_default_to_read_only_on_47823() { + // Settings 需要 Ctx(要真实数据根),这里只测「无设置时的合成规则」: + // 用 Default 的字段逐项对照,避免为此造一个假 Ctx。 + let defaults = Settings { + enabled: false, + port: 47823, + allow_write: false, + token: None, + yolo: false, + }; + let config = defaults.mcp_config(&args()); + assert_eq!(config.port, 47823); + assert!(config.read_only); + assert_eq!(config.host, "127.0.0.1"); + } + + #[test] + fn cli_flags_override_port_and_write_switch() { + let settings = Settings { + enabled: true, + port: 47823, + allow_write: true, + token: Some("t".to_string()), + yolo: true, + }; + let overridden = settings.mcp_config(&AgentServeArgs { + port: Some(50000), + allow_write: false, + ..AgentServeArgs::default() + }); + assert_eq!(overridden.port, 50000); + assert!(!overridden.read_only, "设置里允许写时命令行不必再开"); + assert_eq!(overridden.token.as_deref(), Some("t")); + assert!(settings.yolo()); + // `enabled` 只是 GUI 的自动启动开关:命令行显式 `agent serve` 不看它 + // (所以这里 enabled=true 既不影响 mcp_config,也不影响能不能起服务)。 + assert!(settings.enabled); + } +} diff --git a/crates/cli/src/commands/config.rs b/crates/cli/src/commands/config.rs new file mode 100644 index 0000000..f6598f1 --- /dev/null +++ b/crates/cli/src/commands/config.rs @@ -0,0 +1,341 @@ +//! `config get|set`:走 `core::setting` 的 schema API(单一真相源)。 +//! +//! # 落点(M40:与数据根解析同源) +//! +//! - **数据根键**([`DATA_ROOT_KEYS`]:`core.data_mode` / `core.data_custom_root` / +//! `core.launcher_home` / `core.game_home`)→ 便携目录那份 `launcher.json` +//! (`/.ULL/config/launcher.json`)。这正是 `Ctx::resolve` 决定数据根时读的 +//! 同一份文件(落点由 `Ctx::setting_path_for` 给出),所以 `config get core.data_mode` +//! 展示的就是真正生效的值,`config set` 也真的影响下一次解析; +//! - 其余键 → 解析后的数据根(`/.ULL/config/launcher.json`)。 +//! +//! 之前两条路各看一份文件:`--data-dir` / standard 模式下「改了 `core.data_mode` 没反应、 +//! `config get` 还报另一份文件里的值」(finding `…-improve-ull-config-get-set-launcher-json`)。 +//! +//! 实例级配置(`config/instances/.json`)留给实例管理 mission,这里不半途实现。 +//! +//! **凭据纪律**(D9 / `02-ai-agent-design.md` §3.1):schema 标 `High` 的敏感键 +//! (`ai.api_key` / `download.proxy_url` / `java.extra_args` …)在 read 路径上一律掩码—— +//! 单键 get、无键全量 dump、set 的旧值回显**三条**都要掩,`--show-secret` 才放行明文。 +//! 判定叠两道:schema 的 `Sensitivity::High` + agent 的 `redact::is_sensitive_key` 词表。 + +use serde_json::{json, Map, Value}; +use unlucky_core::setting::catalog::SettingSchema; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::spec::Sensitivity; +use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason}; + +use crate::context::{is_data_root_key, Ctx, DATA_ROOT_KEYS}; +use crate::error::CliError; +use crate::output::Report; + +/// 敏感值在输出里的占位。 +const MASK: &str = ""; + +/// `ull config get [] [--show-secret]`。 +/// +/// # Errors +/// +/// 未知键、配置不可读时返回用户错误。 +pub fn get(ctx: &Ctx, key: Option<&str>, show_secret: bool) -> Result { + match key { + Some(key) => get_one(ctx, key, show_secret), + None => dump(ctx, show_secret), + } +} + +/// 单键 get:读该键自己的落点文件(数据根键走便携目录那份)。 +fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result { + let path = ctx.setting_path_for(key); + let store = SettingStore::load(&path, launcher_schema()); + let schema = store.doc().schema().clone(); + + let Some(value) = store.effective(key) else { + return Err( + CliError::user("unknown_key", format!("配置键 `{key}` 不在 schema 里")) + .with_hint("`ull config get`(不带键)可以看到全部生效键"), + ); + }; + + let sensitive = is_sensitive(&schema, key); + let hidden = sensitive && !show_secret; + let shown = if hidden { + Value::String(MASK.to_string()) + } else { + value.clone() + }; + let problems = render_problems(&store); + let data = json!({ + "key": key, + "value": shown, + "redacted": hidden, + "sensitive": sensitive, + "path": path.display().to_string(), + "problems": problems, + }); + let mut human = vec![format!( + "{key} = {}{}", + render_value(&shown), + if hidden { + "(敏感键;加 --show-secret 显示明文)" + } else { + "" + } + )]; + human.push(format!("来自 {}", path.display())); + for problem in &problems { + human.push(format!("问题:{problem}")); + } + human.extend(source_note(ctx, key)); + Ok(Report::new(data, human)) +} + +/// 无键全量 dump:以数据根那份为底,`core.*` 数据根键换成便携目录那份的生效值。 +fn dump(ctx: &Ctx, show_secret: bool) -> Result { + let path = ctx.launcher_config(); + let store = SettingStore::load(&path, launcher_schema()); + let schema = store.doc().schema().clone(); + + let core_path = ctx + .portable_launcher_config() + .filter(|core| core.as_path() != path.as_path()); + let core_store = core_path + .as_ref() + .map(|core| SettingStore::load(core, launcher_schema())); + + let mut document = store.doc().to_value(); + if let Some(core) = &core_store { + // 只叠加**便携目录那份里真有的键**(默认值不落盘,dump 也只展示文件里有的键); + // 生效数据根在下面的 `data_root` 里单独给,不靠这里的默认值推。 + for key in DATA_ROOT_KEYS { + if let Some(value) = core.doc().get(key) { + overlay(&mut document, key, value.clone()); + } + } + } + let mut problems = render_problems(&store); + if let Some(core) = &core_store { + problems.extend(render_problems(core)); + } + + let (document, masked) = if show_secret { + (document, false) + } else { + mask_document(&schema, &document) + }; + + let text = match serde_json::to_string_pretty(&document) { + Ok(text) => text, + Err(error) => return Err(CliError::internal("config_serialize", error.to_string())), + }; + let mut human: Vec = Vec::new(); + human.push(format!("配置 {}({:?})", path.display(), store.compat())); + if let Some(core) = &core_path { + human.push(format!("数据根键(core.*)读的是 {}", core.display())); + } + human.push(format!( + "数据根:{}(launcher home {},游戏目录 {})", + ctx.data.mode.as_str(), + ctx.data.launcher_home.display(), + ctx.data.game_home.display() + )); + human.extend(text.lines().map(str::to_string)); + for problem in &problems { + human.push(format!("问题:{problem}")); + } + if masked { + human.push("(敏感值已掩码;加 --show-secret 显示明文)".to_string()); + } + Ok(Report::new( + json!({ + "path": path.display().to_string(), + "core_path": core_path.as_ref().map(|core| core.display().to_string()), + "config": document, + "redacted": masked, + "problems": problems, + "data_root": { + "mode": ctx.data.mode.as_str(), + "launcher_home": ctx.data.launcher_home.display().to_string(), + "game_home": ctx.data.game_home.display().to_string(), + }, + }), + human, + )) +} + +/// `ull config set `。 +/// +/// 敏感键的**旧值回显要掩码**(新值是用户刚敲进来的,照常回显)。 +/// +/// # Errors +/// +/// 键不在 schema、值不合法、文件只读/不可读、写盘失败时返回用户错误。 +pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result { + let path = ctx.setting_path_for(key); + let mut store = SettingStore::load(&path, launcher_schema()); + let sensitive = is_sensitive(store.doc().schema(), key); + let value = parse_value(raw); + let old = store.effective(key).cloned().map(|old| { + if sensitive { + Value::String(MASK.to_string()) + } else { + old + } + }); + store.set(key, value.clone()).map_err(|error| { + CliError::user("config_set", error.to_string()) + .with_hint(format!("键与取值范围见 {}", path.display())) + })?; + + let outcome = store.save().map_err(|error| { + CliError::user( + "config_save", + format!("写 {} 失败:{error}", path.display()), + ) + })?; + match &outcome { + SaveOutcome::Saved(saved) => { + let data = json!({ + "key": key, + "old": old, + "new": value, + "saved": saved.display().to_string(), + }); + let mut human = vec![ + format!( + "{key} = {}(原值 {})", + render_value(&value), + render_value(&old.unwrap_or(Value::Null)) + ), + format!("已保存:{}", saved.display()), + ]; + human.extend(source_note(ctx, key)); + Ok(Report::new(data, human)) + } + SaveOutcome::Skipped(SkipReason::NotDirty) => Ok(Report::new( + json!({ "key": key, "new": value, "saved": null }), + vec![format!("{key} 已经是该值,未写盘")], + )), + SaveOutcome::Skipped(reason) => { + let error = CliError::user( + "config_read_only", + format!("{} 当前不可写({})", path.display(), reason.as_str()), + ); + // 不可写常常不是权限问题,而是文件内容让加载降级(`$schema` 认不出 / 版本更高)。 + let problems = render_problems(&store); + Err(if problems.is_empty() { + error.with_hint("修好文件权限/内容后重试") + } else { + error.with_hint(problems.join(";")) + }) + } + } +} + +/// 数据根键的落点说明(其余键不需要:它们的落点就是数据根本身)。 +fn source_note(ctx: &Ctx, key: &str) -> Option { + if !is_data_root_key(key) { + return None; + } + if ctx.data_dir_forced() { + return Some( + "本次由 --data-dir 指定数据根:该键在下次不带 --data-dir 运行时生效".to_string(), + ); + } + if ctx.setting_path_for(key) != ctx.launcher_config() { + return Some("该键决定数据根,落点是便携目录那份(与数据根解析同源)".to_string()); + } + None +} + +/// 加载问题 → 人读一行(`problems` 通道与人类可读输出共用)。 +fn render_problems(store: &SettingStore) -> Vec { + store.problems().iter().map(ToString::to_string).collect() +} + +/// 把 `key` 的生效值写进 dump 文档(点分键 → 嵌套对象)。 +fn overlay(root: &mut Value, key: &str, value: Value) { + let mut segments = key.split('.').filter(|part| !part.is_empty()); + let Some(head) = segments.next() else { + return; + }; + let rest: Vec<&str> = segments.collect(); + let Some(map) = root.as_object_mut() else { + return; + }; + if rest.is_empty() { + map.insert(head.to_string(), value); + return; + } + overlay( + map.entry(head.to_string()) + .or_insert_with(|| Value::Object(Map::new())), + &rest.join("."), + value, + ); +} + +/// 值先按 JSON 解析(数字/布尔/对象),失败按字符串。 +fn parse_value(raw: &str) -> Value { + serde_json::from_str::(raw).unwrap_or_else(|_| Value::String(raw.to_string())) +} + +/// 敏感键:schema 标 `High`,或键名本身命中 agent 的敏感词表(双保险)。 +fn is_sensitive(schema: &SettingSchema, key: &str) -> bool { + matches!(schema.sensitivity(key), Some(Sensitivity::High)) + || unlucky_agent::redact::is_sensitive_key(key) +} + +/// 递归掩码渲染后的配置文档,返回(掩码后的文档,是否掩过)。 +/// +/// `to_value()` 是**嵌套对象**(点分键展开成层级),所以按路径下钻; +/// 命中敏感路径时整棵子树一起掩码,数组元素沿用父路径继续下钻。 +fn mask_document(schema: &SettingSchema, document: &Value) -> (Value, bool) { + mask_at(schema, "", document) +} + +fn mask_at(schema: &SettingSchema, path: &str, value: &Value) -> (Value, bool) { + if !path.is_empty() && is_sensitive(schema, path) { + return (Value::String(MASK.to_string()), true); + } + match value { + Value::Object(map) => { + let mut out = Map::with_capacity(map.len()); + let mut masked = false; + for (key, child) in map { + let child_path = if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }; + let (child, child_masked) = mask_at(schema, &child_path, child); + masked |= child_masked; + out.insert(key.clone(), child); + } + (Value::Object(out), masked) + } + Value::Array(items) => { + let mut masked = false; + let redacted: Vec = items + .iter() + .map(|item| { + let (item, item_masked) = mask_at(schema, path, item); + masked |= item_masked; + item + }) + .collect(); + (Value::Array(redacted), masked) + } + other => (other.clone(), false), + } +} + +fn render_value(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/cli/src/commands/config/tests.rs b/crates/cli/src/commands/config/tests.rs new file mode 100644 index 0000000..62a7a22 --- /dev/null +++ b/crates/cli/src/commands/config/tests.rs @@ -0,0 +1,256 @@ +//! [`super`] 的单测:数据根键与其余键的落点分流、dump 合并、`problems` 通道。 + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{json, Value}; + +use super::{dump, get, set}; +use crate::cli::Globals; +use crate::context::Ctx; + +/// 一次性临时目录(`Drop` 时清理;cli 没有 dev-dependency 的 tempfile)。 +struct TempDir(PathBuf); + +impl TempDir { + fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "ull-config-test-{tag}-{}-{seq}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("建临时目录"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// 正确的 `$schema`(写成真前缀,加载才不会降级)。 +fn schema_url() -> String { + use unlucky_core::setting::keys::launcher_version; + use unlucky_core::setting::schema::{SchemaId, SchemaRef}; + SchemaRef::new(SchemaId::LAUNCHER, launcher_version()).url() +} + +fn write_json(path: &Path, value: &Value) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建目录"); + } + std::fs::write(path, value.to_string()).expect("写配置"); +} + +fn read_json(path: &Path) -> Value { + let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("读 {} 失败:{e}", path.display())); + serde_json::from_slice(&bytes).expect("合法 JSON") +} + +fn ctx_with(globals: Globals, exe_dir: &Path) -> Ctx { + Ctx::resolve_in(&globals, Some(exe_dir.to_path_buf())).expect("解析数据根") +} + +/// 便携模式:两个落点是同一份文件,`core.data_mode` 改了立刻是生效值。 +#[test] +fn portable_mode_keeps_core_keys_in_one_file() { + let temp = TempDir::new("portable"); + let ctx = ctx_with(Globals::default(), temp.path()); + assert_eq!(ctx.data.mode.as_str(), "portable"); + assert_eq!( + ctx.setting_path_for("core.data_mode"), + ctx.launcher_config(), + "便携模式下两份路径本来就该重合" + ); + + set(&ctx, "core.data_mode", "standard").expect("写 core.data_mode"); + let file = temp + .path() + .join(".ULL") + .join("config") + .join("launcher.json"); + assert!(file.is_file(), "应落在 {}", file.display()); + assert_eq!(read_json(&file)["core"]["data_mode"], json!("standard")); + + let report = get(&ctx, Some("core.data_mode"), true).expect("读回来"); + // `core.*` 在 schema 里标了 `AiPolicy::sensitive()`(High),读路径默认掩码成 + // ``,所以要看生效值得像用户那样加 `--show-secret`。 + assert_eq!(report.data["value"], json!("standard")); + assert_eq!( + report.data["path"], + json!(file.display().to_string()), + "read 与 write 必须同一份" + ); +} + +/// standard 模式:数据根键走便携目录那份(与解析同源),其余键走数据根。 +#[test] +fn standard_mode_routes_core_keys_to_the_portable_file() { + let temp = TempDir::new("standard"); + let exe = temp.path().join("exe"); + let portable = exe.join(".ULL").join("config").join("launcher.json"); + let launcher_home = temp.path().join("lhome"); + write_json( + &portable, + &json!({ + "$schema": schema_url(), + "schemaVersion": 1, + "core": { + "data_mode": "standard", + "launcher_home": launcher_home.display().to_string(), + }, + }), + ); + + let ctx = ctx_with(Globals::default(), &exe); + assert_eq!(ctx.data.mode.as_str(), "standard"); + assert_eq!(ctx.data.launcher_home, launcher_home); + assert_ne!( + ctx.setting_path_for("core.data_mode"), + ctx.launcher_config() + ); + + let report = get(&ctx, Some("core.data_mode"), true).expect("读 core.data_mode"); + assert_eq!( + report.data["value"], + json!("standard"), + "get 展示的必须是真正生效的值" + ); + assert_eq!(report.data["path"], json!(portable.display().to_string())); + + // 普通键仍写数据根。 + set(&ctx, "download.concurrency", "16").expect("写 download.concurrency"); + let core_file = ctx.launcher_config(); + assert_eq!(read_json(&core_file)["download"]["concurrency"], json!(16)); + + // 数据根键写便携目录那份,且不动数据根那份的 core.*。 + set(&ctx, "core.data_mode", "portable").expect("写 core.data_mode"); + assert_eq!(read_json(&portable)["core"]["data_mode"], json!("portable")); + assert!( + read_json(&core_file)["core"].get("data_mode").is_none(), + "数据根那份不该被写入 core.data_mode" + ); +} + +/// `--data-dir` 模式:数据根由命令行钉死,core.* 仍落便携目录那份并给出提示。 +#[test] +fn data_dir_flag_routes_core_keys_to_portable_and_says_so() { + let temp = TempDir::new("datadir"); + let exe = temp.path().join("exe"); + std::fs::create_dir_all(&exe).expect("建 exe 目录"); + let data = temp.path().join("data"); + let globals = Globals { + data_dir: Some(data.clone()), + ..Globals::default() + }; + let ctx = ctx_with(globals, &exe); + assert_eq!(ctx.data.mode.as_str(), "custom"); + assert_eq!(ctx.data.launcher_home, data.join(".ULL")); + + let portable = exe.join(".ULL").join("config").join("launcher.json"); + set(&ctx, "core.data_mode", "standard").expect("写 core.data_mode"); + assert!(portable.is_file(), "应落在 {}", portable.display()); + assert!(!data + .join(".ULL") + .join("config") + .join("launcher.json") + .exists()); + + let report = get(&ctx, Some("core.data_mode"), true).expect("读回来"); + assert_eq!(report.data["value"], json!("standard")); + assert!( + report.human.iter().any(|line| line.contains("--data-dir")), + "{:?}", + report.human + ); +} + +/// 全量 dump:`core.*` 取便携目录那份的生效值,并标明两份文件与数据根。 +#[test] +fn dump_merges_core_keys_from_the_portable_file() { + let temp = TempDir::new("dump"); + let exe = temp.path().join("exe"); + let portable = exe.join(".ULL").join("config").join("launcher.json"); + let launcher_home = temp.path().join("home2"); + write_json( + &portable, + &json!({ + "$schema": schema_url(), + "schemaVersion": 1, + "core": { + "data_mode": "standard", + "launcher_home": launcher_home.display().to_string(), + }, + }), + ); + // 数据根那份写一个普通键(默认值不落盘,dump 里只有文件里真有的键)。 + write_json( + &launcher_home.join("config").join("launcher.json"), + &json!({ + "$schema": schema_url(), + "schemaVersion": 1, + "download": { "concurrency": 4 }, + }), + ); + let ctx = ctx_with(Globals::default(), &exe); + + let report = dump(&ctx, true).expect("全量 dump"); + assert_eq!( + report.data["config"]["core"]["data_mode"], + json!("standard") + ); + assert_eq!( + report.data["core_path"], + json!(portable.display().to_string()) + ); + assert_eq!(report.data["data_root"]["mode"], json!("standard")); + assert_eq!( + report.data["config"]["download"]["concurrency"], + json!(4), + "非 core.* 键仍取数据根那份" + ); +} + +/// 认不出的 `$schema` 前缀:值照读 + `problems` 里有 UnknownSchema,不再静默回落。 +#[test] +fn unknown_schema_prefix_warns_and_still_shows_file_values() { + let temp = TempDir::new("badschema"); + let exe = temp.path().join("exe"); + let portable = exe.join(".ULL").join("config").join("launcher.json"); + write_json( + &portable, + &json!({ + "$schema": "https://unlucky-launcher.local/schema/launcher/1.2.0.json", + "schemaVersion": 1, + "core": { "data_mode": "portable" }, + "download": { "concurrency": 32 }, + }), + ); + let ctx = ctx_with(Globals::default(), &exe); + + let report = get(&ctx, Some("download.concurrency"), false).expect("读普通键"); + assert_eq!(report.data["value"], json!(32), "值不该被回落成默认 8"); + let problems = report.data["problems"].as_array().expect("problems 数组"); + assert!( + problems + .iter() + .any(|problem| problem.as_str().is_some_and(|p| p.contains("$schema"))), + "{problems:?}" + ); + assert!( + report.human.iter().any(|line| line.contains("$schema")), + "{:?}", + report.human + ); +} diff --git a/crates/cli/src/commands/identity.rs b/crates/cli/src/commands/identity.rs new file mode 100644 index 0000000..3594e79 --- /dev/null +++ b/crates/cli/src/commands/identity.rs @@ -0,0 +1,216 @@ +//! `ull launch` 的账户选择:三轨账户 → [`AuthPlaceholders`](唯一的身份出口)。 +//! +//! 选择规则(headless 没有交互,必须确定): +//! - `--account `:按玩家名 / 登录名 / 条目 id 精确找(大小写不敏感); +//! - 不给 `--account`:**唯一账户自动选中**;零账户回落离线占位(`--username` 或 `Player`, +//! 与 M21 的行为一致,保证没有账户时 `ull launch` 照旧能跑);多账户报错并列出候选 +//! (猜一个等于让用户拿错身份进服务器,宁可让他选)。 +//! +//! 令牌策略:`--dry-run` **不刷新、不写盘**(M30 的 dry-run 纯读语义),只对过期状态给告警; +//! 真启动前对微软轨做一次 `ensure_fresh`(静默续期),刷新成功立刻回写凭据 +//! (refresh_token 轮换后旧值就作废了,不能丢)。 + +use unlucky_core::auth::account::{AccountKind, AccountMeta, Credentials}; +use unlucky_core::auth::microsoft::MicrosoftAuth; +use unlucky_core::auth::offline::offline_login; +use unlucky_core::auth::AccountStore; +use unlucky_core::download::blocking::block_on; +use unlucky_core::game::ProfileId; +use unlucky_core::launch::request::AuthPlaceholders; + +use crate::context::Ctx; +use crate::error::CliError; + +/// 解析结果。 +pub struct Identity { + /// 喂给 launch 的占位符集合。 + pub placeholders: AuthPlaceholders, + /// 选中的账户(`None` = 离线占位,没有账户)。 + pub account: Option, + /// 需要展示给用户的说明(令牌过期、authlib 注入未落地等)。 + pub notes: Vec, +} + +/// 解析本次启动的身份。 +/// +/// # Errors +/// +/// 账户不存在/重名冲突/多账户未指定/凭据过期且刷新失败/凭据文件缺条目。 +pub fn resolve( + ctx: &Ctx, + account: Option<&str>, + username: Option<&str>, + dry_run: bool, +) -> Result { + let store = AccountStore::open(ctx.auth_data()); + let chosen: Option = match account { + Some(key) => Some( + store + .find(key) + .ok_or_else(|| unknown_account(key, &store))? + .clone(), + ), + None => match store.accounts() { + [] => None, + [only] => Some(only.clone()), + many => { + return Err(CliError::user( + "account_ambiguous", + format!("有 {} 个账户,需要指定用哪个", many.len()), + ) + .with_hint(format!( + "ull launch --account <名字>;候选:{}", + names_of(&store) + ))) + } + }, + }; + + let Some(meta) = chosen else { + // 零账户:回落离线占位(保留 M21 行为)。 + return Ok(offline_fallback(username)); + }; + if username.is_some() { + return Err(CliError::usage(format!( + "已选中账户 {},`--username` 只用于没有账户时的离线占位", + meta.display_name() + )) + .with_hint("去掉 --username,或用 `ull accounts add offline <名字>` 建账户")); + } + + let mut notes = Vec::new(); + let placeholders = match meta.kind { + AccountKind::Offline => offline_login(&meta).map_err(super::map_auth_error)?, + AccountKind::Microsoft => microsoft_identity(ctx, &store, &meta, dry_run, &mut notes)?, + AccountKind::AuthlibInjector => authlib_identity(&store, &meta, &mut notes)?, + }; + Ok(Identity { + placeholders, + account: Some(meta), + notes, + }) +} + +/// 零账户时的离线占位(uuid 随机,userType 用 HMCL 的 `legacy` 占位口径不变)。 +fn offline_fallback(username: Option<&str>) -> Identity { + Identity { + placeholders: AuthPlaceholders { + player_name: username.unwrap_or("Player").to_string(), + uuid: ProfileId::generate().as_str().replace('-', ""), + user_type: "legacy".to_string(), + user_properties: "{}".to_string(), + ..AuthPlaceholders::default() + }, + account: None, + notes: vec![ + "没有账户,按离线占位启动(ull accounts add offline <名字> 可建账户)".to_string(), + ], + } +} + +fn microsoft_identity( + ctx: &Ctx, + store: &AccountStore, + meta: &AccountMeta, + dry_run: bool, + notes: &mut Vec, +) -> Result { + let Some(Credentials::Microsoft(session)) = store.credentials(&meta.id) else { + return Err(CliError::user( + "account_credentials", + format!("账户 {} 的微软凭据缺失或类型不符", meta.display_name()), + ) + .with_hint("重新登录:ull accounts add msa")); + }; + + if dry_run { + // dry-run 不刷新:令牌过期只提示,命令行里的 token 反正会被脱敏。 + if session.is_expired(unlucky_core::auth::microsoft::now_ms()) { + notes.push("微软令牌已过期(--dry-run 不刷新,真启动会自动续期)".to_string()); + } + return session.to_placeholders().map_err(super::map_auth_error); + } + + let auth = MicrosoftAuth::from_env().map_err(super::map_auth_error)?; + let (refreshed, reason) = + block_on(auth.ensure_fresh(session)).map_err(super::map_auth_error)?; + if let Some(reason) = reason { + notes.push(format!("{}(已静默续期)", reason.message())); + persist_refresh(ctx, meta, &refreshed)?; + } + refreshed.to_placeholders().map_err(super::map_auth_error) +} + +/// 刷新成功后立刻回写凭据(refresh_token 已轮换,旧值作废)。 +fn persist_refresh( + ctx: &Ctx, + meta: &AccountMeta, + session: &unlucky_core::auth::MicrosoftSession, +) -> Result<(), CliError> { + let mut store = AccountStore::open(ctx.auth_data()); + store + .update_credentials(&meta.id, Credentials::Microsoft(session.clone())) + .map_err(super::map_auth_error)?; + let mut updated = meta.clone(); + // profile_name 属于可变的私有缓存,刷新后顺手把元数据里的展示名补上。 + if !session.profile_name.trim().is_empty() { + updated.profile_name = session.profile_name.clone(); + store.update_meta(updated).map_err(super::map_auth_error)?; + } + store.save().map_err(super::map_auth_error) +} + +fn authlib_identity( + store: &AccountStore, + meta: &AccountMeta, + notes: &mut Vec, +) -> Result { + let Some(Credentials::AuthlibInjector(session)) = store.credentials(&meta.id) else { + return Err(CliError::user( + "account_credentials", + format!("账户 {} 的 authlib 凭据缺失或类型不符", meta.display_name()), + )); + }; + if !session.has_profile() { + return Err(CliError::user( + "account_credentials", + format!("账户 {} 还没有选中角色", meta.display_name()), + ) + .with_hint("多角色服务器要在图形界面里选角色")); + } + notes.push("authlib-injector 的 -javaagent 参数属后续 mission,本次启动不会注入".to_string()); + session.to_placeholders().map_err(super::map_auth_error) +} + +fn names_of(store: &AccountStore) -> String { + store + .accounts() + .iter() + .map(|meta| meta.display_name().to_string()) + .collect::>() + .join("、") +} + +fn unknown_account(key: &str, store: &AccountStore) -> CliError { + let hint = if store.is_empty() { + "还没有账户:ull accounts add offline <名字> 或 ull accounts add msa".to_string() + } else { + format!("可用账户:{}", names_of(store)) + }; + CliError::user("not_found", format!("找不到账户 `{key}`")).with_hint(hint) +} + +#[cfg(test)] +mod tests { + use super::offline_fallback; + + #[test] + fn fallback_uses_username_or_player() { + let identity = offline_fallback(Some("Alex")); + assert_eq!(identity.placeholders.player_name, "Alex"); + assert!(identity.account.is_none()); + assert_eq!(identity.placeholders.user_type, "legacy"); + assert_eq!(identity.placeholders.uuid.len(), 32); + assert_eq!(offline_fallback(None).placeholders.player_name, "Player"); + } +} diff --git a/crates/cli/src/commands/install/loader.rs b/crates/cli/src/commands/install/loader.rs new file mode 100644 index 0000000..9e979f6 --- /dev/null +++ b/crates/cli/src/commands/install/loader.rs @@ -0,0 +1,159 @@ +//! `ull install --loader [--loader-version ]`:加载器层的编排。 +//! +//! ```text +//! LoaderSource::resolve(官方 meta:版本列表 → profile JSON / installer profile) +//! → LoaderInstallPlan::build(纯计算:库清单 + loader 版本 JSON 落点) +//! → [--dry-run 到此为止,但多带一段 processors 计划] +//! → 原版已装?否 → vanilla::plan_and_install(先装原版) +//! → 需要 installer?→ 依赖库 → 选 Java → 串行跑 processors(M35) +//! → execute(loader 库并发下载 → 全量就绪才写 versions//.json) +//! → register(实例 versionId 指向 loader 版本) +//! ``` +//! +//! 「先装原版」是**硬前置**:loader 版本 JSON 只声明 `inheritsFrom`,jar 与资源都在原版那层, +//! 原版缺失时启动链必然断裂。已装则跳过(`is_version_installed` 判据 = 版本 JSON 在盘上)。 +//! +//! processors 段的顺序也是硬约束:**先跑 processors,再写版本 JSON**。版本清单存在是 +//! "这版装完了"的判据,抢在 processors 前写会留下一个启动必崩却被报成已安装的实例。 + +use unlucky_core::addon::loader::{ + LoaderError, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderSource, +}; +use unlucky_core::download::blocking::block_on; +use unlucky_core::download::cache::now_ms; +use unlucky_core::game::arguments::Environment; +use unlucky_core::game::install::execute; + +use super::report_loader::ProcessorSection; +use super::{ + build_engine, ensure_name_free, failure_error, loader_processors, map_install_error, + register_instance, report, report_loader, vanilla, CONCURRENCY, +}; +use crate::cli::InstallArgs; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +/// 执行加载器安装。 +/// +/// # Errors +/// +/// 用法错误、加载器未知/不支持、元数据读取失败、原版装不上、库下载失败、 +/// installer processors 失败、实例登记失败。 +pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result { + let version = args.version.trim().to_string(); + if version.is_empty() { + return Err(CliError::usage( + "install 需要版本 id:ull install --loader fabric", + )); + } + + let engine = build_engine(ctx); + let layout = ctx.layout(); + let game = ctx.game(); + let meta_cache = layout.meta_cache(); + let now = now_ms(); + + let release = block_on(async { + let source = LoaderSource::new(&engine, &meta_cache); + source + .resolve(kind, &version, args.loader_version.as_deref()) + .await + }) + .map_err(|error: LoaderError| map_loader_error(&error, ctx))?; + + let name = args + .name + .clone() + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| release.version_id.clone()); + ensure_name_free(ctx, &name, &release.version_id)?; + + let plan = LoaderInstallPlan::build(LoaderPlanInput { + layout: &game, + release: &release, + environment: &Environment::host(), + vanilla_installed: game.is_version_installed(&version), + }); + let processor_plan = loader_processors::plan(ctx, &release)?; + + if args.dry_run { + let section = processor_plan.as_ref().map(|plan| ProcessorSection { + plan, + libraries: None, + run: None, + skipped: false, + }); + return Ok(report_loader::build( + ctx, &engine, now, true, &plan, None, None, &name, section, + )); + } + + if plan.vanilla_install_needed { + let (_, vanilla_report) = vanilla::plan_and_install( + ctx, + &engine, + &version, + false, + args.progress, + args.json_progress, + )?; + if let Some(report) = &vanilla_report { + if !report.is_complete() { + return Err(failure_error(report, ctx)); + } + } + } + + // installer processors 段:版本 JSON 已在盘上 = 上一轮早已跑完,整段跳过。 + let mut processor_libs = None; + let mut processor_run = None; + let mut processor_skipped = false; + if let Some(processor_plan) = &processor_plan { + if game.is_version_installed(&release.version_id) { + processor_skipped = true; + } else { + let (libraries, run) = loader_processors::execute(ctx, &engine, processor_plan, args)?; + processor_libs = Some(libraries); + processor_run = Some(run); + } + } + + let sink = report::progress_sink(args.progress, args.json_progress, plan.files().len()); + let install_report = block_on(execute(&engine, &plan.install, CONCURRENCY, sink)) + .map_err(|error| map_install_error(&error, ctx))?; + if !install_report.is_complete() { + return Err(failure_error(&install_report, ctx)); + } + + let registration = register_instance(ctx, &name, &release.version_id)?; + + let section = processor_plan + .as_ref() + .map(|processor_plan| ProcessorSection { + plan: processor_plan, + libraries: processor_libs.as_ref(), + run: processor_run.as_ref(), + skipped: processor_skipped, + }); + Ok(report_loader::build( + ctx, + &engine, + now, + false, + &plan, + Some(&install_report), + Some(®istration), + &name, + section, + )) +} + +/// 加载器错误 → CLI 错误(稳定码 `loader_*`)。 +fn map_loader_error(error: &LoaderError, ctx: &Ctx) -> CliError { + let hint = match error.hint() { + Some(hint) => format!("{hint}(游戏根:{})", ctx.game().root().display()), + None => format!("游戏根:{}", ctx.game().root().display()), + }; + CliError::user(error.kind().as_str(), error.message().to_string()).with_hint(hint) +} diff --git a/crates/cli/src/commands/install/loader_processors.rs b/crates/cli/src/commands/install/loader_processors.rs new file mode 100644 index 0000000..338bf49 --- /dev/null +++ b/crates/cli/src/commands/install/loader_processors.rs @@ -0,0 +1,181 @@ +//! 加载器的 installer processors 段(M35):依赖库下载 → 选 Java → 串行执行。 +//! +//! 只在 `LoaderSupport::RequiresInstaller` 的版本上出现(Forge 1.13~1.20.x 主流段、 +//! NeoForge 1.20.5+)。其余加载器(Fabric/Quilt、Forge spec 0)走纯 json 路线,这一段整块跳过。 +//! +//! ```text +//! ProcessorPlan::build(core,纯计算:变量表 + 每步命令行 + 依赖库清单) +//! → download_libraries(M27 引擎:镜像/健康度/退避/校验) +//! → select_java(M33 解析链:托管 > JAVA_HOME > PATH > 扫盘) +//! → run(串行 java -cp,产物存在性硬校验、摘要软校验) +//! ``` +//! +//! 顺序上与版本 JSON 的关系是**硬约束**:processors 全部成功之后才让 M27 的 `execute` +//! 写 `versions//.json`——那份清单是"这版装完了"的判据,抢在 processors 前写会 +//! 留下一个启动必崩却被报成"已安装"的实例。 + +use std::sync::Arc; + +use unlucky_core::addon::loader::processors::{ + self, ProcessorError, ProcessorPlan, ProcessorPlanInput, ProcessorRun, StepOutcome, StepSink, +}; +use unlucky_core::addon::loader::LoaderRelease; +use unlucky_core::download::blocking::block_on; +use unlucky_core::download::DownloadEngine; +use unlucky_core::game::arguments::Environment; +use unlucky_core::game::install::InstallReport; + +use super::{failure_error, report, CONCURRENCY}; +use crate::cli::InstallArgs; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::progress_line; + +/// 组执行计划;不需要跑 installer 的加载器返回 `None`。 +/// +/// # Errors +/// +/// install profile 的 `data` 表/`processors` 形状不对时返回 `processor_plan` 错误。 +pub(super) fn plan(ctx: &Ctx, release: &LoaderRelease) -> Result, CliError> { + if !release.requires_installer() { + return Ok(None); + } + let (Some(profile), Some(installer)) = ( + release.install_profile.as_ref(), + release.installer.as_deref(), + ) else { + return Err(CliError::user( + "loader_metadata", + format!( + "{} {} 需要跑 installer processors,但没拿到 install profile / installer 路径", + release.kind.label(), + release.loader_version + ), + ) + .with_hint("重跑一次(元数据缓存可能不完整)")); + }; + let game = ctx.game(); + let scratch = ctx + .layout() + .cache() + .join("processors") + .join(safe_name(&release.version_id)); + let plan = ProcessorPlan::build(ProcessorPlanInput { + kind: release.kind, + mc_version: &release.mc_version, + version_id: &release.version_id, + profile, + layout: &game, + installer, + scratch: &scratch, + environment: &Environment::host(), + }) + .map_err(map_processor_error)?; + Ok(Some(plan)) +} + +/// 下载依赖库 + 执行 processors。 +/// +/// # Errors +/// +/// 依赖库下载失败(稳定码沿用 `install_*`)、找不到可用 Java、processor 非零退出、 +/// 产物没落盘。 +pub(super) fn execute( + ctx: &Ctx, + engine: &DownloadEngine, + plan: &ProcessorPlan, + args: &InstallArgs, +) -> Result<(InstallReport, ProcessorRun), CliError> { + let sink = report::progress_sink(args.progress, args.json_progress, plan.libraries.len()); + let libraries = block_on(processors::download_libraries( + engine, + &plan.libraries, + CONCURRENCY, + sink, + )); + if !libraries.is_complete() { + return Err(failure_error(&libraries, ctx)); + } + + let managed = ctx.layout().java_managed(); + let Some(java) = processors::select_java(&managed, &plan.java_requirement) else { + return Err(CliError::user( + "processor_java_missing", + format!( + "跑 installer processors 需要 {},但一个可用的 Java 都没找到", + plan.java_requirement.describe() + ), + ) + .with_hint(format!( + "`ull java install {}` 装一个托管 JDK,或把 JAVA_HOME 指向可用的 JDK(托管目录 {})", + plan.java_requirement.minimum, + managed.display() + ))); + }; + let sink = step_sink(args.progress, args.json_progress, plan.step_count()); + let run = processors::run(plan, &java, sink).map_err(map_processor_error)?; + Ok((libraries, run)) +} + +/// 每步一行的实时进度(只写 stderr)。 +fn step_sink(enabled: bool, json_mode: bool, total: usize) -> Option { + if !enabled { + return None; + } + Some(Arc::new(move |outcome: &StepOutcome| { + let record = serde_json::json!({ + "event": "processor", + "index": outcome.index, + "total": total, + "jar": outcome.label, + "status": outcome.status.as_str(), + "elapsed_ms": outcome.elapsed_ms, + }); + let text = format!( + "processor {}/{} {} → {}(输出 {} 个{})", + outcome.index, + total, + outcome.label, + if outcome.status.as_str() == "skipped" { + "跳过(产物已就绪)" + } else { + "执行完成" + }, + outcome.outputs, + if outcome.elapsed_ms > 0 { + format!(",{} ms", outcome.elapsed_ms) + } else { + String::new() + } + ); + if json_mode { + progress_line(&record.to_string()); + } else { + progress_line(&text); + } + })) +} + +/// processor 执行错误 → CLI 错误(稳定码 `processor_*`)。 +fn map_processor_error(error: ProcessorError) -> CliError { + let cli = CliError::user(error.kind().as_str(), error.message().to_string()); + match error.hint() { + Some(hint) => cli.with_hint(hint.to_string()), + None => cli, + } +} + +/// 版本 id → 可做目录名的片段(`+` 与路径分隔符都换掉)。 +fn safe_name(raw: &str) -> String { + raw.chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { + ch + } else { + '_' + } + }) + .collect::() + .trim_end_matches('.') + .to_string() +} diff --git a/crates/cli/src/commands/install/mod.rs b/crates/cli/src/commands/install/mod.rs new file mode 100644 index 0000000..b9a63b5 --- /dev/null +++ b/crates/cli/src/commands/install/mod.rs @@ -0,0 +1,151 @@ +//! `ull install [--loader ] [--loader-version ]`:装版本本体 / 装加载器层。 +//! +//! ```text +//! --loader 缺省 → vanilla:M27 原版安装(resolve → plan → execute → register) +//! --loader fabric|quilt → addon::loader:原版(已装则跳)→ loader 版本 JSON → 补 loader 库 → register +//! --loader neoforge|forge → 同上;需要 installer processors 的版本再跑 M35 执行器(依赖库 → java → 版本 JSON) +//! ``` +//! +//! CLI 只做四件事:建下载引擎、把计划/结果排版成人读与 `--json` 两种形态 +//! ([`report`] / [`report_loader`])、把错误翻译成稳定错误码、往 stderr 打实时进度。 +//! 所有版本/继承/库/下载逻辑都在 core(CLI 不复制任何路径或镜像规则)。 + +mod loader; +mod loader_processors; +mod report; +mod report_loader; +mod vanilla; + +use std::sync::Arc; + +use unlucky_core::addon::loader::LoaderKind; +use unlucky_core::download::cache::now_ms; +use unlucky_core::download::region::{decide_from, load_cached, store_cached}; +use unlucky_core::download::region_system::detect_signals; +use unlucky_core::download::{ + ContentCache, DownloadEngine, DownloadErrorKind, EngineConfig, HealthRegistry, MirrorTable, +}; +use unlucky_core::game::install::{InstallError, InstallReport, Registration}; + +use crate::cli::InstallArgs; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +/// 文件级并发上限:资源是海量小文件,16 路足够压满带宽又不至于打爆镜像。 +pub(super) const CONCURRENCY: usize = 16; + +/// 执行 `install`:按 `--loader` 分发。 +/// +/// # Errors +/// +/// 用法错误、加载器未知、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。 +pub fn run(ctx: &Ctx, args: &InstallArgs) -> Result { + match args + .loader + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + None | Some("vanilla") | Some("none") => vanilla::run(ctx, args), + Some(raw) => match LoaderKind::parse(raw) { + Some(kind) => loader::run(ctx, args, kind), + None => Err( + CliError::user("loader_unknown", format!("未知加载器 `{raw}`")) + .with_hint("--loader 取值:vanilla / fabric / quilt / neoforge / forge"), + ), + }, + } +} + +/// 登记实例(幂等):`profiles.json` + `config/instances/.json`。 +/// +/// 原版安装与加载器安装共用:前者 `version_id` = 原版 id,后者 = loader 版本 id +/// (实例的 `versionId` 必须指向 loader 版本,否则启动会跑成原版)。 +pub(super) fn register_instance( + ctx: &Ctx, + name: &str, + version_id: &str, +) -> Result { + let created_at = + unlucky_core::launch::time::format_rfc3339_millis(unlucky_core::launch::time::now()); + unlucky_core::game::install::instance::register( + &ctx.layout(), + name, + version_id, + Some(&created_at), + ) + .map_err(|error| CliError::user("instance_register", format!("登记实例失败:{error}"))) +} + +/// 建下载引擎:内容寻址缓存落 `.ULL/cache/`,地区判定走 24h 缓存 + 环境信号。 +/// +/// 地区判定**不做网络探测**(P2 首版;探测开关属设置层接线), +/// 因此中文环境默认镜像优先、其它环境直连——与 D7 的"不探测时"约定一致。 +pub(super) fn build_engine(ctx: &Ctx) -> DownloadEngine { + let layout = ctx.layout(); + let cache_root = layout.cache(); + let now = now_ms(); + let region_path = cache_root.join("region.json"); + let region = load_cached(®ion_path, now).unwrap_or_else(|| { + let profile = decide_from(&detect_signals(), now); + let _ = store_cached(®ion_path, &profile); + profile + }); + DownloadEngine::new( + EngineConfig::default(), + Arc::new(MirrorTable::builtin()), + Arc::new(region), + Arc::new(HealthRegistry::open_in(&cache_root, now)), + Arc::new(ContentCache::open(&cache_root)), + ) +} + +/// 同名但绑定别的版本 → 冲突;同名同版本视为重装(复用实例,允许)。 +pub(super) fn ensure_name_free(ctx: &Ctx, name: &str, version: &str) -> Result<(), CliError> { + let profiles = super::instances::read_profiles(ctx)?; + let conflict = profiles + .profiles + .iter() + .find(|profile| profile.name == name && profile.version_id != version); + match conflict { + Some(profile) => Err(CliError::user( + "instance_exists", + format!("同名实例已存在:{name}(绑定版本 {})", profile.version_id), + ) + .with_hint("用 --name 换个名字,或 `ull instances list` 看看现有实例")), + None => Ok(()), + } +} + +/// 安装期错误 → CLI 错误(保留稳定错误码与提示)。 +pub(super) fn map_install_error(error: &InstallError, ctx: &Ctx) -> CliError { + let hint = match error.hint() { + Some(hint) => format!("{hint}(游戏根:{})", ctx.game().root().display()), + None => format!("游戏根:{}", ctx.game().root().display()), + }; + CliError::user(error.kind().as_str(), error.message().to_string()).with_hint(hint) +} + +/// 有文件失败 → 按"最严重的失败类别"给稳定码,并列出前几条明细。 +pub(super) fn failure_error(report: &InstallReport, ctx: &Ctx) -> CliError { + let code = match report.failure_kind() { + Some(DownloadErrorKind::Integrity) => "install_integrity", + Some(DownloadErrorKind::Io) => "install_io", + _ => "install_network", + }; + let mut message = format!( + "{} 个文件没下成(共 {} 个)", + report.failed(), + report.files.len() + ); + for outcome in report.failures(3) { + if let Some(failure) = &outcome.failure { + message.push_str(&format!(";{}:{}", outcome.name, failure.message)); + } + } + CliError::user(code, message).with_hint(format!( + "重跑 `ull install` 会续传并跳过已就绪文件;游戏根 {}", + ctx.game().root().display() + )) +} diff --git a/crates/cli/src/commands/install/report.rs b/crates/cli/src/commands/install/report.rs new file mode 100644 index 0000000..af04893 --- /dev/null +++ b/crates/cli/src/commands/install/report.rs @@ -0,0 +1,262 @@ +//! 安装结果的两种呈现形态:人类可读行 + `--json` 载荷,外加 stderr 实时进度。 +//! +//! 拆出来是因为 `install` 的流程(规划 → 下载 → 登记)与排版彼此独立: +//! 前者要能读懂,后者要能被脚本断言。`--dry-run` 与真实安装共用同一份排版, +//! 差别只是多出逐文件清单与源选择。 + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use serde_json::json; +use unlucky_core::download::DownloadEngine; +use unlucky_core::game::install::{ + FileOutcome, InstallPlan, InstallReport, ProgressSink, Registration, +}; + +use crate::context::Ctx; +use crate::output::{progress_line, Report}; + +/// 每处理多少个文件打一行进度(3000 个资源下约 90 行,够用又不刷屏)。 +const PROGRESS_EVERY: usize = 32; + +/// 进度里最多逐条打印几条失败(其余交给最终错误汇总)。 +const MAX_PROGRESS_FAILURES: usize = 5; + +/// 实时进度回调(只写 stderr,不污染 `--json` 的 stdout)。 +/// +/// 两种形态:人类模式打文本行;`--json` 模式打 **JSONL**(一行一条事件,`event` 字段分流), +/// 这样脚本既能吃 stdout 的最终信封,也能实时跟进而不必解析文本。 +/// +/// 失败行只打前几条:整源不可达时会一口气失败上千个文件,全打出来既没用又淹没原因。 +pub fn progress_sink(enabled: bool, json: bool, total: usize) -> Option { + if !enabled { + return None; + } + let done = Arc::new(AtomicUsize::new(0)); + let failures = Arc::new(AtomicUsize::new(0)); + Some(Arc::new(move |outcome: &FileOutcome| { + let index = done.fetch_add(1, Ordering::Relaxed) + 1; + if let Some(failure) = &outcome.failure { + let seen = failures.fetch_add(1, Ordering::Relaxed); + if seen < MAX_PROGRESS_FAILURES { + emit( + json, + json!({ + "event": "file_failed", + "done": index, + "total": total, + "name": outcome.name, + "role": outcome.role.as_str(), + "kind": failure.kind.as_str(), + "message": failure.message, + }), + &format!("失败 {index}/{total} {}:{}", outcome.name, failure.message), + ); + } else if seen == MAX_PROGRESS_FAILURES { + emit( + json, + json!({"event": "progress_truncated", "suppressed_after": MAX_PROGRESS_FAILURES}), + "(更多失败不再逐条打印,见最终错误汇总)", + ); + } + return; + } + if index.is_multiple_of(PROGRESS_EVERY) || index == total { + emit( + json, + json!({"event": "progress", "done": index, "total": total}), + &format!("下载 {index}/{total}"), + ); + } + })) +} + +/// 一条进度:JSONL 模式打序列化后的一行,人类模式打短文本。 +fn emit(json_mode: bool, record: serde_json::Value, text: &str) { + if json_mode { + progress_line(&record.to_string()); + } else { + progress_line(text); + } +} + +/// 排版一次安装结果。 +#[allow(clippy::too_many_arguments)] +pub fn build( + ctx: &Ctx, + engine: &DownloadEngine, + now: u64, + dry_run: bool, + plan: &InstallPlan, + report: Option<&InstallReport>, + registration: Option<&Registration>, + name: &str, + version: &str, +) -> Report { + let samples = if dry_run { + plan.source_samples(engine, now) + } else { + Vec::new() + }; + let mut data = json!({ + "dry_run": dry_run, + "version_id": plan.version_id, + "jar_id": plan.jar_id, + "game_root": plan.game_root.display().to_string(), + "plan": plan.summary_json(), + "result": report.map(|report| json!({ + "downloaded": report.downloaded(), + "skipped": report.skipped(), + "failed": report.failed(), + "bytes": report.bytes(), + "version_json": report.version_json.as_ref().map(|path| path.display().to_string()), + "warnings": report.warnings, + "files": report.files.iter().map(outcome_json).collect::>(), + })), + "instance": registration.map(|registration| json!({ + "id": registration.profile.id.as_str(), + "name": registration.profile.name, + "version_id": registration.profile.version_id, + "created": registration.created, + "config_path": registration.config_path.display().to_string(), + "profiles_path": ctx.profiles_config().display().to_string(), + })), + }); + if dry_run { + data["files"] = json!(plan.files_json()); + data["sources"] = json!(samples); + } + + Report::new( + data, + human_lines(dry_run, plan, report, registration, &samples, name, version), + ) +} + +/// 单个文件的执行结果(`--json` 的 `result.files`;脚本据此分流"哪几个没下成")。 +fn outcome_json(outcome: &FileOutcome) -> serde_json::Value { + json!({ + "role": outcome.role.as_str(), + "name": outcome.name, + "dest": outcome.dest.display().to_string(), + "status": outcome.status.as_str(), + "bytes": outcome.bytes, + "source": outcome.source, + "error": outcome.failure.as_ref().map(|failure| json!({ + "kind": failure.kind.as_str(), + "message": failure.message, + })), + }) +} + +fn human_lines( + dry_run: bool, + plan: &InstallPlan, + report: Option<&InstallReport>, + registration: Option<&Registration>, + samples: &[serde_json::Value], + name: &str, + version: &str, +) -> Vec { + let mut human = Vec::new(); + let (total_bytes, unknown) = plan.total_bytes(); + human.push(if dry_run { + format!("安装计划:{}(jar {})", plan.version_id, plan.jar_id) + } else { + format!("已安装 {}(jar {})", plan.version_id, plan.jar_id) + }); + for (role, (count, bytes)) in plan.by_role() { + human.push(format!( + " {:<8} {} 个 / {}", + role.label(), + count, + human_bytes(bytes) + )); + } + human.push(format!( + " 合计 {} 个文件 / {}{}(游戏根 {})", + plan.file_count(), + human_bytes(total_bytes), + if unknown > 0 { + format!(" + {unknown} 个大小未知") + } else { + String::new() + }, + plan.game_root.display() + )); + for sample in samples { + let sources: Vec<&str> = sample["candidates"] + .as_array() + .map(|list| { + list.iter() + .filter_map(|candidate| candidate["source"].as_str()) + .collect() + }) + .unwrap_or_default(); + human.push(format!( + " 源选择 {} → {}", + sample["label"].as_str().unwrap_or_default(), + sources.join(" → ") + )); + } + for warning in &plan.warnings { + human.push(format!("告警: {warning}")); + } + if let Some(report) = report { + human.push(format!( + "下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})", + report.downloaded(), + report.skipped(), + report.failed(), + human_bytes(report.bytes()) + )); + if let Some(path) = &report.version_json { + human.push(format!("版本清单: {}", path.display())); + } + } + match registration { + Some(registration) => human.push(format!( + "已登记实例 {}({})→ 版本 {}({})", + name, + registration.profile.id.as_str(), + version, + if registration.created { + "新建" + } else { + "已存在,复用" + } + )), + None => human.push("(--dry-run:未下载任何文件,未登记实例)".to_string()), + } + human +} + +/// 字节 → 人读(MB/GB 一位小数,KB 取整;不做假精度)。 +pub fn human_bytes(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = 1024 * KB; + const GB: u64 = 1024 * MB; + if bytes >= GB { + format!("{:.1} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{} KB", bytes / KB) + } else { + format!("{bytes} B") + } +} + +#[cfg(test)] +mod tests { + use super::human_bytes; + + #[test] + fn formats_sizes_without_fake_precision() { + assert_eq!(human_bytes(0), "0 B"); + assert_eq!(human_bytes(1023), "1023 B"); + assert_eq!(human_bytes(1024), "1 KB"); + assert_eq!(human_bytes(1024 * 1024), "1.0 MB"); + assert_eq!(human_bytes(3 * 1024 * 1024 * 1024), "3.0 GB"); + } +} diff --git a/crates/cli/src/commands/install/report_loader.rs b/crates/cli/src/commands/install/report_loader.rs new file mode 100644 index 0000000..d94d215 --- /dev/null +++ b/crates/cli/src/commands/install/report_loader.rs @@ -0,0 +1,274 @@ +//! 加载器安装的呈现形态(人类可读行 + `--json` 载荷)。 +//! +//! 与 [`super::report`] 的分工:那边是"原版安装"(客户端 jar + 资源 + 库),这边是 +//! "叠在原版上的 loader 层"(只有库)。共用同一套进度回调与字节格式化。 + +use serde_json::json; +use unlucky_core::addon::loader::processors::{ProcessorPlan, ProcessorRun}; +use unlucky_core::addon::loader::LoaderInstallPlan; +use unlucky_core::download::DownloadEngine; +use unlucky_core::game::install::{InstallReport, Registration}; + +use super::report::human_bytes; +use crate::context::Ctx; +use crate::output::Report; + +/// processors 段(M35)的报告输入:计划 + 依赖库下载结果 + 执行结果。 +/// +/// `--dry-run` 只有 `plan`;幂等跳过时 `libraries`/`run` 都是 `None` 且 `skipped = true`。 +pub struct ProcessorSection<'a> { + /// 执行计划(dry-run 也带上:将执行的 processor 清单 + 所需 Java)。 + pub plan: &'a ProcessorPlan, + /// processor 依赖库的下载结果。 + pub libraries: Option<&'a InstallReport>, + /// 执行结果。 + pub run: Option<&'a ProcessorRun>, + /// 整段跳过(版本 JSON 已在盘上 = processors 早已跑完)。 + pub skipped: bool, +} + +/// 排版一次加载器安装结果。 +/// +/// 参数多是因为载荷本身就多:计划(loader 版本 + 库)、processors 段、执行结果、实例登记、 +/// `--dry-run` 的源选择样本。拆成结构体会让调用方多写十行,不如保持扁平。 +#[allow(clippy::too_many_arguments)] +pub fn build( + ctx: &Ctx, + engine: &DownloadEngine, + now: u64, + dry_run: bool, + plan: &LoaderInstallPlan, + report: Option<&InstallReport>, + registration: Option<&Registration>, + name: &str, + processors: Option>, +) -> Report { + let samples = if dry_run { + plan.install.source_samples(engine, now) + } else { + Vec::new() + }; + let mut data = json!({ + "dry_run": dry_run, + "loader": plan.release.to_json(), + "vanilla_install_needed": plan.vanilla_install_needed, + "game_root": plan.install.game_root.display().to_string(), + "plan": plan.summary_json(), + "result": report.map(|report| json!({ + "downloaded": report.downloaded(), + "skipped": report.skipped(), + "failed": report.failed(), + "bytes": report.bytes(), + "version_json": report.version_json.as_ref().map(|path| path.display().to_string()), + "warnings": report.warnings, + })), + "instance": registration.map(|registration| json!({ + "id": registration.profile.id.as_str(), + "name": registration.profile.name, + "version_id": registration.profile.version_id, + "created": registration.created, + "config_path": registration.config_path.display().to_string(), + "profiles_path": ctx.profiles_config().display().to_string(), + })), + }); + if dry_run { + data["files"] = json!(plan.install.files_json()); + data["sources"] = json!(samples); + } + if let Some(section) = &processors { + data["processors"] = processor_json(section); + } + + Report::new( + data, + human_lines( + dry_run, + plan, + report, + registration, + &samples, + name, + processors.as_ref(), + ), + ) +} + +/// processors 段的 JSON 载荷(计划 + 依赖库结果 + 逐步骤结果)。 +fn processor_json(section: &ProcessorSection<'_>) -> serde_json::Value { + let mut node = section.plan.to_json(); + if let Some(object) = node.as_object_mut() { + object.insert("skipped".to_string(), json!(section.skipped)); + object.insert( + "libraries_result".to_string(), + json!(section.libraries.map(|report| json!({ + "downloaded": report.downloaded(), + "skipped": report.skipped(), + "failed": report.failed(), + "bytes": report.bytes(), + }))), + ); + object.insert( + "run".to_string(), + json!(section.run.map(ProcessorRun::to_json)), + ); + } + node +} + +fn human_lines( + dry_run: bool, + plan: &LoaderInstallPlan, + report: Option<&InstallReport>, + registration: Option<&Registration>, + samples: &[serde_json::Value], + name: &str, + processors: Option<&ProcessorSection<'_>>, +) -> Vec { + let release = &plan.release; + let mut human = Vec::new(); + human.push(format!( + "{} {}《{} {}》(继承原版 {},主类 {})", + if dry_run { + "加载器安装计划" + } else { + "已安装加载器" + }, + release.kind.label(), + release.mc_version, + release.loader_version, + release.mc_version, + release.version_json.main_class.as_deref().unwrap_or("?"), + )); + human.push(format!( + " 版本 id {} → {}", + release.version_id, + plan.install.version_json_path.display() + )); + human.push(format!( + " 原版 {}", + if plan.vanilla_install_needed { + "未安装:执行时先装原版" + } else { + "已安装:跳过" + } + )); + let (total_bytes, unknown) = plan.install.total_bytes(); + human.push(format!( + " loader 库 {} 个 / {}{}", + plan.install.file_count(), + human_bytes(total_bytes), + if unknown > 0 { + format!(" + {unknown} 个大小未知") + } else { + String::new() + } + )); + for sample in samples { + let sources: Vec<&str> = sample["candidates"] + .as_array() + .map(|list| { + list.iter() + .filter_map(|candidate| candidate["source"].as_str()) + .collect() + }) + .unwrap_or_default(); + human.push(format!( + " 源选择 {} → {}", + sample["label"].as_str().unwrap_or_default(), + sources.join(" → ") + )); + } + for warning in plan.warnings() { + human.push(format!("告警: {warning}")); + } + if let Some(section) = processors { + human.extend(processor_lines(section)); + } + if let Some(report) = report { + human.push(format!( + "下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})", + report.downloaded(), + report.skipped(), + report.failed(), + human_bytes(report.bytes()) + )); + if let Some(path) = &report.version_json { + human.push(format!("loader 版本清单: {}", path.display())); + } + } + match registration { + Some(registration) => human.push(format!( + "已登记实例 {}({})→ 版本 {}({})", + name, + registration.profile.id.as_str(), + registration.profile.version_id, + if registration.created { + "新建" + } else { + "已存在,复用" + } + )), + None => human.push("(--dry-run:未下载任何文件,未登记实例)".to_string()), + } + human +} + +/// processors 段的人读行(计划 / 依赖库 / 逐步骤 / 告警)。 +fn processor_lines(section: &ProcessorSection<'_>) -> Vec { + let plan = section.plan; + let mut lines = Vec::new(); + lines.push(format!( + " installer processors {} 步,需要 {}(installer {})", + plan.step_count(), + plan.java_requirement.describe(), + plan.installer.display() + )); + if section.skipped { + lines.push(" 版本清单已存在:processors 整段跳过(幂等)".to_string()); + } + if let Some(libraries) = section.libraries { + lines.push(format!( + " processor 依赖库 {} 个:下载 {} / 跳过 {} / 失败 {}", + plan.libraries.len(), + libraries.downloaded(), + libraries.skipped(), + libraries.failed() + )); + } + if let Some(run) = section.run { + lines.push(format!( + " Java {}({}){}", + run.java + .major + .map_or_else(|| "未知".to_string(), |major| major.to_string()), + run.java.origin.as_str(), + run.java.binary.display() + )); + let total = run.steps.len(); + for step in &run.steps { + lines.push(format!( + " [{}/{}] {} → {}{}", + step.index, + total, + step.label, + if step.status.as_str() == "skipped" { + "跳过(产物已就绪)".to_string() + } else { + format!("执行 {} ms", step.elapsed_ms) + }, + if step.outputs > 0 { + format!("(产物 {} 个)", step.outputs) + } else { + String::new() + } + )); + } + for warning in &run.warnings { + lines.push(format!(" 告警: {warning}")); + } + } + for warning in &plan.warnings { + lines.push(format!(" 告警: {warning}")); + } + lines +} diff --git a/crates/cli/src/commands/install/vanilla.rs b/crates/cli/src/commands/install/vanilla.rs new file mode 100644 index 0000000..e01d8e2 --- /dev/null +++ b/crates/cli/src/commands/install/vanilla.rs @@ -0,0 +1,160 @@ +//! `ull install `(无加载器):M27 原版安装流程的宿主。 +//! +//! ```text +//! RemoteSource::resolve(版本清单缓存 → 版本 JSON → 继承链) +//! → RemoteSource::asset_index(资源索引,顺便拿字节给下面预取) +//! → InstallPlan::build(纯计算:文件清单 / 总大小 / 角色) +//! → [--dry-run 到此为止] +//! → execute(跳过已就绪 → 引擎并发下载 → 原子写版本 JSON) +//! → register(profiles.json + config/instances/.json) +//! ``` +//! +//! 异步整段包在一次 `block_on` 里:CLI 不依赖 tokio(D18 依赖树纪律), +//! reqwest 的连接池也不会跨运行时而失效。 + +use unlucky_core::download::blocking::block_on; +use unlucky_core::download::cache::now_ms; +use unlucky_core::download::DownloadEngine; +use unlucky_core::game::arguments::Environment; +use unlucky_core::game::install::{ + execute, InstallError, InstallErrorKind, InstallPlan, InstallReport, PlanInput, RemoteSource, +}; + +use super::{ + build_engine, ensure_name_free, failure_error, map_install_error, report, CONCURRENCY, +}; +use crate::cli::InstallArgs; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +/// 执行 `install`(原版)。 +/// +/// # Errors +/// +/// 用法错误、版本不存在、网络失败、校验不符、写盘失败、实例清单损坏。 +pub(super) fn run(ctx: &Ctx, args: &InstallArgs) -> Result { + let version = args.version.trim().to_string(); + if version.is_empty() { + return Err(CliError::usage( + "install 需要版本 id:ull install ", + )); + } + let name = args + .name + .clone() + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| version.clone()); + ensure_name_free(ctx, &name, &version)?; + + let engine = build_engine(ctx); + let now = now_ms(); + let (plan, report) = plan_and_install( + ctx, + &engine, + &version, + args.dry_run, + args.progress, + args.json_progress, + )?; + + if let Some(report) = &report { + if !report.is_complete() { + return Err(failure_error(report, ctx)); + } + } + + let registration = if args.dry_run { + None + } else { + Some(super::register_instance(ctx, &name, &version)?) + }; + + Ok(report::build( + ctx, + &engine, + now, + args.dry_run, + &plan, + report.as_ref(), + registration.as_ref(), + &name, + &version, + )) +} + +/// 规划 + 执行原版安装(`--dry-run` 只规划),供加载器安装当"前置步骤"复用。 +/// +/// # Errors +/// +/// 版本不存在、网络失败、清单装不全、写盘失败。 +pub(super) fn plan_and_install( + ctx: &Ctx, + engine: &DownloadEngine, + version: &str, + dry_run: bool, + progress: bool, + json_progress: bool, +) -> Result<(InstallPlan, Option), CliError> { + let game = ctx.game(); + let meta_cache = ctx.layout().meta_cache(); + let now = now_ms(); + + block_on(async { + let source = RemoteSource::new(engine, &game, &meta_cache, now); + let resolved = source.resolve(version).await?; + let fetched = match resolved + .asset_index + .as_ref() + .filter(|index| index.url.is_some()) + { + Some(reference) => { + let (index, bytes) = source.asset_index(reference).await?; + Some((index, game.asset_index(&reference.id), bytes)) + } + None => None, + }; + let mut plan = InstallPlan::build(PlanInput { + layout: &game, + version_id: version, + resolved: &resolved, + asset_index: fetched.as_ref().map(|(index, _, _)| index), + environment: &Environment::host(), + })?; + if let Some((_, dest, bytes)) = fetched { + plan.prefetch(dest, bytes); + } + if dry_run { + return Ok::(Attempt { plan, report: None }); + } + // 计划里没有客户端 jar(旧版清单缺 downloads.client)→ 拒绝"装出个空壳"。 + if !plan.is_installable() { + let reasons = if plan.warnings.is_empty() { + "清单缺少客户端 jar 的下载信息".to_string() + } else { + plan.warnings.join(";") + }; + return Err(InstallError::new( + InstallErrorKind::Metadata, + format!("版本 {version} 装不全:{reasons}"), + ) + .with_hint( + "旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `ull launch`", + )); + } + let sink = report::progress_sink(progress, json_progress, plan.file_count()); + let report = execute(engine, &plan, CONCURRENCY, sink).await?; + Ok(Attempt { + plan, + report: Some(report), + }) + }) + .map(|attempt| (attempt.plan, attempt.report)) + .map_err(|error: InstallError| map_install_error(&error, ctx)) +} + +/// 一次安装尝试的产物(`--dry-run` 只有计划)。 +struct Attempt { + plan: InstallPlan, + report: Option, +} diff --git a/crates/cli/src/commands/instances.rs b/crates/cli/src/commands/instances.rs new file mode 100644 index 0000000..a5021e0 --- /dev/null +++ b/crates/cli/src/commands/instances.rs @@ -0,0 +1,90 @@ +//! `instances list`:实例与已安装版本的总览。 +//! +//! 安装/登记在 [`super::install`];这里只读(`profiles.json` + `versions/` 的存在性)。 +//! 读 `profiles.json` 的实现委托给 `core::game::install::instance`,避免 CLI 自带一份 +//! 解析逻辑(同一份文件两处解析迟早会漂移)。 + +use std::path::PathBuf; + +use serde_json::{json, Value}; +use unlucky_core::game::install::instance::read_profiles as read_profiles_from_layout; +use unlucky_core::game::ProfileList; + +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +/// `ull instances list`。 +/// +/// # Errors +/// +/// `profiles.json` 读不了或解析失败时返回用户错误。 +pub fn list(ctx: &Ctx) -> Result { + let profiles = read_profiles(ctx)?; + let game = ctx.game(); + let installed = game.installed_versions().unwrap_or_default(); + + let mut rows: Vec = Vec::new(); + let mut body: Vec = Vec::new(); + for profile in &profiles.profiles { + let present = game.is_version_installed(&profile.version_id); + body.push(format!( + " {} [{}] {} {}", + profile.name, + profile.version_id, + if present { "已安装" } else { "载荷缺失" }, + profile.id.as_str() + )); + rows.push(json!({ + "id": profile.id.as_str(), + "name": profile.name, + "version_id": profile.version_id, + "version_present": present, + "game_dir": option_path(profile.game_dir.as_ref()), + "java_path": option_path(profile.java_path.as_ref()), + "last_played_at": profile.last_played_at, + })); + } + + let data = json!({ + "data_root": ctx.data.launcher_home.display().to_string(), + "game_root": ctx.data.game_home.display().to_string(), + "instances": rows, + "installed_versions": installed, + }); + + let mut human = vec![format!( + "实例 {} 个(数据根 {})", + profiles.profiles.len(), + ctx.data.launcher_home.display() + )]; + if body.is_empty() { + human.push(" (空)用 `ull install ` 登记第一个实例".to_string()); + } + human.extend(body); + if installed.is_empty() { + human.push("已安装版本: (无)".to_string()); + } else { + human.push(format!("已安装版本: {}", installed.join(", "))); + } + Ok(Report::new(data, human)) +} + +/// 读实例清单(文件不存在 = 空列表,不算错误)。 +/// +/// # Errors +/// +/// 读盘失败或 JSON 非法时返回用户错误。 +pub fn read_profiles(ctx: &Ctx) -> Result { + let path = ctx.profiles_config(); + read_profiles_from_layout(&ctx.layout()).map_err(|error| { + CliError::user( + "profiles_parse", + format!("读实例清单 {} 失败:{error}", path.display()), + ) + }) +} + +fn option_path(path: Option<&PathBuf>) -> Option { + path.map(|path| path.display().to_string()) +} diff --git a/crates/cli/src/commands/java.rs b/crates/cli/src/commands/java.rs new file mode 100644 index 0000000..6e35269 --- /dev/null +++ b/crates/cli/src/commands/java.rs @@ -0,0 +1,365 @@ +//! Java 运行时:`ull java list` / `install` / `vendors`(D13 v1 + D23 v2)。 +//! +//! 四件事:建下载引擎、把 core 的结果排成人读与 `--json` 两种形态、把错误翻成稳定码、 +//! 从设置里取 JDK 的落点与发行版优先级。探测/下载/解压/映射/回退链全在 `core::game::java`。 +//! +//! D23 之后的三个用户可见变化: +//! +//! 1. **落点可选**:`java.installRoot` 设了就装到那里;否则 Standard 数据模式装到 +//! `%APPDATA%\Java\<发行方>\<版本>\`(mac/Linux 见 `game::java::location`), +//! Portable 仍在数据根 `java-managed/`(v1 布局不变); +//! 2. **发行版可选**:`--vendor temurin|oracle|microsoft|zulu` 钉死一家;不给就按 +//! `java.vendorPriority` → 地区默认序跑回退链,降级原因进 `--json` 的 `chain` 块; +//! 3. **`java vendors`** 一眼看清四家的可用性、镜像与当前顺序。 + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::{json, Value}; +use unlucky_core::download::blocking::block_on; +use unlucky_core::download::cache::now_ms; +use unlucky_core::download::region::{decide_from, load_cached, store_cached}; +use unlucky_core::download::region_system::detect_signals; +use unlucky_core::download::{ + ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable, RegionProfile, +}; +use unlucky_core::game::java::{ + self, location, vendor, InstallRequest, JavaOrigin, JavaRuntime, JdkVendor, LocationMode, + DEFAULT_MAJOR, +}; +use unlucky_core::launch::request::JavaSelection; +use unlucky_core::platform::paths::ProcessEnv; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +pub use super::java_launch::{resolve_for_launch, LaunchJava}; +pub use super::java_vendors::vendors; + +/// 可自动安装的最老大版本(再老的 Temurin 没有 GA 产物)。 +const MIN_INSTALLABLE_MAJOR: u32 = 8; + +/// 装配用的 Java 选择(`--java` > `JAVA_HOME` > PATH 上的 `java`)。 +/// +/// `snapshot::prepare` 调它拿 [`JavaSelection`];真正的来源优先级在 +/// [`resolve_for_launch`] 里求值(那一步还会看托管目录与版本要求)。 +#[must_use] +pub fn selection(explicit: Option<&Path>) -> JavaSelection { + let (binary, origin) = match explicit { + Some(path) => (path.to_path_buf(), JavaOrigin::Instance), + None => match env_java() { + Some(binary) => (binary, JavaOrigin::JavaHome), + None => (PathBuf::from(java::java_binary_name()), JavaOrigin::Path), + }, + }; + let runtime = java::probe(&binary, origin); + JavaSelection::new(binary, runtime.major.unwrap_or(DEFAULT_MAJOR), runtime.bits) +} + +/// 托管 JDK 可能存在的全部安装根(Portable 数据根 + Standard/Custom 落点,D23 后不止一个)。 +#[must_use] +pub fn managed_roots(ctx: &Ctx) -> Vec { + let settings = JavaSettings::load(ctx); + location::scan_roots( + ctx.data.mode, + &ctx.layout(), + settings.install_root.as_deref(), + ctx.platform, + &ProcessEnv, + ) +} + +/// `ull java list`:探测托管目录 / `JAVA_HOME` / `PATH` / 实例覆盖,并显示各 JDK 的落点。 +/// +/// # Errors +/// +/// 实例清单读不了时返回用户错误(其余探测失败只体现为缺项)。 +pub fn list(ctx: &Ctx) -> Result { + let settings = JavaSettings::load(ctx); + let roots = managed_roots(ctx); + // 托管 → JAVA_HOME → PATH → 标准安装位置(Program Files 系 / 官方启动器 runtime / 注册表), + // 同一路径只列一次且保留优先级高者(`discover::dedupe` 负责)。 + let mut candidates = java::candidates_in_roots(&roots); + let profiles = super::instances::read_profiles(ctx)?; + for profile in &profiles.profiles { + if let Some(java_path) = &profile.java_path { + candidates.push(java::probe(java_path, JavaOrigin::Instance)); + } + } + let candidates = java::discover::dedupe(candidates); + let candidates: Vec = candidates + .into_iter() + .filter(|candidate| candidate.major.is_some()) + .collect(); + + let mode = settings.location_mode(ctx); + let installed_root = settings.location(ctx).map(|root| root.root).ok(); + let data = json!({ + // 兼容 v1 的 `managed_root`(首个扫描根),同时给出多根清单(D23 之后不止一个根)。 + "managed_root": roots.first().map(|root| root.display().to_string()), + "managed_roots": roots.iter().map(|root| root.display().to_string()).collect::>(), + "install": { + "mode": mode.as_str(), + "root": installed_root.as_ref().map(|root| root.display().to_string()), + "override": settings.install_root.as_ref().map(|root| root.display().to_string()), + }, + "candidates": candidates.iter().map(candidate_json).collect::>(), + }); + let mut human = vec![format!("Java {} 个", candidates.len())]; + if candidates.is_empty() { + human.push( + "(没找到可用 Java)用 `ull java install 21` 装一个,或设 JAVA_HOME / `ull launch --java `" + .to_string(), + ); + } + human.push(format!( + "落点模式:{}", + describe_mode(mode, settings.install_root.as_deref()) + )); + for root in &roots { + human.push(format!(" 扫描根:{}", root.display())); + } + for candidate in &candidates { + human.push(format!( + " {} {} [{}]", + candidate + .version + .clone() + .unwrap_or_else(|| "版本未知".to_string()), + candidate.binary.display(), + source_label(candidate) + )); + } + Ok(Report::new(data, human)) +} + +/// `ull java install [--vendor X]`:回退链查版本 → 下载 → 解压 → `java -version` 验证。 +/// +/// 幂等:同一个归档装过就直接复用(不联网、不解压)。下载走 `core::download` 引擎, +/// 国内环境按 D7 的地区策略优先院校/国内镜像。 +/// +/// # Errors +/// +/// 大版本不合法、平台无产物、所有发行版都失败(网络/校验/解压/跑不起来)时返回用户错误。 +pub fn install(ctx: &Ctx, major: u32, vendor: Option) -> Result { + if !(MIN_INSTALLABLE_MAJOR..=99).contains(&major) { + return Err(CliError::usage(format!( + "大版本号看起来不对:{major}(可用 8 / 11 / 17 / 21 / 25 这类 GA 大版本)" + ))); + } + let settings = JavaSettings::load(ctx); + let engine = build_engine(ctx); + let install_root = settings.location(ctx)?; + let request = match vendor { + Some(vendor) => { + InstallRequest::with_vendor(major, vendor).with_priority(settings.priority.clone()) + } + None => InstallRequest::new(major).with_priority(settings.priority.clone()), + }; + let installed = + block_on(java::install(&engine, &install_root, request)).map_err(map_java_error)?; + + let data = json!({ + "major": installed.major, + "vendor": installed.vendor.id(), + "release": installed.release.release_name, + "version": installed.release.version, + "semver": installed.release.semver, + "archive": installed.release.archive.name, + "sha256": installed.release.archive.sha256, + "size": installed.release.archive.size, + "root": installed.root.display().to_string(), + "install_mode": installed.mode.as_str(), + "binary": installed.binary().display().to_string(), + "reused": installed.reused(), + "source": installed.downloaded, + "bytes": installed.bytes, + "files": installed.files, + "extracted": installed.extracted, + "chain": installed.chain.to_json(), + "runtime": candidate_json(&installed.runtime), + }); + + let mut human = Vec::new(); + if installed.reused() { + human.push(format!( + "Java {}({})已就绪({}),未走网络", + installed.major, + installed.vendor.id(), + installed.root.display() + )); + } else { + human.push(format!( + "已安装 Java {}({},来源 {},{} 字节 / {} 个文件)", + installed.major, + installed.release.summary(), + installed.downloaded.as_deref().unwrap_or("?"), + installed.bytes, + installed.files + )); + } + human.push(format!("落点:{}", installed.location_line())); + human.push(format!("可执行文件:{}", installed.binary().display())); + if let Some(version) = &installed.runtime.version { + human.push(format!("探测:{version}")); + } + for note in installed.chain.degradations() { + human.push(format!("降级:{note}")); + } + human.push("`ull launch` 会自动优先用它".to_string()); + Ok(Report::new(data, human)) +} + +/// 设置里与 JDK 有关的两项(`java.installRoot` / `java.vendorPriority`)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct JavaSettings { + pub(crate) install_root: Option, + pub(crate) priority: Vec, +} + +impl JavaSettings { + /// 读 `<数据根>/.ULL/config/launcher.json`(坏文件退化成默认值,不阻断命令)。 + pub(crate) fn load(ctx: &Ctx) -> Self { + let store = SettingStore::load(ctx.launcher_config(), launcher_schema()); + let install_root = store + .doc() + .effective_str("java.installRoot") + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(PathBuf::from); + let priority = store + .effective("java.vendorPriority") + .and_then(Value::as_array) + .map(|items| { + let texts: Vec = items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(); + vendor::parse_priority(&texts) + }) + .unwrap_or_default(); + Self { + install_root, + priority, + } + } + + /// 落点模式(只描述,不建目录)。 + fn location_mode(&self, ctx: &Ctx) -> LocationMode { + if self.install_root.is_some() { + LocationMode::Custom + } else if ctx.data.mode == unlucky_core::platform::data_dir::DataMode::Standard { + LocationMode::Standard + } else { + LocationMode::Portable + } + } + + /// 真正要装到哪(解析失败 → 用户错误,带可照着做的提示)。 + fn location(&self, ctx: &Ctx) -> Result { + java::JavaInstallRoot::resolve( + ctx.data.mode, + &ctx.layout(), + self.install_root.as_deref(), + ctx.platform, + &ProcessEnv, + ) + .map_err(map_java_error) + } +} + +/// 落点模式 → 人读一行(含「没设覆盖键时默认落在哪」的解释)。 +fn describe_mode(mode: LocationMode, override_root: Option<&Path>) -> String { + match mode { + LocationMode::Custom => format!( + "custom(java.installRoot = {})", + override_root.unwrap_or(Path::new("?")).display() + ), + LocationMode::Standard => { + "standard(%APPDATA%\\Java\\<发行方>\\<版本> 等系统标准目录)".to_string() + } + LocationMode::Portable => "portable(<数据根>/.ULL/data/java-managed)".to_string(), + } +} + +/// 一个运行时的 `--json` 形状。 +fn candidate_json(runtime: &JavaRuntime) -> Value { + json!({ + "binary": runtime.binary.display().to_string(), + "source": runtime.origin.as_str(), + "source_category": runtime.origin.category(), + "version": runtime.version, + "major": runtime.major, + "bits": runtime.bits, + "vendor": runtime.vendor, + "install_root": install_root_of(runtime), + }) +} + +/// 托管 JDK 的实际目录(`bin/java` 的上两级);非托管来源为 `None`。 +fn install_root_of(runtime: &JavaRuntime) -> Option { + if runtime.origin != JavaOrigin::Managed { + return None; + } + runtime + .binary + .parent() + .and_then(Path::parent) + .map(|dir| dir.display().to_string()) +} + +/// 建下载引擎:内容寻址缓存落 `.ULL/cache/`,地区判定走 24h 缓存 + 环境信号。 +fn build_engine(ctx: &Ctx) -> DownloadEngine { + let layout = ctx.layout(); + let cache_root = layout.cache(); + let now = now_ms(); + let region_path = cache_root.join("region.json"); + let region = load_cached(®ion_path, now).unwrap_or_else(|| { + let profile = decide_from(&detect_signals(), now); + let _ = store_cached(®ion_path, &profile); + profile + }); + DownloadEngine::new( + EngineConfig::default(), + Arc::new(MirrorTable::builtin()), + Arc::new(region), + Arc::new(HealthRegistry::open_in(&cache_root, now)), + Arc::new(ContentCache::open(&cache_root)), + ) +} + +/// JDK 下载/解压错误 → CLI 错误(保留稳定码与提示)。 +fn map_java_error(error: java::JavaError) -> CliError { + let cli = CliError::user(error.code(), error.message().to_string()); + match error.hint() { + Some(hint) => cli.with_hint(hint.to_string()), + None => cli, + } +} + +/// `JAVA_HOME/bin/java`(存在才返回)。 +fn env_java() -> Option { + let home = std::env::var_os("JAVA_HOME")?; + let binary = PathBuf::from(home) + .join("bin") + .join(java::java_binary_name()); + binary.is_file().then_some(binary) +} + +/// 运行时 → 展示标签(托管项带上 vendor)。 +fn source_label(runtime: &JavaRuntime) -> String { + match &runtime.vendor { + Some(vendor) => format!("{} · {vendor}", runtime.origin.as_str()), + None => runtime.origin.as_str().to_string(), + } +} + +/// 当前地区档案(`java vendors` 展示「国内/国外」口径;也给 GUI 复用)。 +#[must_use] +pub fn region_profile(ctx: &Ctx) -> RegionProfile { + build_engine(ctx).region().clone() +} diff --git a/crates/cli/src/commands/java_launch.rs b/crates/cli/src/commands/java_launch.rs new file mode 100644 index 0000000..16f374d --- /dev/null +++ b/crates/cli/src/commands/java_launch.rs @@ -0,0 +1,129 @@ +//! 启动期的 Java 解析(`ull launch` 用):实例覆盖 > 托管匹配 > 系统。 +//! +//! 从 `commands/java.rs` 拆出来(那边还要放 list/install/vendors,超 400 行硬上限)。 +//! 这里的职责只有两条:①按 D13 的优先链挑一个 Java;②把结果整理成 `--json` 块与告警行。 +//! 真正「谁优先」的判定在 `core::game::java::probe` / `select`(纯函数),本文件只做装配。 + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; +use unlucky_core::game::java::{self, JavaMismatch, JavaOrigin, JavaRequirement, JavaRuntime}; + +use crate::context::Ctx; + +/// 启动期的 Java 解析结果。 +#[derive(Debug, Clone)] +pub struct LaunchJava { + /// 喂给 `PlanInput.java` 的显式覆盖;`None` = 让 `snapshot::prepare` 按系统兜底。 + pub override_path: Option, + /// 实际选中的运行时。 + pub runtime: Option, + /// 目标版本对 Java 的要求。 + pub requirement: JavaRequirement, + /// 不符说明(只提示,不阻断)。 + pub mismatch: Option, +} + +impl LaunchJava { + /// `--json` 的 `java` 块(app 层 GUI 弹窗 P1 后直接吃这份数据)。 + #[must_use] + pub fn to_json(&self) -> Value { + let runtime = self.runtime.as_ref(); + json!({ + "binary": runtime.map(|runtime| runtime.binary.display().to_string()), + "major": runtime.and_then(|runtime| runtime.major), + "bits": runtime.map(|runtime| runtime.bits), + "source": runtime.map(|runtime| runtime.origin.as_str()), + "vendor": runtime.and_then(|runtime| runtime.vendor.clone()), + "required": self.requirement.describe(), + "required_major": self.requirement.minimum, + "requirement_source": self.requirement.source.as_str(), + "mismatch": self.mismatch.map(|mismatch| json!({ + "code": mismatch.code(), + "message": mismatch.message(), + })), + }) + } + + /// 告警行(dry-run 与真启动都输出)。 + /// + /// 两类:①用户指定的 Java 探测不出来(路径写错了,静默退回默认版本会很难查); + /// ②Java 与版本要求不符(D13:提示,不阻断)。 + #[must_use] + pub fn warnings(&self) -> Vec { + let mut warnings = Vec::new(); + if let Some(runtime) = &self.runtime { + if runtime.major.is_none() { + warnings.push(format!( + "探测不到 {} 的 `java -version` 输出(路径可能不对)", + runtime.binary.display() + )); + } + } + if let Some(mismatch) = self.mismatch { + warnings.push(mismatch.message()); + } + warnings + } + + /// 人读一行(Java 来源 + 要求)。 + #[must_use] + pub fn summary(&self) -> String { + let source = self + .runtime + .as_ref() + .map_or("未探测到", |runtime| runtime.origin.as_str()); + let version = self + .runtime + .as_ref() + .and_then(|runtime| runtime.major) + .map_or_else(|| "未知".to_string(), |major| major.to_string()); + format!( + "Java {version}({source})· 该版本要求 {}", + self.requirement.describe() + ) + } +} + +/// 解析启动用的 Java:实例覆盖 > 托管匹配 > 系统。 +/// +/// 覆盖优先级的求值顺序写在 `core::game::java::probe`(纯函数,可注入候选); +/// 这里只负责「实例覆盖单独成路」与「把结果钉成路径」。 +#[must_use] +pub fn resolve_for_launch(ctx: &Ctx, version_id: &str, explicit: Option<&Path>) -> LaunchJava { + let requirement = java::requirement_for_version(&ctx.game(), version_id); + let (runtime, mismatch) = match explicit { + Some(path) => { + let runtime = java::probe(path, JavaOrigin::Instance); + let mismatch = runtime + .major + .and_then(|major| java::check(major, &requirement)); + (Some(runtime), mismatch) + } + None => { + // 先看托管 + 环境变量;都不满足要求才去扫盘(别为了一次启动白跑 N 次 java -version)。 + // 托管区可能不止一个根(Portable 与 Standard/Custom 并存,D23)。 + let roots = super::java::managed_roots(ctx); + let mut candidates = java::discover_managed_roots(&roots); + candidates.extend(java::system_candidates()); + let mut resolution = java::resolve(&candidates, &requirement); + if resolution.mismatch.is_some() { + candidates.extend(java::standard_candidates()); + resolution = java::resolve(&java::discover::dedupe(candidates), &requirement); + } + (resolution.runtime, resolution.mismatch) + } + }; + // 托管 JDK 要钉死路径(用户 PATH 上的 java 可能正是我们要避开的那一个); + // 系统来源交回 `snapshot::prepare` 的 `selection()` 兜底,避免重复实现一遍环境判断。 + let override_path = match &runtime { + Some(runtime) if runtime.origin == JavaOrigin::Managed => Some(runtime.binary.clone()), + _ => explicit.map(Path::to_path_buf), + }; + LaunchJava { + override_path, + runtime, + requirement, + mismatch, + } +} diff --git a/crates/cli/src/commands/java_vendors.rs b/crates/cli/src/commands/java_vendors.rs new file mode 100644 index 0000000..96cfb22 --- /dev/null +++ b/crates/cli/src/commands/java_vendors.rs @@ -0,0 +1,141 @@ +//! `ull java vendors`:四家发行版的可用性 / 镜像 / 当前优先级顺序(D23)。 +//! +//! 从 `commands/java.rs` 拆出来(那边 list/install 已经占满一屏)。这里只读不写: +//! 不联网、不改配置,把 `mirrors.json` 的表与设置里的优先级排成一张人读 + `--json` 的对照表。 +//! +//! 存在的意义是**可观测**:用户问「国内为什么默认走 Oracle 而不是微软」时,命令要能直接答 +//! (顺序 + 每家的镜像与实测吞吐),而不是让人翻文档。 + +use serde_json::{json, Value}; +use unlucky_core::download::MirrorTable; +use unlucky_core::game::java::{vendor, JdkVendor}; + +use super::java::{region_profile, JavaSettings}; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +/// `ull java vendors`。 +/// +/// # Errors +/// +/// 设置文件坏掉时退化为默认值;命令本身不产生用户错误。 +pub fn vendors(ctx: &Ctx) -> Result { + let settings = JavaSettings::load(ctx); + let domestic = region_profile(ctx).is_domestic(); + let order = vendor::order(&settings.priority, domestic); + let table = MirrorTable::builtin(); + let rows: Vec = order + .iter() + .enumerate() + .map(|(index, item)| vendor_json(&table, *item, index)) + .collect(); + let priority_source = if settings.priority.is_empty() { + "region_default" + } else { + "java.vendorPriority" + }; + let data = json!({ + "region": if domestic { "domestic" } else { "global" }, + "priority_source": priority_source, + "order": vendor::ids(&order), + "vendors": rows, + }); + let mut human = vec![format!( + "发行版顺序({},来源 {}):{}", + if domestic { "国内" } else { "国外" }, + if settings.priority.is_empty() { + "地区默认序" + } else { + "java.vendorPriority" + }, + vendor::ids(&order).join(" → ") + )]; + for row in &rows { + human.push(format!( + " {} {} {} 支持 Java {}", + row["rank"].as_u64().unwrap_or(0), + row["vendor"].as_str().unwrap_or("?"), + row["display"].as_str().unwrap_or(""), + join_numbers(&row["majors"], "/") + )); + for source in row["sources"].as_array().into_iter().flatten() { + if let Some(text) = source.as_str() { + human.push(format!(" 源:{text}")); + } + } + } + Ok(Report::new(data, human)) +} + +/// 一家的 `--json` 形状(源清单按家取,缺表时为空数组而不是报错)。 +fn vendor_json(table: &MirrorTable, item: JdkVendor, index: usize) -> Value { + let sources: Vec = match item { + JdkVendor::Temurin => table + .adoptium + .iter() + .filter(|mirror| mirror.enabled) + .map(|mirror| { + format!( + "{}{}", + mirror.name, + if mirror.latest_only { + "(只留最新补丁)" + } else { + "" + } + ) + }) + .chain(std::iter::once( + "GitHub 官方(国内套 gh-proxy)".to_string(), + )) + .collect(), + JdkVendor::Oracle => table + .oracle_mirrors() + .map(|mirror| match mirror.speed.trim() { + "" => format!("{} {}", mirror.name, mirror.base()), + speed => format!("{} {} {}", mirror.name, mirror.base(), speed), + }) + .collect(), + JdkVendor::Microsoft => table + .microsoft_openjdk() + .map(|microsoft| { + let speed = if microsoft.measured_speed.trim().is_empty() { + "实测约 400 KB/s".to_string() + } else { + microsoft.measured_speed.clone() + }; + vec![format!( + "aka.ms → VS CDN({speed});无国内镜像、无 GitHub 资产" + )] + }) + .unwrap_or_default(), + JdkVendor::Zulu => table + .zulu() + .map(|zulu| vec![format!("{}({})", zulu.api, zulu.measured_speed)]) + .unwrap_or_default(), + }; + json!({ + "rank": index + 1, + "vendor": item.id(), + "display": item.display(), + "majors": item.hint_majors(), + "supported": (8..=99u32).filter(|major| item.supports(*major)).collect::>(), + "sources": sources, + }) +} + +/// JSON 数字数组 → `21/17/11` 这种分隔串。 +fn join_numbers(value: &Value, separator: &str) -> String { + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(Value::as_u64) + .map(|number| number.to_string()) + .collect::>() + .join(separator) + }) + .unwrap_or_default() +} diff --git a/crates/cli/src/commands/launch.rs b/crates/cli/src/commands/launch.rs new file mode 100644 index 0000000..03d6f0d --- /dev/null +++ b/crates/cli/src/commands/launch.rs @@ -0,0 +1,377 @@ +//! `ull launch [--dry-run]`(D18:`--dry-run` 只打印组装后的命令行)。 +//! +//! D20 的隔离档位在**这里**落成 `--gameDir`:本命令按覆盖链 +//! (临时参数 > 实例 `isolation` > 全局 `instances.mode`)派生出运行目录, +//! 交给 [`snapshot::prepare`] 装进 `LaunchDirs.game_dir`(占位符 `${game_directory}` +//! 与子进程 CWD 都取这个值),并在启动时做一次切换检测(只探测、不迁移)。 + +use std::path::PathBuf; + +use serde_json::json; +use unlucky_core::game::install::instance::{ + read_instance_config, write_instance_config, InstanceConfig, Isolation, +}; +use unlucky_core::game::isolation::{derive_game_dir, observe_mode, IsolationMode}; +use unlucky_core::game::ProfileList; +use unlucky_core::launch::time::{format_rfc3339_millis, now}; +use unlucky_core::launch::{LaunchError, Launcher, LauncherConfig}; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +use crate::cli::LaunchArgs; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +use super::identity::{self, Identity}; +use super::snapshot::{self, PlanInput}; + +/// 执行 `launch`。 +/// +/// # Errors +/// +/// 实例/版本不存在、版本 JSON 不可用、命令行组装失败、进程起不来时返回用户错误。 +pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result { + let profiles = super::instances::read_profiles(ctx)?; + let game = ctx.game(); + let profile = profiles + .profiles + .iter() + .find(|profile| profile.id.as_str() == args.instance || profile.name == args.instance); + + let (input, instance_found) = match profile { + Some(profile) => ( + PlanInput { + version_id: profile.version_id.clone(), + instance_id: profile.id.as_str().to_string(), + instance_name: profile.name.clone(), + game_dir: profile.game_dir.clone(), + java: args.java.clone().or_else(|| profile.java_path.clone()), + username: args.username.clone(), + max_memory: args.max_memory, + ..PlanInput::default() + }, + true, + ), + None => { + // 也接受「直接给版本 id」:没有实例档案时按版本跑一次(脚本化常见)。 + if !game.is_version_installed(&args.instance) { + return Err(CliError::user( + "not_found", + format!( + "既没有实例 `{}`,也没有版本 `{}`", + args.instance, args.instance + ), + ) + .with_hint(instance_hint(&profiles))); + } + ( + PlanInput { + version_id: args.instance.clone(), + instance_name: args.instance.clone(), + java: args.java.clone(), + username: args.username.clone(), + max_memory: args.max_memory, + ..PlanInput::default() + }, + false, + ) + } + }; + + // 实例存在性先确认,再解析身份:微软轨解析身份会联网刷新令牌(有副作用), + // 不该在一个注定 not_found 的启动上白跑一遍、更不该顺手改写凭据。 + let identity = identity::resolve( + ctx, + args.account.as_deref(), + args.username.as_deref(), + args.dry_run, + )?; + let input = PlanInput { + auth: Some(identity.placeholders.clone()), + ..input + }; + let isolation = resolve_isolation(ctx, profile, &input, args.dry_run)?; + let input = PlanInput { + // 派生结果就是 `--gameDir` 的取值(覆盖链已求值完)。 + game_dir: Some(isolation.game_dir.clone()), + ..input + }; + + // D13:实例覆盖 > 托管匹配 > 系统。托管命中时把路径钉死交给 `snapshot::prepare`。 + let java = super::java::resolve_for_launch(ctx, &input.version_id, input.java.as_deref()); + let input = PlanInput { + java: java.override_path.clone(), + ..input + }; + + let plan = snapshot::prepare(ctx, &input)?; + let config = LauncherConfig::new(ctx.sessions_root()) + .with_launcher(super::meta::LAUNCHER_NAME, env!("CARGO_PKG_VERSION")); + let launcher = Launcher::new(config); + let mut human = Vec::new(); + human.push(format!( + "隔离档位 {}(来源 {})· 运行目录 {}", + isolation.isolation.as_str(), + isolation.source.as_str(), + isolation.game_dir.display() + )); + if let Some(warning) = &isolation.warning { + human.push(format!("提示: {warning}")); + } + human.push(identity_line(&identity)); + for note in &identity.notes { + human.push(format!("提示: {note}")); + } + human.push(java.summary()); + for warning in java.warnings() { + human.push(format!("告警: {warning}")); + } + + if args.dry_run { + let dry = launcher + .dry_run(&plan.request) + .map_err(|error| map_launch_error(&error, ctx))?; + let mut lines = vec![dry.line().to_string()]; + lines.push(format!( + "(--dry-run:未启动进程 · classpath {} 项 · 编码 {})", + dry.classpath_entries, dry.encoding + )); + if let Some(natives) = &dry.natives { + lines.push(format!("natives:{}", natives.summary())); + } + let mut warnings = dry.warnings.clone(); + warnings.extend(java.warnings()); + // 人读行里 Java 告警已经在上面打过一次,这里只补 `dry_run` 自己新发现的。 + for warning in &dry.warnings { + lines.push(format!("告警: {warning}")); + } + human.extend(lines); + let natives = dry.natives.as_ref().map(|natives| { + json!({ + "dir": natives.dir.display().to_string(), + "files": natives.files, + "skipped": natives.skipped, + "ignored": natives.ignored, + "jars": natives.jars, + "custom": natives.custom, + "up_to_date": natives.up_to_date, + "era": natives.era.as_str(), + }) + }); + let data = json!({ + "dry_run": true, + "instance_found": instance_found, + "version_id": plan.version_id, + "isolation": isolation.isolation.as_str(), + "isolation_source": isolation.source.as_str(), + "game_dir": isolation.game_dir.display().to_string(), + "mode_change_warning": isolation.warning, + "account": account_json(&identity), + "java": java.to_json(), + "program": dry.program.display().to_string(), + "argv": dry.argv, + "display": dry.display, + "classpath_entries": dry.classpath_entries, + "encoding": dry.encoding, + "warnings": warnings, + "natives": natives, + "adapter_warnings": plan.warnings, + }); + return Ok(Report::new(data, human)); + } + + let handle = launcher + .launch(&plan.request) + .map_err(|error| map_launch_error(&error, ctx))?; + let data = json!({ + "dry_run": false, + "instance_found": instance_found, + "isolation": isolation.isolation.as_str(), + "isolation_source": isolation.source.as_str(), + "game_dir": isolation.game_dir.display().to_string(), + "mode_change_warning": isolation.warning, + "account": account_json(&identity), + "java": java.to_json(), + "warnings": java.warnings(), + "session_id": handle.session_id, + "log_path": handle.log_path.display().to_string(), + "exit_code": handle.exit.code, + "exit_type": handle.exit.exit_type.as_str(), + "uptime_ms": handle.uptime_ms, + "summary": handle.exit.summary(), + "artifacts": handle.artifacts.iter().map(|artifact| artifact.path.display().to_string()).collect::>(), + "adapter_warnings": plan.warnings, + }); + human.push(format!( + "会话 {} 结束:{}", + handle.session_id, + handle.exit.summary() + )); + human.push(format!("事件流: {}", handle.log_path.display())); + Ok(Report::new(data, human)) +} + +/// 隔离派生的结果(运行目录 + 生效档位 + 提示)。 +struct IsolationPlan { + /// 生效运行目录(喂 `--gameDir`)。 + game_dir: PathBuf, + /// 生效档位。 + isolation: Isolation, + /// 档位来自哪一层(`temporary` / `instance` / `global`)。 + source: unlucky_core::game::IsolationSource, + /// 切换检测提示(两侧都有内容时;`None` = 不必提示)。 + warning: Option, +} + +/// 按覆盖链派生运行目录,并在启动时做一次切换检测。 +/// +/// 覆盖链:临时参数(`ull launch` 暂无该 flag,留 `None`) > 实例 `isolation` > 全局 `instances.mode`。 +/// 切换检测只探测 `mods/saves/config` 的条目数并写进实例文件的 `lastModeChange`, +/// **不迁移任何数据**(D20 定案);提示只展示一次(`warned` 落盘)。 +/// +/// 遗留实例(`profiles.json` 有档案、`config/instances/.json` 还没有——M27 之前的形态) +/// 会在**首次真启动**时补建基线文件:否则这批「最可能撞上存档消失」的用户永远拿不到提示。 +/// `--dry-run` 仍然一个字节都不写。 +fn resolve_isolation( + ctx: &Ctx, + profile: Option<&unlucky_core::game::InstanceProfile>, + input: &PlanInput, + dry_run: bool, +) -> Result { + let global_mode = global_mode(ctx); + let mut config = match profile { + Some(profile) => { + read_instance_config(&ctx.layout(), profile.id.as_str()).map_err(|error| { + CliError::user( + "instance_config", + format!("读实例设置 {} 失败:{error}", profile.id.as_str()), + ) + })? + } + None => None, + }; + // 补建基线:有档案没有设置文件 → 先按最小配置在内存里建一份(落盘看下面的 dirty)。 + let bootstrap = config.is_none() && profile.is_some(); + if bootstrap { + config = Some(InstanceConfig::new( + input.instance_id.clone(), + input.version_id.clone(), + )); + } + // 读兼容:M27 之前的档案只在 profiles.json 里存 `game_dir`;这种「显式指定目录」的历史语义 + // 等价于 `custom`,不要静默退回全局共享。补建基线时把这条映射**写进文件**, + // 让实例文件成为唯一真相(profiles.json 的 `game_dir` 是遗留字段)。 + if let Some(config) = config.as_mut() { + if config.isolation.is_none() { + if let Some(dir) = profile + .and_then(|profile| profile.game_dir.clone()) + .filter(|dir| !dir.as_os_str().is_empty()) + { + config.isolation = Some(Isolation::Custom); + config.game_dir = Some(dir); + } + } + } + + let derivation = derive_game_dir( + &ctx.game(), + &input.version_id, + global_mode, + config.as_ref(), + None, + ) + .map_err(|error| CliError::user("isolation", error.to_string()))?; + + // 切换检测:生效档位与记录里的 `to` 不同 → 记一次(探测双侧,只记事实)。 + let at = format_rfc3339_millis(now()); + let mut dirty = false; + let warning = match config.as_mut() { + Some(config) => { + let changed = observe_mode(config, &ctx.game(), &at, derivation.isolation) + .map_err(|error| CliError::user("isolation_probe", error.to_string()))?; + let warning = config.take_mode_change_warning(); + dirty = bootstrap || changed || warning.is_some(); + warning.map(|warning| warning.message()) + } + None => None, + }; + // `--dry-run` 不落盘(否则「只看一眼」也会吃掉一次性提示、改写实例状态)。 + if !dry_run && dirty { + if let Some(config) = config.as_ref() { + write_instance_config(&ctx.layout(), config).map_err(|error| { + CliError::user("instance_config", format!("写实例设置失败:{error}")) + })?; + } + } + + Ok(IsolationPlan { + game_dir: derivation.game_dir, + isolation: derivation.isolation, + source: derivation.source, + warning, + }) +} + +/// 全局默认隔离模式(`launcher.json` 的 `instances.mode`,缺键/坏值一律按 D20 默认 `isolated`)。 +fn global_mode(ctx: &Ctx) -> IsolationMode { + let store = SettingStore::load(ctx.launcher_config(), launcher_schema()); + store + .doc() + .effective_str("instances.mode") + .and_then(IsolationMode::parse) + .unwrap_or_default() +} + +fn map_launch_error(error: &LaunchError, ctx: &Ctx) -> CliError { + let hint = format!("版本本体目录:{}", ctx.game().versions_dir().display()); + CliError::user("launch", error.to_string()).with_hint(hint) +} + +fn instance_hint(profiles: &ProfileList) -> String { + if profiles.profiles.is_empty() { + "用 `ull instances list` 查看实例,或 `ull install ` 登记一个".to_string() + } else { + let names: Vec<&str> = profiles + .profiles + .iter() + .map(|profile| profile.name.as_str()) + .collect(); + format!("可用实例:{}", names.join("、")) + } +} + +/// 人读一行:本次用谁的身份启动。 +fn identity_line(identity: &Identity) -> String { + match &identity.account { + Some(meta) => format!( + "账户 {}({})· UUID {}", + meta.display_name(), + meta.kind.label(), + identity.placeholders.uuid + ), + None => format!( + "账户 离线占位 {}(未登记账户)", + identity.placeholders.player_name + ), + } +} + +/// `--json` 里的身份块(**只有可公开字段**,token 永远不出现)。 +fn account_json(identity: &Identity) -> serde_json::Value { + match &identity.account { + Some(meta) => json!({ + "name": meta.display_name(), + "kind": meta.kind.as_str(), + "profile_id": identity.placeholders.uuid, + "account_id": meta.id.as_str(), + "registered": true, + }), + None => json!({ + "name": identity.placeholders.player_name, + "kind": "offline-placeholder", + "profile_id": identity.placeholders.uuid, + "registered": false, + }), + } +} diff --git a/crates/cli/src/commands/meta.rs b/crates/cli/src/commands/meta.rs new file mode 100644 index 0000000..d49425c --- /dev/null +++ b/crates/cli/src/commands/meta.rs @@ -0,0 +1,57 @@ +//! 元命令:`--version` / 无子命令 / `help`。 + +use serde_json::json; + +use crate::cli::help; +use crate::os; +use crate::output::Report; + +/// 启动器名(`${launcher_name}` 与 `--version` 输出共用)。 +pub const LAUNCHER_NAME: &str = "UnluckyLauncher"; + +/// 版本 + 系统摘要(`ull --version`,D19 §5-2 要求附带 os 摘要)。 +/// +/// 探测走 `core::platform::os_info::current()`(进程内缓存),未达基线时附带升级指引。 +#[must_use] +pub fn version() -> Report { + let info = os::detect(); + let data = json!({ + "name": "ull", + "package": env!("CARGO_PKG_NAME"), + "version": env!("CARGO_PKG_VERSION"), + "os": os::to_json(&info), + }); + let mut human = vec![ + format!( + "ull {}({})", + env!("CARGO_PKG_VERSION"), + env!("CARGO_PKG_NAME") + ), + format!("os: {}", os::summary_line(&info)), + ]; + human.extend(os::notes(&info)); + Report::new(data, human) +} + +/// 无子命令:版本摘要 + 帮助(用户原话「无子命令时 --version + --help」)。 +#[must_use] +pub fn welcome() -> Report { + let version_report = version(); + let mut human = version_report.human; + human.push(String::new()); + human.extend(help::general().lines().map(str::to_string)); + let mut data = version_report.data; + data["help"] = json!(help::general()); + Report::new(data, human) +} + +/// 帮助(`ull help <命令>` 给该命令,未知主题退回总帮助)。 +#[must_use] +pub fn help(topic: Option<&str>) -> Report { + let text = topic.and_then(help::topic).unwrap_or_else(help::general); + let data = json!({ + "topic": topic, + "text": text, + }); + Report::new(data, text.lines().map(str::to_string).collect()) +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs new file mode 100644 index 0000000..e6e385e --- /dev/null +++ b/crates/cli/src/commands/mod.rs @@ -0,0 +1,155 @@ +//! 命令分发:命令模型 → 具体实现,并统一挂上环境提示。 + +pub mod accounts; +pub mod agent; +pub mod config; +pub mod identity; +pub mod install; +pub mod instances; +pub mod java; +pub mod java_launch; +pub mod java_vendors; +pub mod launch; +pub mod meta; +pub mod sessions; +pub mod snapshot; + +use crate::cli::{Command, Globals, Invocation}; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::{Outcome, Report}; + +/// 执行一条已解析的命令。 +/// +/// # Errors +/// +/// 用法/数据根/目标不存在等用户错误,或序列化失配等内部错误。 +pub fn run(invocation: &Invocation) -> Result { + match &invocation.command { + // 这三条不需要数据根(`--version` 在不达基线的机器上也要能跑,用于诊断)。 + Command::Welcome => Ok(Outcome::new(meta::welcome())), + Command::Version => Ok(Outcome::new(meta::version())), + Command::Help(topic) => Ok(Outcome::new(meta::help(topic.as_deref()))), + Command::InstancesList => with_ctx(&invocation.globals, instances::list), + Command::Install(args) => with_ctx(&invocation.globals, |ctx| install::run(ctx, args)), + Command::Launch(args) => with_ctx(&invocation.globals, |ctx| launch::run(ctx, args)), + Command::SessionsList { limit } => { + with_ctx(&invocation.globals, |ctx| sessions::list(ctx, *limit)) + } + Command::Logs(args) => with_ctx(&invocation.globals, |ctx| sessions::logs(ctx, args)), + Command::Diag(args) => with_ctx(&invocation.globals, |ctx| sessions::diag(ctx, args)), + Command::ConfigGet(args) => with_ctx(&invocation.globals, |ctx| { + config::get(ctx, args.key.as_deref(), args.show_secret) + }), + Command::ConfigSet(args) => with_ctx(&invocation.globals, |ctx| { + config::set(ctx, &args.key, &args.value) + }), + Command::JavaList => with_ctx(&invocation.globals, java::list), + Command::JavaInstall { major, vendor } => with_ctx(&invocation.globals, |ctx| { + java::install(ctx, *major, *vendor) + }), + Command::JavaVendors => with_ctx(&invocation.globals, java::vendors), + Command::AccountsList => with_ctx(&invocation.globals, accounts::list), + Command::AccountsAdd(args) => with_ctx(&invocation.globals, |ctx| accounts::add(ctx, args)), + Command::AccountsRemove { name } => { + with_ctx(&invocation.globals, |ctx| accounts::remove(ctx, name)) + } + // `AgentServe` 由 `runner::run` 提前分流(stdio 形态下 stdout 就是协议流, + // 不能走「先返回 Report 再打印」的路径);这里只兜住「有人直接调 commands::run」。 + Command::AgentServe(_) => Err(CliError::usage( + "agent serve 必须在进程入口直接运行(runner 会提前分流)", + )), + Command::AgentConfig { port } => { + with_ctx(&invocation.globals, |ctx| agent::config_report(ctx, *port)) + } + } +} + +/// `core::auth` 的错误 → CLI 错误(保留稳定机读码;文案已是人话,这里只补「下一步」)。 +/// +/// 集中一处的原因:账户相关命令(`accounts *`)与 `launch` 的身份解析要为**同一批** +/// 错误给出一致的码与提示——两处各写一份必然会漂。 +pub(crate) fn map_auth_error(error: unlucky_core::auth::AuthError) -> CliError { + use unlucky_core::auth::body; + use unlucky_core::auth::AuthError; + let hint = match &error { + AuthError::Canceled => Some("登录被取消,可以重跑一次".to_string()), + AuthError::Timeout { .. } => { + Some("重新执行 `ull accounts add msa` 拿新的用户码".to_string()) + } + AuthError::CredentialExpired => Some("重新登录:ull accounts add msa".to_string()), + AuthError::Unsupported(_) => { + Some("微软登录需要设置 ULL_MSA_CLIENT_ID(Azure 公共客户端 id)".to_string()) + } + AuthError::Conflict { .. } => Some("用 `ull accounts list` 查看已有账户".to_string()), + AuthError::NotFound { .. } => Some("用 `ull accounts list` 查看账户名".to_string()), + AuthError::Denied { code, .. } if code == "no_profile" || code == "no_license" => { + Some("去 minecraft.net 检查该账号的 Java 版拥有权与档案".to_string()) + } + AuthError::Xbox { .. } => { + Some("去 xbox.com / 家庭组设置里解决该账号的 Xbox 限制".to_string()) + } + // M38 的两类分流落到 hint 行:拦截页(HTML/纯文本/空体)先查网络, + // JSON 拒绝按步骤查许可与区域。`code()` 仍是 "denied"(M31 兼容), + // 细分结论由 `detail_code()` 带在 hint 里,排障脚本照样能机读分流。 + AuthError::Response(detail) if detail.is_interception() => Some(format!( + "检查网络或代理(关掉/换掉代理后再试,必要时用镜像方案);诊断码 {}", + detail.detail_code() + )), + AuthError::Response(detail) => Some(format!( + "{};诊断码 {}", + body::advice(&detail.step, detail.kind), + detail.detail_code() + )), + _ => None, + }; + let cli = CliError::user(error.code(), error.message()); + match hint { + Some(hint) => cli.with_hint(hint), + None => cli, + } +} + +/// 需要数据根的命令:解析上下文 → D19 支持级别闸门 → 执行 → 挂环境提示。 +fn with_ctx(globals: &Globals, action: F) -> Result +where + F: FnOnce(&Ctx) -> Result, +{ + let ctx = Ctx::resolve(globals)?; + ctx.ensure_supported()?; + let report = action(&ctx)?; + Ok(Outcome::with_warnings(report, ctx.notes())) +} + +#[cfg(test)] +mod tests { + use super::map_auth_error; + use unlucky_core::auth::{AuthError, BodyKind, ResponseDetail}; + + fn response(step: &str, kind: BodyKind) -> AuthError { + AuthError::Response(Box::new(ResponseDetail { + step: step.to_string(), + what: "Minecraft 登录被拒".to_string(), + status: 403, + content_type: None, + kind, + server_message: None, + digest: String::new(), + })) + } + + #[test] + fn response_hints_split_network_from_license_and_carry_detail_code() { + let blocked = map_auth_error(response("login_with_xbox", BodyKind::Html)); + assert_eq!(blocked.code(), "denied", "code() 仍是 M31 的 denied"); + let hint = blocked.hint().expect("拦截页要有 hint").to_string(); + assert!(hint.contains("代理"), "{hint}"); + assert!(hint.contains("login_with_xbox_non_json"), "{hint}"); + + let denied = map_auth_error(response("login_with_xbox", BodyKind::Json)); + let hint = denied.hint().expect("JSON 拒绝要有 hint").to_string(); + assert!(hint.contains("许可"), "{hint}"); + assert!(!hint.contains("代理"), "{hint}"); + assert!(hint.contains("login_with_xbox_403"), "{hint}"); + } +} diff --git a/crates/cli/src/commands/sessions.rs b/crates/cli/src/commands/sessions.rs new file mode 100644 index 0000000..a125914 --- /dev/null +++ b/crates/cli/src/commands/sessions.rs @@ -0,0 +1,292 @@ +//! `sessions list` / `logs` / `diag`:读 M18 的会话索引与 JSONL 事件流。 +//! +//! 数据源形状见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§5: +//! - `/game/index.jsonl`:一会话一行摘要(同 id 取最后一行,倒序); +//! - `/game/.jsonl`:事件流(`kind` + `payload`)。 +//! +//! `diag` 把「退出信号 + stdout/stderr 文本」喂给 M15 的内嵌规则集,输出结构化结论。 + +use std::path::Path; + +use serde_json::{json, Value}; +use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin}; +use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput}; +use unlucky_core::diagnose::loader::RuleLoader; +use unlucky_core::launch::session_index::{self, SessionIndexEntry}; + +use crate::cli::{DiagArgs, LogsArgs}; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +/// `ull sessions list [--limit N]`。 +/// +/// # Errors +/// +/// 索引文件读不了时返回用户错误(不存在 = 空列表)。 +pub fn list(ctx: &Ctx, limit: usize) -> Result { + let index = ctx.session_index(); + let entries = session_index::read_all(&index) + .map_err(|error| CliError::io("读会话索引", &index, &error))?; + let shown: Vec<&SessionIndexEntry> = entries.iter().take(limit).collect(); + + let rows: Vec = shown + .iter() + .map(|entry| { + json!({ + "session_id": entry.session_id, + "instance_id": entry.instance_id, + "instance_name": entry.instance_name, + "game_version": entry.game_version, + "started_at": entry.started_at, + "ended_at": entry.ended_at, + "duration_ms": entry.duration_ms, + "exit_code": entry.exit_code, + "exit_type": entry.exit_type, + "silent_failure": entry.silent_failure, + "window_detected": entry.window_detected, + "crash_found": entry.crash_found, + "diagnosis_top": entry.diagnosis_top, + }) + }) + .collect(); + + let mut human = vec![format!( + "会话 {} 条(索引 {})", + shown.len(), + index.display() + )]; + if shown.is_empty() { + human.push(" (空)先用 `ull launch <实例>` 跑一局".to_string()); + } + for entry in &shown { + human.push(format!( + " {} {} {} {} {}", + entry.session_id, + entry.instance_name, + entry.game_version, + entry.started_at, + exit_label(entry), + )); + } + + Ok(Report::new(json!({ "sessions": rows }), human)) +} + +/// `ull logs [--tail N]`:打印事件流末尾 N 条。 +/// +/// # Errors +/// +/// 会话不存在/前缀有歧义、事件流读不了时返回用户错误。 +pub fn logs(ctx: &Ctx, args: &LogsArgs) -> Result { + let entry = find_entry(ctx, &args.session_id)?; + let path = event_path(ctx, &entry); + let events = read_events(&path)?; + let start = events.len().saturating_sub(args.tail.max(1)); + let shown = &events[start..]; + + // 脱敏一次、两条输出共用:`--json` 是机器消费路径,不该比人类路径拿到更多原文。 + let shown: Vec = shown.iter().map(redact_value).collect(); + let mut human = vec![format!( + "会话 {} · {} · 事件 {} 条(显示末尾 {})", + entry.session_id, + entry.instance_name, + events.len(), + shown.len() + )]; + human.extend(shown.iter().map(render_event)); + + Ok(Report::new( + json!({ + "session_id": entry.session_id, + "path": path.display().to_string(), + "total": events.len(), + "events": shown, + }), + human, + )) +} + +/// `ull diag [--tail N]`:规则引擎诊断。 +/// +/// # Errors +/// +/// 会话不存在、事件流读不了、结论序列化失败时返回错误。 +pub fn diag(ctx: &Ctx, args: &DiagArgs) -> Result { + let entry = find_entry(ctx, &args.session_id)?; + let path = event_path(ctx, &entry); + let events = read_events(&path)?; + + let mut stdout_lines: Vec<&str> = Vec::new(); + let mut stderr_lines: Vec<&str> = Vec::new(); + let mut error_lines: u32 = 0; + for event in &events { + let Some(line) = event.pointer("/payload/line").and_then(Value::as_str) else { + continue; + }; + match event.get("kind").and_then(Value::as_str) { + Some("stdout") => stdout_lines.push(line), + Some("stderr") => { + stderr_lines.push(line); + if event.pointer("/payload/level").and_then(Value::as_str) == Some("error") { + error_lines += 1; + } + } + _ => {} + } + } + let stdout_text = tail_join(&stdout_lines, args.tail); + let stderr_text = tail_join(&stderr_lines, args.tail); + + let exit = ExitSignal::classify( + entry.exit_code, + Some(entry.duration_ms), + error_lines, + &stderr_text, + ); + let mut input = DiagnosisInput::new().with_exit(exit); + if !stdout_text.is_empty() { + input.push_text(SignalOrigin::Stdout, stdout_text); + } + if !stderr_text.is_empty() { + input.push_text(SignalOrigin::Stderr, stderr_text); + } + + let loader = RuleLoader::embedded(); + let diagnoser = Diagnoser::new(loader.rule_set().rules().to_vec()); + let report = diagnoser.analyze(&input); + let record = serde_json::to_value(report.to_record()) + .map_err(|error| CliError::internal("diagnosis_serialize", error.to_string()))?; + + let mut human = vec![format!( + "会话 {} · {} · {} · {}", + entry.session_id, + entry.instance_name, + entry.game_version, + exit_label(&entry) + )]; + match report.primary() { + Some(finding) => { + human.push(format!("主因: {}", finding.message)); + if let Some(detail) = &finding.detail { + human.push(format!("说明: {detail}")); + } + for evidence in finding.evidence.iter().take(3) { + human.push(format!("证据: {}", evidence.render())); + } + } + None => human.push("规则引擎没有得出确定结论(needs_more_info)".to_string()), + } + human.push(format!( + "规则评估 {} 条 · 结论 {} 条 · 次生 {} 条", + report.rules_evaluated, + report.findings.len(), + report.secondary.len() + )); + let human: Vec = human + .iter() + .map(|line| unlucky_agent::redact::redact_text(line)) + .collect(); + + let payload = redact_value(&json!({ + "session_id": entry.session_id, + "instance_id": entry.instance_id, + "game_version": entry.game_version, + "exit": { + "code": entry.exit_code, + "exit_type": entry.exit_type, + "silent_failure": entry.silent_failure, + "duration_ms": entry.duration_ms, + }, + "diagnosis": record, + })); + Ok(Report::new(payload, human)) +} + +fn find_entry(ctx: &Ctx, key: &str) -> Result { + let index = ctx.session_index(); + let entries = session_index::read_all(&index) + .map_err(|error| CliError::io("读会话索引", &index, &error))?; + if let Some(entry) = entries.iter().find(|entry| entry.session_id == key) { + return Ok(entry.clone()); + } + let matches: Vec<&SessionIndexEntry> = entries + .iter() + .filter(|entry| entry.session_id.starts_with(key)) + .collect(); + match matches.as_slice() { + [only] => Ok((*only).clone()), + [] => Err( + CliError::user("session_not_found", format!("找不到会话 `{key}`")) + .with_hint(format!("索引文件:{}", index.display())), + ), + _ => Err(CliError::user( + "session_ambiguous", + format!("`{key}` 匹配到 {} 个会话,请给更长的前缀", matches.len()), + )), + } +} + +fn event_path(ctx: &Ctx, entry: &SessionIndexEntry) -> std::path::PathBuf { + ctx.sessions_root().join("game").join(&entry.log_path) +} + +fn read_events(path: &Path) -> Result, CliError> { + let text = std::fs::read_to_string(path) + .map_err(|error| CliError::io("读会话事件流", path, &error))?; + Ok(text + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect()) +} + +fn tail_join(lines: &[&str], tail: usize) -> String { + let start = lines.len().saturating_sub(tail.max(1)); + let mut text = lines[start..].join("\n"); + if !text.is_empty() { + text.push('\n'); + } + text +} + +fn render_event(event: &Value) -> String { + let ts = event.get("ts").and_then(Value::as_str).unwrap_or(""); + let kind = event.get("kind").and_then(Value::as_str).unwrap_or("?"); + let detail = event + .pointer("/payload/line") + .and_then(Value::as_str) + .or_else(|| event.pointer("/payload/summary").and_then(Value::as_str)) + .unwrap_or_default(); + format!("[{ts}] {kind} {detail}") +} + +/// 递归脱敏任意 JSON(所有字符串过 `redact_text`:主目录用户名段 + Bearer 令牌)。 +fn redact_value(value: &Value) -> Value { + match value { + Value::String(text) => Value::String(unlucky_agent::redact::redact_text(text)), + Value::Array(items) => Value::Array(items.iter().map(redact_value).collect()), + Value::Object(map) => Value::Object( + map.iter() + .map(|(key, child)| (key.clone(), redact_value(child))) + .collect(), + ), + other => other.clone(), + } +} + +fn exit_label(entry: &SessionIndexEntry) -> String { + let code = entry + .exit_code + .map_or_else(|| "无退出码".to_string(), |code| format!("退出码 {code}")); + let exit_type = entry + .exit_type + .clone() + .unwrap_or_else(|| "unknown".to_string()); + let silent = if entry.silent_failure { + " · 静默失败" + } else { + "" + }; + format!("{code}/{exit_type}{silent}") +} diff --git a/crates/cli/src/commands/snapshot.rs b/crates/cli/src/commands/snapshot.rs new file mode 100644 index 0000000..b37fd9a --- /dev/null +++ b/crates/cli/src/commands/snapshot.rs @@ -0,0 +1,205 @@ +//! 实例 → [`LaunchRequest`] 的装配:launch 命令与 `--dry-run` 共用这一段。 +//! +//! 全部走 core 的既有公共面(`game::resolve` / `arguments.expand` / `GameLayout` / +//! `launch::adapter::build_request`),CLI 不复制任何继承/参数展开逻辑。 + +use std::path::PathBuf; + +use crate::context::Ctx; +use crate::error::CliError; +use unlucky_core::game::arguments::Environment; +use unlucky_core::game::{self, ProfileId, VersionJson}; +use unlucky_core::launch::adapter::{ + self, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput, +}; +use unlucky_core::launch::request::{AuthPlaceholders, LaunchRequest, MemoryOptions}; + +/// 装配输入(来自命令行 + 实例档案)。 +#[derive(Debug, Clone, Default)] +pub struct PlanInput { + /// 版本 id(实例绑定的 `versions//`)。 + pub version_id: String, + /// 实例 id(写进会话索引;无档案时为空)。 + pub instance_id: String, + /// 实例显示名。 + pub instance_name: String, + /// 已派生的运行目录(D20 隔离覆盖链在 `super::launch` 求值完,这里只消费)。 + pub game_dir: Option, + /// Java 可执行文件覆盖。 + pub java: Option, + /// 离线账户名(无账户时生效;有账户时身份由 `auth` 决定)。 + pub username: Option, + /// 已解析好的账户占位(`None` = 退回 `username` 的离线占位)。 + pub auth: Option, + /// 最大堆 MB。 + pub max_memory: Option, +} + +/// 装配结果。 +#[derive(Debug, Clone)] +pub struct Plan { + /// 实际使用的版本 id。 + pub version_id: String, + /// 装配好的启动请求。 + pub request: LaunchRequest, + /// 非致命告警(缺主类/缺资源索引等)。 + pub warnings: Vec, +} + +/// 装配一个 [`LaunchRequest`]。 +/// +/// # Errors +/// +/// 版本 JSON 缺失/非法、继承链断裂(缺父版本/成环)时返回用户错误。 +pub fn prepare(ctx: &Ctx, input: &PlanInput) -> Result { + let game = ctx.game(); + let version_id = input.version_id.clone(); + let manifest_path = game.version_json(&version_id); + let manifest = read_version_json(&manifest_path)?; + + let source = |id: &str| -> Option { + let path = game.version_json(id); + read_version_json(&path).ok() + }; + let resolved = game::resolve(&source, &manifest).map_err(|error| { + CliError::user("inherit", format!("解析继承链失败:{error}")) + .with_hint(format!("检查 {}", manifest_path.display())) + })?; + + let environment = Environment::host(); + let expanded = resolved + .arguments + .as_ref() + .map(|arguments| arguments.expand(&environment)) + .unwrap_or_default(); + + let mut libraries: Vec = Vec::new(); + for library in resolved.libraries.as_deref().unwrap_or_default() { + if !library.applies(&environment) || library.native_classifier(&environment).is_some() { + continue; + } + let Ok(coordinate) = library.coordinate() else { + continue; + }; + let path = if library.is_local() { + game.version_library(&version_id, &coordinate) + } else { + game.library(&coordinate) + }; + libraries.push(LibraryInput::new(path, library.name.clone())); + } + + // natives 分类器构件(≤1.18.2 才有):收集口径归 core,CLI 只搬运。 + let natives = unlucky_core::launch::natives::collect( + &game, + &version_id, + resolved.libraries.as_deref().unwrap_or_default(), + &environment, + ); + + let jar_id = resolved.jar.clone().unwrap_or_else(|| version_id.clone()); + let primary_jar = game.version_jar(&jar_id); + // 运行目录:调用方(`ull launch`)已按隔离覆盖链派生;`None` 兜底到游戏根 + // (库/其它消费者直接构造 `PlanInput` 时保持旧行为)。 + let game_dir = input + .game_dir + .clone() + .unwrap_or_else(|| ctx.data.game_home.clone()); + let assets_root = game.assets_dir(); + let assets_index = resolved + .assets + .clone() + .or_else(|| resolved.asset_index.as_ref().map(|index| index.id.clone())); + + let snapshot = SnapshotInput { + version_id: &version_id, + version_name: Some(&input.instance_name), + version_type: None, + game_version: None, + main_class: resolved.main_class.as_deref(), + jvm_arguments: &expanded.jvm, + game_arguments: &expanded.game, + legacy_arguments: resolved.minecraft_arguments.as_deref(), + libraries: &libraries, + natives: &natives, + primary_jar, + game_dir, + assets_index: assets_index.as_deref(), + natives_dir: game.natives_dir(&version_id), + library_dir: game.libraries_dir(), + assets_root: assets_root.clone(), + game_assets: assets_root, + }; + + let options = AdapterOptions { + java: super::java::selection(input.java.as_deref()), + memory: MemoryOptions { + min_mb: None, + max_mb: input.max_memory, + }, + auth: input.auth.clone().unwrap_or_else(|| AuthPlaceholders { + player_name: input + .username + .clone() + .unwrap_or_else(|| "Player".to_string()), + uuid: ProfileId::generate().as_str().replace('-', ""), + user_type: "legacy".to_string(), + user_properties: "{}".to_string(), + ..AuthPlaceholders::default() + }), + launcher_name: super::meta::LAUNCHER_NAME.to_string(), + launcher_version: env!("CARGO_PKG_VERSION").to_string(), + language: "zh-CN".to_string(), + ..AdapterOptions::strict() + }; + + let output = adapter::build_request(&snapshot, options); + let warnings = output.warnings.iter().map(describe_warning).collect(); + let mut request = output.request; + // 会话索引要用实例身份(adapter 默认拿版本 id/名)。 + if !input.instance_id.is_empty() { + request.instance.id = input.instance_id.clone(); + } + request.instance.name = input.instance_name.clone(); + + Ok(Plan { + version_id, + request, + warnings, + }) +} + +fn read_version_json(path: &std::path::Path) -> Result { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(CliError::user( + "version_missing", + format!("版本 JSON 不存在:{}", path.display()), + ) + .with_hint("先准备好版本本体文件(下载装配属 P1 后续 mission)")); + } + Err(error) => return Err(CliError::io("读版本 JSON", path, &error)), + }; + VersionJson::from_json_str(&text) + .map_err(|error| CliError::user("version_parse", format!("{}:{error}", path.display()))) +} + +/// 适配层告警 → 人读一行(`AdapterWarning` 没有 Display,这里集中翻译)。 +fn describe_warning(warning: &AdapterWarning) -> String { + match warning { + AdapterWarning::MissingLibrary { name, path } => { + format!("缺少库文件 {name}({})", path.display()) + } + AdapterWarning::MissingGameArguments => { + "版本 JSON 既没有 arguments.game 也没有 minecraftArguments".to_string() + } + AdapterWarning::MissingAssetsIndex => { + "版本 JSON 没有资源索引 id(${assets_index_name} 为空)".to_string() + } + AdapterWarning::MissingMainClass => "版本 JSON 没有 mainClass".to_string(), + AdapterWarning::MissingNativeJar { name, path } => { + format!("缺少 natives 构件 {name}({})", path.display()) + } + } +} diff --git a/crates/cli/src/context.rs b/crates/cli/src/context.rs new file mode 100644 index 0000000..1ae4b90 --- /dev/null +++ b/crates/cli/src/context.rs @@ -0,0 +1,238 @@ +//! 数据根解析与目录视图。 +//! +//! 三模式(D6 / `04-data-and-config.md` §1)全部交给 `core::platform::data_dir`: +//! - `--data-dir ` → custom 模式(`/.ULL` + `/.minecraft`); +//! - 否则先按便携目录的 `launcher.json` 读 `core.data_mode` / `core.data_custom_root` / +//! `core.launcher_home` / `core.game_home`,再交 [`Resolver`] 解析(含 `redirect.json` 跟随)。 +//! +//! CLI 不自己拼路径:目录形状的真相在 `core::platform::layout`。 +//! +//! `ull config get/set` 的落点也从这里出:[`Ctx::setting_path_for`] 让决定数据根的 +//! `core.*` 键落在**便携目录那份** `launcher.json`(与解析同源),其余键落解析后的数据根。 + +use std::path::{Path, PathBuf}; + +use unlucky_core::game::GameLayout; +use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, ResolvedData, Resolver}; +use unlucky_core::platform::layout::{ + LauncherLayout, CONFIG_DIR, LAUNCHER_CONFIG_FILE, LAUNCHER_HOME_DIR, +}; +use unlucky_core::platform::paths::Platform; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +use crate::cli::Globals; +use crate::error::CliError; +use crate::os; + +/// 决定数据根本身的配置键(`request_from_portable_config` 读的就是它们)。 +/// +/// `ull config get/set` 对这几个键必须读写**同一份文件**(便携目录那份),否则会出现 +/// 「`config get core.data_mode` 说 portable、实际按 standard 跑」这种两套真相 +/// (finding 20261030-worker-java2-improve-ull-config-get-set-launcher-json)。 +pub const DATA_ROOT_KEYS: [&str; 4] = [ + "core.data_mode", + "core.data_custom_root", + "core.launcher_home", + "core.game_home", +]; + +/// 该键是否决定数据根。 +#[must_use] +pub fn is_data_root_key(key: &str) -> bool { + DATA_ROOT_KEYS.contains(&key) +} + +/// 便携目录那份 `launcher.json` 的路径(`/.ULL/config/launcher.json`)。 +#[must_use] +pub fn portable_config_path(exe_dir: &Path) -> PathBuf { + exe_dir + .join(LAUNCHER_HOME_DIR) + .join(CONFIG_DIR) + .join(LAUNCHER_CONFIG_FILE) +} + +/// 一次命令执行的上下文:数据根 + 平台 + 系统摘要。 +#[derive(Debug, Clone)] +pub struct Ctx { + /// 解析后的数据目录(launcher home / game home)。 + pub data: ResolvedData, + /// 宿主平台族。 + pub platform: Platform, + /// 系统信息与支持等级(D19 探测 / D21 Win10+ 单基线)。 + pub os: unlucky_core::platform::os_info::OsInfo, + /// exe 目录(便携目录那份 `launcher.json` 的根;拿不到时为 `None`)。 + exe_dir: Option, + /// `--data-dir` 是否由命令行强制指定(此时数据根不看 `core.*` 键)。 + data_dir_forced: bool, +} + +impl Ctx { + /// 解析数据根。 + /// + /// # Errors + /// + /// `--data-dir` 非绝对路径、exe 目录拿不到、`redirect.json` 损坏等返回用户错误。 + pub fn resolve(globals: &Globals) -> Result { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)); + Self::resolve_in(globals, exe_dir) + } + + /// 指定 exe 目录的解析入口(单测用它把便携目录钉在临时目录里)。 + /// + /// # Errors + /// + /// 同 [`Self::resolve`]。 + pub fn resolve_in(globals: &Globals, exe_dir: Option) -> Result { + let platform = Platform::current(); + let data_dir_forced = globals.data_dir.is_some(); + let request = match &globals.data_dir { + Some(dir) => { + if !dir.is_absolute() { + return Err(CliError::usage(format!( + "--data-dir 需要绝对路径,收到 `{}`", + dir.display() + ))); + } + DataDirRequest { + mode: DataMode::Custom, + custom_root: Some(dir.clone()), + ..DataDirRequest::default() + } + } + None => request_from_portable_config(exe_dir.clone()), + }; + let resolver = Resolver::new(platform, exe_dir.clone()); + let data = resolver + .resolve_default(&request) + .map_err(|error| CliError::user("data_dir", error.to_string()))?; + Ok(Self { + data, + platform, + os: os::detect(), + exe_dir, + data_dir_forced, + }) + } + + /// 启动器数据布局(`.ULL/` 四分区)。 + #[must_use] + pub fn layout(&self) -> LauncherLayout { + self.data.layout() + } + + /// 游戏目录布局(`.minecraft/`)。 + #[must_use] + pub fn game(&self) -> GameLayout { + GameLayout::new(self.data.game_home.clone()) + } + + /// `launcher.json` 路径。 + #[must_use] + pub fn launcher_config(&self) -> PathBuf { + self.layout().launcher_config() + } + + /// 便携目录那份 `launcher.json`(数据根解析的唯一输入;拿不到 exe 目录时为 `None`)。 + #[must_use] + pub fn portable_launcher_config(&self) -> Option { + self.exe_dir.as_deref().map(portable_config_path) + } + + /// `--data-dir` 是否由命令行强制指定(此时 `core.*` 数据根键本轮不参与解析)。 + #[must_use] + pub const fn data_dir_forced(&self) -> bool { + self.data_dir_forced + } + + /// 某个配置键的读写落点。 + /// + /// 数据根键([`DATA_ROOT_KEYS`])走便携目录那份——和 [`Self::resolve`] 读的是同一份文件, + /// 这样 `config get core.data_mode` 展示的就是真正生效的值,`config set` 也真的会影响 + /// 下次解析;其余键走解析后的数据根(`/.ULL/config/launcher.json`)。 + #[must_use] + pub fn setting_path_for(&self, key: &str) -> PathBuf { + if is_data_root_key(key) { + if let Some(path) = self.portable_launcher_config() { + return path; + } + } + self.launcher_config() + } + + /// `profiles.json` 路径。 + #[must_use] + pub fn profiles_config(&self) -> PathBuf { + self.layout().profiles_config() + } + + /// 会话根(`/agent/sessions`,launch 的事件流与索引都在这下面)。 + #[must_use] + pub fn sessions_root(&self) -> PathBuf { + self.layout().agent_sessions() + } + + /// 账户数据目录(`data/auth/`:`accounts.json` + `credentials.dat`)。 + #[must_use] + pub fn auth_data(&self) -> PathBuf { + self.layout().auth_data() + } + + /// 会话索引文件(`/game/index.jsonl`)。 + #[must_use] + pub fn session_index(&self) -> PathBuf { + unlucky_core::launch::session_index::index_path(&self.sessions_root()) + } + + /// D19/D21:低于基线(Win10+ / macOS 12+ / Linux 达标)时拒绝执行。 + /// + /// # Errors + /// + /// 系统不支持时返回 `unsupported_os` 用户错误(文案与核心自检同一份)。 + pub fn ensure_supported(&self) -> Result<(), CliError> { + let check = os::check(); + if check.is_usable() { + return Ok(()); + } + Err(CliError::user("unsupported_os", check.message) + .with_hint(format!("最低要求:{}", os::BASELINE))) + } + + /// 环境级提示(低于基线时的升级指引;一等基线时为空)。 + #[must_use] + pub fn notes(&self) -> Vec { + os::notes(&self.os) + } +} + +/// 便携目录的 `launcher.json` → 三模式请求。 +/// +/// 只读 `effective`(文件值 → 默认值),坏文件也永不 panic(`SettingStore` 的加载降级)。 +/// 路径由 [`portable_config_path`] 决定,`config get/set` 用的是同一个函数(同源)。 +fn request_from_portable_config(exe_dir: Option) -> DataDirRequest { + let mut request = DataDirRequest::default(); + let Some(exe_dir) = exe_dir else { + return request; + }; + let path = portable_config_path(&exe_dir); + if !path.is_file() { + return request; + } + let store = SettingStore::load(&path, launcher_schema()); + let text = |key: &str| store.doc().effective_str(key).map(str::to_string); + if let Some(mode) = text("core.data_mode").as_deref().and_then(DataMode::parse) { + request.mode = mode; + } + if let Some(root) = text("core.data_custom_root") { + request.custom_root = Some(PathBuf::from(root)); + } + if let Some(home) = text("core.launcher_home") { + request.launcher_home = Some(PathBuf::from(home)); + } + if let Some(home) = text("core.game_home") { + request.game_home = Some(PathBuf::from(home)); + } + request +} diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs new file mode 100644 index 0000000..ca2f138 --- /dev/null +++ b/crates/cli/src/error.rs @@ -0,0 +1,157 @@ +//! 错误形状与退出码。 +//! +//! 约定(10-cli-design.md §3):**0 成功 / 1 用户错误 / 2 内部错误**。 +//! 用户错误 = 用法错、目标不存在、环境不支持、依赖模块未接线; +//! 内部错误 = 不该发生的事(序列化失配等),脚本见到 2 应该当 bug 报。 + +use std::fmt; + +use serde_json::{json, Value}; + +/// 退出码:成功。 +pub const EXIT_OK: i32 = 0; +/// 退出码:用户错误。 +pub const EXIT_USER: i32 = 1; +/// 退出码:内部错误。 +pub const EXIT_INTERNAL: i32 = 2; + +/// 错误大类(决定退出码)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorKind { + /// 用户侧问题:改参数/改环境可解决。 + User, + /// 内部问题:不该发生,应报 bug。 + Internal, +} + +impl ErrorKind { + /// 稳定名(`--json` 输出用)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::User => "user", + Self::Internal => "internal", + } + } + + /// 对应的进程退出码。 + #[must_use] + pub const fn exit_code(self) -> i32 { + match self { + Self::User => EXIT_USER, + Self::Internal => EXIT_INTERNAL, + } + } +} + +/// 一条 CLI 错误。 +/// +/// `code` 是给脚本的稳定机读标识(`usage` / `not_found` / `unsupported_os` …), +/// `message` 是给人看的中文说明,`hint` 是「下一步该做什么」。 +#[derive(Debug, Clone)] +pub struct CliError { + kind: ErrorKind, + code: &'static str, + message: String, + hint: Option, +} + +impl CliError { + /// 用法错误(参数不对)。 + #[must_use] + pub fn usage(message: impl Into) -> Self { + Self { + kind: ErrorKind::User, + code: "usage", + message: message.into(), + hint: None, + } + } + + /// 用户错误,带稳定 code。 + #[must_use] + pub fn user(code: &'static str, message: impl Into) -> Self { + Self { + kind: ErrorKind::User, + code, + message: message.into(), + hint: None, + } + } + + /// 内部错误,带稳定 code。 + #[must_use] + pub fn internal(code: &'static str, message: impl Into) -> Self { + Self { + kind: ErrorKind::Internal, + code, + message: message.into(), + hint: None, + } + } + + /// IO 失败(读配置/读写会话文件):按用户错误处理,路径进消息。 + #[must_use] + pub fn io(op: &str, path: &std::path::Path, error: &std::io::Error) -> Self { + Self::user("io", format!("{op}失败:{}({error})", path.display())) + } + + /// 补一句提示。 + #[must_use] + pub fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + /// 错误大类。 + #[must_use] + pub const fn kind(&self) -> ErrorKind { + self.kind + } + + /// 稳定错误码。 + #[must_use] + pub const fn code(&self) -> &'static str { + self.code + } + + /// 人类可读说明。 + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + + /// 下一步提示。 + #[must_use] + pub fn hint(&self) -> Option<&str> { + self.hint.as_deref() + } + + /// 进程退出码。 + #[must_use] + pub const fn exit_code(&self) -> i32 { + self.kind.exit_code() + } + + /// `--json` 里的错误对象。 + #[must_use] + pub fn to_json(&self) -> Value { + let mut error = json!({ + "code": self.code, + "kind": self.kind.as_str(), + "message": self.message, + }); + if let Some(hint) = &self.hint { + error["hint"] = Value::String(hint.clone()); + } + json!({ "ok": false, "error": error }) + } +} + +impl fmt::Display for CliError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}", self.message) + } +} + +impl std::error::Error for CliError {} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs new file mode 100644 index 0000000..cdd8dcf --- /dev/null +++ b/crates/cli/src/lib.rs @@ -0,0 +1,27 @@ +//! Unlucky-Launcher 命令行(`unlucky_cli`):`ull` 二进制的可测试内核。 +//! +//! 设计定稿:`DevDocs/PlanDoc/10-cli-design.md`(D18)—— core 是零 UI 纯库, +//! CLI 只是与 Slint GUI 平级的第二个消费者,因此**本 crate 不依赖任何 UI 框架**。 +//! +//! 分层: +//! - [`cli`]:手写参数解析(不引 clap)+ 命令模型 + 帮助文案; +//! - [`commands`]:每个子命令一个函数,输入 [`context::Ctx`],输出 [`output::Report`]; +//! - [`context`]:数据根解析(三模式 / `--data-dir` 覆盖)与目录视图; +//! - [`os`]:系统摘要与 D19/D21 支持级别(事实来自 core::platform::os_info,这里只做呈现); +//! - [`output`]:人类可读 + `--json` 双格式输出; +//! - [`error`]:错误形状与退出码(0 成功 / 1 用户错误 / 2 内部错误)。 +//! +//! 可测试性:入口是 [`run`],参数是 `&[String]`,输出写进两个 `&mut dyn Write`, +//! 返回值就是进程退出码——集成测试因此不需要真的起子进程也能覆盖三条通道。 + +#![forbid(unsafe_code)] + +pub mod cli; +pub mod commands; +pub mod context; +pub mod error; +pub mod os; +pub mod output; +pub mod runner; + +pub use runner::run; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 0000000..28afe48 --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,9 @@ +//! `ull` 二进制入口:把真实 stdio 交给 [`unlucky_cli::run`],退出码照传。 + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let mut stdout = std::io::stdout(); + let mut stderr = std::io::stderr(); + let code = unlucky_cli::run(&args, &mut stdout, &mut stderr); + std::process::exit(code); +} diff --git a/crates/cli/src/os.rs b/crates/cli/src/os.rs new file mode 100644 index 0000000..0957b67 --- /dev/null +++ b/crates/cli/src/os.rs @@ -0,0 +1,78 @@ +//! 系统摘要与启动自检的 **CLI 呈现层**(D19 探测 + D21 Win10+ 单基线)。 +//! +//! 事实采集与等级判定全在 `unlucky_core::platform::os_info`(M20): +//! - [`os_info::detect`] 真探一次(会起子进程,别在热路径反复调用); +//! - [`os_info::current`] 进程内缓存(CLI 每次执行只真探一次); +//! - [`os_info::check`] 启动自检(等级 + 双语文案 + 系统信息)。 +//! +//! 本模块只做 CLI 关心的事:一行摘要、`--json` 对象、拒绝运行时的文案。 +//! 支持级别只有两档(D21 用户拍板「直接 win10 起步」):[`SupportLevel::One`] 与 +//! [`SupportLevel::Unsupported`]——Win8.1/8/7 不承诺 GUI 也不承诺 CLI, +//! 识别出旧版本正是为了明确报「不支持」。 + +use serde_json::{json, Value}; +use unlucky_core::platform::os_info::{self, OsInfo, SupportLevel}; + +/// 取进程内缓存的系统信息(一次进程只真探一次)。 +#[must_use] +pub fn detect() -> OsInfo { + os_info::current().clone() +} + +/// 启动自检(等级 + 双语文案 + 系统信息),供拒绝运行时直接打印。 +#[must_use] +pub fn check() -> os_info::SupportCheck { + os_info::check() +} + +/// 一行摘要(`ull --version` 附带)。 +#[must_use] +pub fn summary_line(info: &OsInfo) -> String { + let version = if info.version_string.is_empty() { + "版本未知" + } else { + info.version_string.as_str() + }; + format!( + "{} · {}({})· {}", + info.product, + version, + info.arch, + support_label(info.support_level) + ) +} + +/// `--json` 里的系统对象。 +#[must_use] +pub fn to_json(info: &OsInfo) -> Value { + json!({ + "family": info.family.manifest_name(), + "product": info.product, + "version": if info.version_string.is_empty() { None } else { Some(&info.version_string) }, + "major_minor": info.major_minor.map(|(major, minor)| format!("{major}.{minor}")), + "build": info.build, + "arch": info.arch, + "session": info.session.as_str(), + "support": info.support_level.as_str(), + "baseline": BASELINE, + }) +} + +/// 支持基线的一句话(`--json` 与帮助文案共用)。 +pub const BASELINE: &str = "Windows 10+ / macOS 12+ / Linux(内核 ≥ 3.2,glibc ≥ 2.17)"; + +/// 环境级提示:仅在低于基线时非空(一等基线不打岔)。 +#[must_use] +pub fn notes(info: &OsInfo) -> Vec { + match info.support_level { + SupportLevel::One => Vec::new(), + SupportLevel::Unsupported => info.describe().zh.lines().map(str::to_string).collect(), + } +} + +fn support_label(level: SupportLevel) -> &'static str { + match level { + SupportLevel::One => "满足最低要求", + SupportLevel::Unsupported => "不支持(低于最低要求)", + } +} diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs new file mode 100644 index 0000000..28aee0f --- /dev/null +++ b/crates/cli/src/output.rs @@ -0,0 +1,136 @@ +//! 双格式输出:人类可读行 + `--json` 机器可读载荷。 +//! +//! 一个命令只产出 [`Report`](`data` 给脚本,`human` 给人);怎么打印由 [`print_report`] +//! 一处决定,命令里不出现 `println!`——这样集成测试能直接断言输出形状, +//! 也能保证 `--json` 覆盖到每个命令(10-cli-design.md §3 全局参数)。 + +use std::io::Write; + +use serde_json::{json, Value}; + +use crate::error::CliError; + +/// 一个命令的结果。 +#[derive(Debug, Clone)] +pub struct Report { + /// `--json` 模式下的 `data` 载荷。 + pub data: Value, + /// 人类可读输出(一行一条)。 + pub human: Vec, +} + +impl Report { + /// 构造。 + #[must_use] + pub fn new(data: Value, human: Vec) -> Self { + Self { data, human } + } +} + +/// 命令结果 + 环境提示(D19/D21 低于基线时的升级指引之类,不混进 `data`)。 +#[derive(Debug, Clone)] +pub struct Outcome { + /// 命令结果。 + pub report: Report, + /// 环境级提示(人类模式进 stderr,JSON 模式进信封的 `warnings`)。 + pub warnings: Vec, +} + +impl Outcome { + /// 无提示的结果。 + #[must_use] + pub fn new(report: Report) -> Self { + Self { + report, + warnings: Vec::new(), + } + } + + /// 带上环境提示。 + #[must_use] + pub fn with_warnings(report: Report, warnings: Vec) -> Self { + Self { report, warnings } + } +} + +/// 打印成功结果。 +/// +/// # Errors +/// +/// 写 stdout/stderr 失败时返回 IO 错误(管道被关掉等)。 +pub fn print_report( + outcome: &Outcome, + json: bool, + quiet: bool, + out: &mut dyn Write, + err: &mut dyn Write, +) -> std::io::Result<()> { + if json { + // 信封固定三个键,脚本按 `ok` 分流后直接吃 `data`。 + let envelope = json!({ + "ok": true, + "data": outcome.report.data, + "warnings": outcome.warnings, + }); + writeln!(out, "{}", serde_json::to_string_pretty(&envelope)?)?; + return Ok(()); + } + if !quiet { + for line in &outcome.report.human { + writeln!(out, "{line}")?; + } + } + for warning in &outcome.warnings { + writeln!(err, "提示: {warning}")?; + } + Ok(()) +} + +/// 打印错误(人类模式进 stderr,JSON 模式进 stdout 的错误信封)。 +/// +/// # Errors +/// +/// 写 stdout/stderr 失败时返回 IO 错误。 +pub fn print_error( + error: &CliError, + json: bool, + out: &mut dyn Write, + err: &mut dyn Write, +) -> std::io::Result<()> { + if json { + writeln!(out, "{}", serde_json::to_string_pretty(&error.to_json())?)?; + return Ok(()); + } + writeln!(err, "错误: {}", error.message())?; + if let Some(hint) = error.hint() { + writeln!(err, "提示: {hint}")?; + } + Ok(()) +} + +/// 长任务的实时进度行(**只进 stderr**,不参与 [`Report`])。 +/// +/// 命令的正经输出仍然全部走 [`Report`](stdout、可断言的形状);下载/安装这类分钟级任务 +/// 需要无缓冲的实时反馈,才单独开这条通道。行内不含 ANSI,`--no-color` 无需处理。 +/// +/// `--json` / `--quiet` 的抑制由**调用方**决定(`InstallArgs::progress` 已按全局选项算好), +/// 这样这条通道不需要知道全局选项长什么样。 +/// +/// 写不出去(管道早闭)就静默放弃:进度永远不该让命令失败。 +pub fn progress_line(text: &str) { + let mut stderr = std::io::stderr(); + let _ = writeln!(stderr, "{text}"); + let _ = stderr.flush(); +} + +#[cfg(test)] +mod tests { + use super::progress_line; + + #[test] + fn progress_line_never_panics() { + // 只验证"写不出去也不抛"(真实 stderr 在测试里通常可写)。 + progress_line("测试进度 1/2"); + progress_line(""); + } +} diff --git a/crates/cli/src/runner.rs b/crates/cli/src/runner.rs new file mode 100644 index 0000000..fe1c5e3 --- /dev/null +++ b/crates/cli/src/runner.rs @@ -0,0 +1,54 @@ +//! 进程入口的可测试形态:解析 → 分发 → 打印 → 退出码。 +//! +//! `main` 只做两件事:把真实 stdio 传进来、把返回码交给进程。 + +use std::io::Write; + +use crate::cli; +use crate::commands; +use crate::error::EXIT_OK; +use crate::output; + +/// 跑一条命令行,返回进程退出码(0 / 1 / 2)。 +/// +/// `args` 不含 argv[0]。`--json` 与 `--quiet` 在解析失败时也要生效, +/// 因此这里先扫一遍原始参数,而不是依赖解析结果。 +pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 { + let json = args.iter().any(|arg| arg == "--json"); + let quiet = args.iter().any(|arg| arg == "--quiet" || arg == "-q"); + + let invocation = match cli::parse(args) { + Ok(invocation) => invocation, + Err(error) => { + let _ = output::print_error(&error, json, out, err); + return error.exit_code(); + } + }; + + // `ull agent serve` 提前分流:stdio 形态下 stdout 是 JSON-RPC 流, + // 任何一行额外输出(包括返回后的 Report)都会让客户端解析失败。 + if let cli::Command::AgentServe(serve_args) = &invocation.command { + return match crate::commands::agent::run_serve_with(&invocation.globals, serve_args, err) { + Ok(code) => code, + Err(error) => { + let _ = output::print_error(&error, json, out, err); + error.exit_code() + } + }; + } + + match commands::run(&invocation) { + Ok(outcome) => { + if let Err(write_err) = output::print_report(&outcome, json, quiet, out, err) { + // 输出写不出去(管道早闭)不再折腾,直接按内部错误退出。 + let _ = writeln!(err, "错误: 写输出失败:{write_err}"); + return crate::error::EXIT_INTERNAL; + } + EXIT_OK + } + Err(error) => { + let _ = output::print_error(&error, json, out, err); + error.exit_code() + } + } +} diff --git a/crates/cli/tests/accounts_test.rs b/crates/cli/tests/accounts_test.rs new file mode 100644 index 0000000..023b485 --- /dev/null +++ b/crates/cli/tests/accounts_test.rs @@ -0,0 +1,293 @@ +//! `ull accounts` 与 `ull launch --account` 的端到端断言。 +//! +//! 职责:证明「账户能建/能删/能选」,并且**凭据红线守住了**—— +//! stdout(含 `--json`)、`launch --dry-run` 的 argv 与身份块里都不出现 token 明文。 + +#[path = "support/mod.rs"] +mod support; + +use std::path::{Path, PathBuf}; + +use support::{parse_json, run_cli, TempDir}; +use unlucky_core::auth::offline::offline_uuid; + +const VERSION: &str = "1.20.1"; + +/// 建一个带「版本本体 + 一个实例」的数据根。 +fn data_root(tag: &str) -> TempDir { + let dir = TempDir::new(tag); + support::write_version(dir.path(), VERSION); + support::write_profiles(dir.path(), &[("生存服", VERSION)]); + dir +} + +/// 覆盖版本 JSON:把 `${auth_access_token}` / `${auth_uuid}` 塞进命令行, +/// 这样「脱敏」才是被真的跑到,而不是「压根没有 token 所以没有明文」。 +fn write_version_with_auth(root: &Path) { + let dir = root.join(".minecraft").join("versions").join(VERSION); + std::fs::create_dir_all(&dir).expect("建版本目录"); + let version = serde_json::json!({ + "id": VERSION, + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "assets": "5", + "assetIndex": { "id": "5" }, + "arguments": { + "game": [ + "--username", "${auth_player_name}", + "--uuid", "${auth_uuid}", + "--accessToken", "${auth_access_token}", + "--userType", "${auth_user_type}", + "--version", "${version_name}" + ], + "jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"] + }, + "libraries": [], + "downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } } + }); + std::fs::write( + dir.join(format!("{VERSION}.json")), + serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"), + ) + .expect("写版本 JSON"); +} + +fn auth_dir(root: &Path) -> PathBuf { + root.join(".ULL").join("data").join("auth") +} + +/// 拼一条 `--json --data-dir ...` 命令。 +fn cli(dir: &TempDir, tail: &[&str]) -> Vec { + let mut args = vec!["--json".to_string(), "--data-dir".to_string(), dir.arg()]; + args.extend(tail.iter().map(|item| (*item).to_string())); + args +} + +/// 拼一条 `launch 生存服 --dry-run ...` 命令。 +fn launch(dir: &TempDir, extra: &[&str]) -> Vec { + let mut tail = vec!["launch", "生存服", "--dry-run"]; + tail.extend_from_slice(extra); + cli(dir, &tail) +} + +/// 跑一条命令并断言退出码。 +fn run(args: &[String], expected: i32) -> (String, String) { + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let (code, out, err) = run_cli(&refs); + assert_eq!(code, expected, "args={refs:?}\nstdout={out}\nstderr={err}"); + (out, err) +} + +#[test] +fn accounts_list_is_empty_at_first() { + let dir = data_root("accounts-empty"); + let (out, _) = run(&cli(&dir, &["accounts", "list"]), 0); + let envelope = parse_json(&out); + assert_eq!(envelope["data"]["count"], 0); + assert_eq!( + envelope["data"]["accounts"].as_array().map(Vec::len), + Some(0) + ); + assert!(!auth_dir(dir.path()).join("accounts.json").exists()); +} + +#[test] +fn add_offline_writes_both_files_and_reports_hmcl_uuid() { + let dir = data_root("accounts-add"); + let (out, _) = run(&cli(&dir, &["accounts", "add", "offline", "小明"]), 0); + let envelope = parse_json(&out); + let account = &envelope["data"]["account"]; + assert_eq!(account["name"], "小明"); + assert_eq!(account["kind"], "offline"); + assert_eq!(account["profile_id"], offline_uuid("小明")); + + let accounts_file = auth_dir(dir.path()).join("accounts.json"); + let credentials_file = auth_dir(dir.path()).join("credentials.dat"); + assert!(accounts_file.is_file(), "元数据文件必须落盘"); + assert!( + credentials_file.is_file(), + "凭据文件必须落盘(哪怕离线轨是空的)" + ); + + // list 里能看到,且输出里没有任何凭据字段。 + let (list_out, _) = run(&cli(&dir, &["accounts", "list"]), 0); + let list = parse_json(&list_out); + assert_eq!(list["data"]["count"], 1); + assert_eq!(list["data"]["accounts"][0]["name"], "小明"); + for forbidden in ["access_token", "refresh_token", "client_token", "password"] { + assert!( + !list_out.contains(forbidden), + "accounts list 不该出现 {forbidden}" + ); + } +} + +#[test] +fn duplicate_offline_account_is_rejected() { + let dir = data_root("accounts-dup"); + run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0); + let (out, _) = run(&cli(&dir, &["accounts", "add", "offline", "steve"]), 1); + assert_eq!(parse_json(&out)["error"]["code"], "conflict"); +} + +#[test] +fn add_offline_requires_a_valid_name() { + let dir = data_root("accounts-badname"); + let (out, _) = run(&cli(&dir, &["accounts", "add", "offline"]), 1); + assert_eq!(parse_json(&out)["error"]["code"], "usage"); + + let (out, _) = run( + &cli(&dir, &["accounts", "add", "offline", "0123456789abcdefg"]), + 1, + ); + assert_eq!(parse_json(&out)["error"]["code"], "malformed"); +} + +#[test] +fn remove_deletes_account_and_reports_missing() { + let dir = data_root("accounts-remove"); + run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0); + let (out, _) = run(&cli(&dir, &["accounts", "remove", "Steve"]), 0); + assert_eq!(parse_json(&out)["data"]["removed"]["name"], "Steve"); + + let (out, _) = run(&cli(&dir, &["accounts", "list"]), 0); + assert_eq!(parse_json(&out)["data"]["count"], 0); + + let (out, _) = run(&cli(&dir, &["accounts", "remove", "Steve"]), 1); + assert_eq!(parse_json(&out)["error"]["code"], "not_found"); +} + +#[test] +fn add_msa_without_client_id_is_explained() { + // 没配 ULL_MSA_CLIENT_ID 时不该去联网,而是明确告知要配什么。 + let saved = std::env::var("ULL_MSA_CLIENT_ID").ok(); + std::env::remove_var("ULL_MSA_CLIENT_ID"); + let dir = data_root("accounts-msa"); + let (out, _) = run(&cli(&dir, &["accounts", "add", "msa"]), 1); + if let Some(value) = saved { + std::env::set_var("ULL_MSA_CLIENT_ID", value); + } + let envelope = parse_json(&out); + assert_eq!(envelope["error"]["code"], "unsupported"); + assert!(envelope["error"]["hint"] + .as_str() + .unwrap_or_default() + .contains("ULL_MSA_CLIENT_ID")); +} + +#[test] +fn launch_dry_run_uses_the_only_account() { + let dir = data_root("launch-account"); + write_version_with_auth(dir.path()); + run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0); + let (out, _) = run(&launch(&dir, &[]), 0); + let data = &parse_json(&out)["data"]; + let account = &data["account"]; + assert_eq!(account["name"], "Steve"); + assert_eq!(account["kind"], "offline"); + assert_eq!(account["profile_id"], offline_uuid("Steve")); + assert_eq!(account["registered"], true); + + // argv 里玩家名与 UUID 都对上了,token 位置是脱敏占位。 + let argv: Vec = data["argv"] + .as_array() + .expect("argv") + .iter() + .map(|item| item.as_str().unwrap_or_default().to_string()) + .collect(); + let username_at = argv + .iter() + .position(|arg| arg == "--username") + .expect("有 --username"); + assert_eq!(argv[username_at + 1], "Steve"); + // launch 的脱敏器把 uuid 也当敏感值处理(M18 定案:dry-run 输出是「脱敏 uuid」), + // 真实 uuid 在下面的身份块里以可公开元数据的形式给出。 + let uuid_at = argv + .iter() + .position(|arg| arg == "--uuid") + .expect("有 --uuid"); + assert_eq!(argv[uuid_at + 1], ""); + // 真凭据位置:离线占位 token 是随机 32 hex,输出里必须只剩脱敏占位。 + let token_at = argv + .iter() + .position(|arg| arg == "--accessToken") + .expect("有 --accessToken"); + assert_eq!(argv[token_at + 1], ""); + assert!(out.contains("")); + // 命令行与展示行里不该有任何 32 位十六进制串(= 没漏出 accessToken)。 + let display = data["display"].as_str().unwrap_or_default(); + assert!(!has_hex32(&argv.join(" ")), "argv 漏了 token:{argv:?}"); + assert!(!has_hex32(display), "display 漏了 token:{display}"); + assert!( + !out.contains("access_token"), + "输出不该出现 access_token 字段" + ); +} + +/// 输出里是否有 32 位十六进制串(token 形状)。 +fn has_hex32(text: &str) -> bool { + text.split(|ch: char| !ch.is_ascii_hexdigit()) + .any(|run| run.len() == 32) +} + +#[test] +fn multiple_accounts_require_explicit_choice() { + let dir = data_root("launch-multi"); + run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0); + run(&cli(&dir, &["accounts", "add", "offline", "Alex"]), 0); + let (out, _) = run(&launch(&dir, &[]), 1); + let envelope = parse_json(&out); + assert_eq!(envelope["error"]["code"], "account_ambiguous"); + assert!( + envelope["error"]["hint"] + .as_str() + .unwrap_or_default() + .contains("Steve"), + "hint 要列候选" + ); + + // 指定名字就能选出来(大小写不敏感)。 + let (out, _) = run(&launch(&dir, &["--account", "alex"]), 0); + let data = &parse_json(&out)["data"]; + assert_eq!(data["account"]["name"], "Alex"); + assert_eq!(data["account"]["profile_id"], offline_uuid("Alex")); +} + +#[test] +fn unknown_account_and_username_conflict_are_usage_errors() { + let dir = data_root("launch-unknown"); + run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0); + let (out, _) = run(&launch(&dir, &["--account", "Nobody"]), 1); + assert_eq!(parse_json(&out)["error"]["code"], "not_found"); + + let (out, _) = run(&launch(&dir, &["--username", "Alex"]), 1); + assert_eq!(parse_json(&out)["error"]["code"], "usage"); +} + +#[test] +fn launch_without_accounts_keeps_the_offline_placeholder() { + let dir = data_root("launch-placeholder"); + let (out, _) = run(&launch(&dir, &["--username", "Node"]), 0); + let data = &parse_json(&out)["data"]; + assert_eq!(data["account"]["registered"], false); + assert_eq!(data["account"]["kind"], "offline-placeholder"); + assert_eq!(data["account"]["name"], "Node"); + let argv: Vec<&str> = data["argv"] + .as_array() + .expect("argv") + .iter() + .map(|item| item.as_str().unwrap_or_default()) + .collect(); + let at = argv + .iter() + .position(|arg| *arg == "--username") + .expect("--username"); + assert_eq!(argv[at + 1], "Node"); +} + +#[test] +fn accounts_help_topic_exists() { + let (out, _) = run(&["help".to_string(), "accounts".to_string()], 0); + assert!(out.contains("credentials.dat"), "{out}"); + assert!(out.contains("accounts add offline"), "{out}"); +} diff --git a/crates/cli/tests/agent_mcp_stdio_test.rs b/crates/cli/tests/agent_mcp_stdio_test.rs new file mode 100644 index 0000000..2600454 --- /dev/null +++ b/crates/cli/tests/agent_mcp_stdio_test.rs @@ -0,0 +1,185 @@ +//! 真机验收:起 `ull agent serve --stdio`,用裸 JSON-RPC 走一遍握手 → 列工具 → 调用。 +//! +//! 这是 M39 任务 5 的自动化形态:**不引任何 MCP 客户端库**,直接按协议喂行给子进程。 +//! stdio 形态是 llama.cpp 内置 MCP client 的入口,它的 stdout 必须是纯 JSON-RPC—— +//! 本测试顺带守住「stdout 里没有多余输出」这条最容易破的约束。 + +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use serde_json::{json, Value}; + +fn data_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ull-agent-stdio-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时数据根"); + dir +} + +struct Session { + child: std::process::Child, + stdin: std::process::ChildStdin, + stdout: std::io::Lines>, +} + +impl Session { + fn start(dir: &Path) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_ull")) + .args([ + "agent", + "serve", + "--stdio", + "--allow-write", + "--data-dir", + &dir.display().to_string(), + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("应能起 ull agent serve --stdio"); + let stdin = child.stdin.take().expect("stdin"); + let stdout = BufReader::new(child.stdout.take().expect("stdout")).lines(); + Self { + child, + stdin, + stdout, + } + } + + fn send(&mut self, message: &Value) { + writeln!(self.stdin, "{message}").expect("写请求"); + self.stdin.flush().expect("刷新 stdin"); + } + + fn next_response(&mut self) -> Value { + let line = self + .stdout + .next() + .expect("stdio 应有一行响应(不应挂断)") + .expect("读响应行失败"); + serde_json::from_str(&line).unwrap_or_else(|_| panic!("stdout 不是 JSON-RPC:{line}")) + } + + fn rpc(&mut self, id: u32, method: &str, params: Value) -> Value { + self.send(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })); + self.next_response() + } +} + +impl Drop for Session { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[test] +fn stdio_handshake_lists_tools_and_calls_them() { + let dir = data_dir("handshake"); + let mut session = Session::start(&dir); + + // 1. initialize + let init = session.rpc( + 1, + "initialize", + json!({ + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { "name": "ull-test", "version": "0.1" } + }), + ); + assert_eq!( + init["result"]["serverInfo"]["name"], + json!("unlucky-launcher"), + "{init}" + ); + session.send(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })); + + // 2. tools/list —— 写工具在(--allow-write),十个工具全在 + let list = session.rpc(2, "tools/list", json!({})); + let names: Vec = list["result"]["tools"] + .as_array() + .expect("tools 数组") + .iter() + .filter_map(|tool| tool["name"].as_str().map(str::to_string)) + .collect(); + assert_eq!(names.len(), 10, "{names:?}"); + for expected in [ + "get_config", + "set_config", + "list_instances", + "list_accounts", + "diagnose_crash", + ] { + assert!(names.iter().any(|name| name == expected), "缺 {expected}"); + } + + // 3. 只读工具真返回(真实 core 端口:空数据根 → 空实例/空账户) + let instances = session.rpc( + 3, + "tools/call", + json!({ "name": "list_instances", "arguments": {} }), + ); + assert_ne!(instances["result"]["isError"], json!(true), "{instances}"); + let text = instances["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default(); + assert!(text.contains("\"total\":0"), "空数据根应 0 个实例:{text}"); + + // 4. 白名单普通键写入真落盘 + let written = session.rpc( + 4, + "tools/call", + json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }), + ); + assert_ne!(written["result"]["isError"], json!(true), "{written}"); + let launcher = dir.join(".ULL").join("config").join("launcher.json"); + let content = std::fs::read_to_string(&launcher).expect("应写出 launcher.json"); + assert!(content.contains("\"concurrency\""), "{content}"); + + // 5. 敏感键被拒(外部客户端没有弹窗),文案引导到设置页 / YOLO + let denied = session.rpc( + 5, + "tools/call", + json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }), + ); + assert_eq!(denied["result"]["isError"], json!(true), "{denied}"); + let text = denied["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default(); + assert!(text.contains("YOLO"), "{text}"); + + // 6. 审计落盘:每一条都在,且会话标签能区分「外部 MCP」 + let audit_dir = dir.join(".ULL").join("data").join("agent").join("audit"); + let mut records: Vec = Vec::new(); + for entry in std::fs::read_dir(&audit_dir) + .expect("审计目录应存在") + .flatten() + { + let text = std::fs::read_to_string(entry.path()).unwrap_or_default(); + for line in text.lines() { + if let Ok(value) = serde_json::from_str::(line) { + records.push(value); + } + } + } + assert!( + records + .iter() + .any(|record| record["tool"] == json!("set_config") && record["outcome"] == json!("ok")), + "普通键写入应有 ok 审计:{records:?}" + ); + assert!( + records + .iter() + .any(|record| record["outcome"] == json!("denied")), + "敏感键拒绝应有 denied 审计:{records:?}" + ); + assert!(records + .iter() + .all(|record| record["agent_session_id"] == json!("mcp-stdio"))); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/cli/tests/config_secret_test.rs b/crates/cli/tests/config_secret_test.rs new file mode 100644 index 0000000..ceae4c0 --- /dev/null +++ b/crates/cli/tests/config_secret_test.rs @@ -0,0 +1,179 @@ +//! 敏感键脱敏(P1-1 回归):单键 get、无键全量 dump、set 的旧值回显三条路径 +//! 都不能把凭据明文交给用户或脚本。 + +#[path = "support/mod.rs"] +mod support; + +use support::{parse_json, run_cli, TempDir}; + +/// 一眼可 grep 的假凭据。 +const SECRET: &str = "sk-live-THIS-MUST-NOT-LEAK"; +/// 代理 URL 里的口令(`download.proxy_url` 是 schema 的 High 档,但键名不含敏感词)。 +const PROXY: &str = "socks5://user:pw@127.0.0.1:1080"; + +/// 建一个已经写入敏感键的数据根。 +fn data_dir_with_secrets() -> TempDir { + let dir = TempDir::new("secret"); + let (code, _, stderr) = run_cli(&[ + "config", + "set", + "ai.api_key", + SECRET, + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stderr}"); + let (code, _, stderr) = run_cli(&[ + "config", + "set", + "download.proxy_url", + PROXY, + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stderr}"); + // 一个普通键:用来验证掩码没有把整份配置打成红条(默认值不落盘,必须先写一次)。 + let (code, _, stderr) = run_cli(&[ + "config", + "set", + "ui.language", + "en_us", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stderr}"); + dir +} + +#[test] +fn full_dump_redacts_sensitive_keys_by_default() { + let dir = data_dir_with_secrets(); + let (code, stdout, stderr) = run_cli(&["config", "get", "--json", "--data-dir", &dir.arg()]); + assert_eq!(code, 0, "{stderr}"); + assert!(!stdout.contains(SECRET), "全量 dump 泄出 API key:{stdout}"); + assert!( + !stdout.contains("user:pw@"), + "全量 dump 泄出代理口令:{stdout}" + ); + + let value = parse_json(&stdout); + assert_eq!(value["data"]["config"]["ai"]["api_key"], ""); + assert_eq!( + value["data"]["config"]["download"]["proxy_url"], + "" + ); + assert_eq!(value["data"]["redacted"], true); + // 非敏感键照常可读(掩码不能把整份配置打成红条)。 + assert_eq!(value["data"]["config"]["ui"]["language"], "en_us"); +} + +#[test] +fn full_dump_human_output_is_redacted_too() { + let dir = data_dir_with_secrets(); + let (code, stdout, _) = run_cli(&["config", "get", "--data-dir", &dir.arg()]); + assert_eq!(code, 0); + assert!(!stdout.contains(SECRET), "{stdout}"); + assert!(!stdout.contains("user:pw@"), "{stdout}"); + assert!(stdout.contains(""), "{stdout}"); + assert!(stdout.contains("--show-secret"), "{stdout}"); +} + +#[test] +fn single_key_get_masks_by_default() { + let dir = data_dir_with_secrets(); + let (code, stdout, _) = run_cli(&[ + "config", + "get", + "ai.api_key", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0); + assert!(!stdout.contains(SECRET), "{stdout}"); + let value = parse_json(&stdout); + assert_eq!(value["data"]["value"], ""); + assert_eq!(value["data"]["sensitive"], true); + assert_eq!(value["data"]["redacted"], true); +} + +#[test] +fn show_secret_reveals_plaintext() { + let dir = data_dir_with_secrets(); + // 全量 dump + --show-secret:明文,且 redacted=false。 + let (code, stdout, _) = run_cli(&[ + "config", + "get", + "--show-secret", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0); + let value = parse_json(&stdout); + assert_eq!(value["data"]["config"]["ai"]["api_key"], SECRET); + assert_eq!(value["data"]["config"]["download"]["proxy_url"], PROXY); + assert_eq!(value["data"]["redacted"], false); + + // 单键 + --show-secret:人类输出就是明文。 + let (code, stdout, _) = run_cli(&[ + "config", + "get", + "ai.api_key", + "--show-secret", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0); + assert!(stdout.contains(SECRET), "{stdout}"); +} + +#[test] +fn set_masks_old_value_but_echoes_new() { + let dir = data_dir_with_secrets(); + + let (code, stdout, stderr) = run_cli(&[ + "config", + "set", + "ai.api_key", + "sk-new-value", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stderr}"); + assert!(!stdout.contains(SECRET), "set 回显了旧密钥:{stdout}"); + let value = parse_json(&stdout); + assert_eq!(value["data"]["old"], ""); + assert_eq!(value["data"]["new"], "sk-new-value"); + + // 人类模式同样不回显旧值明文(新值照常,用户刚敲的)。 + let (code, stdout, _) = run_cli(&[ + "config", + "set", + "ai.api_key", + "sk-third", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0); + assert!( + !stdout.contains("sk-new-value"), + "人类输出回显旧值:{stdout}" + ); + assert!(stdout.contains(""), "{stdout}"); + assert!(stdout.contains("sk-third"), "{stdout}"); + + // 非敏感键仍然正常回显原值。 + let (code, stdout, _) = run_cli(&[ + "config", + "set", + "ui.language", + "zh_cn", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0); + assert_eq!(parse_json(&stdout)["data"]["old"], "en_us"); +} diff --git a/crates/cli/tests/exit_code_test.rs b/crates/cli/tests/exit_code_test.rs new file mode 100644 index 0000000..8e732b1 --- /dev/null +++ b/crates/cli/tests/exit_code_test.rs @@ -0,0 +1,109 @@ +//! 退出码与输出通道(0 成功 / 1 用户错误 / 2 内部错误)。 + +#[path = "support/mod.rs"] +mod support; + +use support::{run_cli, write_version, TempDir}; +use unlucky_cli::error::{CliError, ErrorKind, EXIT_INTERNAL, EXIT_OK, EXIT_USER}; + +#[test] +fn exit_code_constants_match_kinds() { + assert_eq!(EXIT_OK, 0); + assert_eq!(EXIT_USER, 1); + assert_eq!(EXIT_INTERNAL, 2); + assert_eq!(ErrorKind::User.exit_code(), 1); + assert_eq!(ErrorKind::Internal.exit_code(), 2); + assert_eq!(CliError::usage("x").exit_code(), 1); + assert_eq!(CliError::user("io", "x").exit_code(), 1); + assert_eq!(CliError::internal("bug", "x").exit_code(), 2); +} + +#[test] +fn internal_errors_serialize_as_internal() { + let value = CliError::internal("serialize", "序列化失配").to_json(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["kind"], "internal"); + assert_eq!(value["error"]["code"], "serialize"); + assert!(value["error"].get("hint").is_none()); +} + +#[test] +fn success_exits_zero() { + let dir = TempDir::new("code-ok"); + let (code, _, _) = run_cli(&["instances", "list", "--data-dir", &dir.arg()]); + assert_eq!(code, 0); +} + +#[test] +fn user_error_exits_one() { + let dir = TempDir::new("code-user"); + let cases: Vec> = vec![ + vec!["frobnicate".to_string()], + vec!["install".to_string()], + vec![ + "launch".to_string(), + "nope".to_string(), + "--data-dir".to_string(), + dir.arg(), + ], + vec![ + "logs".to_string(), + "deadbeef".to_string(), + "--data-dir".to_string(), + dir.arg(), + ], + // `java install` 已实装(会真联网下载)——用户错误只取参数校验这一层: + // 大版本号离谱时在联网之前就拒,测试因此保持零网络(M27 踩坑记录 G 节同款教训)。 + vec![ + "java".to_string(), + "install".to_string(), + "4".to_string(), + "--data-dir".to_string(), + dir.arg(), + ], + vec![ + "--data-dir".to_string(), + "relative/path".to_string(), + "instances".to_string(), + "list".to_string(), + ], + ]; + for args in cases { + let (code, _, stderr) = run_cli(&args.iter().map(String::as_str).collect::>()); + assert_eq!(code, 1, "args={args:?} stderr={stderr}"); + assert!(stderr.contains("错误:"), "args={args:?} stderr={stderr}"); + } +} + +#[test] +fn human_and_json_go_to_different_streams() { + let dir = TempDir::new("code-streams"); + write_version(dir.path(), "1.20.1"); + + // 人类模式:结果进 stdout。 + let (code, stdout, stderr) = run_cli(&["instances", "list", "--data-dir", &dir.arg()]); + assert_eq!(code, 0); + assert!(stdout.contains("实例"), "{stdout}"); + assert!(stderr.is_empty(), "{stderr}"); + + // 错误进 stderr,不污染 stdout。 + let (code, stdout, stderr) = run_cli(&["launch", "nope", "--data-dir", &dir.arg()]); + assert_eq!(code, 1); + assert!(stdout.is_empty(), "{stdout}"); + assert!(stderr.contains("错误:"), "{stderr}"); +} + +#[test] +fn quiet_suppresses_human_output_only() { + let dir = TempDir::new("code-quiet"); + let (code, stdout, stderr) = + run_cli(&["instances", "list", "--quiet", "--data-dir", &dir.arg()]); + assert_eq!(code, 0); + assert!(stdout.is_empty(), "{stdout}"); + assert!(stderr.is_empty(), "{stderr}"); + + // 错误不受 --quiet 影响(脚本仍要能看到原因)。 + let (code, _, stderr) = run_cli(&["launch", "nope", "--quiet", "--data-dir", &dir.arg()]); + assert_eq!(code, 1); + assert!(stderr.contains("错误:"), "{stderr}"); +} diff --git a/crates/cli/tests/isolation_launch_test.rs b/crates/cli/tests/isolation_launch_test.rs new file mode 100644 index 0000000..2b8d4a2 --- /dev/null +++ b/crates/cli/tests/isolation_launch_test.rs @@ -0,0 +1,345 @@ +//! D20 隔离档位的 CLI 端到端:`ull launch` 按覆盖链把派生结果填进 `--gameDir`。 +//! +//! 四条路径(全局 isolated / 全局 shared / 实例 override / custom)+ 旧档案读兼容 + +//! `--dry-run` 不落盘。断言走 `--json` 输出的 `isolation` / `isolation_source` / `game_dir`, +//! 也就是真正喂给 `${game_directory}` 与子进程 CWD 的那个值。 + +#[path = "support/mod.rs"] +mod support; + +use std::path::{Path, PathBuf}; + +use support::{parse_json, run_cli, TempDir}; + +/// 实例 id(与 `profiles.json` 里写的必须一致)。 +const INSTANCE_ID: &str = "11111111-2222-4333-8444-000000000001"; +/// 版本 id。 +const VERSION: &str = "1.20.1"; + +/// 建数据根:版本本体 + `profiles.json`(一个实例「生存服」)。 +fn data_root(tag: &str) -> TempDir { + let dir = TempDir::new(tag); + write_version_with_game_dir(dir.path()); + write_profiles(dir.path(), None); + dir +} + +/// 写一份「带 `--gameDir ${game_directory}`」的最小版本 JSON + 空 client jar。 +/// +/// 比 `support::write_version` 多这一个参数——`--gameDir` 就是隔离档位的真实落点 +/// (正式版清单 1.13+ 都有这条),没有它就无法从命令行验证派生结果。 +fn write_version_with_game_dir(root: &Path) { + let dir = root.join(".minecraft").join("versions").join(VERSION); + std::fs::create_dir_all(&dir).expect("建版本目录"); + let version = serde_json::json!({ + "id": VERSION, + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "assets": "5", + "assetIndex": { "id": "5" }, + "arguments": { + "game": [ + "--username", "${auth_player_name}", + "--version", "${version_name}", + "--gameDir", "${game_directory}" + ], + "jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"] + }, + "libraries": [], + "downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } } + }); + std::fs::write( + dir.join(format!("{VERSION}.json")), + serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"), + ) + .expect("写版本 JSON"); + std::fs::write(dir.join(format!("{VERSION}.jar")), b"junk").expect("写 client jar"); +} + +/// 写 `config/profiles.json`(可选 `game_dir` 遗留字段)。 +fn write_profiles(root: &Path, legacy_game_dir: Option<&Path>) { + let profiles_dir = root.join(".ULL").join("config"); + std::fs::create_dir_all(&profiles_dir).expect("建配置目录"); + let mut profile = serde_json::json!({ + "id": INSTANCE_ID, + "name": "生存服", + "version_id": VERSION, + }); + if let Some(game_dir) = legacy_game_dir { + profile["game_dir"] = serde_json::json!(game_dir.display().to_string()); + } + let document = serde_json::json!({ + "schema_version": 1, + "profiles": [profile], + }); + std::fs::write( + profiles_dir.join("profiles.json"), + serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"), + ) + .expect("写 profiles.json"); +} + +/// 写实例设置文件(`config/instances/.json`)。 +fn write_instance_config(dir: &Path, value: &serde_json::Value) { + let path = dir + .join(".ULL") + .join("config") + .join("instances") + .join(format!("{INSTANCE_ID}.json")); + std::fs::create_dir_all(path.parent().expect("父目录")).expect("建实例配置目录"); + std::fs::write( + &path, + serde_json::to_vec_pretty(value).expect("序列化实例设置"), + ) + .expect("写实例设置"); +} + +/// 跑一次 `launch --dry-run --json`。 +fn dry_run(dir: &TempDir) -> serde_json::Value { + let (code, stdout, stderr) = run_cli(&[ + "launch", + "生存服", + "--dry-run", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stderr}"); + parse_json(&stdout)["data"].clone() +} + +/// 数据根下的游戏目录。 +fn game_root(dir: &TempDir) -> PathBuf { + dir.path().join(".minecraft") +} + +#[test] +fn default_mode_is_isolated_and_derives_version_dir() { + let dir = data_root("iso-default"); + let data = dry_run(&dir); + assert_eq!(data["isolation"], "isolated", "D20 默认隔离"); + assert_eq!(data["isolation_source"], "global"); + assert_eq!(data["instance_found"], true, "实例名解析仍然有效"); + assert_eq!( + PathBuf::from(data["game_dir"].as_str().expect("game_dir")), + game_root(&dir).join("versions").join(VERSION) + ); + assert!(data["mode_change_warning"].is_null()); + + // 端到端:派生值必须是命令行里 `--gameDir` 的实参(占位符已替换、家目录已脱敏, + // 所以按路径后缀断言)。 + let argv: Vec<&str> = data["argv"] + .as_array() + .expect("argv") + .iter() + .map(|token| token.as_str().expect("token")) + .collect(); + let index = argv + .iter() + .position(|token| *token == "--gameDir") + .unwrap_or_else(|| panic!("命令行里没有 --gameDir:{argv:?}")); + let passed = PathBuf::from(argv[index + 1]); + assert!( + passed.ends_with(Path::new("versions").join(VERSION)), + "--gameDir 不是派生的隔离目录:{}", + passed.display() + ); +} + +#[test] +fn shared_mode_switches_game_dir_to_game_root() { + let dir = data_root("iso-shared"); + let (code, _, stderr) = run_cli(&[ + "config", + "set", + "instances.mode", + "shared", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stderr}"); + + let data = dry_run(&dir); + assert_eq!(data["isolation"], "shared"); + assert_eq!(data["isolation_source"], "global"); + assert_eq!( + PathBuf::from(data["game_dir"].as_str().expect("game_dir")), + game_root(&dir), + "共享档的运行目录就是 .minecraft 根" + ); +} + +#[test] +fn instance_isolation_overrides_global_mode() { + let dir = data_root("iso-instance"); + let (code, _, stderr) = run_cli(&[ + "config", + "set", + "instances.mode", + "shared", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stderr}"); + write_instance_config( + dir.path(), + &serde_json::json!({ + "schemaVersion": 1, + "id": INSTANCE_ID, + "versionId": VERSION, + "isolation": "isolated", + }), + ); + + let data = dry_run(&dir); + assert_eq!(data["isolation"], "isolated", "实例档位压过全局"); + assert_eq!(data["isolation_source"], "instance"); + assert_eq!( + PathBuf::from(data["game_dir"].as_str().expect("game_dir")), + game_root(&dir).join("versions").join(VERSION) + ); +} + +#[test] +fn custom_isolation_uses_the_recorded_game_dir() { + let dir = data_root("iso-custom"); + let custom = dir.path().join("mcs").join("farm"); + write_instance_config( + dir.path(), + &serde_json::json!({ + "schemaVersion": 1, + "id": INSTANCE_ID, + "versionId": VERSION, + "isolation": "custom", + "gameDir": custom.display().to_string(), + }), + ); + + let data = dry_run(&dir); + assert_eq!(data["isolation"], "custom"); + assert_eq!( + PathBuf::from(data["game_dir"].as_str().expect("game_dir")), + custom + ); +} + +#[test] +fn legacy_profile_game_dir_reads_as_custom() { + // M27 之前的档案只在 profiles.json 里存 game_dir(没有实例设置文件): + // 显式指定过的目录不能被静默退回全局档。 + let dir = TempDir::new("iso-legacy"); + write_version_with_game_dir(dir.path()); + let legacy = dir.path().join("old-instance-dir"); + write_profiles(dir.path(), Some(&legacy)); + let data = dry_run(&dir); + assert_eq!(data["isolation"], "custom"); + assert_eq!( + PathBuf::from(data["game_dir"].as_str().expect("game_dir")), + legacy + ); +} + +#[test] +fn dry_run_does_not_write_instance_state() { + // 切换检测只在真启动时落盘:`--dry-run` 不该吃掉「切换后首次启动提示一次」。 + let dir = data_root("iso-dry-run"); + let config_path = dir + .path() + .join(".ULL") + .join("config") + .join("instances") + .join(format!("{INSTANCE_ID}.json")); + assert!(!config_path.exists()); + let _ = dry_run(&dir); + assert!( + !config_path.exists(), + "--dry-run 写盘了:{}", + config_path.display() + ); +} + +#[test] +fn unknown_instance_still_reports_available_names() { + let dir = data_root("iso-unknown"); + let (code, _, stderr) = run_cli(&[ + "launch", + "不存在的实例", + "--dry-run", + "--data-dir", + &dir.arg(), + ]); + assert_ne!(code, 0, "未知实例要失败"); + assert!(stderr.contains("生存服"), "提示里要列出可用实例:{stderr}"); +} + +/// 读实例设置文件(不存在返回 `None`)。 +fn read_instance_config(dir: &TempDir) -> Option { + let path = dir + .path() + .join(".ULL") + .join("config") + .join("instances") + .join(format!("{INSTANCE_ID}.json")); + let text = std::fs::read_to_string(path).ok()?; + Some(serde_json::from_str(&text).expect("实例设置应为合法 JSON")) +} + +/// 真启动一次(用一个不存在的 java,保证 spawn 必失败、不会真的跑起游戏), +/// 只为驱动「真启动才写盘」的那段逻辑。 +fn real_launch_that_fails(dir: &TempDir) -> (i32, String, String) { + let bogus_java = dir.path().join("no-such-java.exe"); + run_cli(&[ + "launch", + "生存服", + "--java", + &bogus_java.display().to_string(), + "--data-dir", + &dir.arg(), + ]) +} + +#[test] +fn first_real_launch_bootstraps_instance_file() { + // 回归 P2-3(review r1):profiles.json 有档案但没有实例设置文件(M27 之前的形态), + // 真启动要补建基线文件,否则切换检测对这批遗留实例永远不启动。 + let dir = data_root("iso-bootstrap"); + assert!(read_instance_config(&dir).is_none(), "前置:还没有设置文件"); + + let (code, _, stderr) = real_launch_that_fails(&dir); + assert_ne!(code, 0, "假 java 必然启动失败:{stderr}"); + + let config = read_instance_config(&dir).expect("首次真启动应补建设置文件"); + assert_eq!(config["versionId"], VERSION); + assert_eq!(config["id"], INSTANCE_ID); + // 基线:from == to,且已经标记 warned(不该平白弹一次提示)。 + assert_eq!(config["lastModeChange"]["from"], "isolated"); + assert_eq!(config["lastModeChange"]["to"], "isolated"); + assert_eq!(config["lastModeChange"]["warned"], true); + assert!(config["lastModeChange"]["at"].is_string()); + assert!(config["lastModeChange"]["probe"]["isolatedSide"].is_number()); + assert!(config["lastModeChange"]["probe"]["sharedSide"].is_number()); + // inherit 不落盘。 + assert!(config.get("isolation").is_none(), "{config}"); +} + +#[test] +fn legacy_profile_dir_is_recorded_as_custom_on_bootstrap() { + // 同一批遗留档案里「显式指定过目录」的那部分:补建时把 old profiles.game_dir → custom + // 固化进实例文件,之后实例文件就是唯一真相。 + let dir = TempDir::new("iso-bootstrap-legacy"); + write_version_with_game_dir(dir.path()); + let legacy = dir.path().join("old-instance-dir"); + write_profiles(dir.path(), Some(&legacy)); + + let (code, _, stderr) = real_launch_that_fails(&dir); + assert_ne!(code, 0, "假 java 必然启动失败:{stderr}"); + + let config = read_instance_config(&dir).expect("首次真启动应补建设置文件"); + assert_eq!(config["isolation"], "custom"); + assert_eq!( + PathBuf::from(config["gameDir"].as_str().expect("gameDir")), + legacy + ); + assert_eq!(config["lastModeChange"]["to"], "custom"); + assert_eq!(config["lastModeChange"]["warned"], true, "基线不弹提示"); +} diff --git a/crates/cli/tests/java_test.rs b/crates/cli/tests/java_test.rs new file mode 100644 index 0000000..c1570ae --- /dev/null +++ b/crates/cli/tests/java_test.rs @@ -0,0 +1,197 @@ +//! `ull java list` / `ull java install` / `ull launch` 的 Java 选择(D13)CLI 端。 +//! +//! 不联网:`install` 只测参数校验与稳定错误码;`launch --dry-run` 断言 `java` 块的结构 +//! 与「实例覆盖优先」——真正的下载/解压由 `core::game::java` 的测试与本机验收覆盖。 + +#[path = "support/mod.rs"] +mod support; + +use support::{parse_json, run_cli, write_version, TempDir}; + +/// 一个可组装的版本 id。 +const VERSION: &str = "1.20.1"; + +#[test] +fn java_list_reports_the_managed_root() { + let dir = TempDir::new("java-list"); + let (code, stdout, stderr) = run_cli(&["--json", "--data-dir", &dir.arg(), "java", "list"]); + assert_eq!(code, 0, "stderr: {stderr}"); + let envelope = parse_json(&stdout); + let json = &envelope["data"]; + assert!( + json["managed_root"] + .as_str() + .is_some_and(|root| root.ends_with("java-managed")), + "managed_root 应是 data/java-managed:{json}" + ); + // D23:多根扫描 + 落点模式要说清(`--data-dir` 是 custom 数据模式 → portable 落点)。 + assert!( + json["managed_roots"] + .as_array() + .is_some_and(|roots| !roots.is_empty()), + "{json}" + ); + assert_eq!(json["install"]["mode"], "portable", "{json}"); + assert!( + json["install"]["root"] + .as_str() + .is_some_and(|root| root.ends_with("java-managed")), + "落点根应是数据根里的 java-managed:{json}" + ); + assert!(json["candidates"].is_array(), "{json}"); +} + +#[test] +fn java_vendors_lists_the_four_vendors_with_the_current_order() { + let dir = TempDir::new("java-vendors"); + let (code, stdout, stderr) = run_cli(&["--json", "--data-dir", &dir.arg(), "java", "vendors"]); + assert_eq!(code, 0, "stderr: {stderr}"); + let envelope = parse_json(&stdout); + let json = &envelope["data"]; + let order: Vec<&str> = json["order"] + .as_array() + .expect("order 数组") + .iter() + .filter_map(|item| item.as_str()) + .collect(); + assert_eq!(order.len(), 4, "{json}"); + assert!(order.contains(&"temurin"), "{json}"); + assert!(order.contains(&"oracle"), "{json}"); + assert!(order.contains(&"microsoft"), "{json}"); + assert!(order.contains(&"zulu"), "{json}"); + // 本机时区/语言决定 domestic/global,两种默认序都合法,但来源必须明示。 + let source = json["priority_source"].as_str().unwrap_or("?"); + assert_eq!( + source, "region_default", + "没设 java.vendorPriority 时:{json}" + ); + let vendors = json["vendors"].as_array().expect("vendors 数组"); + assert_eq!(vendors.len(), 4); + let microsoft = vendors + .iter() + .find(|row| row["vendor"] == "microsoft") + .expect("应有 microsoft 行"); + assert_eq!( + microsoft["supported"].as_array().map(Vec::len), + Some(4), + "MS 只发 25/21/17/11:{microsoft}" + ); + assert!( + microsoft["sources"] + .as_array() + .is_some_and(|sources| !sources.is_empty()), + "MS 的源(aka.ms → VS CDN)要列出来:{microsoft}" + ); +} + +#[test] +fn java_install_rejects_an_unknown_vendor_without_network() { + let dir = TempDir::new("java-vendor-bad"); + let (code, stdout, _stderr) = run_cli(&[ + "--json", + "--data-dir", + &dir.arg(), + "java", + "install", + "21", + "--vendor", + "graalvm", + ]); + assert_eq!(code, 1); + let envelope = parse_json(&stdout); + assert_eq!(envelope["error"]["code"], "usage", "{envelope}"); +} + +#[test] +fn java_install_rejects_an_absurd_major_without_network() { + let dir = TempDir::new("java-install"); + let (code, stdout, _stderr) = + run_cli(&["--json", "--data-dir", &dir.arg(), "java", "install", "4"]); + assert_eq!(code, 1); + let envelope = parse_json(&stdout); + assert_eq!(envelope["error"]["code"], "usage", "{envelope}"); +} + +#[test] +fn launch_dry_run_reports_the_java_requirement_of_the_version() { + let dir = TempDir::new("java-launch"); + write_version(dir.path(), VERSION); + let (code, stdout, stderr) = run_cli(&[ + "--json", + "--data-dir", + &dir.arg(), + "launch", + VERSION, + "--dry-run", + ]); + assert_eq!(code, 0, "stderr: {stderr}"); + let envelope = parse_json(&stdout); + let json = &envelope["data"]; + let java = &json["java"]; + // 1.20.1 的清单没写 javaVersion → 静态表兜底 17。 + assert_eq!(java["required_major"], 17, "{json}"); + assert_eq!(java["requirement_source"], "table", "{json}"); + assert_eq!(json["version_id"], VERSION); + // `source` 只可能是探测到的来源之一(本机未必装了 Java)。 + let source = java["source"].as_str().unwrap_or("none"); + assert!( + ["instance", "managed", "JAVA_HOME", "PATH", "none"].contains(&source), + "意外的来源:{source}" + ); +} + +#[test] +fn instance_java_override_wins_over_the_system_java() { + let dir = TempDir::new("java-override"); + write_version(dir.path(), VERSION); + // 故意给一个不存在的路径:只要能进 `source=instance` 就说明覆盖优先级生效 + // (探测失败只影响 major,不该把用户指定的路径换成系统 Java)。 + let fake = dir.path().join("some-jdk").join("bin").join("java"); + let (code, stdout, stderr) = run_cli(&[ + "--json", + "--data-dir", + &dir.arg(), + "launch", + VERSION, + "--dry-run", + "--java", + &fake.display().to_string(), + ]); + assert_eq!(code, 0, "stderr: {stderr}"); + let envelope = parse_json(&stdout); + let json = &envelope["data"]; + assert_eq!(json["java"]["source"], "instance", "{json}"); + assert_eq!( + json["java"]["binary"].as_str(), + Some(fake.display().to_string().as_str()) + ); +} + +#[test] +fn legacy_version_requires_java_8_and_flags_a_newer_runtime() { + let dir = TempDir::new("java-legacy"); + write_version(dir.path(), "1.12.2"); + let (code, stdout, stderr) = run_cli(&[ + "--json", + "--data-dir", + &dir.arg(), + "launch", + "1.12.2", + "--dry-run", + ]); + assert_eq!(code, 0, "stderr: {stderr}"); + let envelope = parse_json(&stdout); + let json = &envelope["data"]; + assert_eq!(json["java"]["required_major"], 8, "{json}"); + assert_eq!(json["java"]["required"], "Java 8", "{json}"); + // 本机若有比 8 新的 Java,必须给出 java_too_new 告警(不能只靠「最低版本」)。 + if let Some(code) = json["java"]["mismatch"]["code"].as_str() { + assert!(code == "java_too_new" || code == "java_too_old", "{json}"); + assert!( + json["warnings"] + .as_array() + .is_some_and(|warnings| !warnings.is_empty()), + "不符时必须进 warnings:{json}" + ); + } +} diff --git a/crates/cli/tests/os_test.rs b/crates/cli/tests/os_test.rs new file mode 100644 index 0000000..086da5f --- /dev/null +++ b/crates/cli/tests/os_test.rs @@ -0,0 +1,115 @@ +//! D19/D21 支持基线:core 的纯判定函数 + CLI 的呈现层(注入样本,不动真机)。 + +use unlucky_cli::os; +use unlucky_core::platform::linux_caps::{GlProbe, SessionKind}; +use unlucky_core::platform::os_info::{ + self, linux_level, macos_level, support_level_for, windows_level, OsFamily, OsInfo, + SupportLevel, +}; +use unlucky_core::platform::vk_probe::VkProbe; + +/// 手工构造一份样本系统信息(字段全是探测事实,等级用 core 的纯函数算)。 +fn sample(family: OsFamily, version: Option<(u32, u32)>, product: &str) -> OsInfo { + let version_string = + version.map_or_else(String::new, |(major, minor)| format!("{major}.{minor}")); + OsInfo { + family, + arch: "x86_64".to_string(), + major_minor: version, + build: None, + product: product.to_string(), + version_string, + session: SessionKind::Unknown, + gl: GlProbe::Unknown, + vk: VkProbe::Unknown, + kernel: None, + glibc: None, + support_level: support_level_for(family, version, None, None), + } +} + +#[test] +fn windows_samples_below_win10_are_unsupported() { + // D21 用户拍板「直接 win10 起步」:Win8.1 / 8 / 7 及更旧一律不支持。 + assert_eq!(windows_level(Some((6, 1))), SupportLevel::Unsupported); // Win7 + assert_eq!(windows_level(Some((6, 2))), SupportLevel::Unsupported); // Win8 + assert_eq!(windows_level(Some((6, 3))), SupportLevel::Unsupported); // Win8.1 + assert_eq!(windows_level(Some((5, 1))), SupportLevel::Unsupported); // XP + assert_eq!(windows_level(Some((10, 0))), SupportLevel::One); // Win10 + + // 探不到版本 → 保守判不支持(不能因为"探不到"就放行)。 + assert_eq!(windows_level(None), SupportLevel::Unsupported); +} + +#[test] +fn macos_and_linux_samples() { + assert_eq!(macos_level(Some(11)), SupportLevel::Unsupported); + assert_eq!(macos_level(Some(12)), SupportLevel::One); + assert_eq!(macos_level(None), SupportLevel::Unsupported); + // Linux:内核/glibc 探得到但过低 → 不支持;取不到(容器/musl/Termux)不阻塞。 + assert_eq!(linux_level(Some((2, 6)), None), SupportLevel::Unsupported); + assert_eq!(linux_level(None, Some((2, 12))), SupportLevel::Unsupported); + assert_eq!(linux_level(Some((6, 8)), Some((2, 39))), SupportLevel::One); + assert_eq!(linux_level(None, None), SupportLevel::One); +} + +#[test] +fn unsupported_sample_renders_upgrade_hint() { + let info = sample(OsFamily::Windows, Some((6, 3)), "Windows 8.1"); + + assert!(!info.support_level.is_usable()); + let line = os::summary_line(&info); + assert!(line.contains("Windows 8.1"), "{line}"); + assert!(line.contains("6.3"), "{line}"); + assert!(line.contains("不支持"), "{line}"); + + let notes = os::notes(&info); + assert_eq!(notes.len(), 1); + assert!(notes[0].contains("Windows 10 或更高版本"), "{notes:?}"); + + // 文案是中英双语(core 同一份措辞),脚本只读 --json。 + let message = info.describe().combined(); + assert!(message.contains("Windows 10 或更高版本"), "{message}"); + assert!(message.contains("Windows 10 or newer"), "{message}"); + + let value = os::to_json(&info); + assert_eq!(value["family"], "windows"); + assert_eq!(value["product"], "Windows 8.1"); + assert_eq!(value["support"], "unsupported"); + assert_eq!(value["major_minor"], "6.3"); +} + +#[test] +fn supported_sample_has_no_notes() { + let info = sample(OsFamily::Windows, Some((10, 0)), "Windows 11"); + assert!(info.support_level.is_usable()); + assert!(os::notes(&info).is_empty()); + assert!(os::summary_line(&info).contains("满足最低要求")); + assert_eq!(os::to_json(&info)["support"], "one"); +} + +#[test] +fn missing_version_is_reported_as_null_not_zero() { + // 探不到版本时 `version` 是 null(脚本按 null 判定"未探测"),而不是空串。 + let info = sample(OsFamily::Windows, None, "Windows"); + let value = os::to_json(&info); + assert!(value["version"].is_null()); + assert!(value["major_minor"].is_null()); + assert_eq!(value["support"], "unsupported"); + assert!(os::summary_line(&info).contains("版本未知")); +} + +#[test] +fn real_detect_matches_core_cache() { + // 真机上 CLI 的摘要必须就是 core 的缓存快照(同一份事实,不做二次加工)。 + let info = os::detect(); + assert_eq!(&info, os_info::current()); + assert!(!info.arch.is_empty()); + assert!(!info.product.is_empty()); + // 自检形态与事实一致。 + let check = os::check(); + assert_eq!(check.level, info.support_level); + assert_eq!(check.os, info); + assert_eq!(check.is_usable(), info.support_level.is_usable()); + assert!(check.message.contains(&info.product), "{}", check.message); +} diff --git a/crates/cli/tests/output_test.rs b/crates/cli/tests/output_test.rs new file mode 100644 index 0000000..a9df87c --- /dev/null +++ b/crates/cli/tests/output_test.rs @@ -0,0 +1,287 @@ +//! `--json` 输出形状与真实 core 轻量路径(只读命令 + dry-run)。 + +#[path = "support/mod.rs"] +mod support; + +use support::{parse_json, run_cli, write_profiles, write_session, write_version, TempDir}; + +#[test] +fn version_json_has_os_summary() { + let (code, stdout, _) = run_cli(&["--version", "--json"]); + assert_eq!(code, 0); + let value = parse_json(&stdout); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["name"], "ull"); + assert_eq!(value["data"]["version"], env!("CARGO_PKG_VERSION")); + // D19/D21:os 对象来自 core::platform::os_info(family + 两档 support)。 + assert!(value["data"]["os"]["family"].is_string()); + let support = value["data"]["os"]["support"].as_str().unwrap_or_default(); + assert!(matches!(support, "one" | "unsupported"), "{support}"); +} + +#[test] +fn welcome_without_subcommand_prints_version_and_help() { + let (code, stdout, _) = run_cli(&[]); + assert_eq!(code, 0); + assert!(stdout.contains("ull "), "{stdout}"); + assert!(stdout.contains("用法:"), "{stdout}"); + + let (code, stdout, _) = run_cli(&["--json"]); + assert_eq!(code, 0); + let value = parse_json(&stdout); + assert!(value["data"]["help"] + .as_str() + .unwrap_or_default() + .contains("用法:")); +} + +#[test] +fn instances_list_json_shape() { + let dir = TempDir::new("instances"); + write_version(dir.path(), "1.20.1"); + write_profiles(dir.path(), &[("生存服", "1.20.1"), ("空实例", "1.19.4")]); + + let (code, stdout, _) = run_cli(&["instances", "list", "--json", "--data-dir", &dir.arg()]); + assert_eq!(code, 0); + let value = parse_json(&stdout); + let instances = value["data"]["instances"] + .as_array() + .expect("instances 数组"); + assert_eq!(instances.len(), 2); + assert_eq!(instances[0]["name"], "生存服"); + assert_eq!(instances[0]["version_present"], true); + assert_eq!(instances[1]["version_present"], false); + let installed = value["data"]["installed_versions"] + .as_array() + .expect("已安装版本"); + assert_eq!(installed.len(), 1); + assert_eq!(installed[0], "1.20.1"); +} + +#[test] +fn install_rejects_unknown_loader_before_touching_network() { + let dir = TempDir::new("install-loader"); + let (code, stdout, _) = run_cli(&[ + "install", + "1.20.1", + "--loader", + "liteloader", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 1, "未知加载器是用户错误"); + let value = parse_json(&stdout); + assert_eq!(value["error"]["code"], "loader_unknown"); + assert!( + !dir.path() + .join(".ULL") + .join("config") + .join("profiles.json") + .exists(), + "拒绝时不该登记任何实例" + ); +} + +#[test] +fn install_reports_name_conflict_without_downloading() { + let dir = TempDir::new("install-conflict"); + write_profiles(dir.path(), &[("生存服", "1.19.4")]); + + // 同名但绑定别的版本 → 冲突,且这一步在联网/下载之前就返回。 + let (code, stdout, _) = run_cli(&[ + "install", + "1.20.1", + "--name", + "生存服", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 1); + let value = parse_json(&stdout); + assert_eq!(value["error"]["code"], "instance_exists"); + assert!( + value["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("1.19.4"), + "错误里应说明冲突版本:{stdout}" + ); +} + +#[test] +fn install_requires_a_version_id() { + let dir = TempDir::new("install-empty"); + let (code, _, stderr) = run_cli(&["install", " ", "--data-dir", &dir.arg()]); + assert_eq!(code, 1); + assert!(stderr.contains("版本 id"), "stderr={stderr}"); +} + +#[test] +fn launch_dry_run_prints_assembled_command() { + let dir = TempDir::new("dryrun"); + write_version(dir.path(), "1.20.1"); + write_profiles(dir.path(), &[("生存服", "1.20.1")]); + + let (code, stdout, _) = run_cli(&[ + "launch", + "生存服", + "--dry-run", + "--username", + "Steve", + "--max-memory", + "2048", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "{stdout}"); + let value = parse_json(&stdout); + let data = &value["data"]; + assert_eq!(data["dry_run"], true); + assert_eq!(data["instance_found"], true); + let argv = data["argv"].as_array().expect("argv 数组"); + let joined = argv + .iter() + .filter_map(|item| item.as_str()) + .collect::>() + .join(" "); + assert!(joined.contains("-Xmx2048"), "{joined}"); + assert!( + joined.contains("net.minecraft.client.main.Main"), + "{joined}" + ); + assert!(joined.contains("--username Steve"), "{joined}"); + // 凭据绝不落到 dry-run 输出里。 + assert!(!joined.contains("accessToken "), "{joined}"); + assert!(data["classpath_entries"].as_u64().unwrap_or(0) >= 1); +} + +#[test] +fn sessions_logs_and_diag_read_the_index() { + let dir = TempDir::new("sessions"); + let sid = write_session(dir.path()); + + let (code, stdout, _) = run_cli(&["sessions", "list", "--json", "--data-dir", &dir.arg()]); + assert_eq!(code, 0); + let value = parse_json(&stdout); + let sessions = value["data"]["sessions"].as_array().expect("sessions 数组"); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0]["session_id"], sid); + assert_eq!(sessions[0]["exit_code"], 1); + + let (code, stdout, _) = run_cli(&[ + "logs", + &sid, + "--tail", + "1", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0); + let value = parse_json(&stdout); + let events = value["data"]["events"].as_array().expect("events 数组"); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["kind"], "stderr"); + + // 前缀匹配唯一即可(脚本里手打全 id 太长)。 + let (code, stdout, _) = run_cli(&["diag", "20261030", "--json", "--data-dir", &dir.arg()]); + assert_eq!(code, 0, "{stdout}"); + let value = parse_json(&stdout); + assert_eq!(value["data"]["session_id"], sid); + let candidates = value["data"]["diagnosis"]["candidates"] + .as_array() + .expect("candidates 数组"); + assert!(!candidates.is_empty(), "OOM 日志应命中规则:{stdout}"); + assert_eq!(value["data"]["diagnosis"]["needs_more_info"], false); +} + +#[test] +fn logs_json_payload_is_redacted_like_human_output() { + // P2-1:`--json` 是机器消费路径,不该比人类输出拿到更多原文(主目录用户名段)。 + let dir = TempDir::new("logs-redact"); + let sid = write_session(dir.path()); + let events = dir + .path() + .join(".ULL") + .join("data") + .join("agent") + .join("sessions") + .join("game") + .join(format!("{sid}.jsonl")); + let extra = serde_json::json!({ + "v": 1, "seq": 9, "ts": "2026-10-30T10:15:07Z", "t_ms": 6000, + "kind": "stdout", "session_id": sid, + "payload": { "line": r"cat C:\Users\Bob\secret.txt failed", "level": "info" } + }); + let mut text = std::fs::read_to_string(&events).expect("读事件流"); + text.push_str(&serde_json::to_string(&extra).expect("序列化事件")); + text.push('\n'); + std::fs::write(&events, text).expect("写事件流"); + + let (code, stdout, stderr) = run_cli(&["logs", &sid, "--json", "--data-dir", &dir.arg()]); + assert_eq!(code, 0, "{stderr}"); + assert!(!stdout.contains("Bob"), "JSON 载荷泄出用户名:{stdout}"); + assert!(stdout.contains("***"), "JSON 载荷未脱敏:{stdout}"); +} + +#[test] +fn config_get_set_round_trip() { + let dir = TempDir::new("config"); + let data_dir = dir.arg(); + + let (code, stdout, _) = run_cli(&[ + "config", + "get", + "ui.language", + "--json", + "--data-dir", + &data_dir, + ]); + assert_eq!(code, 0); + let value = parse_json(&stdout); + assert_eq!(value["data"]["key"], "ui.language"); + assert_eq!(value["data"]["value"], "zh_cn"); + + let (code, stdout, _) = run_cli(&[ + "config", + "set", + "ui.language", + "en_us", + "--json", + "--data-dir", + &data_dir, + ]); + assert_eq!(code, 0, "{stdout}"); + let value = parse_json(&stdout); + assert_eq!(value["data"]["old"], "zh_cn"); + assert_eq!(value["data"]["new"], "en_us"); + + let (_, stdout, _) = run_cli(&[ + "config", + "get", + "ui.language", + "--json", + "--data-dir", + &data_dir, + ]); + assert_eq!(parse_json(&stdout)["data"]["value"], "en_us"); +} + +#[test] +fn error_envelope_shape() { + let dir = TempDir::new("errors"); + let (code, stdout, _) = run_cli(&["launch", "nope", "--json", "--data-dir", &dir.arg()]); + assert_eq!(code, 1); + let value = parse_json(&stdout); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "not_found"); + assert_eq!(value["error"]["kind"], "user"); + assert!(value["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("nope")); + assert!(value["error"]["hint"].is_string()); +} diff --git a/crates/cli/tests/parse_test.rs b/crates/cli/tests/parse_test.rs new file mode 100644 index 0000000..62a17cb --- /dev/null +++ b/crates/cli/tests/parse_test.rs @@ -0,0 +1,338 @@ +//! 子命令解析:手写解析器的语法契约。 + +#[path = "support/mod.rs"] +mod support; + +use unlucky_cli::cli::{parse, AccountsAddKind, Command, Globals}; +use unlucky_cli::error::ErrorKind; + +fn invoke(args: &[&str]) -> unlucky_cli::cli::Invocation { + let owned: Vec = args.iter().map(|arg| arg.to_string()).collect(); + parse(&owned).unwrap_or_else(|error| panic!("解析失败:{error}")) +} + +#[test] +fn no_args_is_welcome() { + let invocation = invoke(&[]); + assert_eq!(invocation.command, Command::Welcome); + assert_eq!(invocation.globals, Globals::default()); +} + +#[test] +fn version_flags() { + assert_eq!(invoke(&["--version"]).command, Command::Version); + assert_eq!(invoke(&["-V"]).command, Command::Version); + assert_eq!(invoke(&["version"]).command, Command::Version); +} + +#[test] +fn help_takes_topic_from_positional() { + assert_eq!(invoke(&["--help"]).command, Command::Help(None)); + assert_eq!( + invoke(&["--help", "launch"]).command, + Command::Help(Some("launch".to_string())) + ); + assert_eq!( + invoke(&["help", "config"]).command, + Command::Help(Some("config".to_string())) + ); +} + +#[test] +fn globals_are_position_independent() { + let invocation = invoke(&["instances", "list", "--json", "--quiet"]); + assert_eq!(invocation.command, Command::InstancesList); + assert!(invocation.globals.json); + assert!(invocation.globals.quiet); + + let before = invoke(&["--json", "instances", "list"]); + assert!(before.globals.json); + assert_eq!(before.command, Command::InstancesList); +} + +#[test] +fn data_dir_accepts_both_forms() { + assert_eq!( + invoke(&["--data-dir", "/tmp/ull", "instances", "list"]) + .globals + .data_dir, + Some(std::path::PathBuf::from("/tmp/ull")) + ); + assert_eq!( + invoke(&["instances", "list", "--data-dir=/tmp/ull"]) + .globals + .data_dir, + Some(std::path::PathBuf::from("/tmp/ull")) + ); +} + +#[test] +fn install_flags() { + let Command::Install(args) = + invoke(&["install", "1.20.1", "--loader", "vanilla", "--name=x"]).command + else { + panic!("应为 install"); + }; + assert_eq!(args.version, "1.20.1"); + assert_eq!(args.loader.as_deref(), Some("vanilla")); + assert_eq!(args.name.as_deref(), Some("x")); + assert!(!args.dry_run); + assert!(args.progress, "人类模式默认打实时进度"); +} + +#[test] +fn install_loader_version_flag() { + let Command::Install(args) = invoke(&[ + "install", + "1.20.1", + "--loader", + "fabric", + "--loader-version", + "0.15.11", + ]) + .command + else { + panic!("应为 install"); + }; + assert_eq!(args.loader.as_deref(), Some("fabric")); + assert_eq!(args.loader_version.as_deref(), Some("0.15.11")); + // 不带该 flag 时为 None(由 core 取官方默认版本)。 + let Command::Install(default) = invoke(&["install", "1.20.1"]).command else { + panic!("应为 install"); + }; + assert!(default.loader_version.is_none()); +} + +#[test] +fn install_dry_run_flag_and_progress_suppression() { + let Command::Install(args) = invoke(&["install", "1.20.1", "--dry-run"]).command else { + panic!("应为 install"); + }; + assert!(args.dry_run); + assert!(args.progress); + assert!(!args.json_progress, "人类模式打文本进度"); + + // `--json`:stderr 换成 JSONL 进度(stdout 仍是单个信封)。 + let Command::Install(args) = invoke(&["--json", "install", "1.20.1"]).command else { + panic!("应为 install"); + }; + assert!(args.progress); + assert!(args.json_progress); + + // `--quiet`:一律不打进度。 + let Command::Install(args) = invoke(&["install", "1.20.1", "-q"]).command else { + panic!("应为 install"); + }; + assert!(!args.progress); + assert!(!args.dry_run); +} + +#[test] +fn launch_flags_and_defaults() { + let Command::Launch(args) = invoke(&["launch", "生存服"]).command else { + panic!("应为 launch"); + }; + assert_eq!(args.instance, "生存服"); + assert!(!args.dry_run); + assert_eq!(args.java, None); + assert_eq!(args.max_memory, None); + + let Command::Launch(args) = invoke(&[ + "launch", + "生存服", + "--dry-run", + "--java", + "/opt/jdk/bin/java", + "--username", + "Steve", + "--max-memory", + "4096", + ]) + .command + else { + panic!("应为 launch"); + }; + assert!(args.dry_run); + assert_eq!(args.username.as_deref(), Some("Steve")); + assert_eq!(args.max_memory, Some(4096)); +} + +#[test] +fn accounts_subcommands() { + assert_eq!(invoke(&["accounts"]).command, Command::AccountsList); + assert_eq!(invoke(&["accounts", "list"]).command, Command::AccountsList); + + let Command::AccountsAdd(args) = invoke(&["accounts", "add", "offline", "Steve"]).command + else { + panic!("应为 accounts add offline"); + }; + assert_eq!(args.kind, AccountsAddKind::Offline); + assert_eq!(args.name.as_deref(), Some("Steve")); + + let Command::AccountsAdd(args) = invoke(&["accounts", "add", "msa"]).command else { + panic!("应为 accounts add msa"); + }; + assert_eq!(args.kind, AccountsAddKind::Microsoft); + assert_eq!(args.name, None); + + let Command::AccountsRemove { name } = invoke(&["accounts", "remove", "Steve"]).command else { + panic!("应为 accounts remove"); + }; + assert_eq!(name, "Steve"); +} + +#[test] +fn launch_account_flag() { + let Command::Launch(args) = invoke(&["launch", "生存服", "--account", "Steve"]).command + else { + panic!("应为 launch"); + }; + assert_eq!(args.account.as_deref(), Some("Steve")); + assert_eq!(args.username, None); +} + +#[test] +fn logs_and_diag_default_tails() { + let Command::Logs(args) = invoke(&["logs", "abc"]).command else { + panic!("应为 logs"); + }; + assert_eq!(args.tail, 200); + + let Command::Diag(args) = invoke(&["diag", "abc", "--tail", "10"]).command else { + panic!("应为 diag"); + }; + assert_eq!(args.tail, 10); +} + +#[test] +fn config_get_set_forms() { + let Command::ConfigGet(args) = invoke(&["config", "get"]).command else { + panic!("应为 config get"); + }; + assert_eq!(args.key, None); + assert!(!args.show_secret); + + let Command::ConfigGet(args) = + invoke(&["config", "get", "ui.language", "--show-secret"]).command + else { + panic!("应为 config get"); + }; + assert_eq!(args.key.as_deref(), Some("ui.language")); + assert!(args.show_secret); + + let Command::ConfigSet(args) = invoke(&["config", "set", "ui.language", "en_us"]).command + else { + panic!("应为 config set"); + }; + assert_eq!(args.key, "ui.language"); + assert_eq!(args.value, "en_us"); +} + +#[test] +fn java_subcommands() { + assert_eq!(invoke(&["java"]).command, Command::JavaList); + assert_eq!(invoke(&["java", "list"]).command, Command::JavaList); + assert_eq!( + invoke(&["java", "install", "21"]).command, + Command::JavaInstall { + major: 21, + vendor: None + } + ); + // D23:`--vendor` 钉死发行版(别名也认),`java vendors` 列出四家。 + assert_eq!( + invoke(&["java", "install", "21", "--vendor", "oracle"]).command, + Command::JavaInstall { + major: 21, + vendor: Some(unlucky_core::game::java::JdkVendor::Oracle), + } + ); + assert_eq!( + invoke(&["java", "install", "17", "--vendor", "adoptium"]).command, + Command::JavaInstall { + major: 17, + vendor: Some(unlucky_core::game::java::JdkVendor::Temurin), + } + ); + assert_eq!(invoke(&["java", "vendors"]).command, Command::JavaVendors); +} + +#[test] +fn usage_errors_are_user_errors() { + let cases: &[&[&str]] = &[ + &["frobnicate"], + &["install"], + &["launch"], + &["launch", "a", "b"], + &["instances", "list", "--nope"], + &["install", "1.20.1", "--dry-run=1"], + &["config", "set", "only-key"], + &["java", "install", "abc"], + &["--data-dir"], + &["sessions", "watch"], + &["accounts", "add"], + &["accounts", "add", "offline"], + &["accounts", "add", "authlib"], + &["accounts", "remove"], + &["accounts", "frobnicate"], + ]; + for args in cases { + let owned: Vec = args.iter().map(|arg| arg.to_string()).collect(); + let error = parse(&owned).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::User, "args={args:?}"); + assert_eq!(error.exit_code(), 1, "args={args:?}"); + } +} + +#[test] +fn value_flags_do_not_swallow_the_next_flag() { + // P2-2:漏了值的写法必须在解析期报用法错误,而不是把 `--dry-run` 当成 Java 路径。 + let cases: &[&[&str]] = &[ + &["launch", "x", "--java", "--dry-run"], + &["launch", "x", "--username", "--json"], + &["install", "1.20.1", "--name", "--loader", "fabric"], + &["install", "1.20.1", "--loader", "--name", "x"], + &["logs", "abc", "--tail", "--json"], + &["--data-dir", "--json", "instances", "list"], + ]; + for args in cases { + let owned: Vec = args.iter().map(|arg| arg.to_string()).collect(); + let error = parse(&owned).expect_err(&format!("args={args:?} 应当报用法错误")); + assert_eq!(error.code(), "usage", "args={args:?}"); + assert!( + error.message().contains("需要一个值"), + "args={args:?} message={}", + error.message() + ); + } + + // 负数值与正常值不受影响(只拒 `--` 开头的 token)。 + let Command::Launch(args) = invoke(&["launch", "x", "--java", "/opt/jdk/bin/java"]).command + else { + panic!("应为 launch"); + }; + assert_eq!( + args.java.as_deref(), + Some(std::path::Path::new("/opt/jdk/bin/java")) + ); +} + +#[test] +fn help_text_covers_every_command() { + for topic in [ + "instances", + "install", + "launch", + "sessions", + "logs", + "diag", + "config", + "java", + ] { + assert!( + unlucky_cli::cli::help::topic(topic).is_some(), + "缺少 {topic} 的帮助" + ); + } + assert!(unlucky_cli::cli::help::topic("nope").is_none()); +} diff --git a/crates/cli/tests/support/mod.rs b/crates/cli/tests/support/mod.rs new file mode 100644 index 0000000..a14e011 --- /dev/null +++ b/crates/cli/tests/support/mod.rs @@ -0,0 +1,159 @@ +//! 集成测试共用夹具:临时数据根、命令执行器、最小版本 JSON / 会话索引。 +//! +//! 每个测试目标都会整份编译本文件但只用其中一部分,因此这里放开 dead_code。 +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// 一次性临时目录(`Drop` 时清理)。 +pub struct TempDir { + path: PathBuf, +} + +impl TempDir { + /// 建一个唯一目录。 + pub fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "ull-cli-test-{tag}-{}-{seq}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("创建临时目录"); + Self { path } + } + + /// 目录路径。 + pub fn path(&self) -> &Path { + &self.path + } + + /// 目录的字符串形式(喂给 `--data-dir`)。 + pub fn arg(&self) -> String { + self.path.display().to_string() + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +/// 跑一次 CLI,返回(退出码, stdout, stderr)。 +pub fn run_cli(args: &[&str]) -> (i32, String, String) { + let owned: Vec = args.iter().map(|arg| arg.to_string()).collect(); + let mut out: Vec = Vec::new(); + let mut err: Vec = Vec::new(); + let code = unlucky_cli::run(&owned, &mut out, &mut err); + ( + code, + String::from_utf8_lossy(&out).into_owned(), + String::from_utf8_lossy(&err).into_owned(), + ) +} + +/// 解析 `--json` 输出(断言它是合法 JSON 并返回)。 +pub fn parse_json(stdout: &str) -> serde_json::Value { + serde_json::from_str(stdout).unwrap_or_else(|error| panic!("输出不是 JSON:{error}\n{stdout}")) +} + +/// 写一份最小可组装的新版版本 JSON + 空 client jar。 +pub fn write_version(data_root: &Path, id: &str) { + let dir = data_root.join(".minecraft").join("versions").join(id); + std::fs::create_dir_all(&dir).expect("创建版本目录"); + let version = serde_json::json!({ + "id": id, + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "assets": "5", + "assetIndex": { "id": "5" }, + "arguments": { + "game": ["--username", "${auth_player_name}", "--version", "${version_name}"], + "jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"] + }, + "libraries": [], + "downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } } + }); + std::fs::write( + dir.join(format!("{id}.json")), + serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"), + ) + .expect("写版本 JSON"); + std::fs::write(dir.join(format!("{id}.jar")), b"junk").expect("写 client jar"); +} + +/// 写 `config/profiles.json`(实例名 → 版本 id)。 +pub fn write_profiles(data_root: &Path, profiles: &[(&str, &str)]) { + let dir = data_root.join(".ULL").join("config"); + std::fs::create_dir_all(&dir).expect("创建配置目录"); + let entries: Vec = profiles + .iter() + .enumerate() + .map(|(index, (name, version))| { + serde_json::json!({ + "id": format!("11111111-2222-4333-8444-{:012}", index + 1), + "name": name, + "version_id": version, + }) + }) + .collect(); + let document = serde_json::json!({ "schema_version": 1, "profiles": entries }); + std::fs::write( + dir.join("profiles.json"), + serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"), + ) + .expect("写 profiles.json"); +} + +/// 写一份「退出码 1 + OOM」的会话索引与事件流,返回会话 id。 +pub fn write_session(data_root: &Path) -> String { + let sid = "20261030T101500-abcdef"; + let dir = data_root + .join(".ULL") + .join("data") + .join("agent") + .join("sessions") + .join("game"); + std::fs::create_dir_all(&dir).expect("创建会话目录"); + let events = [ + serde_json::json!({"v":1,"seq":0,"ts":"2026-10-30T10:15:00Z","t_ms":0,"kind":"session_start","session_id":sid,"payload":{"instance_name":"生存服","game_version":"1.20.1"}}), + serde_json::json!({"v":1,"seq":1,"ts":"2026-10-30T10:15:02Z","t_ms":1500,"kind":"stdout","session_id":sid,"payload":{"line":"[main/INFO]: Loading Minecraft 1.20.1","level":"info"}}), + serde_json::json!({"v":1,"seq":2,"ts":"2026-10-30T10:15:05Z","t_ms":4200,"kind":"stderr","session_id":sid,"payload":{"line":"java.lang.OutOfMemoryError: Java heap space","level":"error"}}), + ]; + let mut text = String::new(); + for event in events { + text.push_str(&serde_json::to_string(&event).expect("序列化事件")); + text.push('\n'); + } + std::fs::write(dir.join(format!("{sid}.jsonl")), text).expect("写事件流"); + let entry = serde_json::json!({ + "v": 1, + "session_id": sid, + "instance_id": "11111111-2222-4333-8444-555555555555", + "instance_name": "生存服", + "game_version": "1.20.1", + "started_at": "2026-10-30T10:15:00Z", + "ended_at": "2026-10-30T10:15:06Z", + "duration_ms": 5000, + "exit_code": 1, + "exit_type": "application_error", + "silent_failure": false, + "window_detected": false, + "event_count": 3, + "log_path": format!("{sid}.jsonl"), + "crash_found": false, + "diagnosis_top": null + }); + std::fs::write( + dir.join("index.jsonl"), + format!("{}\n", serde_json::to_string(&entry).expect("序列化索引")), + ) + .expect("写会话索引"); + sid.to_string() +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..d39441e --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,74 @@ +[package] +name = "unlucky-core" +description = "Unlucky-Launcher 领域库:纯逻辑,零 UI 依赖(D3 架构纪律)" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true + +# 零 UI 依赖是硬约束:这里只允许纯逻辑/协议/IO 类依赖(serde/tokio/reqwest 等后续按模块加), +# 任何时候都不得出现 slint / egui / iced / winit / gtk / javafx —— CI 的 core-no-ui job 守门。 +[dependencies] +# M15(core::diagnose)预接线:59 条规则正则、规则 YAML 解析、诊断输出序列化。 +# 真正使用方是 crates/core/src/diagnose/ 下的 collectors/engine/hs_err/loader/rules(M15 落地)。 +regex.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml_ng.workspace = true + +# M13(core::download)预接线:HTTP / 异步运行时 / 流 / 哈希校验。 +reqwest.workspace = true +tokio.workspace = true +futures-util.workspace = true +sha1.workspace = true +sha2.workspace = true +zip.workspace = true +# M31(core::auth):离线 UUID = MD5 v3(与 HMCL / 服务端 nameUUIDFromBytes 对拍)。 +md-5.workspace = true +# M33(core::game::java)预接线:tar.gz 解压(Temurin 的 Linux/macOS 产物)。 +flate2.workspace = true +tar.workspace = true + +# M13 的 8 个下载测试 target(文件由 M13 阶段 B 用真实用例覆盖;脚手架期是占位)。 +# `tests/download/support/mod.rs` 是被各 test include 的公共夹具,不单独注册。 +[[test]] +name = "backoff" +path = "tests/download/backoff_test.rs" + +[[test]] +name = "health" +path = "tests/download/health_test.rs" + +[[test]] +name = "mirrors" +path = "tests/download/mirrors_test.rs" + +[[test]] +name = "region" +path = "tests/download/region_test.rs" + +[[test]] +name = "cache" +path = "tests/download/cache_test.rs" + +[[test]] +name = "chunked" +path = "tests/download/chunked_test.rs" + +[[test]] +name = "resume" +path = "tests/download/resume_test.rs" + +[[test]] +name = "engine_mock" +path = "tests/download/engine_mock_test.rs" + +[[test]] +name = "partfile" +path = "tests/download/partfile_test.rs" + +[lints] +workspace = true diff --git a/crates/core/src/addon/loader/fabric.rs b/crates/core/src/addon/loader/fabric.rs new file mode 100644 index 0000000..ec28c57 --- /dev/null +++ b/crates/core/src/addon/loader/fabric.rs @@ -0,0 +1,247 @@ +//! Fabric 系(Fabric / Quilt):官方 meta → loader 版本 JSON。 +//! +//! 依据 `DevDocs/analyse/05-modloader-modpack.md` §1.4:这两个加载器**没有 installer**, +//! `meta.fabricmc.net` / `meta.quiltmc.org` 直接给 launcher 可用的 profile JSON, +//! 所以安装 = "把 profile 收成我们的版本 JSON + 补库",没有任何子进程。 +//! +//! 本文件是**纯函数**(不进网络):URL 拼装与 JSON 归一化分开写,测试直接喂离线夹具。 +//! +//! # 归一化做了三件容易被上游坑的事 +//! +//! 1. **`jar` 钉成原版 id**:profile 通常不写 `jar`,而 `game::inherit::resolve` 在缺 `jar` +//! 时会把 `jar` 兜底成**自身 id**——那会让启动去找 `versions//.jar` +//! (不存在)。这里显式写 `jar = <原版 id>`,让继承链正确地回到原版 jar。 +//! 2. **`mainClass` 断言**:缺主类就不是一份能启动的清单,宁可在安装期报 metadata, +//! 也不要装出一个起不来的实例(HMCL 同款防御,analyse/05 §1.4)。 +//! 3. **扁平 `sha1`/`size` 提升成 `downloads.artifact`**:Fabric profile 的库把摘要写在 +//! 库对象顶层,M27 的库展开只认 `downloads.artifact`。不提上来的后果是"没有校验值 → +//! 存在即信",重装时无法识别被截断的库文件。 + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::plan::{LoaderRelease, LoaderSupport}; +use super::{LoaderError, LoaderKind}; +use crate::game::library::{Library, LibraryDownloads}; +use crate::game::manifest::{DownloadInfo, VersionJson}; + +/// Fabric 官方 meta 根(BMCLAPI 有对应镜像规则 `bmclapi-fabric-meta`)。 +pub const FABRIC_META: &str = "https://meta.fabricmc.net/v2"; +/// Quilt 官方 meta 根(同上,`bmclapi-quilt-meta`)。 +pub const QUILT_META: &str = "https://meta.quiltmc.org/v3"; + +/// meta 根地址(按加载器)。 +#[must_use] +pub fn meta_base(kind: LoaderKind) -> &'static str { + match kind { + LoaderKind::Quilt => QUILT_META, + _ => FABRIC_META, + } +} + +/// 该原版版本可用的加载器版本列表地址。 +#[must_use] +pub fn loader_list_url(kind: LoaderKind, mc_version: &str) -> String { + format!("{}/versions/loader/{mc_version}", meta_base(kind)) +} + +/// 指定加载器版本的 launcher profile 地址(官方直出可用的版本 JSON)。 +#[must_use] +pub fn profile_url(kind: LoaderKind, mc_version: &str, loader_version: &str) -> String { + format!( + "{}/versions/loader/{mc_version}/{loader_version}/profile/json", + meta_base(kind) + ) +} + +/// 列表里的一个加载器版本。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LoaderEntry { + /// 版本号(`0.15.11`)。 + #[serde(default)] + pub version: String, + /// 是否稳定版(Quilt 的列表不带这个键,缺省按稳定处理)。 + #[serde(default = "default_true")] + pub stable: bool, + /// Maven 坐标(`net.fabricmc:fabric-loader:0.15.11`)。 + #[serde(default)] + pub maven: String, +} + +const fn default_true() -> bool { + true +} + +/// 列表响应的一行(`[{ "loader": {…}, "intermediary": {…}, … }]`)。 +#[derive(Debug, Deserialize)] +struct ListRow { + #[serde(default)] + loader: Option, +} + +/// 解析版本列表(最新的排在前,与官方顺序一致——**不重排**)。 +/// +/// # Errors +/// +/// JSON 非法、或列表形状完全不符(顶层不是数组 / 一条都认不出)时返回 metadata 错误。 +pub fn parse_loader_list(kind: LoaderKind, json: &str) -> Result, LoaderError> { + let rows: Vec = serde_json::from_str(json).map_err(|error| { + LoaderError::metadata(format!("{} 版本列表解析失败:{error}", kind.label())) + })?; + let entries: Vec = rows + .into_iter() + .filter_map(|row| row.loader) + .filter(|entry| !entry.version.is_empty()) + .collect(); + if entries.is_empty() { + return Err(LoaderError::not_found(format!( + "{} 版本列表为空(该原版版本可能还没有对应加载器)", + kind.label() + ))); + } + Ok(entries) +} + +/// 从列表里挑一个默认版本:优先第一个稳定版,否则取第一个(列表最新在前)。 +#[must_use] +pub fn pick_default(entries: &[LoaderEntry]) -> Option<&LoaderEntry> { + entries + .iter() + .find(|entry| entry.stable) + .or_else(|| entries.first()) +} + +/// 把官方 profile JSON 归一化成 `--` 的 loader 版本 JSON。 +/// +/// # Errors +/// +/// profile 不是合法版本 JSON、缺主类时返回 metadata 错误。 +pub fn build_release( + kind: LoaderKind, + mc_version: &str, + loader_version: &str, + profile_json: &str, +) -> Result { + let mut version_json = VersionJson::from_json_str(profile_json).map_err(|error| { + LoaderError::metadata(format!( + "{} {loader_version} 的 profile JSON 解析失败:{error}", + kind.label() + )) + })?; + let mut warnings = Vec::new(); + + let version_id = kind.version_id(mc_version, loader_version); + version_json.id = Some(version_id.clone()); + version_json.name = Some(format!("{mc_version} · {} {loader_version}", kind.label())); + version_json.inherits_from = Some(mc_version.to_string()); + // 启动用原版 jar(见模块文档第 1 条)。 + version_json.jar = Some(mc_version.to_string()); + + if version_json + .main_class + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + return Err(LoaderError::metadata(format!( + "{} {loader_version} 的 profile 没有 mainClass:这份清单装上去也起不来", + kind.label() + )) + .with_hint("换一个加载器版本,或用官方启动器核对")); + } + + let libraries = version_json.libraries.get_or_insert_with(Vec::new); + for library in libraries.iter_mut() { + lift_flat_digest(library); + } + ensure_loader_library(kind, loader_version, libraries, &mut warnings); + + Ok(LoaderRelease { + kind, + mc_version: mc_version.to_string(), + loader_version: loader_version.to_string(), + version_id, + version_json, + support: LoaderSupport::JsonRoute, + installer: None, + install_profile: None, + warnings, + }) +} + +/// 把库对象顶层的 `sha1`/`size` 提升成 `downloads.artifact`(Fabric profile 的写法)。 +fn lift_flat_digest(library: &mut Library) { + if library.downloads.is_some() { + return; + } + let Some(base) = library + .url + .as_deref() + .filter(|url| !url.is_empty()) + .map(str::to_string) + else { + return; + }; + let Ok(relative) = library.relative_path() else { + return; + }; + let sha1 = library + .extra + .get("sha1") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let size = library + .extra + .get("size") + .and_then(serde_json::Value::as_u64); + let path = relative.to_string_lossy().replace('\\', "/"); + let url = if base.ends_with('/') { + format!("{base}{path}") + } else { + format!("{base}/{path}") + }; + library.downloads = Some(LibraryDownloads { + artifact: Some(DownloadInfo { + id: None, + sha1, + size, + url: Some(url), + path: Some(path), + }), + classifiers: BTreeMap::new(), + }); +} + +/// loader 本体库必须在列表里:缺了就补一条(并用告警说明),别装出个"没有 loader"的实例。 +fn ensure_loader_library( + kind: LoaderKind, + loader_version: &str, + libraries: &mut Vec, + warnings: &mut Vec, +) { + let (group, artifact) = match kind { + LoaderKind::Quilt => ("org.quiltmc", "quilt-loader"), + _ => ("net.fabricmc", "fabric-loader"), + }; + let present = libraries.iter().any(|library| { + library + .coordinate() + .is_ok_and(|coordinate| coordinate.group == group && coordinate.artifact == artifact) + }); + if present { + return; + } + let maven_base = match kind { + LoaderKind::Quilt => "https://maven.quiltmc.org/repository/release/", + _ => "https://maven.fabricmc.net/", + }; + libraries.push(Library { + name: format!("{group}:{artifact}:{loader_version}"), + url: Some(maven_base.to_string()), + ..Library::default() + }); + warnings.push(format!( + "{} {loader_version} 的 profile 没带 {group}:{artifact}:已按 maven 坐标补一条(缺库会让游戏起不来)", + kind.label() + )); +} diff --git a/crates/core/src/addon/loader/forge.rs b/crates/core/src/addon/loader/forge.rs new file mode 100644 index 0000000..76c0ee5 --- /dev/null +++ b/crates/core/src/addon/loader/forge.rs @@ -0,0 +1,280 @@ +//! NeoForge / Forge:installer 内的 install profile → loader 版本 JSON。 +//! +//! 依据 `DevDocs/analyse/05-modloader-modpack.md` §1.2/§1.3:Forge 有两代 installer 协议, +//! NeoForge 是 fork(早期与 Forge 完全同构)。判据也在那儿:install profile 有没有 +//! `spec` / processors、有没有独立的 `version.json`。maven 坐标与版本表在 [`super::maven`]。 +//! +//! # 本模块的取舍(为什么只做一部分) +//! +//! 这两家都**不发**launcher 直接可用的 profile,只发 installer jar;能纯 json 化到什么程度 +//! 取决于 installer 声明的处理器: +//! +//! | install profile 形状 | 实测样例 | 本模块结论 | +//! |----------------------|----------|-----------| +//! | `spec: 1` + 有 client 侧 processors | Forge 1.20.1-47.2.0、NeoForge 21.1.80 | [`LoaderSupport::RequiresInstaller`] | +//! | `spec: 0` + 无 processors + 内嵌 `version.json` | Forge 1.12.2-14.23.5.2860 | [`LoaderSupport::JsonRoute`] | +//! | 只有内联 `versionInfo`(+ `install.filePath` 解包) | 1.12.2 之前的老重建版 | 明确 unsupported | +//! +//! 第一类必须跑 `java -cp … installertools …` 才能产出 `client-slim/extra` 等打补丁产物 +//! (processor 的 `data` 变量表里有 `MC_SLIM`/`MC_EXTRA`/`PATCHED`),解析结果因此带上 +//! [`LoaderSupport::RequiresInstaller`] 标记 + install profile 本体,交给 +//! [`super::processors`](M35 执行器)真跑;这里的**判定**仍只管「要不要跑 installer」。 +//! +//! 第二类的本体 jar 在 version.json 里 `url` 是空串,且**官方 maven 只发布 `-universal` +//! classifier**(无 classifier 路径官方 404,只有 BMCLAPI 的 302 别名兜底)—— +//! 补救见 [`fill_missing_urls`],URL 带 classifier、磁盘落点保持官方 installer 的原样。 +//! +//! # 一条容易踩的坑:processor 的库不进启动 classpath +//! +//! install profile 自带的 `libraries` 是**processor 依赖**(installertools/srgutils…), +//! 它们不属于游戏 classpath。把它们并进 loader 版本 JSON 会让启动多加载一堆无关 jar, +//! 因此这里只记数量进告警、绝不合并;真下载它们的是 [`super::processors`]。 + +use std::collections::BTreeMap; + +use serde::Deserialize; + +use super::maven; +use super::plan::{LoaderRelease, LoaderSupport}; +use super::{LoaderError, LoaderKind}; +use crate::game::library::Library; +use crate::game::manifest::VersionJson; + +pub use super::processors::spec::{Datum, Processor}; + +/// 老协议 installer 的 `install` 段(`filePath` 指向内嵌的 universal jar)。 +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyInstall { + /// 内嵌 jar 在 installer 里的相对路径。 + #[serde(default)] + pub file_path: Option, +} + +/// installer 里的 `install_profile.json`(只解析我们需要的键,其余忽略)。 +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallProfile { + /// 协议代号(`1` = processor 时代)。 + #[serde(default)] + pub spec: Option, + /// 组件名(`forge` / `NeoForge`)。 + #[serde(default)] + pub profile: Option, + /// installer 自己的版本串(`1.20.1-forge-47.2.0`)。 + #[serde(default)] + pub version: Option, + /// 目标原版版本。 + #[serde(default)] + pub minecraft: Option, + /// 内嵌版本 JSON 在 installer 里的路径(通常是 `/version.json`)。 + #[serde(default, rename = "json")] + pub version_json_path: Option, + /// universal jar 的 maven 坐标(`net.minecraftforge:forge:…`,spec 0 才有)。 + #[serde(default)] + pub path: Option, + /// processor 列表。 + #[serde(default)] + pub processors: Vec, + /// processor 的变量表(`{MAPPINGS}` / `{MC_SLIM}` 之类占位的数据源)。 + #[serde(default)] + pub data: BTreeMap, + /// processor / universal jar 依赖(**不是**启动 classpath,别合并)。 + #[serde(default)] + pub libraries: Vec, + /// 老协议:内嵌 jar 解包信息。 + #[serde(default)] + pub install: Option, + /// 老协议:内联的版本 JSON 片段。 + #[serde(default, rename = "versionInfo")] + pub version_info: Option, +} + +impl InstallProfile { + /// client 侧 processor 数量。 + #[must_use] + pub fn client_processors(&self) -> usize { + self.processors + .iter() + .filter(|processor| processor.applies_to_client()) + .count() + } + + /// 是否是老协议 installer(内联 `versionInfo` + 解包 universal jar)。 + #[must_use] + pub fn is_legacy_protocol(&self) -> bool { + self.version_json_path.is_none() && (self.install.is_some() || self.version_info.is_some()) + } +} + +/// 用 install profile + 内嵌版本 JSON 组 loader 版本。 +/// +/// `embedded` 是 installer 内 `version.json` 的内容(读不到就是 `None`)。 +/// +/// # Errors +/// +/// 原版版本对不上、installer 里没有 `version.json`、生成的清单缺主类时返回错误。 +pub fn build_release( + kind: LoaderKind, + mc_version: &str, + maven_version: &str, + profile: &InstallProfile, + embedded: Option<&VersionJson>, +) -> Result { + if let Some(declared) = profile.minecraft.as_deref() { + if declared != mc_version { + return Err(LoaderError::metadata(format!( + "installer 声明的原版是 {declared},与目标的 {mc_version} 不符(选错了加载器版本)" + )) + .with_hint("`--loader-version` 要和 `install ` 的原版版本配套")); + } + } + let loader_version = maven::display_loader_version(kind, mc_version, maven_version); + let version_id = kind.version_id(mc_version, &loader_version); + let mut warnings = Vec::new(); + + let Some(embedded) = embedded else { + let reason = if profile.is_legacy_protocol() { + format!( + "{} {} 走老协议 installer(内联 versionInfo + 解包 universal jar),本期不做解包路线", + kind.label(), + loader_version + ) + } else { + format!( + "{} {} 的 installer 里没有可读的 version.json", + kind.label(), + loader_version + ) + }; + return Err(LoaderError::unsupported(reason).with_hint( + "这段协议要动 installer 内的 jar,属后续 mission;可先用官方启动器装好该加载器", + )); + }; + + let mut version_json = embedded.clone(); + let mut support = LoaderSupport::JsonRoute; + let client_processors = profile.client_processors(); + if client_processors > 0 { + support = LoaderSupport::RequiresInstaller { + reason: format!( + "{} {} 的 installer 声明了 {client_processors} 个 client 侧 processor(拆包/映射/打补丁),必须先外调 java 执行", + kind.label(), + loader_version + ), + }; + warnings.push(format!( + "install profile 里另有 {} 个 processor 依赖(installertools 等):它们不进启动 classpath,只在安装期给执行器用", + profile.libraries.len() + )); + } + + version_json.id = Some(version_id.clone()); + version_json.name = Some(format!("{mc_version} · {} {loader_version}", kind.label())); + version_json.inherits_from = Some(mc_version.to_string()); + version_json.jar = Some(mc_version.to_string()); + + if version_json + .main_class + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + return Err(LoaderError::metadata(format!( + "{} {} 的 version.json 没有 mainClass", + kind.label(), + loader_version + ))); + } + + let libraries = version_json.libraries.get_or_insert_with(Vec::new); + let installer_body = profile.path.as_deref(); + for library in libraries.iter_mut() { + fill_missing_urls(library, maven::base(kind), installer_body); + } + + Ok(LoaderRelease { + kind, + mc_version: mc_version.to_string(), + loader_version, + version_id, + version_json, + support, + installer: None, + install_profile: Some(profile.clone()), + warnings, + }) +} + +/// spec 0 的 version.json 里,forge 本体库的 `url` 常是空串(jar 原本内嵌在 installer 里), +/// 于是"补一个 maven 地址"就能直取,省掉解包 installer。 +/// +/// ⚠️ 本体 jar 的**发布 classifier 是 `universal`**:installer 内嵌的条目叫 +/// `maven/…/forge-.jar`(无 classifier),但官方 maven 只托管 +/// `forge--universal.jar`——无 classifier 的那条路径**官方 404** +/// (实测 1.12.2-14.23.5.2860:`-universal.jar` 200 / 4466148 字节,sha1 与 version.json 一致; +/// 无 classifier 路径仅靠 BMCLAPI 的 302 别名兜底)。 +/// 因此这里**只改 URL**:给 installer 声明的本体构件追加 `-universal`; +/// `artifact.path`(磁盘落点)保持官方 installer 的原样,启动 classpath 与官方布局一致。 +fn fill_missing_urls(library: &mut Library, base: &str, installer_body: Option<&str>) { + if library + .url + .as_deref() + .is_none_or(|url| url.trim().is_empty()) + { + library.url = Some(if base.ends_with('/') { + base.to_string() + } else { + format!("{base}/") + }); + } + // 先算"是不是 installer 声明的本体"(要读 library 的坐标),再拿 artifact 的可变借用。 + let is_body = is_installer_body(library, installer_body); + let root = library.url.clone().unwrap_or_else(|| base.to_string()); + let Some(artifact) = library + .downloads + .as_mut() + .and_then(|downloads| downloads.artifact.as_mut()) + else { + return; + }; + if artifact.has_url() { + return; + } + let Some(path) = artifact.path.clone() else { + return; + }; + let remote_path = if is_body { + with_universal_classifier(&path) + } else { + path + }; + artifact.url = Some(if root.ends_with('/') { + format!("{root}{remote_path}") + } else { + format!("{root}/{remote_path}") + }); +} + +/// 这条库是不是 install profile 的 `path` 声明的本体构件(`group:artifact:version`,无 classifier)。 +fn is_installer_body(library: &Library, installer_body: Option<&str>) -> bool { + let Some(body) = installer_body else { + return false; + }; + let mut parts = body.split(':'); + let (Some(group), Some(artifact)) = (parts.next(), parts.next()) else { + return false; + }; + library.coordinate().is_ok_and(|coordinate| { + coordinate.classifier.is_none() + && coordinate.group == group + && coordinate.artifact == artifact + }) +} + +/// `…/forge-1.12.2-14.23.5.2860.jar` → `…/forge-1.12.2-14.23.5.2860-universal.jar`。 +fn with_universal_classifier(path: &str) -> String { + match path.strip_suffix(".jar") { + Some(stem) => format!("{stem}-universal.jar"), + None => path.to_string(), + } +} diff --git a/crates/core/src/addon/loader/maven.rs b/crates/core/src/addon/loader/maven.rs new file mode 100644 index 0000000..898e2f4 --- /dev/null +++ b/crates/core/src/addon/loader/maven.rs @@ -0,0 +1,173 @@ +//! Maven 坐标与版本表(Forge / NeoForge):版本列表地址、版本选择、installer 地址。 +//! +//! 与 [`super::forge`] 的分工:这里只管"maven 上有什么版本、jar 在哪", +//! 那边只管"installer 里的 profile 怎么变成版本 JSON"。 +//! +//! 镜像:两家 maven 都在 D7 镜像表里有 BMCLAPI 规则(`bmclapi-neoforge` / +//! `bmclapi-forge-maven-net`),所以这里一律**写官方地址**,重写交给 `core::download` +//! 的镜像展开(与 M27 的"不拼镜像 URL"纪律一致)。 + +use super::LoaderKind; + +/// NeoForge maven 根(规则 `bmclapi-neoforge` 把 `/releases` 重写到 `/maven`)。 +pub const NEOFORGE_MAVEN: &str = "https://maven.neoforged.net/releases"; +/// Forge maven 根(规则 `bmclapi-forge-maven-net`)。 +pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net"; +/// NeoForge 唯一走 Forge 布局的原版版本(1.20.1 是"NeoForge 还叫 forge 的时候")。 +pub const NEOFORGE_FORGE_LAYOUT_MC: &str = "1.20.1"; + +/// maven 根地址。 +#[must_use] +pub fn base(kind: LoaderKind) -> &'static str { + match kind { + LoaderKind::NeoForge => NEOFORGE_MAVEN, + _ => FORGE_MAVEN, + } +} + +/// 该原版版本对应的 `maven-metadata.xml` 地址。 +#[must_use] +pub fn metadata_url(kind: LoaderKind, mc_version: &str) -> String { + let (root, path) = match kind { + LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => { + (NEOFORGE_MAVEN, "net/neoforged/forge") + } + LoaderKind::NeoForge => (NEOFORGE_MAVEN, "net/neoforged/neoforge"), + _ => (FORGE_MAVEN, "net/minecraftforge/forge"), + }; + format!("{root}/{path}/maven-metadata.xml") +} + +/// installer jar 地址。 +#[must_use] +pub fn installer_url(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String { + let (root, path, file) = match kind { + LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => ( + NEOFORGE_MAVEN, + "net/neoforged/forge", + format!("forge-{maven_version}-installer.jar"), + ), + LoaderKind::NeoForge => ( + NEOFORGE_MAVEN, + "net/neoforged/neoforge", + format!("neoforge-{maven_version}-installer.jar"), + ), + _ => ( + FORGE_MAVEN, + "net/minecraftforge/forge", + format!("forge-{maven_version}-installer.jar"), + ), + }; + format!("{root}/{path}/{maven_version}/{file}") +} + +/// 扫 maven-metadata.xml 里的 `` 值。 +/// +/// 故意不引 XML 库:maven 元数据是**极简且稳定**的格式(只有一层 `` 列表), +/// 为它拉一个 XML 解析器不划算。解析不出就返回空表,由上层报"没有可用版本"。 +#[must_use] +pub fn parse_metadata_versions(xml: &str) -> Vec { + const OPEN: &str = ""; + const CLOSE: &str = ""; + let mut out = Vec::new(); + let mut rest = xml; + while let Some(start) = rest.find(OPEN) { + let after = &rest[start + OPEN.len()..]; + let Some(end) = after.find(CLOSE) else { + break; + }; + let value = after[..end].trim(); + if !value.is_empty() { + out.push(value.to_string()); + } + rest = &after[end + CLOSE.len()..]; + } + out +} + +/// NeoForge 版本号 → 原版版本号(`21.1.80` → `1.21.1`,analyse/05 §1.3 的反解规则)。 +/// +/// - 主版本 `0` 是快照版(命名不带原版版本,返回 `None`); +/// - 主版本 `≥26` 是 MC 新命名方案(`26.1.2.125` → `26.1`); +/// - 其余是 `1.<主>.<次>`(`20.4.237` → `1.20.4`)。 +#[must_use] +pub fn neoforge_mc_version(version: &str) -> Option { + let core = version.split(['-', '+']).next().unwrap_or(version); + let segments: Vec = core + .split('.') + .map(str::parse::) + .collect::>() + .ok()?; + let (major, minor) = (*segments.first()?, *segments.get(1)?); + match major { + 0 => None, + 26.. => Some(format!("{major}.{minor}")), + _ => Some(format!("1.{major}.{minor}")), + } +} + +/// maven 版本是否属于该原版版本。 +#[must_use] +pub fn matches_mc(kind: LoaderKind, mc_version: &str, version: &str) -> bool { + match kind { + LoaderKind::NeoForge if mc_version == NEOFORGE_FORGE_LAYOUT_MC => { + version.starts_with(&format!("{mc_version}-")) + } + LoaderKind::NeoForge => neoforge_mc_version(version).as_deref() == Some(mc_version), + _ => version.starts_with(&format!("{mc_version}-")), + } +} + +/// 从 maven 版本表里挑出属于该原版版本的加载器版本(**升序**,末尾最好)。 +#[must_use] +pub fn select_versions(kind: LoaderKind, mc_version: &str, versions: &[String]) -> Vec { + let mut picked: Vec = versions + .iter() + .filter(|version| matches_mc(kind, mc_version, version)) + .cloned() + .collect(); + picked.sort_by(|left, right| { + numeric_key(left) + .cmp(&numeric_key(right)) + .then_with(|| left.cmp(right)) + }); + picked +} + +/// 用户给的版本号归一成 maven 版本号(Forge 的 maven 版本带原版前缀)。 +/// +/// `--loader-version 47.2.0` 与 `--loader-version 1.20.1-47.2.0` 都接受; +/// NeoForge(非 1.20.1 布局)本身就是完整 maven 版本,原样返回。 +#[must_use] +pub fn normalize_maven_version(kind: LoaderKind, mc_version: &str, raw: &str) -> String { + let raw = raw.trim(); + let needs_prefix = match kind { + LoaderKind::NeoForge => mc_version == NEOFORGE_FORGE_LAYOUT_MC, + _ => true, + }; + if needs_prefix && !raw.starts_with(&format!("{mc_version}-")) { + format!("{mc_version}-{raw}") + } else { + raw.to_string() + } +} + +/// 用户视角的加载器版本号(Forge 去掉原版前缀,NeoForge 原样)。 +#[must_use] +pub fn display_loader_version(kind: LoaderKind, mc_version: &str, maven_version: &str) -> String { + match kind { + LoaderKind::NeoForge if mc_version != NEOFORGE_FORGE_LAYOUT_MC => maven_version.to_string(), + _ => maven_version + .strip_prefix(&format!("{mc_version}-")) + .unwrap_or(maven_version) + .to_string(), + } +} + +/// 版本号 → 可比较的数字键(非数字段按 0,保证不 panic)。 +fn numeric_key(version: &str) -> Vec { + version + .split(['-', '.', '+']) + .map(|segment| segment.parse::().unwrap_or(0)) + .collect() +} diff --git a/crates/core/src/addon/loader/meta.rs b/crates/core/src/addon/loader/meta.rs new file mode 100644 index 0000000..29d0367 --- /dev/null +++ b/crates/core/src/addon/loader/meta.rs @@ -0,0 +1,242 @@ +//! 加载器元数据的网络层:版本列表 + 官方 profile/installer → [`LoaderRelease`]。 +//! +//! 分工与 M27 的 [`crate::game::install::RemoteSource`] 一样:**只发请求、只解析**, +//! 不写游戏目录(installer jar 落 `.ULL/cache/meta/` 作离线复用),镜像/退避/health 全在 +//! `core::download` 的引擎里(我们一律递**官方地址**,重写由镜像表负责)。 +//! +//! 两族的取数路径完全不同: +//! +//! ```text +//! Fabric / Quilt : meta API → profile/json(官方直出可用版本 JSON) +//! NeoForge/Forge : maven-metadata.xml → installer jar → 读 install_profile.json + version.json +//! ``` + +use std::fs::File; +use std::path::{Path, PathBuf}; + +use zip::ZipArchive; + +use super::fabric; +use super::forge::{self, InstallProfile}; +use super::maven; +use super::plan::LoaderRelease; +use super::{LoaderError, LoaderKind}; +use crate::download::{DownloadEngine, DownloadRequest, ResourceKind}; +use crate::game::manifest::VersionJson; + +/// installer jar 的缓存文件名前缀(超长/含 `+` 的版本串会被规整成安全文件名)。 +const INSTALLER_TAG: &str = "installer"; + +/// 远端加载器元数据来源。 +pub struct LoaderSource<'a> { + engine: &'a DownloadEngine, + meta_cache: &'a Path, +} + +impl<'a> LoaderSource<'a> { + /// 绑定下载引擎与元数据缓存目录(`.ULL/cache/meta/`)。 + #[must_use] + pub fn new(engine: &'a DownloadEngine, meta_cache: &'a Path) -> Self { + Self { engine, meta_cache } + } + + /// 取该原版版本可用的加载器版本列表(**最新在前**)。 + /// + /// # Errors + /// + /// 网络失败、元数据解析失败、该原版版本没有加载器版本时返回 [`LoaderError`]。 + pub async fn loader_versions( + &self, + kind: LoaderKind, + mc_version: &str, + ) -> Result, LoaderError> { + if kind.is_fabric_family() { + let url = fabric::loader_list_url(kind, mc_version); + let body = self.fetch_text(&url).await?; + let entries = fabric::parse_loader_list(kind, &body)?; + return Ok(entries.into_iter().map(|entry| entry.version).collect()); + } + let url = maven::metadata_url(kind, mc_version); + let body = self.fetch_text(&url).await?; + let all = maven::parse_metadata_versions(&body); + // maven 表是升序的,统一成"最新在前"。 + let mut picked = maven::select_versions(kind, mc_version, &all); + picked.reverse(); + if picked.is_empty() { + return Err(LoaderError::not_found(format!( + "{} 在 {} 上没有可用版本(maven 表 {} 条,全部与该原版版本不匹配)", + kind.label(), + mc_version, + all.len() + ))); + } + Ok(picked) + } + + /// 解析一个加载器版本(`loader_version` 为 `None` 时取官方默认:Fabric 系取首个稳定版, + /// maven 系取该原版版本下最新的一版)。 + /// + /// # Errors + /// + /// 网络失败、版本不存在、installer 缺 profile、协议不受支持时返回 [`LoaderError`]。 + pub async fn resolve( + &self, + kind: LoaderKind, + mc_version: &str, + loader_version: Option<&str>, + ) -> Result { + let requested = loader_version + .map(str::trim) + .filter(|value| !value.is_empty()); + if kind.is_fabric_family() { + let version = match requested { + Some(version) => version.to_string(), + None => { + let url = fabric::loader_list_url(kind, mc_version); + let body = self.fetch_text(&url).await?; + let entries = fabric::parse_loader_list(kind, &body)?; + fabric::pick_default(&entries) + .map(|entry| entry.version.clone()) + .ok_or_else(|| { + LoaderError::not_found(format!("{} 没有可用的加载器版本", kind.label())) + })? + } + }; + let url = fabric::profile_url(kind, mc_version, &version); + let body = self.fetch_text(&url).await?; + return fabric::build_release(kind, mc_version, &version, &body); + } + + let maven_version = match requested { + Some(raw) => maven::normalize_maven_version(kind, mc_version, raw), + None => self + .loader_versions(kind, mc_version) + .await? + .into_iter() + .next() + .ok_or_else(|| { + LoaderError::not_found(format!("{} 没有可用的加载器版本", kind.label())) + })?, + }; + let url = maven::installer_url(kind, mc_version, &maven_version); + let path = self.installer_path(kind, &maven_version); + let jar = self.fetch_installer(&url, &path).await?; + let (profile, embedded) = read_installer(&jar)?; + let mut release = forge::build_release( + kind, + mc_version, + &maven_version, + &profile, + embedded.as_ref(), + )?; + // M35 执行器要的两样东西:installer 本体(跑 processor 与解包)与它声明的 profile。 + release.installer = Some(jar); + Ok(release) + } + + /// installer jar 的本地缓存路径(`+`/非法字符规整成 `_`)。 + fn installer_path(&self, kind: LoaderKind, maven_version: &str) -> PathBuf { + let safe: String = maven_version + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { + ch + } else { + '_' + } + }) + .collect(); + self.meta_cache + .join(format!("{}-{safe}-{INSTALLER_TAG}.jar", kind.as_str())) + } + + /// 取 installer jar:本地缓存里有可读的 zip 就直接用(离线复用),否则下载。 + async fn fetch_installer(&self, url: &str, path: &Path) -> Result { + if path.is_file() && ZipArchive::new(File::open(path).map_err(io_error(path))?).is_ok() { + return Ok(path.to_path_buf()); + } + let request = DownloadRequest::new(ResourceKind::Library, url, path.to_path_buf()); + let outcome = self.engine.download(request).await?; + Ok(outcome.path) + } + + /// 取一小段文本元数据(版本列表 / profile JSON / maven metadata)。 + async fn fetch_text(&self, url: &str) -> Result { + let dest = self.meta_cache.join("loader-meta.bin"); + let bytes = self + .engine + .download_bytes(DownloadRequest::new( + ResourceKind::VersionManifest, + url, + dest, + )) + .await?; + String::from_utf8(bytes) + .map_err(|_| LoaderError::metadata(format!("元数据不是 UTF-8:{url}"))) + } +} + +/// 读 installer jar 里的 `install_profile.json` 与内嵌的版本 JSON。 +/// +/// 内嵌版本 JSON 的路径来自 profile 的 `json` 字段(通常 `/version.json`); +/// 该条目不存在时返回 `None`,由 [`forge::build_release`] 判定为 unsupported。 +/// +/// # Errors +/// +/// 不是合法 zip、缺 `install_profile.json`、profile/版本 JSON 无法解析时返回 metadata 错误。 +pub fn read_installer(path: &Path) -> Result<(InstallProfile, Option), LoaderError> { + let file = File::open(path).map_err(io_error(path))?; + let mut archive = ZipArchive::new(file).map_err(|error| { + LoaderError::metadata(format!("{} 不是合法 zip:{error}", path.display())) + })?; + + let profile_text = read_entry(&mut archive, "install_profile.json")?.ok_or_else(|| { + LoaderError::metadata(format!( + "{} 里没有 install_profile.json(不是 Forge/NeoForge installer?)", + path.display() + )) + })?; + let profile: InstallProfile = serde_json::from_str(&profile_text).map_err(|error| { + LoaderError::metadata(format!("install_profile.json 解析失败:{error}")) + })?; + + let embedded = match profile + .version_json_path + .as_deref() + .map(|entry| entry.trim_start_matches('/')) + .filter(|entry| !entry.is_empty()) + { + Some(entry) => match read_entry(&mut archive, entry)? { + Some(text) => Some(VersionJson::from_json_str(&text).map_err(|error| { + LoaderError::metadata(format!("installer 内 {entry} 解析失败:{error}")) + })?), + None => None, + }, + None => None, + }; + Ok((profile, embedded)) +} + +/// 读一个 zip 条目成字符串;条目不存在返回 `Ok(None)`。 +fn read_entry(archive: &mut ZipArchive, name: &str) -> Result, LoaderError> { + use std::io::Read; + + let mut entry = match archive.by_name(name) { + Ok(entry) => entry, + Err(zip::result::ZipError::FileNotFound) => return Ok(None), + Err(error) => { + return Err(LoaderError::metadata(format!( + "读 installer 条目 {name} 失败:{error}" + ))) + } + }; + let mut text = String::new(); + entry + .read_to_string(&mut text) + .map_err(|error| LoaderError::io(format!("installer 条目 {name} 不是可读文本:{error}")))?; + Ok(Some(text)) +} + +fn io_error(path: &Path) -> impl FnOnce(std::io::Error) -> LoaderError + '_ { + move |error| LoaderError::io(format!("{} 读写失败:{error}", path.display())) +} diff --git a/crates/core/src/addon/loader/mod.rs b/crates/core/src/addon/loader/mod.rs new file mode 100644 index 0000000..d8eee17 --- /dev/null +++ b/crates/core/src/addon/loader/mod.rs @@ -0,0 +1,249 @@ +//! 加载器安装(Fabric / Quilt / NeoForge / Forge):官方元数据 → loader 版本 JSON → 补库清单。 +//! +//! 设计输入:`DevDocs/PlanDoc/08-mod-and-recommend.md` §1(D10 加载器安装)、 +//! `DevDocs/analyse/05-modloader-modpack.md` §1(五加载器安装协议、`patch + priority` 主线)。 +//! +//! # 与 M27 安装层的关系(不复制粘贴,只叠一层) +//! +//! 加载器不是"另一个版本",而是**叠在已装原版之上的一个 `inheritsFrom` 层**: +//! +//! ```text +//! versions/1.20.1/1.20.1.json 原版(M27 负责装) +//! versions/1.20.1-fabric-0.15.11/…json loader 层:inheritsFrom=1.20.1 + 主类 + loader 库 +//! ``` +//! +//! 因此本模块**只做两件事**:①把官方元数据翻译成一份 loader 版本 JSON; +//! ②把它的库列表交给 [`crate::game::install::libraries`] 展开成下载计划。 +//! 落盘、跳过判定、实例登记一律复用 M27([`crate::game::install::execute`] / `register`), +//! 不另起一套下载/写盘逻辑。 +//! +//! # 三条路线(按"能不能不跑 java 就装出来"分类,而不是按加载器名字) +//! +//! | 路线 | 加载器 | 做法 | +//! |------|--------|------| +//! | [`LoaderSupport::JsonRoute`] | Fabric / Quilt | 官方 meta 直接给 launcher 用的 profile JSON | +//! | [`LoaderSupport::JsonRoute`] | Forge spec-0(如 1.12.2 重建版) | installer 里的 `version.json` + 库清单,universal jar 走 maven 直取 | +//! | [`LoaderSupport::RequiresInstaller`] | Forge spec≥1 / NeoForge | installer 声明了 client 侧 processors(拆包/映射),**必须外调 java** | +//! +//! 第三类本期只出**明确 unsupported**:版本 JSON 与库清单仍照常生成(`--dry-run` 可看), +//! 但真装会被拒——半成品实例比明确报错更糟(沿 M27 §7「如实告知」的纪律)。 +//! 老协议 installer(内联 `versionInfo` + universal jar 解包,≈1.12.2 之前的重建版)同样归 unsupported。 + +pub mod fabric; +pub mod forge; +pub mod maven; +pub mod meta; +pub mod plan; +pub mod processors; + +#[cfg(test)] +mod tests; + +pub use meta::LoaderSource; +pub use plan::{LoaderInstallPlan, LoaderPlanInput, LoaderRelease, LoaderSupport}; + +use std::fmt; + +use crate::download::{DownloadError, DownloadErrorKind}; + +/// 支持的加载器种类。 +/// +/// `vanilla` / `none` **不在这里**:那是"不装加载器",由 CLI 走 M27 的原版路径。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum LoaderKind { + /// Fabric(`meta.fabricmc.net`,纯 meta,无 installer)。 + Fabric, + /// Quilt(`meta.quiltmc.org`,与 Fabric 同构)。 + Quilt, + /// NeoForge(`maven.neoforged.net`,installer 内嵌 install profile)。 + NeoForge, + /// Forge(`maven.minecraftforge.net`,两代 installer 协议)。 + Forge, +} + +impl LoaderKind { + /// 解析命令行里的加载器名(大小写不敏感;`neo-forge` 是 Modrinth 的另一种拼法)。 + #[must_use] + pub fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "fabric" => Some(Self::Fabric), + "quilt" => Some(Self::Quilt), + "neoforge" | "neo-forge" => Some(Self::NeoForge), + "forge" => Some(Self::Forge), + _ => None, + } + } + + /// 稳定字符串 id(进版本 id、JSON 输出与日志,**不要改名**)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Fabric => "fabric", + Self::Quilt => "quilt", + Self::NeoForge => "neoforge", + Self::Forge => "forge", + } + } + + /// 人读名称。 + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Fabric => "Fabric", + Self::Quilt => "Quilt", + Self::NeoForge => "NeoForge", + Self::Forge => "Forge", + } + } + + /// 是否属于 Fabric 系(纯 meta、profile JSON 直接可用)。 + #[must_use] + pub const fn is_fabric_family(self) -> bool { + matches!(self, Self::Fabric | Self::Quilt) + } + + /// 全部加载器(CLI 的 `--loader` 枚举提示用)。 + #[must_use] + pub const fn all() -> &'static [Self] { + &[Self::Fabric, Self::Quilt, Self::NeoForge, Self::Forge] + } + + /// loader 版本 id 的词根:`--`(`versions//` 与实例 `versionId` 都用它)。 + #[must_use] + pub fn version_id(self, mc_version: &str, loader_version: &str) -> String { + format!("{mc_version}-{}-{loader_version}", self.as_str()) + } +} + +impl fmt::Display for LoaderKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.label()) + } +} + +/// 加载器安装失败的大类(CLI 据此给稳定错误码 `loader_*`)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoaderErrorKind { + /// 该加载器/该版本走不通我们的路线(需要跑 installer 或属未支持的老协议)。 + Unsupported, + /// 官方元数据里没有这个加载器版本(或没有匹配该原版版本的版本)。 + NotFound, + /// 元数据解析失败(profile JSON / install profile / maven metadata 坏了)。 + Metadata, + /// 网络侧失败(所有候选源都失败 / 超时)。 + Network, + /// 本地 IO(读 installer 缓存、写盘失败)。 + Io, +} + +impl LoaderErrorKind { + /// 稳定字符串 id(进 `--json` 的 `error.code`)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Unsupported => "loader_unsupported", + Self::NotFound => "loader_not_found", + Self::Metadata => "loader_metadata", + Self::Network => "loader_network", + Self::Io => "loader_io", + } + } +} + +/// 加载器安装错误:大类 + 人读说明 + 可选提示。 +#[derive(Debug, Clone)] +pub struct LoaderError { + kind: LoaderErrorKind, + message: String, + hint: Option, +} + +impl LoaderError { + /// 构造。 + #[must_use] + pub fn new(kind: LoaderErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + hint: None, + } + } + + /// 走不通我们的路线(需要外调 java installer / 老协议)。 + #[must_use] + pub fn unsupported(message: impl Into) -> Self { + Self::new(LoaderErrorKind::Unsupported, message) + } + + /// 元数据里查不到。 + #[must_use] + pub fn not_found(message: impl Into) -> Self { + Self::new(LoaderErrorKind::NotFound, message) + } + + /// 元数据坏了/形状不符。 + #[must_use] + pub fn metadata(message: impl Into) -> Self { + Self::new(LoaderErrorKind::Metadata, message) + } + + /// 本地 IO。 + #[must_use] + pub fn io(message: impl Into) -> Self { + Self::new(LoaderErrorKind::Io, message) + } + + /// 补一句提示。 + #[must_use] + pub fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + /// 大类。 + #[must_use] + pub const fn kind(&self) -> LoaderErrorKind { + self.kind + } + + /// 人读说明。 + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + + /// 下一步提示。 + #[must_use] + pub fn hint(&self) -> Option<&str> { + self.hint.as_deref() + } +} + +impl fmt::Display for LoaderError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for LoaderError {} + +impl From for LoaderError { + fn from(error: DownloadError) -> Self { + let kind = match error.kind { + DownloadErrorKind::Io => LoaderErrorKind::Io, + DownloadErrorKind::InvalidRequest => LoaderErrorKind::Metadata, + DownloadErrorKind::Integrity + | DownloadErrorKind::NotFound + | DownloadErrorKind::ServerError + | DownloadErrorKind::Timeout + | DownloadErrorKind::Network + | DownloadErrorKind::RangeUnsupported + | DownloadErrorKind::AllSourcesFailed => LoaderErrorKind::Network, + }; + let mut message = error.message.clone(); + if let Some(source) = &error.source { + message.push_str(&format!("(源 {source})")); + } + Self::new(kind, message) + } +} diff --git a/crates/core/src/addon/loader/plan.rs b/crates/core/src/addon/loader/plan.rs new file mode 100644 index 0000000..dc8aae8 --- /dev/null +++ b/crates/core/src/addon/loader/plan.rs @@ -0,0 +1,235 @@ +//! 加载器安装计划:一份 loader 版本 JSON + 需要补下载的库清单。 +//! +//! 计划层与网络层分开:网络在 [`super::meta`],这里只做**纯计算**——把已经解析好的 +//! loader 版本 JSON 交给 [`crate::game::install::libraries`] 展开成待下载文件,再包成 +//! M27 的 [`InstallPlan`](落盘/跳过判定/原子写版本 JSON 全部沿用同一套实现)。 +//! +//! 为什么不直接调 [`InstallPlan::build`]:那是"装一个完整原版版本"的入口(客户端 jar + +//! 资源索引 + 逐对象),而 loader 层**只有库**——jar 与资源都由 `inheritsFrom` 的原版提供。 +//! 直接组 [`InstallPlan`] 的字段既省掉一堆"没有 jar"的告警,又不复制任何路径规则。 + +use std::path::PathBuf; + +use serde_json::{json, Value}; + +use super::forge::InstallProfile; +use super::LoaderKind; +use crate::game::arguments::Environment; +use crate::game::install::libraries; +use crate::game::install::{InstallPlan, PlanFile}; +use crate::game::layout::GameLayout; +use crate::game::manifest::VersionJson; + +/// 加载器版本的可用路线(判据是"能不能不跑 java 就装出来",不是加载器名字)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoaderSupport { + /// 纯元数据/纯下载路线:不跑任何外部进程。 + JsonRoute, + /// 官方 installer 声明了必须先跑的 processors(拆包/映射),走 M35 执行器外调 java。 + RequiresInstaller { + /// 人读原因(含该版本声明的 processors 数量与作用)。 + reason: String, + }, +} + +impl LoaderSupport { + /// 稳定字符串 id(JSON 输出用)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::JsonRoute => "json_route", + Self::RequiresInstaller { .. } => "requires_installer", + } + } + + /// 是否可真正安装(两条路线都走到了:processors 走 M35 执行器)。 + #[must_use] + pub const fn is_installable(&self) -> bool { + true + } + + /// 是否要外调 java 跑 installer processors。 + #[must_use] + pub const fn requires_installer(&self) -> bool { + matches!(self, Self::RequiresInstaller { .. }) + } + + /// JSON 视图。 + #[must_use] + pub fn to_json(&self) -> Value { + match self { + Self::JsonRoute => json!({"mode": self.as_str(), "requires_installer": false}), + Self::RequiresInstaller { reason } => { + json!({"mode": self.as_str(), "requires_installer": true, "reason": reason}) + } + } + } +} + +/// 一次解析出来的加载器版本(网络层产物,计划层的输入)。 +#[derive(Debug, Clone)] +pub struct LoaderRelease { + /// 加载器种类。 + pub kind: LoaderKind, + /// 目标原版版本(`inheritsFrom` 的父版本 id)。 + pub mc_version: String, + /// 加载器自身版本号(`0.15.11` / `47.2.0` / `21.1.80`)。 + pub loader_version: String, + /// 生成的 loader 版本 id(`1.20.1-fabric-0.15.11`,即 `versions//` 的目录名)。 + pub version_id: String, + /// loader 版本 JSON(`inheritsFrom` 指向原版)。 + pub version_json: VersionJson, + /// 可走哪条路线。 + pub support: LoaderSupport, + /// installer jar 的磁盘路径(M35 执行器的输入;Fabric 系为 `None`)。 + pub installer: Option, + /// installer 里读出来的 install profile(执行器要 `processors` / `data` / `libraries`)。 + pub install_profile: Option, + /// 解析期告警(不致命:例如 profile 缺 loader 库时我们补了一条)。 + pub warnings: Vec, +} + +impl LoaderRelease { + /// 是否可真正安装。 + #[must_use] + pub const fn is_installable(&self) -> bool { + self.support.is_installable() + } + + /// 是否要外调 java 跑 installer processors。 + #[must_use] + pub const fn requires_installer(&self) -> bool { + self.support.requires_installer() + } + + /// 需要 installer 时的原因(`JsonRoute` 返回 `None`)。 + #[must_use] + pub fn installer_reason(&self) -> Option<&str> { + match &self.support { + LoaderSupport::JsonRoute => None, + LoaderSupport::RequiresInstaller { reason } => Some(reason), + } + } + + /// JSON 视图(`--dry-run` 与诊断共用)。 + #[must_use] + pub fn to_json(&self) -> Value { + json!({ + "kind": self.kind.as_str(), + "label": self.kind.label(), + "mc_version": self.mc_version, + "loader_version": self.loader_version, + "version_id": self.version_id, + "inherits_from": self.version_json.inherits_from, + "jar": self.version_json.jar, + "main_class": self.version_json.main_class, + "libraries": self.version_json.libraries.as_ref().map_or(0, Vec::len), + "support": self.support.to_json(), + "warnings": self.warnings, + }) + } +} + +/// [`LoaderInstallPlan::build`] 的输入。 +pub struct LoaderPlanInput<'a> { + /// 游戏目录布局(`versions/` `libraries/` 的路径规则)。 + pub layout: &'a GameLayout, + /// 已解析的加载器版本。 + pub release: &'a LoaderRelease, + /// 规则求值环境(OS / 架构 / 特性开关)。 + pub environment: &'a Environment, + /// 原版的 `versions//.json` 是否已在盘上。 + pub vanilla_installed: bool, +} + +/// 加载器安装计划:M27 的 [`InstallPlan`] + 加载器自己的上下文。 +/// +/// 执行期直接 `execute(&engine, &plan.install, …)`:那份计划里的 `version_json` 就是 +/// 带 `inheritsFrom` 的 loader 层,`execute` 会在**全部库就绪后**原子写到 +/// `versions//.json`(半成品不写,沿 M27 的纪律)。 +#[derive(Debug, Clone)] +pub struct LoaderInstallPlan { + /// 加载器版本上下文。 + pub release: LoaderRelease, + /// 待执行的下载计划(库 + 版本 JSON 落盘)。 + pub install: InstallPlan, + /// 是否还需要先装原版(原版已装则 `false`,真装时会跳过)。 + pub vanilla_install_needed: bool, +} + +impl LoaderInstallPlan { + /// 组计划。 + /// + /// 缺下载地址的库降级成告警(见 [`Self::summary_json`] 的 `warnings`),不硬失败—— + /// 与 M27「宁可给出可修复的骨架,也不静默假装成功」一致。 + #[must_use] + pub fn build(input: LoaderPlanInput<'_>) -> Self { + let release = input.release; + let (files, library_warnings) = libraries::plan( + input.layout, + &release.version_id, + release + .version_json + .libraries + .as_deref() + .unwrap_or_default(), + input.environment, + ); + + let mut warnings = release.warnings.clone(); + warnings.extend(library_warnings); + if let Some(reason) = release.installer_reason() { + warnings.push(format!("需要外调 java 跑 installer processors:{reason}")); + } + if !input.vanilla_installed { + warnings.push(format!( + "原版 {} 尚未安装:执行时会先装原版再叠加载器", + release.mc_version + )); + } + + let install = InstallPlan { + version_id: release.version_id.clone(), + // jar 来自原版层(生成的 loader JSON 已把 `jar` 钉成原版 id)。 + jar_id: release.mc_version.clone(), + game_root: input.layout.root().to_path_buf(), + version_json_path: input.layout.version_json(&release.version_id), + files, + version_json: release.version_json.clone(), + prefetched: Vec::new(), + warnings, + }; + + Self { + release: release.clone(), + install, + vanilla_install_needed: !input.vanilla_installed, + } + } + + /// 计划里的待下载文件。 + #[must_use] + pub fn files(&self) -> &[PlanFile] { + &self.install.files + } + + /// 告警(含"原版未装""本期不能真装")。 + #[must_use] + pub fn warnings(&self) -> &[String] { + &self.install.warnings + } + + /// 汇总 JSON(`--dry-run` 的主载荷)。 + #[must_use] + pub fn summary_json(&self) -> Value { + let mut summary = self.install.summary_json(); + if let Some(object) = summary.as_object_mut() { + object.insert("loader".to_string(), self.release.to_json()); + object.insert( + "vanilla_install_needed".to_string(), + json!(self.vanilla_install_needed), + ); + } + summary + } +} diff --git a/crates/core/src/addon/loader/processors/download.rs b/crates/core/src/addon/loader/processors/download.rs new file mode 100644 index 0000000..a9e39ba --- /dev/null +++ b/crates/core/src/addon/loader/processors/download.rs @@ -0,0 +1,83 @@ +//! processor 依赖库的下载:把 [`ProcessorPlan::libraries`] 交给 M27 的下载引擎。 +//! +//! 为什么不直接复用 `game::install::execute`:那个入口在**全量就绪后写版本 JSON**, +//! 而 processors 的产物要先于版本清单落盘(版本 JSON 存在 = 这版真装完了)。 +//! 所以这里只借它的**逐文件跳过判定**(`current_bytes`:摘要 → 大小 → 存在即信) +//! 与结果类型,下载本身仍走同一个引擎——镜像、健康度、退避、续传一条不少。 + +use futures_util::stream::{self, StreamExt}; + +use crate::download::DownloadEngine; +use crate::game::install::execute::current_bytes; +use crate::game::install::{ + FileFailure, FileOutcome, FileStatus, InstallReport, PlanFile, ProgressSink, +}; + +/// 并发下载 processor 依赖库(`concurrency` 上限,引擎内部另有信号量)。 +/// +/// 失败**不**中断:与 M27 一致,逐文件结构化返回,由调用方决定是否继续。 +pub async fn download_libraries( + engine: &DownloadEngine, + files: &[PlanFile], + concurrency: usize, + sink: Option, +) -> InstallReport { + let outcomes: Vec = stream::iter(files.iter()) + .map(|file| { + let sink = sink.clone(); + async move { + let outcome = install_one(engine, file).await; + if let Some(sink) = &sink { + sink(&outcome); + } + outcome + } + }) + .buffer_unordered(concurrency.max(1)) + .collect() + .await; + + InstallReport { + files: outcomes, + version_json: None, + warnings: Vec::new(), + } +} + +/// 单文件:先判「已就绪」,否则走引擎下载。 +async fn install_one(engine: &DownloadEngine, file: &PlanFile) -> FileOutcome { + if let Some(bytes) = current_bytes(file) { + return FileOutcome { + role: file.role, + name: file.name.clone(), + dest: file.dest.clone(), + status: FileStatus::Skipped, + bytes, + source: None, + failure: None, + }; + } + match engine.download(file.request()).await { + Ok(outcome) => FileOutcome { + role: file.role, + name: file.name.clone(), + dest: file.dest.clone(), + status: FileStatus::Downloaded, + bytes: outcome.bytes, + source: Some(outcome.source), + failure: None, + }, + Err(error) => FileOutcome { + role: file.role, + name: file.name.clone(), + dest: file.dest.clone(), + status: FileStatus::Failed, + bytes: 0, + source: None, + failure: Some(FileFailure { + kind: error.kind, + message: error.message.clone(), + }), + }, + } +} diff --git a/crates/core/src/addon/loader/processors/error.rs b/crates/core/src/addon/loader/processors/error.rs new file mode 100644 index 0000000..99d53ad --- /dev/null +++ b/crates/core/src/addon/loader/processors/error.rs @@ -0,0 +1,181 @@ +//! processors 执行器的错误分类(CLI 据此给稳定错误码 `processor_*`)。 +//! +//! 与 [`crate::addon::loader::LoaderError`] 分开是刻意的:加载器**解析**阶段的失败 +//! (元数据坏了、版本不存在)与**执行**阶段的失败(java 跑挂了、产物没落盘)对用户 +//! 是完全不同的两件事——前者换版本/换线路,后者多半是 Java 或磁盘问题。 +//! 失败分流也是 mission 的硬要求:`processor_failed` 必须能单独被脚本识别。 + +use std::fmt; + +/// processor 执行失败的大类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProcessorErrorKind { + /// 规划期:install profile 形状不对(缺 `jar`、坐标不合法、变量缺定义)。 + Plan, + /// 找不到可用的 Java(M33 的解析链一个候选都没有)。 + Java, + /// processor 自己要用的 jar/依赖不在盘上(下载没覆盖到)。 + Library, + /// 外调 java 失败(非零退出),消息里带脱敏后的 stderr 尾部。 + Failed, + /// processor 跑完了但产物没落盘。 + Output, + /// 本地 IO(解包 installer 条目、建目录、复制 MC jar、起进程失败)。 + Io, +} + +impl ProcessorErrorKind { + /// 稳定字符串 id(进 `--json` 的 `error.code`)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Plan => "processor_plan", + Self::Java => "processor_java_missing", + Self::Library => "processor_library_missing", + Self::Failed => "processor_failed", + Self::Output => "processor_output_missing", + Self::Io => "processor_io", + } + } +} + +/// processor 执行错误:大类 + 人读说明 + 可选提示 + 可选 stderr 尾部(已脱敏)。 +#[derive(Debug, Clone)] +pub struct ProcessorError { + kind: ProcessorErrorKind, + message: String, + hint: Option, + stderr_tail: Option, +} + +impl ProcessorError { + /// 构造。 + #[must_use] + pub fn new(kind: ProcessorErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + hint: None, + stderr_tail: None, + } + } + + /// 规划期失败(install profile 形状/变量问题)。 + #[must_use] + pub fn plan(message: impl Into) -> Self { + Self::new(ProcessorErrorKind::Plan, message) + } + + /// 找不到可用的 Java。 + #[must_use] + pub fn java_missing(message: impl Into) -> Self { + Self::new(ProcessorErrorKind::Java, message) + } + + /// processor 依赖不在盘上。 + #[must_use] + pub fn library_missing(message: impl Into) -> Self { + Self::new(ProcessorErrorKind::Library, message) + } + + /// processor 产物没落盘。 + #[must_use] + pub fn output_missing(message: impl Into) -> Self { + Self::new(ProcessorErrorKind::Output, message) + } + + /// 本地 IO 失败。 + #[must_use] + pub fn io(message: impl Into) -> Self { + Self::new(ProcessorErrorKind::Io, message) + } + + /// 外调 java 非零退出:消息 = 说明 + 脱敏后的 stderr 尾部。 + #[must_use] + pub fn failed(label: &str, exit: &str, stderr_tail: &str) -> Self { + let mut message = format!("processor {label} 执行失败(退出码 {exit})"); + if !stderr_tail.trim().is_empty() { + message.push_str(";stderr 尾部:\n"); + message.push_str(stderr_tail.trim_end()); + } + let mut error = Self::new(ProcessorErrorKind::Failed, message); + error.stderr_tail = Some(stderr_tail.trim_end().to_string()); + error + } + + /// 补一句提示。 + #[must_use] + pub fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + /// 大类。 + #[must_use] + pub const fn kind(&self) -> ProcessorErrorKind { + self.kind + } + + /// 人读说明。 + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + + /// 下一步提示。 + #[must_use] + pub fn hint(&self) -> Option<&str> { + self.hint.as_deref() + } + + /// 脱敏后的 stderr 尾部(只有 `processor_failed` 会有)。 + #[must_use] + pub fn stderr_tail(&self) -> Option<&str> { + self.stderr_tail.as_deref() + } +} + +impl fmt::Display for ProcessorError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for ProcessorError {} + +#[cfg(test)] +mod tests { + use super::{ProcessorError, ProcessorErrorKind}; + + #[test] + fn stable_codes_are_distinct_and_prefixed() { + let kinds = [ + ProcessorErrorKind::Plan, + ProcessorErrorKind::Java, + ProcessorErrorKind::Library, + ProcessorErrorKind::Failed, + ProcessorErrorKind::Output, + ProcessorErrorKind::Io, + ]; + for kind in kinds { + assert!(kind.as_str().starts_with("processor_"), "{}", kind.as_str()); + } + assert_eq!(ProcessorErrorKind::Failed.as_str(), "processor_failed"); + } + + #[test] + fn failed_carries_the_stderr_tail_in_message_and_accessor() { + let error = ProcessorError::failed("g:a:1", "1", "boom\nCaused by: x"); + assert_eq!(error.kind(), ProcessorErrorKind::Failed); + assert!(error.message().contains("退出码 1"), "{}", error.message()); + assert!(error.message().contains("Caused by: x")); + assert_eq!(error.stderr_tail(), Some("boom\nCaused by: x")); + } + + #[test] + fn empty_stderr_keeps_the_message_clean() { + let error = ProcessorError::failed("g:a:1", "255", ""); + assert!(!error.message().contains("stderr 尾部")); + assert_eq!(error.stderr_tail(), Some("")); + } +} diff --git a/crates/core/src/addon/loader/processors/exec.rs b/crates/core/src/addon/loader/processors/exec.rs new file mode 100644 index 0000000..0c2029c --- /dev/null +++ b/crates/core/src/addon/loader/processors/exec.rs @@ -0,0 +1,363 @@ +//! 逐个外调 java 执行 processor:`java -cp <依赖…>: <主类> <参数…>`。 +//! +//! 依据 `DevDocs/analyse/05-modloader-modpack.md` §1.2(Forge 新式 installer 的 processors +//! 流水线)与 HMCL `ForgeNewInstallTask.ProcessorTask` 的实现,四条硬规矩: +//! +//! 1. **串行**:后面的 processor 吃前面的产物(拆包 → 映射 → 打补丁),并行必炸; +//! 2. **幂等**:每步先按 `outputs` 的 SHA-1 判定,全部命中就整步跳过(重跑 install 零成本); +//! 3. **主类来自 jar 的 MANIFEST**:官方 install profile 不写主类(少数 fork 给 `mainClass`); +//! 4. **失败要能定位**:非零退出带脱敏后的 stderr 尾部(路径/用户名不能泄漏到日志外)。 +//! +//! # 关于产物 SHA-1 与 zlib 变体(HMCL 踩过的坑) +//! +//! processor 动态生成的 jar 是用**本机 JDK 的 zlib** 压缩的。zlib-ng 之类非标准实现会把 +//! 同样的内容压出不同字节 → SHA-1 与 profile 里的期望值不符。HMCL 的处置是「哈希不符时 +//! 退化成 ZIP 结构校验」。我们的处置更保守:**存在性强制、摘要不符只告警**, +//! 既不误杀真实装,也不假装校验通过。 + +use std::path::Path; +use std::process::Command; +use std::sync::Arc; +use std::time::Instant; + +use serde_json::{json, Value}; + +use super::error::ProcessorError; +use super::jar::read_main_class; +use super::plan::{ProcessorPlan, ProcessorStep}; +use super::prepare::prepare; +use super::text::{host_redactor, non_empty, tail}; +use crate::download::verify; +use crate::download::HashAlgo; +use crate::game::java::{self, JavaRequirement, JavaRuntime}; +use crate::launch::redact::Redactor; + +/// 一步的结局。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StepStatus { + /// 产物已就绪(SHA-1 命中),整步跳过。 + Skipped, + /// 真的起了一次 java。 + Executed, +} + +impl StepStatus { + /// 稳定字符串 id(`--json` 用)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Skipped => "skipped", + Self::Executed => "executed", + } + } +} + +/// 一步的执行结果。 +#[derive(Debug, Clone)] +pub struct StepOutcome { + /// 序号(从 1 起)。 + pub index: usize, + /// jar 的 maven 坐标。 + pub label: String, + /// 结局。 + pub status: StepStatus, + /// 产物个数。 + pub outputs: usize, + /// 耗时(毫秒;跳过时为 0)。 + pub elapsed_ms: u128, + /// stdout 尾巴(已脱敏,可能为空)。 + pub stdout_tail: Option, + /// stderr 尾巴(已脱敏,可能为空)。 + pub stderr_tail: Option, +} + +impl StepOutcome { + /// JSON 视图。 + #[must_use] + pub fn to_json(&self) -> Value { + json!({ + "index": self.index, + "jar": self.label, + "status": self.status.as_str(), + "outputs": self.outputs, + "elapsed_ms": self.elapsed_ms, + "stderr_tail": self.stderr_tail, + }) + } +} + +/// 一次 processors 执行的汇总。 +#[derive(Debug, Clone)] +pub struct ProcessorRun { + /// 实际用的 Java(含来源与主版本)。 + pub java: JavaRuntime, + /// 逐步骤结果(顺序即执行顺序)。 + pub steps: Vec, + /// 不致命的告警(摘要不符、没有 outputs 的步骤等)。 + pub warnings: Vec, +} + +impl ProcessorRun { + /// 真正跑过的步数。 + #[must_use] + pub fn executed(&self) -> usize { + self.steps + .iter() + .filter(|step| step.status == StepStatus::Executed) + .count() + } + + /// 跳过的步数(幂等命中)。 + #[must_use] + pub fn skipped(&self) -> usize { + self.steps + .iter() + .filter(|step| step.status == StepStatus::Skipped) + .count() + } + + /// JSON 视图。 + #[must_use] + pub fn to_json(&self) -> Value { + json!({ + "java": { + "binary": self.java.binary.display().to_string(), + "source": self.java.origin.as_str(), + "major": self.java.major, + "version": self.java.version, + }, + "executed": self.executed(), + "skipped": self.skipped(), + "steps": self.steps.iter().map(StepOutcome::to_json).collect::>(), + "warnings": self.warnings, + }) + } +} + +/// 每步完成的回调(CLI 往 stderr 打实时进度用)。 +pub type StepSink = Arc; + +/// 按 M33 的解析链挑一个能跑 installer 的 Java:托管 > `JAVA_HOME` > `PATH` > 扫盘。 +/// +/// 与启动路径的差异只有一点:**扫盘只在前面都不满足时才做**(别为了一次安装白跑 +/// N 次 `java -version`),这里复用 [`crate::game::java::pick`] 的同一套优先序。 +#[must_use] +pub fn select_java(managed_root: &Path, requirement: &JavaRequirement) -> Option { + let mut candidates = java::discover_managed(managed_root); + candidates.extend(java::system_candidates()); + if let Some(runtime) = java::pick(&candidates, requirement) { + if usable(runtime) { + return Some(runtime.clone()); + } + } + candidates.extend(java::standard_candidates()); + let deduped = java::discover::dedupe(candidates); + java::pick(&deduped, requirement).cloned().filter(usable) +} + +/// 候选是否真的能用:探测到主版本,或者那个文件确实在盘上。 +fn usable(runtime: &JavaRuntime) -> bool { + runtime.major.is_some() || runtime.binary.is_file() +} + +/// 顺序执行所有 processor。 +/// +/// 全部产物已就绪时走快路径:不复制 client jar、不解包 installer、不起任何进程。 +/// +/// # Errors +/// +/// 临时目录/解包/复制失败(IO)、依赖缺失、`java -cp` 起不来或非零退出、 +/// 产物没落盘时返回 [`ProcessorError`]。 +pub fn run( + plan: &ProcessorPlan, + runtime: &JavaRuntime, + sink: Option, +) -> Result { + let redactor = host_redactor(); + let mut warnings = Vec::new(); + let mut outcomes = Vec::with_capacity(plan.steps.len()); + let satisfied: Vec = plan + .steps + .iter() + .map(|step| outputs_satisfied(step, &mut warnings)) + .collect(); + if satisfied.iter().any(|done| !done) { + prepare(plan)?; + } + for (step, done) in plan.steps.iter().zip(satisfied) { + let outcome = if done { + StepOutcome { + index: step.index, + label: step.label.clone(), + status: StepStatus::Skipped, + outputs: step.outputs.len(), + elapsed_ms: 0, + stdout_tail: None, + stderr_tail: None, + } + } else { + run_step(plan, runtime, step, &redactor, &mut warnings)? + }; + if let Some(sink) = &sink { + sink(&outcome); + } + outcomes.push(outcome); + } + + Ok(ProcessorRun { + java: runtime.clone(), + steps: outcomes, + warnings, + }) +} + +/// 起一次 java,跑完校验产物。 +fn run_step( + plan: &ProcessorPlan, + runtime: &JavaRuntime, + step: &ProcessorStep, + redactor: &Redactor, + warnings: &mut Vec, +) -> Result { + ensure_dependencies(step)?; + let main_class = match &step.main_class { + Some(main_class) => main_class.clone(), + None => read_main_class(&step.jar)?, + }; + let classpath = step.classpath_join()?; + + let started = Instant::now(); + let output = Command::new(&runtime.binary) + .arg("-cp") + .arg(&classpath) + .arg(&main_class) + .args(&step.args) + .current_dir(&plan.scratch) + .output() + .map_err(|error| { + ProcessorError::io(format!( + "起 java 失败({}):{error}", + runtime.binary.display() + )) + .with_hint( + "确认 `ull java list` 里的路径可用,或用 `ull java install ` 装一个托管 JDK", + ) + })?; + let elapsed_ms = started.elapsed().as_millis(); + let stdout = redactor + .scrub(&String::from_utf8_lossy(&output.stdout)) + .into_owned(); + let stderr = redactor + .scrub(&String::from_utf8_lossy(&output.stderr)) + .into_owned(); + + if !output.status.success() { + let exit = output + .status + .code() + .map_or_else(|| "signal".to_string(), |code| code.to_string()); + return Err( + ProcessorError::failed(&step.label, &exit, &tail(&stderr)).with_hint( + "installer 的 processor 是官方产物,失败多半是 Java 版本/磁盘权限/网络(部分步骤要联网取映射)", + ), + ); + } + + verify_outputs(step, warnings)?; + Ok(StepOutcome { + index: step.index, + label: step.label.clone(), + status: StepStatus::Executed, + outputs: step.outputs.len(), + elapsed_ms, + stdout_tail: non_empty(tail(&stdout)), + stderr_tail: non_empty(tail(&stderr)), + }) +} + +/// 依赖 jar 是否都在盘上(缺了直接报错:起了 java 也只会得到 `NoClassDefFoundError`)。 +fn ensure_dependencies(step: &ProcessorStep) -> Result<(), ProcessorError> { + if !step.jar.is_file() { + return Err(ProcessorError::library_missing(format!( + "processor {} 的可执行 jar 不在盘上:{}", + step.label, + step.jar.display() + ))); + } + let missing: Vec = step + .classpath + .iter() + .filter(|path| !path.is_file()) + .map(|path| path.display().to_string()) + .collect(); + if missing.is_empty() { + return Ok(()); + } + Err(ProcessorError::library_missing(format!( + "processor {} 有 {} 个依赖 jar 不在盘上:{}", + step.label, + missing.len(), + missing.join(";") + )) + .with_hint("重跑一次 `ull install` 会补齐依赖(下载走镜像/健康度引擎)")) +} + +/// 产物存在性(硬)与摘要(软)校验。 +fn verify_outputs(step: &ProcessorStep, warnings: &mut Vec) -> Result<(), ProcessorError> { + let missing: Vec = step + .outputs + .iter() + .filter(|output| !output.path.is_file()) + .map(|output| output.path.display().to_string()) + .collect(); + if !missing.is_empty() { + return Err(ProcessorError::output_missing(format!( + "processor {} 跑完了但产物没落盘:{}", + step.label, + missing.join(";") + )) + .with_hint("磁盘权限/杀软拦截是常见原因;也可以删掉 versions/ 下这版后重装")); + } + for output in &step.outputs { + let Some(expected) = &output.sha1 else { + continue; + }; + let Ok(actual) = verify::digest_file(&output.path, HashAlgo::Sha1) else { + continue; + }; + if !verify::matches(expected, &actual) { + warnings.push(format!( + "processor {} 的产物摘要与 profile 不符(期望 {expected} 实际 {actual}):非标准 zlib 实现会压出不同字节,按可用处理", + step.label + )); + } + } + Ok(()) +} + +/// 产物是否都已就绪(幂等判据:存在 + 摘要命中;profile 没给摘要就只认存在)。 +fn outputs_satisfied(step: &ProcessorStep, warnings: &mut Vec) -> bool { + if step.outputs.is_empty() { + // 没有产物声明 → 无法判断是否跑过,只能每次都跑(HMCL 同样处理)。 + return false; + } + for output in &step.outputs { + if !output.path.is_file() { + return false; + } + let Some(expected) = &output.sha1 else { + continue; + }; + let Ok(actual) = verify::digest_file(&output.path, HashAlgo::Sha1) else { + return false; + }; + if !verify::matches(expected, &actual) { + warnings.push(format!( + "{} 的摘要与 profile 不符,重新执行该 processor", + output.path.display() + )); + return false; + } + } + true +} diff --git a/crates/core/src/addon/loader/processors/jar.rs b/crates/core/src/addon/loader/processors/jar.rs new file mode 100644 index 0000000..93b265a --- /dev/null +++ b/crates/core/src/addon/loader/processors/jar.rs @@ -0,0 +1,83 @@ +//! 从 processor jar 的 `MANIFEST.MF` 里取 `Main-Class`。 +//! +//! 官方 install profile **不写主类**(`processors[].jar` 只给坐标),主类在 jar 的清单里; +//! 少数 fork 会额外给 `mainClass`(那条路走 [`super::plan::ProcessorStep::main_class`], +//! 不到这里)。清单按 RFC 822 折行:续行以**单个空格**开头。 + +use std::path::Path; + +use super::error::ProcessorError; + +/// 读 jar 的 `Main-Class`。 +/// +/// # Errors +/// +/// jar 打不开/不是 zip(IO/规划错误)、没有 `META-INF/MANIFEST.MF` 或清单里没有 +/// `Main-Class`(规划错误)。 +pub(super) fn read_main_class(jar: &Path) -> Result { + use std::io::Read; + + let file = std::fs::File::open(jar).map_err(|error| { + ProcessorError::io(format!( + "打开 processor jar 失败({}):{error}", + jar.display() + )) + })?; + let mut archive = zip::ZipArchive::new(file).map_err(|error| { + ProcessorError::plan(format!("{} 不是合法 jar:{error}", jar.display())) + })?; + let mut manifest = String::new(); + { + let mut entry = archive.by_name("META-INF/MANIFEST.MF").map_err(|error| { + ProcessorError::plan(format!( + "{} 里没有 META-INF/MANIFEST.MF(拿不到主类):{error}", + jar.display() + )) + })?; + entry.read_to_string(&mut manifest).map_err(|error| { + ProcessorError::io(format!("读 {} 的 MANIFEST 失败:{error}", jar.display())) + })?; + } + parse_main_class(&manifest).ok_or_else(|| { + ProcessorError::plan(format!( + "{} 的 MANIFEST 里没有 Main-Class(install profile 也没给 mainClass)", + jar.display() + )) + }) +} + +/// 从 MANIFEST 文本里取 `Main-Class`(续行以单个空格开头)。 +fn parse_main_class(manifest: &str) -> Option { + let mut lines = manifest.lines().peekable(); + while let Some(line) = lines.next() { + let Some(rest) = line.strip_prefix("Main-Class:") else { + continue; + }; + let mut value = rest.trim().to_string(); + while let Some(next) = lines.peek() { + let Some(continuation) = next.strip_prefix(' ') else { + break; + }; + value.push_str(continuation.trim()); + lines.next(); + } + return (!value.is_empty()).then_some(value); + } + None +} + +#[cfg(test)] +mod tests { + use super::parse_main_class; + + #[test] + fn parses_main_class_with_continuation_lines() { + let manifest = "Manifest-Version: 1.0\r\nMain-Class: com.example.\r\n Tool\r\n\r\n"; + assert_eq!( + parse_main_class(manifest).as_deref(), + Some("com.example.Tool") + ); + assert!(parse_main_class("Manifest-Version: 1.0\n").is_none()); + assert!(parse_main_class("Main-Class: \n").is_none()); + } +} diff --git a/crates/core/src/addon/loader/processors/mod.rs b/crates/core/src/addon/loader/processors/mod.rs new file mode 100644 index 0000000..aa68102 --- /dev/null +++ b/crates/core/src/addon/loader/processors/mod.rs @@ -0,0 +1,56 @@ +//! Installer Processors 执行器(M35):把 install profile 的 `processors[]` 跑起来。 +//! +//! 设计输入:`DevDocs/analyse/05-modloader-modpack.md` §1.2(Forge 新式 installer 的 +//! processor 流水线)与 HMCL `ForgeNewInstallTask` / `ProcessorTask` 的实现。 +//! +//! # 覆盖范围 +//! +//! | 版本段 | 有无客户端 processor | 本模块 | +//! |--------|----------------------|--------| +//! | Forge 1.13 ~ 1.20.x(spec 1) | 有(拆包/映射/打补丁) | ✅ 真跑 | +//! | NeoForge 1.20.5+(26.x 只剩 1 个 `PROCESS_MINECRAFT_JAR`) | 有 | ✅ 真跑 | +//! | Forge spec 0(1.12.2 重建版)/ Fabric / Quilt | 无 | 不需要(走 M32 的 json 路线) | +//! +//! # 流水线 +//! +//! ```text +//! plan install profile 的 data 表 → 变量表({MINECRAFT_JAR}/{SIDE}/[坐标]/'字面量') +//! → 每步 java -cp 参数 + 产物/摘要 + processor 依赖库待下清单 +//! download 依赖库(走 M27 引擎:镜像/健康度/退避/校验) +//! exec 串行外调 java(主类取自 jar 的 MANIFEST)→ 产物存在性(硬)+ 摘要(软) +//! ``` +//! +//! # 三条边界 +//! +//! 1. **不写版本 JSON**:那是 M27 `execute` 的事,且必须等 processors 全部成功 +//! (版本清单存在 = 这一版真的装完了,半成品不许留); +//! 2. **不改共享层**:原版 client jar 先复制到临时目录再交给 processor, +//! processor 只会改临时副本; +//! 3. **不猜**:`data` 表里缺变量、processor 缺 `jar`、产物没落盘,一律明确报错, +//! 不拿半截参数去试(那种失败的 stderr 更难查)。 +//! +//! # 幂等 +//! +//! 两级:①每步按 `outputs` 的 SHA-1 命中整步跳过(重跑零成本、不碰文件系统); +//! ②调用方以「版本 JSON 已落盘」为粗粒度判据整段跳过。 + +pub mod download; +pub mod error; +pub mod exec; +pub mod jar; +pub mod plan; +pub mod prepare; +pub mod spec; +pub mod text; +pub mod vars; + +#[cfg(test)] +mod tests; + +pub use download::download_libraries; +pub use error::{ProcessorError, ProcessorErrorKind}; +pub use exec::{run, select_java, ProcessorRun, StepOutcome, StepSink, StepStatus}; +pub use plan::{ProcessorPlan, ProcessorPlanInput, ProcessorStep, StepOutput}; +pub use spec::{Datum, Processor}; +pub use text::{host_redactor, tail}; +pub use vars::{EmbeddedFile, Variables, VariablesInput, MINECRAFT_JAR_NAME}; diff --git a/crates/core/src/addon/loader/processors/plan.rs b/crates/core/src/addon/loader/processors/plan.rs new file mode 100644 index 0000000..8665ad6 --- /dev/null +++ b/crates/core/src/addon/loader/processors/plan.rs @@ -0,0 +1,366 @@ +//! processor 执行计划:`data` 表展开 → 每步的 `java -cp …` 命令行 → 依赖库待下载清单。 +//! +//! 纯计算(不下载、不起进程),因此 `--dry-run` 能直接把「将执行的 processor 清单 + +//! 所需 Java + 依赖库」打出来。唯一读盘的动作是 [`crate::game::java::requirement_for_version`] +//! 读原版版本 JSON 判 Java 要求——那是诊断信息,读不到就按静态表兜底。 +//! +//! # 为什么依赖库与启动 classpath 要分开 +//! +//! install profile 的 `libraries` 是 **processor 依赖**(installertools / jarsplitter / +//! srgutils…),它们不进游戏 classpath。M32 只把数量记进告警,M35 起真正下载它们 +//! (否则 `java -cp` 一排文件都不在盘上),但仍然**绝不**并进 loader 版本 JSON。 + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; + +use super::error::ProcessorError; +use super::spec::Processor; +use super::vars::{absolute, Variables, VariablesInput, MINECRAFT_JAR_NAME}; +use crate::addon::loader::forge::InstallProfile; +use crate::addon::loader::{maven, LoaderKind}; +use crate::download::ResourceKind; +use crate::game::arguments::Environment; +use crate::game::install::libraries; +use crate::game::install::{FileRole, PlanFile}; +use crate::game::java::{requirement_for_version, JavaRequirement}; +use crate::game::layout::GameLayout; +use crate::game::library::MavenCoordinate; + +/// 一个 processor 的某个产物。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StepOutput { + /// 产物绝对路径。 + pub path: PathBuf, + /// 期望 SHA-1(profile 没给或给的不是摘要时为 `None`,只能按存在性判定)。 + pub sha1: Option, +} + +/// 一个待执行的 processor(已展开参数,不再依赖 install profile)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProcessorStep { + /// 序号(从 1 起,顺序即执行顺序)。 + pub index: usize, + /// 人读标识(jar 的 maven 坐标)。 + pub label: String, + /// 可执行 jar 的磁盘路径。 + pub jar: PathBuf, + /// 主类(profile 没写就留 `None`,执行期读 jar 的 MANIFEST)。 + pub main_class: Option, + /// `-cp` 的依赖 jar(不含 `jar` 自己)。 + pub classpath: Vec, + /// 已展开的参数。 + pub args: Vec, + /// 产物与期望摘要。 + pub outputs: Vec, +} + +impl ProcessorStep { + /// 一步的 `-cp` 全串(依赖在前、可执行 jar 在后,与 HMCL 一致)。 + /// + /// # Errors + /// + /// 路径里含分隔符时返回 IO 错误(Windows 的 `;` 不可能出现在合法路径里, + /// 真出现说明路径是拼错的)。 + pub fn classpath_join(&self) -> Result { + let paths = self + .classpath + .iter() + .chain(std::iter::once(&self.jar)) + .map(|path| path.as_path()); + std::env::join_paths(paths) + .map(|joined| joined.to_string_lossy().into_owned()) + .map_err(|error| ProcessorError::io(format!("classpath 拼不起来:{error}"))) + } +} + +/// [`ProcessorPlan::build`] 的输入。 +pub struct ProcessorPlanInput<'a> { + /// 加载器种类(只影响补库时的 maven 基址兜底)。 + pub kind: LoaderKind, + /// 目标原版版本。 + pub mc_version: &'a str, + /// loader 版本 id(依赖库 `hint=local` 的落点)。 + pub version_id: &'a str, + /// installer 里读出来的 install profile。 + pub profile: &'a InstallProfile, + /// 游戏目录布局。 + pub layout: &'a GameLayout, + /// installer jar 的磁盘路径。 + pub installer: &'a Path, + /// 临时目录(解包/`MINECRAFT_JAR` 副本都落这里)。 + pub scratch: &'a Path, + /// 规则求值环境。 + pub environment: &'a Environment, +} + +/// processor 执行计划。 +#[derive(Debug, Clone)] +pub struct ProcessorPlan { + /// loader 版本 id。 + pub version_id: String, + /// installer jar 路径。 + pub installer: PathBuf, + /// 临时目录。 + pub scratch: PathBuf, + /// 原版 client jar(复制源)。 + pub minecraft_jar: PathBuf, + /// 交给 processor 的 client jar 副本(`{MINECRAFT_JAR}`)。 + pub minecraft_jar_copy: PathBuf, + /// 展开后的变量表。 + pub variables: Variables, + /// 按顺序待执行的 processor。 + pub steps: Vec, + /// processor 依赖库(走 M27 引擎下载)。 + pub libraries: Vec, + /// 跑 installer 需要的 Java 要求。 + pub java_requirement: JavaRequirement, + /// 规划期告警(不致命)。 + pub warnings: Vec, +} + +impl ProcessorPlan { + /// 组计划。 + /// + /// # Errors + /// + /// `data` 表变量缺定义、构件坐标不合法、processor 缺 `jar` 字段时返回规划错误。 + pub fn build(input: ProcessorPlanInput<'_>) -> Result { + let scratch = absolute(input.scratch); + let variables = Variables::build(VariablesInput { + data: &input.profile.data, + layout: input.layout, + mc_version: input.mc_version, + installer: input.installer, + scratch: &scratch, + })?; + + let mut warnings = Vec::new(); + let mut steps = Vec::new(); + let mut needed: Vec<(String, PathBuf)> = Vec::new(); + for processor in input + .profile + .processors + .iter() + .filter(|processor| processor.applies_to_client()) + { + let coordinate = processor.jar_coordinate().ok_or_else(|| { + ProcessorError::plan("install profile 里有 processor 缺 `jar` 字段") + })?; + let jar = library_path(input.layout, coordinate)?; + needed.push((coordinate.to_string(), jar.clone())); + let mut classpath = Vec::with_capacity(processor.classpath.len()); + for entry in &processor.classpath { + let path = library_path(input.layout, entry)?; + needed.push((entry.trim().to_string(), path.clone())); + classpath.push(path); + } + let mut args = Vec::with_capacity(processor.args.len()); + for arg in &processor.args { + args.push(variables.expand(arg)?); + } + let outputs = step_outputs(&variables, processor, coordinate, &scratch, &mut warnings)?; + steps.push(ProcessorStep { + index: steps.len() + 1, + label: coordinate.to_string(), + jar, + main_class: processor.main_class().map(str::to_string), + classpath, + args, + outputs, + }); + } + + let (mut libraries, library_warnings) = libraries::plan( + input.layout, + input.version_id, + &input.profile.libraries, + input.environment, + ); + warnings.extend(library_warnings); + let base = library_base(input.kind, input.profile); + for (coordinate, dest) in &needed { + if libraries.iter().any(|file| &file.dest == dest) { + continue; + } + warnings.push(format!( + "processor 依赖 {coordinate} 不在 install profile 的 libraries 节里,按 maven 基址补一条" + )); + let Ok(parsed) = MavenCoordinate::parse(coordinate) else { + continue; + }; + libraries.push(PlanFile { + role: FileRole::Library, + name: coordinate.clone(), + url: join_url(&base, &parsed.relative_path()), + sha1: None, + size: None, + dest: dest.clone(), + resource: ResourceKind::Library, + }); + } + // NeoForge 的 install profile 会把同一条依赖**声明多次**(实测 21.1.80: + // cli-utils / srgutils / jopt-simple / gson 各 2~3 次)。不下重:同一落点被下两次 + // 会在同一个 `.part` 中间文件上互相抢(真机现场:原子落盘 os error 2)。 + let (libraries, duplicated) = dedupe_by_dest(libraries); + if duplicated > 0 { + warnings.push(format!( + "install profile 重复声明了 {duplicated} 个 processor 依赖,已按落点去重" + )); + } + + let java_requirement = requirement_for_version(input.layout, input.mc_version); + let minecraft_jar_copy = variables + .get("MINECRAFT_JAR") + .map_or_else(|| scratch.join(MINECRAFT_JAR_NAME), PathBuf::from); + Ok(Self { + version_id: input.version_id.to_string(), + installer: absolute(input.installer), + scratch, + minecraft_jar: input.layout.version_jar(input.mc_version), + minecraft_jar_copy, + variables, + steps, + libraries, + java_requirement, + warnings, + }) + } + + /// 是否一个 processor 都没有。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.steps.is_empty() + } + + /// 汇总行数(CLI 人读输出用)。 + #[must_use] + pub fn step_count(&self) -> usize { + self.steps.len() + } + + /// `--dry-run` / 报告里的 processors 载荷。 + #[must_use] + pub fn to_json(&self) -> Value { + json!({ + "count": self.steps.len(), + "installer": self.installer.display().to_string(), + "scratch": self.scratch.display().to_string(), + "minecraft_jar": self.minecraft_jar.display().to_string(), + "libraries": self.libraries.len(), + "library_files": self.libraries.iter().map(|file| json!({ + "name": file.name, + "url": file.url, + "dest": file.dest.display().to_string(), + "sha1": file.sha1, + "size": file.size, + })).collect::>(), + "variables": self.variables.len(), + "embedded_files": self.variables.embedded().len(), + "java_requirement": { + "minimum": self.java_requirement.minimum, + "maximum": self.java_requirement.maximum, + "describe": self.java_requirement.describe(), + "source": self.java_requirement.source.as_str(), + }, + "steps": self.steps.iter().map(step_json).collect::>(), + "warnings": self.warnings, + }) + } +} + +/// 一条 processor 的 JSON 视图。 +fn step_json(step: &ProcessorStep) -> Value { + json!({ + "index": step.index, + "jar": step.label, + "jar_path": step.jar.display().to_string(), + "main_class": step.main_class, + "classpath": step.classpath.len(), + "args": step.args, + "outputs": step.outputs.iter().map(|output| json!({ + "path": output.path.display().to_string(), + "sha1": output.sha1, + })).collect::>(), + }) +} + +/// 展开一步的 `outputs` 表(路径 + 期望摘要)。 +fn step_outputs( + variables: &Variables, + processor: &Processor, + coordinate: &str, + scratch: &Path, + warnings: &mut Vec, +) -> Result, ProcessorError> { + let mut outputs = Vec::with_capacity(processor.outputs.len()); + for (raw_path, raw_sha1) in &processor.outputs { + let expanded = variables.expand(raw_path)?; + let path = PathBuf::from(&expanded); + let path = if path.is_absolute() { + path + } else { + scratch.join(path) + }; + let expanded_sha1 = variables.expand(raw_sha1).unwrap_or_default(); + let sha1 = is_sha1(&expanded_sha1).then(|| expanded_sha1.to_ascii_lowercase()); + if sha1.is_none() { + warnings.push(format!( + "processor {coordinate} 的输出 {} 没给 SHA-1,只能按存在性判定", + path.display() + )); + } + outputs.push(StepOutput { path, sha1 }); + } + Ok(outputs) +} + +/// 40 位十六进制(SHA-1 的样子)。 +fn is_sha1(value: &str) -> bool { + value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit()) +} + +/// 坐标 → `libraries/` 下的路径。 +fn library_path(layout: &GameLayout, raw: &str) -> Result { + let coordinate = MavenCoordinate::parse(raw.trim()).map_err(|error| { + ProcessorError::plan(format!("processor 依赖坐标不合法:`{raw}`({error})")) + })?; + Ok(layout.libraries_dir().join(coordinate.relative_path())) +} + +/// 补库时用的 maven 基址:install profile 自己声明的 `url` 优先,否则按加载器兜底。 +fn library_base(kind: LoaderKind, profile: &InstallProfile) -> String { + profile + .libraries + .iter() + .filter_map(|library| library.url.as_deref()) + .map(str::trim) + .find(|url| !url.is_empty()) + .map_or_else(|| maven::base(kind).to_string(), str::to_string) +} + +/// `基址 + 相对路径`(保证中间恰好一个 `/`)。 +fn join_url(base: &str, relative: &Path) -> String { + let path = relative.to_string_lossy().replace('\\', "/"); + if base.ends_with('/') { + format!("{base}{path}") + } else { + format!("{base}/{path}") + } +} + +/// 按落点去重(保留首次出现的条目),返回(去重后的清单,丢掉几条)。 +fn dedupe_by_dest(files: Vec) -> (Vec, usize) { + let total = files.len(); + let mut seen: Vec = Vec::with_capacity(total); + let mut kept: Vec = Vec::with_capacity(total); + for file in files { + if seen.contains(&file.dest) { + continue; + } + seen.push(file.dest.clone()); + kept.push(file); + } + let dropped = total - kept.len(); + (kept, dropped) +} diff --git a/crates/core/src/addon/loader/processors/prepare.rs b/crates/core/src/addon/loader/processors/prepare.rs new file mode 100644 index 0000000..aa4a49d --- /dev/null +++ b/crates/core/src/addon/loader/processors/prepare.rs @@ -0,0 +1,201 @@ +//! 临时目录准备:建目录、解包 installer 内的文件、复制原版 client jar。 +//! +//! 三条都是「起 java 之前的脏活」,失败一律归 `processor_io`/`processor_plan` +//! (区别于 processor 自己跑挂的 `processor_failed`)。 + +use super::error::ProcessorError; +use super::plan::ProcessorPlan; + +/// 单条目的**预分配**上限(64 MiB):installer jar 下载后只校验「是合法 zip」, +/// 没有校验和(`meta.rs` 的 `fetch_installer`),因此 zip header 里声明的解压尺寸 +/// 是不可信输入——损坏或镜像投毒的 installer 可以声明天文数字,让一次小文件解包直接 +/// 申请多 GB 内存。`Vec` 仍按实际读入增长,合法条目行为不变。 +const PREALLOC_MAX: u64 = 64 << 20; + +/// 单条目的**实际字节**上限(256 MiB):预分配封顶挡不住「声明小、解压极大」的 +/// zip bomb,所以读入过程本身也要有硬顶。超过就明确报错(不静默截断)。 +const MAX_EMBEDDED_BYTES: u64 = 256 << 20; + +/// 执行前的准备:建临时目录、解包 installer 内的文件、复制原版 client jar。 +pub(super) fn prepare(plan: &ProcessorPlan) -> Result<(), ProcessorError> { + std::fs::create_dir_all(&plan.scratch).map_err(|error| { + ProcessorError::io(format!( + "建临时目录失败({}):{error}", + plan.scratch.display() + )) + })?; + extract_embedded(plan)?; + copy_minecraft_jar(plan) +} + +/// 把 install profile 声明要解包的文件从 installer zip 里抽到临时目录。 +fn extract_embedded(plan: &ProcessorPlan) -> Result<(), ProcessorError> { + let embedded = plan.variables.embedded(); + if embedded.is_empty() { + return Ok(()); + } + use std::io::Read; + + let installer = &plan.installer; + let file = std::fs::File::open(installer).map_err(|error| { + ProcessorError::io(format!( + "打开 installer 失败({}):{error}", + installer.display() + )) + })?; + let mut archive = zip::ZipArchive::new(file).map_err(|error| { + ProcessorError::plan(format!("{} 不是合法 zip:{error}", installer.display())) + })?; + for item in embedded { + let mut entry = archive.by_name(&item.entry).map_err(|error| { + ProcessorError::plan(format!( + "installer 里没有 {}(data 表声明要解包):{error}", + item.entry + )) + })?; + let mut bytes = Vec::with_capacity(prealloc_hint(entry.size())); + let read = (&mut entry) + .take(MAX_EMBEDDED_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| { + ProcessorError::io(format!("读 installer 条目 {} 失败:{error}", item.entry)) + })?; + if read as u64 > MAX_EMBEDDED_BYTES { + return Err(ProcessorError::plan(format!( + "installer 条目 {} 解压后超过 {} MiB,拒绝继续(installer 可能损坏或被投毒)", + item.entry, + MAX_EMBEDDED_BYTES >> 20 + ))); + } + crate::platform::atomic::write_atomic(&item.dest, &bytes).map_err(|error| { + ProcessorError::io(format!( + "写临时文件失败({}):{error}", + item.dest.display() + )) + })?; + } + Ok(()) +} + +/// 预分配提示:**不信任** header 声明的尺寸,封顶 [`PREALLOC_MAX`]。 +fn prealloc_hint(declared: u64) -> usize { + declared.min(PREALLOC_MAX) as usize +} + +/// 复制原版 client jar 到临时目录(processor 会改写输入,不能让它碰共享层)。 +fn copy_minecraft_jar(plan: &ProcessorPlan) -> Result<(), ProcessorError> { + let source = &plan.minecraft_jar; + let target = &plan.minecraft_jar_copy; + let fresh = match (std::fs::metadata(source), std::fs::metadata(target)) { + (Ok(left), Ok(right)) => left.len() != right.len(), + (Ok(_), Err(_)) => true, + (Err(error), _) => { + return Err(ProcessorError::io(format!( + "原版 client jar 不在盘上({}):{error}", + source.display() + )) + .with_hint("先装原版:`ull install `")) + } + }; + if !fresh { + return Ok(()); + } + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + ProcessorError::io(format!("建目录失败({}):{error}", parent.display())) + })?; + } + std::fs::copy(source, target).map_err(|error| { + ProcessorError::io(format!( + "复制 {} → {} 失败:{error}", + source.display(), + target.display() + )) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::{prealloc_hint, prepare, PREALLOC_MAX}; + use crate::addon::loader::forge::InstallProfile; + use crate::addon::loader::processors::{ProcessorPlan, ProcessorPlanInput}; + use crate::addon::loader::LoaderKind; + use crate::game::arguments::Environment; + use crate::game::layout::GameLayout; + + /// 一步 processor + 一个要从 installer 里解包的 data 值。 + const PROFILE: &str = r#"{ + "spec": 1, "minecraft": "1.20.1", "json": "/version.json", + "processors": [{"jar": "net.minecraftforge:binarypatcher:1.1.1", "args": ["--apply", "{BINPATCH}"]}], + "libraries": [], + "data": {"BINPATCH": {"client": "/data/client.lzma"}} + }"#; + + #[test] + fn preallocation_is_capped_so_a_forged_header_cannot_oom() { + // installer jar 没有校验和:header 可以声称 16 EiB 的解压尺寸。 + assert_eq!(prealloc_hint(u64::MAX), PREALLOC_MAX as usize); + assert_eq!(prealloc_hint(1 << 40), PREALLOC_MAX as usize); + // 合法条目照旧按实际尺寸预分配(封顶不影响常见大小)。 + assert_eq!(prealloc_hint(0), 0); + assert_eq!(prealloc_hint(4096), 4096); + assert_eq!(prealloc_hint(PREALLOC_MAX), PREALLOC_MAX as usize); + assert_eq!(prealloc_hint(PREALLOC_MAX - 1), PREALLOC_MAX as usize - 1); + } + + #[test] + fn prepare_extracts_the_declared_entry_and_copies_the_client_jar() { + let root = super::super::tests::temp_root("prepare-extract"); + let layout = GameLayout::new(&root); + let profile: InstallProfile = serde_json::from_str(PROFILE).expect("夹具应可解析"); + let installer = root.join("cache/installer.jar"); + write_zip(&installer, "data/client.lzma", b"patch-bytes"); + let vanilla = root.join("versions/1.20.1/1.20.1.jar"); + std::fs::create_dir_all(vanilla.parent().expect("有父目录")).expect("建目录"); + std::fs::write(&vanilla, b"client-jar").expect("写原版 jar"); + + let plan = ProcessorPlan::build(ProcessorPlanInput { + kind: LoaderKind::Forge, + mc_version: "1.20.1", + version_id: "1.20.1-forge-47.2.0", + profile: &profile, + layout: &layout, + installer: &installer, + scratch: &root.join("scratch"), + environment: &Environment::host(), + }) + .expect("计划应能组出来"); + assert_eq!(plan.variables.embedded().len(), 1); + + prepare(&plan).expect("准备应成功"); + let dest = &plan.variables.embedded()[0].dest; + assert_eq!(std::fs::read(dest).expect("读解包产物"), b"patch-bytes"); + // processor 拿到的是副本,共享层的原版 jar 内容不受影响。 + assert_eq!( + std::fs::read(&plan.minecraft_jar_copy).expect("读 client jar 副本"), + b"client-jar" + ); + assert_ne!(plan.minecraft_jar_copy, plan.minecraft_jar); + // 幂等:重跑按大小判定,不重复复制、不报错。 + prepare(&plan).expect("重跑应成功"); + } + + /// 写一个只含一条 Stored 条目的 zip(installer 夹具)。 + fn write_zip(path: &Path, entry: &str, bytes: &[u8]) { + use std::io::Write; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建目录"); + } + let file = std::fs::File::create(path).expect("建 zip"); + let mut writer = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + writer.start_file(entry, options).expect("写条目"); + writer.write_all(bytes).expect("写内容"); + writer.finish().expect("收尾 zip"); + } +} diff --git a/crates/core/src/addon/loader/processors/spec.rs b/crates/core/src/addon/loader/processors/spec.rs new file mode 100644 index 0000000..09372cc --- /dev/null +++ b/crates/core/src/addon/loader/processors/spec.rs @@ -0,0 +1,121 @@ +//! install profile 的 `processors[]` 与 `data` 表:**纯解析,不动作**。 +//! +//! 字段含义照 Forge installer spec(与 HMCL `ForgeNewInstallProfile.Processor/Datum` 一致): +//! +//! ```json +//! "processors": [{ +//! "sides": ["client"], // 缺省 = 两侧都跑 +//! "jar": "net.minecraftforge:installertools:1.3.0", // 可执行 jar(主类在 MANIFEST 里) +//! "classpath": ["net.minecraftforge:jarsplitter:1.1.4"], // 依赖 jar(jar 自己不在里面) +//! "args": ["--task", "MCP_DATA", "--output", "{MAPPINGS}"], +//! "outputs": {"{MAPPINGS}": "ab12…(期望 SHA-1)"} +//! }], +//! "data": {"MAPPINGS": {"client": "[de.oceanlabs.mcp:mcp_config:1.20.1@zip]", "server": "…"}} +//! ``` +//! +//! 三条容易看错的点: +//! +//! 1. `data` 的每条是**按侧**的(`client` / `server`),client 侧只取 `.client`; +//! 2. 官方只写 `jar`,**不写主类**——主类在 jar 的 `META-INF/MANIFEST.MF`(`Main-Class`); +//! 少数 fork 会额外给 `mainClass`,所以这里留了口子; +//! 3. `classpath` **不含** `jar` 自己(Forge 的约定),拼 `-cp` 时要补上。 + +use std::collections::BTreeMap; + +use serde::Deserialize; + +/// install profile 里的一个 processor。 +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +pub struct Processor { + /// 生效侧(`client` / `server`;空表示两侧都要)。 + #[serde(default)] + pub sides: Vec, + /// 可执行 jar 的 maven 坐标。 + #[serde(default)] + pub jar: Option, + /// 主类覆盖(官方 profile 不写,留给少数 fork)。 + #[serde(default, rename = "mainClass")] + pub main_class: Option, + /// 依赖 jar 的 maven 坐标(**不含** `jar` 自己)。 + #[serde(default)] + pub classpath: Vec, + /// 命令行参数(可含 `{VAR}` / `[坐标]` / `'字面量'` 三种占位)。 + #[serde(default)] + pub args: Vec, + /// 产物表:路径占位 → 期望 SHA-1(既有产物可用于幂等跳过)。 + #[serde(default)] + pub outputs: BTreeMap, +} + +impl Processor { + /// 是否在 client 侧生效。 + #[must_use] + pub fn applies_to_client(&self) -> bool { + self.sides.is_empty() || self.sides.iter().any(|side| side == "client") + } + + /// `jar` 坐标(去掉首尾空白;缺失或空串返回 `None`)。 + #[must_use] + pub fn jar_coordinate(&self) -> Option<&str> { + self.jar + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + } + + /// 主类覆盖(缺失或空串返回 `None`)。 + #[must_use] + pub fn main_class(&self) -> Option<&str> { + self.main_class + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + } +} + +/// `data` 表的一条:按侧给值(client 侧只用 `.client`)。 +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +pub struct Datum { + /// client 侧的值(`[坐标]` / `'字面量'` / installer 内的文件路径)。 + #[serde(default)] + pub client: Option, + /// server 侧的值(本期不跑 server 安装,只解析不动作)。 + #[serde(default)] + pub server: Option, +} + +impl Datum { + /// client 侧的值(空串视为没有)。 + #[must_use] + pub fn client_value(&self) -> Option<&str> { + self.client + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + } +} + +#[cfg(test)] +mod tests { + use super::Processor; + + #[test] + fn sides_empty_means_both_and_client_subset_is_recognised() { + let both: Processor = serde_json::from_str(r#"{"jar": "g:a:1"}"#).expect("应可解析"); + assert!(both.applies_to_client()); + let server: Processor = + serde_json::from_str(r#"{"sides": ["server"], "jar": "g:a:1"}"#).expect("应可解析"); + assert!(!server.applies_to_client()); + let client: Processor = + serde_json::from_str(r#"{"sides": ["client"], "jar": "g:a:1"}"#).expect("应可解析"); + assert!(client.applies_to_client()); + } + + #[test] + fn blank_jar_and_main_class_are_treated_as_absent() { + let processor: Processor = + serde_json::from_str(r#"{"jar": " ", "mainClass": ""}"#).expect("应可解析"); + assert!(processor.jar_coordinate().is_none()); + assert!(processor.main_class().is_none()); + } +} diff --git a/crates/core/src/addon/loader/processors/tests/exec.rs b/crates/core/src/addon/loader/processors/tests/exec.rs new file mode 100644 index 0000000..7354b00 --- /dev/null +++ b/crates/core/src/addon/loader/processors/tests/exec.rs @@ -0,0 +1,185 @@ +//! 执行器:幂等跳过(不碰文件系统)、依赖缺失分流、起不来 java 的分类、脱敏器。 + +use std::path::{Path, PathBuf}; + +use crate::addon::loader::forge::InstallProfile; +use crate::addon::loader::processors::{host_redactor, run}; +use crate::addon::loader::processors::{ProcessorErrorKind, ProcessorPlan, ProcessorPlanInput}; +use crate::addon::loader::LoaderKind; +use crate::game::arguments::Environment; +use crate::game::java::{JavaOrigin, JavaRuntime}; +use crate::game::layout::GameLayout; + +use super::temp_root; + +/// 一个最小 profile:一步 processor,产物是临时目录里的 `built.jar`(只按存在性判定)。 +const MINIMAL_PROFILE: &str = r#"{ + "spec": 1, + "minecraft": "1.20.1", + "json": "/version.json", + "processors": [{ + "jar": "net.minecraftforge:installertools:1.3.0", + "classpath": ["net.minecraftforge:jarsplitter:1.1.4"], + "args": ["--task", "SPLIT"], + "outputs": {"{OUT}": "'not-a-sha1'"} + }], + "libraries": [], + "data": {"OUT": {"client": "'built.jar'"}} +}"#; + +fn minimal_profile() -> InstallProfile { + serde_json::from_str(MINIMAL_PROFILE).expect("夹具应可解析") +} + +/// 组装计划(依赖库不落盘:执行器不负责下载,缺失由用例自己摆)。 +fn plan(root: &Path) -> ProcessorPlan { + let profile = minimal_profile(); + let layout = GameLayout::new(root); + ProcessorPlan::build(ProcessorPlanInput { + kind: LoaderKind::Forge, + mc_version: "1.20.1", + version_id: "1.20.1-forge-47.2.0", + profile: &profile, + layout: &layout, + installer: &root.join("installer.jar"), + scratch: &root.join("scratch"), + environment: &Environment::host(), + }) + .expect("计划应能组出来") +} + +/// 一个「必然起不来」的 java:这些用例只验证分类与顺序,不验证 java 行为。 +fn missing_java() -> JavaRuntime { + JavaRuntime::unknown( + PathBuf::from("/nonexistent/ull-test-java"), + JavaOrigin::Path, + ) +} + +fn touch(path: &Path) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建目录"); + } + std::fs::write(path, b"stub").expect("写夹具文件"); +} + +/// 写一个最小 jar(可选带 `Main-Class`),给"主类从 MANIFEST 读"的用例用。 +fn write_jar(path: &Path, main_class: Option<&str>) { + use std::io::Write; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建目录"); + } + let file = std::fs::File::create(path).expect("建 jar"); + let mut writer = zip::ZipWriter::new(file); + let options = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + if let Some(main_class) = main_class { + writer + .start_file("META-INF/MANIFEST.MF", options) + .expect("写 manifest 条目"); + write!( + writer, + "Manifest-Version: 1.0\r\nMain-Class: {main_class}\r\n\r\n" + ) + .expect("写 manifest"); + } + writer.finish().expect("收尾 jar"); +} + +#[test] +fn ready_outputs_skip_the_whole_step_without_touching_the_disk() { + let root = temp_root("exec-idempotent"); + let plan = plan(&root); + // 产物已就绪:原版 client jar 压根不存在也无所谓——幂等快路径不该去复制它。 + touch(&plan.steps[0].outputs[0].path); + + let run = run(&plan, &missing_java(), None).expect("产物齐备时不该跑任何进程"); + assert_eq!(run.executed(), 0); + assert_eq!(run.skipped(), 1); + assert_eq!(run.steps[0].status.as_str(), "skipped"); + assert_eq!(run.to_json()["steps"][0]["status"], "skipped"); + // prepare 不该跑:原版 jar 压根不存在、client jar 副本没被复制。 + assert!(!plan.minecraft_jar_copy.exists()); +} + +#[test] +fn missing_minecraft_jar_is_reported_before_any_java_runs() { + let root = temp_root("exec-no-vanilla"); + let plan = plan(&root); + let error = run(&plan, &missing_java(), None).expect_err("缺原版 jar 必须报错"); + assert_eq!(error.kind(), ProcessorErrorKind::Io); + assert!( + error.message().contains("原版 client jar"), + "{}", + error.message() + ); +} + +#[test] +fn missing_processor_jar_is_a_library_error_naming_the_file() { + let root = temp_root("exec-no-lib"); + let plan = plan(&root); + touch(&plan.minecraft_jar); + + let error = run(&plan, &missing_java(), None).expect_err("缺依赖必须报错"); + assert_eq!(error.kind(), ProcessorErrorKind::Library); + assert!( + error.message().contains("installertools-1.3.0.jar"), + "{}", + error.message() + ); +} + +#[test] +fn a_java_that_cannot_start_is_an_io_error_with_a_hint() { + let root = temp_root("exec-no-java"); + let plan = plan(&root); + touch(&plan.minecraft_jar); + // 可执行 jar 得是真的 zip + MANIFEST:主类只从那儿来(profile 没写 mainClass)。 + write_jar( + &plan.steps[0].jar, + Some("net.minecraftforge.installertools.Main"), + ); + touch(&plan.steps[0].classpath[0]); + + let error = run(&plan, &missing_java(), None).expect_err("java 起不来必须报错"); + assert_eq!(error.kind(), ProcessorErrorKind::Io); + assert!( + error.message().contains("起 java 失败"), + "{}", + error.message() + ); + assert!(error.hint().is_some()); + // 走到这一步说明临时目录、MC jar 副本都准备好了(顺序:prepare → 起进程)。 + assert!(plan.minecraft_jar_copy.is_file()); +} + +#[test] +fn a_jar_without_a_main_class_is_a_plan_error() { + let root = temp_root("exec-no-main"); + let plan = plan(&root); + touch(&plan.minecraft_jar); + write_jar(&plan.steps[0].jar, None); + touch(&plan.steps[0].classpath[0]); + + let error = run(&plan, &missing_java(), None).expect_err("拿不到主类必须报错"); + assert_eq!(error.kind(), ProcessorErrorKind::Plan); + assert!(error.message().contains("主类"), "{}", error.message()); +} + +#[test] +fn host_redactor_masks_the_user_home_directory() { + let redactor = host_redactor(); + let home = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")); + let Some(home) = home else { + return; // 极简环境(无 HOME/USERPROFILE)时跳过 + }; + let text = format!("{}/game/libraries/x.jar", home.to_string_lossy()); + let scrubbed = redactor.scrub(&text); + assert!(scrubbed.contains(""), "{scrubbed}"); + assert!( + !scrubbed.contains(&home.to_string_lossy().to_string()), + "{scrubbed}" + ); +} diff --git a/crates/core/src/addon/loader/processors/tests/mod.rs b/crates/core/src/addon/loader/processors/tests/mod.rs new file mode 100644 index 0000000..6e706f4 --- /dev/null +++ b/crates/core/src/addon/loader/processors/tests/mod.rs @@ -0,0 +1,71 @@ +//! `addon::loader::processors` 的单元测试:占位替换 / 计划形状 / 执行与失败分流 / 幂等跳过。 +//! +//! 刻意**全部离线**:installer 用夹具 JSON 描述,依赖库的"在不在盘上"由测试自己摆, +//! 真正外调 java 的用例只走「起不来」这一条确定性分支(假 java 路径不存在), +//! 真机执行由验收(Forge 1.20.1 / NeoForge 21.1.80)覆盖。 + +mod exec; +mod plan; +mod vars; + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::addon::loader::forge::InstallProfile; + +/// 实测 Forge 1.20.1-47.2.0 的形状(裁剪到 3 个 processor:2 个 client 侧 + 1 个仅 server)。 +pub(crate) const FORGE_PROFILE: &str = r#"{ + "spec": 1, + "profile": "forge", + "version": "1.20.1-forge-47.2.0", + "minecraft": "1.20.1", + "json": "/version.json", + "processors": [ + { + "sides": ["client"], + "jar": "net.minecraftforge:installertools:1.3.0", + "classpath": ["net.minecraftforge:jarsplitter:1.1.4"], + "args": ["--task", "MCP_DATA", "--input", "{MAPPINGS}", "--output", "{MC_SLIM}", "--side", "{SIDE}"], + "outputs": {"{MC_SLIM}": "aa11bb22cc33dd44ee55ff660011223344556677"} + }, + { + "sides": ["server"], + "jar": "net.minecraftforge:installertools:1.3.0", + "classpath": [], + "args": ["--task", "SERVER"], + "outputs": {} + }, + { + "jar": "net.minecraftforge:jarsplitter:1.1.4", + "classpath": ["net.minecraftforge:installertools:1.3.0"], + "args": ["--input", "{MINECRAFT_JAR}", "--output", "{MC_EXTRA}", "--singleThreaded"], + "outputs": {"{MC_EXTRA}": "'not-a-sha1'"} + } + ], + "libraries": [ + {"name": "net.minecraftforge:installertools:1.3.0", "url": "https://maven.minecraftforge.net/"}, + {"name": "net.minecraftforge:jarsplitter:1.1.4", "url": "https://maven.minecraftforge.net/"} + ], + "data": { + "MAPPINGS": {"client": "[de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip]", "server": "[de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip]"}, + "MC_SLIM": {"client": "[net.minecraft:client:1.20.1-20230612.114412:slim]", "server": "[net.minecraft:server:1.20.1-20230612.114412:slim]"}, + "MC_EXTRA": {"client": "[net.minecraft:client:1.20.1-20230612.114412:extra]", "server": "[net.minecraft:server:1.20.1-20230612.114412:extra]"}, + "BINPATCH": {"client": "/data/client.lzma", "server": "/data/server.lzma"}, + "SERVER_ONLY": {"server": "'server'"} + } +}"#; + +/// 夹具 profile。 +pub(crate) fn profile() -> InstallProfile { + serde_json::from_str(FORGE_PROFILE).expect("夹具 install profile 应可解析") +} + +/// 一次性临时目录(用例结束不清理,落在系统 temp 下)。 +pub(crate) fn temp_root(tag: &str) -> PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "ull-processors-{}-{tag}-{unique}", + std::process::id() + )) +} diff --git a/crates/core/src/addon/loader/processors/tests/plan.rs b/crates/core/src/addon/loader/processors/tests/plan.rs new file mode 100644 index 0000000..c5bce32 --- /dev/null +++ b/crates/core/src/addon/loader/processors/tests/plan.rs @@ -0,0 +1,224 @@ +//! 执行计划:client 侧过滤、参数展开、产物/摘要、依赖库补齐、Java 要求。 + +use crate::addon::loader::forge::InstallProfile; +use crate::addon::loader::processors::{ProcessorErrorKind, ProcessorPlan, ProcessorPlanInput}; +use crate::addon::loader::LoaderKind; +use crate::game::arguments::Environment; +use crate::game::layout::GameLayout; + +use super::{profile, temp_root}; + +fn plan_for(root: &std::path::Path, profile: &InstallProfile) -> ProcessorPlan { + let layout = GameLayout::new(root); + ProcessorPlan::build(ProcessorPlanInput { + kind: LoaderKind::Forge, + mc_version: "1.20.1", + version_id: "1.20.1-forge-47.2.0", + profile, + layout: &layout, + installer: &root.join("cache/forge-installer.jar"), + scratch: &root.join("cache/processors"), + environment: &Environment::host(), + }) + .expect("计划应能组出来") +} + +#[test] +fn only_client_side_processors_become_steps_in_declared_order() { + let root = temp_root("plan-order"); + let plan = plan_for(&root, &profile()); + + assert_eq!(plan.step_count(), 2, "server-only 的 processor 必须被过滤"); + assert_eq!(plan.steps[0].index, 1); + assert_eq!( + plan.steps[0].label, + "net.minecraftforge:installertools:1.3.0" + ); + assert_eq!(plan.steps[1].index, 2); + assert_eq!(plan.steps[1].label, "net.minecraftforge:jarsplitter:1.1.4"); + assert_eq!(plan.steps[1].classpath.len(), 1); + assert!(!plan.is_empty()); +} + +#[test] +fn args_are_expanded_with_paths_and_the_client_side() { + let root = temp_root("plan-args"); + let plan = plan_for(&root, &profile()); + let args = &plan.steps[0].args; + + assert_eq!(args[0], "--task"); + assert_eq!(args[1], "MCP_DATA"); + assert!( + args[3].ends_with("mcp_config-1.20.1-20230612.114412.zip"), + "输入构件应展开成 libraries 路径:{}", + args[3] + ); + assert!( + args[5].ends_with("client-1.20.1-20230612.114412-slim.jar"), + "产物路径应按 data 表展开:{}", + args[5] + ); + assert_eq!(args[7], "client"); + // 第二步用的是临时目录里的 client jar 副本,不是共享层的原版 jar。 + assert_eq!( + plan.steps[1].args[1], + plan.minecraft_jar_copy.to_string_lossy() + ); + assert!(plan + .minecraft_jar_copy + .to_string_lossy() + .ends_with("minecraft.jar")); + assert!(!plan.minecraft_jar_copy.starts_with(root.join("versions"))); +} + +#[test] +fn outputs_carry_paths_and_only_real_sha1s() { + let root = temp_root("plan-outputs"); + let plan = plan_for(&root, &profile()); + + let first = &plan.steps[0].outputs; + assert_eq!(first.len(), 1); + assert_eq!( + first[0].sha1.as_deref(), + Some("aa11bb22cc33dd44ee55ff660011223344556677") + ); + assert!(first[0].path.to_string_lossy().ends_with("-slim.jar")); + // 非 SHA-1 的 values 只算"存在性校验",并留一条告警。 + assert!(plan.steps[1].outputs[0].sha1.is_none()); + assert!( + plan.warnings + .iter() + .any(|warning| warning.contains("没给 SHA-1")), + "{:?}", + plan.warnings + ); +} + +#[test] +fn processor_dependencies_are_planned_once_even_when_libraries_omit_them() { + let root = temp_root("plan-libs"); + let lean = serde_json::from_str( + r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json", + "processors": [{"jar": "net.minecraftforge:installertools:1.3.0", + "classpath": ["net.minecraftforge:jarsplitter:1.1.4"], + "args": [], "outputs": {}}], + "libraries": [], + "data": {}}"#, + ) + .expect("夹具应可解析"); + let layout = GameLayout::new(&root); + let plan = ProcessorPlan::build(ProcessorPlanInput { + kind: LoaderKind::NeoForge, + mc_version: "1.20.1", + version_id: "1.20.1-neoforge-21.1.80", + profile: &lean, + layout: &layout, + installer: &root.join("installer.jar"), + scratch: &root.join("tmp"), + environment: &Environment::host(), + }) + .expect("计划应能组出来"); + + assert_eq!(plan.libraries.len(), 2, "jar 与 classpath 各补一条"); + for file in &plan.libraries { + assert!( + file.url.contains("neoforged") || file.url.contains("minecraftforge"), + "补库应落在加载器的 maven 上:{}", + file.url + ); + assert!(file.dest.starts_with(root.join("libraries"))); + } + assert!(plan + .warnings + .iter() + .any(|warning| warning.contains("不在 install profile 的 libraries 节里"))); +} + +#[test] +fn java_requirement_follows_the_target_minecraft_version() { + let root = temp_root("plan-java"); + let plan = plan_for(&root, &profile()); + assert_eq!(plan.java_requirement.minimum, 17); + assert_eq!(plan.java_requirement.maximum, None); + assert_eq!(plan.java_requirement.describe(), "Java 17+"); +} + +#[test] +fn json_view_lists_every_step_for_dry_run() { + let root = temp_root("plan-json"); + let plan = plan_for(&root, &profile()); + let json = plan.to_json(); + + assert_eq!(json["count"], 2); + assert_eq!(json["java_requirement"]["describe"], "Java 17+"); + assert_eq!( + json["steps"][0]["jar"], + "net.minecraftforge:installertools:1.3.0" + ); + assert_eq!(json["steps"][0]["classpath"], 1); + assert_eq!( + json["steps"][1]["outputs"][0]["sha1"], + serde_json::Value::Null + ); +} + +#[test] +fn duplicated_library_declarations_are_deduped_by_destination() { + // NeoForge 21.1.80 实测:install profile 把 cli-utils / srgutils 等声明了 2~3 次。 + // 同一落点下两次会在同一个 `.part` 中间文件上互相抢(真机 os error 2),必须去重。 + let root = temp_root("plan-dedup"); + let duplicated: InstallProfile = serde_json::from_str( + r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json", + "processors": [{"jar": "net.neoforged.installertools:cli-utils:2.1.2@jar"}], + "libraries": [ + {"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"}, + {"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"}, + {"name": "net.neoforged.installertools:cli-utils:2.1.2@jar", "url": "https://maven.neoforged.net/releases/"} + ], + "data": {}}"#, + ) + .expect("夹具应可解析"); + let layout = GameLayout::new(&root); + let plan = ProcessorPlan::build(ProcessorPlanInput { + kind: LoaderKind::NeoForge, + mc_version: "1.20.1", + version_id: "1.20.1-neoforge-21.1.80", + profile: &duplicated, + layout: &layout, + installer: &root.join("installer.jar"), + scratch: &root.join("tmp"), + environment: &Environment::host(), + }) + .expect("计划应能组出来"); + + assert_eq!(plan.libraries.len(), 1, "同一落点只应留一条"); + assert!( + plan.warnings.iter().any(|warning| warning.contains("去重")), + "{:?}", + plan.warnings + ); +} + +#[test] +fn processor_without_jar_is_a_plan_error() { + let root = temp_root("plan-nojar"); + let broken: InstallProfile = serde_json::from_str( + r#"{"spec": 1, "minecraft": "1.20.1", "json": "/version.json", + "processors": [{"args": ["--task", "X"]}], "libraries": [], "data": {}}"#, + ) + .expect("夹具应可解析"); + let layout = GameLayout::new(&root); + let error = ProcessorPlan::build(ProcessorPlanInput { + kind: LoaderKind::Forge, + mc_version: "1.20.1", + version_id: "1.20.1-forge-47.2.0", + profile: &broken, + layout: &layout, + installer: &root.join("installer.jar"), + scratch: &root.join("tmp"), + environment: &Environment::host(), + }) + .expect_err("缺 jar 必须报错"); + assert_eq!(error.kind(), ProcessorErrorKind::Plan); + assert!(error.message().contains("jar"), "{}", error.message()); +} diff --git a/crates/core/src/addon/loader/processors/tests/vars.rs b/crates/core/src/addon/loader/processors/tests/vars.rs new file mode 100644 index 0000000..8c33dc9 --- /dev/null +++ b/crates/core/src/addon/loader/processors/tests/vars.rs @@ -0,0 +1,181 @@ +//! 变量表:三种字面量、标准变量、token 替换的边界。 + +use std::collections::BTreeMap; +use std::path::Path; + +use crate::addon::loader::processors::spec::Datum; +use crate::addon::loader::processors::vars::{Variables, VariablesInput}; +use crate::addon::loader::processors::ProcessorErrorKind; +use crate::game::layout::GameLayout; + +use super::{profile, temp_root}; + +fn data(entries: &[(&str, &str)]) -> BTreeMap { + entries + .iter() + .map(|(key, value)| { + ( + (*key).to_string(), + Datum { + client: Some((*value).to_string()), + server: None, + }, + ) + }) + .collect() +} + +fn build<'a>( + layout: &'a GameLayout, + data: &'a BTreeMap, + installer: &'a Path, + scratch: &'a Path, +) -> Variables { + Variables::build(VariablesInput { + data, + layout, + mc_version: "1.20.1", + installer, + scratch, + }) + .expect("变量表应能组装") +} + +#[test] +fn artifact_literals_resolve_into_the_libraries_directory() { + let root = temp_root("vars-artifact"); + let layout = GameLayout::new(&root); + let installer = root.join("installer.jar"); + let scratch = root.join("tmp"); + let table = data(&[("MAPPINGS", "[de.oceanlabs.mcp:mcp_config:1.20.1@zip]")]); + let variables = build(&layout, &table, &installer, &scratch); + + let value = variables.get("MAPPINGS").expect("应有 MAPPINGS"); + assert!( + Path::new(value).ends_with("de/oceanlabs/mcp/mcp_config/1.20.1/mcp_config-1.20.1.zip"), + "{value}" + ); + assert!(Path::new(value).is_absolute(), "{value}"); + assert!( + Path::new(value).starts_with(layout.libraries_dir()), + "{value}" + ); + + // 官方 profile 也把坐标直接写在 `args` 里(`--input [de.oceanlabs.mcp:…@zip]`), + // 整段是坐标时同样要展开成路径。 + let arg = variables + .expand("[de.oceanlabs.mcp:mcp_config:1.20.1@zip]") + .expect("整段坐标应能展开"); + assert_eq!(arg, value); +} + +#[test] +fn quoted_literals_lose_the_quotes() { + let root = temp_root("vars-literal"); + let layout = GameLayout::new(&root); + let table = data(&[("SIDE_HINT", "'client'")]); + let variables = build(&layout, &table, &root.join("i.jar"), &root.join("tmp")); + assert_eq!(variables.get("SIDE_HINT"), Some("client")); +} + +#[test] +fn plain_paths_are_extracted_from_the_installer_into_scratch() { + let root = temp_root("vars-embedded"); + let layout = GameLayout::new(&root); + let installer = root.join("installer.jar"); + let scratch = root.join("tmp"); + let table = data(&[("BINPATCH", "/data/client.lzma")]); + let variables = build(&layout, &table, &installer, &scratch); + + let value = variables.get("BINPATCH").expect("应有 BINPATCH"); + assert!(value.ends_with("data-client.lzma"), "{value}"); + assert!( + value.starts_with(&scratch.to_string_lossy().to_string()), + "{value}" + ); + assert_eq!(variables.embedded().len(), 1); + assert_eq!(variables.embedded()[0].entry, "data/client.lzma"); +} + +#[test] +fn server_only_entries_are_ignored_for_client_installs() { + let root = temp_root("vars-server"); + let layout = GameLayout::new(&root); + let variables = build( + &layout, + &profile().data, + &root.join("i.jar"), + &root.join("tmp"), + ); + assert!(variables.get("SERVER_ONLY").is_none()); + assert!(variables.get("MAPPINGS").is_some()); +} + +#[test] +fn standard_variables_are_absolute_and_side_is_client() { + let root = temp_root("vars-standard"); + let layout = GameLayout::new(&root); + let installer = root.join("installer.jar"); + let scratch = root.join("tmp"); + let variables = build(&layout, &BTreeMap::new(), &installer, &scratch); + + assert_eq!(variables.get("SIDE"), Some("client")); + assert_eq!(variables.get("MINECRAFT_VERSION"), Some("1.20.1")); + for key in ["MINECRAFT_JAR", "ROOT", "INSTALLER", "LIBRARY_DIR"] { + let value = variables.get(key).unwrap_or_else(|| panic!("缺 {key}")); + assert!( + Path::new(value).is_absolute(), + "{key} 不是绝对路径:{value}" + ); + } + assert!(variables + .get("MINECRAFT_JAR") + .unwrap_or_default() + .ends_with("minecraft.jar")); +} + +#[test] +fn token_replacement_handles_quotes_and_escapes() { + let root = temp_root("vars-expand"); + let layout = GameLayout::new(&root); + let variables = build( + &layout, + &BTreeMap::new(), + &root.join("i.jar"), + &root.join("tmp"), + ); + + assert_eq!( + variables.expand("--side '{SIDE}'").expect("应能展开"), + "--side {SIDE}" + ); + assert_eq!(variables.expand("a\\{b").expect("应能展开"), "a{b"); + assert_eq!( + variables.expand("--side {SIDE}").expect("应能展开"), + "--side client" + ); +} + +#[test] +fn missing_or_unclosed_placeholders_are_plan_errors() { + let root = temp_root("vars-error"); + let layout = GameLayout::new(&root); + let variables = build( + &layout, + &BTreeMap::new(), + &root.join("i.jar"), + &root.join("tmp"), + ); + + let missing = variables + .expand("--output {NOPE}") + .expect_err("缺变量必须报错"); + assert_eq!(missing.kind(), ProcessorErrorKind::Plan); + assert!(missing.message().contains("NOPE"), "{}", missing.message()); + + let unclosed = variables.expand("{SIDE").expect_err("未闭合必须报错"); + assert_eq!(unclosed.kind(), ProcessorErrorKind::Plan); + + let bad_escape = variables.expand("abc\\").expect_err("悬空转义必须报错"); + assert_eq!(bad_escape.kind(), ProcessorErrorKind::Plan); +} diff --git a/crates/core/src/addon/loader/processors/text.rs b/crates/core/src/addon/loader/processors/text.rs new file mode 100644 index 0000000..bbf387c --- /dev/null +++ b/crates/core/src/addon/loader/processors/text.rs @@ -0,0 +1,71 @@ +//! 进程输出的尾部截取与脱敏。 +//! +//! processor 的 stdout/stderr 是外部程序(而且部分步骤要联网)写的,内容不可控: +//! 可能带绝对路径、用户名、甚至一大段堆栈。进错误消息/`--json` 前必须做两件事: +//! **只留尾巴**(够定位、不灌满日志)与**抹掉主目录**(`C:\Users\<名>` → ``)。 + +use std::path::PathBuf; + +use crate::launch::redact::Redactor; + +/// 尾巴保留的行数(够看异常栈头)。 +const TAIL_LINES: usize = 12; +/// 尾巴保留的字符上限。 +const TAIL_CHARS: usize = 1200; + +/// 宿主脱敏器:把用户主目录换成 ``。 +/// +/// 复用 [`crate::launch::redact::Redactor`](与 M15/M31 落地的同一套规则:token、 +/// ``、长不透明串、带点 token/JWT)。注册 `USERPROFILE`(Windows)/`HOME`(macOS、Linux) +/// 两个来源,因为 MSYS/CI 环境下两者可能只有一个在场。 +#[must_use] +pub fn host_redactor() -> Redactor { + let mut redactor = Redactor::new(); + for key in ["USERPROFILE", "HOME"] { + if let Some(value) = std::env::var_os(key) { + redactor = redactor.with_home(PathBuf::from(value)); + } + } + redactor +} + +/// 取文本尾部(末 [`TAIL_LINES`] 行,再截到 [`TAIL_CHARS`] 字符)。 +#[must_use] +pub fn tail(text: &str) -> String { + let trimmed = text.trim_end(); + if trimmed.is_empty() { + return String::new(); + } + let lines: Vec<&str> = trimmed.lines().collect(); + let start = lines.len().saturating_sub(TAIL_LINES); + let mut joined = lines[start..].join("\n"); + let length = joined.chars().count(); + if length > TAIL_CHARS { + joined = joined.chars().skip(length - TAIL_CHARS).collect(); + joined.insert(0, '…'); + } + joined +} + +/// 空串 → `None`(JSON 里不吐空字段)。 +pub(super) fn non_empty(value: String) -> Option { + (!value.is_empty()).then_some(value) +} + +#[cfg(test)] +mod tests { + use super::tail; + + #[test] + fn tail_keeps_the_last_lines_and_truncates_long_ones() { + let text: String = (1..=40).map(|index| format!("line {index}\n")).collect(); + let tailed = tail(&text); + assert!(tailed.starts_with("line 29"), "{tailed}"); + assert!(tailed.ends_with("line 40")); + let long = "x".repeat(5000); + let tailed = tail(&long); + assert!(tailed.starts_with('…')); + assert_eq!(tailed.chars().count(), 1201); + assert_eq!(tail(" \n"), ""); + } +} diff --git a/crates/core/src/addon/loader/processors/vars.rs b/crates/core/src/addon/loader/processors/vars.rs new file mode 100644 index 0000000..a0b7524 --- /dev/null +++ b/crates/core/src/addon/loader/processors/vars.rs @@ -0,0 +1,324 @@ +//! 占位替换:install profile 的 `data` 表 + 标准变量 → 一张 `{键 → 值}` 表。 +//! +//! 这是 Forge processor 协议里最容易出错的一层,三种字面量语义(照 installer spec, +//! 与 HMCL `ForgeNewInstallTask.parseLiteral` 同构): +//! +//! | 写法 | 含义 | 例 | +//! |------|------|----| +//! | `[group:artifact:version(:classifier)(@ext)]` | maven 构件 → `libraries/` 下的绝对路径 | `[de.oceanlabs.mcp:mcp_config:1.20.1@zip]` | +//! | `'literal'` | 去掉引号的常量 | `'client'` | +//! | 其它 | **installer 里的文件路径** → 解包到临时目录后的绝对路径 | `/data/client.lzma` | +//! +//! 展开(`{KEY}`)的规则与 HMCL 的 `replaceTokens` 一致:`\` 转义下一个字符、 +//! `'…'` 原样输出、`{KEY}` 查表替换(**查不到就报错**,不静默留空——留空会让 +//! processor 拿着半截参数跑出更难查的错)。 +//! +//! # 为什么 `MINECRAFT_JAR` 指向临时目录的副本 +//! +//! HMCL 在起 processor 前把原版 client jar **复制到临时目录**再用,注释写得很直白: +//! `External processors must not receive the shared cache path`。processor 会改写输入的 +//! jar(拆包/打补丁),直接把 `versions//.jar` 递过去等于让外挂程序改共享层。 + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use super::error::ProcessorError; +use super::spec::Datum; +use crate::game::layout::GameLayout; +use crate::game::library::MavenCoordinate; + +/// 临时目录里那份原版 client jar 的文件名。 +pub const MINECRAFT_JAR_NAME: &str = "minecraft.jar"; + +/// 一个需要从 installer zip 里解出来的文件。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmbeddedFile { + /// installer 内的条目路径(`data/client.lzma`)。 + pub entry: String, + /// 解包落点(临时目录下的扁平文件名)。 + pub dest: PathBuf, +} + +/// 变量表的构造输入。 +pub struct VariablesInput<'a> { + /// install profile 的 `data` 表。 + pub data: &'a BTreeMap, + /// 游戏目录布局(`libraries/` 与 `versions/` 的路径规则)。 + pub layout: &'a GameLayout, + /// 目标原版版本(`net.minecraft:client:` 这类坐标回落用)。 + pub mc_version: &'a str, + /// installer jar 的磁盘路径(`{INSTALLER}`,也是解包来源)。 + pub installer: &'a Path, + /// 临时目录(`{MINECRAFT_JAR}` 与解包文件都落这里)。 + pub scratch: &'a Path, +} + +/// 展开后的变量表。 +#[derive(Debug, Clone, Default)] +pub struct Variables { + values: BTreeMap, + embedded: Vec, + /// 参数里 `[坐标]` 的解析上下文(官方 profile 会在 `args` 里直接写坐标,不只写在 `data` 里)。 + artifacts: Option, +} + +/// `[坐标]` 的解析上下文。 +#[derive(Debug, Clone)] +struct ArtifactRoots { + libraries: PathBuf, + vanilla_jar: PathBuf, +} + +impl Variables { + /// 组装变量表:先展开 `data` 表,再注入标准变量。 + /// + /// # Errors + /// + /// 构件坐标不合法、`data` 表里的值是空路径、变量引用缺定义时返回 + /// [`ProcessorErrorKind::Plan`](super::error::ProcessorErrorKind::Plan) 错误。 + pub fn build(input: VariablesInput<'_>) -> Result { + let mut variables = Self { + artifacts: Some(ArtifactRoots { + libraries: input.layout.libraries_dir(), + vanilla_jar: input.layout.version_jar(input.mc_version), + }), + ..Self::default() + }; + for (key, datum) in input.data { + let Some(raw) = datum.client_value() else { + continue; + }; + let value = variables.resolve_literal(raw, &input)?; + variables.values.insert(key.clone(), value); + } + variables.inject_standard(&input); + Ok(variables) + } + + /// 标准变量(installer spec 没有写死,但各家启动器都注入同一套)。 + fn inject_standard(&mut self, input: &VariablesInput<'_>) { + let scratch = absolute(input.scratch); + let standard = [ + ("SIDE", "client".to_string()), + ("MINECRAFT_VERSION", input.mc_version.to_string()), + ( + "MINECRAFT_JAR", + absolute(&scratch.join(MINECRAFT_JAR_NAME)) + .to_string_lossy() + .into_owned(), + ), + ( + "ROOT", + absolute(input.layout.root()).to_string_lossy().into_owned(), + ), + ( + "INSTALLER", + absolute(input.installer).to_string_lossy().into_owned(), + ), + ( + "LIBRARY_DIR", + absolute(&input.layout.libraries_dir()) + .to_string_lossy() + .into_owned(), + ), + ]; + for (key, value) in standard { + self.values.insert(key.to_string(), value); + } + } + + /// 把一个 `data` 表的值展开成实际字符串。 + fn resolve_literal( + &mut self, + raw: &str, + input: &VariablesInput<'_>, + ) -> Result { + let trimmed = raw.trim(); + if let Some(inner) = trim_brackets(trimmed) { + return self.resolve_artifact(inner); + } + if let Some(inner) = trimmed + .strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + { + return Ok(inner.to_string()); + } + if trimmed.starts_with('{') { + // data 表自己引用别的键:官方 profile 不这么写,但展开一次成本为零。 + return self.expand(trimmed); + } + let entry = trimmed.trim_start_matches('/').to_string(); + if entry.is_empty() { + return Err(ProcessorError::plan(format!( + "install profile 的 data 表里有空路径:`{raw}`" + ))); + } + // 其余一律当 installer 内的文件:解到临时目录(执行期才真正解包)。 + let dest = absolute(&absolute(input.scratch).join(embedded_name(&entry))); + self.embedded.push(EmbeddedFile { + entry, + dest: dest.clone(), + }); + Ok(dest.to_string_lossy().into_owned()) + } + + /// `[坐标]` → `libraries/` 下的绝对路径。 + fn resolve_artifact(&self, descriptor: &str) -> Result { + let Some(roots) = &self.artifacts else { + return Err(ProcessorError::plan(format!( + "构件坐标 `{descriptor}` 需要游戏目录上下文才能解析" + ))); + }; + artifact_path(&roots.libraries, &roots.vanilla_jar, descriptor) + } + + /// 展开一段含占位的文本(`{KEY}` / `'字面量'` / `[坐标]` / `\` 转义)。 + /// + /// `[坐标]` 只在**整段就是坐标**时生效(与 HMCL `parseLiteral` 一致): + /// official profile 的 `args` 里常直接写 `--input [de.oceanlabs.mcp:…@zip]`。 + /// + /// # Errors + /// + /// `{KEY}` 在表里查不到、`\` 结尾无字符可转义、`{`/`'` 未闭合、坐标不合法时返回规划错误。 + pub fn expand(&self, pattern: &str) -> Result { + let trimmed = pattern.trim(); + if let Some(inner) = trim_brackets(trimmed) { + return self.resolve_artifact(inner); + } + let mut out = String::with_capacity(pattern.len()); + let mut chars = pattern.chars(); + while let Some(ch) = chars.next() { + match ch { + '\\' => match chars.next() { + Some(next) => out.push(next), + None => { + return Err(ProcessorError::plan(format!( + "占位文本以 `\\` 结尾(无法转义):`{pattern}`" + ))) + } + }, + '{' => { + let key = collect_until(&mut chars, '}', pattern)?; + let value = self.values.get(&key).ok_or_else(|| { + ProcessorError::plan(format!( + "占位 {{{key}}} 在 install profile 的 data 表里没有定义(原文 `{pattern}`)" + )) + })?; + out.push_str(value); + } + '\'' => out.push_str(&collect_until(&mut chars, '\'', pattern)?), + other => out.push(other), + } + } + Ok(out) + } + + /// 某个变量的值。 + #[must_use] + pub fn get(&self, key: &str) -> Option<&str> { + self.values.get(key).map(String::as_str) + } + + /// 需要从 installer 里解包的文件(执行期用)。 + #[must_use] + pub fn embedded(&self) -> &[EmbeddedFile] { + &self.embedded + } + + /// 变量条数(`--dry-run` 只报数量,不吐全部路径)。 + #[must_use] + pub fn len(&self) -> usize { + self.values.len() + } + + /// 是否一条变量都没有(实际不可能:标准变量恒在)。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } +} + +/// 读到 `closing` 为止(跳过 `\` 转义),返回中间文本。 +fn collect_until( + chars: &mut impl Iterator, + closing: char, + pattern: &str, +) -> Result { + let mut out = String::new(); + while let Some(ch) = chars.next() { + if ch == '\\' { + match chars.next() { + Some(next) => out.push(next), + None => { + return Err(ProcessorError::plan(format!( + "占位文本以 `\\` 结尾(无法转义):`{pattern}`" + ))) + } + } + } else if ch == closing { + return Ok(out); + } else { + out.push(ch); + } + } + Err(ProcessorError::plan(format!( + "占位文本缺少收尾的 `{closing}`:`{pattern}`" + ))) +} + +/// 整段是 `[坐标]` 时取出坐标(不到首尾就返回 `None`)。 +fn trim_brackets(raw: &str) -> Option<&str> { + raw.strip_prefix('[') + .and_then(|rest| rest.strip_suffix(']')) +} + +/// `[坐标]` → `libraries/` 下的绝对路径。 +/// +/// 少数坐标进不了 `libraries/`:`net.minecraft:client:`(无 classifier)对应的是 +/// `versions//.jar`。官方 installer 会把 client jar 塞进本地 maven 布局, +/// 我们不复制(磁盘上原版 jar 只有一份),因此按目标版本回落。 +fn artifact_path( + libraries: &Path, + vanilla_jar: &Path, + descriptor: &str, +) -> Result { + let coordinate = MavenCoordinate::parse(descriptor).map_err(|error| { + ProcessorError::plan(format!( + "install profile 里的构件坐标不合法:`{descriptor}`({error})" + )) + })?; + let path = libraries.join(coordinate.relative_path()); + if coordinate.group == "net.minecraft" + && coordinate.artifact == "client" + && coordinate.classifier.is_none() + && vanilla_jar.is_file() + { + return Ok(absolute(vanilla_jar).to_string_lossy().into_owned()); + } + Ok(absolute(&path).to_string_lossy().into_owned()) +} + +/// installer 内的条目 → 临时目录里的扁平文件名(`data/client.lzma` → `data-client.lzma`)。 +/// +/// 压平是为了防路径穿越:`../` 与绝对路径在拼进临时目录时都会失去意义。 +fn embedded_name(entry: &str) -> String { + entry + .chars() + .map(|ch| { + if matches!(ch, '/' | '\\' | ':') { + '-' + } else { + ch + } + }) + .collect() +} + +/// 尽量转绝对路径(相对路径按当前工作目录;失败就原样返回)。 +#[must_use] +pub fn absolute(path: &Path) -> PathBuf { + if path.is_absolute() { + return path.to_path_buf(); + } + std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()) +} diff --git a/crates/core/src/addon/loader/tests/fabric.rs b/crates/core/src/addon/loader/tests/fabric.rs new file mode 100644 index 0000000..ca9a43e --- /dev/null +++ b/crates/core/src/addon/loader/tests/fabric.rs @@ -0,0 +1,135 @@ +//! Fabric / Quilt:profile 归一化的形状对拍。 + +use super::{FABRIC_PROFILE, QUILT_PROFILE}; +use crate::addon::loader::fabric::{build_release, parse_loader_list, pick_default}; +use crate::addon::loader::{LoaderErrorKind, LoaderKind, LoaderSupport}; + +#[test] +fn fabric_profile_becomes_inheriting_loader_version() { + let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE) + .expect("fabric profile 应可归一化"); + + assert_eq!(release.version_id, "1.20.1-fabric-0.15.11"); + let json = &release.version_json; + assert_eq!(json.id.as_deref(), Some("1.20.1-fabric-0.15.11")); + assert_eq!(json.inherits_from.as_deref(), Some("1.20.1")); + // ★ 关键:jar 必须钉成原版 id,否则继承解析会把 jar 兜底成 loader 自身 id。 + assert_eq!(json.jar.as_deref(), Some("1.20.1")); + assert_eq!( + json.main_class.as_deref(), + Some("net.fabricmc.loader.impl.launch.knot.KnotClient") + ); + // jvm 参数(`-DFabricMcEmu`)必须原样保留:老版本启动靠它。 + let jvm = json + .arguments + .as_ref() + .map(|arguments| arguments.jvm.len()) + .unwrap_or_default(); + assert_eq!(jvm, 1, "profile 的 jvm 参数丢了"); + assert_eq!(release.support, LoaderSupport::JsonRoute); + assert!(release.is_installable()); + assert!(release.warnings.is_empty(), "{:?}", release.warnings); +} + +#[test] +fn fabric_flat_digest_is_lifted_into_artifact() { + let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE) + .expect("fabric profile 应可归一化"); + let libraries = release.version_json.libraries.expect("应有库"); + let asm = libraries + .iter() + .find(|library| library.name.starts_with("org.ow2.asm:asm:")) + .expect("应有 asm"); + let artifact = asm + .downloads + .as_ref() + .and_then(|downloads| downloads.artifact.as_ref()) + .expect("扁平 sha1/size 应提升成 downloads.artifact"); + assert_eq!( + artifact.sha1.as_deref(), + Some("aa205cf0a06dbd8e04ece91c0b37c3f5d567546a") + ); + assert_eq!(artifact.size, Some(123_598)); + assert_eq!( + artifact.url.as_deref(), + Some("https://maven.fabricmc.net/org/ow2/asm/asm/9.6/asm-9.6.jar") + ); +} + +#[test] +fn quilt_profile_is_isomorphic() { + let release = build_release(LoaderKind::Quilt, "1.20.1", "0.20.0-beta.9", QUILT_PROFILE) + .expect("quilt profile 应可归一化"); + + assert_eq!(release.version_id, "1.20.1-quilt-0.20.0-beta.9"); + assert_eq!(release.version_json.jar.as_deref(), Some("1.20.1")); + assert_eq!( + release.version_json.main_class.as_deref(), + Some("org.quiltmc.loader.impl.launch.knot.KnotClient") + ); + assert_eq!(release.support, LoaderSupport::JsonRoute); + // Quilt 的 profile 没有 loading 库时不该编造;这里有 quilt-loader,因此无告警。 + assert!(release.warnings.is_empty(), "{:?}", release.warnings); +} + +#[test] +fn missing_main_class_is_metadata_error() { + let profile = r#"{"id":"x","inheritsFrom":"1.20.1","libraries":[]}"#; + let error = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", profile) + .expect_err("缺主类必须报错"); + assert_eq!(error.kind(), LoaderErrorKind::Metadata); +} + +#[test] +fn missing_loader_library_is_appended_with_warning() { + // 去掉 fabric-loader 那条,看归一化会不会补。 + let profile = r#"{ + "id": "fabric-loader-0.15.11-1.20.1", + "inheritsFrom": "1.20.1", + "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", + "libraries": [{"name": "org.ow2.asm:asm:9.6", "url": "https://maven.fabricmc.net/"}] + }"#; + let release = build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", profile) + .expect("缺 loader 库应降级为告警 + 补一条"); + assert_eq!(release.warnings.len(), 1); + let libraries = release.version_json.libraries.expect("应有库"); + assert!(libraries + .iter() + .any(|library| library.name == "net.fabricmc:fabric-loader:0.15.11")); +} + +#[test] +fn loader_list_parses_and_prefers_stable() { + let list = r#"[ + {"loader": {"version": "0.16.0-beta.1", "stable": false, "maven": "net.fabricmc:fabric-loader:0.16.0-beta.1"}}, + {"loader": {"version": "0.15.11", "stable": true, "maven": "net.fabricmc:fabric-loader:0.15.11"}}, + {"loader": {"version": "0.15.10", "stable": true, "maven": "net.fabricmc:fabric-loader:0.15.10"}} + ]"#; + let entries = parse_loader_list(LoaderKind::Fabric, list).expect("列表应可解析"); + assert_eq!(entries.len(), 3); + assert_eq!( + pick_default(&entries).map(|entry| entry.version.as_str()), + Some("0.15.11") + ); +} + +#[test] +fn quilt_loader_list_without_stable_flag_still_picks_first() { + // 实测 Quilt 的 loader 条目没有 stable 键。 + let list = r#"[ + {"loader": {"version": "0.20.0-beta.9", "maven": "org.quiltmc:quilt-loader:0.20.0-beta.9"}}, + {"loader": {"version": "0.19.2", "maven": "org.quiltmc:quilt-loader:0.19.2"}} + ]"#; + let entries = parse_loader_list(LoaderKind::Quilt, list).expect("列表应可解析"); + assert!(entries.iter().all(|entry| entry.stable)); + assert_eq!( + pick_default(&entries).map(|entry| entry.version.as_str()), + Some("0.20.0-beta.9") + ); +} + +#[test] +fn empty_loader_list_is_not_found() { + let error = parse_loader_list(LoaderKind::Fabric, "[]").expect_err("空列表应报错"); + assert_eq!(error.kind(), LoaderErrorKind::NotFound); +} diff --git a/crates/core/src/addon/loader/tests/forge.rs b/crates/core/src/addon/loader/tests/forge.rs new file mode 100644 index 0000000..123c426 --- /dev/null +++ b/crates/core/src/addon/loader/tests/forge.rs @@ -0,0 +1,258 @@ +//! NeoForge / Forge:install profile 解析与三条路线的判定。 + +use crate::addon::loader::forge::{build_release, InstallProfile}; +use crate::addon::loader::{LoaderErrorKind, LoaderKind, LoaderSupport}; +use crate::game::manifest::VersionJson; + +/// spec 1 + client 侧 processor(实测 NeoForge 21.1.80 / Forge 1.20.1 的形状,裁剪过)。 +pub(crate) const NEOFORGE_PROFILE: &str = r#"{ + "spec": 1, + "profile": "NeoForge", + "version": "neoforge-21.1.80", + "minecraft": "1.21.1", + "json": "/version.json", + "path": null, + "processors": [ + {"sides": ["server"], "jar": "net.neoforged.installertools:installertools:2.1.2"}, + {"sides": ["client"], "jar": "net.neoforged.installertools:installertools:2.1.2"}, + {"jar": "net.neoforged.installertools:installertools:2.1.2"}, + {"sides": ["client"], "jar": "net.neoforged.installertools:installertools:2.1.2"} + ], + "libraries": [ + {"name": "com.google.code.gson:gson:2.8.9@jar"}, + {"name": "org.apache.commons:commons-text:1.3@jar"} + ], + "data": {"MC_SLIM": {"client": "[net.neoforged:neoforge:21.1.80:client-slim]"}} +}"#; + +/// spec 1 版本 JSON 的裁剪版(内容形状照 21.1.80)。 +pub(crate) const NEOFORGE_VERSION_JSON: &str = r#"{ + "id": "neoforge-21.1.80", + "inheritsFrom": "1.21.1", + "type": "release", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "arguments": {"game": ["--launchTarget", "forgeclient"], "jvm": ["-DignoreList=bootstraplauncher"]}, + "libraries": [ + {"name": "cpw.mods:securejarhandler:2.1.10", + "downloads": {"artifact": {"path": "cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar", + "url": "https://maven.neoforged.net/releases/cpw/mods/securejarhandler/2.1.10/securejarhandler-2.1.10.jar", + "sha1": "51e6a22c6c716beb11e244bf5b8be480f51dd6b5", "size": 88749}}} + ] +}"#; + +/// spec 0 + 无 processors(实测 Forge 1.12.2-14.23.5.2860),universal jar 的 url 是空串。 +pub(crate) const FORGE_LEGACY_PROFILE: &str = r#"{ + "spec": 0, + "profile": "forge", + "version": "1.12.2-forge-14.23.5.2860", + "minecraft": "1.12.2", + "json": "/version.json", + "path": "net.minecraftforge:forge:1.12.2-14.23.5.2860", + "processors": [], + "libraries": [{"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860"}] +}"#; + +pub(crate) const FORGE_LEGACY_VERSION_JSON: &str = r#"{ + "id": "1.12.2-forge-14.23.5.2860", + "inheritsFrom": "1.12.2", + "mainClass": "net.minecraft.launchwrapper.Launch", + "minecraftArguments": "--username ${auth_player_name} --tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker", + "libraries": [ + {"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860", + "downloads": {"artifact": {"path": "net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar", + "url": "", "sha1": "029250575d3aa2cf80b56dffb66238a1eeaea2ac", "size": 4466148}}} + ] +}"#; + +fn profile(json: &str) -> InstallProfile { + serde_json::from_str(json).expect("夹具 profile 应可解析") +} + +fn version_json(json: &str) -> VersionJson { + VersionJson::from_json_str(json).expect("夹具版本 JSON 应可解析") +} + +#[test] +fn client_processors_mark_version_as_installer_required() { + let release = build_release( + LoaderKind::NeoForge, + "1.21.1", + "21.1.80", + &profile(NEOFORGE_PROFILE), + Some(&version_json(NEOFORGE_VERSION_JSON)), + ) + .expect("版本 JSON 齐备时应能生成清单"); + + // 2 条 sides 含 client + 1 条无 sides(两侧都要)= 3 条 client 侧。 + assert_eq!(profile(NEOFORGE_PROFILE).client_processors(), 3); + // M35 起 RequiresInstaller 是可真装的路线(外调 java 跑 processors), + // 但"要跑 installer"这个事实仍要能被上层看见。 + assert!(release.is_installable()); + assert!(release.requires_installer()); + let reason = release + .installer_reason() + .expect("应给需要 installer 的原因"); + assert!(reason.contains("3 个 client 侧 processor"), "{reason}"); + // 版本清单本身仍然正确生成(--dry-run 能看到它)。 + assert_eq!(release.version_id, "1.21.1-neoforge-21.1.80"); + assert_eq!( + release.version_json.inherits_from.as_deref(), + Some("1.21.1") + ); + assert_eq!(release.version_json.jar.as_deref(), Some("1.21.1")); + // processor 依赖不进启动 classpath,只记数量进告警。 + assert_eq!(release.version_json.libraries.map_or(0, |l| l.len()), 1); + assert!(release + .warnings + .iter() + .any(|warning| warning.contains("processor 依赖"))); +} + +#[test] +fn spec_zero_profile_is_json_route_and_fills_empty_url() { + let release = build_release( + LoaderKind::Forge, + "1.12.2", + "1.12.2-14.23.5.2860", + &profile(FORGE_LEGACY_PROFILE), + Some(&version_json(FORGE_LEGACY_VERSION_JSON)), + ) + .expect("spec 0 应走 json 路线"); + + assert_eq!(release.support, LoaderSupport::JsonRoute); + assert_eq!(release.loader_version, "14.23.5.2860"); + assert_eq!(release.version_id, "1.12.2-forge-14.23.5.2860"); + assert_eq!( + release.version_json.main_class.as_deref(), + Some("net.minecraft.launchwrapper.Launch") + ); + + // 空 url 必须补成 maven 地址:本体 jar 在官方 maven 上**只发布在 -universal classifier 下** + // (无 classifier 路径官方 404,仅靠 BMCLAPI 302 别名兜底),所以 URL 要带 -universal。 + let library = release + .version_json + .libraries + .as_ref() + .and_then(|libraries| libraries.first()) + .expect("应有 forge 本体库"); + let artifact = library + .downloads + .as_ref() + .and_then(|downloads| downloads.artifact.as_ref()) + .expect("应有 artifact"); + assert_eq!( + artifact.url.as_deref(), + Some( + "https://maven.minecraftforge.net/net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860-universal.jar" + ), + "URL 必须带 -universal(官方 maven 只托管这个 classifier)" + ); + assert_eq!(artifact.size, Some(4_466_148)); + assert!(library + .url + .as_deref() + .unwrap_or_default() + .contains("minecraftforge")); + + // 磁盘落点仍用原 path(与官方 installer 内嵌条目同名,启动 classpath 布局不变)。 + assert_eq!( + artifact.path.as_deref(), + Some("net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar") + ); + let dest = library.relative_path().expect("落点应可推导"); + assert!( + dest.ends_with("forge-1.12.2-14.23.5.2860.jar"), + "落点不该带 classifier:{}", + dest.display() + ); +} + +#[test] +fn legacy_inline_protocol_is_unsupported() { + // 老协议:没有独立 version.json,只有内联 versionInfo + 解包信息。 + let profile = profile( + r#"{"spec": 1, "minecraft": "1.7.10", + "install": {"filePath": "forge-1.7.10-10.13.4.1614-universal.jar"}, + "versionInfo": {"id": "1.7.10-Forge10.13.4.1614", "mainClass": "net.minecraft.launchwrapper.Launch"}, + "processors": []}"#, + ); + assert!(profile.is_legacy_protocol()); + let error = build_release( + LoaderKind::Forge, + "1.7.10", + "1.7.10-10.13.4.1614", + &profile, + None, + ) + .expect_err("老协议必须明确 unsupported"); + assert_eq!(error.kind(), LoaderErrorKind::Unsupported); + assert!(error.message().contains("老协议"), "{}", error.message()); + assert!(error.hint().is_some()); +} + +#[test] +fn universal_classifier_rewrite_is_scoped_to_the_installer_body() { + // 只有 install profile 的 `path` 声明的本体构件走 -universal; + // 其它同样"空 url + 有 path"的库必须保持原路径(乱加 classifier 会 404)。 + let embedded = version_json( + r#"{"id": "1.12.2-forge-14.23.5.2860", "inheritsFrom": "1.12.2", + "mainClass": "net.minecraft.launchwrapper.Launch", + "libraries": [ + {"name": "net.minecraftforge:forge:1.12.2-14.23.5.2860", + "downloads": {"artifact": {"path": "net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860.jar", "url": ""}}}, + {"name": "net.minecraftforge:mergetool:1.1.5", + "downloads": {"artifact": {"path": "net/minecraftforge/mergetool/1.1.5/mergetool-1.1.5.jar", "url": ""}}} + ]}"#, + ); + let release = build_release( + LoaderKind::Forge, + "1.12.2", + "1.12.2-14.23.5.2860", + &profile(FORGE_LEGACY_PROFILE), + Some(&embedded), + ) + .expect("spec 0 应走 json 路线"); + let libraries = release.version_json.libraries.expect("应有库"); + let url_of = |name: &str| { + libraries + .iter() + .find(|library| library.name == name) + .and_then(|library| library.downloads.as_ref()) + .and_then(|downloads| downloads.artifact.as_ref()) + .and_then(|artifact| artifact.url.clone()) + .unwrap_or_default() + }; + assert!(url_of("net.minecraftforge:forge:1.12.2-14.23.5.2860").ends_with("-universal.jar")); + assert_eq!( + url_of("net.minecraftforge:mergetool:1.1.5"), + "https://maven.minecraftforge.net/net/minecraftforge/mergetool/1.1.5/mergetool-1.1.5.jar" + ); +} + +#[test] +fn version_mismatch_between_installer_and_target_is_metadata_error() { + let error = build_release( + LoaderKind::NeoForge, + "1.20.1", + "21.1.80", + &profile(NEOFORGE_PROFILE), + Some(&version_json(NEOFORGE_VERSION_JSON)), + ) + .expect_err("原版版本不符必须报错"); + assert_eq!(error.kind(), LoaderErrorKind::Metadata); + assert!(error.message().contains("1.21.1"), "{}", error.message()); +} + +#[test] +fn missing_main_class_is_metadata_error() { + let embedded = version_json(r#"{"id": "x", "inheritsFrom": "1.12.2", "libraries": []}"#); + let error = build_release( + LoaderKind::Forge, + "1.12.2", + "1.12.2-14.23.5.2860", + &profile(FORGE_LEGACY_PROFILE), + Some(&embedded), + ) + .expect_err("缺主类必须报错"); + assert_eq!(error.kind(), LoaderErrorKind::Metadata); +} diff --git a/crates/core/src/addon/loader/tests/maven.rs b/crates/core/src/addon/loader/tests/maven.rs new file mode 100644 index 0000000..2d30b12 --- /dev/null +++ b/crates/core/src/addon/loader/tests/maven.rs @@ -0,0 +1,134 @@ +//! maven 版本表:XML 扫描、NeoForge 版本反解、版本挑选、installer 地址。 + +use crate::addon::loader::maven::{ + display_loader_version, installer_url, matches_mc, metadata_url, neoforge_mc_version, + normalize_maven_version, parse_metadata_versions, select_versions, +}; +use crate::addon::loader::LoaderKind; + +/// 裁剪过的 maven-metadata.xml(真实结构,只留 `` 列表)。 +const METADATA: &str = r#" + + net.neoforged + neoforge + + 26.1.2.125 + + 20.4.237 + 21.1.9 + 21.1.80 + 26.1.2.125 + 0.0.1-snapshot-1 + + +"#; + +#[test] +fn metadata_versions_are_scanned() { + let versions = parse_metadata_versions(METADATA); + assert_eq!( + versions, + vec![ + "20.4.237", + "21.1.9", + "21.1.80", + "26.1.2.125", + "0.0.1-snapshot-1" + ] + ); + assert!(parse_metadata_versions("not xml").is_empty()); +} + +#[test] +fn neoforge_versions_map_back_to_minecraft() { + // analyse/05 §1.3 的三条规则。 + assert_eq!(neoforge_mc_version("21.1.80").as_deref(), Some("1.21.1")); + assert_eq!(neoforge_mc_version("20.4.237").as_deref(), Some("1.20.4")); + assert_eq!(neoforge_mc_version("26.1.2.125").as_deref(), Some("26.1")); + // 快照(主版本 0)不挂原版版本号。 + assert_eq!(neoforge_mc_version("0.0.1-snapshot-1"), None); +} + +#[test] +fn select_versions_filters_and_sorts_numerically() { + let versions = parse_metadata_versions(METADATA); + let picked = select_versions(LoaderKind::NeoForge, "1.21.1", &versions); + // 数字序:9 < 80(字符串序会反过来)。 + assert_eq!(picked, vec!["21.1.9", "21.1.80"]); + assert!(select_versions(LoaderKind::NeoForge, "1.19.2", &versions).is_empty()); +} + +#[test] +fn forge_versions_carry_the_minecraft_prefix() { + let versions = vec![ + "1.20.1-47.3.0".to_string(), + "1.20.1-47.2.0".to_string(), + "1.12.2-14.23.5.2860".to_string(), + ]; + let picked = select_versions(LoaderKind::Forge, "1.20.1", &versions); + assert_eq!(picked, vec!["1.20.1-47.2.0", "1.20.1-47.3.0"]); + assert!(matches_mc(LoaderKind::Forge, "1.20.1", "1.20.1-47.3.0")); + assert!(!matches_mc(LoaderKind::Forge, "1.20.2", "1.20.1-47.3.0")); +} + +#[test] +fn neoforge_1201_uses_forge_layout() { + // 1.20.1 的 NeoForge 早产版挂在 net/neoforged/forge 下,版本串带原版前缀。 + assert!(metadata_url(LoaderKind::NeoForge, "1.20.1") + .ends_with("net/neoforged/forge/maven-metadata.xml")); + assert!(metadata_url(LoaderKind::NeoForge, "1.21.1") + .ends_with("net/neoforged/neoforge/maven-metadata.xml")); + assert!(metadata_url(LoaderKind::Forge, "1.20.1") + .ends_with("net/minecraftforge/forge/maven-metadata.xml")); + + let versions = vec!["1.20.1-47.1.106".to_string(), "1.20.1-47.1.90".to_string()]; + let picked = select_versions(LoaderKind::NeoForge, "1.20.1", &versions); + assert_eq!(picked.len(), 2); + assert_eq!( + display_loader_version(LoaderKind::NeoForge, "1.20.1", "1.20.1-47.1.106"), + "47.1.106" + ); +} + +#[test] +fn versions_are_normalized_and_displayed() { + // Forge:用户给 "47.2.0",maven 上是 "1.20.1-47.2.0"。 + assert_eq!( + normalize_maven_version(LoaderKind::Forge, "1.20.1", "47.2.0"), + "1.20.1-47.2.0" + ); + assert_eq!( + normalize_maven_version(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"), + "1.20.1-47.2.0" + ); + assert_eq!( + display_loader_version(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"), + "47.2.0" + ); + // NeoForge(非 1.20.1 布局)本身就是完整 maven 版本。 + assert_eq!( + normalize_maven_version(LoaderKind::NeoForge, "1.21.1", "21.1.80"), + "21.1.80" + ); + assert_eq!( + display_loader_version(LoaderKind::NeoForge, "1.21.1", "21.1.80"), + "21.1.80" + ); +} + +#[test] +fn installer_urls_point_at_official_maven() { + assert_eq!( + installer_url(LoaderKind::Forge, "1.20.1", "1.20.1-47.2.0"), + "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar" + ); + assert_eq!( + installer_url(LoaderKind::NeoForge, "1.21.1", "21.1.80"), + "https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.80/neoforge-21.1.80-installer.jar" + ); + // 1.20.1 的 NeoForge 走 forge 布局(文件名也是 forge-)。 + assert_eq!( + installer_url(LoaderKind::NeoForge, "1.20.1", "1.20.1-47.1.106"), + "https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar" + ); +} diff --git a/crates/core/src/addon/loader/tests/mod.rs b/crates/core/src/addon/loader/tests/mod.rs new file mode 100644 index 0000000..4d2f93f --- /dev/null +++ b/crates/core/src/addon/loader/tests/mod.rs @@ -0,0 +1,56 @@ +//! `addon::loader` 的单元测试:离线夹具对拍 + 镜像选择 + unsupported 判定。 +//! +//! 刻意**不打真实网络**:JSON 归一化、版本表挑选、镜像候选、计划形状全是纯计算。 +//! 夹具是官方响应的**裁剪版**(保留形状与关键字段,去掉几百行库与 base64 图标)。 + +mod fabric; +mod forge; +mod maven; +mod plan; + +/// 裁剪过的 Fabric `/profile/json`(1.20.1 + fabric-loader 0.15.11)。 +pub(crate) const FABRIC_PROFILE: &str = r#"{ + "id": "fabric-loader-0.15.11-1.20.1", + "inheritsFrom": "1.20.1", + "releaseTime": "2026-10-29T10:25:18+0000", + "time": "2026-10-29T10:25:18+0000", + "type": "release", + "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", + "arguments": {"game": [], "jvm": ["-DFabricMcEmu= net.minecraft.client.main.Main "]}, + "libraries": [ + {"name": "org.ow2.asm:asm:9.6", "url": "https://maven.fabricmc.net/", + "sha1": "aa205cf0a06dbd8e04ece91c0b37c3f5d567546a", "size": 123598}, + {"name": "net.fabricmc:sponge-mixin:0.13.3+mixin.0.8.5", "url": "https://maven.fabricmc.net/"}, + {"name": "net.fabricmc:intermediary:1.20.1", "url": "https://maven.fabricmc.net/"}, + {"name": "net.fabricmc:fabric-loader:0.15.11", "url": "https://maven.fabricmc.net/"} + ] +}"#; + +/// 裁剪过的 Quilt `/profile/json`(1.20.1 + quilt-loader 0.20.0-beta.9)。 +pub(crate) const QUILT_PROFILE: &str = r#"{ + "id": "quilt-loader-0.20.0-beta.9-1.20.1", + "inheritsFrom": "1.20.1", + "type": "release", + "mainClass": "org.quiltmc.loader.impl.launch.knot.KnotClient", + "arguments": {"game": []}, + "libraries": [ + {"name": "net.fabricmc:tiny-mappings-parser:0.3.0+build.17", "url": "https://maven.fabricmc.net/"}, + {"name": "org.quiltmc:quilt-json5:1.0.4+final", "url": "https://maven.quiltmc.org/repository/release/"}, + {"name": "org.quiltmc:quilt-loader:0.20.0-beta.9", "url": "https://maven.quiltmc.org/repository/release/"} + ] +}"#; + +/// 原版 1.20.1 的**已裁剪**版本 JSON(继承链解析的父层)。 +pub(crate) const VANILLA_1201: &str = r#"{ + "id": "1.20.1", + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "assets": "5", + "assetIndex": {"id": "5", "sha1": "0000", "size": 1, + "url": "https://piston-meta.mojang.com/v1/packages/5.json"}, + "downloads": {"client": {"sha1": "1111", "size": 2, + "url": "https://piston-data.mojang.com/v1/objects/1/client.jar"}}, + "libraries": [ + {"name": "com.mojang:brigadier:1.0.18", "url": "https://libraries.minecraft.net/"} + ] +}"#; diff --git a/crates/core/src/addon/loader/tests/plan.rs b/crates/core/src/addon/loader/tests/plan.rs new file mode 100644 index 0000000..fbe193e --- /dev/null +++ b/crates/core/src/addon/loader/tests/plan.rs @@ -0,0 +1,192 @@ +//! 计划形状 / 继承链两级解析 / 镜像选择。 + +use std::collections::BTreeMap; + +use super::{FABRIC_PROFILE, VANILLA_1201}; +use crate::addon::loader::fabric::{build_release, loader_list_url, profile_url}; +use crate::addon::loader::maven; +use crate::addon::loader::{ + LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease, LoaderSupport, +}; +use crate::download::mirrors::MirrorTable; +use crate::download::region::RegionProfile; +use crate::download::types::ResourceKind; +use crate::game::arguments::Environment; +use crate::game::install::FileRole; +use crate::game::{inherit, layout::GameLayout, VersionJson}; + +fn fabric_release() -> LoaderRelease { + build_release(LoaderKind::Fabric, "1.20.1", "0.15.11", FABRIC_PROFILE).expect("夹具应可归一化") +} + +fn environment() -> Environment { + Environment { + os: "windows".to_string(), + os_version: String::new(), + arch: "x86_64".to_string(), + ..Environment::host() + } +} + +#[test] +fn plan_contains_only_loader_libraries() { + let layout = GameLayout::new("/g/.minecraft"); + let release = fabric_release(); + let plan = LoaderInstallPlan::build(LoaderPlanInput { + layout: &layout, + release: &release, + environment: &environment(), + vanilla_installed: false, + }); + + assert_eq!(plan.install.version_id, "1.20.1-fabric-0.15.11"); + // jar 与资源归原版层,loader 计划里只有库。 + assert_eq!(plan.install.jar_id, "1.20.1"); + assert!(plan + .files() + .iter() + .all(|file| file.role == FileRole::Library)); + assert!( + plan.files() + .iter() + .any(|file| file.name.contains("fabric-loader")), + "缺 fabric-loader:{:?}", + plan.files().iter().map(|f| &f.name).collect::>() + ); + assert!(plan.install.version_json_path.ends_with( + std::path::Path::new("versions") + .join("1.20.1-fabric-0.15.11") + .join("1.20.1-fabric-0.15.11.json") + )); + // 只下 loader 那几个库,不该冒出资产/客户端 jar。 + assert!(plan.install.file_count() >= 4); + assert!(plan.install.client_jar().is_none()); +} + +#[test] +fn plan_marks_missing_vanilla_and_unsupported() { + let layout = GameLayout::new("/g/.minecraft"); + let release = fabric_release(); + let plan = LoaderInstallPlan::build(LoaderPlanInput { + layout: &layout, + release: &release, + environment: &environment(), + vanilla_installed: false, + }); + assert!(plan.vanilla_install_needed); + assert!(plan + .warnings() + .iter() + .any(|warning| warning.contains("原版 1.20.1 尚未安装"))); + + let installed = LoaderInstallPlan::build(LoaderPlanInput { + layout: &layout, + release: &release, + environment: &environment(), + vanilla_installed: true, + }); + assert!(!installed.vanilla_install_needed); + + // 把 support 改成"需要 installer",计划里要出现"要外调 java"的告警(M35 起可真装)。 + let mut blocked = fabric_release(); + blocked.support = LoaderSupport::RequiresInstaller { + reason: "需要 processors".to_string(), + }; + let plan = LoaderInstallPlan::build(LoaderPlanInput { + layout: &layout, + release: &blocked, + environment: &environment(), + vanilla_installed: true, + }); + assert!(plan + .warnings() + .iter() + .any(|warning| warning.contains("需要外调 java 跑 installer processors"))); +} + +#[test] +fn summary_json_carries_loader_block() { + let layout = GameLayout::new("/g/.minecraft"); + let release = fabric_release(); + let plan = LoaderInstallPlan::build(LoaderPlanInput { + layout: &layout, + release: &release, + environment: &environment(), + vanilla_installed: true, + }); + let summary = plan.summary_json(); + assert_eq!(summary["loader"]["kind"], "fabric"); + assert_eq!(summary["loader"]["version_id"], "1.20.1-fabric-0.15.11"); + assert_eq!(summary["loader"]["support"]["mode"], "json_route"); + assert_eq!(summary["loader"]["inherits_from"], "1.20.1"); + assert_eq!(summary["vanilla_install_needed"], false); +} + +#[test] +fn loader_json_resolves_two_level_chain() { + // loader → 原版 两级继承:解析后必须是扁平的、jar 指原版、库两侧合并。 + let vanilla = VersionJson::from_json_str(VANILLA_1201).expect("原版夹具"); + let loader = fabric_release().version_json; + let mut table: BTreeMap = BTreeMap::new(); + table.insert("1.20.1".to_string(), vanilla); + table.insert("1.20.1-fabric-0.15.11".to_string(), loader.clone()); + + let resolved = inherit::resolve(&table, &loader).expect("两级链应可解析"); + assert!(resolved.is_resolved()); + assert_eq!(resolved.inherits_from, None); + assert_eq!(resolved.jar.as_deref(), Some("1.20.1")); + assert_eq!( + resolved.main_class.as_deref(), + Some("net.fabricmc.loader.impl.launch.knot.KnotClient") + ); + // 资源来自原版层。 + assert_eq!(resolved.assets.as_deref(), Some("5")); + assert!(resolved.client_jar().is_some()); + // 库两层的并集(父在前,子在后)。 + let libraries = resolved.libraries.expect("应有库"); + assert_eq!(libraries.len(), 5); + assert_eq!(libraries[0].name, "com.mojang:brigadier:1.0.18"); +} + +#[test] +fn loader_meta_and_maven_urls_hit_bmclapi_mirrors() { + let table = MirrorTable::builtin(); + let mirror_first = RegionProfile::mirror_first(0); + + let fabric_meta = loader_list_url(LoaderKind::Fabric, "1.20.1"); + let candidates = table.expand(&fabric_meta, ResourceKind::VersionManifest, &mirror_first); + let sources: Vec<&str> = candidates.iter().map(|c| c.source.as_str()).collect(); + assert_eq!( + sources.first(), + Some(&"bmclapi"), + "镜像优先时应先走 BMCLAPI" + ); + assert!(candidates.iter().any(|candidate| candidate + .url + .contains("/fabric-meta/v2/versions/loader/1.20.1"))); + + let quilt_profile = profile_url(LoaderKind::Quilt, "1.20.1", "0.20.0-beta.9"); + let candidates = table.expand(&quilt_profile, ResourceKind::VersionManifest, &mirror_first); + assert_eq!(candidates[0].source, "bmclapi"); + assert!(candidates[0].url.contains("/quilt-meta/")); + + let neoforge_installer = maven::installer_url(LoaderKind::NeoForge, "1.21.1", "21.1.80"); + let candidates = table.expand(&neoforge_installer, ResourceKind::Library, &mirror_first); + assert_eq!(candidates[0].source, "bmclapi"); + assert!(candidates[0] + .url + .starts_with("https://bmclapi2.bangbang93.com/maven/net/neoforged/neoforge/")); + + let forge_meta = maven::metadata_url(LoaderKind::Forge, "1.20.1"); + let candidates = table.expand(&forge_meta, ResourceKind::VersionManifest, &mirror_first); + assert_eq!(candidates[0].source, "bmclapi"); + assert!(candidates[0] + .url + .starts_with("https://bmclapi2.bangbang93.com/maven/net/minecraftforge/forge/")); + + // 直连模式:官方在前,镜像只兜底。 + let direct = RegionProfile::direct(0); + let candidates = table.expand(&fabric_meta, ResourceKind::VersionManifest, &direct); + assert_eq!(candidates[0].source, "official"); + assert_eq!(candidates[0].url, fabric_meta); +} diff --git a/crates/core/src/addon/mod.rs b/crates/core/src/addon/mod.rs new file mode 100644 index 0000000..8cb22d4 --- /dev/null +++ b/crates/core/src/addon/mod.rs @@ -0,0 +1,19 @@ +//! Mod 与整合包:加载器安装(Fabric/Quilt/NeoForge/Forge)、整合包双格式导入导出、 +//! 本地 Mod 管理、CurseForge / Modrinth 客户端。 +//! +//! 设计定稿:`DevDocs/PlanDoc/08-mod-and-recommend.md`(决策 D10)。 +//! 安全要点:整合包路径穿越双检;AI 推荐走「结构化过滤在前 + LLM 排序在后 + 来源链接防幻觉」。 +//! +//! # 当前公共面 +//! +//! - [`loader`](P2 · M32):加载器安装——官方元数据 → 带 `inheritsFrom` 的 loader 版本 JSON +//! → 补库清单,落盘与实例登记复用 `core::game::install`(M27)。 +//! +//! 规划中:整合包导入导出(D10 §2)、本地 mod 管理(§3)、CF/Modrinth 客户端(§5)。 + +pub mod loader; + +pub use loader::{ + LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease, + LoaderSource, LoaderSupport, +}; diff --git a/crates/core/src/auth/account.rs b/crates/core/src/auth/account.rs new file mode 100644 index 0000000..3aff5fc --- /dev/null +++ b/crates/core/src/auth/account.rs @@ -0,0 +1,339 @@ +//! 账户模型:**条目 id 与 profileID 解耦**的三轨统一抽象。 +//! +//! 承 `analyse/04` §2.3 / §3.2 / §12.1(1) 的三条硬教训: +//! 1. 条目 id(`account:<32hex>`)**不得**等于 profileID / 登录名 / 服务器 URL—— +//! 这些值可能被多个条目共享(同一角色导入两次、服务器换域名); +//! 2. **元数据 / 凭据双写分离**:`AccountMeta` 只装「能公开」的字段(列表渲染不需要解密); +//! 3. 反序列化**允许不完整对象**(`profile_name` 可空 = 「有档案但名字待刷新」), +//! 补齐推迟到登录时——不能因为名字未知就整条丢弃。 + +use serde::{Deserialize, Serialize}; + +use crate::auth::authlib::AuthlibSession; +use crate::auth::error::{AuthError, AuthResult}; +use crate::auth::microsoft::MicrosoftSession; +use crate::auth::secret::random_hex32; + +/// 条目 id 前缀(防被误当 UUID;承 HMCL `AccountID.PREFIX`)。 +pub const ACCOUNT_ID_PREFIX: &str = "account:"; + +/// 账户条目 id:`account:<32 位十六进制>`。 +/// +/// 与 profileID 无关:改服务器地址、改玩家名、同角色多条目都不影响它。 +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct AccountId(String); + +impl AccountId { + /// 生成一个新条目 id。 + #[must_use] + pub fn generate() -> Self { + Self(format!("{ACCOUNT_ID_PREFIX}{}", random_hex32())) + } + + /// 从磁盘字符串解析(宽容:坏数据返回 `None`,不 panic)。 + #[must_use] + pub fn parse(raw: &str) -> Option { + let body = raw.strip_prefix(ACCOUNT_ID_PREFIX)?; + if body.len() != 32 || !body.chars().all(|ch| ch.is_ascii_hexdigit()) { + return None; + } + Some(Self(raw.to_ascii_lowercase())) + } + + /// 字符串视图。 + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for AccountId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +/// 三条轨道。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AccountKind { + /// 离线(本地生成的 UUID,不联网)。 + Offline, + /// 微软(device-code)。 + Microsoft, + /// authlib-injector 第三方认证服务器(标准 Yggdrasil 协议)。 + AuthlibInjector, +} + +impl AccountKind { + /// 落盘用的稳定名(承 HMCL 的 `offline` / `microsoft` / `authlibInjector`)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Offline => "offline", + Self::Microsoft => "microsoft", + Self::AuthlibInjector => "authlibInjector", + } + } + + /// 解析(未知值返回 `None`,让上层跳过该条记录)。 + #[must_use] + pub fn parse(raw: &str) -> Option { + match raw { + "offline" => Some(Self::Offline), + "microsoft" => Some(Self::Microsoft), + // 兼容两种写法:HMCL 用 camelCase,别的地方有人写 kebab。 + "authlibInjector" | "authlib-injector" => Some(Self::AuthlibInjector), + _ => None, + } + } + + /// 人读标签(CLI/JSON 展示用)。 + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Offline => "离线", + Self::Microsoft => "微软", + Self::AuthlibInjector => "authlib-injector", + } + } +} + +/// 账户元数据(**可公开**,不含任何凭据)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountMeta { + /// 条目 id。 + pub id: AccountId, + /// 轨道。 + pub kind: AccountKind, + /// Minecraft 档案 UUID(32 位无连字符)。**不可变**。 + pub profile_id: String, + /// 展示名/玩家名;空串 = 「有档案但名字待刷新」(合法中间态)。 + #[serde(default)] + pub profile_name: String, + /// 登录名(authlib 轨的邮箱/用户名;其余轨为 `None`)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub login_name: Option, + /// authlib 认证服务器 API 根(其余轨为 `None`;凭据之外唯一需要持久化的服务器信息)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_url: Option, + /// 创建时刻(Unix 毫秒;仅用于排序/展示)。 + pub created_at_ms: u64, +} + +impl AccountMeta { + /// 构造一条元数据(`profile_name` 可后补)。 + #[must_use] + pub fn new( + kind: AccountKind, + profile_id: impl Into, + profile_name: impl Into, + created_at_ms: u64, + ) -> Self { + Self { + id: AccountId::generate(), + kind, + profile_id: profile_id.into(), + profile_name: profile_name.into(), + login_name: None, + server_url: None, + created_at_ms, + } + } + + /// 展示名:优先玩家名,其次登录名,最后条目 id(永不 panic、永不空串)。 + #[must_use] + pub fn display_name(&self) -> &str { + if !self.profile_name.trim().is_empty() { + return &self.profile_name; + } + if let Some(login_name) = &self.login_name { + if !login_name.trim().is_empty() { + return login_name; + } + } + self.id.as_str() + } + + /// 该条目是否匹配一个查找键(名字 / 登录名 / 条目 id)。 + /// + /// 大小写不敏感:玩家名在 MC 里大小写不敏感,CLI 用户不该为此折腾。 + #[must_use] + pub fn matches(&self, key: &str) -> bool { + let key = key.trim(); + if key.is_empty() { + return false; + } + self.profile_name.eq_ignore_ascii_case(key) + || self + .login_name + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case(key)) + || self.id.as_str().eq_ignore_ascii_case(key) + } + + /// 该条目的凭据是否与轨道匹配(防止磁盘上元数据被换成另一轨)。 + /// + /// # Errors + /// + /// 轨道与凭据种类不一致时返回 [`AuthError::Malformed`]。 + pub fn check_credentials(&self, credentials: &Credentials) -> AuthResult<()> { + if credentials.kind() == self.kind { + return Ok(()); + } + Err(AuthError::Malformed(format!( + "账户 {} 的元数据轨道是 {},凭据却是 {}", + self.id, + self.kind.as_str(), + credentials.kind().as_str() + ))) + } + + /// 从 JSON 值宽容解析(单条坏记录只返回 `Err`,由调用方跳过并记 warning)。 + /// + /// # Errors + /// + /// 缺字段/类型不符/轨道未知时返回 [`AuthError::Malformed`]。 + pub fn from_value(value: &serde_json::Value) -> AuthResult { + serde_json::from_value(value.clone()) + .map_err(|error| AuthError::Malformed(format!("账户元数据不合法:{error}"))) + } +} + +/// 凭据载荷(**私有**,只落 `credentials.dat`,永不进列表/日志)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Credentials { + /// 离线账户没有凭据(accessToken 每次登录随机生成,不落盘)。 + Offline, + /// 微软会话(access + refresh + `notAfter`)。 + Microsoft(MicrosoftSession), + /// authlib 会话(accessToken + clientToken)。 + AuthlibInjector(AuthlibSession), +} + +impl Credentials { + /// 所属轨道。 + #[must_use] + pub const fn kind(&self) -> AccountKind { + match self { + Self::Offline => AccountKind::Offline, + Self::Microsoft(_) => AccountKind::Microsoft, + Self::AuthlibInjector(_) => AccountKind::AuthlibInjector, + } + } + + /// 该凭据里的所有明文敏感值(武装脱敏器用;离线轨为空)。 + #[must_use] + pub fn secrets(&self) -> Vec<&str> { + match self { + Self::Offline => Vec::new(), + Self::Microsoft(session) => session + .secrets() + .into_iter() + .map(|secret| secret.expose()) + .collect(), + Self::AuthlibInjector(session) => session + .secrets() + .into_iter() + .map(|secret| secret.expose()) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::{AccountId, AccountKind, AccountMeta, Credentials}; + use crate::auth::microsoft::MicrosoftSession; + use crate::auth::secret::Secret; + + #[test] + fn account_id_shape_and_parse() { + let id = AccountId::generate(); + assert!(id.as_str().starts_with("account:")); + assert_eq!(id.as_str().len(), "account:".len() + 32); + assert_eq!(AccountId::parse(id.as_str()), Some(id.clone())); + assert_eq!(AccountId::parse("account:xyz"), None); + assert_eq!(AccountId::parse("0192-blah"), None); + } + + #[test] + fn kind_roundtrip() { + for kind in [ + AccountKind::Offline, + AccountKind::Microsoft, + AccountKind::AuthlibInjector, + ] { + assert_eq!(AccountKind::parse(kind.as_str()), Some(kind)); + assert!(!kind.label().is_empty()); + } + assert_eq!(AccountKind::parse("nope"), None); + } + + #[test] + fn meta_matches_name_login_and_id() { + let mut meta = AccountMeta::new(AccountKind::Offline, "uuid32", "Steve", 1); + meta.login_name = Some("steve@example.com".to_string()); + assert!(meta.matches("Steve")); + assert!(meta.matches("steve")); + assert!(meta.matches("steve@example.com")); + assert!(meta.matches(meta.id.as_str())); + assert!(!meta.matches("Alex")); + assert!(!meta.matches(" ")); + } + + #[test] + fn display_name_falls_back() { + let mut meta = AccountMeta::new(AccountKind::Microsoft, "uuid", "", 1); + assert_eq!(meta.display_name(), meta.id.as_str()); + meta.login_name = Some("me@example.com".to_string()); + assert_eq!(meta.display_name(), "me@example.com"); + meta.profile_name = "Steve".to_string(); + assert_eq!(meta.display_name(), "Steve"); + } + + #[test] + fn credential_kind_must_match_meta() { + let meta = AccountMeta::new(AccountKind::Microsoft, "uuid", "Steve", 1); + let session = MicrosoftSession::new( + "uuid", + "Steve", + Secret::new("a"), + Secret::new("r"), + 60, + 0, + None, + ); + assert!(meta + .check_credentials(&Credentials::Microsoft(session)) + .is_ok()); + assert!(meta.check_credentials(&Credentials::Offline).is_err()); + } + + #[test] + fn secrets_only_expose_real_credentials() { + assert!(Credentials::Offline.secrets().is_empty()); + let session = MicrosoftSession::new( + "uuid", + "Steve", + Secret::new("mc-token"), + Secret::new("live-refresh"), + 60, + 0, + None, + ); + assert_eq!( + Credentials::Microsoft(session).secrets(), + vec!["mc-token", "live-refresh"] + ); + } + + #[test] + fn malformed_record_is_an_error_not_a_panic() { + let value = serde_json::json!({ "id": "account:zz", "kind": "offline" }); + assert!(AccountMeta::from_value(&value).is_err()); + } +} diff --git a/crates/core/src/auth/authlib.rs b/crates/core/src/auth/authlib.rs new file mode 100644 index 0000000..f2dabff --- /dev/null +++ b/crates/core/src/auth/authlib.rs @@ -0,0 +1,347 @@ +//! authlib-injector 轨(**账户侧**):标准 Yggdrasil 协议的登录/刷新/校验/注销。 +//! +//! 边界(mission 明示):本模块只做「账户」;`-javaagent` 三件套(jar 下载、 +//! `-Dauthlibinjector.side=client`、`yggdrasil.prefetched` 的 Base64)归后续 mission。 +//! 依据 `analyse/04` §6.1–§6.2(端点布局与四动作报文)与 §6.3(服务发现约定)。 +//! +//! 三条照抄要点: +//! - 端点 = `authserver/{authenticate,refresh,validate,invalidate}`,`apiRoot` +//! 必须带尾斜杠(`https://littleskin.cn/api/yggdrasil/` 这种目录布局是规范要求); +//! - `clientToken` 是**启动器侧生成的随机值**,服务端回传必须一致(防会话被替换/重放); +//! - 「token 无效」用**返回值 `false`** 表达(`validate` 的 `ForbiddenOperationException`), +//! 「服务器抽风」才抛错——调用方据此决定「刷新」还是「重试」。 + +pub mod server_meta; + +pub use server_meta::AuthlibServerMeta; + +use reqwest::Client; +use serde_json::json; + +use crate::auth::error::{AuthError, AuthResult}; +use crate::auth::http::{post_json, Reply}; +use crate::auth::secret::Secret; +use crate::download::config::EngineConfig; +use crate::download::http::build_client; +use crate::launch::request::AuthPlaceholders; + +/// authlib 轨的 launch 用户类型(承 HMCL `YggdrasilSession.toAuthInfo`:也是 `msa`)。 +pub const USER_TYPE_MSA: &str = "msa"; + +/// 把用户输入规范成 API 根:补 `https://`、补尾斜杠、去空白。 +/// +/// # Errors +/// +/// 空串或只剩协议头时返回 [`AuthError::Malformed`]。 +pub fn normalize_api_root(input: &str) -> AuthResult { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(AuthError::Malformed( + "认证服务器地址不能为空(例:https://littleskin.cn/api/yggdrasil/)".to_string(), + )); + } + let with_scheme = if trimmed.starts_with("https://") || trimmed.starts_with("http://") { + trimmed.to_string() + } else { + format!("https://{trimmed}") + }; + let host = with_scheme + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(""); + if host.trim_matches('/').is_empty() { + return Err(AuthError::Malformed(format!( + "认证服务器地址不完整:`{input}`" + ))); + } + Ok(if with_scheme.ends_with('/') { + with_scheme + } else { + format!("{with_scheme}/") + }) +} + +/// Yggdrasil 四个端点(都从 API 根派生)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthlibEndpoints { + /// `authserver/authenticate`。 + pub authenticate: String, + /// `authserver/refresh`。 + pub refresh: String, + /// `authserver/validate`。 + pub validate: String, + /// `authserver/invalidate`。 + pub invalidate: String, +} + +impl AuthlibEndpoints { + /// 从 API 根派生(先 [`normalize_api_root`])。 + /// + /// # Errors + /// + /// 地址不合法时返回 [`AuthError::Malformed`]。 + pub fn from_api_root(api_root: &str) -> AuthResult { + let root = normalize_api_root(api_root)?; + let join = |path: &str| format!("{root}{path}"); + Ok(Self { + authenticate: join("authserver/authenticate"), + refresh: join("authserver/refresh"), + validate: join("authserver/validate"), + invalidate: join("authserver/invalidate"), + }) + } +} + +/// 一个 Yggdrasil 角色(`selectedProfile`)。 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct AuthlibProfile { + /// 32 位无连字符 UUID。 + pub id: String, + /// 角色名。 + pub name: String, +} + +/// authlib 会话 = 该账户的凭据载荷。 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthlibSession { + /// Yggdrasil accessToken。 + pub access_token: Secret, + /// 启动器生成的 clientToken(刷新/校验都要带)。 + pub client_token: Secret, + /// 当前选中的角色(多角色服务器里由 refresh 的 `selectedProfile` 决定)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_profile: Option, +} + +impl AuthlibSession { + /// 该会话的全部凭据值(武装脱敏器)。 + #[must_use] + pub fn secrets(&self) -> Vec<&Secret> { + vec![&self.access_token, &self.client_token] + } + + /// 有没有角色(没角色的会话不能启动游戏)。 + #[must_use] + pub fn has_profile(&self) -> bool { + self.selected_profile + .as_ref() + .is_some_and(|profile| !profile.name.trim().is_empty() && !profile.id.is_empty()) + } + + /// 转成 launch 占位符。 + /// + /// `user_properties` 用 `{}`:纹理由游戏进程经 authlib-injector 向服务器取 + /// (注入参数属后续 mission),启动参数不需要它。 + /// + /// # Errors + /// + /// 会话没有角色时返回 [`AuthError::CredentialExpired`](需要重新选角色/登录)。 + pub fn to_placeholders(&self) -> AuthResult { + let profile = self + .selected_profile + .as_ref() + .filter(|profile| !profile.name.trim().is_empty()) + .ok_or(AuthError::CredentialExpired)?; + Ok(AuthPlaceholders { + player_name: profile.name.clone(), + uuid: profile.id.clone(), + access_token: self.access_token.expose().to_string(), + user_type: USER_TYPE_MSA.to_string(), + user_properties: "{}".to_string(), + }) + } +} + +/// authlib 认证客户端。 +#[derive(Debug, Clone)] +pub struct AuthlibAuth { + client: Client, +} + +impl AuthlibAuth { + /// 构造(HTTP 基座复用 `core::download`)。 + /// + /// # Errors + /// + /// 客户端建不起来时返回 [`AuthError::Network`]。 + pub fn new() -> AuthResult { + let client = build_client(&EngineConfig::default()) + .map_err(|error| AuthError::network("http-client", error.to_string()))?; + Ok(Self { client }) + } + + /// 注入客户端(测试)。 + #[must_use] + pub fn with_client(mut self, client: Client) -> Self { + self.client = client; + self + } + + /// 密码登录(**新账户**)。密码只在本调用栈里出现,不落盘、不入日志。 + /// + /// # Errors + /// + /// 密码错/服务器拒绝 → [`AuthError::Denied`];网络与结构问题各有分流。 + pub async fn authenticate( + &self, + api_root: &str, + login_name: &str, + password: &Secret, + client_token: &Secret, + ) -> AuthResult { + let endpoints = AuthlibEndpoints::from_api_root(api_root)?; + let body = json!({ + "agent": { "name": "Minecraft", "version": 1 }, + "username": login_name, + "password": password.expose(), + "clientToken": client_token.expose(), + "requestUser": true, + }); + let reply = post_json(&self.client, &endpoints.authenticate, &body, None).await?; + if !reply.is_success() { + return Err(rejected(&reply, &endpoints.authenticate, "登录")); + } + let session = session_from(&reply, client_token, &endpoints.authenticate)?; + if !session.has_profile() { + // 服务端没自动选角色(多角色服务器):交给调用方走 refresh + selectedProfile。 + return Ok(session); + } + Ok(session) + } + + /// 刷新(可顺带 `selectedProfile` 换角色)。 + /// + /// # Errors + /// + /// token 失效 → [`AuthError::CredentialExpired`];换角色失败 → [`AuthError::Malformed`]。 + pub async fn refresh( + &self, + api_root: &str, + session: &AuthlibSession, + select: Option<&AuthlibProfile>, + ) -> AuthResult { + let endpoints = AuthlibEndpoints::from_api_root(api_root)?; + let mut body = json!({ + "accessToken": session.access_token.expose(), + "clientToken": session.client_token.expose(), + "requestUser": true, + }); + if let Some(profile) = select { + body["selectedProfile"] = json!({ "id": profile.id, "name": profile.name }); + } + let reply = post_json(&self.client, &endpoints.refresh, &body, None).await?; + if !reply.is_success() { + if is_forbidden(&reply) { + return Err(AuthError::CredentialExpired); + } + return Err(rejected(&reply, &endpoints.refresh, "刷新")); + } + let refreshed = session_from(&reply, &session.client_token, &endpoints.refresh)?; + if let Some(wanted) = select { + let got = refreshed.selected_profile.as_ref(); + if got.map(|profile| profile.id.as_str()) != Some(wanted.id.as_str()) { + return Err(AuthError::Malformed(format!( + "服务器没有切到角色 {}(refresh 未生效)", + wanted.name + ))); + } + } + Ok(refreshed) + } + + /// 校验 token 是否仍有效(`ForbiddenOperationException` = `Ok(false)`)。 + /// + /// # Errors + /// + /// 服务器 5xx/网络失败时返回错误(调用方据此区分「要刷新」与「要重试」)。 + pub async fn validate(&self, api_root: &str, session: &AuthlibSession) -> AuthResult { + let endpoints = AuthlibEndpoints::from_api_root(api_root)?; + let body = json!({ + "accessToken": session.access_token.expose(), + "clientToken": session.client_token.expose(), + }); + let reply = post_json(&self.client, &endpoints.validate, &body, None).await?; + if is_forbidden(&reply) { + return Ok(false); + } + if !reply.is_success() { + return Err(rejected(&reply, &endpoints.validate, "校验")); + } + // 成功时 body 为空或 `{}`,都算有效。 + Ok(true) + } + + /// 主动注销(删账户时顺手清理服务端会话;失败按「尽力而为」返回错误,调用方可不理)。 + /// + /// # Errors + /// + /// 网络/服务端失败时返回错误。 + pub async fn invalidate(&self, api_root: &str, session: &AuthlibSession) -> AuthResult<()> { + let endpoints = AuthlibEndpoints::from_api_root(api_root)?; + let body = json!({ + "accessToken": session.access_token.expose(), + "clientToken": session.client_token.expose(), + }); + let reply = post_json(&self.client, &endpoints.invalidate, &body, None).await?; + if reply.is_success() || is_forbidden(&reply) { + return Ok(()); + } + Err(rejected(&reply, &endpoints.invalidate, "注销")) + } +} + +/// 响应 → 会话(`clientToken` 回传必须一致;缺失时保留原值)。 +fn session_from(reply: &Reply, client_token: &Secret, url: &str) -> AuthResult { + let access_token = reply + .text("accessToken") + .ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 accessToken")))?; + if let Some(returned) = reply.text("clientToken") { + if returned != client_token.expose() { + return Err(AuthError::Malformed( + "服务器回传的 clientToken 与本地不一致(会话可能被替换)".to_string(), + )); + } + } + Ok(AuthlibSession { + access_token: Secret::new(access_token), + client_token: client_token.clone(), + selected_profile: selected_profile(reply), + }) +} + +fn selected_profile(reply: &Reply) -> Option { + let profile = reply + .body + .as_ref() + .and_then(|body| body.get("selectedProfile"))?; + let id = profile.get("id")?.as_str()?; + let name = profile.get("name")?.as_str()?; + Some(AuthlibProfile { + id: id.to_string(), + name: name.to_string(), + }) +} + +fn is_forbidden(reply: &Reply) -> bool { + reply.text("error") == Some("ForbiddenOperationException") +} + +fn rejected(reply: &Reply, url: &str, what: &str) -> AuthError { + match reply.text("error") { + Some(code) => AuthError::Denied { + code: code.to_string(), + description: reply + .text("errorMessage") + .unwrap_or("服务器未给描述") + .to_string(), + }, + None => AuthError::network( + url.split('?').next().unwrap_or(url), + format!("{what}失败(HTTP {})", reply.status), + ), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/core/src/auth/authlib/server_meta.rs b/crates/core/src/auth/authlib/server_meta.rs new file mode 100644 index 0000000..5885293 --- /dev/null +++ b/crates/core/src/auth/authlib/server_meta.rs @@ -0,0 +1,86 @@ +//! 认证服务器元数据里我们真正需要的字段(服务发现的产物)。 +//! +//! authlib-injector 规范里根路径 `/` 返回 `{ meta, skinDomains, signaturePublickey }` +//! (`analyse/04` §6.3)。本期**只保存**名字/非邮箱登录/skinDomains: +//! `signaturePublickey` 与 `prefetched` 的 Base64 属于注入参数(后续 mission), +//! 现在存了也没人消费,不如不存。 + +use serde_json::Value; + +/// 服务器元数据(缺失字段一律回落默认值)。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthlibServerMeta { + /// 服务器显示名(缺省回落 URL)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// 是否允许非邮箱登录名(决定输入框校验)。 + #[serde(default)] + pub non_email_login: bool, + /// `skinDomains`(纹理白名单,注入参数会用;本期只保存)。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skin_domains: Vec, +} + +impl AuthlibServerMeta { + /// 从根元数据 JSON 解析(容错:缺字段就用默认值)。 + #[must_use] + pub fn from_value(value: &Value) -> Self { + let meta = value.get("meta"); + let name = meta + .and_then(|meta| meta.get("serverName")) + .and_then(Value::as_str) + .map(str::to_string); + let non_email_login = meta + .and_then(|meta| meta.get("feature.non_email_login")) + .and_then(Value::as_bool) + .unwrap_or(false); + let skin_domains = value + .get("skinDomains") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + Self { + name, + non_email_login, + skin_domains, + } + } +} + +#[cfg(test)] +mod tests { + use super::AuthlibServerMeta; + use serde_json::json; + + #[test] + fn parses_full_metadata() { + let value = json!({ + "meta": { + "serverName": "Example Skin Site", + "feature.non_email_login": true + }, + "skinDomains": ["example.com", "*.example.com"], + "signaturePublickey": "-----BEGIN PUBLIC KEY-----" + }); + let meta = AuthlibServerMeta::from_value(&value); + assert_eq!(meta.name.as_deref(), Some("Example Skin Site")); + assert!(meta.non_email_login); + assert_eq!(meta.skin_domains.len(), 2); + } + + #[test] + fn tolerates_missing_fields() { + let meta = AuthlibServerMeta::from_value(&json!({})); + assert_eq!(meta, AuthlibServerMeta::default()); + assert!(meta.name.is_none()); + assert!(!meta.non_email_login); + assert!(meta.skin_domains.is_empty()); + } +} diff --git a/crates/core/src/auth/authlib/tests.rs b/crates/core/src/auth/authlib/tests.rs new file mode 100644 index 0000000..2cb9bcc --- /dev/null +++ b/crates/core/src/auth/authlib/tests.rs @@ -0,0 +1,97 @@ +//! authlib 轨的纯函数/模型测试(HTTP 部分在 `tests/auth/authlib_api.rs`)。 + +use super::{normalize_api_root, AuthlibEndpoints, AuthlibProfile, AuthlibSession, USER_TYPE_MSA}; +use crate::auth::secret::Secret; + +#[test] +fn api_root_normalization() { + assert_eq!( + normalize_api_root("littleskin.cn/api/yggdrasil").expect("补协议"), + "https://littleskin.cn/api/yggdrasil/" + ); + assert_eq!( + normalize_api_root(" http://127.0.0.1:8080/api/yggdrasil/ ").expect("保留协议"), + "http://127.0.0.1:8080/api/yggdrasil/" + ); + assert_eq!( + normalize_api_root("https://littleskin.cn").expect("根域名"), + "https://littleskin.cn/" + ); + assert!(normalize_api_root(" ").is_err()); + assert!(normalize_api_root("https://").is_err()); +} + +#[test] +fn endpoints_follow_directory_layout() { + let endpoints = + AuthlibEndpoints::from_api_root("https://example.invalid/api/yggdrasil").expect("端点"); + assert_eq!( + endpoints.authenticate, + "https://example.invalid/api/yggdrasil/authserver/authenticate" + ); + assert_eq!( + endpoints.refresh, + "https://example.invalid/api/yggdrasil/authserver/refresh" + ); + assert_eq!( + endpoints.validate, + "https://example.invalid/api/yggdrasil/authserver/validate" + ); + assert_eq!( + endpoints.invalidate, + "https://example.invalid/api/yggdrasil/authserver/invalidate" + ); +} + +#[test] +fn placeholders_require_a_profile() { + let without = AuthlibSession { + access_token: Secret::new("token"), + client_token: Secret::new("client"), + selected_profile: None, + }; + assert!(!without.has_profile()); + assert!(without.to_placeholders().is_err()); + + let with = AuthlibSession { + selected_profile: Some(AuthlibProfile { + id: "0123456789abcdef0123456789abcdef".to_string(), + name: "Steve".to_string(), + }), + ..without + }; + assert!(with.has_profile()); + let placeholders = with.to_placeholders().expect("占位符"); + assert_eq!(placeholders.player_name, "Steve"); + assert_eq!(placeholders.uuid, "0123456789abcdef0123456789abcdef"); + assert_eq!(placeholders.user_type, USER_TYPE_MSA); + assert_eq!(placeholders.access_token, "token"); +} + +#[test] +fn session_debug_never_leaks_tokens() { + let session = AuthlibSession { + access_token: Secret::new("access-token-value"), + client_token: Secret::new("client-token-value"), + selected_profile: None, + }; + let debug = format!("{session:?}"); + assert!(!debug.contains("access-token-value")); + assert!(!debug.contains("client-token-value")); + assert_eq!(session.secrets().len(), 2); +} + +#[test] +fn session_survives_json_roundtrip() { + let session = AuthlibSession { + access_token: Secret::new("access-token-value"), + client_token: Secret::new("client-token-value"), + selected_profile: Some(AuthlibProfile { + id: "0123456789abcdef0123456789abcdef".to_string(), + name: "Steve".to_string(), + }), + }; + let text = serde_json::to_string(&session).expect("序列化"); + let back: AuthlibSession = serde_json::from_str(&text).expect("反序列化"); + assert_eq!(back, session); +} diff --git a/crates/core/src/auth/body/mod.rs b/crates/core/src/auth/body/mod.rs new file mode 100644 index 0000000..91a1c53 --- /dev/null +++ b/crates/core/src/auth/body/mod.rs @@ -0,0 +1,279 @@ +//! 非 2xx 应答的**正文诊断**(M38):把 M31「响应体一律丢掉」这个盲区补上。 +//! +//! 起因是 `findings/20261030-tower-improve-login-with-xbox-403.md`:MSA 真机验收两轮都是 +//! OAuth→XBL→XSTS 全通、`login_with_xbox` 稳定 403,而旧 `rejected()` 只报状态码, +//! 于是「区域/网络拦截页」和「服务端拒绝(许可/区域)」在文案上分不开。 +//! +//! 这里只做三件事: +//! 1. **分类**:JSON(真实服务应答)/ HTML 或纯文本(多为网关、风控或代理拦截页)/ 空体; +//! 2. **提取**:JSON 里的 `error` / `errorMessage` / `reason` / `Message` 等服务端文案直出; +//! 3. **摘要**:正文压成 ≤[`DIGEST_CHARS`] 字符,**先过 [`Redactor`] 再进错误消息**。 +//! +//! 凭据红线(M31 的 `Secret`/脱敏体系不破): +//! - 摘要只从**非 2xx** 响应构造——OAuth 成功响应体(含 token)永远不会走到这里; +//! - `Reply` 的原文快照只在 JSON 解析失败时保留(见 [`crate::auth::http`]), +//! 解析成功的应答不留原文,避免把 token 文本复制进第二个字段; +//! - 摘要与提取出的服务端文案都过同一套脱敏:[`Redactor::scrub`](已登记 token 整串替换、 +//! ≥64 不透明串打码、JWT 形状的带点 token 打码——第三条 M40 已上提到 `launch::redact`, +//! 本模块不再自己补规则)。 + +use serde_json::Value; + +use crate::auth::error::AuthError; +use crate::auth::http::Reply; +use crate::launch::redact::Redactor; + +#[cfg(test)] +mod tests; + +/// 错误文案 / 事件里正文摘要的上限(字符)。 +pub const DIGEST_CHARS: usize = 200; + +/// 服务端文案的取值字段(按优先级,命中即收;`Message` 是 XSTS 的写法)。 +const SERVER_MESSAGE_KEYS: [&str; 6] = [ + "errorMessage", + "error_message", + "error", + "errorType", + "reason", + "Message", +]; + +/// 一个非 2xx 应答的正文形状。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BodyKind { + /// 合法 JSON(真实服务应答)。 + Json, + /// HTML 页面(网关/风控/代理拦截页的典型形状)。 + Html, + /// 其它非 JSON 文本。 + Text, + /// 空体。 + Empty, +} + +impl BodyKind { + /// 稳定名(错误文案与 JSONL 事件共用)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Json => "json", + Self::Html => "html", + Self::Text => "text", + Self::Empty => "empty", + } + } + + /// 是否是「拦截页」形状(HTML / 纯文本):这一类先怀疑网络,而不是账号。 + #[must_use] + pub const fn is_interception(self) -> bool { + matches!(self, Self::Html | Self::Text) + } +} + +/// 一次非 2xx 的诊断结果([`AuthError::Response`] 的载荷,也是 JSONL 事件的字段来源)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponseDetail { + /// 链上的步骤(`xbl` / `xsts` / `login_with_xbox` / `entitlements` / `profile`)。 + pub step: String, + /// 这一步在做什么(人话前缀,如「Minecraft 登录被拒」)。 + pub what: String, + /// HTTP 状态码。 + pub status: u16, + /// `Content-Type`(服务端没给时为 `None`)。 + pub content_type: Option, + /// 正文形状。 + pub kind: BodyKind, + /// 服务端文案(已脱敏、已截断)。 + pub server_message: Option, + /// 正文摘要(已脱敏,≤[`DIGEST_CHARS`] 字符)。 + pub digest: String, +} + +impl ResponseDetail { + /// 细分机读码(**只增不改**):JSON 拒绝沿用 M31 的 `_`; + /// 非 JSON 用新增后缀 `_non_json`;空体用 `_empty_body`。 + #[must_use] + pub fn detail_code(&self) -> String { + let suffix = match self.kind { + BodyKind::Json => self.status.to_string(), + BodyKind::Html | BodyKind::Text => "non_json".to_string(), + BodyKind::Empty => "empty_body".to_string(), + }; + format!("{}_{suffix}", self.step) + } + + /// 是否是「疑似网络拦截页」(HTML / 纯文本)。 + #[must_use] + pub const fn is_interception(&self) -> bool { + self.kind.is_interception() + } + + /// 「;服务端文案…;响应摘要…」片段(都没有时为空串,方便复用文案拼装)。 + #[must_use] + pub fn note(&self) -> String { + let mut note = String::new(); + if let Some(message) = &self.server_message { + note.push_str(&format!(";服务端文案「{message}」")); + } + if !self.digest.is_empty() { + note.push_str(&format!(";响应摘要:{}", self.digest)); + } + note + } + + /// 人读一句话(**不含凭据**),403 的两种分流在这里分开。 + #[must_use] + pub fn message(&self) -> String { + let mut text = format!("{}(HTTP {}", self.what, self.status); + if let Some(content_type) = &self.content_type { + text.push_str(&format!(",Content-Type: {content_type}")); + } + text.push(')'); + match self.kind { + BodyKind::Json => text.push_str(":服务端返回了 JSON 拒绝"), + BodyKind::Html | BodyKind::Text => { + text.push_str(":响应非 JSON(可能网络拦截/网关页)"); + } + BodyKind::Empty => text.push_str(":响应体为空(可能网络拦截/网关页)"), + } + text.push(';'); + text.push_str(advice(&self.step, self.kind)); + text.push_str(&self.note()); + text + } +} + +/// 下一步该往哪查(403 分流的核心:拦截页查网络/代理,JSON 拒绝查账号许可)。 +#[must_use] +pub fn advice(step: &str, kind: BodyKind) -> &'static str { + if kind.is_interception() { + return "请检查网络或代理(关掉/换掉代理后再试,必要时用镜像方案)"; + } + match step { + "login_with_xbox" => "若网络正常,请检查该账号的 Minecraft 许可与所在区域", + "entitlements" => "若网络正常,请检查该账号是否拥有 Minecraft Java 版", + "profile" => "若网络正常,请去 minecraft.net 检查档案状态", + _ => "请按服务端文案处理;如反复失败,检查网络代理是否生效", + } +} + +/// 分类一个非 2xx 应答。 +pub(crate) fn classify(reply: &Reply) -> BodyKind { + if reply.body.is_some() { + return BodyKind::Json; + } + let Some(raw) = reply + .raw + .as_deref() + .map(str::trim) + .filter(|raw| !raw.is_empty()) + else { + return BodyKind::Empty; + }; + let content_type = reply + .content_type + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase(); + if content_type.contains("html") || looks_like_html(raw) { + BodyKind::Html + } else { + BodyKind::Text + } +} + +/// 正文是不是 HTML 文档(没给 `Content-Type` 时的兜底判断)。 +fn looks_like_html(raw: &str) -> bool { + raw.trim_start().starts_with('<') +} + +/// 组装一条诊断(分类 + 服务端文案 + 摘要)。 +pub(crate) fn rejection( + step: &str, + reply: &Reply, + what: &str, + redactor: &Redactor, +) -> ResponseDetail { + ResponseDetail { + step: step.to_string(), + what: what.to_string(), + status: reply.status, + content_type: reply.content_type.clone(), + kind: classify(reply), + server_message: server_message(reply, redactor), + digest: digest(reply, redactor), + } +} + +/// 从 JSON 体里挑出服务端文案(去重后以 ` / ` 连接)。 +fn server_message(reply: &Reply, redactor: &Redactor) -> Option { + let body = reply.body.as_ref()?; + let mut parts: Vec = Vec::new(); + for key in SERVER_MESSAGE_KEYS { + let Some(text) = body.get(key).and_then(Value::as_str) else { + continue; + }; + let text = one_line(text); + if text.is_empty() || parts.iter().any(|part| part == &text) { + continue; + } + parts.push(text); + } + if parts.is_empty() { + return None; + } + Some(truncate(&scrub(&parts.join(" / "), redactor), DIGEST_CHARS)) +} + +/// 正文摘要:JSON 用紧凑文本,非 JSON 用原文快照;先脱敏再截断。 +fn digest(reply: &Reply, redactor: &Redactor) -> String { + let raw = match (&reply.body, &reply.raw) { + (Some(body), _) => body.to_string(), + (None, Some(raw)) => raw.to_string(), + (None, None) => return String::new(), + }; + truncate(&scrub(&one_line(&raw), redactor), DIGEST_CHARS) +} + +/// 走完整套脱敏:[`Redactor::scrub`] 的三条规则(已登记 token + ≥64 不透明串 + 带点 token)。 +/// +/// M40 之前这里自己补了一条「带点 token」规则:`Redactor` 只按「连续不透明串 ≥64」判形状, +/// 而 JWT(`header.payload.signature`)里有点号,把连续段切短,恰好躲过那条规则——服务端 +/// 把 token 回显在错误体里时就会原样进摘要。现在规则已上提到 `launch::redact`(全局底座), +/// 这里只调共用实现,两处规则不会再漂。 +fn scrub(text: &str, redactor: &Redactor) -> String { + redactor.scrub(text).into_owned() +} + +/// 压成一行(HTML 的分行会让错误消息难以阅读)。 +fn one_line(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut space = false; + for ch in text.chars() { + if ch.is_whitespace() { + space = !out.is_empty(); + continue; + } + if std::mem::take(&mut space) { + out.push(' '); + } + out.push(ch); + } + out +} + +/// 按字符截断(超长补 `…`)。 +fn truncate(text: &str, limit: usize) -> String { + if text.chars().count() <= limit { + return text.to_string(); + } + let mut out: String = text.chars().take(limit.saturating_sub(1)).collect(); + out.push('…'); + out +} + +/// 把诊断包成 [`AuthError::Response`](链上各步骤的统一出口)。 +pub(crate) fn response(step: &str, reply: &Reply, what: &str, redactor: &Redactor) -> AuthError { + AuthError::Response(Box::new(rejection(step, reply, what, redactor))) +} diff --git a/crates/core/src/auth/body/tests.rs b/crates/core/src/auth/body/tests.rs new file mode 100644 index 0000000..de5143c --- /dev/null +++ b/crates/core/src/auth/body/tests.rs @@ -0,0 +1,168 @@ +//! [`super`] 的单测:分类、服务端文案提取、摘要脱敏与 403 分流文案。 + +use super::{ + advice, classify, digest, one_line, response, server_message, truncate, BodyKind, DIGEST_CHARS, +}; +use crate::auth::error::AuthError; +use crate::auth::http::Reply; +use crate::auth::secret::{redactor_for, Secret}; +use crate::launch::redact::Redactor; +use serde_json::json; + +const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g"; + +fn json_reply(status: u16, body: serde_json::Value) -> Reply { + Reply { + status, + body: Some(body), + content_type: Some("application/json".to_string()), + raw: None, + } +} + +fn text_reply(status: u16, content_type: Option<&str>, raw: &str) -> Reply { + Reply { + status, + body: None, + content_type: content_type.map(str::to_string), + raw: Some(raw.to_string()), + } +} + +#[test] +fn classification_splits_json_html_text_and_empty() { + assert_eq!( + classify(&json_reply(403, json!({ "error": "x" }))), + BodyKind::Json + ); + assert_eq!( + classify(&text_reply(403, Some("text/html"), "")), + BodyKind::Html + ); + // 没给 Content-Type 时靠正文形状兜底。 + assert_eq!( + classify(&text_reply(403, None, "blocked")), + BodyKind::Html + ); + assert_eq!( + classify(&text_reply(502, Some("text/plain"), "Bad Gateway")), + BodyKind::Text + ); + assert_eq!(classify(&text_reply(403, None, " ")), BodyKind::Empty); +} + +#[test] +fn server_message_prefers_error_fields_and_dedups() { + let redactor = Redactor::new(); + let reply = json_reply( + 403, + json!({ "error": "UNAUTHORIZED", "errorMessage": "no license", "path": "/x" }), + ); + let message = server_message(&reply, &redactor).expect("应有服务端文案"); + assert!(message.contains("no license"), "{message}"); + assert!(message.contains("UNAUTHORIZED"), "{message}"); + + let same = json_reply(403, json!({ "error": "DENIED", "errorMessage": "DENIED" })); + assert_eq!(server_message(&same, &redactor).as_deref(), Some("DENIED")); + assert!(server_message(&json_reply(403, json!({ "path": "/x" })), &redactor).is_none()); + assert!(server_message(&text_reply(500, None, "boom"), &redactor).is_none()); +} + +#[test] +fn digest_is_redacted_and_capped() { + let token = Secret::new("mc-access-token-value"); + let redactor = redactor_for([&token]); + let reply = json_reply( + 403, + json!({ "error": "DENIED", "echo": format!("Bearer {}", token.expose()) }), + ); + let masked_digest = digest(&reply, &redactor); + assert!( + !masked_digest.contains("mc-access-token-value"), + "{masked_digest}" + ); + + let long = "blocked ".repeat(60); + let capped = digest(&text_reply(500, Some("text/plain"), &long), &redactor); + assert!(capped.chars().count() <= DIGEST_CHARS); + assert!(capped.ends_with('…')); +} + +#[test] +fn opaque_runs_are_masked_even_without_known_secret() { + let masked = digest(&text_reply(401, Some("text/plain"), JWT), &Redactor::new()); + assert!(!masked.contains("eyJhbGci"), "{masked}"); + assert!(masked.contains("redacted"), "{masked}"); +} + +#[test] +fn dotted_tokens_are_masked_by_the_shared_redactor() { + // 规则本体在 `launch::redact`(M40 上提),这里只验「auth 摘要走的是同一份实现」。 + let redactor = Redactor::new(); + assert!(!redactor.scrub(JWT).contains("eyJhbGci"), "JWT 应被打码"); + // 普通文案里的点不会触发(分段太短)。 + assert_eq!( + redactor.scrub("Service.Unavailable.today"), + "Service.Unavailable.today" + ); + let masked = redactor.scrub(&format!("token={JWT}&end")).into_owned(); + assert!(!masked.contains("eyJhbGci"), "{masked}"); + assert!(masked.ends_with("&end"), "{masked}"); +} + +#[test] +fn one_line_collapses_whitespace() { + assert_eq!(one_line(" a\n\n b\tc "), "a b c"); + assert_eq!(truncate("abcdef", 4), "abc…"); + assert_eq!(truncate("abc", 4), "abc"); +} + +#[test] +fn detail_codes_and_advice_split_network_from_license() { + let redactor = Redactor::new(); + let blocked = response( + "login_with_xbox", + &text_reply(403, Some("text/html"), "blocked"), + "Minecraft 登录被拒", + &redactor, + ); + let AuthError::Response(detail) = &blocked else { + panic!("应是 Response,实际 {blocked:?}"); + }; + assert_eq!(detail.detail_code(), "login_with_xbox_non_json"); + assert!( + detail.message().contains("网络拦截"), + "{}", + detail.message() + ); + assert!(detail.message().contains("代理"), "{}", detail.message()); + + let denied = response( + "login_with_xbox", + &json_reply( + 403, + json!({ "error": "UNAUTHORIZED", "errorMessage": "nope" }), + ), + "Minecraft 登录被拒", + &redactor, + ); + let AuthError::Response(detail) = &denied else { + panic!("应是 Response,实际 {denied:?}"); + }; + assert_eq!(detail.detail_code(), "login_with_xbox_403"); + assert!(detail.message().contains("许可"), "{}", detail.message()); + assert!(!detail.message().contains("代理"), "{}", detail.message()); + + let empty = response( + "profile", + &text_reply(500, None, ""), + "拉取档案被拒", + &redactor, + ); + let AuthError::Response(detail) = &empty else { + panic!("应是 Response,实际 {empty:?}"); + }; + assert_eq!(detail.detail_code(), "profile_empty_body"); + assert!(!advice("xbl", BodyKind::Html).is_empty()); + assert!(!advice("xbl", BodyKind::Json).is_empty()); +} diff --git a/crates/core/src/auth/error.rs b/crates/core/src/auth/error.rs new file mode 100644 index 0000000..a9fd332 --- /dev/null +++ b/crates/core/src/auth/error.rs @@ -0,0 +1,250 @@ +//! 认证错误:把 HMCL 的异常树压成「调用方能分流」的一层(`analyse/04` §2.6 / §4.5)。 +//! +//! 三条设计: +//! 1. **`CredentialExpired` 是唯一「要人再登录」的信号**(承 HMCL `CredentialExpiredException`), +//! 其余错误都不该弹交互; +//! 2. **取消 / 超时 / 被拒 三分**(mission 明示的错误分流):用户关窗口 = `Canceled`, +//! 设备码 15 分钟硬超时 = `Timeout`,`access_denied` = `Denied`; +//! 3. **文案里永不含凭据**:错误只描述发生了什么,token 一律不进 message。 + +use std::fmt; + +use crate::auth::body::ResponseDetail; + +/// Xbox Live 错误码:账号被封(`MicrosoftService.BANNED`)。 +pub const XERR_BANNED: u32 = 2_148_916_227; +/// 没有 Xbox 账号(需先建档案)。 +pub const XERR_MISSING_XBOX_ACCOUNT: u32 = 2_148_916_233; +/// 地区不可用。 +pub const XERR_COUNTRY_UNAVAILABLE: u32 = 2_148_916_235; +/// 儿童账号未加入家庭组。 +pub const XERR_ADD_FAMILY: u32 = 2_148_916_238; + +/// 认证失败。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthError { + /// 网络层失败(DNS/连接/TLS/超时以外的 IO)。 + Network { + /// 目标 URL(不含 query 里的凭据)。 + url: String, + /// 底层描述。 + detail: String, + }, + /// 超时:连接超时、整体超时,或设备码流程等满 `expires_in`/15 分钟。 + Timeout { + /// 在等什么。 + what: String, + }, + /// 用户主动取消(关窗口、Ctrl+C、`access_denied` 也归这里)。 + Canceled, + /// 凭据过期/被撤销,必须重新交互式登录(`AADSTS70000` 族)。 + CredentialExpired, + /// 服务端明确拒绝(OAuth `error` + `error_description`)。 + Denied { + /// OAuth 错误码(`invalid_grant` / `authorization_declined` …)。 + code: String, + /// 服务端描述。 + description: String, + }, + /// 链上某一步拿到非 2xx,且带回了正文诊断(M38)。 + /// + /// 与 [`Self::Denied`] 同一分流层(`code()` 仍是 `denied`,向后兼容 M31 的调用方), + /// 细分码看 [`AuthError::detail_code`]:JSON 拒绝沿用 `_`, + /// 拦截页(HTML/纯文本)是新增的 `_non_json`,空体是 `_empty_body`。 + /// + /// 载荷装箱:`ResponseDetail` 有 7 个字段(含 4 个字符串),不装箱会把整个枚举撑到 + /// 128 字节以上,`Result` 的每个 Ok 也跟着变胖(clippy `result_large_err`)。 + Response(Box), + /// Xbox Live / XSTS 业务错误码(`XErr`)。 + Xbox { + /// `XErr` 数值。 + code: u32, + }, + /// 响应结构不合法(JSON 缺字段、类型不对、profile 被换掉)。 + Malformed(String), + /// 凭据/元数据落盘或读取失败。 + Store { + /// 涉及的路径。 + path: String, + /// 底层描述。 + detail: String, + }, + /// 找不到指定账户。 + NotFound { + /// 用户给的查找键。 + account: String, + }, + /// 账户重复(同名同轨已存在)。 + Conflict { + /// 冲突的账户名。 + account: String, + }, + /// 该轨/该能力本期未实现(如 CLI 上的 authlib 密码登录)。 + Unsupported(String), +} + +impl AuthError { + /// 稳定机读码(CLI `--json` 与日志都用它,不随文案变)。 + #[must_use] + pub const fn code(&self) -> &'static str { + match self { + Self::Network { .. } => "network", + Self::Timeout { .. } => "timeout", + Self::Canceled => "canceled", + Self::CredentialExpired => "credential_expired", + Self::Denied { .. } => "denied", + // 与 `Denied` 同一粗分流层:M31 时代的调用方(CLI 提示、launch 身份解析)无需改。 + Self::Response(_) => "denied", + Self::Xbox { .. } => "xbox", + Self::Malformed(_) => "malformed", + Self::Store { .. } => "store", + Self::NotFound { .. } => "not_found", + Self::Conflict { .. } => "conflict", + Self::Unsupported(_) => "unsupported", + } + } + + /// 是否是「必须重新登录」的信号(唯一该弹交互的一类)。 + #[must_use] + pub const fn is_credential_expired(&self) -> bool { + matches!(self, Self::CredentialExpired) + } + + /// 是否属于「网络/服务端抽风,可重试或跳过」的一类。 + #[must_use] + pub const fn is_transient(&self) -> bool { + match self { + Self::Network { .. } | Self::Timeout { .. } | Self::Xbox { .. } => true, + // 拦截页/空体多半是网络侧问题;JSON 拒绝是服务端明确表态,重试无意义。 + Self::Response(detail) => detail.kind.is_interception(), + _ => false, + } + } + + /// 细分机读码(M38 新增):OAuth 拒绝返回 `` 里带的错误码,链上非 2xx 返回 + /// `_` / `_non_json` / `_empty_body`,其余回落到 [`Self::code`]。 + /// + /// 与 [`Self::code`] 的分工:`code()` 是**分流层**(决定弹不弹交互、能不能重试), + /// 这个方法给日志与 `--json` 的**排障层**用。既有码一律不改,只增。 + #[must_use] + pub fn detail_code(&self) -> String { + match self { + Self::Response(detail) => detail.detail_code(), + Self::Denied { code, .. } => code.clone(), + other => other.code().to_string(), + } + } + + /// 网络错误的构造(`url` 只放 host+path,别把 query 里的 code 带进来)。 + #[must_use] + pub fn network(url: impl Into, detail: impl Into) -> Self { + Self::Network { + url: url.into(), + detail: detail.into(), + } + } + + /// 落盘错误的构造。 + #[must_use] + pub fn store(path: impl fmt::Display, detail: impl Into) -> Self { + Self::Store { + path: path.to_string(), + detail: detail.into(), + } + } + + /// 人读一句话(**不含凭据**)。 + #[must_use] + pub fn message(&self) -> String { + match self { + Self::Network { url, detail } => format!("网络请求失败({url}):{detail}"), + Self::Timeout { what } => format!("等待超时:{what}"), + Self::Canceled => "登录已取消".to_string(), + Self::CredentialExpired => "凭据已过期,需要重新登录".to_string(), + Self::Denied { code, description } => { + format!("服务端拒绝({code}):{description}") + } + Self::Response(detail) => detail.message(), + Self::Xbox { code } => format!("Xbox 授权失败(XErr {code}):{}", xbox_hint(*code)), + Self::Malformed(detail) => format!("响应不符合预期:{detail}"), + Self::Store { path, detail } => format!("账户数据读写失败({path}):{detail}"), + Self::NotFound { account } => format!("找不到账户 `{account}`"), + Self::Conflict { account } => format!("账户 `{account}` 已存在"), + Self::Unsupported(detail) => format!("本期未支持:{detail}"), + } + } +} + +impl fmt::Display for AuthError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message()) + } +} + +impl std::error::Error for AuthError {} + +/// `XErr` 码 → 中文说明(承 `analyse/04` §5.2 的四码表)。 +#[must_use] +pub fn xbox_hint(code: u32) -> &'static str { + match code { + XERR_BANNED => "该微软账号已被 Xbox Live 封禁", + XERR_MISSING_XBOX_ACCOUNT => "该微软账号还没有 Xbox 档案,请先去 xbox.com 创建", + XERR_COUNTRY_UNAVAILABLE => "该账号所在地区不被 Xbox Live 支持", + XERR_ADD_FAMILY => "儿童账号需要先加入 Microsoft 家庭组", + _ => "Xbox Live 拒绝了本次授权(可能无 Xbox 档案或账号受限)", + } +} + +/// 结果别名。 +pub type AuthResult = Result; + +#[cfg(test)] +mod tests { + use super::{xbox_hint, AuthError, XERR_ADD_FAMILY}; + + #[test] + fn codes_are_stable() { + assert_eq!(AuthError::Canceled.code(), "canceled"); + assert_eq!(AuthError::CredentialExpired.code(), "credential_expired"); + assert!(AuthError::Xbox { + code: XERR_ADD_FAMILY + } + .is_transient()); + assert!(!AuthError::Canceled.is_transient()); + } + + #[test] + fn messages_carry_no_secret_shape() { + let error = AuthError::Denied { + code: "invalid_grant".to_string(), + description: "AADSTS70000: bad redirect".to_string(), + }; + assert!(error.message().contains("AADSTS70000")); + assert!(AuthError::Xbox { code: 2148916238 } + .message() + .contains("家庭组")); + assert!(!xbox_hint(0).is_empty()); + } + + #[test] + fn response_shares_denied_coarse_code_but_has_its_own_detail_code() { + let error = AuthError::Response(Box::new(super::ResponseDetail { + step: "login_with_xbox".to_string(), + what: "Minecraft 登录被拒".to_string(), + status: 403, + content_type: Some("text/html".to_string()), + kind: crate::auth::body::BodyKind::Html, + server_message: None, + digest: String::new(), + })); + // 粗分流层向后兼容:仍是 `denied`;排障层才是细分码。 + assert_eq!(error.code(), "denied"); + assert_eq!(error.detail_code(), "login_with_xbox_non_json"); + assert!(error.is_transient(), "拦截页可以重试"); + assert_eq!( + AuthError::Canceled.detail_code(), + "canceled", + "没有细分的错误回落到 code()" + ); + } +} diff --git a/crates/core/src/auth/http.rs b/crates/core/src/auth/http.rs new file mode 100644 index 0000000..ad42508 --- /dev/null +++ b/crates/core/src/auth/http.rs @@ -0,0 +1,208 @@ +//! 认证用的 HTTP 薄封装(三条轨共用):**复用既有 download 引擎的客户端基座** +//! (mission Context:网络操作走既有 HTTP 基座),只补三件认证需要的东西: +//! 表单/JSON 体、Bearer 头、单请求整体超时。 +//! +//! 三条纪律: +//! 1. **响应体不原样进错误文案**——OAuth 的 token 响应体本身就是凭据,把它塞进 +//! `Malformed` 的 message 就等于把 token 写进日志(`analyse/04` §11 观察项 9 的坑); +//! 因此解析失败只说「不是合法 JSON」,贴原文的唯一通道是 +//! [`crate::auth::body`] 的脱敏摘要(M38:仅非 2xx、≤200 字符、过 Redactor)。 +//! 2. 状态码不做归类,**原样交给调用方**:XSTS 的 401 带 `XErr`、`/minecraft/profile` 的 +//! 404 要复判 license,都是「非 200 但有语义」的分支,提前归一化会丢掉信息。 +//! 3. **诊断留证,但不留凭据**(M38):`Reply` 额外携带 `Content-Type` 与非 JSON 正文的快照 +//! ([`RAW_PREVIEW_BYTES`] 字节封顶),诊断由 [`crate::auth::body`] 分类/脱敏后再进错误消息。 +//! 原文快照**只在 JSON 解析失败时**保留,所以 OAuth 成功响应体不会被复制进第二个字段。 + +use std::time::Duration; + +use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; +use reqwest::Client; +use serde_json::Value; + +use crate::auth::error::{AuthError, AuthResult}; +use crate::auth::secret::Secret; + +/// 单请求整体超时(设备码轮询的每个请求都远快于此)。 +pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// 非 JSON 正文保留的快照上限(字节):够判断「是不是 HTML」并抓前几句文案。 +pub(crate) const RAW_PREVIEW_BYTES: usize = 512; + +/// 一次应答:状态码 + 可解析的 JSON 体 + 诊断用的头/正文快照。 +#[derive(Debug, Clone)] +pub(crate) struct Reply { + /// HTTP 状态码。 + pub status: u16, + /// 解析成功时的 JSON 体(空体/非 JSON 为 `None`)。 + pub body: Option, + /// `Content-Type` 原样(诊断文案与分流判断用;缺失为 `None`)。 + pub content_type: Option, + /// 非 JSON 正文的前 [`RAW_PREVIEW_BYTES`] 字节(有损 UTF-8;JSON 解析成功时为 `None`)。 + pub raw: Option, +} + +impl Reply { + /// 是否 2xx。 + pub(crate) fn is_success(&self) -> bool { + (200..300).contains(&self.status) + } + + /// 取一个字符串字段。 + pub(crate) fn text(&self, path: &str) -> Option<&str> { + self.body.as_ref()?.get(path)?.as_str() + } + + /// 取一个 u64 字段(数字或数字字符串都接受)。 + pub(crate) fn number(&self, path: &str) -> Option { + let value = self.body.as_ref()?.get(path)?; + value + .as_u64() + .or_else(|| value.as_str().and_then(|raw| raw.trim().parse().ok())) + } +} + +/// 表单 `POST`(OAuth 的设备码/token 端点)。 +pub(crate) async fn post_form( + client: &Client, + url: &str, + form: Vec<(&str, String)>, +) -> AuthResult { + let request = client + .post(url) + .header(ACCEPT, "application/json") + .timeout(REQUEST_TIMEOUT) + .form(&form); + send(request, url).await +} + +/// JSON `POST`(XBL / XSTS / login_with_xbox)。 +pub(crate) async fn post_json( + client: &Client, + url: &str, + body: &Value, + bearer: Option<&Secret>, +) -> AuthResult { + let text = serde_json::to_string(body) + .map_err(|error| AuthError::Malformed(format!("请求体序列化失败:{error}")))?; + let mut request = client + .post(url) + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json") + .timeout(REQUEST_TIMEOUT) + .body(text); + if let Some(token) = bearer { + request = request.header(AUTHORIZATION, format!("Bearer {}", token.expose())); + } + send(request, url).await +} + +/// 带 Bearer 的 `GET`(entitlements / profile)。 +pub(crate) async fn get_json(client: &Client, url: &str, bearer: &Secret) -> AuthResult { + let request = client + .get(url) + .header(ACCEPT, "application/json") + .header(AUTHORIZATION, format!("Bearer {}", bearer.expose())) + .timeout(REQUEST_TIMEOUT); + send(request, url).await +} + +/// 发请求并解析 JSON(无凭据泄漏的错误映射)。 +async fn send(request: reqwest::RequestBuilder, url: &str) -> AuthResult { + let response = request + .send() + .await + .map_err(|error| http_error(url, &error))?; + let status = response.status().as_u16(); + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let bytes = response + .bytes() + .await + .map_err(|error| http_error(url, &error))?; + let body = if bytes.is_empty() { + None + } else { + serde_json::from_slice::(&bytes).ok() + }; + // 只有解析失败(HTML 拦截页 / 纯文本 / 坏 JSON)才留原文快照: + // 解析成功的响应体可能含 token,不留第二份副本。 + let raw = match (&body, bytes.is_empty()) { + (None, false) => Some(preview(&bytes)), + _ => None, + }; + Ok(Reply { + status, + body, + content_type, + raw, + }) +} + +/// 取正文前 [`RAW_PREVIEW_BYTES`] 字节(有损解码,截断时补 `…`)。 +fn preview(bytes: &[u8]) -> String { + let truncated = bytes.len() > RAW_PREVIEW_BYTES; + let head = &bytes[..bytes.len().min(RAW_PREVIEW_BYTES)]; + let mut text = String::from_utf8_lossy(head).into_owned(); + if truncated { + text.push('…'); + } + text +} + +/// `reqwest::Error` → [`AuthError`](超时与非超时分开,文案只带 host+path)。 +fn http_error(url: &str, error: &reqwest::Error) -> AuthError { + let target = url.split('?').next().unwrap_or(url).to_string(); + if error.is_timeout() { + AuthError::Timeout { + what: format!("请求 {target}"), + } + } else { + AuthError::network(target, error.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::{preview, Reply, RAW_PREVIEW_BYTES}; + use serde_json::json; + + fn reply(status: u16, body: Option) -> Reply { + Reply { + status, + body, + content_type: Some("application/json".to_string()), + raw: None, + } + } + + #[test] + fn reply_reads_text_and_numbers() { + let sample = reply( + 200, + Some(json!({ "user_code": "ABC", "expires_in": 900, "interval": "5" })), + ); + assert!(sample.is_success()); + assert_eq!(sample.text("user_code"), Some("ABC")); + assert_eq!(sample.number("expires_in"), Some(900)); + assert_eq!(sample.number("interval"), Some(5)); + assert_eq!(sample.number("missing"), None); + assert_eq!(sample.text("missing"), None); + } + + #[test] + fn non_success_is_not_success() { + assert!(!reply(401, None).is_success()); + } + + #[test] + fn preview_caps_and_marks_truncation() { + let short = preview(b"Bad Gateway"); + assert_eq!(short, "Bad Gateway"); + let long = preview(&vec![b'x'; RAW_PREVIEW_BYTES + 64]); + assert!(long.ends_with('…')); + assert_eq!(long.trim_end_matches('…').len(), RAW_PREVIEW_BYTES); + } +} diff --git a/crates/core/src/auth/journal.rs b/crates/core/src/auth/journal.rs new file mode 100644 index 0000000..6c54c67 --- /dev/null +++ b/crates/core/src/auth/journal.rs @@ -0,0 +1,266 @@ +//! auth 失败事件的落盘(`.ULL/logs/auth.jsonl`,M38)。 +//! +//! 起因:MSA 真机验收两轮 403,`ull accounts add msa` 失败后终端只有一行文案, +//! 验收人拿不到状态码/正文摘要去对账。这里把**失败**写成一行一条 JSONL: +//! 便携模式数据根还没初始化时会先补建 `logs/` 分区,403 的细节不再随终端滚走。 +//! +//! 三条纪律: +//! 1. **只记失败**,不记成功(成功路径的凭据载荷一律不落日志); +//! 2. **记的是诊断字段**(`code` / `detail_code` / `status` / `content_type` / `body_kind` / +//! 已脱敏的 `server_message`、`digest`),没有任何 token/密码字段; +//! 3. **落盘是尽力而为**:写日志失败绝不让登录本身失败(调用方忽略返回值)。 +//! +//! 数据根从哪来:默认走 `platform::data_dir` 的 **Portable** 解析(exe 同目录的 `.ULL`, +//! 跟随 `redirect.json`)。宿主若用 `--data-dir` 等非便携模式,应显式 +//! [`AuthJournal::in_logs_dir`] 后交给 [`crate::auth::MicrosoftAuth::with_journal`]。 + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; + +use crate::auth::body::BodyKind; +use crate::auth::error::AuthError; +use crate::launch::redact::Redactor; +use crate::platform::data_dir::{DataDirRequest, Resolver}; +use crate::platform::layout::LauncherLayout; +use crate::platform::paths::Platform; + +/// 认证失败事件文件名(在 `.ULL/logs/` 下)。 +pub const AUTH_LOG_FILE: &str = "auth.jsonl"; + +/// 一行认证失败事件。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthFailureEvent { + /// Unix 毫秒。 + pub ts_ms: u64, + /// 触发入口(`device_code_login` / `refresh` / `validate`)。 + pub scope: String, + /// 链上的步骤(`AuthError::Response` 才有)。 + pub step: Option, + /// 粗分流码([`AuthError::code`])。 + pub code: String, + /// 细分码([`AuthError::detail_code`])。 + pub detail_code: String, + /// HTTP 状态码(链上拒绝才有)。 + pub status: Option, + /// `Content-Type`(链上拒绝才有)。 + pub content_type: Option, + /// 正文形状(链上拒绝才有)。 + pub body_kind: Option, + /// 服务端文案(已脱敏)。 + pub server_message: Option, + /// 正文摘要(已脱敏,≤200 字符)。 + pub digest: Option, + /// 是否可重试([`AuthError::is_transient`])。 + pub transient: bool, + /// 人读一句话(已脱敏,不含凭据)。 + pub message: String, +} + +impl AuthFailureEvent { + /// 从一条错误构造(`scope` 说明是哪个入口失败的)。 + #[must_use] + pub fn of(scope: &str, error: &AuthError) -> Self { + let detail = match error { + AuthError::Response(detail) => Some(detail.as_ref()), + _ => None, + }; + let scrub = |text: &str| Redactor::new().scrub(text).into_owned(); + Self { + ts_ms: crate::auth::microsoft::now_ms(), + scope: scope.to_string(), + step: detail.map(|detail| detail.step.clone()), + code: error.code().to_string(), + detail_code: error.detail_code(), + status: detail.map(|detail| detail.status), + content_type: detail.and_then(|detail| detail.content_type.clone()), + body_kind: detail.map(|detail| detail.kind), + server_message: detail + .and_then(|detail| detail.server_message.as_deref()) + .map(scrub), + digest: detail.map(|detail| scrub(&detail.digest)), + transient: error.is_transient(), + message: scrub(&error.message()), + } + } + + /// JSON 形状(字段名稳定,给验收/排障工具读)。 + #[must_use] + pub fn to_json(&self) -> Value { + json!({ + "ts_ms": self.ts_ms, + "level": "error", + "event": "auth_failure", + "scope": self.scope, + "step": self.step, + "code": self.code, + "detail_code": self.detail_code, + "status": self.status, + "content_type": self.content_type, + "body_kind": self.body_kind.map(BodyKind::as_str), + "server_message": self.server_message, + "digest": self.digest, + "transient": self.transient, + "message": self.message, + }) + } +} + +/// 认证失败日志(JSONL,一行一条)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthJournal { + path: PathBuf, +} + +impl AuthJournal { + /// 落在某个 `logs/` 分区下(`/auth.jsonl`)。 + #[must_use] + pub fn in_logs_dir(logs: impl Into) -> Self { + Self { + path: logs.into().join(AUTH_LOG_FILE), + } + } + + /// 直接指定文件路径。 + #[must_use] + pub fn at(path: impl Into) -> Self { + Self { path: path.into() } + } + + /// 便携模式默认位置(exe 同目录 `.ULL/logs/auth.jsonl`,跟随 `redirect.json`)。 + /// + /// 拿不到 exe 目录(极少数嵌入场景)时返回 `None`——没有可写的日志根就不写。 + #[must_use] + pub fn portable_default() -> Option { + let exe_dir = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf))?; + let resolver = Resolver::new(Platform::current(), Some(exe_dir)); + let resolved = resolver.resolve_default(&DataDirRequest::default()).ok()?; + Some(Self::in_logs_dir( + LauncherLayout::new(resolved.launcher_home).logs(), + )) + } + + /// 日志文件路径。 + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + /// 追加一条事件(父目录不存在时先建)。 + /// + /// # Errors + /// + /// 建目录或追加失败;调用方按「尽力而为」处理即可。 + pub fn record(&self, event: &AuthFailureEvent) -> std::io::Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut line = serde_json::to_string(&event.to_json()).unwrap_or_default(); + line.push('\n'); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + file.write_all(line.as_bytes())?; + file.flush() + } +} + +#[cfg(test)] +mod tests { + use super::{AuthFailureEvent, AuthJournal}; + use crate::auth::body::{BodyKind, ResponseDetail}; + use crate::auth::error::AuthError; + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-auth-journal-{tag}-{}-{:?}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir + } + + fn blocked_error() -> AuthError { + AuthError::Response(Box::new(ResponseDetail { + step: "login_with_xbox".to_string(), + what: "Minecraft 登录被拒".to_string(), + status: 403, + content_type: Some("text/html".to_string()), + kind: BodyKind::Html, + server_message: None, + digest: "blocked".to_string(), + })) + } + + #[test] + fn event_carries_structured_diagnostics() { + let event = AuthFailureEvent::of("device_code_login", &blocked_error()); + assert_eq!(event.scope, "device_code_login"); + assert_eq!(event.step.as_deref(), Some("login_with_xbox")); + assert_eq!(event.code, "denied"); + assert_eq!(event.detail_code, "login_with_xbox_non_json"); + assert_eq!(event.status, Some(403)); + assert_eq!(event.content_type.as_deref(), Some("text/html")); + assert_eq!(event.body_kind, Some(BodyKind::Html)); + assert!(event.transient); + let value = event.to_json(); + assert_eq!(value["event"], "auth_failure"); + assert_eq!(value["level"], "error"); + assert_eq!(value["body_kind"], "html"); + assert!(value["message"] + .as_str() + .is_some_and(|text| !text.is_empty())); + } + + #[test] + fn non_chain_errors_still_record() { + let event = AuthFailureEvent::of("refresh", &AuthError::Canceled); + assert_eq!(event.detail_code, "canceled"); + assert_eq!(event.step, None); + assert_eq!(event.status, None); + assert!(!event.transient); + } + + #[test] + fn record_initializes_the_logs_dir_and_appends_one_line() { + let root = temp_dir("init"); + let logs = root.join("logs"); + let journal = AuthJournal::in_logs_dir(&logs); + assert!(!logs.exists(), "初始不该存在(模拟未初始化的数据根)"); + journal + .record(&AuthFailureEvent::of("device_code_login", &blocked_error())) + .expect("写日志"); + journal + .record(&AuthFailureEvent::of("refresh", &AuthError::Canceled)) + .expect("写日志"); + let text = std::fs::read_to_string(journal.path()).expect("读日志"); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2, "一行一条:{text}"); + for line in lines { + let value: serde_json::Value = serde_json::from_str(line).expect("每行都是合法 JSON"); + assert_eq!(value["event"], "auth_failure"); + } + // 日志里不该出现任何凭据字段名。 + for forbidden in ["access_token", "refresh_token", "password", "token"] { + assert!(!text.contains(forbidden), "日志出现了 {forbidden}:{text}"); + } + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn journal_paths_are_layout_based() { + let journal = AuthJournal::in_logs_dir("/tmp/ull-logs"); + assert!(journal.path().ends_with("auth.jsonl")); + let explicit = AuthJournal::at("/tmp/x/custom.jsonl"); + assert_eq!(explicit.path(), std::path::Path::new("/tmp/x/custom.jsonl")); + } +} diff --git a/crates/core/src/auth/microsoft/chain.rs b/crates/core/src/auth/microsoft/chain.rs new file mode 100644 index 0000000..841ab82 --- /dev/null +++ b/crates/core/src/auth/microsoft/chain.rs @@ -0,0 +1,377 @@ +//! 微软五步链:Live token → **XBL** → **XSTS** → **login_with_xbox** → **entitlements** → **profile**。 +//! +//! 逐条承 `analyse/04` §5.2(报文级别),四处容易抄错的地方都在注释里点名: +//! 1. XBL 的 `RpsTicket` 必须带 **`d=` 前缀**(MSA 消费版票据标记); +//! 2. `identityToken` 是 **`XBL3.0 x=;`**(分号分隔,`x` 后紧跟 `=`); +//! 3. XSTS 在「无 Xbox 档案/儿童账号/地区不支持」时回 **401**(不是 200+JSON), +//! 必须读 body 里的 `XErr` 才能给出准确原因; +//! 4. `/minecraft/profile` 的 **404 二义**:没买游戏 vs 买了没建档案, +//! 要用 `entitlements/license` 复判一次才分得清。 +//! +//! **XSTS/MC 令牌不缓存**:刷新就是整链重跑(`analyse/04` §5.1),所以本模块只做无状态串联。 + +use reqwest::Client; +use serde_json::json; + +use crate::auth::body; +use crate::auth::error::AuthError; +use crate::auth::secret::{redactor_for, Secret}; + +use super::endpoints::MicrosoftEndpoints; +use super::flow::OAuthTokens; +use super::session::MicrosoftSession; +use crate::auth::http::{get_json, post_json, Reply}; + +/// Xbox Live 用户票据 + 用户哈希。 +#[derive(Debug, Clone)] +pub(crate) struct XboxTicket { + /// JWT 票据(凭据形状)。 + pub token: Secret, + /// `uhs`(`XBL3.0 x=;...` 的 userhash;本身不是凭据但只在本链内部用)。 + pub uhs: String, +} + +/// MC 侧换到的令牌。 +#[derive(Debug, Clone)] +pub(crate) struct MinecraftToken { + /// MC access token(落盘)。 + pub access_token: Secret, + /// 有效期(秒)。 + pub expires_in: u64, +} + +/// Minecraft 档案(不可变 id + 可变 name)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct GameProfile { + /// 32 位无连字符 UUID。 + pub id: String, + /// 玩家名。 + pub name: String, +} + +/// 第 1 步:Xbox Live User Authenticate。 +pub(crate) async fn xbl_authenticate( + client: &Client, + endpoints: &MicrosoftEndpoints, + live_access_token: &Secret, +) -> Result { + let body = json!({ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": format!("d={}", live_access_token.expose()), + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT", + }); + let reply = post_json(client, &endpoints.xbl_authenticate, &body, None).await?; + if let Some(code) = xerr(&reply) { + return Err(AuthError::Xbox { code }); + } + if !reply.is_success() { + // M38:XErr 优先(业务码才是真原因),没有 XErr 时把正文诊断补进文案。 + return Err(body::response( + "xbl", + &reply, + "Xbox Live 用户认证被拒", + &redactor_for([live_access_token]), + )); + } + let token = reply + .text("Token") + .ok_or_else(|| AuthError::Malformed("XBL 响应缺 Token".to_string()))?; + let uhs = uhs_of(&reply) + .ok_or_else(|| AuthError::Malformed("XBL 响应缺 DisplayClaims.xui[0].uhs".to_string()))?; + Ok(XboxTicket { + token: Secret::new(token), + uhs, + }) +} + +/// 第 2 步:XSTS Authorize(401 也读 body,`XErr` 才是真原因)。 +pub(crate) async fn xsts_authorize( + client: &Client, + endpoints: &MicrosoftEndpoints, + xbl: &XboxTicket, +) -> Result { + let body = json!({ + "Properties": { "SandboxId": "RETAIL", "UserTokens": [xbl.token.expose()] }, + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT", + }); + let reply = post_json(client, &endpoints.xsts_authorize, &body, None).await?; + if let Some(code) = xerr(&reply) { + return Err(AuthError::Xbox { code }); + } + if !reply.is_success() { + return Err(body::response( + "xsts", + &reply, + "XSTS 授权被拒", + &redactor_for([&xbl.token]), + )); + } + let token = reply + .text("Token") + .ok_or_else(|| AuthError::Malformed("XSTS 响应缺 Token".to_string()))?; + let uhs = uhs_of(&reply).ok_or_else(|| AuthError::Malformed("XSTS 响应缺 uhs".to_string()))?; + // 一致性断言:同一账号在 XBL 与 XSTS 两次返回的 userhash 必须相同。 + if uhs != xbl.uhs { + return Err(AuthError::Malformed( + "XBL 与 XSTS 返回的 userhash 不一致".to_string(), + )); + } + Ok(XboxTicket { + token: Secret::new(token), + uhs, + }) +} + +/// 第 3 步:`login_with_xbox` 换 Minecraft 令牌。 +pub(crate) async fn login_with_xbox( + client: &Client, + endpoints: &MicrosoftEndpoints, + xsts: &XboxTicket, +) -> Result { + let body = json!({ + "identityToken": format!("XBL3.0 x={};{}", xsts.uhs, xsts.token.expose()), + }); + let reply = post_json(client, &endpoints.login_with_xbox, &body, None).await?; + if !reply.is_success() { + // M38 的核心一行:403 到底是「拦截页」还是「服务端拒绝」,文案要分开引导。 + return Err(body::response( + "login_with_xbox", + &reply, + "Minecraft 登录被拒", + &redactor_for([&xsts.token]), + )); + } + let access_token = reply + .text("access_token") + .ok_or_else(|| AuthError::Malformed("login_with_xbox 缺 access_token".to_string()))?; + Ok(MinecraftToken { + access_token: Secret::new(access_token), + expires_in: reply.number("expires_in").unwrap_or(0), + }) +} + +/// 第 4 步:拥有权校验(必须 200,否则后面启动时也会被服务端踢)。 +pub(crate) async fn check_entitlements( + client: &Client, + endpoints: &MicrosoftEndpoints, + mc_token: &Secret, +) -> Result<(), AuthError> { + let reply = get_json(client, &endpoints.entitlements_mcstore, mc_token).await?; + if reply.is_success() { + return Ok(()); + } + let detail = body::rejection( + "entitlements", + &reply, + "拥有权校验未通过", + &redactor_for([mc_token]), + ); + // 拦截页(HTML/纯文本)与非 404 的空体先当网络问题报,别让用户去查许可; + // JSON 拒绝与 404 沿用 M31 的 `no_entitlement`(稳定码不改)。 + let interception = detail.kind.is_interception() + || (detail.kind == body::BodyKind::Empty && reply.status != 404); + if interception { + return Err(AuthError::Response(Box::new(detail))); + } + let what = if reply.status == 404 { + "该账号没有 Minecraft Java 版拥有权" + } else { + detail.what.as_str() + }; + Err(AuthError::Denied { + code: "no_entitlement".to_string(), + description: format!( + "{what}(entitlements/mcstore 返回 HTTP {}){}", + reply.status, + detail.note() + ), + }) +} + +/// 第 5 步:拉档案(404 用 license 复判二义)。 +pub(crate) async fn fetch_profile( + client: &Client, + endpoints: &MicrosoftEndpoints, + mc_token: &Secret, +) -> Result { + let reply = get_json(client, &endpoints.profile, mc_token).await?; + // 只有「像服务端应答」的 404 才走二义复判:HTML/纯文本的 404 是网关页, + // 拿它去复判 license 会得出错误的「没买游戏」结论。 + if reply.status == 404 && !body::classify(&reply).is_interception() { + return Err(profile_missing(client, endpoints, mc_token).await); + } + if !reply.is_success() { + return Err(body::response( + "profile", + &reply, + "拉取 Minecraft 档案被拒", + &redactor_for([mc_token]), + )); + } + let id = reply + .text("id") + .ok_or_else(|| AuthError::Malformed("档案响应缺 id".to_string()))?; + let name = reply + .text("name") + .ok_or_else(|| AuthError::Malformed("档案响应缺 name".to_string()))?; + Ok(GameProfile { + id: id.to_string(), + name: name.to_string(), + }) +} + +/// 404 之后的二义消解:没买游戏 vs 买了没建档案。 +async fn profile_missing( + client: &Client, + endpoints: &MicrosoftEndpoints, + mc_token: &Secret, +) -> AuthError { + let reply = match get_json(client, &endpoints.entitlements_license, mc_token).await { + Ok(reply) => reply, + // 复判本身失败时,退回「档案不存在」这个更保守的说法。 + Err(_) => { + return AuthError::Denied { + code: "no_profile".to_string(), + description: "该账号没有 Minecraft 档案(复判 license 也失败)".to_string(), + } + } + }; + if has_minecraft_license(&reply) { + AuthError::Denied { + code: "no_profile".to_string(), + description: "账号已拥有 Minecraft,但还没有创建档案(去 minecraft.net 起个名字)" + .to_string(), + } + } else { + AuthError::Denied { + code: "no_license".to_string(), + description: "该账号没有购买 Minecraft Java 版".to_string(), + } + } +} + +/// `entitlements/license` 响应里是否有 `game_minecraft`。 +fn has_minecraft_license(reply: &Reply) -> bool { + reply + .body + .as_ref() + .and_then(|body| body.get("items")) + .and_then(|items| items.as_array()) + .is_some_and(|items| { + items.iter().any(|item| { + item.get("name") + .and_then(|name| name.as_str()) + .is_some_and(|name| name == "game_minecraft") + }) + }) +} + +/// 整链:Live token → [`MicrosoftSession`](刷新与新登录共用这一段)。 +pub(crate) async fn complete_chain( + client: &Client, + endpoints: &MicrosoftEndpoints, + tokens: OAuthTokens, + now_ms: u64, +) -> Result { + let xbl = xbl_authenticate(client, endpoints, &tokens.access_token).await?; + let xsts = xsts_authorize(client, endpoints, &xbl).await?; + let mc = login_with_xbox(client, endpoints, &xsts).await?; + check_entitlements(client, endpoints, &mc.access_token).await?; + let profile = fetch_profile(client, endpoints, &mc.access_token).await?; + Ok(MicrosoftSession::new( + profile.id, + profile.name, + mc.access_token, + tokens.refresh_token, + mc.expires_in, + now_ms, + None, + )) +} + +/// 从响应里取 `XErr`(XSTS 的 401 业务码)。 +fn xerr(reply: &Reply) -> Option { + let body = reply.body.as_ref()?; + for key in ["XErr", "errorCode"] { + if let Some(code) = body.get(key).and_then(|value| value.as_u64()) { + if code != 0 { + return Some(code as u32); + } + } + } + None +} + +fn uhs_of(reply: &Reply) -> Option { + reply + .body + .as_ref()? + .get("DisplayClaims")? + .get("xui")? + .as_array()? + .first()? + .get("uhs")? + .as_str() + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::{has_minecraft_license, uhs_of, xerr, Reply}; + use serde_json::{json, Value}; + + fn reply(status: u16, body: Option) -> Reply { + Reply { + status, + body, + content_type: Some("application/json".to_string()), + raw: None, + } + } + + #[test] + fn xerr_is_read_from_401_body() { + let sample = reply( + 401, + Some(json!({ "Identity": "0", "XErr": 2_148_916_238_u32, "Message": "" })), + ); + assert_eq!(xerr(&sample), Some(2_148_916_238)); + assert_eq!(xerr(&reply(200, Some(json!({ "XErr": 0 })))), None); + assert_eq!(xerr(&reply(200, None)), None); + } + + #[test] + fn uhs_comes_from_first_xui_entry() { + let sample = reply( + 200, + Some(json!({ "DisplayClaims": { "xui": [{ "uhs": "1234" }] } })), + ); + assert_eq!(uhs_of(&sample).as_deref(), Some("1234")); + assert_eq!( + uhs_of(&reply(200, Some(json!({ "DisplayClaims": {} })))), + None + ); + } + + #[test] + fn license_detection() { + let with = reply( + 200, + Some( + json!({ "items": [{ "name": "product_minecraft" }, { "name": "game_minecraft" }] }), + ), + ); + let without = reply( + 200, + Some(json!({ "items": [{ "name": "product_minecraft" }] })), + ); + assert!(has_minecraft_license(&with)); + assert!(!has_minecraft_license(&without)); + assert!(!has_minecraft_license(&reply(500, None))); + } +} diff --git a/crates/core/src/auth/microsoft/endpoints.rs b/crates/core/src/auth/microsoft/endpoints.rs new file mode 100644 index 0000000..f981a24 --- /dev/null +++ b/crates/core/src/auth/microsoft/endpoints.rs @@ -0,0 +1,118 @@ +//! 微软端点表:**直连**(不走镜像,承 mission Context 与 `analyse/04` §4 的端点族区分)。 +//! +//! 两个端点族不能混用(`analyse/04` §4 的「第一个坑」): +//! - 设备码流:`login.microsoftonline.com/consumers/oauth2/v2.0/*`; +//! - HMCL 的浏览器授权码流走 `login.live.com`(本期不实现,故不在此表)。 +//! +//! 端点集中成一张表是为了**测试可替换**:整表指向一个进程内 mock 服务器即可覆盖全链, +//! 生产默认走 [`MicrosoftEndpoints::consumers`]。 + +/// 微软账户 OAuth 与 Xbox / Minecraft 服务端点。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MicrosoftEndpoints { + /// 设备码申请(`POST`,表单 `client_id` + `scope`)。 + pub device_code: String, + /// 换 token / 刷新 token(`POST`,表单)。 + pub token: String, + /// Xbox Live 用户认证(`POST` JSON)。 + pub xbl_authenticate: String, + /// XSTS 授权(`POST` JSON;无 Xbox 档案时回 **401** 带 `XErr`)。 + pub xsts_authorize: String, + /// `login_with_xbox`(`POST` JSON,`identityToken = "XBL3.0 x=;"`)。 + pub login_with_xbox: String, + /// 拥有权校验 `entitlements/mcstore`(`GET`,必须 200)。 + pub entitlements_mcstore: String, + /// 拥有权细分 `entitlements/license`(`GET`,`/minecraft/profile` 404 时复判用)。 + pub entitlements_license: String, + /// 档案 `minecraft/profile`(`GET`,带 Bearer)。 + pub profile: String, +} + +impl MicrosoftEndpoints { + /// 生产端点(微软消费版账户,直连)。 + #[must_use] + pub fn consumers() -> Self { + Self { + device_code: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode" + .to_string(), + token: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token".to_string(), + xbl_authenticate: "https://user.auth.xboxlive.com/user/authenticate".to_string(), + xsts_authorize: "https://xsts.auth.xboxlive.com/xsts/authorize".to_string(), + login_with_xbox: "https://api.minecraftservices.com/authentication/login_with_xbox" + .to_string(), + entitlements_mcstore: "https://api.minecraftservices.com/entitlements/mcstore" + .to_string(), + entitlements_license: "https://api.minecraftservices.com/entitlements/license" + .to_string(), + profile: "https://api.minecraftservices.com/minecraft/profile".to_string(), + } + } + + /// 把整表指向同一个基址(测试用;各端点用自己的路径后缀区分)。 + #[must_use] + pub fn all_on(base: &str) -> Self { + let join = |path: &str| format!("{}/{path}", base.trim_end_matches('/')); + Self { + device_code: join("devicecode"), + token: join("token"), + xbl_authenticate: join("xbl"), + xsts_authorize: join("xsts"), + login_with_xbox: join("login_with_xbox"), + entitlements_mcstore: join("entitlements/mcstore"), + entitlements_license: join("entitlements/license"), + profile: join("profile"), + } + } + + /// 所有端点(调试/诊断用)。 + #[must_use] + pub fn all(&self) -> [&str; 8] { + [ + &self.device_code, + &self.token, + &self.xbl_authenticate, + &self.xsts_authorize, + &self.login_with_xbox, + &self.entitlements_mcstore, + &self.entitlements_license, + &self.profile, + ] + } +} + +impl Default for MicrosoftEndpoints { + fn default() -> Self { + Self::consumers() + } +} + +#[cfg(test)] +mod tests { + use super::MicrosoftEndpoints; + + #[test] + fn consumers_endpoints_stay_on_two_families() { + let endpoints = MicrosoftEndpoints::consumers(); + assert!(endpoints.device_code.contains("login.microsoftonline.com")); + assert!(endpoints.token.contains("/consumers/oauth2/v2.0/token")); + assert!(endpoints + .xbl_authenticate + .contains("user.auth.xboxlive.com")); + assert!(endpoints.xsts_authorize.contains("xsts.auth.xboxlive.com")); + // 授权码流的 login.live.com 不在这张表里(本期不实现)。 + assert!(endpoints + .all() + .iter() + .all(|url| !url.contains("login.live.com"))); + } + + #[test] + fn all_on_keeps_paths_distinct() { + let endpoints = MicrosoftEndpoints::all_on("http://127.0.0.1:1234/"); + assert_eq!(endpoints.device_code, "http://127.0.0.1:1234/devicecode"); + assert_eq!( + endpoints.entitlements_license, + "http://127.0.0.1:1234/entitlements/license" + ); + } +} diff --git a/crates/core/src/auth/microsoft/flow.rs b/crates/core/src/auth/microsoft/flow.rs new file mode 100644 index 0000000..4c1d21d --- /dev/null +++ b/crates/core/src/auth/microsoft/flow.rs @@ -0,0 +1,313 @@ +//! device-code 流:发起(拿 `user_code` + `verification_uri`)→ 轮询换 token →(刷新同构)。 +//! +//! 逐条承 `analyse/04` §4.3 / §4.4 / §4.5: +//! - **硬超时**:只等 `min(expires_in, 900s)`,防止后台任务永久挂起; +//! - **`slow_down` 是流程内状态**,不是失败:轮询间隔 +5 秒继续; +//! - **每次轮询前先 sleep**(别密集打服务端); +//! - **refresh 同时返回新 refresh_token**,调用方必须整体覆盖(轮换); +//! - 收紧 HMCL 的观察项 1:**只要 `error` 非空就失败**,不再「描述为空就静默放行」。 +//! +//! 打印 `user_code`/URL 不在这里:core 不做 UI,宿主拿 [`DeviceCodePrompt`] 自己决定 +//! (CLI 打到 stderr,GUI 弹对话框 + 二维码)。 + +use std::time::{Duration, Instant}; + +use reqwest::Client; + +use crate::auth::error::{AuthError, AuthResult}; +use crate::auth::secret::Secret; + +use super::endpoints::MicrosoftEndpoints; +use crate::auth::http::{post_form, Reply}; + +/// 申请的 scope(`XboxLive.signin` + 长期刷新)。 +pub const SCOPE: &str = "XboxLive.signin offline_access"; +/// 设备码流程的硬上限:无论服务端给多长,最多等这么久。 +pub const DEVICE_CODE_MAX_WAIT: Duration = Duration::from_secs(900); +/// 默认轮询间隔下限(服务端 `interval` 更小时按下限走)。 +pub const DEFAULT_MIN_INTERVAL: Duration = Duration::from_secs(1); +/// `slow_down` 的退避步长(承 HMCL:+5 秒)。 +pub const DEFAULT_SLOW_DOWN_STEP: Duration = Duration::from_secs(5); +/// device_code grant 类型。 +pub const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code"; + +/// 轮询策略(生产用 [`PollPolicy::default`],测试用小间隔跑同一套状态机)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PollPolicy { + /// 轮询间隔下限。 + pub min_interval: Duration, + /// `slow_down` 的退避步长。 + pub slow_down_step: Duration, + /// 整个流程最多等多久(还会与 `expires_in` 取小)。 + pub max_wait: Duration, +} + +impl Default for PollPolicy { + fn default() -> Self { + Self { + min_interval: DEFAULT_MIN_INTERVAL, + slow_down_step: DEFAULT_SLOW_DOWN_STEP, + max_wait: DEVICE_CODE_MAX_WAIT, + } + } +} + +/// 要展示给用户的信息(URL + 用户码)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceCodePrompt { + /// 用户在验证页输入的短码(如 `ABCD1234`)。**不是凭据**,可打印。 + pub user_code: String, + /// 验证地址(`https://www.microsoft.com/link` 等)。 + pub verification_uri: String, + /// 服务端给的有效期。 + pub expires_in: Duration, + /// 服务端建议的轮询间隔。 + pub interval: Duration, +} + +impl DeviceCodePrompt { + /// 人读两行(CLI 打到 stderr,GUI 直接展示)。 + #[must_use] + pub fn lines(&self) -> (String, String) { + ( + format!("请在任意设备打开 {} 并输入代码:", self.verification_uri), + format!( + "用户码 {}({} 分钟内有效)", + self.user_code, + self.expires_in.as_secs() / 60 + ), + ) + } +} + +/// 设备码申请的中间态(`device_code` 是凭据形状,不进日志)。 +#[derive(Debug, Clone)] +pub(crate) struct DeviceCodeGrant { + /// 展示信息。 + pub prompt: DeviceCodePrompt, + /// 轮询用的长码(脱敏 Debug,永不打印)。 + pub device_code: Secret, +} + +/// OAuth 换到的 token 对。 +#[derive(Debug, Clone)] +pub(crate) struct OAuthTokens { + /// Live access token(只在链内用,不落盘)。 + pub access_token: Secret, + /// Live refresh token(落盘、轮换)。 + pub refresh_token: Secret, +} + +/// 申请设备码。 +pub(crate) async fn request_device_code( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, +) -> AuthResult { + let reply = post_form( + client, + &endpoints.device_code, + vec![ + ("client_id", client_id.to_string()), + ("scope", SCOPE.to_string()), + ], + ) + .await?; + if !reply.is_success() { + return Err(oauth_error(&reply, &endpoints.device_code)); + } + let user_code = reply + .text("user_code") + .ok_or_else(|| AuthError::Malformed("设备码响应缺 user_code".to_string()))?; + let device_code = reply + .text("device_code") + .ok_or_else(|| AuthError::Malformed("设备码响应缺 device_code".to_string()))?; + let verification_uri = reply + .text("verification_uri") + .or_else(|| reply.text("verification_url")) + .ok_or_else(|| AuthError::Malformed("设备码响应缺 verification_uri".to_string()))?; + let expires_in = reply.number("expires_in").unwrap_or(900).max(1); + // 服务端间隔原样收下,下限交给 [`PollPolicy::min_interval`](生产 1 秒,测试可压到毫秒)。 + let interval = reply.number("interval").unwrap_or(5); + Ok(DeviceCodeGrant { + prompt: DeviceCodePrompt { + user_code: user_code.to_string(), + verification_uri: verification_uri.to_string(), + expires_in: Duration::from_secs(expires_in), + interval: Duration::from_secs(interval), + }, + device_code: Secret::new(device_code), + }) +} + +/// 轮询直到用户完成授权(或超时/被拒/取消)。 +pub(crate) async fn poll_device_token( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + grant: &DeviceCodeGrant, + policy: PollPolicy, +) -> AuthResult { + let limit = grant.prompt.expires_in.min(policy.max_wait); + let mut interval = grant.prompt.interval.max(policy.min_interval); + let started = Instant::now(); + loop { + if started.elapsed() >= limit { + return Err(AuthError::Timeout { + what: format!("等待设备码授权(上限 {} 秒)", limit.as_secs()), + }); + } + tokio::time::sleep(interval).await; + let reply = post_form( + client, + &endpoints.token, + vec![ + ("client_id", client_id.to_string()), + ("grant_type", DEVICE_CODE_GRANT.to_string()), + ("code", grant.device_code.expose().to_string()), + ], + ) + .await?; + + if let Some(error) = reply.text("error") { + match error { + "authorization_pending" => continue, + // 服务端压力反馈:不是失败,退避后继续(承 HMCL)。 + "slow_down" => { + interval += policy.slow_down_step; + continue; + } + "expired_token" => { + return Err(AuthError::Timeout { + what: "设备码已过期(需重新发起登录)".to_string(), + }) + } + "access_denied" | "authorization_declined" => return Err(AuthError::Canceled), + _ => return Err(oauth_error(&reply, &endpoints.token)), + } + } + return tokens_from(&reply, &endpoints.token); + } +} + +/// 用 refresh_token 换新的 token 对(**刷新也会返回新 refresh_token**)。 +pub(crate) async fn refresh_tokens( + client: &Client, + endpoints: &MicrosoftEndpoints, + client_id: &str, + refresh_token: &Secret, +) -> AuthResult { + let reply = post_form( + client, + &endpoints.token, + vec![ + ("client_id", client_id.to_string()), + ("grant_type", "refresh_token".to_string()), + ("refresh_token", refresh_token.expose().to_string()), + ], + ) + .await?; + if let Some(error) = reply.text("error") { + // `invalid_grant` + `AADSTS70000` = 授权被撤销/过期 → 唯一该重新登录的信号。 + if error == "invalid_grant" { + let description = reply.text("error_description").unwrap_or(""); + if description.contains("AADSTS70000") { + return Err(AuthError::CredentialExpired); + } + } + return Err(oauth_error(&reply, &endpoints.token)); + } + if !reply.is_success() { + return Err(oauth_error(&reply, &endpoints.token)); + } + tokens_from(&reply, &endpoints.token) +} + +fn tokens_from(reply: &Reply, url: &str) -> AuthResult { + let access_token = reply + .text("access_token") + .ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 access_token")))?; + let refresh_token = reply + .text("refresh_token") + .ok_or_else(|| AuthError::Malformed(format!("{url} 响应缺 refresh_token")))?; + Ok(OAuthTokens { + access_token: Secret::new(access_token), + refresh_token: Secret::new(refresh_token), + }) +} + +/// OAuth 错误归一:**`error` 非空即失败**(HMCL 只在两个字段都非空时处理,我们收紧)。 +fn oauth_error(reply: &Reply, url: &str) -> AuthError { + let Some(code) = reply.text("error") else { + return AuthError::network( + url.split('?').next().unwrap_or(url), + format!("HTTP {}(响应不是 OAuth 错误体)", reply.status), + ); + }; + let description = reply + .text("error_description") + .unwrap_or("服务端未给描述") + .to_string(); + if code == "invalid_grant" && description.contains("AADSTS70000") { + return AuthError::CredentialExpired; + } + AuthError::Denied { + code: code.to_string(), + description, + } +} + +#[cfg(test)] +mod tests { + use super::{oauth_error, PollPolicy, DEFAULT_MIN_INTERVAL, DEVICE_CODE_MAX_WAIT}; + use crate::auth::error::AuthError; + use crate::auth::http::Reply; + use serde_json::json; + + #[test] + fn default_policy_matches_hmcl_bounds() { + let policy = PollPolicy::default(); + assert_eq!(policy.min_interval, DEFAULT_MIN_INTERVAL); + assert_eq!(policy.max_wait, DEVICE_CODE_MAX_WAIT); + assert_eq!(policy.max_wait.as_secs(), 900); + } + + #[test] + fn error_without_description_still_fails() { + let reply = reply(400, Some(json!({ "error": "invalid_request" }))); + match oauth_error(&reply, "https://example.invalid/token") { + AuthError::Denied { code, .. } => assert_eq!(code, "invalid_request"), + other => panic!("应是 Denied,实际 {other:?}"), + } + } + + #[test] + fn aadsts70000_becomes_credential_expired() { + let reply = reply( + 400, + Some(json!({ + "error": "invalid_grant", + "error_description": "AADSTS70000: The grant is expired or revoked" + })), + ); + assert!(oauth_error(&reply, "https://example.invalid/token").is_credential_expired()); + } + + #[test] + fn non_oauth_body_is_network_error() { + let reply = reply(500, None); + assert_eq!( + oauth_error(&reply, "https://example.invalid/token").code(), + "network" + ); + } + + fn reply(status: u16, body: Option) -> Reply { + Reply { + status, + body, + content_type: Some("application/json".to_string()), + raw: None, + } + } +} diff --git a/crates/core/src/auth/microsoft/mod.rs b/crates/core/src/auth/microsoft/mod.rs new file mode 100644 index 0000000..ace4aee --- /dev/null +++ b/crates/core/src/auth/microsoft/mod.rs @@ -0,0 +1,332 @@ +//! 微软轨:device-code 全流程 + 会话/刷新语义(`PlanDoc/07-accounts.md` §1、`analyse/04` §4–§5)。 +//! +//! 分四块,各管一件事: +//! - [`session`]:`MicrosoftSession`(凭据载荷)与**刷新判定**(纯函数,可脱离网络单测); +//! - [`endpoints`]:端点表(生产 = 微软消费版直连;测试 = 整表指向 mock); +//! - [`flow`]:device-code 发起 / 轮询 / refresh(OAuth 层); +//! - [`chain`]:XBL → XSTS → login_with_xbox → entitlements → profile(Xbox/MC 层)。 +//! +//! # client id 从哪来 +//! +//! device-code 流必须带一个**已在 Azure 注册的公共客户端 id**。HMCL 把它烘进 jar 的 +//! manifest(`hmcl.microsoft.auth.id`),我们同理:二进制里不硬编码,改由 +//! `ULL_MSA_CLIENT_ID`(运行期)或构建期 `option_env!` 提供。没有配时 +//! [`MicrosoftAuth::from_env`] 返回 [`AuthError::Unsupported`] 并说清要配什么—— +//! 这比编一个假 id 让用户看到 `invalid_client` 诚实。 + +pub mod chain; +pub mod endpoints; +pub mod flow; + +pub mod session; + +pub use endpoints::MicrosoftEndpoints; +pub use flow::{DeviceCodePrompt, PollPolicy}; +pub use session::{MicrosoftSession, RefreshDecision, RefreshReason, USER_TYPE_MSA}; + +use std::time::{SystemTime, UNIX_EPOCH}; + +use reqwest::Client; + +use crate::auth::error::{AuthError, AuthResult}; +use crate::auth::journal::{AuthFailureEvent, AuthJournal}; +use crate::download::config::EngineConfig; +use crate::download::http::build_client; + +/// 运行期环境变量名(配 client id)。 +pub const CLIENT_ID_ENV: &str = "ULL_MSA_CLIENT_ID"; +/// 构建期可注入的 client id(`ULL_MSA_CLIENT_ID=... cargo build`)。 +pub const CLIENT_ID_BUILD: Option<&str> = option_env!("ULL_MSA_CLIENT_ID"); + +/// 微软账户登录/刷新。 +#[derive(Debug, Clone)] +pub struct MicrosoftAuth { + client: Client, + client_id: String, + endpoints: MicrosoftEndpoints, + poll: PollPolicy, + journal: Option, +} + +impl MicrosoftAuth { + /// 按显式 client id 构造(HTTP 基座复用 `core::download` 的客户端构造)。 + /// + /// # Errors + /// + /// HTTP 客户端建不起来时返回 [`AuthError::Network`]。 + pub fn new(client_id: impl Into) -> AuthResult { + let client = build_client(&EngineConfig::default()) + .map_err(|error| AuthError::network("http-client", error.to_string()))?; + Ok(Self { + client, + client_id: client_id.into(), + endpoints: MicrosoftEndpoints::consumers(), + poll: PollPolicy::default(), + journal: None, + }) + } + + /// 从环境/构建期取 client id(CLI 与 GUI 的默认入口)。 + /// + /// 与 [`Self::new`] 的唯一差别:这条**生产入口**默认挂上便携数据根的失败日志 + /// (`.ULL/logs/auth.jsonl`,见 [`AuthJournal::portable_default`]), + /// 交互式命令失败时 403 的状态码/正文摘要不会再随终端滚走。 + /// + /// # Errors + /// + /// 两处都没配时返回 [`AuthError::Unsupported`]。 + pub fn from_env() -> AuthResult { + let client_id = std::env::var(CLIENT_ID_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| CLIENT_ID_BUILD.map(str::to_string)) + .ok_or_else(|| { + AuthError::Unsupported(format!( + "微软登录需要 Azure 公共客户端 id:设置环境变量 {CLIENT_ID_ENV}(或构建时注入)" + )) + })?; + let mut auth = Self::new(client_id)?; + auth.journal = AuthJournal::portable_default(); + Ok(auth) + } + + /// 换失败日志落点(`None` = 不落盘;CI/单测用)。 + #[must_use] + pub fn with_journal(mut self, journal: Option) -> Self { + self.journal = journal; + self + } + + /// 关掉失败日志。 + #[must_use] + pub fn without_journal(mut self) -> Self { + self.journal = None; + self + } + + /// 失败日志落点(测试断言/宿主展示用)。 + #[must_use] + pub fn journal(&self) -> Option<&AuthJournal> { + self.journal.as_ref() + } + + /// 换 HTTP 客户端(测试注入 / 复用宿主连接池)。 + #[must_use] + pub fn with_client(mut self, client: Client) -> Self { + self.client = client; + self + } + + /// 换端点表(测试整表指向 mock 服务器)。 + #[must_use] + pub fn with_endpoints(mut self, endpoints: MicrosoftEndpoints) -> Self { + self.endpoints = endpoints; + self + } + + /// 换轮询策略(测试用小间隔)。 + #[must_use] + pub fn with_poll_policy(mut self, poll: PollPolicy) -> Self { + self.poll = poll; + self + } + + /// 当前端点表。 + #[must_use] + pub fn endpoints(&self) -> &MicrosoftEndpoints { + &self.endpoints + } + + /// device-code 登录全流程。 + /// + /// `present` 在拿到用户码后**立刻**被调用(CLI 打到 stderr、GUI 弹框/二维码), + /// 然后才开始轮询——用户有整整 `expires_in` 的时间去另一台设备上输码。 + /// + /// 失败时除返回错误外,还会往失败日志(若配了)追加一条结构化记录。 + /// + /// # Errors + /// + /// 网络/超时/取消/被拒/无拥有权/无档案,见 [`AuthError`]。 + pub async fn device_code_login(&self, mut present: F) -> AuthResult + where + F: FnMut(&DeviceCodePrompt), + { + let result = self.login_and_chain(&mut present).await; + if let Err(error) = &result { + self.record_failure("device_code_login", error); + } + result + } + + /// 登录主体(`device_code_login` 的失败日志由外层统一记)。 + async fn login_and_chain( + &self, + present: &mut impl FnMut(&DeviceCodePrompt), + ) -> AuthResult { + let grant = + flow::request_device_code(&self.client, &self.endpoints, &self.client_id).await?; + present(&grant.prompt); + let tokens = flow::poll_device_token( + &self.client, + &self.endpoints, + &self.client_id, + &grant, + self.poll, + ) + .await?; + chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await + } + + /// 刷新:Live refresh → 整链重跑(**原样覆盖 refresh_token**)。 + /// + /// # Errors + /// + /// 凭据被撤销时返回 [`AuthError::CredentialExpired`](唯一该重新交互的信号)。 + pub async fn refresh(&self, session: &MicrosoftSession) -> AuthResult { + let result = self.refresh_and_chain(session).await; + if let Err(error) = &result { + self.record_failure("refresh", error); + } + result + } + + /// 刷新主体(失败日志由外层统一记)。 + async fn refresh_and_chain(&self, session: &MicrosoftSession) -> AuthResult { + let tokens = flow::refresh_tokens( + &self.client, + &self.endpoints, + &self.client_id, + &session.refresh_token, + ) + .await?; + let refreshed = + chain::complete_chain(&self.client, &self.endpoints, tokens, now_ms()).await?; + if !session.same_profile(&refreshed) { + // 防「刷出来是另一个账号」(承 HMCL WrongAccountException)。 + return Err(AuthError::Malformed(format!( + "刷新后档案变了({} → {})", + session.profile_id, refreshed.profile_id + ))); + } + Ok(refreshed) + } + + /// 尽力而为地记一条失败事件(写日志失败绝不影响登录本身的结论)。 + fn record_failure(&self, scope: &str, error: &AuthError) { + if let Some(journal) = &self.journal { + let _ = journal.record(&AuthFailureEvent::of(scope, error)); + } + } + + /// 网络侧校验:`GET /minecraft/profile` 是否还认这个 token。 + /// + /// 404 的二义在这里**不吞**:档案被删是真实错误,不该被当成「token 失效」静默刷新 + /// (`analyse/04` §11 观察项 3 那个隐式约定,我们写明)。 + /// + /// # Errors + /// + /// 网络失败、无拥有权、无档案等。 + pub async fn validate(&self, session: &MicrosoftSession) -> AuthResult { + if session.is_expired(now_ms()) { + return Ok(false); + } + match chain::fetch_profile(&self.client, &self.endpoints, &session.access_token).await { + Ok(_) => Ok(true), + // 「档案被删 / 没买游戏」是真实错误,穿透上抛(别静默当成 token 失效去刷新)。 + Err(error @ AuthError::Denied { .. }) => { + let fatal = matches!( + &error, + AuthError::Denied { code, .. } if code == "no_profile" || code == "no_license" + ); + if fatal { + Err(error) + } else { + Ok(false) + } + } + // 链上非 2xx:拦截页(HTML/纯文本)说明网络有问题,别伪装成「token 失效」; + // 服务端的 JSON 拒绝 / 空体仍按「token 不被接受」处理(与 M31 语义一致)。 + Err(error @ AuthError::Response(_)) => { + let interception = matches!( + &error, + AuthError::Response(detail) if detail.is_interception() + ); + if interception { + Err(error) + } else { + Ok(false) + } + } + Err(other) => Err(other), + } + } + + /// 需要就刷新(本地判定,不打网络),返回 `(会话, 是否刷新过)`。 + /// + /// 这是 GUI/CLI 的「静默续期」入口:`Reuse` 时零网络,`Refresh` 时整链重跑。 + /// + /// # Errors + /// + /// 刷新失败时原样上抛(含 [`AuthError::CredentialExpired`])。 + pub async fn ensure_fresh( + &self, + session: &MicrosoftSession, + ) -> AuthResult<(MicrosoftSession, Option)> { + match session.refresh_decision(now_ms()) { + RefreshDecision::Reuse => Ok((session.clone(), None)), + RefreshDecision::Refresh(reason) => { + let refreshed = self.refresh(session).await?; + Ok((refreshed, Some(reason))) + } + } + } +} + +/// 当前 Unix 毫秒。 +#[must_use] +pub fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::{now_ms, MicrosoftAuth, CLIENT_ID_ENV}; + use crate::auth::error::AuthError; + + #[test] + fn explicit_client_id_builds() { + let auth = MicrosoftAuth::new("0000-test-client").expect("构造"); + assert!(auth + .endpoints() + .profile + .contains("api.minecraftservices.com")); + } + + #[test] + fn missing_client_id_is_explained() { + // 测试进程里也可能被外部配了环境变量,先摘掉再验。 + let saved = std::env::var(CLIENT_ID_ENV).ok(); + std::env::remove_var(CLIENT_ID_ENV); + let result = MicrosoftAuth::from_env(); + if let Some(value) = saved { + std::env::set_var(CLIENT_ID_ENV, value); + } + match result { + Err(AuthError::Unsupported(message)) => assert!(message.contains(CLIENT_ID_ENV)), + // 构建期注入过 client id 时唯一合法,跳过断言。 + Ok(_) => assert!(super::CLIENT_ID_BUILD.is_some()), + Err(other) => panic!("应是 Unsupported,实际 {other:?}"), + } + } + + #[test] + fn now_ms_is_sane() { + // 2020-01-01 之后、2100 之前(防止本地时钟/单位抄错)。 + let now = now_ms(); + assert!(now > 1_577_836_800_000); + assert!(now < 4_102_444_800_000); + } +} diff --git a/crates/core/src/auth/microsoft/session.rs b/crates/core/src/auth/microsoft/session.rs new file mode 100644 index 0000000..b6df49b --- /dev/null +++ b/crates/core/src/auth/microsoft/session.rs @@ -0,0 +1,231 @@ +//! 微软会话与刷新语义:**HMCL 三层判定的类型化版本**(`analyse/04` §5.3 / §3.5 / §8.4)。 +//! +//! 三层判定原样平移: +//! 1. `has_profile_name()`:没有玩家名就没法启动(`--username` 不能为空); +//! 2. `not_after`:本地时间戳过期即视为未登录(省一次网络); +//! 3. 进程内 `authenticated` 标记归宿主(GUI/CLI)持有,不在本类型里。 +//! +//! 关键语义(照 HMCL,别自己发明): +//! - 存下来的 `refresh_token` 是 **Live(MSA)** 的,`access_token` 是 **Minecraft** 的; +//! - 刷新 = 整条链重跑(Live refresh → XBL → XSTS → login_with_xbox → entitlements → profile), +//! 不存在「只刷 MC token」的捷径; +//! - `profile_name` 属于**可变的私有缓存**(2022 年后允许改名),元数据里只留不可变的 profileID。 + +use serde::{Deserialize, Serialize}; + +use crate::auth::error::{AuthError, AuthResult}; +use crate::auth::secret::Secret; +use crate::launch::request::AuthPlaceholders; + +/// 微软账户的 launch 用户类型(`AuthInfo.USER_TYPE_MSA`)。 +pub const USER_TYPE_MSA: &str = "msa"; + +/// 一次成功登录后的会话(= 该账户的凭据载荷)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MicrosoftSession { + /// Minecraft 档案 UUID(32 位无连字符)。**不可变**。 + pub profile_id: String, + /// 玩家名(可空串:磁盘恢复允许「有档案但名字未知」,登录时补齐)。 + #[serde(default)] + pub profile_name: String, + /// MC token 类型(`Bearer`)。 + pub token_type: String, + /// Minecraft access token。 + pub access_token: Secret, + /// Live refresh token(轮换:每次刷新都会被新值整体覆盖)。 + pub refresh_token: Secret, + /// MC token 过期时刻(Unix 毫秒;0 = 未知 → 强制刷新)。 + pub not_after_ms: u64, + /// Xbox user id(`XUID`,诊断/排障用,非凭据)。 + pub user_id: Option, +} + +/// 过期判定的结论。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefreshDecision { + /// 本地认为可用(有玩家名且未过期):直接复用,不打网络。 + Reuse, + /// 需要整链重跑。 + Refresh(RefreshReason), +} + +/// 为什么要刷新。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefreshReason { + /// 玩家名缺失(改名或历史档案)——启动参数拿不到 `--username`。 + MissingProfileName, + /// 本地时间戳已过期。 + Expired, + /// 名字缺失**且**过期(两条理由都成立)。 + MissingProfileNameAndExpired, +} + +impl RefreshReason { + /// 人读一行。 + #[must_use] + pub const fn message(self) -> &'static str { + match self { + Self::MissingProfileName => "会话缺少玩家名,需要刷新", + Self::Expired => "Minecraft 令牌已过期,需要刷新", + Self::MissingProfileNameAndExpired => "会话缺少玩家名且令牌已过期,需要刷新", + } + } +} + +impl MicrosoftSession { + /// 构造(`expires_in` 是秒,`now_ms` 是当前 Unix 毫秒)。 + #[must_use] + pub fn new( + profile_id: impl Into, + profile_name: impl Into, + access_token: Secret, + refresh_token: Secret, + expires_in_secs: u64, + now_ms: u64, + user_id: Option, + ) -> Self { + Self { + profile_id: profile_id.into(), + profile_name: profile_name.into(), + token_type: "Bearer".to_string(), + access_token, + refresh_token, + not_after_ms: now_ms.saturating_add(expires_in_secs.saturating_mul(1000)), + user_id, + } + } + + /// 有没有玩家名(空串/全空白都算没有)。 + #[must_use] + pub fn has_profile_name(&self) -> bool { + !self.profile_name.trim().is_empty() + } + + /// 本地是否过期(`not_after_ms == 0` 视为过期,承 HMCL「坏数据强制刷新」)。 + #[must_use] + pub fn is_expired(&self, now_ms: u64) -> bool { + self.not_after_ms == 0 || now_ms >= self.not_after_ms + } + + /// 刷新判定(纯函数,可单测;不打网络)。 + #[must_use] + pub fn refresh_decision(&self, now_ms: u64) -> RefreshDecision { + match (self.has_profile_name(), self.is_expired(now_ms)) { + (true, false) => RefreshDecision::Reuse, + (false, false) => RefreshDecision::Refresh(RefreshReason::MissingProfileName), + (true, true) => RefreshDecision::Refresh(RefreshReason::Expired), + (false, true) => RefreshDecision::Refresh(RefreshReason::MissingProfileNameAndExpired), + } + } + + /// 该会话所有凭据值(武装脱敏器用)。 + #[must_use] + pub fn secrets(&self) -> Vec<&Secret> { + vec![&self.access_token, &self.refresh_token] + } + + /// 转成 launch 的占位符集合(`user_properties` 为 `{}`,承 HMCL)。 + /// + /// # Errors + /// + /// 会话缺少玩家名/UUID 时返回 [`AuthError::CredentialExpired`]——此时必须刷新, + /// 不能拿空名字去拼命令行(`--username` 空串会导致游戏直接退出)。 + pub fn to_placeholders(&self) -> AuthResult { + if !self.has_profile_name() { + return Err(AuthError::CredentialExpired); + } + if self.profile_id.is_empty() { + return Err(AuthError::Malformed("会话缺少 profileID".to_string())); + } + Ok(AuthPlaceholders { + player_name: self.profile_name.clone(), + uuid: self.profile_id.clone(), + access_token: self.access_token.expose().to_string(), + user_type: USER_TYPE_MSA.to_string(), + user_properties: "{}".to_string(), + }) + } + + /// 刷新后守住两条不变量(`analyse/04` §5.3 的 `WrongAccountException` / `hasProfileName`)。 + /// + /// 返回 `false` 表示「刷出来的不是同一个档案」,调用方必须当错误处理。 + #[must_use] + pub fn same_profile(&self, other: &Self) -> bool { + self.profile_id == other.profile_id + } +} + +#[cfg(test)] +mod tests { + use super::{MicrosoftSession, RefreshDecision, RefreshReason}; + use crate::auth::secret::Secret; + + fn session(name: &str, not_after_ms: u64) -> MicrosoftSession { + MicrosoftSession { + profile_id: "0f8fad5bd9cb469fa16570867728950e".to_string(), + profile_name: name.to_string(), + token_type: "Bearer".to_string(), + access_token: Secret::new("mc-token-value"), + refresh_token: Secret::new("live-refresh-value"), + not_after_ms, + user_id: None, + } + } + + #[test] + fn decision_matrix() { + let now = 1_000_000; + assert_eq!( + session("Steve", now + 1).refresh_decision(now), + RefreshDecision::Reuse + ); + assert_eq!( + session("", now + 1).refresh_decision(now), + RefreshDecision::Refresh(RefreshReason::MissingProfileName) + ); + assert_eq!( + session("Steve", now - 1).refresh_decision(now), + RefreshDecision::Refresh(RefreshReason::Expired) + ); + assert_eq!( + session(" ", now).refresh_decision(now), + RefreshDecision::Refresh(RefreshReason::MissingProfileNameAndExpired) + ); + } + + #[test] + fn zero_not_after_forces_refresh() { + assert_eq!( + session("Steve", 0).refresh_decision(5), + RefreshDecision::Refresh(RefreshReason::Expired) + ); + } + + #[test] + fn new_computes_not_after_from_expires_in() { + let built = MicrosoftSession::new( + "uuid", + "Steve", + Secret::new("a"), + Secret::new("r"), + 86_400, + 1_000, + None, + ); + assert_eq!(built.not_after_ms, 1_000 + 86_400_000); + assert_eq!(built.token_type, "Bearer"); + } + + #[test] + fn placeholders_need_a_name() { + assert!(session("", 10).to_placeholders().is_err()); + let placeholders = session("Steve", 10).to_placeholders().expect("占位符"); + assert_eq!(placeholders.player_name, "Steve"); + assert_eq!(placeholders.user_type, "msa"); + assert_eq!(placeholders.user_properties, "{}"); + // token 进了占位符(启动需要),但它自己的 Debug 是脱敏的。 + assert_eq!(placeholders.access_token, "mc-token-value"); + assert!(!format!("{:?}", session("Steve", 10)).contains("mc-token-value")); + } +} diff --git a/crates/core/src/auth/mod.rs b/crates/core/src/auth/mod.rs new file mode 100644 index 0000000..329030c --- /dev/null +++ b/crates/core/src/auth/mod.rs @@ -0,0 +1,52 @@ +//! 账户体系:三轨(离线 UUID / 微软 device-code / authlib-injector)、凭据存储边界。 +//! +//! 设计定稿:`DevDocs/PlanDoc/07-accounts.md`(决策 D9/Q21),协议细节来自 +//! `DevDocs/analyse/04-authentication.md`(HMCL v3.17 拆解,逐报文对照)。 +//! +//! # 三条铁律 +//! +//! 1. **凭据永不外泄**:token/密码不落日志、不进事件流、不打印(`AGENTS.md` §6、D4)。 +//! 类型层保证:[`Secret`] 的 `Debug`/`Display` 恒为 ``,取明文只能 `.expose()`; +//! 对外展示一律走元数据([`AccountMeta`],不含凭据)。 +//! 2. **账户操作永不 YOLO**:本模块不向 AI 暴露任何账户读写面(07 §3); +//! CLI 也只开 `list / add offline / add msa / remove` 四条(authlib 密码登录走 GUI 输入框, +//! 避免密码进 argv)。 +//! 3. **元数据 / 凭据分文件**:`data/auth/accounts.json` + `data/auth/credentials.dat`, +//! 先写凭据后写元数据,原子写,单条坏记录只跳过不整体失败。 +//! +//! # 公共面 +//! +//! - [`account`]:`AccountId` / `AccountKind` / `AccountMeta` / `Credentials`(三轨统一抽象); +//! - [`store`]:`AccountStore`(加载/增删/落盘,`data/auth/`); +//! - [`offline`]:HMCL 兼容离线 UUID + 启动占位; +//! - [`microsoft`]:device-code 全链(XBL→XSTS→login_with_xbox→entitlements→profile)+ 刷新判定; +//! - [`authlib`]:标准 Yggdrasil 协议(authenticate/refresh/validate/invalidate,账户侧); +//! - [`error`]:错误分流(取消 / 超时 / 被拒 / 凭据过期); +//! - [`body`]:非 2xx 应答的正文诊断(分类 JSON/HTML/空体 + 脱敏摘要,M38); +//! - [`journal`]:认证失败事件落盘(`.ULL/logs/auth.jsonl`,M38); +//! - [`secret`]:凭据值包装与脱敏。 +//! +//! # 本期不做(后续 mission) +//! +//! OS keychain / AES-256-GCM 凭据加密(07 §2 分级 1–2)、authlib-injector 的 +//! `-javaagent` 三件套与 jar 下载、皮肤与多角色选择、离线 UUID 的「官方风格」双模式 +//! (07 §1 升级 2,口径待定案)。接口约定见 `DevDocs/SubAgentsTalk/auth-接口约定.md`。 + +pub mod account; +pub mod authlib; +pub mod body; +pub mod error; +pub mod http; +pub mod journal; +pub mod microsoft; +pub mod offline; +pub mod secret; +pub mod store; + +pub use account::{AccountId, AccountKind, AccountMeta, Credentials}; +pub use body::{BodyKind, ResponseDetail}; +pub use error::{AuthError, AuthResult}; +pub use journal::{AuthFailureEvent, AuthJournal}; +pub use microsoft::{MicrosoftAuth, MicrosoftSession, RefreshDecision, RefreshReason}; +pub use secret::Secret; +pub use store::{AccountStore, AuthPaths}; diff --git a/crates/core/src/auth/offline.rs b/crates/core/src/auth/offline.rs new file mode 100644 index 0000000..3ab321f --- /dev/null +++ b/crates/core/src/auth/offline.rs @@ -0,0 +1,197 @@ +//! 离线轨:**HMCL / Minecraft 服务端同款离线 UUID** + 启动占位字段。 +//! +//! UUID 算法(`analyse/04` §7.1):`UUID.nameUUIDFromBytes("OfflinePlayer:" + name)` +//! = 对 UTF-8 字节做 MD5,再按 RFC 4122 改写成 **v3** 版本位与 **IETF 变体**位。 +//! 必须逐字节一致:离线服务器的 `ops.json` / 白名单 / 领地数据全按这个 UUID 存, +//! 算错就对不上(皮肤、权限、存档全乱)。因此有与 JDK `UUID.nameUUIDFromBytes` +//! 对拍的已知向量测试(见 `crates/core/tests/auth/offline_uuid.rs`)。 +//! +//! 启动占位(`analyse/04` §7.2,承 `OfflineAccount.logInWithoutSkin`): +//! - `accessToken` = **每次登录随机生成的 32 位十六进制**(游戏客户端不接受空 token); +//! - `userType` = `msa`(HMCL 现版本如此;历史注释提过 `legacy`,但那是旧结论); +//! - `userProperties` = `{}`; +//! - `uuid` 用**无连字符** 32 位形式(`--uuid` 参数要求)。 + +use md5::{Digest, Md5}; + +use crate::auth::account::{AccountKind, AccountMeta}; +use crate::auth::error::{AuthError, AuthResult}; +use crate::auth::secret::{random_hex32, Secret}; +use crate::launch::request::AuthPlaceholders; + +/// 离线 UUID 的固定前缀(改一个字节就会与所有服务端对不上)。 +pub const OFFLINE_PREFIX: &str = "OfflinePlayer:"; +/// 离线账户的 launch 用户类型(`AuthInfo.USER_TYPE_MSA`)。 +pub const OFFLINE_USER_TYPE: &str = "msa"; +/// 玩家名长度上限(MC 客户端 `--username` 的实际上限)。 +pub const MAX_NAME_CHARS: usize = 16; + +/// 算离线 UUID(**32 位无连字符小写十六进制**)。 +#[must_use] +pub fn offline_uuid(name: &str) -> String { + let digest = Md5::digest(format!("{OFFLINE_PREFIX}{name}").as_bytes()); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest); + + // RFC 4122 §4.3:版本位 3(MD5 based)、变体位 IETF(10xx)。 + bytes[6] = (bytes[6] & 0x0F) | 0x30; + bytes[8] = (bytes[8] & 0x3F) | 0x80; + + let mut hex = String::with_capacity(32); + for byte in bytes { + hex.push_str(&format!("{byte:02x}")); + } + hex +} + +/// 带连字符的离线 UUID(展示/诊断用;启动参数要的是无连字符版)。 +#[must_use] +pub fn offline_uuid_dashed(name: &str) -> String { + let raw = offline_uuid(name); + format!( + "{}-{}-{}-{}-{}", + &raw[0..8], + &raw[8..12], + &raw[12..16], + &raw[16..20], + &raw[20..32] + ) +} + +/// 校验并规范化离线玩家名。 +/// +/// 规则:非空、去掉首尾空白后 ≤16 字符、不含控制字符。 +/// **允许非 ASCII**(中文名在离线服里很常见,UUID 按 UTF-8 字节算即可)。 +/// +/// # Errors +/// +/// 空名/过长/含控制字符时返回 [`AuthError::Malformed`]。 +pub fn normalize_name(name: &str) -> AuthResult { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(AuthError::Malformed("玩家名不能为空".to_string())); + } + if trimmed.chars().count() > MAX_NAME_CHARS { + return Err(AuthError::Malformed(format!( + "玩家名最长 {MAX_NAME_CHARS} 个字符,收到 {} 个", + trimmed.chars().count() + ))); + } + if trimmed.chars().any(char::is_control) { + return Err(AuthError::Malformed("玩家名不能含控制字符".to_string())); + } + Ok(trimmed.to_string()) +} + +/// 建一条离线账户元数据(名字已规范化)。 +/// +/// # Errors +/// +/// 名字不合法时返回 [`AuthError::Malformed`]。 +pub fn offline_meta(name: &str, created_at_ms: u64) -> AuthResult { + let name = normalize_name(name)?; + Ok(AccountMeta::new( + AccountKind::Offline, + offline_uuid(&name), + name, + created_at_ms, + )) +} + +/// 生成一次「登录」的启动占位(不联网、不落盘)。 +/// +/// # Errors +/// +/// 元数据不是离线轨或名字为空时返回 [`AuthError::Malformed`] / [`AuthError::CredentialExpired`]。 +pub fn offline_login(meta: &AccountMeta) -> AuthResult { + if meta.kind != AccountKind::Offline { + return Err(AuthError::Malformed(format!( + "账户 {} 不是离线轨({})", + meta.id, + meta.kind.as_str() + ))); + } + if meta.profile_name.trim().is_empty() { + return Err(AuthError::CredentialExpired); + } + Ok(AuthPlaceholders { + player_name: meta.profile_name.clone(), + uuid: meta.profile_id.clone(), + // 随机占位:每次登录都变(与 HMCL 一致),不落盘、不进日志。 + access_token: Secret::new(random_hex32()).expose().to_string(), + user_type: OFFLINE_USER_TYPE.to_string(), + user_properties: "{}".to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::{ + normalize_name, offline_login, offline_meta, offline_uuid, offline_uuid_dashed, + OFFLINE_USER_TYPE, + }; + use crate::auth::account::AccountKind; + + #[test] + fn uuid_matches_jdk_name_uuid_from_bytes() { + // 向量由 JDK 25 的 UUID.nameUUIDFromBytes 生成(见 tests/auth/offline_uuid.rs 的说明)。 + assert_eq!( + offline_uuid("Notch"), + "b50ad385829d3141a2167e7d7539ba7f".to_string() + ); + assert_eq!( + offline_uuid_dashed("Notch"), + "b50ad385-829d-3141-a216-7e7d7539ba7f" + ); + } + + #[test] + fn uuid_has_v3_and_ietf_bits() { + let raw = offline_uuid("AnyPlayer"); + let version = u8::from_str_radix(&raw[12..13], 16).expect("版本位"); + assert_eq!(version, 3, "必须标记为 v3(MD5)"); + let variant = u8::from_str_radix(&raw[16..17], 16).expect("变体位"); + assert!( + (0x8..=0xb).contains(&variant), + "变体位必须是 IETF(10xx),实际 {variant:x}" + ); + } + + #[test] + fn uuid_is_stable_and_case_sensitive() { + assert_eq!(offline_uuid("Steve"), offline_uuid("Steve")); + assert_ne!(offline_uuid("Steve"), offline_uuid("steve")); + } + + #[test] + fn name_validation() { + assert_eq!(normalize_name(" Steve ").expect("合法"), "Steve"); + assert_eq!(normalize_name("小明").expect("中文合法"), "小明"); + assert!(normalize_name(" ").is_err()); + assert!(normalize_name("0123456789abcdefg").is_err()); + assert!(normalize_name("bad\u{7}name").is_err()); + } + + #[test] + fn offline_meta_and_login_shape() { + let meta = offline_meta("小明", 1_000).expect("建元数据"); + assert_eq!(meta.kind, AccountKind::Offline); + assert_eq!(meta.profile_id, offline_uuid("小明")); + let placeholders = offline_login(&meta).expect("占位符"); + assert_eq!(placeholders.player_name, "小明"); + assert_eq!(placeholders.uuid, offline_uuid("小明")); + assert_eq!(placeholders.user_type, OFFLINE_USER_TYPE); + assert_eq!(placeholders.user_properties, "{}"); + assert_eq!(placeholders.access_token.len(), 32); + // 每次登录的占位 token 都不同(HMCL 行为)。 + let again = offline_login(&meta).expect("占位符"); + assert_ne!(placeholders.access_token, again.access_token); + } + + #[test] + fn offline_login_rejects_other_tracks() { + let mut meta = offline_meta("Steve", 1).expect("建元数据"); + meta.kind = AccountKind::Microsoft; + assert!(offline_login(&meta).is_err()); + } +} diff --git a/crates/core/src/auth/secret.rs b/crates/core/src/auth/secret.rs new file mode 100644 index 0000000..8296db4 --- /dev/null +++ b/crates/core/src/auth/secret.rs @@ -0,0 +1,182 @@ +//! 凭据值包装:**打印出来永远是 ``**。 +//! +//! 依据 `DevDocs/PlanDoc/07-accounts.md` §3 与 `DevDocs/AGENTS.md` §6 的凭据红线: +//! token/密码永不进日志、事件流、stdout、测试快照。HMCL 靠「往日志过滤器注册 token」 +//! (`analyse/04` §11 观察项 9:绕开 Logger 就失效),我们在**类型层**堵: +//! 只要某值被包成 [`Secret`],`Debug`/`Display`/`{:?}`/`{}` 都拿不到明文, +//! 取明文必须显式写 `.expose()`(一眼可搜、CR 可审)。 +//! +//! 落盘仍然需要明文(凭据文件),因此保留 serde 透明序列化——序列化不是打印。 + +use std::borrow::Cow; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::launch::redact::{Redactor, TOKEN_PLACEHOLDER}; + +/// `Debug`/`Display` 与脱敏替换的统一标记。 +pub const REDACTED: &str = ""; + +/// 一个凭据值(access token / refresh token / client token / 密码)。 +/// +/// 相等性按明文比较(刷新后判断「token 有没有变」要用到),哈希同理。 +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Secret(String); + +impl Secret { + /// 包住一个明文值。 + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// 取明文。**唯一取明文入口**:出现处都应能说清「这是发请求,不是打印」。 + #[must_use] + pub fn expose(&self) -> &str { + &self.0 + } + + /// 是否为空(空 token 等价于「没有凭据」)。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// 明文字节长度(判空/判占位长度用,不泄露内容)。 + #[must_use] + pub fn len(&self) -> usize { + self.0.len() + } +} + +impl fmt::Debug for Secret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(REDACTED) + } +} + +impl fmt::Display for Secret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(REDACTED) + } +} + +impl From for Secret { + fn from(value: String) -> Self { + Self(value) + } +} + +impl From<&str> for Secret { + fn from(value: &str) -> Self { + Self(value.to_string()) + } +} + +/// 生成 32 个十六进制字符的随机串(离线 accessToken 占位、条目 id)。 +/// +/// **不是密码学随机**(同 `game::ProfileId::generate` 的取舍):混合纳秒时间戳、进程 id、 +/// 进程内计数器后过 SplitMix64。这些值不参与任何服务端校验(HMCL 同样用 +/// `UUID.randomUUID().toString()` 的紧凑形式当占位),唯一性够用即可。 +#[must_use] +pub fn random_hex32() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos() as u64); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut seed = nanos ^ u64::from(std::process::id()).rotate_left(17) ^ seq.rotate_left(41); + + let mut out = String::with_capacity(32); + let mut state = seed; + while out.len() < 32 { + // 两个 u64 混合出 16 个 hex 字符一轮。 + state = split_mix64(state); + seed ^= split_mix64(seed.rotate_left(29) ^ state); + out.push_str(&format!("{:016x}", seed)); + } + out.truncate(32); + out +} + +fn split_mix64(seed: u64) -> u64 { + let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// 用一组凭据武装一个日志脱敏器(`launch::redact::Redactor` 的唯一构造入口)。 +/// +/// 太短的值(< 6 字节)会被 `Redactor` 自动忽略,避免把正常文本搅乱。 +#[must_use] +pub fn redactor_for<'a>(secrets: impl IntoIterator) -> Redactor { + let mut redactor = Redactor::new(); + for secret in secrets { + if !secret.is_empty() { + redactor = redactor.with_secret(secret.expose().to_string()); + } + } + redactor +} + +/// 对一段文本做凭据脱敏(走 `launch::redact` 的三条规则:已登记凭据、长度 ≥64 不透明串、 +/// 带点 token/JWT)。 +#[must_use] +pub fn scrub<'a>(text: &'a str, secrets: &[&Secret]) -> Cow<'a, str> { + let owned: Vec = secrets.iter().map(|secret| (*secret).clone()).collect(); + redactor_for(owned.iter()).scrub(text) +} + +/// 占位常量转出(给调用方断言 / 拼提示用)。 +#[must_use] +pub const fn token_placeholder() -> &'static str { + TOKEN_PLACEHOLDER +} + +#[cfg(test)] +mod tests { + use super::{random_hex32, scrub, Secret, REDACTED, TOKEN_PLACEHOLDER}; + + #[test] + fn debug_and_display_never_leak() { + let secret = Secret::new("SUPER-SECRET-TOKEN-VALUE"); + assert_eq!(format!("{secret:?}"), REDACTED); + assert_eq!(format!("{secret}"), REDACTED); + assert!(!format!("{secret:?}").contains("SUPER")); + let bundle = format!("{:?}", vec![secret.clone()]); + assert!(!bundle.contains("SECRET"), "{bundle}"); + assert_eq!(secret.expose(), "SUPER-SECRET-TOKEN-VALUE"); + } + + #[test] + fn serde_roundtrip_keeps_plaintext() { + let secret = Secret::new("abc123"); + let text = serde_json::to_string(&secret).expect("序列化"); + assert_eq!(text, "\"abc123\""); + let back: Secret = serde_json::from_str(&text).expect("反序列化"); + assert_eq!(back, secret); + } + + #[test] + fn scrub_replaces_registered_secret() { + let secret = Secret::new("0f8fad5bd9cb469fa16570867728950e"); + let text = format!("--accessToken {} --uuid abc", secret.expose()); + let scrubbed = scrub(&text, &[&secret]).into_owned(); + assert!(!scrubbed.contains("0f8fad")); + assert!(scrubbed.contains(TOKEN_PLACEHOLDER)); + } + + #[test] + fn random_hex32_is_32_hex_and_unique() { + let first = random_hex32(); + let second = random_hex32(); + assert_eq!(first.len(), 32); + assert!(first.chars().all(|ch| ch.is_ascii_hexdigit())); + assert_ne!(first, second); + } +} diff --git a/crates/core/src/auth/store.rs b/crates/core/src/auth/store.rs new file mode 100644 index 0000000..0021b24 --- /dev/null +++ b/crates/core/src/auth/store.rs @@ -0,0 +1,251 @@ +//! 账户存储:`data/auth/` 下的**元数据 / 凭据分文件**落盘(内存视图与增删)。 +//! +//! 契约(`PlanDoc/07-accounts.md` §2、`PlanDoc/04-data-and-config.md`): +//! 1. **分文件**:`accounts.json`(可公开)+ `credentials.dat`(凭据)。列账户不碰凭据文件; +//! 2. **先写凭据、再写元数据**(HMCL 安全顺序)——元数据永不指向不存在的凭据; +//! 3. **原子写**:走 `platform::atomic::write_atomic`(tmp → fsync → rename); +//! 4. **容错**:单条坏记录只跳过该条并留 warning,绝不整体加载失败; +//! 5. **孤儿清理**:`save()` 只写「元数据里还存在」的凭据。 +//! +//! 落盘细节(payload 形状、权限收紧)在同目录的 [`payload`] 子模块,本文件只管模型与不变量。 +//! +//! 保护级别:本期 `plain-v1`(分文件 + 权限 + 全链脱敏)。OS keychain / AES-256-GCM +//! 分级是 07 §2 的第 1–2 条,需要 keyring/aes-gcm/argon2 的依赖决策,属后续 mission。 + +mod payload; + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use crate::auth::account::{AccountId, AccountMeta, Credentials}; +use crate::auth::error::{AuthError, AuthResult}; + +/// 元数据文件名(`data/auth/accounts.json`)。 +pub const ACCOUNTS_FILE: &str = "accounts.json"; +/// 凭据文件名(`data/auth/credentials.dat`)。 +pub const CREDENTIALS_FILE: &str = "credentials.dat"; +/// 元数据 schema 版本。 +pub const AUTH_SCHEMA_VERSION: u32 = 1; +/// 本期凭据保护级别(明文 + 分文件 + 权限 + 脱敏)。 +pub const PROTECTION_PLAIN_V1: &str = "plain-v1"; + +/// `data/auth/` 的两个文件位置。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthPaths { + root: PathBuf, +} + +impl AuthPaths { + /// 以数据分区为根(`LauncherLayout::auth_data()`)。 + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + /// 根目录。 + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + /// 元数据文件。 + #[must_use] + pub fn accounts_file(&self) -> PathBuf { + self.root.join(ACCOUNTS_FILE) + } + + /// 凭据文件。 + #[must_use] + pub fn credentials_file(&self) -> PathBuf { + self.root.join(CREDENTIALS_FILE) + } +} + +/// 账户库(内存视图 + 落盘)。 +#[derive(Debug, Clone)] +pub struct AccountStore { + paths: AuthPaths, + accounts: Vec, + credentials: BTreeMap, + warnings: Vec, +} + +impl AccountStore { + /// 打开(不存在就是空库;坏记录进 warnings)。 + #[must_use] + pub fn open(root: impl Into) -> Self { + Self::open_paths(AuthPaths::new(root)) + } + + /// 打开指定文件位置。 + #[must_use] + pub fn open_paths(paths: AuthPaths) -> Self { + let mut warnings = Vec::new(); + let mut accounts = payload::read_accounts(&paths.accounts_file(), &mut warnings); + accounts.sort_by_key(|meta| meta.created_at_ms); + let known: Vec = accounts + .iter() + .map(|meta| meta.id.as_str().to_string()) + .collect(); + let credentials = + payload::read_credentials(&paths.credentials_file(), &known, &mut warnings); + Self { + paths, + accounts, + credentials, + warnings, + } + } + + /// 文件位置。 + #[must_use] + pub fn paths(&self) -> &AuthPaths { + &self.paths + } + + /// 账户列表(按创建时间排序,展示顺序稳定)。 + #[must_use] + pub fn accounts(&self) -> &[AccountMeta] { + &self.accounts + } + + /// 加载过程中的告警(坏记录、孤儿凭据等)。 + #[must_use] + pub fn warnings(&self) -> &[String] { + &self.warnings + } + + /// 是否没有任何账户。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.accounts.is_empty() + } + + /// 按名字 / 登录名 / 条目 id 查找(大小写不敏感)。 + #[must_use] + pub fn find(&self, key: &str) -> Option<&AccountMeta> { + self.accounts.iter().find(|meta| meta.matches(key)) + } + + /// 取某条目的凭据。 + #[must_use] + pub fn credentials(&self, id: &AccountId) -> Option<&Credentials> { + self.credentials.get(id.as_str()) + } + + /// 取某条目的「元数据 + 凭据」(找不到凭据时报错)。 + /// + /// # Errors + /// + /// 条目不存在 → [`AuthError::NotFound`];条目在但凭据缺失 → [`AuthError::Malformed`] + /// (说明文件被手工删过,别静默当成「没登录」)。 + pub fn entry(&self, id: &AccountId) -> AuthResult<(&AccountMeta, &Credentials)> { + let meta = self + .accounts + .iter() + .find(|meta| meta.id == *id) + .ok_or_else(|| AuthError::NotFound { + account: id.to_string(), + })?; + let credentials = self + .credentials + .get(id.as_str()) + .ok_or_else(|| AuthError::Malformed(format!("账户 {id} 的凭据缺失")))?; + Ok((meta, credentials)) + } + + /// 新增账户(内存)。同名同轨冲突时报错——`--account <名字>` 才有唯一解。 + /// + /// # Errors + /// + /// 重复、条目 id 重复、轨道与凭据不符都返回错误。 + pub fn insert(&mut self, meta: AccountMeta, credentials: Credentials) -> AuthResult<()> { + meta.check_credentials(&credentials)?; + if self.accounts.iter().any(|existing| existing.id == meta.id) { + return Err(AuthError::Conflict { + account: meta.id.to_string(), + }); + } + let duplicate = self + .accounts + .iter() + .any(|existing| existing.kind == meta.kind && existing.matches(meta.display_name())); + if duplicate { + return Err(AuthError::Conflict { + account: meta.display_name().to_string(), + }); + } + self.credentials + .insert(meta.id.as_str().to_string(), credentials); + self.accounts.push(meta); + self.accounts.sort_by_key(|meta| meta.created_at_ms); + Ok(()) + } + + /// 删除账户(连同凭据)。 + /// + /// # Errors + /// + /// 找不到时返回 [`AuthError::NotFound`]。 + pub fn remove(&mut self, key: &str) -> AuthResult { + let index = self + .accounts + .iter() + .position(|meta| meta.matches(key)) + .ok_or_else(|| AuthError::NotFound { + account: key.to_string(), + })?; + let meta = self.accounts.remove(index); + self.credentials.remove(meta.id.as_str()); + Ok(meta) + } + + /// 更新某条目的凭据(刷新后轮换 token 用)。 + /// + /// # Errors + /// + /// 条目不存在或轨道不符时返回错误。 + pub fn update_credentials( + &mut self, + id: &AccountId, + credentials: Credentials, + ) -> AuthResult<()> { + let meta = self + .accounts + .iter() + .find(|meta| meta.id == *id) + .ok_or_else(|| AuthError::NotFound { + account: id.to_string(), + })?; + meta.check_credentials(&credentials)?; + self.credentials + .insert(id.as_str().to_string(), credentials); + Ok(()) + } + + /// 同步某条目的元数据字段(刷新补齐了玩家名之后写回)。 + /// + /// # Errors + /// + /// 条目不存在时返回 [`AuthError::NotFound`]。 + pub fn update_meta(&mut self, meta: AccountMeta) -> AuthResult<()> { + let slot = self + .accounts + .iter_mut() + .find(|existing| existing.id == meta.id) + .ok_or_else(|| AuthError::NotFound { + account: meta.id.to_string(), + })?; + *slot = meta; + Ok(()) + } + + /// 落盘:**先凭据、后元数据**。 + /// + /// # Errors + /// + /// 两个文件任一写失败都返回 [`AuthError::Store`]。 + pub fn save(&self) -> AuthResult<()> { + payload::write(&self.paths, &self.accounts, &self.credentials) + } +} diff --git a/crates/core/src/auth/store/payload.rs b/crates/core/src/auth/store/payload.rs new file mode 100644 index 0000000..b070278 --- /dev/null +++ b/crates/core/src/auth/store/payload.rs @@ -0,0 +1,247 @@ +//! 落盘细节:payload 形状、宽容解析、凭据文件权限。 +//! +//! 两个文件的形状(都带 `schemaVersion`,便于以后演进): +//! +//! ```json +//! // accounts.json —— 可公开 +//! { "schemaVersion": 1, "accounts": [ { "id": "account:…", "kind": "offline", +//! "profileId": "…", "profileName": "Steve", "createdAtMs": 1700000000000 } ] } +//! +//! // credentials.dat —— 凭据 +//! { "schemaVersion": 1, "protection": "plain-v1", +//! "entries": [ { "accountId": "account:…", "data": { "kind": "microsoft", … } } ] } +//! ``` +//! +//! `entries[].data` 里是 [`Credentials`] 的序列化结果(由 serde 自己定义形状), +//! 本模块只负责「哪条属于哪个账户」。 + +use std::collections::BTreeMap; +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::auth::account::{AccountMeta, Credentials}; +use crate::auth::error::{AuthError, AuthResult}; +use crate::platform::atomic::write_atomic; + +use super::{AuthPaths, AUTH_SCHEMA_VERSION, PROTECTION_PLAIN_V1}; + +/// 写两处文件:**先凭据、后元数据**。 +pub(super) fn write( + paths: &AuthPaths, + accounts: &[AccountMeta], + credentials: &BTreeMap, +) -> AuthResult<()> { + let credentials_payload = credentials_payload(accounts, credentials); + let path = paths.credentials_file(); + write_atomic(&path, credentials_payload.as_bytes()) + .map_err(|error| AuthError::store(path.display(), error.to_string()))?; + restrict(&path)?; + + let accounts_payload = accounts_payload(accounts); + let path = paths.accounts_file(); + write_atomic(&path, accounts_payload.as_bytes()) + .map_err(|error| AuthError::store(path.display(), error.to_string()))?; + Ok(()) +} + +fn credentials_payload( + accounts: &[AccountMeta], + credentials: &BTreeMap, +) -> String { + let mut entries = Vec::new(); + for meta in accounts { + // 孤儿凭据(元数据已删)不写回。 + let Some(data) = credentials.get(meta.id.as_str()) else { + continue; + }; + entries.push(json!({ + "accountId": meta.id.as_str(), + "data": data, + })); + } + let payload = json!({ + "schemaVersion": AUTH_SCHEMA_VERSION, + "protection": PROTECTION_PLAIN_V1, + "entries": entries, + }); + serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()) +} + +fn accounts_payload(accounts: &[AccountMeta]) -> String { + let payload = json!({ + "schemaVersion": AUTH_SCHEMA_VERSION, + "accounts": accounts, + }); + serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()) +} + +/// 读元数据(坏记录跳过并记 warning)。 +pub(super) fn read_accounts(path: &Path, warnings: &mut Vec) -> Vec { + let mut accounts = Vec::new(); + let Some(text) = read_to_string(path, warnings) else { + return accounts; + }; + let Ok(value) = serde_json::from_str::(&text) else { + warnings.push(format!("{} 不是合法 JSON,按空账户库处理", path.display())); + return accounts; + }; + let Some(items) = value.get("accounts").and_then(Value::as_array) else { + warnings.push(format!("{} 缺 accounts 数组", path.display())); + return accounts; + }; + for item in items { + match AccountMeta::from_value(item) { + Ok(meta) => accounts.push(meta), + Err(error) => warnings.push(format!("跳过一条账户元数据:{}", error.message())), + } + } + accounts +} + +/// 读凭据(只保留 `known` 里存在的账户,孤儿丢弃并记 warning)。 +pub(super) fn read_credentials( + path: &Path, + known: &[String], + warnings: &mut Vec, +) -> BTreeMap { + let mut credentials = BTreeMap::new(); + let Some(text) = read_to_string(path, warnings) else { + return credentials; + }; + let Ok(value) = serde_json::from_str::(&text) else { + warnings.push(format!( + "{} 不是合法 JSON,按「无凭据」处理(账户列表仍可用)", + path.display() + )); + return credentials; + }; + if let Some(protection) = value.get("protection").and_then(Value::as_str) { + if protection != PROTECTION_PLAIN_V1 { + warnings.push(format!( + "{} 的保护级别是 `{protection}`,本期只认 `{PROTECTION_PLAIN_V1}`", + path.display() + )); + } + } + let Some(entries) = value.get("entries").and_then(Value::as_array) else { + return credentials; + }; + for entry in entries { + let Some(account_id) = entry.get("accountId").and_then(Value::as_str) else { + continue; + }; + let account_id = account_id.to_ascii_lowercase(); + if !known.contains(&account_id) { + warnings.push(format!("凭据 {account_id} 没有对应账户,已忽略")); + continue; + } + let Some(data) = entry.get("data") else { + continue; + }; + match serde_json::from_value::(data.clone()) { + Ok(parsed) => { + credentials.insert(account_id, parsed); + } + Err(error) => warnings.push(format!("跳过一条凭据({account_id}):{error}")), + } + } + credentials +} + +fn read_to_string(path: &Path, warnings: &mut Vec) -> Option { + match std::fs::read_to_string(path) { + Ok(text) => Some(text), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + warnings.push(format!("读 {} 失败:{error}", path.display())); + None + } + } +} + +/// 把凭据文件收紧到「仅本人可读写」。 +/// +/// POSIX 侧显式 `0600`;Windows 侧 `set_permissions` 只能表达只读位,凭据保护依赖 +/// 用户目录默认 ACL(`data/auth/` 位于用户数据根下)。**这是已知缺口**: +/// 补一个 `platform::atomic::write_atomic_private` 比在 auth 里散写 cfg 更对, +/// 已作为 finding 报给 tower(见完成报告)。 +fn restrict(path: &Path) -> AuthResult<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let permissions = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(path, permissions) + .map_err(|error| AuthError::store(path.display(), error.to_string()))?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{accounts_payload, credentials_payload}; + use crate::auth::account::{AccountKind, AccountMeta, Credentials}; + use crate::auth::microsoft::MicrosoftSession; + use crate::auth::secret::Secret; + + fn microsoft_meta() -> (AccountMeta, Credentials) { + let meta = AccountMeta::new( + AccountKind::Microsoft, + "0f8fad5bd9cb469fa16570867728950e", + "Steve", + 7, + ); + let session = MicrosoftSession::new( + "0f8fad5bd9cb469fa16570867728950e", + "Steve", + Secret::new("mc-access-token"), + Secret::new("live-refresh-token"), + 3600, + 1_000, + None, + ); + (meta, Credentials::Microsoft(session)) + } + + #[test] + fn metadata_payload_never_contains_tokens() { + let (meta, creds) = microsoft_meta(); + let payload = accounts_payload(std::slice::from_ref(&meta)); + assert!(payload.contains("0f8fad5bd9cb469fa16570867728950e")); + assert!(payload.contains("\"kind\": \"microsoft\"")); + assert!(!payload.contains("mc-access-token")); + assert!(!payload.contains("live-refresh-token")); + // 凭据对象的 Debug 也是脱敏的,别把 token 带进 payload。 + assert!(!format!("{creds:?}").contains("mc-access-token")); + } + + #[test] + fn credentials_payload_carries_protection_and_entries() { + let (meta, creds) = microsoft_meta(); + let mut map = BTreeMap::new(); + map.insert(meta.id.as_str().to_string(), creds); + let payload = credentials_payload(std::slice::from_ref(&meta), &map); + assert!(payload.contains("\"protection\": \"plain-v1\"")); + assert!(payload.contains(meta.id.as_str())); + assert!(payload.contains("mc-access-token")); + } + + #[test] + fn orphan_credentials_are_not_written() { + let (meta, creds) = microsoft_meta(); + let mut map = BTreeMap::new(); + map.insert( + "account:deadbeefdeadbeefdeadbeefdeadbeef".to_string(), + creds, + ); + let payload = credentials_payload(std::slice::from_ref(&meta), &map); + assert!(!payload.contains("mc-access-token")); + assert!(payload.contains("\"entries\": []")); + } +} diff --git a/crates/core/src/diagnose/collectors.rs b/crates/core/src/diagnose/collectors.rs new file mode 100644 index 0000000..d418031 --- /dev/null +++ b/crates/core/src/diagnose/collectors.rs @@ -0,0 +1,267 @@ +//! 五类信号采集接口:退出码 / 日志环形缓冲 / crash-reports / hs_err / 环境快照(+ mod 清单)。 +//! +//! 分工(与 `launch-接口约定.md` 对齐): +//! - **推送类**([`ExitCollector`] / [`EnvironmentCollector`]):launch 已经算出事实,直接塞进 [`SignalBundle`]; +//! - **磁盘类**([`CrashReportCollector`] / [`HsErrCollector`] / [`LatestLogCollector`]):本模块自己按 +//! mtime 取最新一份,并可用 [`CollectionContext::started_at`] 过滤历史崩溃; +//! - 日志行由 launch 逐行喂 [`LogBuffer::push`],diagnose 不解析会话 JSONL(两边零类型耦合)。 +//! +//! 采集是**尽力而为**:[`collect_all`] 把单个来源的失败收集成 [`CollectError`] 而不是中断整体—— +//! 崩溃现场本来就残缺,少一个来源不该失去全部结论。 + +use std::fmt; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +#[path = "environment.rs"] +pub mod environment; +#[path = "signals.rs"] +pub mod signals; +#[path = "sources.rs"] +pub mod sources; + +pub use environment::{ComponentVersion, EnvironmentSnapshot, ModRecord}; +pub use signals::{ + ExitSignal, ExitType, LogBuffer, LogLine, SignalOrigin, CRASH_KEYWORDS, EXIT_ACCESS_VIOLATION, + EXIT_DLL_NOT_FOUND, EXIT_SIGKILL, EXIT_SIGTERM, EXIT_STACK_BUFFER_OVERRUN, JVM_ERROR_KEYWORDS, +}; +pub use sources::{ + CrashReportCollector, EnvironmentCollector, ExitCollector, HsErrCollector, LatestLogCollector, +}; + +use super::engine::DiagnosisInput; +use super::hs_err::HsErrReport; + +/// 信号种类(可观测性 / 采集计划编排用)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SignalKind { + /// 退出码 + 退出类型。 + Exit, + /// stdout/stderr 环形缓冲。 + Log, + /// `crash-reports/crash-*.txt`。 + CrashReport, + /// `hs_err_pid*.log`。 + HsErr, + /// 环境快照。 + Environment, + /// mod 清单。 + Mods, +} + +impl SignalKind { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Exit => "exit", + Self::Log => "log", + Self::CrashReport => "crash_report", + Self::HsErr => "hs_err", + Self::Environment => "environment", + Self::Mods => "mods", + } + } +} + +/// 采集上下文。 +#[derive(Debug, Clone, Default)] +pub struct CollectionContext { + /// 游戏工作目录(`crash-reports/`、`hs_err_pid*.log`、`logs/latest.log` 都在它下面)。 + pub run_dir: PathBuf, + /// 进程启动时刻:只采集修改时间 ≥ 它的文件,避免把上次崩溃算进本次。 + pub started_at: Option, +} + +impl CollectionContext { + /// 以工作目录构造。 + #[must_use] + pub fn new(run_dir: impl Into) -> Self { + Self { + run_dir: run_dir.into(), + started_at: None, + } + } + + /// 带「只认本次会话文件」的时间下界。 + #[must_use] + pub fn since(mut self, started_at: SystemTime) -> Self { + self.started_at = Some(started_at); + self + } +} + +/// 采集失败(尽力而为:单源失败不影响其它来源)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CollectError { + /// 出错的信号种类。 + pub kind: SignalKind, + /// 相关路径(可能为空,例如纯逻辑错误)。 + pub path: Option, + /// 人类可读原因。 + pub message: String, +} + +impl CollectError { + fn io(kind: SignalKind, path: &Path, error: &io::Error) -> Self { + Self { + kind, + path: Some(path.to_path_buf()), + message: error.to_string(), + } + } +} + +impl fmt::Display for CollectError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let path = self + .path + .as_deref() + .map_or_else(String::new, |path| format!("({})", path.display())); + write!(f, "{} 采集失败{path}:{}", self.kind.as_str(), self.message) + } +} + +impl std::error::Error for CollectError {} + +/// 采到的源文件列表(诊断包导出 / 可观测性)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CollectedSources { + /// 文件路径。 + pub paths: Vec, +} + +impl CollectedSources { + /// 空。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 记一个源文件。 + pub fn push(&mut self, path: impl Into) { + self.paths.push(path.into()); + } + + /// 是否什么都没采到。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.paths.is_empty() + } +} + +/// 一个来源的采到的文本(含来源文件与时间)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapturedText { + /// 来源文件。 + pub path: PathBuf, + /// 文本。 + pub text: String, + /// 修改时间。 + pub modified: Option, +} + +/// 采集到的全部信号。 +#[derive(Debug, Clone, Default)] +pub struct SignalBundle { + /// 退出信号。 + pub exit: Option, + /// 日志环形缓冲(launch 逐行喂;`latest.log` 兜底也进这里)。 + pub log: LogBuffer, + /// 崩溃报告全文。 + pub crash_report: Option, + /// hs_err 解析结果。 + pub hs_err: Option, + /// 环境快照。 + pub environment: Option, + /// mod 清单。 + pub mods: Vec, + /// 本次采到的源文件。 + pub sources: Vec, +} + +impl SignalBundle { + /// 空包(日志缓冲默认 2000 行)。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 是否没有任何信号。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.exit.is_none() + && self.log.is_empty() + && self.crash_report.is_none() + && self.hs_err.is_none() + && self.environment.is_none() + && self.mods.is_empty() + } + + /// 转成规则引擎的输入。 + /// + /// 日志按来源分组成多份待匹配文本(stdout/stderr 分开),这样证据里能保留来源; + /// crash-report 与 hs_err 各占一份,取证优先级由 `SignalOrigin::evidence_rank` 决定。 + #[must_use] + pub fn diagnosis_input(&self) -> DiagnosisInput { + let mut input = DiagnosisInput::new(); + input.exit = self.exit.clone(); + for (origin, text) in grouped_log_text(&self.log) { + input.push_text(origin, text); + } + if let Some(crash_report) = &self.crash_report { + input.push_text(SignalOrigin::CrashReport, crash_report.text.clone()); + } + if let Some(report) = &self.hs_err { + input.push_hs_err(report.clone()); + } + input + } +} + +fn grouped_log_text(log: &LogBuffer) -> Vec<(SignalOrigin, String)> { + let mut groups: Vec<(SignalOrigin, String)> = Vec::new(); + for line in log.lines() { + match groups.iter_mut().find(|(origin, _)| *origin == line.origin) { + Some((_, text)) => { + text.push_str(&line.text); + text.push('\n'); + } + None => groups.push((line.origin, format!("{}\n", line.text))), + } + } + groups +} + +/// 信号采集器。 +pub trait SignalCollector { + /// 采哪种信号。 + fn kind(&self) -> SignalKind; + + /// 采集并写入 `out`;返回本次采到的源文件。 + /// + /// # Errors + /// + /// 读盘失败 / 解析失败时返回 [`CollectError`];调用方([`collect_all`])会收集错误继续。 + fn collect( + &self, + ctx: &CollectionContext, + out: &mut SignalBundle, + ) -> Result; +} + +/// 按顺序跑一批采集器,单源失败只记录不中断。 +pub fn collect_all( + collectors: &[&dyn SignalCollector], + ctx: &CollectionContext, +) -> (SignalBundle, Vec) { + let mut bundle = SignalBundle::new(); + let mut errors = Vec::new(); + for collector in collectors { + if let Err(error) = collector.collect(ctx, &mut bundle) { + errors.push(error); + } + } + (bundle, errors) +} diff --git a/crates/core/src/diagnose/engine.rs b/crates/core/src/diagnose/engine.rs new file mode 100644 index 0000000..5549de9 --- /dev/null +++ b/crates/core/src/diagnose/engine.rs @@ -0,0 +1,321 @@ +//! 规则引擎:全量匹配 → 去重 → 抑制关系 → 置信度排序 → 结构化报告。 +//! +//! 与 HMCL `CrashReportAnalyzer.analyze` + `GameCrashWindow.merge` 的差异(analyse/02 §7.6 §7.10): +//! 1. 同一条规则只留**最优证据来源**的那次命中(崩溃报告 > hs_err > 日志 > stdout > stderr); +//! 2. 结论带 `priority`/`confidence`,主因在 `findings[0]`,被抑制的次生结论进 `secondary`; +//! 3. 输出是纯数据 [`DiagnosisRecord`](可序列化、可跨线程),不再泄漏匹配器状态; +//! 4. 退出码 / hs_err 这类**结构化信号**由内置结论补位(见 `synthetic.rs`)。 +//! +//! 性能取舍:`scope: line` 逐行扫(59 条 × 行数,弱机可接受);`scope: file` 的跨行规则 +//! 只在 ≤1 MiB 文本上跑,超长日志取首尾各 512 KiB。 + +use std::collections::BTreeMap; + +use crate::diagnose::collectors::{ExitSignal, SignalOrigin}; +use crate::diagnose::hs_err::HsErrReport; +use crate::diagnose::rules::{CompiledRule, MatchScope}; + +#[path = "finding.rs"] +mod finding; +#[path = "synthetic.rs"] +mod synthetic; + +pub use finding::{ + Candidate, DiagnosisRecord, DiagnosisReport, Evidence, ExitRecord, Finding, + CONCLUSIVE_CONFIDENCE, +}; +pub use synthetic::synthetic_findings; + +/// 超过这个字节数就不再整份扫描 `scope: file` 规则(取首尾窗口)。 +const FILE_SCOPE_LIMIT: usize = 1024 * 1024; +/// 超长文本的扫描窗口(首尾各一份)。 +const FILE_SCOPE_WINDOW: usize = 512 * 1024; +/// 证据行截断长度(避免把整份栈塞进诊断输出)。 +const EVIDENCE_MAX_CHARS: usize = 300; + +/// 待匹配的一段文本(一个信号来源一份)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Haystack { + /// 来源(决定证据前缀与取证优先级)。 + pub origin: SignalOrigin, + /// 文本。 + pub text: String, +} + +impl Haystack { + /// 构造。 + #[must_use] + pub fn new(origin: SignalOrigin, text: impl Into) -> Self { + Self { + origin, + text: text.into(), + } + } +} + +/// 引擎输入:退出信号 + 若干待匹配文本 +(可选)解析好的 hs_err。 +#[derive(Debug, Clone, Default)] +pub struct DiagnosisInput { + /// 退出信号。 + pub exit: Option, + /// 待匹配文本(按 `SignalOrigin::evidence_rank` 优先取证据)。 + pub haystacks: Vec, + /// 解析好的 `hs_err_pid*.log`(产生内置结论,其摘要也已进 `haystacks`)。 + pub hs_err: Option, +} + +impl DiagnosisInput { + /// 空输入。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 带退出信号。 + #[must_use] + pub fn with_exit(mut self, exit: ExitSignal) -> Self { + self.exit = Some(exit); + self + } + + /// 追加一段待匹配文本。 + pub fn push_text(&mut self, origin: SignalOrigin, text: impl Into) { + self.haystacks.push(Haystack::new(origin, text)); + } + + /// 追加解析好的 hs_err:摘要进待匹配文本,结构留给内置结论。 + pub fn push_hs_err(&mut self, report: HsErrReport) { + self.haystacks + .push(Haystack::new(SignalOrigin::HsErr, report.digest())); + self.hs_err = Some(report); + } + + /// 是否什么信号都没有。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.exit.is_none() && self.haystacks.is_empty() + } +} + +/// 规则引擎(规则只编译一次,可跨线程共享)。 +#[derive(Debug, Clone, Default)] +pub struct Diagnoser { + rules: Vec, +} + +impl Diagnoser { + /// 用一组已编译的规则构造。 + #[must_use] + pub fn new(rules: Vec) -> Self { + Self { rules } + } + + /// 规则表。 + #[must_use] + pub fn rules(&self) -> &[CompiledRule] { + &self.rules + } + + /// 跑一遍诊断。 + #[must_use] + pub fn analyze(&self, input: &DiagnosisInput) -> DiagnosisReport { + let mut ordered = input.haystacks.iter().collect::>(); + ordered.sort_by_key(|haystack| haystack.origin.evidence_rank()); + + let mut findings = Vec::new(); + for rule in &self.rules { + for haystack in &ordered { + if let Some(found) = scan(rule, haystack) { + findings.push(build_finding(rule, haystack, &found)); + break; + } + } + } + findings.extend(synthetic_findings(input)); + + let (mut findings, mut secondary) = split_suppressed(findings); + findings.sort_by(Finding::rank_cmp); + secondary.sort_by(Finding::rank_cmp); + + DiagnosisReport { + findings, + secondary, + exit: input.exit.clone(), + rules_evaluated: self.rules.len(), + hs_err: input.hs_err.clone(), + } + } +} + +/// 一次正则命中(绝对偏移 + 命名捕获组)。 +#[derive(Debug)] +struct RawMatch { + groups: BTreeMap, + offset: usize, +} + +fn scan(rule: &CompiledRule, haystack: &Haystack) -> Option { + match rule.scope() { + MatchScope::Line => scan_lines(rule, &haystack.text), + MatchScope::Block => scan_blocks(rule, &haystack.text), + MatchScope::File => scan_file(rule, &haystack.text), + } +} + +/// 逐行匹配(返回命中行的起始偏移,行号即证据行号)。 +fn scan_lines(rule: &CompiledRule, text: &str) -> Option { + let mut offset = 0; + for chunk in text.split_inclusive('\n') { + let line = chunk.trim_end_matches(['\r', '\n']); + if let Some(caps) = rule.regex().captures(line) { + return Some(capture_groups(rule, &caps, offset)); + } + offset += chunk.len(); + } + None +} + +/// 按空行切块匹配(一段异常 + 其上下文)。 +/// +/// 分隔符是**空行**而不是字面 `"\n\n"`:日志文件可能是 CRLF(Windows 上 `latest.log` 常见), +/// 按字节找 `\n\n` 会把整份日志当成一个块,`scope: block` 的规则就退化成整份扫描。 +fn scan_blocks(rule: &CompiledRule, text: &str) -> Option { + let mut block_start = 0; + let mut offset = 0; + for chunk in text.split_inclusive('\n') { + offset += chunk.len(); + if chunk.trim().is_empty() { + if let Some(caps) = rule.regex().captures(&text[block_start..offset]) { + return Some(capture_groups(rule, &caps, block_start)); + } + block_start = offset; + } + } + if block_start < text.len() { + let caps = rule.regex().captures(&text[block_start..])?; + return Some(capture_groups(rule, &caps, block_start)); + } + None +} + +/// 整份匹配;超长文本只看首尾窗口(跨行贪婪规则的性能边界)。 +fn scan_file(rule: &CompiledRule, text: &str) -> Option { + if text.len() <= FILE_SCOPE_LIMIT { + let caps = rule.regex().captures(text)?; + let offset = caps.get(0).map_or(0, |found| found.start()); + return Some(capture_groups(rule, &caps, offset)); + } + let head_end = ceil_boundary(text, FILE_SCOPE_WINDOW); + if let Some(caps) = rule.regex().captures(&text[..head_end]) { + let offset = caps.get(0).map_or(0, |found| found.start()); + return Some(capture_groups(rule, &caps, offset)); + } + let tail_start = floor_boundary(text, text.len() - FILE_SCOPE_WINDOW); + let caps = rule.regex().captures(&text[tail_start..])?; + let offset = tail_start + caps.get(0).map_or(0, |found| found.start()); + Some(capture_groups(rule, &caps, offset)) +} + +fn capture_groups(rule: &CompiledRule, caps: ®ex::Captures<'_>, offset: usize) -> RawMatch { + let mut groups = BTreeMap::new(); + for name in rule.regex().capture_names().flatten() { + if let Some(found) = caps.name(name) { + groups.insert(name.to_owned(), found.as_str().trim().to_owned()); + } + } + RawMatch { groups, offset } +} + +fn build_finding(compiled: &CompiledRule, haystack: &Haystack, found: &RawMatch) -> Finding { + let rule = compiled.rule(); + let (message, detail) = rule.cause.render(&found.groups); + Finding { + rule_id: rule.id.clone(), + category: compiled.category(), + severity: rule.severity, + confidence: rule.confidence.clamp(0.0, 1.0), + priority: rule.priority, + message_key: rule.cause.key.clone(), + message, + detail, + evidence: vec![Evidence::new( + haystack.origin, + line_of(&haystack.text, found.offset), + snippet(&haystack.text, found.offset), + )], + fix_actions: rule.fix_action.clone(), + groups: found.groups.clone(), + suppress: rule.suppress.clone(), + suppressed_by: None, + } +} + +/// 把被 suppress 的结论拆到次生列表(不丢弃,UI 折叠展示)。 +fn split_suppressed(findings: Vec) -> (Vec, Vec) { + let suppressers = findings + .iter() + .map(|finding| { + ( + finding.rule_id.clone(), + finding.priority, + finding.suppress.clone(), + ) + }) + .collect::>(); + let mut kept = Vec::new(); + let mut suppressed = Vec::new(); + for mut finding in findings { + let mut best: Option<(&str, u8)> = None; + for (id, priority, suppress) in &suppressers { + if *id == finding.rule_id || !suppress.iter().any(|item| item == &finding.rule_id) { + continue; + } + if best.is_none_or(|(_, best_priority)| *priority > best_priority) { + best = Some((id.as_str(), *priority)); + } + } + match best { + Some((id, _)) => { + finding.suppressed_by = Some(id.to_owned()); + suppressed.push(finding); + } + None => kept.push(finding), + } + } + (kept, suppressed) +} + +fn line_of(text: &str, offset: usize) -> usize { + let offset = offset.min(text.len()); + 1 + text[..offset].matches('\n').count() +} + +fn snippet(text: &str, offset: usize) -> String { + let offset = offset.min(text.len()); + let start = text[..offset].rfind('\n').map_or(0, |index| index + 1); + let end = text[start..] + .find('\n') + .map_or(text.len(), |index| start + index); + let line = text[start..end].trim(); + let mut out = line.chars().take(EVIDENCE_MAX_CHARS).collect::(); + if line.chars().count() > EVIDENCE_MAX_CHARS { + out.push('…'); + } + out +} + +fn ceil_boundary(text: &str, mut index: usize) -> usize { + index = index.min(text.len()); + while index < text.len() && !text.is_char_boundary(index) { + index += 1; + } + index +} + +fn floor_boundary(text: &str, mut index: usize) -> usize { + index = index.min(text.len()); + while index > 0 && !text.is_char_boundary(index) { + index -= 1; + } + index +} diff --git a/crates/core/src/diagnose/environment.rs b/crates/core/src/diagnose/environment.rs new file mode 100644 index 0000000..a40854d --- /dev/null +++ b/crates/core/src/diagnose/environment.rs @@ -0,0 +1,155 @@ +//! 环境快照与 mod 清单(analyse/02 §7.1 信号 E)。 +//! +//! HMCL 只在崩溃窗口里散着展示这些信息;我们做成结构化数据, +//! 既进诊断输出(`environment.json` 一样的机器可读上下文),也让规则能按版本号做前置判断。 +//! 由 `collectors.rs` 用 `#[path]` 声明并统一导出。 + +use std::path::PathBuf; + +/// 一个已装组件的版本(Forge/Fabric/OptiFine…)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComponentVersion { + /// 组件名(`forge`/`fabric`/`optifine`/`minecraft`…)。 + pub name: String, + /// 版本串。 + pub version: String, +} + +impl ComponentVersion { + /// 构造。 + #[must_use] + pub fn new(name: impl Into, version: impl Into) -> Self { + Self { + name: name.into(), + version: version.into(), + } + } +} + +/// 一个已装 mod(供交叉验证「冲突/mixin 失败」类结论,analyse/02 §8.4-10)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ModRecord { + /// modid(解析不出时为空)。 + pub id: String, + /// 展示名。 + pub name: Option, + /// 版本。 + pub version: Option, + /// jar 文件名(诊断包里给人看)。 + pub file: Option, + /// 是否启用(`.disabled` 视为未启用)。 + pub enabled: bool, +} + +impl ModRecord { + /// 从文件名构造(不解 jar,P0 足够:冲突类结论要的是「装了哪些文件」)。 + #[must_use] + pub fn from_file_name(file: &str) -> Self { + let enabled = !file.ends_with(".disabled"); + let stem = file + .trim_end_matches(".disabled") + .trim_end_matches(".jar") + .to_owned(); + let (name, version) = stem + .rsplit_once('-') + .filter(|(_, version)| version.chars().next().is_some_and(|ch| ch.is_ascii_digit())) + .map_or((stem.clone(), None), |(name, version)| { + (name.to_owned(), Some(version.to_owned())) + }); + Self { + id: name.to_lowercase(), + name: Some(name), + version, + file: Some(file.to_owned()), + enabled, + } + } +} + +/// 启动环境快照(Java/内存/OS/组件/mod 清单)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct EnvironmentSnapshot { + /// 启动器版本(`ull 0.1.0`)。 + pub launcher: Option, + /// 实例 id。 + pub instance_id: Option, + /// 游戏版本。 + pub game_version: Option, + /// 操作系统名(`Windows 10.0.19045`)。 + pub os_name: Option, + /// 架构(`x86_64` / `aarch64`)。 + pub os_arch: Option, + /// 物理内存(MB)。 + pub physical_memory_mb: Option, + /// Java 可执行文件。 + pub java_path: Option, + /// Java 大版本。 + pub java_major: Option, + /// Java 位数(32 / 64)。 + pub java_bits: Option, + /// `-Xmx`(MB)。 + pub xmx_mb: Option, + /// `-Xms`(MB)。 + pub xms_mb: Option, + /// 工作目录(跑游戏的 cwd)。 + pub run_dir: Option, + /// 已装组件版本。 + pub components: Vec, + /// 已装 mod。 + pub mods: Vec, +} + +impl EnvironmentSnapshot { + /// 一行人类可读摘要(证据行 / UI 头部)。 + #[must_use] + pub fn summary(&self) -> String { + let os = format!( + "{} {}", + self.os_name.as_deref().unwrap_or("未知系统"), + self.os_arch.as_deref().unwrap_or("") + ); + let java = self.java_major.map_or_else( + || "Java 未知".to_owned(), + |major| match self.java_bits { + Some(bits) => format!("Java {major}({bits} 位)"), + None => format!("Java {major}"), + }, + ); + let memory = match (self.xmx_mb, self.physical_memory_mb) { + (Some(xmx), Some(physical)) => format!("-Xmx {xmx} MB / 物理 {physical} MB"), + (Some(xmx), None) => format!("-Xmx {xmx} MB"), + (None, Some(physical)) => format!("物理 {physical} MB"), + (None, None) => "内存未知".to_owned(), + }; + let mods = self.mods.len(); + format!("{} / {} / {} / {} 个 mod", os.trim(), java, memory, mods) + } + + /// 是不是 32 位 Java(`-Xmx` 超过 ~1.5 GB 必崩的直接证据)。 + #[must_use] + pub fn is_java_32bit(&self) -> bool { + self.java_bits == Some(32) + } + + /// 组件版本(按名字查,`optifine` 之类的冲突规则用)。 + #[must_use] + pub fn component(&self, name: &str) -> Option<&str> { + self.components + .iter() + .find(|component| component.name.eq_ignore_ascii_case(name)) + .map(|component| component.version.as_str()) + } + + /// 是否装了某个 mod(按 id 或文件名模糊匹配)。 + #[must_use] + pub fn has_mod(&self, id: &str) -> bool { + let needle = id.to_ascii_lowercase(); + self.mods.iter().any(|record| { + record.id.contains(&needle) + || record + .file + .as_deref() + .is_some_and(|file| file.to_ascii_lowercase().contains(&needle)) + }) + } +} diff --git a/crates/core/src/diagnose/finding.rs b/crates/core/src/diagnose/finding.rs new file mode 100644 index 0000000..0b5388f --- /dev/null +++ b/crates/core/src/diagnose/finding.rs @@ -0,0 +1,256 @@ +//! 结论与报告的数据结构(纯数据,可序列化,不持有匹配器状态)。 +//! +//! HMCL 的 `CrashReportAnalyzer.Result` 直接把 `Matcher` 传出去(不可序列化、不能跨线程), +//! 这里改成「证据 + 已渲染文案 + 物化动作」的结构化记录(analyse/02 §7.10-6)。 +//! +//! 由 `engine.rs` 用 `#[path]` 声明并统一导出;拆分只为守单文件 400 行上限。 + +use std::collections::BTreeMap; + +use serde::Serialize; + +use crate::diagnose::collectors::{ExitSignal, SignalOrigin}; +use crate::diagnose::hs_err::HsErrReport; +use crate::diagnose::rules::{FixAction, MaterializedFixAction, RuleCategory, Severity}; + +/// 「结论足够确定」的可信度门槛:低于它就该交给 AI 深化(PlanDoc/06 §2.3)。 +pub const CONCLUSIVE_CONFIDENCE: f32 = 0.8; + +/// 一条证据(哪个来源的第几行的哪段文本)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Evidence { + /// 来源。 + pub origin: SignalOrigin, + /// 行号(1 起;结构化信号为 0)。 + pub line: usize, + /// 文本片段(≤300 字符)。 + pub text: String, +} + +impl Evidence { + /// 构造。 + #[must_use] + pub fn new(origin: SignalOrigin, line: usize, text: impl Into) -> Self { + Self { + origin, + line, + text: text.into(), + } + } + + /// 诊断输出里的字符串形式:`": "`。 + #[must_use] + pub fn render(&self) -> String { + format!("{}:{} {}", self.origin, self.line, self.text) + } +} + +/// 一条结论。 +#[derive(Debug, Clone)] +pub struct Finding { + /// 规则 id(内置结论用 `signal.*` / `hs_err.*`)。 + pub rule_id: String, + /// 分类。 + pub category: RuleCategory, + /// 严重度。 + pub severity: Severity, + /// 可信度。 + pub confidence: f32, + /// 主因权重。 + pub priority: u8, + /// i18n / AI 引用 id。 + pub message_key: String, + /// 渲染后的中文结论(M16 `diagnose_crash` 直接给模型看)。 + pub message: String, + /// 细化说明 / 处理建议。 + pub detail: Option, + /// 证据。 + pub evidence: Vec, + /// 六维修复动作。 + pub fix_actions: Vec, + /// 规则捕获组(供动作模板替换)。 + pub groups: BTreeMap, + /// 本结论会抑制哪些规则 id。 + pub suppress: Vec, + /// 被哪条结论抑制(`Some` 即次生结论)。 + pub suppressed_by: Option, +} + +impl Finding { + /// 一条只有骨架的结论(内置结论的构造起点,其余字段由调用方填)。 + #[must_use] + pub fn skeleton( + rule_id: &str, + category: RuleCategory, + severity: Severity, + confidence: f32, + priority: u8, + message_key: &str, + message: &str, + ) -> Self { + Self { + rule_id: rule_id.to_owned(), + category, + severity, + confidence, + priority, + message_key: message_key.to_owned(), + message: message.to_owned(), + detail: None, + evidence: Vec::new(), + fix_actions: Vec::new(), + groups: BTreeMap::new(), + suppress: Vec::new(), + suppressed_by: None, + } + } + + /// 物化后的修复动作(模板已替换)。 + #[must_use] + pub fn materialized_actions(&self) -> Vec { + self.fix_actions + .iter() + .map(|action| action.materialize(&self.groups)) + .collect() + } + + /// 转成诊断输出里的候选。 + #[must_use] + pub fn to_candidate(&self) -> Candidate { + Candidate { + id: self.rule_id.clone(), + severity: self.severity.as_str(), + category: self.category.as_str(), + confidence: self.confidence, + message_key: self.message_key.clone(), + message: self.message.clone(), + detail: self.detail.clone(), + evidence: self.evidence.iter().map(Evidence::render).collect(), + fix_actions: self.materialized_actions(), + } + } + + /// 排序:priority 高 → confidence 高 → id 字典序(保证确定性)。 + #[must_use] + pub fn rank_cmp(&self, other: &Self) -> std::cmp::Ordering { + other + .priority + .cmp(&self.priority) + .then_with(|| other.confidence.total_cmp(&self.confidence)) + .then_with(|| self.rule_id.cmp(&other.rule_id)) + } +} + +/// 诊断输出里的一条候选结论(字段名与 `launch-接口约定.md` §3 `diagnosis` 对齐)。 +#[derive(Debug, Clone, Serialize)] +pub struct Candidate { + /// 规则 id。 + pub id: String, + /// 严重度。 + pub severity: &'static str, + /// 分类。 + pub category: &'static str, + /// 可信度。 + pub confidence: f32, + /// i18n / AI 引用 id。 + pub message_key: String, + /// 渲染后的中文结论。 + pub message: String, + /// 细化说明。 + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + /// 证据字符串。 + pub evidence: Vec, + /// 六维修复动作(M16/UI 一键修复入口)。 + pub fix_actions: Vec, +} + +/// 退出信号摘要(诊断输出用)。 +#[derive(Debug, Clone, Serialize)] +pub struct ExitRecord { + /// 退出码。 + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + /// 退出类型(与 launch 约定同值)。 + pub exit_type: &'static str, + /// 疑似静默失败。 + pub silent_failure: bool, + /// 存活毫秒数。 + #[serde(skip_serializing_if = "Option::is_none")] + pub uptime_ms: Option, +} + +/// 交给 launch 写入 `diagnosis` 事件的结构化结果。 +#[derive(Debug, Clone, Serialize)] +pub struct DiagnosisRecord { + /// 结论来源(`rules` / `ai`)。 + pub source: &'static str, + /// 规则没把握、需要 AI 或用户补充信息。 + pub needs_more_info: bool, + /// 结论(`candidates[0]` 是主因)。 + pub candidates: Vec, + /// 被抑制的次生结论。 + pub secondary: Vec, + /// 本次判定用的退出信号摘要。 + #[serde(skip_serializing_if = "Option::is_none")] + pub exit: Option, +} + +/// 一次诊断的完整结果。 +#[derive(Debug, Clone, Default)] +pub struct DiagnosisReport { + /// 结论(已排序,主因在前)。 + pub findings: Vec, + /// 被抑制的次生结论。 + pub secondary: Vec, + /// 本次判定用的退出信号。 + pub exit: Option, + /// 评估了多少条规则(可观测性)。 + pub rules_evaluated: usize, + /// 解析出的 hs_err(若有)。 + pub hs_err: Option, +} + +impl DiagnosisReport { + /// 主因(可能没有)。 + #[must_use] + pub fn primary(&self) -> Option<&Finding> { + self.findings.first() + } + + /// 规则层有没有把握:没有任何结论,或主因可信度 < [`CONCLUSIVE_CONFIDENCE`]。 + #[must_use] + pub fn needs_more_info(&self) -> bool { + self.primary() + .is_none_or(|finding| finding.confidence < CONCLUSIVE_CONFIDENCE) + } + + /// 去重后的修复动作(主因优先,供 UI 一键修复 / agent 直接执行)。 + #[must_use] + pub fn fix_actions(&self) -> Vec { + let mut out: Vec = Vec::new(); + for action in self.findings.iter().flat_map(Finding::materialized_actions) { + if !out.contains(&action) { + out.push(action); + } + } + out + } + + /// 转成可序列化的诊断记录。 + #[must_use] + pub fn to_record(&self) -> DiagnosisRecord { + DiagnosisRecord { + source: "rules", + needs_more_info: self.needs_more_info(), + candidates: self.findings.iter().map(Finding::to_candidate).collect(), + secondary: self.secondary.iter().map(Finding::to_candidate).collect(), + exit: self.exit.as_ref().map(|exit| ExitRecord { + code: exit.code, + exit_type: exit.exit_type.as_str(), + silent_failure: exit.silent_failure, + uptime_ms: exit.uptime_ms, + }), + } + } +} diff --git a/crates/core/src/diagnose/fix_action.rs b/crates/core/src/diagnose/fix_action.rs new file mode 100644 index 0000000..2be9b00 --- /dev/null +++ b/crates/core/src/diagnose/fix_action.rs @@ -0,0 +1,213 @@ +//! 六维修复动作本体:枚举、维度、以及「代入捕获组 → agent 工具参数」的物化。 +//! +//! 六个维度与 analyse/02 §7.7 的动作分类一一对应,也是 M16 `ToolSpec` 的参数来源 +//! (契约见同目录 `CONTRACT.md` §3)。 + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::fix_params::{AdjustMemory, ChangeGraphics, ChangeJava, Reinstall, RemoveMod, SeekHelp}; +use super::substitute; + +/// 六维修复动作(YAML 里 `kind:` 选维度)。 +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum FixAction { + /// 换 Java。 + ChangeJava(ChangeJava), + /// 调内存。 + AdjustMemory(AdjustMemory), + /// 改图形设置。 + ChangeGraphics(ChangeGraphics), + /// 删/禁/换 mod。 + RemoveMod(RemoveMod), + /// 重装/修组件。 + Reinstall(Reinstall), + /// 求助上报。 + SeekHelp(SeekHelp), +} + +/// 六维动作的维度枚举(不含参数,便于判等/去重/映射工具)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FixActionKind { + /// 换 Java。 + ChangeJava, + /// 调内存。 + AdjustMemory, + /// 改图形设置。 + ChangeGraphics, + /// 删/禁/换 mod。 + RemoveMod, + /// 重装/修组件。 + Reinstall, + /// 求助上报。 + SeekHelp, +} + +impl FixActionKind { + /// 稳定字符串(诊断输出的 `kind` 字段,与 YAML 的 `kind:` 一致)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ChangeJava => "change_java", + Self::AdjustMemory => "adjust_memory", + Self::ChangeGraphics => "change_graphics", + Self::RemoveMod => "remove_mod", + Self::Reinstall => "reinstall", + Self::SeekHelp => "seek_help", + } + } + + /// 建议的 agent 工具 id。 + /// + /// M16 的 P0 工具只有 5 个(`get_config`/`set_config`/`list_game_sessions`/`read_game_log`/ + /// `diagnose_crash`),因此它按 `kind` 桥接;这里的 id 是 P1 落地细分工具时的直接映射。 + #[must_use] + pub const fn agent_tool(self) -> &'static str { + match self { + Self::ChangeJava => "java.set", + Self::AdjustMemory => "memory.set", + Self::ChangeGraphics => "graphics.set", + Self::RemoveMod => "mod.remove", + Self::Reinstall => "component.reinstall", + Self::SeekHelp => "diagnostics.report", + } + } + + /// 全部六维(测试与 UI 遍历用)。 + #[must_use] + pub const fn all() -> [Self; 6] { + [ + Self::ChangeJava, + Self::AdjustMemory, + Self::ChangeGraphics, + Self::RemoveMod, + Self::Reinstall, + Self::SeekHelp, + ] + } +} + +/// 已做模板替换/单位换算的动作,可直接当 agent 工具参数。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MaterializedFixAction { + /// 维度。 + pub kind: FixActionKind, + /// agent 工具 id(见 [`FixActionKind::agent_tool`])。 + pub tool: &'static str, + /// 扁平字符串参数表。 + pub args: BTreeMap, +} + +impl FixAction { + /// 维度。 + #[must_use] + pub const fn kind(&self) -> FixActionKind { + match self { + Self::ChangeJava(_) => FixActionKind::ChangeJava, + Self::AdjustMemory(_) => FixActionKind::AdjustMemory, + Self::ChangeGraphics(_) => FixActionKind::ChangeGraphics, + Self::RemoveMod(_) => FixActionKind::RemoveMod, + Self::Reinstall(_) => FixActionKind::Reinstall, + Self::SeekHelp(_) => FixActionKind::SeekHelp, + } + } + + /// 代入规则命名捕获组,产出 agent 工具可直接用的参数表。 + #[must_use] + pub fn materialize(&self, groups: &BTreeMap) -> MaterializedFixAction { + let kind = self.kind(); + let mut args = BTreeMap::new(); + match self { + Self::ChangeJava(p) => { + let derived = p + .class_file_version_group + .as_ref() + .and_then(|group| groups.get(group)) + .and_then(|raw| raw.trim().parse::().ok()) + .and_then(class_file_to_java_major); + put_opt(&mut args, "required_major", p.required_major.or(derived)); + put_opt(&mut args, "max_major", p.max_major); + put_str(&mut args, "vendor", p.vendor.as_str()); + put_opt_str(&mut args, "note", render_opt(&p.note, groups)); + } + Self::AdjustMemory(p) => { + put_opt(&mut args, "suggested_xmx_mb", p.suggested_xmx_mb); + put_opt(&mut args, "min_xmx_mb", p.min_xmx_mb); + put_bool(&mut args, "need_64bit_java", p.need_64bit_java); + put_bool(&mut args, "increase_system_swap", p.increase_system_swap); + put_opt_str(&mut args, "note", render_opt(&p.note, groups)); + } + Self::ChangeGraphics(p) => { + put_str(&mut args, "setting", p.setting.as_str()); + put_bool(&mut args, "update_driver", p.update_driver); + put_opt_str(&mut args, "suggested_value", p.suggested_value.clone()); + put_opt_str(&mut args, "note", render_opt(&p.note, groups)); + } + Self::RemoveMod(p) => { + let ids = p + .mod_ids + .iter() + .map(|id| substitute(id, groups)) + .collect::>(); + if !ids.is_empty() { + args.insert("mod_ids".to_owned(), ids.join(",")); + } + put_str(&mut args, "action", p.action.as_str()); + put_opt_str(&mut args, "note", render_opt(&p.note, groups)); + } + Self::Reinstall(p) => { + put_str(&mut args, "component", p.component.as_str()); + put_opt_str(&mut args, "path_hint", render_opt(&p.path_hint, groups)); + put_opt_str(&mut args, "note", render_opt(&p.note, groups)); + } + Self::SeekHelp(p) => { + put_bool(&mut args, "export_diagnostics", p.export_diagnostics); + if !p.needs_more_info.is_empty() { + args.insert("needs_more_info".to_owned(), p.needs_more_info.join("; ")); + } + put_opt_str(&mut args, "note", render_opt(&p.note, groups)); + } + } + MaterializedFixAction { + kind, + tool: kind.agent_tool(), + args, + } + } +} + +/// 可选的模板字段:`Some` 时做 `{group}` 替换,`None` 保持 `None`。 +fn render_opt(value: &Option, groups: &BTreeMap) -> Option { + value.as_ref().map(|text| substitute(text, groups)) +} + +fn put_str(args: &mut BTreeMap, key: &str, value: &str) { + args.insert(key.to_owned(), value.to_owned()); +} + +fn put_opt_str(args: &mut BTreeMap, key: &str, value: Option) { + if let Some(value) = value { + args.insert(key.to_owned(), value); + } +} + +fn put_opt(args: &mut BTreeMap, key: &str, value: Option) { + if let Some(value) = value { + args.insert(key.to_owned(), value.to_string()); + } +} + +fn put_bool(args: &mut BTreeMap, key: &str, value: bool) { + args.insert(key.to_owned(), value.to_string()); +} + +/// `class file version 52` → `Java 8`(HMCL `getJavaVersionFromMajorVersion` 同式)。 +#[must_use] +pub fn class_file_to_java_major(class_file_version: u32) -> Option { + class_file_version + .checked_sub(44) + .filter(|major| *major > 0) +} diff --git a/crates/core/src/diagnose/fix_params.rs b/crates/core/src/diagnose/fix_params.rs new file mode 100644 index 0000000..3b4b891 --- /dev/null +++ b/crates/core/src/diagnose/fix_params.rs @@ -0,0 +1,252 @@ +//! 六维修复动作的参数类型(`fix_action.rs` 的载荷 + 几个小枚举)。 +//! +//! 由 `rules.rs` 用 `#[path]` 声明并统一导出;文件拆分只为守 400 行上限。 + +use serde::Deserialize; + +/// 建议的 JVM 实现。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JavaVendor { + /// 不限定(HotSpot 优先)。 + #[default] + Any, + /// 必须 HotSpot(OpenJ9 等不兼容实现的反面)。 + Hotspot, +} + +impl JavaVendor { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Any => "any", + Self::Hotspot => "hotspot", + } + } +} + +/// 图形侧要修的那一类设置。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GraphicsSetting { + /// 不指定具体项,只表达「图形相关」。 + #[default] + Any, + /// 渲染器(OpenGL 核心/兼容、ANGLE、软渲染)。 + Renderer, + /// 显卡驱动。 + Driver, + /// 图形 API(MC 26.2+ 的「默认 / 优先 Vulkan / 优先 OpenGL」三选一)。 + GraphicsApi, + /// 分辨率 / 资源包分辨率。 + Resolution, + /// 覆盖层类软件(RTSS、Discord 等)。 + Overlay, + /// 软件渲染兜底。 + SoftwareRenderer, +} + +impl GraphicsSetting { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Any => "any", + Self::Renderer => "renderer", + Self::Driver => "driver", + Self::GraphicsApi => "graphics_api", + Self::Resolution => "resolution", + Self::Overlay => "overlay", + Self::SoftwareRenderer => "software_renderer", + } + } +} + +/// 对 mod 的处理方式。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ModAction { + /// 移出 mods 目录。 + #[default] + Remove, + /// 暂时禁用(挪到 `.disabled`)。 + Disable, + /// 换一个版本 / 换替代品。 + Replace, + /// 升级到兼容版本。 + Upgrade, +} + +impl ModAction { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Remove => "remove", + Self::Disable => "disable", + Self::Replace => "replace", + Self::Upgrade => "upgrade", + } + } +} + +/// 要重装/修复的组件。粒度对齐 download/game 侧的组件概念,便于 agent 直接调用。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentKind { + /// Java 运行时本身。 + Java, + /// 游戏主 jar。 + GameJar, + /// 版本清单(version JSON)。 + GameJson, + /// 未指定具体 loader。 + Loader, + /// Fabric loader。 + Fabric, + /// Forge。 + Forge, + /// NeoForge。 + #[serde(rename = "neoforge")] + NeoForge, + /// OptiFine。 + #[serde(rename = "optifine")] + OptiFine, + /// natives 原生库。 + Natives, + /// mods 目录(含「jar 被解压成目录」的修复)。 + Mods, + /// libraries 库文件。 + Libraries, + /// assets 资源。 + Assets, + /// MixinBootstrap 等前置。 + MixinBootstrap, +} + +impl ComponentKind { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Java => "java", + Self::GameJar => "game_jar", + Self::GameJson => "game_json", + Self::Loader => "loader", + Self::Fabric => "fabric", + Self::Forge => "forge", + Self::NeoForge => "neoforge", + Self::OptiFine => "optifine", + Self::Natives => "natives", + Self::Mods => "mods", + Self::Libraries => "libraries", + Self::Assets => "assets", + Self::MixinBootstrap => "mixin_bootstrap", + } + } +} + +/// 换 Java 的参数。 +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ChangeJava { + /// 建议的大版本(17 / 21 …)。 + #[serde(default)] + pub required_major: Option, + /// 允许的最高大版本(老 Forge 只能用 Java 8 时给 `8`)。 + #[serde(default)] + pub max_major: Option, + /// 从该命名捕获组取 `class file version N`,按 `N - 44` 换算成 Java 大版本。 + #[serde(default)] + pub class_file_version_group: Option, + /// JVM 实现偏好。 + #[serde(default)] + pub vendor: JavaVendor, + /// 给用户看的补充说明。 + #[serde(default)] + pub note: Option, +} + +/// 调内存的参数。 +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdjustMemory { + /// 建议的 `-Xmx`(MB);规则不确定时留空,由 UI 按物理内存算。 + #[serde(default)] + pub suggested_xmx_mb: Option, + /// `-Xmx` 的下限(MB)。 + #[serde(default)] + pub min_xmx_mb: Option, + /// 需要 32 位 → 64 位 Java(「Could not reserve enough space」类)。 + #[serde(default)] + pub need_64bit_java: bool, + /// 需要用户扩大系统虚拟内存 / 关掉占内存的程序。 + #[serde(default)] + pub increase_system_swap: bool, + /// 补充说明。 + #[serde(default)] + pub note: Option, +} + +/// 改图形设置的参数。 +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ChangeGraphics { + /// 要改的设置类别。 + #[serde(default)] + pub setting: GraphicsSetting, + /// 建议更新显卡驱动。 + #[serde(default)] + pub update_driver: bool, + /// 建议的目标值(如 `854x480`、`OpenGL 兼容模式`)。 + #[serde(default)] + pub suggested_value: Option, + /// 补充说明。 + #[serde(default)] + pub note: Option, +} + +/// 删/禁/换 mod 的参数。 +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RemoveMod { + /// modid 列表,允许 `{group}` 模板(由规则命名捕获组代入)。 + #[serde(default)] + pub mod_ids: Vec, + /// 处理方式。 + #[serde(default)] + pub action: ModAction, + /// 补充说明。 + #[serde(default)] + pub note: Option, +} + +/// 重装/修组件的参数。 +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Reinstall { + /// 要修的组件。 + pub component: ComponentKind, + /// 出问题的路径提示(来自规则捕获组或原文)。 + #[serde(default)] + pub path_hint: Option, + /// 补充说明。 + #[serde(default)] + pub note: Option, +} + +/// 求助/上报的参数。 +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SeekHelp { + /// 建议导出诊断包(日志 + 崩溃报告 + 清单 + 环境)。 + #[serde(default)] + pub export_diagnostics: bool, + /// 还需要用户补充什么(「把日志上限调到 20000 行再复现」)。 + #[serde(default)] + pub needs_more_info: Vec, + /// 补充说明。 + #[serde(default)] + pub note: Option, +} diff --git a/crates/core/src/diagnose/hs_err.rs b/crates/core/src/diagnose/hs_err.rs new file mode 100644 index 0000000..24e4ae5 --- /dev/null +++ b/crates/core/src/diagnose/hs_err.rs @@ -0,0 +1,298 @@ +//! `hs_err_pid*.log`(JVM 致命错误日志)解析器。 +//! +//! HMCL 完全不解析它(analyse/02 §7.10-8),而 `EXCEPTION_ACCESS_VIOLATION` 这类问题 +//! 只有这份文件能给出权威证据(失败帧落在显卡驱动 DLL 里就是驱动问题)。 +//! 格式稳定:**标题行 + 线程段 + 内存段**,本模块只做容错抽取,字段全部 `Option`,永不失败。 +//! +//! 用法(launch 对接见 `CONTRACT.md` §5): +//! - `parse(&text)`:已有全文时用(首选,避免二次截断); +//! - `parse_file(&path)`:给路径时用; +//! - [`HsErrReport::digest`] 产出的摘要既是规则匹配的文本,也是给它人看的证据。 + +use std::fs; +use std::io; +use std::path::Path; + +#[path = "hs_err_report.rs"] +mod report; + +pub use report::{FailedFrame, FrameKind, HsErrKind, HsErrReport, MemorySummary}; + +/// 解析文本(永不失败;无法识别的行直接跳过)。 +#[must_use] +pub fn parse(text: &str) -> HsErrReport { + let mut report = HsErrReport::default(); + let lines = text.lines().collect::>(); + + for (index, raw) in lines.iter().enumerate() { + let line = raw.trim_end_matches('\r'); + let body = line.trim_start_matches('#').trim(); + if report.header.is_none() && is_header(body) { + report.header = Some(body.to_owned()); + } + if report.exception.is_none() { + report.exception = extract_exception(body); + } + if report.siginfo.is_none() && body.starts_with("siginfo:") { + report.siginfo = Some(body.to_owned()); + } + if report.signal.is_none() { + report.signal = body + .contains("si_signo:") + .then(|| body.to_owned()) + .or_else(|| extract_signal(body)); + } + if report.jre_version.is_none() && body.contains("JRE version:") { + report.jre_version = Some(body.to_owned()); + } + if report.java_vm.is_none() && body.contains("Java VM:") { + report.java_vm = Some(body.to_owned()); + } + if report.os.is_none() && body.starts_with("OS:") { + report.os = Some(body.to_owned()); + } + if report.command_line.is_none() && body.starts_with("java_command:") { + report.command_line = Some(body.to_owned()); + } + if report.current_thread.is_none() && body.starts_with("Current thread (") { + report.current_thread = Some(body.to_owned()); + } + if report.memory.is_none() && body.starts_with("Memory:") { + report.memory = parse_memory(body); + } + if report.failed_frame.is_none() && body.eq_ignore_ascii_case("Problematic frame:") { + report.failed_frame = lines + .iter() + .skip(index + 1) + .map(|next| next.trim_end_matches('\r')) + .find(|next| !next.trim().is_empty() && next.trim() != "#") + .map(|next| parse_frame(next.trim_start_matches('#').trim())); + } + if report.pc.is_none() && body.contains("pc=") { + report.pc = field(body, "pc="); + } + if report.pid.is_none() && body.contains("pid=") { + report.pid = field(body, "pid=").and_then(|raw| raw.parse::().ok()); + } + if report.tid.is_none() && body.contains("tid=") { + report.tid = field(body, "tid="); + } + if body.eq_ignore_ascii_case("VM Arguments:") { + // 真实 hs_err 里 jvm_args/java_command 是顶格写的,不缩进 + report.vm_arguments = collect_unindented(&lines, index + 1, 32); + } + if body.starts_with("Heap:") { + // "Heap:" 之后的缩进行才是内容(garbage-first heap total … used …) + report.heap = collect_block(&lines, index + 1, 6); + } + } + + report.header_lines = collect_header_block(&lines); + report.pc = report + .exception + .as_deref() + .and_then(|line| field(line, "pc=")) + .or(report.pc); + // pid/tid 在「异常行」和「Out of Memory Error (os_*.cpp:NNN), pid=…」两种标题里都出现, + // 所以两种形态都认,先到先得。 + if report.pid.is_none() { + report.pid = report + .exception + .as_deref() + .and_then(|line| field(line, "pid=")) + .and_then(|raw| raw.parse::().ok()); + } + if report.tid.is_none() { + report.tid = report + .exception + .as_deref() + .and_then(|line| field(line, "tid=")); + } + report.kind = classify(&report); + report +} + +/// 读文件并解析(非 UTF-8 字节按 U+FFFD 替换,不报错)。 +pub fn parse_file(path: impl AsRef) -> io::Result { + let path = path.as_ref(); + let bytes = fs::read(path)?; + let text = String::from_utf8_lossy(&bytes); + let mut report = parse(&text); + report.path = Some(path.to_path_buf()); + Ok(report) +} + +fn is_header(body: &str) -> bool { + const MARKERS: [&str; 5] = [ + "insufficient memory", + "A fatal error has been detected", + "Out of Memory Error", + "Internal Error", + "SIGSEGV", + ]; + MARKERS.iter().any(|marker| body.contains(marker)) +} + +fn extract_exception(body: &str) -> Option { + let marker = "EXCEPTION_"; + let start = body.find(marker)?; + let rest = &body[start..]; + let end = rest + .find(" at pc=") + .or_else(|| rest.find(", pid=")) + .unwrap_or(rest.len()); + Some(rest[..end].trim().to_owned()) +} + +fn extract_signal(body: &str) -> Option { + let start = body.find("SIG")?; + let rest = &body[start..]; + let end = rest.find(')').map_or(rest.len(), |index| index + 1); + Some(rest[..end].trim().to_owned()) +} + +/// 从 `key=value` 形态里取值(到下一个空格/逗号为止)。 +fn field(line: &str, key: &str) -> Option { + let start = line.find(key)? + key.len(); + let rest = &line[start..]; + let end = rest.find([' ', ',', ']']).unwrap_or(rest.len()); + let value = rest[..end].trim(); + (!value.is_empty()).then(|| value.to_owned()) +} + +fn parse_frame(raw: &str) -> FailedFrame { + let kind = match raw.chars().next() { + Some('C') => FrameKind::Native, + Some('J' | 'j') => FrameKind::Java, + Some('V') => FrameKind::VmStub, + _ => FrameKind::Unknown, + }; + let (module, offset) = raw + .find('[') + .and_then(|start| { + let inner = &raw[start + 1..]; + let inner = &inner[..inner.find(']')?]; + let mut parts = inner.split('+'); + let module = parts.next().map(str::trim).filter(|m| !m.is_empty())?; + Some(( + Some(module.to_owned()), + parts.next().map(|o| o.trim().to_owned()), + )) + }) + .unwrap_or((None, None)); + FailedFrame { + raw: raw.to_owned(), + kind, + module, + offset, + } +} + +fn parse_memory(body: &str) -> Option { + let mut summary = MemorySummary { + raw: body.to_owned(), + ..MemorySummary::default() + }; + for part in body.split(',') { + let part = part.trim(); + if part.contains("page") { + summary.page_size = part + .trim_start_matches("Memory:") + .split_whitespace() + .next() + .map(|value| value.trim_end_matches(',').to_owned()); + } else if part.starts_with("physical") { + let (total, free) = parse_kb_pair(part); + summary.physical_kb = total; + summary.physical_free_kb = free; + } else if part.starts_with("swap") { + let (total, free) = parse_kb_pair(part); + summary.swap_kb = total; + summary.swap_free_kb = free; + } + } + Some(summary) +} + +/// `physical 16741480k(4716856k free)` → (16741480, 4716856)。 +fn parse_kb_pair(part: &str) -> (Option, Option) { + let numbers = part + .split(|ch: char| !ch.is_ascii_digit()) + .filter(|token| !token.is_empty()) + .filter_map(|token| token.parse::().ok()) + .collect::>(); + (numbers.first().copied(), numbers.get(1).copied()) +} + +/// 取从 `start` 开始、到第一个空行为止的连续行(VM Arguments / java_command 段)。 +fn collect_unindented(lines: &[&str], start: usize, limit: usize) -> Vec { + lines + .iter() + .skip(start) + .map(|line| line.trim_end_matches('\r')) + .take_while(|line| !line.trim().is_empty()) + .take(limit) + .map(|line| line.trim().to_owned()) + .collect() +} + +/// 取从 `start` 开始、以空白/制表缩进的连续行(最多 `limit` 行)。 +fn collect_block(lines: &[&str], start: usize, limit: usize) -> Vec { + lines + .iter() + .skip(start) + .map(|line| line.trim_end_matches('\r')) + .skip_while(|line| line.trim().is_empty()) + .take_while(|line| line.starts_with([' ', '\t'])) + .take(limit) + .map(|line| line.trim().to_owned()) + .collect() +} + +/// 文件开头的 `#` 注释块(真实 hs_err 的标题/异常/失败帧都在这里)。 +fn collect_header_block(lines: &[&str]) -> Vec { + let mut out = Vec::new(); + for line in lines { + let line = line.trim_end_matches('\r'); + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + if trimmed.starts_with('#') { + out.push(trimmed.to_owned()); + } + } else { + break; + } + if out.len() >= 64 { + break; + } + } + out +} + +fn classify(report: &HsErrReport) -> HsErrKind { + let haystack = [ + report.header.as_deref(), + report.exception.as_deref(), + report.siginfo.as_deref(), + ] + .into_iter() + .flatten() + .collect::>() + .join("\n"); + if haystack.contains("EXCEPTION_ACCESS_VIOLATION") || haystack.contains("SIGSEGV") { + HsErrKind::AccessViolation + } else if haystack.contains("insufficient memory") + || haystack.contains("Out of Memory") + || haystack.contains("OutOfMemory") + { + HsErrKind::OutOfMemory + } else if haystack.contains("StackOverflow") || haystack.contains("stack overflow") { + HsErrKind::StackOverflow + } else if haystack.contains("Internal Error") + || haystack.contains("A fatal error has been detected") + { + HsErrKind::InternalError + } else { + HsErrKind::Other + } +} diff --git a/crates/core/src/diagnose/hs_err_report.rs b/crates/core/src/diagnose/hs_err_report.rs new file mode 100644 index 0000000..a4bb49b --- /dev/null +++ b/crates/core/src/diagnose/hs_err_report.rs @@ -0,0 +1,210 @@ +//! `hs_err_pid*.log` 解析结果的数据结构(标题行 / 线程段 / 内存段 / 失败帧)。 +//! +//! 由 `hs_err.rs` 用 `#[path]` 声明并重新导出;拆分只为守单文件 400 行上限。 + +use std::fmt; +use std::path::PathBuf; + +/// hs_err 的崩溃类别(标题行/异常名归并成少数几类,供内置结论判定)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum HsErrKind { + /// `EXCEPTION_ACCESS_VIOLATION` / `SIGSEGV`:野指针,显卡驱动/原生库最常见。 + AccessViolation, + /// 内存不足(`insufficient memory` / `Out of Memory Error`)。 + OutOfMemory, + /// 栈溢出。 + StackOverflow, + /// JVM 内部致命错误(`Internal Error` / `A fatal error has been detected`)。 + InternalError, + /// 其它 / 无法归类。 + #[default] + Other, +} + +impl HsErrKind { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::AccessViolation => "access_violation", + Self::OutOfMemory => "out_of_memory", + Self::StackOverflow => "stack_overflow", + Self::InternalError => "internal_error", + Self::Other => "other", + } + } +} + +impl fmt::Display for HsErrKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// 失败帧的类型(`# C` 原生码、`# J`/`# j` Java 帧、`# V` VM 生成桩帧)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FrameKind { + /// 原生(C/C++)帧:出现在显卡驱动/OpenGL 里基本可判定驱动问题。 + Native, + /// Java 帧(`# J` / `# j`,二者区别是解释执行还是编译执行,诊断不需要再细分)。 + Java, + /// VM 生成桩帧(`# V`):JIT/解释器生成的运行时代码,不是用户代码。 + VmStub, + /// 未识别。 + #[default] + Unknown, +} + +impl FrameKind { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Native => "native", + Self::Java => "java", + Self::VmStub => "vm_stub", + Self::Unknown => "unknown", + } + } +} + +/// `# Problematic frame:` 之后的那一行。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FailedFrame { + /// 原始文本(`C [ig8d64.dll+0x9b3c4d]`)。 + pub raw: String, + /// 帧类型。 + pub kind: FrameKind, + /// 命中的模块/DLL(`ig8d64.dll`)。 + pub module: Option, + /// 模块内偏移(`0x9b3c4d`)。 + pub offset: Option, +} + +/// 内存段摘要(`Memory: 4k page, physical …, swap …`)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct MemorySummary { + /// 页大小(`4k`)。 + pub page_size: Option, + /// 物理内存 kB。 + pub physical_kb: Option, + /// 物理可用 kB。 + pub physical_free_kb: Option, + /// 交换区 kB。 + pub swap_kb: Option, + /// 交换区可用 kB。 + pub swap_free_kb: Option, + /// 原始行。 + pub raw: String, +} + +impl MemorySummary { + /// 一行人类可读摘要(GB 保留一位小数)。 + #[must_use] + pub fn summary(&self) -> String { + let gb = |kb: Option| { + kb.map(|kb| format!("{:.1} GB", kb as f64 / 1024.0 / 1024.0)) + .unwrap_or_else(|| "?".to_owned()) + }; + format!( + "物理内存 {}(可用 {}),交换区 {}(可用 {})", + gb(self.physical_kb), + gb(self.physical_free_kb), + gb(self.swap_kb), + gb(self.swap_free_kb) + ) + } +} + +/// 解析结果。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HsErrReport { + /// 来源文件(`parse` 时为 `None`)。 + pub path: Option, + /// 标题行(`#[derive(Default)]` 之外的语义见 `parse`)。 + pub header: Option, + /// 崩溃类别。 + pub kind: HsErrKind, + /// 异常名(`EXCEPTION_ACCESS_VIOLATION (0xc0000005)`)。 + pub exception: Option, + /// `siginfo:` 行(Unix 侧带 si_signo / si_addr)。 + pub siginfo: Option, + /// 信号名(`SIGSEGV (0xb)`)。 + pub signal: Option, + /// 出错时的指令指针。 + pub pc: Option, + /// 进程 pid。 + pub pid: Option, + /// 线程 tid(保留原始十六进制/十进制串)。 + pub tid: Option, + /// 失败帧。 + pub failed_frame: Option, + /// 当前线程(`JavaThread "Render thread" [_thread_in_native, id=…]`)。 + pub current_thread: Option, + /// JRE 版本行内容。 + pub jre_version: Option, + /// JVM 实现行内容。 + pub java_vm: Option, + /// 操作系统行内容。 + pub os: Option, + /// `java_command` 行。 + pub command_line: Option, + /// `VM Arguments:` 段的参数行。 + pub vm_arguments: Vec, + /// 内存段摘要。 + pub memory: Option, + /// `Heap:` 段的前几行(原样,含 total/used)。 + pub heap: Vec, + /// 标题块原文(逐行保留 `#` 前缀,规则可以直接在上面匹配)。 + pub header_lines: Vec, +} + +impl HsErrReport { + /// 供规则匹配 / 证据展示的摘要文本。 + /// + /// 保留标题块原文(`EXCEPTION_ACCESS_VIOLATION` 与 `# C [ig…dll]` 的相对位置不变, + /// 因此 `graphics.graphics_driver` 这类跨行规则可以直接命中),再补上线程/内存段要点。 + #[must_use] + pub fn digest(&self) -> String { + let mut out = String::new(); + for line in &self.header_lines { + out.push_str(line); + out.push('\n'); + } + for line in [ + self.siginfo.as_deref(), + self.current_thread.as_deref(), + self.jre_version.as_deref(), + self.java_vm.as_deref(), + self.os.as_deref(), + self.command_line.as_deref(), + ] + .into_iter() + .flatten() + { + out.push_str(line); + out.push('\n'); + } + for line in &self.heap { + out.push_str(line); + out.push('\n'); + } + if let Some(memory) = &self.memory { + out.push_str(&memory.raw); + out.push('\n'); + } + out + } + + /// 一句话摘要(诊断输出 / UI 头部)。 + #[must_use] + pub fn summary(&self) -> String { + let kind = self.kind.as_str(); + let frame = self + .failed_frame + .as_ref() + .map_or_else(String::new, |frame| format!(",失败帧 {}", frame.raw)); + let header = self.header.as_deref().unwrap_or("JVM 致命错误"); + format!("{header}({kind}{frame})") + } +} diff --git a/crates/core/src/diagnose/loader.rs b/crates/core/src/diagnose/loader.rs new file mode 100644 index 0000000..7cdb8fa --- /dev/null +++ b/crates/core/src/diagnose/loader.rs @@ -0,0 +1,398 @@ +//! 规则库加载:YAML 分组文件 + 内嵌兜底 + 热重载。 +//! +//! 对应 PlanDoc/06 §2.2「规则文件 YAML 外置可热更」(修 HMCL 编译期枚举不可热更的债): +//! 1. 内嵌一份(`include_str!`):任何情况下都能诊断,不依赖安装目录完整; +//! 2. 用户目录 `data/diagnose/rules.d/*.yaml` 覆盖内嵌的同名文件(缺哪个用内嵌补哪个); +//! 3. 热重载是 **mtime+size 指纹轮询**([`RuleLoader::refresh`]),不引 `notify` 依赖; +//! 解析失败时**保留上一份好规则**并返回错误列表——用户手改错 YAML 不会让诊断能力归零。 + +use std::collections::hash_map::DefaultHasher; +use std::collections::HashSet; +use std::fmt; +use std::fs; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use super::rules::{CompiledRule, Rule, RuleCategory}; + +/// 规则目录(相对数据根,由 platform 模块解析)。 +pub const RULES_DIR: &str = "diagnose/rules.d"; + +/// 规则 schema 版本(文件里 `version: 1`)。 +pub const SCHEMA_VERSION: u32 = 1; + +/// 内嵌规则库:`(文件名, 内容)`。按分类拆文件,每文件守 400 行上限。 +pub const EMBEDDED_RULES: &[(&str, &str)] = &[ + ("jvm.yaml", include_str!("rules.d/jvm.yaml")), + ("memory.yaml", include_str!("rules.d/memory.yaml")), + ("graphics.yaml", include_str!("rules.d/graphics.yaml")), + ("integrity.yaml", include_str!("rules.d/integrity.yaml")), + ("fabric.yaml", include_str!("rules.d/fabric.yaml")), + ("forge.yaml", include_str!("rules.d/forge.yaml")), + ("mod_runtime.yaml", include_str!("rules.d/mod_runtime.yaml")), + ( + "mod_conflict.yaml", + include_str!("rules.d/mod_conflict.yaml"), + ), + ("world.yaml", include_str!("rules.d/world.yaml")), + ("other.yaml", include_str!("rules.d/other.yaml")), +]; + +/// 一个 YAML 文件的 schema。 +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuleFile { + #[serde(default)] + version: u32, + category: RuleCategory, + rules: Vec, +} + +/// 编译好的规则集(字段私有,只能经 loader 构造,保证「id 唯一 + 正则可编译」)。 +#[derive(Debug, Clone, Default)] +pub struct RuleSet { + rules: Vec, +} + +impl RuleSet { + /// 规则表。 + #[must_use] + pub fn rules(&self) -> &[CompiledRule] { + &self.rules + } + + /// 规则条数。 + #[must_use] + pub fn len(&self) -> usize { + self.rules.len() + } + + /// 是否空规则集。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.rules.is_empty() + } + + /// 按 id 查规则(测试 / 调试用)。 + #[must_use] + pub fn get(&self, id: &str) -> Option<&CompiledRule> { + self.rules.iter().find(|rule| rule.id() == id) + } + + /// 从 `(文件名, 内容)` 列表编译;有任何错误就整体失败(调用方决定是否回退)。 + pub fn from_sources(sources: &[(String, String)]) -> Result> { + let mut errors = Vec::new(); + let mut rules: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for (file, text) in sources { + let parsed = match serde_yaml_ng::from_str::(text) { + Ok(parsed) => parsed, + Err(error) => { + errors.push(LoadError::yaml(file, &error)); + continue; + } + }; + if parsed.version > SCHEMA_VERSION { + errors.push(LoadError { + file: file.clone(), + kind: LoadErrorKind::UnsupportedVersion, + message: format!( + "规则文件 version={} 高于本程序支持的 {}", + parsed.version, SCHEMA_VERSION + ), + }); + continue; + } + for rule in parsed.rules { + if !seen.insert(rule.id.clone()) { + errors.push(LoadError { + file: file.clone(), + kind: LoadErrorKind::DuplicateId, + message: format!("规则 id 重复:{}", rule.id), + }); + continue; + } + match CompiledRule::compile(rule, parsed.category) { + Ok(compiled) => rules.push(compiled), + Err(error) => errors.push(LoadError { + file: file.clone(), + kind: LoadErrorKind::Regex, + message: error.to_string(), + }), + } + } + } + if errors.is_empty() { + Ok(Self { rules }) + } else { + Err(errors) + } + } +} + +/// 加载错误分类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoadErrorKind { + /// 读盘失败。 + Io, + /// YAML 语法/schema 错误(`serde_yaml_ng` 的报错带行号)。 + Yaml, + /// 正则编译失败。 + Regex, + /// 规则 id 跨文件重复。 + DuplicateId, + /// 规则文件版本高于本程序。 + UnsupportedVersion, +} + +impl LoadErrorKind { + /// 稳定字符串。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Io => "io", + Self::Yaml => "yaml", + Self::Regex => "regex", + Self::DuplicateId => "duplicate_id", + Self::UnsupportedVersion => "unsupported_version", + } + } +} + +/// 一条加载错误(带文件名,用户改 YAML 时能定位)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoadError { + /// 文件名(磁盘目录加载时是完整路径)。 + pub file: String, + /// 分类。 + pub kind: LoadErrorKind, + /// 报错原文(YAML 错误含行号)。 + pub message: String, +} + +impl LoadError { + fn yaml(file: &str, error: &serde_yaml_ng::Error) -> Self { + Self { + file: file.to_owned(), + kind: LoadErrorKind::Yaml, + message: error.to_string(), + } + } + + fn io(file: &Path, error: &std::io::Error) -> Self { + Self { + file: file.display().to_string(), + kind: LoadErrorKind::Io, + message: error.to_string(), + } + } +} + +impl fmt::Display for LoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "[{}] {}:{}", + self.kind.as_str(), + self.file, + self.message + ) + } +} + +impl std::error::Error for LoadError {} + +/// 热重载结果。 +#[derive(Debug, Clone, PartialEq)] +pub enum ReloadOutcome { + /// 指纹没变,规则未动。 + Unchanged, + /// 重新加载成功。 + Reloaded { + /// 新规则条数。 + rules: usize, + }, + /// 新文件有错:**保留旧规则**,错误交给调用方展示。 + Rejected { + /// 错误列表。 + errors: Vec, + }, +} + +/// 规则库加载器(持有当前生效的规则集 + 目录指纹)。 +#[derive(Debug, Clone)] +pub struct RuleLoader { + dir: Option, + set: RuleSet, + fingerprint: u64, + last_errors: Vec, +} + +impl Default for RuleLoader { + fn default() -> Self { + Self::embedded() + } +} + +impl RuleLoader { + /// 只用内嵌规则(无磁盘目录)。 + #[must_use] + pub fn embedded() -> Self { + let sources = embedded_sources(); + let (set, errors) = compile_sources(&sources); + Self { + dir: None, + set, + fingerprint: 0, + last_errors: errors, + } + } + + /// 以磁盘目录为准、内嵌补齐缺文件。 + /// + /// 磁盘规则有任何错误时**退回内嵌规则**并记录错误(保证永远有可用规则集)。 + #[must_use] + pub fn from_dir(dir: impl Into) -> Self { + let dir = dir.into(); + let (sources, mut errors) = merged_sources(Some(&dir)); + let set = match RuleSet::from_sources(&sources) { + Ok(set) => set, + Err(mut load_errors) => { + errors.append(&mut load_errors); + RuleSet::from_sources(&embedded_sources()).unwrap_or_default() + } + }; + Self { + fingerprint: dir_fingerprint(&dir), + dir: Some(dir), + set, + last_errors: errors, + } + } + + /// 当前生效的规则。 + #[must_use] + pub fn rules(&self) -> &[CompiledRule] { + self.set.rules() + } + + /// 当前生效的规则集。 + #[must_use] + pub fn rule_set(&self) -> &RuleSet { + &self.set + } + + /// 上次加载留下的错误(UI 可提示「你的规则文件有错,已用内置规则」)。 + #[must_use] + pub fn last_errors(&self) -> &[LoadError] { + &self.last_errors + } + + /// 规则目录(内嵌模式为 `None`)。 + #[must_use] + pub fn dir(&self) -> Option<&Path> { + self.dir.as_deref() + } + + /// 目录指纹(测试用:确认 mtime/size 变化真的改变了指纹)。 + #[must_use] + pub fn fingerprint(&self) -> u64 { + self.fingerprint + } + + /// 轮询热重载:指纹变了才重新解析。 + pub fn refresh(&mut self) -> ReloadOutcome { + let Some(dir) = self.dir.clone() else { + return ReloadOutcome::Unchanged; + }; + let fingerprint = dir_fingerprint(&dir); + if fingerprint == self.fingerprint { + return ReloadOutcome::Unchanged; + } + self.fingerprint = fingerprint; + let (sources, mut errors) = merged_sources(Some(&dir)); + match RuleSet::from_sources(&sources) { + Ok(set) => { + self.set = set; + self.last_errors = errors; + ReloadOutcome::Reloaded { + rules: self.set.len(), + } + } + Err(mut load_errors) => { + errors.append(&mut load_errors); + self.last_errors = errors.clone(); + ReloadOutcome::Rejected { errors } + } + } + } +} + +fn embedded_sources() -> Vec<(String, String)> { + EMBEDDED_RULES + .iter() + .map(|(name, text)| ((*name).to_owned(), (*text).to_owned())) + .collect() +} + +/// 磁盘文件覆盖内嵌:磁盘有的用磁盘,没的用内嵌。 +fn merged_sources(dir: Option<&Path>) -> (Vec<(String, String)>, Vec) { + let mut sources = Vec::new(); + let mut errors = Vec::new(); + for (name, embedded) in EMBEDDED_RULES { + let path = dir.map(|dir| dir.join(name)); + match path { + Some(path) if path.is_file() => match fs::read_to_string(&path) { + Ok(text) => sources.push((path.display().to_string(), text)), + Err(error) => { + errors.push(LoadError::io(&path, &error)); + sources.push(((*name).to_owned(), (*embedded).to_owned())); + } + }, + _ => sources.push(((*name).to_owned(), (*embedded).to_owned())), + } + } + (sources, errors) +} + +fn compile_sources(sources: &[(String, String)]) -> (RuleSet, Vec) { + match RuleSet::from_sources(sources) { + Ok(set) => (set, Vec::new()), + Err(errors) => (RuleSet::default(), errors), + } +} + +/// 目录指纹:所有 `*.yaml` 的(文件名, 长度, mtime)之和。 +/// +/// 长度 + mtime 足以发现「保存了但秒级 mtime 未变」之外的一切编辑;漏检时用户点刷新即可。 +fn dir_fingerprint(dir: &Path) -> u64 { + let mut entries = fs::read_dir(dir) + .map(|entries| { + entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml")) + }) + .collect::>() + }) + .unwrap_or_default(); + entries.sort(); + let mut hasher = DefaultHasher::new(); + for path in entries { + path.file_name().unwrap_or_default().hash(&mut hasher); + if let Ok(metadata) = fs::metadata(&path) { + metadata.len().hash(&mut hasher); + metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()) + .hash(&mut hasher); + } + } + hasher.finish() +} diff --git a/crates/core/src/diagnose/mod.rs b/crates/core/src/diagnose/mod.rs new file mode 100644 index 0000000..d026f43 --- /dev/null +++ b/crates/core/src/diagnose/mod.rs @@ -0,0 +1,17 @@ +//! 崩溃诊断:五类信号采集(含 hs_err_pid 解析)、YAML 规则引擎(59 条规则起步,可热更新)、 +//! exit0 静默失败检测、结构化诊断结论。 +//! +//! 设计定稿:`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §2(采集 / 规则 / 流水线)。 +//! 定位:规则引擎为主、AI 补位;AI 拿到的应是「规则结论 + 相关片段」而非裸日志 +//! (`DevDocs/PlanDoc/02-ai-agent-design.md` §3.2)。 +//! +//! 规划中的公共面(M15):信号采集、规则匹配、诊断报告、规则热加载。 + +// 只有这五个顶层模块;子模块(collectors 下的 signals/sources/environment、engine 下的 +// finding/synthetic、hs_err 下的 hs_err_report、rules 下的 fix_action/fix_params)由各自的 +// 父文件用 #[path] 声明。这里**不要**重复声明,否则同一文件会被编译成两份不同类型。 +pub mod collectors; +pub mod engine; +pub mod hs_err; +pub mod loader; +pub mod rules; diff --git a/crates/core/src/diagnose/rules.d/fabric.yaml b/crates/core/src/diagnose/rules.d/fabric.yaml new file mode 100644 index 0000000..9a9db85 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/fabric.yaml @@ -0,0 +1,141 @@ +# 规则库 · Fabric mod 解析(8 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的 Fabric 组(analyse/02 §7.3 第 5 组)。 +# Fabric 的 ModResolutionException 家族特征:异常后面跟一段以 " - " 开头的列表, +# 因此这些规则用 file scope(跨行)。 +version: 1 +category: fabric +rules: + - id: fabric.mod_resolution + severity: critical + scope: file + pattern: 'ModResolutionException: (?(.*)[\n\r]*( - (.*)[\n\r]*)+)' + confidence: 0.75 + priority: 70 + cause: + key: diagnose.cause.fabric.mod_resolution + summary: 'Fabric 解析 mod 依赖失败(缺依赖/版本冲突/模组不配套)' + detail: '按下方列出的 mod 名单逐个处理:缺依赖就补装,冲突就删一个' + fix_action: + - kind: remove_mod + action: disable + note: '按 Fabric 报的 mod 名单逐个禁用排查' + sample: samples/fabric/mod_resolution.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION' + + - id: fabric.mod_resolution_conflict + severity: critical + pattern: 'ModResolutionException: Found conflicting mods: (?.*) conflicts with (?.*)' + confidence: 0.92 + priority: 86 + suppress: + - integrity.no_class_def_found + - integrity.no_such_method + cause: + key: diagnose.cause.fabric.mod_resolution_conflict + summary: 'Fabric 报告 mod 冲突:{sourcemod} 与 {destmod} 不能共存' + detail: '两个只能留一个(或都升级到互相兼容的版本)' + fix_action: + - kind: remove_mod + mod_ids: + - '{sourcemod}' + - '{destmod}' + action: remove + sample: samples/fabric/mod_resolution_conflict.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION_CONFLICT' + + - id: fabric.mod_resolution_missing + severity: critical + pattern: 'ModResolutionException: Could not find required mod: (?.*) requires (?.*)' + confidence: 0.9 + priority: 85 + cause: + key: diagnose.cause.fabric.mod_resolution_missing + summary: '缺少前置 mod:{sourcemod} 需要 {destmod}' + detail: '装上 {destmod} 的对应版本;装不上就删掉 {sourcemod}' + fix_action: + - kind: remove_mod + mod_ids: + - '{sourcemod}' + action: replace + note: '补装前置 {destmod},或换掉 {sourcemod}' + sample: samples/fabric/mod_resolution_missing.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION_MISSING' + + - id: fabric.mod_resolution_missing_minecraft + severity: critical + pattern: 'ModResolutionException: Could not find required mod: (?.*) requires \{minecraft @ (?.*)\}' + confidence: 0.9 + priority: 85 + cause: + key: diagnose.cause.fabric.mod_resolution_missing_minecraft + summary: '{mod} 要求 Minecraft {version},与当前实例版本不一致' + fix_action: + - kind: remove_mod + mod_ids: + - '{mod}' + action: replace + note: '换该 mod 的 {version} 版本,或改实例版本' + sample: samples/fabric/mod_resolution_missing_minecraft.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION_MISSING_MINECRAFT' + + - id: fabric.mod_resolution_collection + severity: critical + pattern: 'ModResolutionException: Could not resolve valid mod collection \(at: (?.*) requires (?.*)\)' + confidence: 0.88 + priority: 84 + cause: + key: diagnose.cause.fabric.mod_resolution_collection + summary: 'mod 集合无法解析:{sourcemod} 与 {destmod} 的版本组合不成立' + fix_action: + - kind: remove_mod + mod_ids: + - '{sourcemod}' + action: upgrade + sample: samples/fabric/mod_resolution_collection.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION_COLLECTION' + + - id: fabric.mod_resolution_details + severity: warning + pattern: '(\tMod File:|-- MOD |\tFailure message:)' + confidence: 0.5 + priority: 45 + cause: + key: diagnose.cause.fabric.mod_resolution_details + summary: 'Fabric 打印了 mod 解析明细(Mod File / Failure message):具体结论见上方规则' + detail: '这条只表示「日志里有解析失败明细」,用于把证据片段指给 AI/用户' + fix_action: + - kind: seek_help + export_diagnostics: true + sample: samples/fabric/mod_resolution_details.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_RESOLUTION0' + + - id: fabric.loader_0_12 + severity: critical + pattern: 'java\.lang\.NoClassDefFoundError: org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy' + confidence: 0.85 + priority: 80 + cause: + key: diagnose.cause.fabric.loader_0_12 + summary: 'Fabric Loader 0.12 与旧 mixin 类不兼容(loader 删掉了 deprecated 类)' + fix_action: + - kind: reinstall + component: fabric + note: '换匹配的 Fabric Loader 版本' + sample: samples/fabric/loader_0_12.log + source: 'HMCL CrashReportAnalyzer.Rule.FABRIC_VERSION_0_12' + + - id: fabric.warnings + severity: warning + scope: file + pattern: '(Warnings were found!|Incompatible mod set!|Incompatible mods found!)(.*?)[\n\r]+(?[^\[]+)\[' + confidence: 0.7 + priority: 62 + cause: + key: diagnose.cause.fabric.warnings + summary: 'Fabric 报告 mod 集合不兼容:{reason}' + fix_action: + - kind: remove_mod + action: disable + note: '按警告列表逐个禁用' + sample: samples/fabric/warnings.log + source: 'HMCL CrashReportAnalyzer.Rule.FABRIC_WARNINGS' diff --git a/crates/core/src/diagnose/rules.d/forge.yaml b/crates/core/src/diagnose/rules.d/forge.yaml new file mode 100644 index 0000000..71aa1cd --- /dev/null +++ b/crates/core/src/diagnose/rules.d/forge.yaml @@ -0,0 +1,136 @@ +# 规则库 · Forge mod 解析(8 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的 Forge 组(analyse/02 §7.3 第 6 组)。 +version: 1 +category: forge +rules: + - id: forge.missing_dependencies + severity: critical + scope: file + pattern: 'Missing or unsupported mandatory dependencies:(?(.*)[\n\r]*(\t(.*)[\n\r]*)+)' + confidence: 0.85 + priority: 80 + cause: + key: diagnose.cause.forge.missing_dependencies + summary: 'Forge 报「缺少或不支持强制依赖」:有 mod 的前置没装或版本不对' + detail: '按报错里的 mod 名补齐前置;装不上就删掉依赖它的 mod' + fix_action: + - kind: remove_mod + action: disable + note: '按报错逐条补齐/禁用' + sample: samples/forge/missing_dependencies.log + source: 'HMCL CrashReportAnalyzer.Rule.FORGEMOD_RESOLUTION' + + - id: forge.duplicate_mods + severity: critical + scope: file + pattern: 'Found duplicate mods:(?(.*)(?:\r\n|\n|\r)*(\t(.*)(?:\r\n|\n|\r)*)+)' + confidence: 0.9 + priority: 86 + cause: + key: diagnose.cause.forge.duplicate_mods + summary: '同一个 mod 装了两份(mods 目录里重复的 jar)' + detail: '删掉重复的那一份,只留一个版本' + fix_action: + - kind: remove_mod + action: remove + note: '保留一个版本' + sample: samples/forge/duplicate_mods.log + source: 'HMCL CrashReportAnalyzer.Rule.FORGE_FOUND_DUPLICATE_MODS' + + - id: forge.duplicated_mod + severity: critical + pattern: 'Found a duplicate mod (?.*) at (?.*)' + confidence: 0.92 + priority: 88 + cause: + key: diagnose.cause.forge.duplicated_mod + summary: 'mod 重复:{name} 同时出现在 {path}' + fix_action: + - kind: remove_mod + mod_ids: + - '{name}' + action: remove + note: '删除路径 {path} 那一份' + sample: samples/forge/duplicated_mod.log + source: 'HMCL CrashReportAnalyzer.Rule.DUPLICATED_MOD' + + - id: forge.repeat_installation + severity: critical + pattern: 'MultipleArgumentsForOptionException: Found multiple arguments for option (.*?), but you asked for only one' + confidence: 0.85 + priority: 80 + cause: + key: diagnose.cause.forge.repeat_installation + summary: 'Forge 被装了两次(同一版本出现多个安装参数)' + detail: '删掉重复的 Forge 安装/版本 JSON,再重装一次' + fix_action: + - kind: reinstall + component: forge + sample: samples/forge/repeat_installation.log + source: 'HMCL CrashReportAnalyzer.Rule.FORGE_REPEAT_INSTALLATION' + + - id: forge.optifine_repeat_installation + severity: critical + pattern: 'ResolutionException: Module optifine reads another module named optifine' + confidence: 0.88 + priority: 82 + cause: + key: diagnose.cause.forge.optifine_repeat_installation + summary: 'OptiFine 装了两份(mods 里一个 + 自动安装一个)' + fix_action: + - kind: remove_mod + mod_ids: + - optifine + action: remove + note: '只保留一种安装方式' + sample: samples/forge/optifine_repeat_installation.log + source: 'HMCL CrashReportAnalyzer.Rule.OPTIFINE_REPEAT_INSTALLATION' + + - id: forge.too_many_mods + severity: critical + pattern: 'maximum id range exceeded' + confidence: 0.9 + priority: 80 + cause: + key: diagnose.cause.forge.too_many_mods + summary: '装的 mod 太多,方块/物品 ID 超出上限(1.12 及更早的经典问题)' + detail: '删掉一部分 mod,或换用 1.13+ 的版本(不再有 ID 上限)' + fix_action: + - kind: remove_mod + action: disable + note: '减量或换版本' + sample: samples/forge/too_many_mods.log + source: 'HMCL CrashReportAnalyzer.Rule.TOO_MANY_MODS_LEAD_TO_EXCEEDING_THE_ID_LIMIT' + + - id: forge.module_name_invalid + severity: warning + pattern: >- + Invalid module name: '' is not a Java identifier + confidence: 0.8 + priority: 70 + cause: + key: diagnose.cause.forge.module_name_invalid + summary: '某个 mod 的 jar 文件名不含英文字母/数字,Forge 把它当成非法 JPMS 模块名(如「拔刀剑.jar」)' + detail: '把该 jar 改成英文文件名(保留 .jar 后缀)再启动' + fix_action: + - kind: remove_mod + action: replace + note: '把 jar 改成英文文件名' + sample: samples/forge/module_name_invalid.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_NAME' + + - id: forge.bootstrap_failed + severity: critical + pattern: 'Failed to create mod instance\. ModID: (?.*?),' + confidence: 0.85 + priority: 80 + cause: + key: diagnose.cause.forge.bootstrap_failed + summary: 'mod {id} 的实例创建失败(初始化就抛异常)' + fix_action: + - kind: remove_mod + mod_ids: + - '{id}' + action: disable + sample: samples/forge/bootstrap_failed.log + source: 'HMCL CrashReportAnalyzer.Rule.BOOTSTRAP_FAILED' diff --git a/crates/core/src/diagnose/rules.d/graphics.yaml b/crates/core/src/diagnose/rules.d/graphics.yaml new file mode 100644 index 0000000..a070e88 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/graphics.yaml @@ -0,0 +1,221 @@ +# 规则库 · 显卡 / 图形(11 条 = HMCL 移植 6 + Vulkan 组 5) +# 移植自 HMCL CrashReportAnalyzer.Rule 的图形组(analyse/02 §7.3 第 3 组); +# Vulkan 组见文件末尾(依据 research/05-mc-vulkan.md §3.4 的 renderpearl 原因码)。 +version: 1 +category: graphics +rules: + - id: graphics.gl_operation_failure + severity: warning + pattern: '(1282: Invalid operation|Maybe try a lower resolution resourcepack\?)' + confidence: 0.7 + priority: 60 + cause: + key: diagnose.cause.graphics.gl_operation_failure + summary: '某个 mod/光影包做了非法的 OpenGL 操作(GL_INVALID_OPERATION)' + detail: '换掉最近装的光影/资源包,或把资源包分辨率降下来' + fix_action: + - kind: change_graphics + setting: renderer + suggested_value: 'OpenGL 兼容模式' + note: '先低分辨率资源包验证' + sample: samples/graphics/gl_operation_failure.log + source: 'HMCL CrashReportAnalyzer.Rule.GL_OPERATION_FAILURE' + + - id: graphics.opengl_not_supported + severity: critical + pattern: 'The driver does not appear to support OpenGL' + confidence: 0.85 + priority: 75 + cause: + key: diagnose.cause.graphics.opengl_not_supported + summary: '显卡驱动不支持 OpenGL(多半是没装显卡驱动,走了软件渲染)' + detail: '装/更新显卡厂商驱动;虚拟机里需要开 3D 加速' + fix_action: + - kind: change_graphics + setting: driver + update_driver: true + sample: samples/graphics/opengl_not_supported.log + source: 'HMCL CrashReportAnalyzer.Rule.OPENGL_NOT_SUPPORTED' + + - id: graphics.graphics_driver + severity: critical + # HMCL 的跨行贪婪模式(EXCEPTION_ACCESS_VIOLATION … # C [ig/nvoglv/atio)→ file scope。 + # 末四段是 SDL 变体:26.3-snapshot-4 起窗口层 GLFW→SDL(lwjgl-glfw 消失、lwjgl-sdl 出现), + # 同类故障的原文会变成 SDL_GetError 的文本;旧 GLFW 模式保留(老版本日志仍需要)。 + scope: file + pattern: '(Pixel format not accelerated|GLX: Failed to create context: GLXBadFBConfig|Couldn''t set pixel format|net\.minecraftforge\.fml\.client\.SplashProgress|org\.lwjgl\.LWJGLException|EXCEPTION_ACCESS_VIOLATION(.|\n|\r)+# C {2}\[(ig|atio|nvoglv)|SDL_CreateWindow failed|Couldn''t find matching render driver|No available video device)' + confidence: 0.8 + priority: 78 + cause: + key: diagnose.cause.graphics.graphics_driver + summary: '显卡驱动侧创建 OpenGL 上下文失败(像素格式/驱动崩溃)' + detail: '更新显卡驱动并重启;老显卡可用软渲染或换 OpenGL 兼容模式' + fix_action: + - kind: change_graphics + setting: driver + update_driver: true + - kind: change_graphics + setting: software_renderer + note: '驱动更新无效时用软渲染兜底(性能会掉)' + sample: samples/graphics/graphics_driver.log + source: 'HMCL CrashReportAnalyzer.Rule.GRAPHICS_DRIVER' + + - id: graphics.macos_display_port + severity: critical + # 原 GLFW 模式保留。后两段是 SDL 变体(26.3 起窗口层换 SDL,无显示器/远程会话在 SDL 侧 + # 报 SDL_Init 失败)——**但它必须和 `cocoa` 同时出现才命中**:SDL 在 macOS 上的视频驱动 + # 就叫 `cocoa`,用它把本规则的 macOS 特异性钉住,免得 Windows/Linux 的 SDL_Init 失败被 + # 误归因成「macOS 显示器问题」。⚠️ SDL 侧原文未在 26.3 实机验证(预测模式),拿到真实 + # 日志后按实际文本收紧;宁可漏诊也不要跨平台误归因。 + pattern: '(java\.lang\.IllegalStateException: GLFW error before init: \[0x10008\]Cocoa: Failed to find service port for display|(?i:cocoa).*SDL_Init.*failed|SDL_Init.*failed.*(?i:cocoa))' + confidence: 0.85 + priority: 78 + cause: + key: diagnose.cause.graphics.macos_display_port + summary: 'macOS 上 GLFW 初始化窗口失败(Cocoa 找不到 display service port)' + detail: '多见于远程桌面/无显示器会话;换成本机登录或升级 macOS 后再试' + fix_action: + - kind: change_graphics + setting: any + note: 'macOS 会话/显示器问题' + sample: samples/graphics/macos_display_port.log + source: 'HMCL CrashReportAnalyzer.Rule.MACOS_FAILED_TO_FIND_SERVICE_PORT_FOR_DISPLAY' + + - id: graphics.resolution_too_high + severity: warning + pattern: 'Maybe try a (lower resolution|lowerresolution) (resourcepack|texturepack)\?' + confidence: 0.8 + priority: 65 + cause: + key: diagnose.cause.graphics.resolution_too_high + summary: '资源包分辨率过高(游戏直接提示换低分辨率资源包)' + fix_action: + - kind: change_graphics + setting: resolution + suggested_value: '低分辨率资源包' + sample: samples/graphics/resolution_too_high.log + source: 'HMCL CrashReportAnalyzer.Rule.RESOLUTION_TOO_HIGH' + + - id: graphics.rtss_sodium + severity: warning + pattern: 'RivaTuner Statistics Server \(RTSS\) is not compatible with Sodium' + confidence: 0.9 + priority: 70 + cause: + key: diagnose.cause.graphics.rtss_sodium + summary: 'RTSS(微星小飞机/帧数显示)与 Sodium 不兼容' + detail: '关掉或卸载 RTSS 的 OSD 覆盖层,或改用 Sodium 兼容的帧数显示' + fix_action: + - kind: change_graphics + setting: overlay + suggested_value: '关闭 RTSS 覆盖层' + sample: samples/graphics/rtss_sodium.log + source: 'HMCL CrashReportAnalyzer.Rule.RTSS_FOREST_SODIUM' + + # ── Vulkan 后端组(MC 26.2+ 的实验性 Vulkan 渲染)──────────────────────────── + # 证据:research/05-mc-vulkan.md §3.4。renderpearl 的 + # `BackendCreationException$Reason` 有五个稳定原因码(四个 vulkan_* + opengl_missing), + # 它们同时出现在 latest.log 的 `Backend failure reason: <码>` 与崩溃报告的 + # `Backend failure reason` 行,是最可靠的落点(比英文正文稳,正文会随版本改)。 + # 注意语义:26.2/26.3 的 Vulkan 首选失败会**自动回落 OpenGL**,所以单条 vulkan_* 命中 + # 通常不是崩溃主因(warning);只有五个后端全挂才是 critical(见最后一条)。 + + - id: graphics.vulkan_loader_missing + severity: warning + pattern: '(Vulkan loader library is missing|vulkan_loader_missing)' + confidence: 0.85 + priority: 80 + cause: + key: diagnose.cause.graphics.vulkan_loader_missing + summary: '机器上没有 Vulkan loader(Vulkan 后端起不来,游戏已回落 OpenGL)' + detail: '装/更新显卡驱动(Windows 需要 vulkan-1.dll,Linux 需要 libvulkan.so.1);装不上就改用 OpenGL 后端' + fix_action: + - kind: change_graphics + setting: graphics_api + suggested_value: 'opengl' + note: 'Vulkan loader 缺失时强制 OpenGL,避免每次启动都白试一遍' + - kind: change_graphics + setting: driver + update_driver: true + sample: samples/graphics/vulkan_loader_missing.log + source: 'research/05-mc-vulkan.md §3.4(renderpearl BackendCreationException.Reason)' + + - id: graphics.vulkan_device_version_too_low + severity: warning + pattern: '(vulkan_device_version_too_low|Vulkan [0-9]\.[0-9].{0,40}at least [0-9]\.[0-9] required)' + confidence: 0.85 + priority: 78 + cause: + key: diagnose.cause.graphics.vulkan_device_version_too_low + summary: '显卡/驱动支持的 Vulkan 版本低于 1.2(Vulkan 后端起不来)' + detail: '更新显卡驱动;驱动停更的老卡(10 年以上)没有 Vulkan 1.2,只能继续用 OpenGL' + fix_action: + - kind: change_graphics + setting: graphics_api + suggested_value: 'opengl' + - kind: change_graphics + setting: driver + update_driver: true + sample: samples/graphics/vulkan_device_version_too_low.log + source: 'research/05-mc-vulkan.md §3.4' + + - id: graphics.vulkan_missing_extension + severity: warning + pattern: '(vulkan_missing_extension|missing: .*VK_KHR_(dynamic_rendering|push_descriptor))' + confidence: 0.85 + priority: 76 + cause: + key: diagnose.cause.graphics.vulkan_missing_extension + summary: '驱动缺 Vulkan 必需扩展(VK_KHR_dynamic_rendering / VK_KHR_push_descriptor)' + detail: 'MC 26.2+ 的 Vulkan 后端要求 VK 1.2 + dynamic_rendering + push_descriptor,三者缺一就跑不了;Mesa Dozen 也不支持 push_descriptor' + fix_action: + - kind: change_graphics + setting: graphics_api + suggested_value: 'opengl' + - kind: change_graphics + setting: driver + update_driver: true + note: '只有在驱动有新版本时才可能补上扩展' + sample: samples/graphics/vulkan_missing_extension.log + source: 'research/05-mc-vulkan.md §3.4 + §4.4(Dozen 反例)' + + - id: graphics.vulkan_missing_feature + severity: warning + pattern: '(vulkan_missing_feature|missing capabilities: .*VULKAN_CORE_1_2)' + confidence: 0.8 + priority: 74 + cause: + key: diagnose.cause.graphics.vulkan_missing_feature + summary: '驱动缺 Vulkan 必需特性集(VULKAN_CORE_1_2)' + detail: '同上,属于驱动能力不足;老核显/虚拟机里常见,用 OpenGL 后端即可' + fix_action: + - kind: change_graphics + setting: graphics_api + suggested_value: 'opengl' + sample: samples/graphics/vulkan_missing_feature.log + source: 'research/05-mc-vulkan.md §3.4' + + - id: graphics.no_graphics_backend + severity: critical + pattern: 'No supported graphics backend was found' + confidence: 0.9 + priority: 82 + suppress: + - graphics.vulkan_loader_missing + - graphics.vulkan_device_version_too_low + - graphics.vulkan_missing_extension + - graphics.vulkan_missing_feature + cause: + key: diagnose.cause.graphics.no_graphics_backend + summary: 'Vulkan 与 OpenGL 两个后端都创建失败(26.2+ 只有这两条路)' + detail: '硬件/驱动两侧都不满足:先更新显卡驱动;虚拟机需要开 3D 加速;实在不行只能换机器' + fix_action: + - kind: change_graphics + setting: graphics_api + suggested_value: 'opengl' + note: '先确认 Vulkan 失败原因再决定是否强推 OpenGL' + - kind: change_graphics + setting: software_renderer + note: '软渲染兜底能起来但基本没法玩,只用于导数据/排查' + sample: samples/graphics/no_graphics_backend.log + source: 'research/05-mc-vulkan.md §1.5(两个后端全失败才崩)' diff --git a/crates/core/src/diagnose/rules.d/integrity.yaml b/crates/core/src/diagnose/rules.d/integrity.yaml new file mode 100644 index 0000000..5eda6ee --- /dev/null +++ b/crates/core/src/diagnose/rules.d/integrity.yaml @@ -0,0 +1,138 @@ +# 规则库 · 文件与安装完整性(8 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的完整性组(analyse/02 §7.3 第 4 组)。 +# 组内约定:通用异常(NoSuchMethodError / NoClassDefFoundError)优先级低, +# 让「安装不完整 / jar 被解压 / mod 冲突」这类能定位根因的规则通过 suppress 把它们降级为次生结论。 +version: 1 +category: integrity +rules: + - id: integrity.file_changed + severity: critical + pattern: 'java\.lang\.SecurityException: SHA1 digest error for (?.*)|signer information does not match signer information of other classes in the same package' + confidence: 0.85 + priority: 85 + cause: + key: diagnose.cause.integrity.file_changed + summary: '游戏文件被改过(SHA1 校验不一致):jar 内容与清单哈希对不上' + detail: '重新下载/校验该文件;不要手改 jar 里的内容' + fix_action: + - kind: reinstall + component: game_jar + path_hint: '{file}' + sample: samples/integrity/file_changed.log + source: 'HMCL CrashReportAnalyzer.Rule.FILE_CHANGED' + + - id: integrity.no_such_method + severity: warning + pattern: 'java\.lang\.NoSuchMethodError: (?.*?)' + confidence: 0.6 + priority: 40 + cause: + key: diagnose.cause.integrity.no_such_method + summary: 'NoSuchMethodError:加载器/coremod 注入的东西与当前库版本不匹配' + detail: '常见于 mod 与游戏版本/Forge 版本不配套;重装加载器或删掉冲突 mod' + fix_action: + - kind: reinstall + component: loader + note: '先重装加载器;仍失败再逐个禁用 mod' + sample: samples/integrity/no_such_method.log + source: 'HMCL CrashReportAnalyzer.Rule.NO_SUCH_METHOD_ERROR' + + - id: integrity.no_class_def_found + severity: warning + pattern: 'java\.lang\.NoClassDefFoundError: (?.*)' + confidence: 0.6 + priority: 40 + cause: + key: diagnose.cause.integrity.no_class_def_found + summary: 'NoClassDefFoundError:缺少类文件(库没下全 / jar 被解压 / mod 不配套)' + detail: '先检查 libraries 是否完整,再排查 mods 里的解压目录' + fix_action: + - kind: reinstall + component: libraries + sample: samples/integrity/no_class_def_found.log + source: 'HMCL CrashReportAnalyzer.Rule.NO_CLASS_DEF_FOUND_ERROR' + + - id: integrity.illegal_access + severity: warning + pattern: 'java\.lang\.IllegalAccessError: tried to access class (.*?) from class (?.*?)' + confidence: 0.55 + priority: 45 + cause: + key: diagnose.cause.integrity.illegal_access + summary: 'IllegalAccessError:某个 coremod 想反射访问别的类的内部成员(在新 JDK 上被封了)' + fix_action: + - kind: reinstall + component: loader + note: '或换该 coremod/mod 的兼容版本' + sample: samples/integrity/illegal_access.log + source: 'HMCL CrashReportAnalyzer.Rule.ILLEGAL_ACCESS_ERROR' + + - id: integrity.file_already_exists + severity: warning + pattern: 'java\.nio\.file\.FileAlreadyExistsException: (?.*)' + confidence: 0.75 + priority: 60 + cause: + key: diagnose.cause.integrity.file_already_exists + summary: '某个 mod 要求一个「不该存在」的文件/目录,导致写入失败' + detail: '按提示删掉该文件(通常是玩家手建的空目录/unzip 残留)' + fix_action: + - kind: reinstall + component: mods + path_hint: '{file}' + note: '按路径删掉多余文件' + sample: samples/integrity/file_already_exists.log + source: 'HMCL CrashReportAnalyzer.Rule.FILE_ALREADY_EXISTS' + + - id: integrity.mod_files_decompressed + severity: critical + pattern: '(The directories below appear to be extracted jar files\. Fix this before you continue|Extracted mod jars found, loading will NOT continue)' + confidence: 0.95 + priority: 90 + suppress: + - integrity.no_class_def_found + - integrity.no_such_method + cause: + key: diagnose.cause.integrity.mod_files_decompressed + summary: 'mods 目录里有被解压成文件夹的 jar:加载器会直接拒绝启动' + detail: '把解压出来的目录删掉,只保留 .jar 文件(很多压缩软件双击就解压了)' + fix_action: + - kind: reinstall + component: mods + note: '删掉解压出来的目录' + sample: samples/integrity/mod_files_decompressed.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_FILES_ARE_DECOMPRESSED' + + - id: integrity.incomplete_forge + severity: critical + pattern: '(java\.io\.UncheckedIOException: java\.io\.IOException: Invalid paths argument, contained no existing paths: \[(.*?)(forge-(.*?)-client\.jar|fmlcore-(.*?)\.jar)\]|Failed to find Minecraft resource version (.*?) at (.*?)forge-(.*?)-client\.jar|Cannot find launch target fmlclient, unable to launch|java\.lang\.IllegalStateException: Could not find net/minecraft/client/Minecraft\.class in classloader SecureModuleClassLoader)' + confidence: 0.9 + priority: 88 + suppress: + - integrity.no_class_def_found + - integrity.no_such_method + cause: + key: diagnose.cause.integrity.incomplete_forge + summary: 'Forge 安装不完整(缺 forge-*-client.jar / fmlcore)' + detail: '重装该版本的 Forge,并检查 libraries 是否下全' + fix_action: + - kind: reinstall + component: forge + sample: samples/integrity/incomplete_forge.log + source: 'HMCL CrashReportAnalyzer.Rule.INCOMPLETE_FORGE_INSTALLATION' + + - id: integrity.unsatisfied_link + severity: critical + pattern: 'java\.lang\.UnsatisfiedLinkError: Failed to locate library: (?.*)' + confidence: 0.85 + priority: 82 + cause: + key: diagnose.cause.integrity.unsatisfied_link + summary: '找不到原生库(natives):{name} 没解压出来或被杀软删了' + detail: '重装组件(重新解压 natives);Windows 上检查杀软隔离区' + fix_action: + - kind: reinstall + component: natives + path_hint: '{name}' + sample: samples/integrity/unsatisfied_link.log + source: 'HMCL CrashReportAnalyzer.Rule.UNSATISFIED_LINK_ERROR' diff --git a/crates/core/src/diagnose/rules.d/jvm.yaml b/crates/core/src/diagnose/rules.d/jvm.yaml new file mode 100644 index 0000000..780258c --- /dev/null +++ b/crates/core/src/diagnose/rules.d/jvm.yaml @@ -0,0 +1,142 @@ +# 规则库 · JVM / Java 版本(8 条) +# 移植自 HMCL CrashReportAnalyzer.Rule(analyse/02 §7.3 第 1 组)。 +# schema 见 ../CONTRACT.md §2:pattern(regex) -> {cause, confidence, fix_action[]} +version: 1 +category: jvm +rules: + - id: jvm.openj9 + severity: critical + pattern: '(Open J9 is not supported|OpenJ9 is incompatible|\.J9VMInternals\.)' + confidence: 0.9 + priority: 88 + cause: + key: diagnose.cause.jvm.openj9 + summary: '当前用的是 OpenJ9 虚拟机:Minecraft 与绝大多数 mod 只支持 HotSpot(Oracle/OpenJDK)' + detail: '请换成 HotSpot 的 Java 8/17/21(Adoptium、Zulu、Microsoft OpenJDK 的 hotspot 版本都可以)' + fix_action: + - kind: change_java + vendor: hotspot + note: 'OpenJ9 不兼容,换 HotSpot' + sample: samples/jvm/openj9.log + source: 'HMCL CrashReportAnalyzer.Rule.OPENJ9' + + - id: jvm.need_jdk11 + severity: critical + pattern: '(no such method: sun\.misc\.Unsafe\.defineAnonymousClass\(Class,byte\[\],Object\[\]\)Class/invokeVirtual|java\.lang\.UnsupportedClassVersionError: icyllis/modernui/forge/MixinConnector has been compiled by a more recent version of the Java Runtime \(class file version 55\.0\), this version of the Java Runtime only recognizes class file versions up to 52\.0|java\.lang\.IllegalArgumentException: The requested compatibility level JAVA_11 could not be set\. Level is not supported by the active JRE or ASM version)' + confidence: 0.92 + priority: 90 + cause: + key: diagnose.cause.jvm.need_jdk11 + summary: '模组/加载器需要 Java 11,当前 Java 版本不够' + detail: '把实例的 Java 换到 11 或更高(1.17+ 的游戏通常要 17/21)' + fix_action: + - kind: change_java + required_major: 11 + sample: samples/jvm/need_jdk11.log + source: 'HMCL CrashReportAnalyzer.Rule.NEED_JDK11' + + - id: jvm.too_old_java + severity: critical + pattern: 'java\.lang\.UnsupportedClassVersionError: (.*?) version (?\d+)\.0' + confidence: 0.92 + priority: 88 + cause: + key: diagnose.cause.jvm.too_old_java + summary: 'Java 版本过低:无法加载用 class file version {expected} 编译的类,请换用更高版本的 Java' + detail: '这个 class 版本号减去 44 就是所需的 Java 大版本(52→Java 8、61→Java 17)' + fix_action: + - kind: change_java + class_file_version_group: expected + sample: samples/jvm/too_old_java.log + source: 'HMCL CrashReportAnalyzer.Rule.TOO_OLD_JAVA' + + - id: jvm.32bit + severity: critical + pattern: '(Could not reserve enough space for (.*?)KB object heap|The specified size exceeds the maximum representable size|Invalid maximum heap size)' + scope: line + confidence: 0.9 + priority: 92 + suppress: + - memory.out_of_memory + cause: + key: diagnose.cause.jvm.32bit + summary: '分配堆内存失败:多半是 32 位 Java 或 -Xmx 超过系统可用内存' + detail: '32 位 Java 最多只能用约 1.5 GB;换 64 位 Java 或把 -Xmx 降到 2048 MB 以下' + fix_action: + - kind: adjust_memory + need_64bit_java: true + min_xmx_mb: 1024 + - kind: change_java + required_major: 17 + note: '32 位换 64 位' + sample: samples/jvm/jvm_32bit.log + source: 'HMCL CrashReportAnalyzer.Rule.JVM_32BIT' + + - id: jvm.jdk_9 + severity: critical + pattern: 'java\.lang\.ClassCastException: (java\.base/jdk|class jdk)' + confidence: 0.8 + priority: 80 + cause: + key: diagnose.cause.jvm.jdk_9 + summary: '游戏只能在 Java 8 上跑(Forge/旧核心库不兼容 Java 9+),当前 Java 版本太高' + fix_action: + - kind: change_java + max_major: 8 + sample: samples/jvm/jdk_9.log + source: 'HMCL CrashReportAnalyzer.Rule.JDK_9' + + - id: jvm.version_too_high + severity: critical + pattern: '(Unable to make protected final java\.lang\.Class java\.lang\.ClassLoader\.defineClass|java\.lang\.NoSuchFieldException: ucp|Unsupported class file major version|because module java\.base does not export|java\.lang\.ClassNotFoundException: jdk\.nashorn\.api\.scripting\.NashornScriptEngineFactory|java\.lang\.ClassNotFoundException: java\.lang\.invoke\.LambdaMetafactory|Exception in thread "main" java\.lang\.NullPointerException: Cannot read the array length because "urls" is null)' + confidence: 0.85 + priority: 84 + cause: + key: diagnose.cause.jvm.version_too_high + summary: 'Java 版本过高:老版本 Forge/核心库用了被新 JDK 移除或封装的接口' + detail: '1.16 及更早优先用 Java 8;1.17–1.20.4 用 Java 17;1.20.5+ 用 Java 21' + fix_action: + - kind: change_java + vendor: hotspot + note: '降到该游戏版本要求的 Java' + - kind: reinstall + component: loader + note: '或把 Forge/加载器升级到支持新 Java 的版本' + sample: samples/jvm/java_version_is_too_high.log + source: 'HMCL CrashReportAnalyzer.Rule.JAVA_VERSION_IS_TOO_HIGH' + + - id: jvm.modlauncher_8 + severity: critical + pattern: 'java\.lang\.NoSuchMethodError: (''void sun\.security\.util\.ManifestEntryVerifier\.\(java\.util\.jar\.Manifest\)''|sun\.security\.util\.ManifestEntryVerifier\.\(Ljava/util/jar/Manifest;\)V)' + confidence: 0.9 + priority: 86 + cause: + key: diagnose.cause.jvm.modlauncher_8 + summary: 'ModLauncher 8 与当前 Java 不兼容(JDK-8273826):1.16+ Forge 在新 JDK 上会这样崩' + fix_action: + - kind: change_java + max_major: 8 + - kind: reinstall + component: forge + note: '或升级 Forge 到新版' + sample: samples/jvm/modlauncher_8.log + source: 'HMCL CrashReportAnalyzer.Rule.MODLAUNCHER_8' + + - id: jvm.mac_jdk8u261 + severity: critical + pattern: >- + Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'NSWindow drag regions should only be invalidated on the Main Thread!' + confidence: 0.85 + priority: 82 + cause: + key: diagnose.cause.jvm.mac_jdk8u261 + summary: 'macOS 上 JDK 8u261+ 用新 Xcode 编译导致的 Forge/OptiFine 崩溃(sp614x/optifine#4824)' + detail: '换 Java 8u252 及更早,或升级 Forge/OptiFine' + fix_action: + - kind: change_java + max_major: 8 + note: '优先试 8u252' + - kind: reinstall + component: optifine + sample: samples/jvm/mac_jdk8u261.log + source: 'HMCL CrashReportAnalyzer.Rule.MAC_JDK_8U261' diff --git a/crates/core/src/diagnose/rules.d/memory.yaml b/crates/core/src/diagnose/rules.d/memory.yaml new file mode 100644 index 0000000..44b7385 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/memory.yaml @@ -0,0 +1,39 @@ +# 规则库 · 内存(2 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的内存组(analyse/02 §7.3 第 2 组)。 +version: 1 +category: memory +rules: + - id: memory.out_of_memory + severity: critical + # 最后一条是跨行组合("Error occurred during initialization of VM" + "Too small maximum heap"), + # 因此整条规则用 file scope;HMCL 的 \R 在 Rust regex 里写成 (?:\r\n|\n|\r)。 + scope: file + pattern: '(java\.lang\.OutOfMemoryError|The system is out of physical RAM or swap space|Out of Memory Error|Error occurred during initialization of VM(?:\r\n|\n|\r)Too small maximum heap)' + confidence: 0.9 + priority: 80 + cause: + key: diagnose.cause.memory.out_of_memory + summary: '游戏内存不足(OutOfMemoryError):-Xmx 太小,或装的 mod 太多/整合包太大' + detail: '把 -Xmx 调到物理内存的一半左右(一般 4096–8192 MB),装光影/大量 mod 时适度加大' + fix_action: + - kind: adjust_memory + min_xmx_mb: 2048 + note: '优先加大 -Xmx;若已经是 32 位 Java,先换 64 位' + sample: samples/memory/out_of_memory.log + source: 'HMCL CrashReportAnalyzer.Rule.OUT_OF_MEMORY' + + - id: memory.memory_exceeded + severity: critical + pattern: 'There is insufficient memory for the Java Runtime Environment to continue' + confidence: 0.88 + priority: 82 + cause: + key: diagnose.cause.memory.memory_exceeded + summary: '系统物理内存/交换区耗尽:JVM 连续分配失败,整机内存不够' + detail: '关掉占内存的程序、降低 -Xmx,或扩大系统虚拟内存(页面文件)' + fix_action: + - kind: adjust_memory + increase_system_swap: true + note: '整机内存不足,不只是 -Xmx 的问题' + sample: samples/memory/memory_exceeded.log + source: 'HMCL CrashReportAnalyzer.Rule.MEMORY_EXCEEDED' diff --git a/crates/core/src/diagnose/rules.d/mod_conflict.yaml b/crates/core/src/diagnose/rules.d/mod_conflict.yaml new file mode 100644 index 0000000..6f0b867 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/mod_conflict.yaml @@ -0,0 +1,152 @@ +# 规则库 · 模组互斥(8 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的互斥组(analyse/02 §7.3 第 8 组): +# 具体到某个知名 mod 与 OptiFine / Sodium 的成名冲突,修复动作几乎都是「删 OptiFine 或其对手」。 +version: 1 +category: mod_conflict +rules: + - id: conflict.optifine_forge + severity: critical + scope: file + # 移植说明:HMCL 原文里的 \> / \< 在这里改成裸 > / <(正则里 \< \> 是词边界断言,要字面量得写裸字符)。 + # 末尾 alternative 保持 upstream 语义:`\(Reflector\.java)` 的前置 \( 是**字面括号**,尾部裸 ) 才是**闭合外层分组**, + # 因此它是前缀匹配。这里显式写成 `\(Reflector\.java(?::\d+)?\))`:裸 `(Reflector.java)` 与带行号的 + # `(Reflector.java:455)` 两种真实栈帧形态都能命中(P2-1 回归,见 tests/diagnose/engine_rank.rs)。 + pattern: >- + (java\.lang\.NoSuchMethodError: 'java\.lang\.Class sun\.misc\.Unsafe\.defineAnonymousClass\(java\.lang\.Class, byte\[\], java\.lang\.Object\[\]\)'|java\.lang\.NoSuchMethodError: 'void net\.minecraft\.client\.renderer\.texture\.SpriteContents\.\(net\.minecraft\.resources\.ResourceLocation, |java\.lang\.NoSuchMethodError: 'void net\.minecraftforge\.client\.gui\.overlay\.ForgeGui\.renderSelectedItemName\(net\.minecraft\.client\.gui\.GuiGraphics, int\)'|java\.lang\.NoSuchMethodError: 'java\.lang\.String com\.mojang\.blaze3d\.systems\.RenderSystem\.getBackendDescription\(\)'|java\.lang\.NoSuchMethodError: 'net\.minecraft\.network\.chat\.FormattedText net\.minecraft\.client\.gui\.Font\.ellipsize\(net\.minecraft\.network\.chat\.FormattedText, int\)'|java\.lang\.NoSuchMethodError: 'void net\.minecraft\.server\.level\.DistanceManager\.(.*?)\(net\.minecraft\.server\.level\.TicketType, net\.minecraft\.world\.level\.ChunkPos, int, java\.lang\.Object, boolean\)'|java\.lang\.NoSuchMethodError: 'void net\.minecraft\.client\.renderer\.block\.model\.BakedQuad\.\(int\[\], int, net\.minecraft\.core\.Direction, net\.minecraft\.client\.renderer\.texture\.TextureAtlasSprite, boolean, boolean\)'|TRANSFORMER/net\.optifine/net\.optifine\.reflect\.Reflector\.\(Reflector\.java(?::\d+)?\)) + confidence: 0.9 + priority: 87 + suppress: + - integrity.no_such_method + - integrity.no_class_def_found + cause: + key: diagnose.cause.conflict.optifine_forge + summary: 'OptiFine 与 Forge(或某个 Forge mod)不兼容:NoSuchMethodError 指向被 OptiFine 改写的原版方法' + detail: '删掉 OptiFine,改用同类替代(Sodium/Iris、Embeddium、Oculus 等),或换 OptiFine 的匹配版本' + fix_action: + - kind: remove_mod + mod_ids: + - optifine + action: remove + note: 'OptiFine 与 Forge 冲突,删掉其一' + sample: samples/mod_conflict/optifine_forge.log + source: 'HMCL CrashReportAnalyzer.Rule.OPTIFINE_IS_NOT_COMPATIBLE_WITH_FORGE' + + - id: conflict.shaders_mod + severity: critical + pattern: 'java\.lang\.RuntimeException: Shaders Mod detected\. Please remove it, OptiFine has built-in support for shaders\.' + confidence: 0.95 + priority: 85 + cause: + key: diagnose.cause.conflict.shaders_mod + summary: '装了独立的 Shaders Mod,但 OptiFine 自带光影支持,两者不能共存' + fix_action: + - kind: remove_mod + mod_ids: + - shadersmod + action: remove + sample: samples/mod_conflict/shaders_mod.log + source: 'HMCL CrashReportAnalyzer.Rule.SHADERS_MOD' + + - id: conflict.multipart_optifine + severity: critical + pattern: 'Error occurred applying transform of coremod META-INF/asm/multipart\.js function render' + confidence: 0.8 + priority: 80 + cause: + key: diagnose.cause.conflict.multipart_optifine + summary: 'Forge Multipart 的 coremod 转换失败(多为 OptiFine 同时存在导致)' + fix_action: + - kind: remove_mod + mod_ids: + - optifine + action: remove + note: '删 OptiFine 或 ForgeMultipart 之一' + sample: samples/mod_conflict/multipart_optifine.log + source: 'HMCL CrashReportAnalyzer.Rule.MOD_FOREST_OPTIFINE' + + - id: conflict.performant_optifine + severity: critical + pattern: 'org\.spongepowered\.asm\.mixin\.injection\.throwables\.InjectionError: Critical injection failure: Redirector OnisOnLadder\(Lnet/minecraft/block/BlockState;Lnet/minecraft/world/World;Lnet/minecraft/util/math/BlockPos;Lnet/minecraft/entity/LivingEntity;\)Z in performant\.mixins\.json:entity\.LivingEntityMixin failed injection check, \(0/1\) succeeded\. Scanned 1 target\(s\)\. Using refmap performant\.refmap\.json' + confidence: 0.9 + priority: 86 + cause: + key: diagnose.cause.conflict.performant_optifine + summary: 'Performant 与 OptiFine 的 mixin 冲突(LivingEntityMixin 注入失败)' + fix_action: + - kind: remove_mod + mod_ids: + - performant + - optifine + action: remove + note: '两者只能留一个' + sample: samples/mod_conflict/performant_optifine.log + source: 'HMCL CrashReportAnalyzer.Rule.PERFORMANT_FOREST_OPTIFINE' + + - id: conflict.twilight_optifine + severity: warning + pattern: 'java\.lang\.IllegalArgumentException: (.*) outside of image bounds (.*)' + confidence: 0.6 + priority: 70 + cause: + key: diagnose.cause.conflict.twilight_optifine + summary: '贴图越界(outside of image bounds):1.16 上 Twilight Forest 与 OptiFine 的经典冲突' + fix_action: + - kind: remove_mod + mod_ids: + - optifine + action: remove + note: '删 OptiFine 或 Twilight Forest 的兼容版本' + sample: samples/mod_conflict/twilight_optifine.log + source: 'HMCL CrashReportAnalyzer.Rule.TWILIGHT_FOREST_OPTIFINE' + + - id: conflict.jade_optifine + severity: critical + pattern: 'Critical injection failure: LVT in net/minecraft/client/renderer/GameRenderer::m_109093_\(FJZ\)V has incompatible changes at opcode 760 in callback jade\.mixins\.json:GameRendererMixin->@Inject::jade\$runTick\(FJZLorg/spongepowered/asm/mixin/injection/callback/CallbackInfo;IILcom/mojang/blaze3d/platform/Window;Lorg/joml/Matrix4f;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/gui/GuiGraphics;\)V\.' + confidence: 0.9 + priority: 86 + cause: + key: diagnose.cause.conflict.jade_optifine + summary: 'Jade 与 OptiFine 在 1.20+ 冲突(GameRendererMixin 注入失败)' + fix_action: + - kind: remove_mod + mod_ids: + - optifine + - jade + action: remove + note: '两者只能留一个' + sample: samples/mod_conflict/jade_optifine.log + source: 'HMCL CrashReportAnalyzer.Rule.JADE_FOREST_OPTIFINE' + + - id: conflict.neoforge_optifine + severity: critical + pattern: 'cpw\.mods\.modlauncher\.InvalidLauncherSetupException: Invalid Services found OptiFine' + confidence: 0.9 + priority: 86 + cause: + key: diagnose.cause.conflict.neoforge_optifine + summary: 'NeoForge 与 OptiFine 不兼容(launcher setup 阶段就拒绝)' + fix_action: + - kind: remove_mod + mod_ids: + - optifine + action: remove + sample: samples/mod_conflict/neoforge_optifine.log + source: 'HMCL CrashReportAnalyzer.Rule.NEOFORGE_FOREST_OPTIFINE' + + - id: conflict.optifine_world_load + severity: critical + # 移植修正:HMCL 原文写的是 ChunkManager$ProxyTicketManager,Java 正则里 $ 是行尾锚点, + # 那条规则实际上永远命中不了。这里转义成字面量 $,规则才能真正生效。 + pattern: 'java\.lang\.NoSuchMethodError: net\.minecraft\.world\.server\.ChunkManager\$ProxyTicketManager\.shouldForceTicks\(J\)Z' + confidence: 0.85 + priority: 84 + cause: + key: diagnose.cause.conflict.optifine_world_load + summary: 'OptiFine 导致世界无法加载(ChunkManager$ProxyTicketManager 方法被改写)' + fix_action: + - kind: remove_mod + mod_ids: + - optifine + action: remove + sample: samples/mod_conflict/optifine_world_load.log + source: 'HMCL CrashReportAnalyzer.Rule.OPTIFINE_CAUSES_THE_WORLD_TO_FAIL_TO_LOAD' diff --git a/crates/core/src/diagnose/rules.d/mod_runtime.yaml b/crates/core/src/diagnose/rules.d/mod_runtime.yaml new file mode 100644 index 0000000..04f4707 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/mod_runtime.yaml @@ -0,0 +1,136 @@ +# 规则库 · mod 运行期崩(8 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的运行期组(analyse/02 §7.3 第 7 组)。 +version: 1 +category: mod_runtime +rules: + - id: mod.loading_crashed_forge + severity: critical + pattern: 'LoaderExceptionModCrash: Caught exception from (?.*?) \((?.*)\)' + confidence: 0.9 + priority: 85 + cause: + key: diagnose.cause.mod.loading_crashed_forge + summary: 'Forge 抓到 {name}({id})在加载时抛异常' + detail: '禁用该 mod 或换它的兼容版本;同一条栈里往往还有具体异常类型' + fix_action: + - kind: remove_mod + mod_ids: + - '{id}' + action: disable + sample: samples/mod_runtime/loading_crashed_forge.log + source: 'HMCL CrashReportAnalyzer.Rule.LOADING_CRASHED_FORGE' + + - id: mod.loading_crashed_fabric + severity: critical + pattern: >- + Could not execute entrypoint stage '(.*?)' due to errors, provided by '(?.*)'! + confidence: 0.9 + priority: 85 + cause: + key: diagnose.cause.mod.loading_crashed_fabric + summary: 'Fabric 在入口点阶段执行 mod {id} 时出错' + fix_action: + - kind: remove_mod + mod_ids: + - '{id}' + action: disable + sample: samples/mod_runtime/loading_crashed_fabric.log + source: 'HMCL CrashReportAnalyzer.Rule.LOADING_CRASHED_FABRIC' + + - id: mod.config_failed + severity: warning + pattern: 'Failed loading config file (?.*?) of type (.*?) for modid (?.*)' + confidence: 0.7 + priority: 60 + cause: + key: diagnose.cause.mod.config_failed + summary: 'mod {id} 的配置文件 {file} 解析失败(多半是被手改坏或版本升级后格式变了)' + detail: '删掉该配置文件让 mod 重新生成默认值' + fix_action: + - kind: remove_mod + mod_ids: + - '{id}' + action: disable + note: '删配置文件 {file} 后重试(不一定要禁用 mod)' + sample: samples/mod_runtime/config_failed.log + source: 'HMCL CrashReportAnalyzer.Rule.CONFIG' + + - id: mod.mixin_failure + severity: critical + pattern: '(MixinApplyError|Mixin prepare failed |Mixin apply failed |mixin\.injection\.throwables\.|\.mixins\.json\] FAILED during \))' + confidence: 0.7 + priority: 65 + cause: + key: diagnose.cause.mod.mixin_failure + summary: 'Mixin 注入失败:某个 mod 的 mixin 与当前游戏/其他 mod 的代码不匹配' + detail: '常见于 mod 与游戏版本不配套;看日志里 mixin 的 target 类名能定位到 mod' + fix_action: + - kind: remove_mod + action: disable + note: '按 mixin 配置文件名(xxx.mixins.json)定位 mod' + sample: samples/mod_runtime/mixin_failure.log + source: 'HMCL CrashReportAnalyzer.Rule.MODMIXIN_FAILURE' + + - id: mod.mixin_apply_failed + severity: critical + pattern: 'Mixin apply for mod (?.*) failed' + confidence: 0.85 + priority: 82 + cause: + key: diagnose.cause.mod.mixin_apply_failed + summary: 'mod {id} 的 mixin 应用失败' + fix_action: + - kind: remove_mod + mod_ids: + - '{id}' + action: disable + sample: samples/mod_runtime/mixin_apply_failed.log + source: 'HMCL CrashReportAnalyzer.Rule.MIXIN_APPLY_MOD_FAILED' + + - id: mod.forge_error + severity: warning + scope: file + pattern: 'An exception was thrown, the game will display an error screen and halt\.(?:\r\n|\n|\r)*(?.*(?:\r\n|\n|\r)*(\s*at .*(?:\r\n|\n|\r))+)' + confidence: 0.6 + priority: 55 + cause: + key: diagnose.cause.mod.forge_error + summary: 'Forge 捕获到未处理异常并已弹出错误界面(Forge 自己给了栈)' + detail: '栈顶的 at 行指向哪个 mod 的包名,就去禁用/升级那个 mod' + fix_action: + - kind: seek_help + export_diagnostics: true + sample: samples/mod_runtime/forge_error.log + source: 'HMCL CrashReportAnalyzer.Rule.FORGE_ERROR' + + - id: mod.night_config_fixes + severity: warning + pattern: 'com\.electronwill\.nightconfig\.core\.io\.ParsingException: Not enough data available' + confidence: 0.85 + priority: 75 + cause: + key: diagnose.cause.mod.night_config_fixes + summary: 'night-config 配置文件读坏了(常见于 nightconfigfixes 相关 mod 组合)' + detail: '删掉对应 mod 的配置文件,或移除 nightconfigfixes' + fix_action: + - kind: remove_mod + mod_ids: + - nightconfigfixes + action: remove + sample: samples/mod_runtime/night_config_fixes.log + source: 'HMCL CrashReportAnalyzer.Rule.NIGHT_CONFIG_FIXES' + + - id: mod.install_mixinbootstrap + severity: critical + pattern: 'java\.lang\.ClassNotFoundException: org\.spongepowered\.asm\.launch\.MixinTweaker' + confidence: 0.8 + priority: 75 + cause: + key: diagnose.cause.mod.install_mixinbootstrap + summary: '缺少 MixinBootstrap 前置(Forge 1.12.2 上很多 mod 需要它)' + fix_action: + - kind: reinstall + component: mixin_bootstrap + note: '装上 MixinBootstrap 0.8+' + sample: samples/mod_runtime/install_mixinbootstrap.log + source: 'HMCL CrashReportAnalyzer.Rule.INSTALL_MIXINBOOTSTRAP' diff --git a/crates/core/src/diagnose/rules.d/other.yaml b/crates/core/src/diagnose/rules.d/other.yaml new file mode 100644 index 0000000..e7b61b1 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/other.yaml @@ -0,0 +1,22 @@ +# 规则库 · 其它(1 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的杂项组(analyse/02 §7.3 第 10 组)。 +# 将来「用户反馈沉淀的新规则」如果归不了类,也先落这里(标记 source 与需要复核)。 +version: 1 +category: other +rules: + - id: other.debug_crash + severity: info + pattern: 'Manually triggered debug crash' + confidence: 0.95 + priority: 70 + cause: + key: diagnose.cause.other.debug_crash + summary: '这次崩溃是手动触发的(游戏里按 F3+C),不是真的故障' + detail: '如果用户点了「导出诊断包」做测试,可以忽略这条' + fix_action: + - kind: seek_help + export_diagnostics: false + needs_more_info: + - '确认是否手动触发的调试崩溃' + sample: samples/other/debug_crash.log + source: 'HMCL CrashReportAnalyzer.Rule.DEBUG_CRASH' diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/loader_0_12.log b/crates/core/src/diagnose/rules.d/samples/fabric/loader_0_12.log new file mode 100644 index 0000000..0f5b91c --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/loader_0_12.log @@ -0,0 +1,2 @@ +[12:00:40] [main/ERROR]: Failed to start Fabric loader +java.lang.NoClassDefFoundError: org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution.log b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution.log new file mode 100644 index 0000000..7ecdc20 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution.log @@ -0,0 +1,3 @@ +net.fabricmc.loader.impl.FormattedException: net.fabricmc.loader.impl.discovery.ModResolutionException: Mod resolution encountered an incompatible mod set! + - Mod 'Sodium' (sodium) 0.5.8 requires version 0.6.0 or later of mod 'Iris' (iris) + - Mod 'Iris' (iris) 0.5.0 is present but is incompatible diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_collection.log b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_collection.log new file mode 100644 index 0000000..c57dca2 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_collection.log @@ -0,0 +1 @@ +net.fabricmc.loader.impl.discovery.ModResolutionException: Could not resolve valid mod collection (at: iris requires sodium) diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_conflict.log b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_conflict.log new file mode 100644 index 0000000..0ceb775 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_conflict.log @@ -0,0 +1 @@ +net.fabricmc.loader.impl.discovery.ModResolutionException: Found conflicting mods: sodium conflicts with sodium-extra diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_details.log b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_details.log new file mode 100644 index 0000000..03c4c77 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_details.log @@ -0,0 +1,3 @@ + Mod File: /mods/sodium-fabric-0.5.0.jar + Failure message: Mod resolution failed +net.fabricmc.loader.impl.discovery.ModResolutionException: see above diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_missing.log b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_missing.log new file mode 100644 index 0000000..94eedaa --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_missing.log @@ -0,0 +1 @@ +net.fabricmc.loader.impl.discovery.ModResolutionException: Could not find required mod: sodium requires iris diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_missing_minecraft.log b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_missing_minecraft.log new file mode 100644 index 0000000..b7d94aa --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/mod_resolution_missing_minecraft.log @@ -0,0 +1 @@ +net.fabricmc.loader.impl.discovery.ModResolutionException: Could not find required mod: sodium requires {minecraft @ 1.20.1} diff --git a/crates/core/src/diagnose/rules.d/samples/fabric/warnings.log b/crates/core/src/diagnose/rules.d/samples/fabric/warnings.log new file mode 100644 index 0000000..20dab22 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/fabric/warnings.log @@ -0,0 +1,3 @@ +Warnings were found! + - Sodium 0.4.10 is incompatible with Iris 1.6.0 +[ diff --git a/crates/core/src/diagnose/rules.d/samples/forge/bootstrap_failed.log b/crates/core/src/diagnose/rules.d/samples/forge/bootstrap_failed.log new file mode 100644 index 0000000..98dee26 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/bootstrap_failed.log @@ -0,0 +1 @@ +[12:00:55] [main/ERROR]: Failed to create mod instance. ModID: alexsmobs, Class: com.github.alexthe666.alexsmobs.AlexsMobs diff --git a/crates/core/src/diagnose/rules.d/samples/forge/duplicate_mods.log b/crates/core/src/diagnose/rules.d/samples/forge/duplicate_mods.log new file mode 100644 index 0000000..04a2863 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/duplicate_mods.log @@ -0,0 +1,3 @@ +Found duplicate mods: + jei-1.20.1-15.2.0.27.jar + jei-1.20.1-15.2.0.28.jar diff --git a/crates/core/src/diagnose/rules.d/samples/forge/duplicated_mod.log b/crates/core/src/diagnose/rules.d/samples/forge/duplicated_mod.log new file mode 100644 index 0000000..83c75f2 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/duplicated_mod.log @@ -0,0 +1 @@ +[12:00:50] [main/ERROR]: Found a duplicate mod jei at /mods/jei-1.20.1-15.2.0.28.jar diff --git a/crates/core/src/diagnose/rules.d/samples/forge/missing_dependencies.log b/crates/core/src/diagnose/rules.d/samples/forge/missing_dependencies.log new file mode 100644 index 0000000..c6b364d --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/missing_dependencies.log @@ -0,0 +1,2 @@ +Missing or unsupported mandatory dependencies: + Mod ID: citadel, Requested by: alexsmobs, Expected range: [1.20.1-2.4.0,), Actual version: MISSING diff --git a/crates/core/src/diagnose/rules.d/samples/forge/module_name_invalid.log b/crates/core/src/diagnose/rules.d/samples/forge/module_name_invalid.log new file mode 100644 index 0000000..f77b204 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/module_name_invalid.log @@ -0,0 +1 @@ +[12:00:54] [main/ERROR]: java.lang.IllegalArgumentException: Invalid module name: '' is not a Java identifier diff --git a/crates/core/src/diagnose/rules.d/samples/forge/optifine_repeat_installation.log b/crates/core/src/diagnose/rules.d/samples/forge/optifine_repeat_installation.log new file mode 100644 index 0000000..f8450c6 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/optifine_repeat_installation.log @@ -0,0 +1 @@ +[12:00:52] [main/ERROR]: org.spongepowered.asm.launch.platform.MixinPlatformManager threw ResolutionException: Module optifine reads another module named optifine diff --git a/crates/core/src/diagnose/rules.d/samples/forge/repeat_installation.log b/crates/core/src/diagnose/rules.d/samples/forge/repeat_installation.log new file mode 100644 index 0000000..203ec4b --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/repeat_installation.log @@ -0,0 +1 @@ +[12:00:51] [main/ERROR]: joptsimple.MultipleArgumentsForOptionException: Found multiple arguments for option net.minecraftforge, but you asked for only one diff --git a/crates/core/src/diagnose/rules.d/samples/forge/too_many_mods.log b/crates/core/src/diagnose/rules.d/samples/forge/too_many_mods.log new file mode 100644 index 0000000..73430ec --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/forge/too_many_mods.log @@ -0,0 +1 @@ +[12:00:53] [main/ERROR]: java.lang.RuntimeException: maximum id range exceeded diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/gl_operation_failure.log b/crates/core/src/diagnose/rules.d/samples/graphics/gl_operation_failure.log new file mode 100644 index 0000000..8e8d5ab --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/gl_operation_failure.log @@ -0,0 +1,3 @@ +[12:00:20] [Render thread/ERROR]: ########## GL ERROR ########## +[12:00:20] [Render thread/ERROR]: @ Pre render +[12:00:20] [Render thread/ERROR]: 1282: Invalid operation diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/graphics_driver.log b/crates/core/src/diagnose/rules.d/samples/graphics/graphics_driver.log new file mode 100644 index 0000000..7baa8ef --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/graphics_driver.log @@ -0,0 +1,7 @@ +# +# A fatal error has been detected by the Java Runtime Environment: +# +# EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffb1a2b3c4d, pid=12344, tid=0x0000000000002a1c +# +# Problematic frame: +# C [ig8d64.dll+0x9b3c4d] diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/macos_display_port.log b/crates/core/src/diagnose/rules.d/samples/graphics/macos_display_port.log new file mode 100644 index 0000000..e15d8ab --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/macos_display_port.log @@ -0,0 +1 @@ +[12:00:22] [main/ERROR]: java.lang.IllegalStateException: GLFW error before init: [0x10008]Cocoa: Failed to find service port for display diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/no_graphics_backend.log b/crates/core/src/diagnose/rules.d/samples/graphics/no_graphics_backend.log new file mode 100644 index 0000000..33dcf79 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/no_graphics_backend.log @@ -0,0 +1,15 @@ +---- Minecraft Crash Report ---- +// I let you down. Sorry :( + +Time: 2026-10-30 12:00:21 +Description: Initializing game + +net.minecraft.client.Minecraft$GameLoadException: No supported graphics backend was found. + at net.minecraft.client.Minecraft.(Minecraft.java:612) + at net.minecraft.client.main.Main.main(Main.java:184) + +-- Graphics Backend -- + Backend: VULKAN + Backend failure reason: vulkan_loader_missing + Backend: OPENGL + Backend failure reason: opengl_missing diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/opengl_not_supported.log b/crates/core/src/diagnose/rules.d/samples/graphics/opengl_not_supported.log new file mode 100644 index 0000000..e6d21b3 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/opengl_not_supported.log @@ -0,0 +1 @@ +[12:00:21] [main/ERROR]: The driver does not appear to support OpenGL diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/resolution_too_high.log b/crates/core/src/diagnose/rules.d/samples/graphics/resolution_too_high.log new file mode 100644 index 0000000..684495b --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/resolution_too_high.log @@ -0,0 +1 @@ +[12:00:23] [Render thread/WARN]: Maybe try a lower resolution resourcepack? diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/rtss_sodium.log b/crates/core/src/diagnose/rules.d/samples/graphics/rtss_sodium.log new file mode 100644 index 0000000..7528a6d --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/rtss_sodium.log @@ -0,0 +1 @@ +[12:00:24] [main/ERROR]: RivaTuner Statistics Server (RTSS) is not compatible with Sodium diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_device_version_too_low.log b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_device_version_too_low.log new file mode 100644 index 0000000..f4756a5 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_device_version_too_low.log @@ -0,0 +1,5 @@ +[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: Intel proprietary driver +[12:00:21] [Render thread/ERROR]: Backend failure reason: vulkan_device_version_too_low +[12:00:21] [Render thread/ERROR]: Backend failure message: Device NVIDIA GeForce GT 710 reports Vulkan 1.0.0, at least 1.2 required +[12:00:21] [Render thread/ERROR]: Failed to create backend VULKAN +[12:00:21] [Render thread/INFO]: Falling back to OpenGL diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_loader_missing.log b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_loader_missing.log new file mode 100644 index 0000000..3580d49 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_loader_missing.log @@ -0,0 +1,5 @@ +[12:00:21] [Render thread/INFO]: Backend library: LWJGL version 3.4.3+13 +[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: AMD proprietary driver +[12:00:21] [Render thread/ERROR]: Vulkan loader library is missing +[12:00:21] [Render thread/ERROR]: Failed to create backend VULKAN: Backend failure reason: vulkan_loader_missing +[12:00:21] [Render thread/INFO]: Falling back to OpenGL diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_missing_extension.log b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_missing_extension.log new file mode 100644 index 0000000..aabed4e --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_missing_extension.log @@ -0,0 +1,5 @@ +[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: Mesa (zink) +[12:00:21] [Render thread/ERROR]: Backend failure reason: vulkan_missing_extension +[12:00:21] [Render thread/ERROR]: Device [AMD Radeon RX 580] does not support required extensions from FeatureSet [VULKAN_12], missing: [VK_KHR_dynamic_rendering, VK_KHR_push_descriptor] +[12:00:21] [Render thread/ERROR]: Failed to create backend VULKAN +[12:00:21] [Render thread/INFO]: Falling back to OpenGL diff --git a/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_missing_feature.log b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_missing_feature.log new file mode 100644 index 0000000..be3a375 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/graphics/vulkan_missing_feature.log @@ -0,0 +1,5 @@ +[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: llvmpipe (Mesa) +[12:00:21] [Render thread/ERROR]: Backend failure reason: vulkan_missing_feature +[12:00:21] [Render thread/ERROR]: Backend failure missing capabilities: VULKAN_CORE_1_2 +[12:00:21] [Render thread/ERROR]: Failed to create backend VULKAN +[12:00:21] [Render thread/INFO]: Falling back to OpenGL diff --git a/crates/core/src/diagnose/rules.d/samples/hs_err/access_violation.log b/crates/core/src/diagnose/rules.d/samples/hs_err/access_violation.log new file mode 100644 index 0000000..072f1a2 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/hs_err/access_violation.log @@ -0,0 +1,34 @@ +# +# A fatal error has been detected by the Java Runtime Environment: +# +# EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007ffb1a2b3c4d, pid=12344, tid=0x0000000000002a1c +# +# JRE version: Java(TM) SE Runtime Environment (8.0_51-b16) (build 1.8.0_51-b16) +# Java VM: Java HotSpot(TM) 64-Bit Server VM (25.51-b03 mixed mode windows-amd64 compressed oops) +# Problematic frame: +# C [ig8d64.dll+0x9b3c4d] +# +# Failed to write core dump. Minidumps are not enabled by default on client versions of Windows +# +# If you would like to submit a bug report, please visit: +# http://bugreport.java.com/bugreport/crash.jsp +# The crash happened outside the Java Virtual Machine in native code. +# See problematic frame for where to report the bug. +# + +--------------- T H R E A D --------------- + +Current thread (0x000000001f3b8000): JavaThread "Render thread" [_thread_in_native, id=10780, stack(0x000000001a000000,0x000000001a100000)] + +siginfo: ExceptionCode=0xc0000005, ExceptionInformation=0x0000000000000000 0x0000000000000000 0x0000000000000000 + +VM Arguments: +jvm_args: -Xmx4096m -Xms1024m -Djava.library.path=C:\Users\player\.minecraft\natives +java_command: net.minecraft.launchwrapper.Launch --gameDir . --assetsDir assets + +--------------- H E A P --------------- + +Heap: + garbage-first heap total 4194304K, used 1048576K [0x0000000700000000, 0x0000000800000000) + +Memory: 4k page, physical 16741480k(4716856k free), swap 18672384k(9433608k free) diff --git a/crates/core/src/diagnose/rules.d/samples/hs_err/out_of_memory.log b/crates/core/src/diagnose/rules.d/samples/hs_err/out_of_memory.log new file mode 100644 index 0000000..28e5158 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/hs_err/out_of_memory.log @@ -0,0 +1,19 @@ +# +# There is insufficient memory for the Java Runtime Environment to continue. +# Native memory allocation (mmap) failed to map 1073741824 bytes for committing reserved memory. +# +# Out of Memory Error (os_windows.cpp:3521), pid=9876, tid=0x0000000000001f44 +# +# JRE version: Java(TM) SE Runtime Environment (17.0.9+9) (build 17.0.9+9-LTS-211) +# Java VM: Java HotSpot(TM) 64-Bit Server VM (17.0.9+9-LTS-211, mixed mode, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, windows-amd64) +# + +--------------- T H R E A D --------------- + +Current thread (0x0000021d6f82b1a0): JavaThread "Render thread" [_thread_in_vm, id=14120, stack(0x000000a1b0000000,0x000000a1b0100000)] + +VM Arguments: +jvm_args: -Xmx8192m -Xms2048m +java_command: net.fabricmc.loader.impl.launch.knot.KnotClient --gameDir . + +Memory: 4k page, physical 8134564k(120944k free), swap 9723904k(550664k free) diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/file_already_exists.log b/crates/core/src/diagnose/rules.d/samples/integrity/file_already_exists.log new file mode 100644 index 0000000..6538598 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/file_already_exists.log @@ -0,0 +1 @@ +[12:00:34] [main/ERROR]: java.nio.file.FileAlreadyExistsException: config/example-mod.toml diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/file_changed.log b/crates/core/src/diagnose/rules.d/samples/integrity/file_changed.log new file mode 100644 index 0000000..c61c24e --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/file_changed.log @@ -0,0 +1,2 @@ +[12:00:30] [main/ERROR]: Failed to verify class file +java.lang.SecurityException: SHA1 digest error for net/minecraft/client/main/Main.class diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/illegal_access.log b/crates/core/src/diagnose/rules.d/samples/integrity/illegal_access.log new file mode 100644 index 0000000..ae7a12c --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/illegal_access.log @@ -0,0 +1,2 @@ +[12:00:33] [main/ERROR]: coremod failed +java.lang.IllegalAccessError: tried to access class net.minecraft.client.Minecraft from class com.example.coremod.Hook diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/incomplete_forge.log b/crates/core/src/diagnose/rules.d/samples/integrity/incomplete_forge.log new file mode 100644 index 0000000..6471fca --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/incomplete_forge.log @@ -0,0 +1 @@ +[12:00:36] [main/ERROR]: java.io.UncheckedIOException: java.io.IOException: Invalid paths argument, contained no existing paths: [/home/user/.minecraft/libraries/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-client.jar] diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/mod_files_decompressed.log b/crates/core/src/diagnose/rules.d/samples/integrity/mod_files_decompressed.log new file mode 100644 index 0000000..0456caf --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/mod_files_decompressed.log @@ -0,0 +1,2 @@ +[12:00:35] [main/ERROR]: Extracted mod jars found, loading will NOT continue +[12:00:35] [main/ERROR]: The directories below appear to be extracted jar files. Fix this before you continue diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/no_class_def_found.log b/crates/core/src/diagnose/rules.d/samples/integrity/no_class_def_found.log new file mode 100644 index 0000000..e9b4360 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/no_class_def_found.log @@ -0,0 +1,2 @@ +[12:00:32] [main/ERROR]: A mod crashed on startup +java.lang.NoClassDefFoundError: net/minecraftforge/fml/loading/FMLEnvironment diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/no_such_method.log b/crates/core/src/diagnose/rules.d/samples/integrity/no_such_method.log new file mode 100644 index 0000000..cbafdd0 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/no_such_method.log @@ -0,0 +1,3 @@ +[12:00:31] [main/ERROR]: Failed to start +java.lang.NoSuchMethodError: net.minecraft.world.World.getBlockState(Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/block/state/BlockState; + at com.example.mod.Foo.bar(Foo.java:42) diff --git a/crates/core/src/diagnose/rules.d/samples/integrity/unsatisfied_link.log b/crates/core/src/diagnose/rules.d/samples/integrity/unsatisfied_link.log new file mode 100644 index 0000000..3504911 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/integrity/unsatisfied_link.log @@ -0,0 +1,2 @@ +[12:00:37] [main/ERROR]: Failed to initialise LWJGL +java.lang.UnsatisfiedLinkError: Failed to locate library: lwjgl.dll diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/java_version_is_too_high.log b/crates/core/src/diagnose/rules.d/samples/jvm/java_version_is_too_high.log new file mode 100644 index 0000000..7f53dd0 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/java_version_is_too_high.log @@ -0,0 +1,3 @@ +[12:00:06] [main/ERROR]: Unable to launch +java.lang.NoSuchFieldException: ucp + at java.base/java.lang.ClassLoader$UnsafeAccessor.getUnsafe(ClassLoader.java:123) diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/jdk_9.log b/crates/core/src/diagnose/rules.d/samples/jvm/jdk_9.log new file mode 100644 index 0000000..b4670b3 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/jdk_9.log @@ -0,0 +1,2 @@ +[12:00:05] [main/ERROR]: Failed to launch +java.lang.ClassCastException: java.base/jdk.internal.loader.ClassLoaders$AppClassLoader cannot be cast to java.net.URLClassLoader diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/jvm_32bit.log b/crates/core/src/diagnose/rules.d/samples/jvm/jvm_32bit.log new file mode 100644 index 0000000..37f7dcc --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/jvm_32bit.log @@ -0,0 +1,3 @@ +[12:00:04] [main/INFO]: Starting JVM with -Xmx4096m +Error occurred during initialization of VM +Could not reserve enough space for 4194304KB object heap diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/mac_jdk8u261.log b/crates/core/src/diagnose/rules.d/samples/jvm/mac_jdk8u261.log new file mode 100644 index 0000000..9fe8637 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/mac_jdk8u261.log @@ -0,0 +1 @@ +Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'NSWindow drag regions should only be invalidated on the Main Thread!' diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/modlauncher_8.log b/crates/core/src/diagnose/rules.d/samples/jvm/modlauncher_8.log new file mode 100644 index 0000000..07308da --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/modlauncher_8.log @@ -0,0 +1,3 @@ +[12:00:07] [main/ERROR]: ModLauncher failed +java.lang.NoSuchMethodError: 'void sun.security.util.ManifestEntryVerifier.(java.util.jar.Manifest)' + at cpw.mods.modlauncher.TransformingClassLoader.buildTransformedClassNodeFor(TransformingClassLoader.java:151) diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/need_jdk11.log b/crates/core/src/diagnose/rules.d/samples/jvm/need_jdk11.log new file mode 100644 index 0000000..0fc8526 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/need_jdk11.log @@ -0,0 +1,2 @@ +[12:00:02] [main/ERROR]: Failed to start the game +java.lang.IllegalArgumentException: The requested compatibility level JAVA_11 could not be set. Level is not supported by the active JRE or ASM version diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/openj9.log b/crates/core/src/diagnose/rules.d/samples/jvm/openj9.log new file mode 100644 index 0000000..346f9fb --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/openj9.log @@ -0,0 +1,2 @@ +[12:00:01] [main/INFO]: Loading Minecraft 1.20.1 with Forge 47.2.0 +OpenJ9 is incompatible with the authentication libraries used by this launcher diff --git a/crates/core/src/diagnose/rules.d/samples/jvm/too_old_java.log b/crates/core/src/diagnose/rules.d/samples/jvm/too_old_java.log new file mode 100644 index 0000000..0546e67 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/jvm/too_old_java.log @@ -0,0 +1,2 @@ +[12:00:03] [main/ERROR]: Could not find or load main class net.fabricmc.loader.impl.launch.knot.KnotClient +java.lang.UnsupportedClassVersionError: net/fabricmc/loader/impl/launch/knot/KnotClient has been compiled by a more recent version of the Java Runtime (class file version 61.0), this version of the Java Runtime only recognizes class file versions up to 52.0 diff --git a/crates/core/src/diagnose/rules.d/samples/memory/memory_exceeded.log b/crates/core/src/diagnose/rules.d/samples/memory/memory_exceeded.log new file mode 100644 index 0000000..83afa47 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/memory/memory_exceeded.log @@ -0,0 +1,3 @@ +# +# There is insufficient memory for the Java Runtime Environment to continue. +# Native memory allocation (mmap) failed to map 67108864 bytes for committing reserved memory. diff --git a/crates/core/src/diagnose/rules.d/samples/memory/out_of_memory.log b/crates/core/src/diagnose/rules.d/samples/memory/out_of_memory.log new file mode 100644 index 0000000..10c2b86 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/memory/out_of_memory.log @@ -0,0 +1,3 @@ +[12:00:10] [Render thread/ERROR]: Encountered an unexpected exception +java.lang.OutOfMemoryError: Java heap space + at net.minecraft.client.renderer.chunk.RenderChunk.rebuildChunk(RenderChunk.java:123) diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/jade_optifine.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/jade_optifine.log new file mode 100644 index 0000000..f6d34cc --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/jade_optifine.log @@ -0,0 +1 @@ +[12:01:15] [main/ERROR]: Critical injection failure: LVT in net/minecraft/client/renderer/GameRenderer::m_109093_(FJZ)V has incompatible changes at opcode 760 in callback jade.mixins.json:GameRendererMixin->@Inject::jade$runTick(FJZLorg/spongepowered/asm/mixin/injection/callback/CallbackInfo;IILcom/mojang/blaze3d/platform/Window;Lorg/joml/Matrix4f;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/gui/GuiGraphics;)V. diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/multipart_optifine.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/multipart_optifine.log new file mode 100644 index 0000000..54d6c01 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/multipart_optifine.log @@ -0,0 +1 @@ +[12:01:12] [main/ERROR]: Error occurred applying transform of coremod META-INF/asm/multipart.js function render diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/neoforge_optifine.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/neoforge_optifine.log new file mode 100644 index 0000000..34a268c --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/neoforge_optifine.log @@ -0,0 +1 @@ +[12:01:16] [main/ERROR]: cpw.mods.modlauncher.InvalidLauncherSetupException: Invalid Services found OptiFine diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/optifine_forge.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/optifine_forge.log new file mode 100644 index 0000000..f271bd8 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/optifine_forge.log @@ -0,0 +1,4 @@ +[12:01:10] [main/ERROR]: Failed to launch Forge +java.lang.NoSuchMethodError: 'java.lang.String com.mojang.blaze3d.systems.RenderSystem.getBackendDescription()' + at net.optifine.reflect.Reflector.(Reflector.java:455) + TRANSFORMER/net.optifine/net.optifine.reflect.Reflector.(Reflector.java:455) diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/optifine_world_load.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/optifine_world_load.log new file mode 100644 index 0000000..3bdf673 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/optifine_world_load.log @@ -0,0 +1 @@ +[12:01:17] [main/ERROR]: java.lang.NoSuchMethodError: net.minecraft.world.server.ChunkManager$ProxyTicketManager.shouldForceTicks(J)Z diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/performant_optifine.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/performant_optifine.log new file mode 100644 index 0000000..43cbeb0 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/performant_optifine.log @@ -0,0 +1 @@ +[12:01:13] [main/ERROR]: org.spongepowered.asm.mixin.injection.throwables.InjectionError: Critical injection failure: Redirector OnisOnLadder(Lnet/minecraft/block/BlockState;Lnet/minecraft/world/World;Lnet/minecraft/util/math/BlockPos;Lnet/minecraft/entity/LivingEntity;)Z in performant.mixins.json:entity.LivingEntityMixin failed injection check, (0/1) succeeded. Scanned 1 target(s). Using refmap performant.refmap.json diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/shaders_mod.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/shaders_mod.log new file mode 100644 index 0000000..0af6950 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/shaders_mod.log @@ -0,0 +1 @@ +[12:01:11] [main/ERROR]: java.lang.RuntimeException: Shaders Mod detected. Please remove it, OptiFine has built-in support for shaders. diff --git a/crates/core/src/diagnose/rules.d/samples/mod_conflict/twilight_optifine.log b/crates/core/src/diagnose/rules.d/samples/mod_conflict/twilight_optifine.log new file mode 100644 index 0000000..e58781c --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_conflict/twilight_optifine.log @@ -0,0 +1 @@ +[12:01:14] [main/ERROR]: java.lang.IllegalArgumentException: 512 outside of image bounds 256 diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/config_failed.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/config_failed.log new file mode 100644 index 0000000..b77ae0b --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/config_failed.log @@ -0,0 +1 @@ +[12:01:02] [main/ERROR]: Failed loading config file alexsmobs-common.toml of type COMMON for modid alexsmobs diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/forge_error.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/forge_error.log new file mode 100644 index 0000000..9a53a92 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/forge_error.log @@ -0,0 +1,3 @@ +[12:01:05] [main/ERROR]: An exception was thrown, the game will display an error screen and halt. +java.lang.NullPointerException: Cannot invoke "net.minecraft.world.level.Level.getBlockState()" because "level" is null + at com.example.mod.Foo.tick(Foo.java:123) diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/install_mixinbootstrap.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/install_mixinbootstrap.log new file mode 100644 index 0000000..e810221 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/install_mixinbootstrap.log @@ -0,0 +1 @@ +[12:01:07] [main/ERROR]: java.lang.ClassNotFoundException: org.spongepowered.asm.launch.MixinTweaker diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/loading_crashed_fabric.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/loading_crashed_fabric.log new file mode 100644 index 0000000..70bb109 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/loading_crashed_fabric.log @@ -0,0 +1 @@ +[12:01:01] [main/ERROR]: Could not execute entrypoint stage 'main' due to errors, provided by 'sodium'! diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/loading_crashed_forge.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/loading_crashed_forge.log new file mode 100644 index 0000000..786ce0c --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/loading_crashed_forge.log @@ -0,0 +1 @@ +[12:01:00] [main/ERROR]: LoaderExceptionModCrash: Caught exception from Alex's Mobs (alexsmobs) diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/mixin_apply_failed.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/mixin_apply_failed.log new file mode 100644 index 0000000..60d2499 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/mixin_apply_failed.log @@ -0,0 +1 @@ +[12:01:04] [main/ERROR]: Mixin apply for mod examplemod failed examplemod.mixins.json:MixinFoo from mod examplemod->@Inject::handler diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/mixin_failure.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/mixin_failure.log new file mode 100644 index 0000000..40c6276 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/mixin_failure.log @@ -0,0 +1 @@ +[12:01:03] [main/ERROR]: org.spongepowered.asm.mixin.injection.throwables.InvalidInjectionException: Critical injection failure: @Inject annotation could not find target method diff --git a/crates/core/src/diagnose/rules.d/samples/mod_runtime/night_config_fixes.log b/crates/core/src/diagnose/rules.d/samples/mod_runtime/night_config_fixes.log new file mode 100644 index 0000000..1a4cbe5 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/mod_runtime/night_config_fixes.log @@ -0,0 +1 @@ +[12:01:06] [main/ERROR]: com.electronwill.nightconfig.core.io.ParsingException: Not enough data available diff --git a/crates/core/src/diagnose/rules.d/samples/other/debug_crash.log b/crates/core/src/diagnose/rules.d/samples/other/debug_crash.log new file mode 100644 index 0000000..eef42ff --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/other/debug_crash.log @@ -0,0 +1 @@ +[12:01:30] [Render thread/INFO]: Manually triggered debug crash diff --git a/crates/core/src/diagnose/rules.d/samples/world/block_model.log b/crates/core/src/diagnose/rules.d/samples/world/block_model.log new file mode 100644 index 0000000..627f520 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/world/block_model.log @@ -0,0 +1,6 @@ +[12:01:21] [Worker-Main-1/ERROR]: Unable to bake model +java.lang.RuntimeException: Failed to tessellate +-- Block being tessellated -- +Details: + Block: minecraft:grass_block + Block location: World: (12,64,-34) diff --git a/crates/core/src/diagnose/rules.d/samples/world/entity_tick.log b/crates/core/src/diagnose/rules.d/samples/world/entity_tick.log new file mode 100644 index 0000000..f0bfd21 --- /dev/null +++ b/crates/core/src/diagnose/rules.d/samples/world/entity_tick.log @@ -0,0 +1,6 @@ +[12:01:20] [Server thread/ERROR]: Encountered an unexpected exception +java.lang.NullPointerException: Ticking entity +-- Entity being ticked -- +Details: + Entity Type: minecraft:cow (net.minecraft.world.entity.animal.Cow) + Entity's Exact location: 123.45, 64.00, -67.89 diff --git a/crates/core/src/diagnose/rules.d/world.yaml b/crates/core/src/diagnose/rules.d/world.yaml new file mode 100644 index 0000000..7c197eb --- /dev/null +++ b/crates/core/src/diagnose/rules.d/world.yaml @@ -0,0 +1,39 @@ +# 规则库 · 游戏实体/方块(2 条) +# 移植自 HMCL CrashReportAnalyzer.Rule 的实体/方块组(analyse/02 §7.3 第 9 组): +# 崩在 tick 时能拿到实体类型/方块类型与坐标,是「哪个 mod 的实体坏了」的直接线索。 +version: 1 +category: world +rules: + - id: world.entity_tick + severity: critical + scope: file + pattern: "Entity Type: (?.*)[\\w\\W\\n\\r]*?Entity's Exact location: (?.*)" + confidence: 0.75 + priority: 70 + cause: + key: diagnose.cause.world.entity_tick + summary: '崩在实体 tick:实体 {type}(坐标 {location})' + detail: '看看这个实体来自哪个 mod(血条/怪物增强/刷怪类 mod 居多),禁用该 mod 或删掉存档里这个实体' + fix_action: + - kind: remove_mod + action: disable + note: '按实体类型定位 mod' + sample: samples/world/entity_tick.log + source: 'HMCL CrashReportAnalyzer.Rule.ENTITY' + + - id: world.block_model + severity: warning + scope: file + pattern: 'Block: (?.*)[\w\W\n\r]*?Block location: (?.*)' + confidence: 0.65 + priority: 65 + cause: + key: diagnose.cause.world.block_model + summary: '崩在方块({type},坐标 {location}):多半是方块模型/贴图相关 mod 的问题' + detail: '定位该方块所属 mod;资源包也可能导致(先换回原版资源包验证)' + fix_action: + - kind: remove_mod + action: disable + note: '按方块类型定位 mod / 先换回原版资源包' + sample: samples/world/block_model.log + source: 'HMCL CrashReportAnalyzer.Rule.BLOCK' diff --git a/crates/core/src/diagnose/rules.rs b/crates/core/src/diagnose/rules.rs new file mode 100644 index 0000000..c61e852 --- /dev/null +++ b/crates/core/src/diagnose/rules.rs @@ -0,0 +1,319 @@ +//! 诊断规则模型:YAML schema + 编译后的规则。 +//! +//! 设计定稿:`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §2.2–§2.3; +//! 规则来源:HMCL `CrashReportAnalyzer.Rule` 59 条(`DevDocs/analyse/02-launch-pipeline.md` §7.3)。 +//! 字段说明与给 M16/M18 的契约见同目录 `CONTRACT.md`。 +//! +//! 相对 HMCL「枚举即签名库」的四处升级(analyse/02 §7.10): +//! 1. 规则是**数据**不是代码:加规则只改 `rules.d/*.yaml`,不重编译; +//! 2. 每条规则带 `confidence`/`priority`/`suppress`,可做主因排序(HMCL 只能平铺); +//! 3. 命名捕获组由正则自声明,不再有 `groupNames` 顺序契约; +//! 4. 每条规则绑定六维修复动作,直接成为 agent 工具的参数来源。 + +use std::collections::BTreeMap; +use std::fmt; + +use regex::Regex; +use serde::Deserialize; + +// 六维修复动作按体量拆两个文件(本目录文件上限 400 行),从本模块统一导出公开面。 +#[path = "fix_action.rs"] +mod fix_action; +#[path = "fix_params.rs"] +mod fix_params; + +pub use fix_action::{class_file_to_java_major, FixAction, FixActionKind, MaterializedFixAction}; +pub use fix_params::{ + AdjustMemory, ChangeGraphics, ChangeJava, ComponentKind, GraphicsSetting, JavaVendor, + ModAction, Reinstall, RemoveMod, SeekHelp, +}; + +/// 规则分类:HMCL 59 条规则的 10 个根因分组,与 `rules.d/.yaml` 一一对应。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuleCategory { + /// JVM 实现 / Java 版本不兼容。 + Jvm, + /// 堆内存不足、系统内存耗尽。 + Memory, + /// 显卡驱动 / OpenGL / 分辨率 / 图形覆盖层。 + Graphics, + /// 文件被改、安装不完整、原生库缺失。 + Integrity, + /// Fabric 的 ModResolutionException 家族。 + Fabric, + /// Forge 的依赖解析 / 重复安装 / mod 元数据。 + Forge, + /// mod 加载期或运行期崩溃(含 mixin)。 + ModRuntime, + /// 具体 mod 与 OptiFine 等成名冲突。 + ModConflict, + /// 崩在某个实体 / 方块的 tick。 + World, + /// 其它(手动触发调试崩溃等)。 + Other, +} + +impl RuleCategory { + /// 稳定字符串(也用于诊断输出的 `category` 字段)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Jvm => "jvm", + Self::Memory => "memory", + Self::Graphics => "graphics", + Self::Integrity => "integrity", + Self::Fabric => "fabric", + Self::Forge => "forge", + Self::ModRuntime => "mod_runtime", + Self::ModConflict => "mod_conflict", + Self::World => "world", + Self::Other => "other", + } + } + + /// 全部分类(loader 校验规则文件归属、测试遍历用)。 + #[must_use] + pub const fn all() -> [Self; 10] { + [ + Self::Jvm, + Self::Memory, + Self::Graphics, + Self::Integrity, + Self::Fabric, + Self::Forge, + Self::ModRuntime, + Self::ModConflict, + Self::World, + Self::Other, + ] + } +} + +impl fmt::Display for RuleCategory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// 结论严重度(UI 配色与「是否弹窗」的依据)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Severity { + /// 提示性:能跑但不最佳。 + Info, + /// 警告:可疑但结论不确定。 + #[default] + Warning, + /// 严重:基本可以确定崩溃原因。 + Critical, +} + +impl Severity { + /// 稳定字符串(诊断输出用)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Info => "info", + Self::Warning => "warning", + Self::Critical => "critical", + } + } +} + +impl fmt::Display for Severity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// 匹配范围:决定引擎怎么切文本,也是性能开关(analyse/02 §8.3 第 1 条)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MatchScope { + /// 逐行匹配(默认,绝大多数 Java 异常都是单行签名)。 + #[default] + Line, + /// 按空行切块匹配(一段异常 + 上下文)。 + Block, + /// 整份文本匹配(仅跨行贪婪模式:mod 解析列表、实体 tick 上下文等)。 + File, +} + +/// 结论文案(`summary` 支持 `{group}` 占位符,`key` 是稳定的 i18n/AI 引用 id)。 +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Cause { + /// 稳定 id,如 `diagnose.cause.jvm.too_old_java`。 + pub key: String, + /// 一句话结论(中文,可直接给用户看)。 + pub summary: String, + /// 更细的解释 / 处理建议。 + #[serde(default)] + pub detail: Option, +} + +impl Cause { + /// 代入捕获组后的(结论, 细化说明)。 + #[must_use] + pub fn render(&self, groups: &BTreeMap) -> (String, Option) { + ( + substitute(&self.summary, groups), + self.detail + .as_ref() + .map(|detail| substitute(detail, groups)), + ) + } +} + +/// 一条规则的 YAML schema。 +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Rule { + /// 全局唯一 id(`命名空间.短名`)。 + pub id: String, + /// 严重度。 + #[serde(default)] + pub severity: Severity, + /// 正则(Rust `regex` 语法,命名组用 `(?…)`)。 + pub pattern: String, + /// 匹配范围。 + #[serde(default)] + pub scope: MatchScope, + /// 命中可信度 0.0~1.0。 + #[serde(default = "default_confidence")] + pub confidence: f32, + /// 主因排序权重 0~100。 + #[serde(default = "default_priority")] + pub priority: u8, + /// 命中本规则时把列出的规则降级为次生结论。 + #[serde(default)] + pub suppress: Vec, + /// 结论文案。 + pub cause: Cause, + /// 六维修复动作。 + #[serde(default)] + pub fix_action: Vec, + /// 回归样本路径(相对 `rules.d/`)。 + #[serde(default)] + pub sample: Option, + /// 出处(HMCL 枚举名 / issue 链接)。 + #[serde(default)] + pub source: Option, +} + +fn default_confidence() -> f32 { + 0.5 +} + +fn default_priority() -> u8 { + 50 +} + +/// 编译后的规则(正则只编译一次)。 +#[derive(Debug, Clone)] +pub struct CompiledRule { + rule: Rule, + category: RuleCategory, + regex: Regex, +} + +impl CompiledRule { + /// 编译一条规则。 + pub fn compile(rule: Rule, category: RuleCategory) -> Result { + let regex = Regex::new(&rule.pattern).map_err(|err| RuleError::InvalidPattern { + id: rule.id.clone(), + message: err.to_string(), + })?; + Ok(Self { + rule, + category, + regex, + }) + } + + /// 原始规则。 + #[must_use] + pub const fn rule(&self) -> &Rule { + &self.rule + } + + /// 规则 id。 + #[must_use] + pub fn id(&self) -> &str { + &self.rule.id + } + + /// 所属分类。 + #[must_use] + pub const fn category(&self) -> RuleCategory { + self.category + } + + /// 匹配范围。 + #[must_use] + pub const fn scope(&self) -> MatchScope { + self.rule.scope + } + + /// 编译好的正则。 + #[must_use] + pub const fn regex(&self) -> &Regex { + &self.regex + } + + /// 命名捕获组名(文档/测试用;顺序无关,取代 HMCL 的 `groupNames` 契约)。 + #[must_use] + pub fn named_groups(&self) -> Vec<&str> { + self.regex.capture_names().flatten().collect() + } +} + +/// 规则编译错误。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuleError { + /// 正则非法(规则 id + 正则库报错原文)。 + InvalidPattern { + /// 出错的规则 id。 + id: String, + /// 正则库报错。 + message: String, + }, +} + +impl fmt::Display for RuleError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPattern { id, message } => { + write!(f, "规则 {id} 的正则非法:{message}") + } + } + } +} + +impl std::error::Error for RuleError {} + +/// 把 `{group}` 占位符替换成捕获组值;未知占位符原样保留(便于发现规则写错)。 +#[must_use] +pub fn substitute(template: &str, groups: &BTreeMap) -> String { + if !template.contains('{') { + return template.to_owned(); + } + let mut out = String::with_capacity(template.len()); + let mut rest = template; + while let Some(start) = rest.find('{') { + let Some(close) = rest[start..].find('}') else { + break; + }; + let end = start + close; + let name = &rest[start + 1..end]; + out.push_str(&rest[..start]); + match groups.get(name) { + Some(value) => out.push_str(value), + None => out.push_str(&rest[start..=end]), + } + rest = &rest[end + 1..]; + } + out.push_str(rest); + out +} diff --git a/crates/core/src/diagnose/signals.rs b/crates/core/src/diagnose/signals.rs new file mode 100644 index 0000000..73a34d7 --- /dev/null +++ b/crates/core/src/diagnose/signals.rs @@ -0,0 +1,396 @@ +//! 采集到的原子信号:日志环形缓冲 + 退出信号。 +//! +//! 五类信号里的「退出码」和「日志环形缓冲」两种(analyse/02 §7.1 信号 A/B)。 +//! 环境快照(信号 E)与 mod 清单在 [`crate::diagnose::collectors`] 里,因为它带采集动作。 +//! +//! 环形缓冲相对 HMCL `Log.java`(固定 2000 行、一律淘汰)的升级: +//! 命中 `Exception|Error|Caused by|OutOfMemory` 的行**永久保留**, +//! 解决「崩在启动早期、根因被后续输出挤出缓冲区」(analyse/02 §7.10-3)。 + +use std::collections::VecDeque; +use std::fmt; + +/// 一条日志文本的来源(诊断输出的 `evidence[]` 前缀也是它)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SignalOrigin { + /// 游戏进程 stdout。 + Stdout, + /// 游戏进程 stderr。 + Stderr, + /// `logs/latest.log`(内存缓冲不够长时的兜底)。 + LatestLog, + /// `crash-reports/crash-*.txt`。 + CrashReport, + /// `hs_err_pid*.log` 的摘要文本。 + HsErr, + /// 启动器自身日志 / 结构化事件(`cmd_built`、`assembly_failed` 等)。 + Launcher, +} + +impl SignalOrigin { + /// 稳定字符串(evidence 前缀 / 序列化用)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Stdout => "stdout", + Self::Stderr => "stderr", + Self::LatestLog => "latest_log", + Self::CrashReport => "crash_report", + Self::HsErr => "hs_err", + Self::Launcher => "launcher", + } + } + + /// `launch` 侧 `LineSink` 的 `stream` 字段映射(`stdout` / `stderr`)。 + #[must_use] + pub fn from_stream_name(stream: &str) -> Self { + match stream.trim().to_ascii_lowercase().as_str() { + "stderr" => Self::Stderr, + _ => Self::Stdout, + } + } + + /// 取证优先级:同一规则在多个来源命中时保留 rank 最小者(崩溃报告 > hs_err > 日志)。 + #[must_use] + pub const fn evidence_rank(self) -> u8 { + match self { + Self::CrashReport => 0, + Self::HsErr => 1, + Self::Launcher => 2, + Self::LatestLog => 3, + Self::Stdout => 4, + Self::Stderr => 5, + } + } +} + +impl fmt::Display for SignalOrigin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// 环形缓冲里的一行。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogLine { + /// 来源。 + pub origin: SignalOrigin, + /// 行文本(不含行尾换行)。 + pub text: String, +} + +impl LogLine { + /// 构造一行。 + #[must_use] + pub fn new(origin: SignalOrigin, text: impl Into) -> Self { + Self { + origin, + text: text.into(), + } + } +} + +/// 是否「关键行」:不随环形淘汰(HMCL 局限 3 的正面修正)。 +#[must_use] +pub fn is_key_line(text: &str) -> bool { + const MARKERS: [&str; 4] = ["Exception", "Error", "Caused by", "OutOfMemory"]; + MARKERS.iter().any(|marker| text.contains(marker)) +} + +/// 日志环形缓冲:最近 N 行 + 被判为关键行的历史永久保留。 +#[derive(Debug, Clone)] +pub struct LogBuffer { + capacity: usize, + key_capacity: usize, + recent: VecDeque, + /// 已从环形区淘汰、但因为是关键行而留下来的行(按时间顺序)。 + kept: VecDeque, + pushed: usize, + dropped: usize, +} + +impl Default for LogBuffer { + fn default() -> Self { + Self::new(2000) + } +} + +impl LogBuffer { + /// 默认容量 2000 行(与 HMCL 的 `Log` 一致)。 + #[must_use] + pub fn new(capacity: usize) -> Self { + Self { + capacity: capacity.max(1), + key_capacity: capacity.max(1), + recent: VecDeque::new(), + kept: VecDeque::new(), + pushed: 0, + dropped: 0, + } + } + + /// 关键行保留上限(默认 = 环形容量)。 + #[must_use] + pub fn with_key_capacity(mut self, key_capacity: usize) -> Self { + self.key_capacity = key_capacity.max(1); + self + } + + /// 记一行(`launch` 的 stdout/stderr 事件逐行喂进来)。 + pub fn push(&mut self, origin: SignalOrigin, line: &str) { + let text = line.trim_end_matches(['\r', '\n']).to_owned(); + self.pushed += 1; + if self.recent.len() >= self.capacity { + if let Some(evicted) = self.recent.pop_front() { + self.dropped += 1; + if is_key_line(&evicted.text) { + if self.kept.len() >= self.key_capacity { + self.kept.pop_front(); + } + self.kept.push_back(evicted); + } + } + } + self.recent.push_back(LogLine::new(origin, text)); + } + + /// 一次性喂多行(导入日志、`latest.log` 兜底)。 + pub fn extend<'a, I>(&mut self, origin: SignalOrigin, lines: I) + where + I: IntoIterator, + { + for line in lines { + self.push(origin, line); + } + } + + /// 按时间顺序返回全部保留行(关键行历史在前,最近 N 行在后)。 + pub fn lines(&self) -> impl Iterator { + self.kept.iter().chain(self.recent.iter()) + } + + /// 拼成可供规则匹配的文本(`\n` 连接)。 + #[must_use] + pub fn text(&self) -> String { + let mut out = String::with_capacity(self.byte_len()); + for line in self.lines() { + out.push_str(&line.text); + out.push('\n'); + } + out + } + + /// 当前保留行数。 + #[must_use] + pub fn len(&self) -> usize { + self.kept.len() + self.recent.len() + } + + /// 是否一行都没有。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// 一共推入多少行。 + #[must_use] + pub const fn pushed(&self) -> usize { + self.pushed + } + + /// 被淘汰多少行(不含被永久保留的关键行)。 + #[must_use] + pub const fn dropped(&self) -> usize { + self.dropped + } + + /// 因关键行规则而保留下来的历史行数。 + #[must_use] + pub fn kept_key_lines(&self) -> usize { + self.kept.len() + } + + fn byte_len(&self) -> usize { + self.lines().map(|line| line.text.len() + 1).sum() + } +} + +/// 退出类型。字符串值与 `launch-接口约定.md` §3 `exit.exit_type` 完全一致。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub enum ExitType { + /// 正常退出(但可能是静默失败,见 [`ExitSignal::silent_failure`])。 + #[default] + Normal, + /// 应用层崩溃(有崩溃关键字 / 非 0 退出码)。 + ApplicationError, + /// JVM 没起来(`-Xmx` 过大、参数非法、JVM 致命错误)。 + JvmError, + /// 被 SIGKILL(Linux 137 / 系统 OOM killer)。 + Sigkill, + /// 被 SIGTERM(143)。 + Sigterm, + /// 被启动器主动停止(用户取消 / watchdog)。 + Killed, + /// 等待被中断(会话被丢弃)。 + Interrupted, +} + +impl ExitType { + /// 稳定字符串(与 launch 约定同值)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Normal => "normal", + Self::ApplicationError => "application_error", + Self::JvmError => "jvm_error", + Self::Sigkill => "sigkill", + Self::Sigterm => "sigterm", + Self::Killed => "killed", + Self::Interrupted => "interrupted", + } + } + + /// 从 launch 约定字符串反解(未知值回落 `ApplicationError`,绝不 panic)。 + #[must_use] + pub fn from_name(name: &str) -> Self { + match name.trim().to_ascii_lowercase().as_str() { + "normal" => Self::Normal, + "jvm_error" => Self::JvmError, + "sigkill" => Self::Sigkill, + "sigterm" => Self::Sigterm, + "killed" => Self::Killed, + "interrupted" => Self::Interrupted, + _ => Self::ApplicationError, + } + } + + /// 是否异常结束(非 `Normal`)。 + #[must_use] + pub const fn is_abnormal(self) -> bool { + !matches!(self, Self::Normal) + } +} + +impl fmt::Display for ExitType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// JVM 没起来的三个签名(analyse/02 §7.2,`JVM_ERROR` 判定依据)。 +pub const JVM_ERROR_KEYWORDS: [&str; 3] = [ + "Could not create the Java Virtual Machine", + "Error occurred during initialization of VM", + "A fatal exception has occurred. Program will exit.", +]; + +/// 应用层崩溃关键字(HMCL `ExitWaiter` 的 5 条 + 我们能拿到的补充)。 +pub const CRASH_KEYWORDS: [&str; 5] = [ + "Game crashed! Crash report saved to", + "Exception in thread", + "Unhandled exception", + "Unable to launch", + "An exception was thrown, the game will display an error screen and halt", +]; + +/// Windows 常见退出码(`0xC0000005` 等按 `i32` 表示,analyse/02 §8.4-2)。 +pub const EXIT_ACCESS_VIOLATION: i32 = -1_073_741_819; +/// `0xC0000409` STACK_BUFFER_OVERRUN。 +pub const EXIT_STACK_BUFFER_OVERRUN: i32 = -1_073_740_791; +/// `0xC0000135` DLL_NOT_FOUND。 +pub const EXIT_DLL_NOT_FOUND: i32 = -1_073_741_515; +/// POSIX SIGKILL(128 + 9)。 +pub const EXIT_SIGKILL: i32 = 137; +/// POSIX SIGTERM(128 + 15)。 +pub const EXIT_SIGTERM: i32 = 143; + +/// 进程退出信号(`launch` 的 `exit` 事件映射过来;离线诊断时由 [`ExitSignal::classify`] 反推)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ExitSignal { + /// 退出码(被信号杀死时为负或 128+N)。 + pub code: Option, + /// 退出类型。 + pub exit_type: ExitType, + /// 进程存活毫秒数。 + pub uptime_ms: Option, + /// stderr/错误行条数。 + pub error_lines: u32, + /// 退出码 0 但疑似静默失败(存活 <30s 或 stderr 有错误特征)。 + pub silent_failure: bool, + /// Windows 状态码原始串(`0xc0000005`)。 + pub windows_status: Option, + /// 命中的崩溃关键字。 + pub crash_keywords: Vec, +} + +impl ExitSignal { + /// 从原始退出事实推断类型(离线诊断 / 导入日志用;launch 侧有权威版本)。 + #[must_use] + pub fn classify( + code: Option, + uptime_ms: Option, + error_lines: u32, + error_text: &str, + ) -> Self { + let has = |keywords: &[&str]| keywords.iter().any(|kw| error_text.contains(kw)); + let mut crash_keywords = Vec::new(); + for keyword in CRASH_KEYWORDS { + if error_text.contains(keyword) { + crash_keywords.push(keyword.to_owned()); + } + } + let exit_type = match code { + Some(EXIT_SIGKILL) => ExitType::Sigkill, + Some(EXIT_SIGTERM) => ExitType::Sigterm, + // 与 HMCL `ExitType` 一致:只有「非 0 退出码 + JVM 关键字」才算 JVM_ERROR。 + // exit 0 却打出这些关键字的场景归到靜默失败(silent_failure)而不是 JVM 错误。 + Some(0) => ExitType::Normal, + Some(_) if has(&JVM_ERROR_KEYWORDS) => ExitType::JvmError, + Some(_) => ExitType::ApplicationError, + None => ExitType::ApplicationError, + }; + let short_lived = uptime_ms.is_some_and(|ms| ms < 30_000); + let silent_failure = exit_type == ExitType::Normal + && (short_lived || error_lines > 0 || has(&CRASH_KEYWORDS)); + Self { + code, + exit_type, + uptime_ms, + error_lines, + silent_failure, + windows_status: code.and_then(hex_status), + crash_keywords, + } + } + + /// Windows 状态码的十六进制串(如 `0xc0000005`)。 + #[must_use] + pub fn status_hex(&self) -> Option { + self.windows_status + .clone() + .or_else(|| self.code.and_then(hex_status)) + } + + /// 退出码是否是 Windows 访问违例(`0xC0000005`)。 + #[must_use] + pub fn is_access_violation(&self) -> bool { + self.code == Some(EXIT_ACCESS_VIOLATION) + } + + /// 一句话摘要(写进诊断报告 / 证据行)。 + #[must_use] + pub fn summary(&self) -> String { + let code = self + .code + .map_or_else(|| "未知".to_owned(), |code| code.to_string()); + let uptime = self + .uptime_ms + .map_or_else(String::new, |ms| format!(",存活 {} ms", ms)); + format!("退出码 {code}({}){uptime}", self.exit_type) + } +} + +fn hex_status(code: i32) -> Option { + // 只有 Windows 的 NTSTATUS 形态(负数高位为 1)才有意义。 + (code < 0).then(|| format!("0x{:08x}", code as u32)) +} diff --git a/crates/core/src/diagnose/sources.rs b/crates/core/src/diagnose/sources.rs new file mode 100644 index 0000000..49cddd1 --- /dev/null +++ b/crates/core/src/diagnose/sources.rs @@ -0,0 +1,299 @@ +//! 五类信号的具体采集器(推送类 2 个 + 磁盘类 3 个)。 +//! +//! 由 `collectors.rs` 用 `#[path]` 声明并统一导出;拆分只为守单文件 400 行上限。 + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use crate::diagnose::hs_err; + +use super::{ + CapturedText, CollectError, CollectedSources, CollectionContext, EnvironmentSnapshot, + ExitSignal, SignalBundle, SignalCollector, SignalKind, SignalOrigin, +}; + +/// 推送类:退出信号(launch 的 `exit` 事件已算好)。 +#[derive(Debug, Clone)] +pub struct ExitCollector { + signal: ExitSignal, +} + +impl ExitCollector { + /// 构造。 + #[must_use] + pub fn new(signal: ExitSignal) -> Self { + Self { signal } + } +} + +impl SignalCollector for ExitCollector { + fn kind(&self) -> SignalKind { + SignalKind::Exit + } + + fn collect( + &self, + _ctx: &CollectionContext, + out: &mut SignalBundle, + ) -> Result { + out.exit = Some(self.signal.clone()); + Ok(CollectedSources::new()) + } +} + +/// 推送类:环境快照 + mod 清单(launch/game 侧已有数据)。 +#[derive(Debug, Clone, Default)] +pub struct EnvironmentCollector { + snapshot: EnvironmentSnapshot, +} + +impl EnvironmentCollector { + /// 构造。 + #[must_use] + pub fn new(snapshot: EnvironmentSnapshot) -> Self { + Self { snapshot } + } +} + +impl SignalCollector for EnvironmentCollector { + fn kind(&self) -> SignalKind { + SignalKind::Environment + } + + fn collect( + &self, + _ctx: &CollectionContext, + out: &mut SignalBundle, + ) -> Result { + out.mods = self.snapshot.mods.clone(); + out.environment = Some(self.snapshot.clone()); + Ok(CollectedSources::new()) + } +} + +/// 磁盘类:`crash-reports/crash-*.txt`(双通道里的「权威全文」)。 +#[derive(Debug, Clone, Default)] +pub struct CrashReportCollector { + dir: Option, +} + +impl CrashReportCollector { + /// 默认扫 `/crash-reports`。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 指定目录。 + #[must_use] + pub fn with_dir(dir: impl Into) -> Self { + Self { + dir: Some(dir.into()), + } + } +} + +impl SignalCollector for CrashReportCollector { + fn kind(&self) -> SignalKind { + SignalKind::CrashReport + } + + fn collect( + &self, + ctx: &CollectionContext, + out: &mut SignalBundle, + ) -> Result { + let dir = self + .dir + .clone() + .unwrap_or_else(|| ctx.run_dir.join("crash-reports")); + let Some(path) = newest_file(&dir, ctx.started_at, |name| { + name.starts_with("crash-") && name.ends_with(".txt") + }) else { + return Ok(CollectedSources::new()); + }; + let text = read_lossy(SignalKind::CrashReport, &path)?; + let modified = modified_at(&path); + let mut sources = CollectedSources::new(); + sources.push(&path); + out.sources.push(path.clone()); + out.crash_report = Some(CapturedText { + path, + text, + modified, + }); + Ok(sources) + } +} + +/// 磁盘类:`hs_err_pid*.log`(HMCL 的空白区,显卡驱动类崩溃的唯一权威证据)。 +#[derive(Debug, Clone, Default)] +pub struct HsErrCollector { + dirs: Vec, +} + +impl HsErrCollector { + /// 默认扫 ``(JVM 把 hs_err 写在进程工作目录)。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 指定候选目录表(**整表替换**,不是追加;给出后不再自动扫 `run_dir`)。 + /// + /// 典型用法:`HsErrCollector::with_dirs([run_dir.clone(), home_dir])`。 + #[must_use] + pub fn with_dirs(dirs: impl IntoIterator) -> Self { + Self { + dirs: dirs.into_iter().collect(), + } + } +} + +impl SignalCollector for HsErrCollector { + fn kind(&self) -> SignalKind { + SignalKind::HsErr + } + + fn collect( + &self, + ctx: &CollectionContext, + out: &mut SignalBundle, + ) -> Result { + let dirs = if self.dirs.is_empty() { + vec![ctx.run_dir.clone()] + } else { + self.dirs.clone() + }; + let mut newest: Option = None; + for dir in dirs { + if let Some(path) = newest_file(&dir, ctx.started_at, |name| { + name.starts_with("hs_err_pid") && name.ends_with(".log") + }) { + newest = match newest { + Some(current) => match (modified_at(¤t), modified_at(&path)) { + (Some(a), Some(b)) if b <= a => Some(current), + _ => Some(path), + }, + None => Some(path), + }; + } + } + let Some(path) = newest else { + return Ok(CollectedSources::new()); + }; + let report = hs_err::parse_file(&path).map_err(|error| CollectError { + kind: SignalKind::HsErr, + path: Some(path.clone()), + message: error.to_string(), + })?; + let mut sources = CollectedSources::new(); + sources.push(&path); + out.sources.push(path); + out.hs_err = Some(report); + Ok(sources) + } +} + +/// 磁盘类:`logs/latest.log` 尾巴(内存缓冲不够长时的兜底)。 +#[derive(Debug, Clone)] +pub struct LatestLogCollector { + path: Option, + max_lines: usize, +} + +impl Default for LatestLogCollector { + fn default() -> Self { + Self::new() + } +} + +impl LatestLogCollector { + /// 默认读 `/logs/latest.log` 的最后 5000 行。 + #[must_use] + pub fn new() -> Self { + Self { + path: None, + max_lines: 5000, + } + } + + /// 保留行数上限。 + #[must_use] + pub fn with_max_lines(mut self, max_lines: usize) -> Self { + self.max_lines = max_lines.max(1); + self + } + + /// 指定文件。 + #[must_use] + pub fn with_path(path: impl Into) -> Self { + Self { + path: Some(path.into()), + max_lines: 5000, + } + } +} + +impl SignalCollector for LatestLogCollector { + fn kind(&self) -> SignalKind { + SignalKind::Log + } + + fn collect( + &self, + ctx: &CollectionContext, + out: &mut SignalBundle, + ) -> Result { + let path = self + .path + .clone() + .unwrap_or_else(|| ctx.run_dir.join("logs").join("latest.log")); + if !path.is_file() { + return Ok(CollectedSources::new()); + } + let text = read_lossy(SignalKind::Log, &path)?; + let lines = text.lines().collect::>(); + let start = lines.len().saturating_sub(self.max_lines); + out.log + .extend(SignalOrigin::LatestLog, lines[start..].iter().copied()); + let mut sources = CollectedSources::new(); + sources.push(&path); + out.sources.push(path); + Ok(sources) + } +} + +fn newest_file( + dir: &Path, + since: Option, + matches: impl Fn(&str) -> bool, +) -> Option { + let entries = fs::read_dir(dir).ok()?; + entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_string_lossy().into_owned(); + if !matches(&name) { + return None; + } + let path = entry.path(); + let modified = modified_at(&path)?; + if since.is_some_and(|since| modified < since) { + return None; + } + Some((modified, path)) + }) + .max_by(|a, b| a.0.cmp(&b.0)) + .map(|(_, path)| path) +} + +fn modified_at(path: &Path) -> Option { + fs::metadata(path).ok()?.modified().ok() +} + +fn read_lossy(kind: SignalKind, path: &Path) -> Result { + let bytes = fs::read(path).map_err(|error| CollectError::io(kind, path, &error))?; + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} diff --git a/crates/core/src/diagnose/synthetic.rs b/crates/core/src/diagnose/synthetic.rs new file mode 100644 index 0000000..3353bd6 --- /dev/null +++ b/crates/core/src/diagnose/synthetic.rs @@ -0,0 +1,279 @@ +//! 引擎内置结论:退出码 / hs_err 这类**结构化信号**没有文本签名,塞 YAML 别扭,放在这里。 +//! +//! 它们参与和其它规则一样的排序与抑制流程(`priority`/`confidence`/`suppress` 语义相同), +//! 所以「主因是驱动、次生是图形规则」这种关系能自动成立。 +//! 由 `engine.rs` 用 `#[path]` 声明,拆分只为守单文件 400 行上限。 + +use super::{DiagnosisInput, Evidence, Finding}; +use crate::diagnose::collectors::{ + ExitSignal, ExitType, SignalOrigin, EXIT_DLL_NOT_FOUND, EXIT_STACK_BUFFER_OVERRUN, +}; +use crate::diagnose::hs_err::{HsErrKind, HsErrReport}; +use crate::diagnose::rules::{ + AdjustMemory, ChangeGraphics, ChangeJava, ComponentKind, FixAction, GraphicsSetting, + JavaVendor, Reinstall, RuleCategory, SeekHelp, Severity, +}; + +/// 所有内置结论。 +#[must_use] +pub fn synthetic_findings(input: &DiagnosisInput) -> Vec { + let mut out = Vec::new(); + if let Some(exit) = &input.exit { + out.extend(exit_findings(exit)); + } + if let Some(report) = &input.hs_err { + out.extend(hs_err_findings(report)); + } + out +} + +fn exit_findings(exit: &ExitSignal) -> Vec { + let mut out = Vec::new(); + if exit.is_access_violation() { + let mut finding = base( + "signal.access_violation", + RuleCategory::Graphics, + Severity::Critical, + 0.55, + 82, + "diagnose.cause.signal.access_violation", + &format!( + "进程以 0xC0000005(访问违例)退出:多半是显卡驱动/渲染栈崩溃,建议先更新显卡驱动或换渲染器({})", + exit.summary() + ), + ); + finding.fix_actions = vec![FixAction::ChangeGraphics(ChangeGraphics { + setting: GraphicsSetting::Driver, + update_driver: true, + suggested_value: None, + note: Some("访问违例优先怀疑显卡驱动".to_owned()), + })]; + finding.suppress = vec!["graphics.graphics_driver".to_owned()]; + finding.evidence = vec![Evidence::new(SignalOrigin::Launcher, 0, exit.summary())]; + out.push(finding); + } + if exit.code == Some(EXIT_DLL_NOT_FOUND) { + let mut finding = base( + "signal.dll_not_found", + RuleCategory::Integrity, + Severity::Critical, + 0.7, + 85, + "diagnose.cause.signal.dll_not_found", + "进程以 0xC0000135(找不到 DLL)退出:缺原生库或 Java 安装损坏,重装 natives/Java 组件", + ); + finding.fix_actions = vec![FixAction::Reinstall(Reinstall { + component: ComponentKind::Natives, + path_hint: None, + note: Some("找不到 DLL:优先修 natives,其次重装 Java".to_owned()), + })]; + finding.evidence = vec![Evidence::new(SignalOrigin::Launcher, 0, exit.summary())]; + out.push(finding); + } + if exit.code == Some(EXIT_STACK_BUFFER_OVERRUN) { + let mut finding = base( + "signal.stack_buffer_overrun", + RuleCategory::Jvm, + Severity::Warning, + 0.5, + 60, + "diagnose.cause.signal.stack_buffer_overrun", + "进程以 0xC0000409(栈缓冲区溢出)退出:JVM/原生库内部错误,需要 hs_err 或崩溃报告进一步判断", + ); + finding.fix_actions = vec![FixAction::SeekHelp(SeekHelp { + export_diagnostics: true, + needs_more_info: vec!["run 目录下的 hs_err_pid*.log".to_owned()], + note: None, + })]; + finding.evidence = vec![Evidence::new(SignalOrigin::Launcher, 0, exit.summary())]; + out.push(finding); + } + if exit.exit_type == ExitType::Sigkill { + let mut finding = base( + "signal.sigkill", + RuleCategory::Memory, + Severity::Warning, + 0.6, + 70, + "diagnose.cause.signal.sigkill", + "进程被 SIGKILL 强杀:常见于系统内存耗尽(OOM killer)或外部清理进程,建议降低 -Xmx 或扩大虚拟内存", + ); + finding.fix_actions = vec![FixAction::AdjustMemory(AdjustMemory { + increase_system_swap: true, + note: Some("被强杀:先确认系统内存是否够".to_owned()), + ..AdjustMemory::default() + })]; + finding.evidence = vec![Evidence::new(SignalOrigin::Launcher, 0, exit.summary())]; + out.push(finding); + } + if exit.exit_type == ExitType::JvmError { + let mut finding = base( + "signal.jvm_error", + RuleCategory::Jvm, + Severity::Critical, + 0.55, + 72, + "diagnose.cause.signal.jvm_error", + "JVM 未能启动(游戏根本没跑起来):先查 -Xmx 是否超过可用内存、JVM 参数是否非法", + ); + finding.fix_actions = vec![ + FixAction::AdjustMemory(AdjustMemory { + increase_system_swap: true, + note: Some("JVM 初始化失败,优先怀疑 -Xmx 过大".to_owned()), + ..AdjustMemory::default() + }), + FixAction::ChangeJava(ChangeJava { + vendor: JavaVendor::Hotspot, + note: Some("换成 HotSpot 的官方 Java 试试".to_owned()), + ..ChangeJava::default() + }), + ]; + finding.evidence = vec![Evidence::new(SignalOrigin::Launcher, 0, exit.summary())]; + out.push(finding); + } + if exit.silent_failure { + let mut finding = base( + "signal.silent_failure", + RuleCategory::Other, + Severity::Warning, + 0.4, + 55, + "diagnose.cause.signal.silent_failure", + &format!( + "退出码为 0 但疑似静默失败({} 条错误行):游戏可能刚起来就被 mod 异常终止,需要更长的日志确认", + exit.error_lines + ), + ); + finding.fix_actions = vec![FixAction::SeekHelp(SeekHelp { + export_diagnostics: true, + needs_more_info: vec!["把日志行数上限调到 20000 后复现一次".to_owned()], + note: None, + })]; + finding.evidence = vec![Evidence::new(SignalOrigin::Launcher, 0, exit.summary())]; + out.push(finding); + } + out +} + +fn hs_err_findings(report: &HsErrReport) -> Vec { + let mut out = Vec::new(); + match report.kind { + HsErrKind::AccessViolation => { + let module = report + .failed_frame + .as_ref() + .and_then(|frame| frame.module.clone()) + .unwrap_or_else(|| "未知模块".to_owned()); + let mut finding = base( + "hs_err.access_violation", + RuleCategory::Graphics, + Severity::Critical, + 0.75, + 88, + "diagnose.cause.hs_err.access_violation", + &format!( + "JVM 致命错误 {module}(访问违例,失败帧 {}):原生层崩溃,显卡驱动/渲染栈是首要嫌疑", + report + .failed_frame + .as_ref() + .map_or("未知", |frame| frame.raw.as_str()) + ), + ); + finding.fix_actions = vec![ + FixAction::ChangeGraphics(ChangeGraphics { + setting: GraphicsSetting::Driver, + update_driver: true, + suggested_value: None, + note: Some(format!("失败帧落在 {module}")), + }), + FixAction::SeekHelp(SeekHelp { + export_diagnostics: true, + needs_more_info: Vec::new(), + note: Some("把 hs_err 一并上报,便于确认驱动版本".to_owned()), + }), + ]; + finding.suppress = vec!["graphics.graphics_driver".to_owned()]; + finding.evidence = vec![Evidence::new(SignalOrigin::HsErr, 1, report.summary())]; + out.push(finding); + } + HsErrKind::OutOfMemory => { + let memory = report + .memory + .as_ref() + .map_or_else(String::new, |memory| memory.summary()); + let mut finding = base( + "hs_err.out_of_memory", + RuleCategory::Memory, + Severity::Critical, + 0.85, + 86, + "diagnose.cause.hs_err.out_of_memory", + &format!("JVM 因内存不足终止:{memory},降低 -Xmx 或扩大系统虚拟内存"), + ); + finding.fix_actions = vec![FixAction::AdjustMemory(AdjustMemory { + increase_system_swap: true, + note: Some(memory), + ..AdjustMemory::default() + })]; + finding.evidence = vec![Evidence::new(SignalOrigin::HsErr, 1, report.summary())]; + out.push(finding); + } + HsErrKind::StackOverflow => { + let mut finding = base( + "hs_err.stack_overflow", + RuleCategory::ModRuntime, + Severity::Warning, + 0.6, + 62, + "diagnose.cause.hs_err.stack_overflow", + "JVM 栈溢出导致的致命错误:通常是某个 mod 的递归/死循环,禁用最近装的 mod 再试", + ); + finding.fix_actions = vec![FixAction::SeekHelp(SeekHelp { + export_diagnostics: true, + needs_more_info: vec!["最近安装/更新的 mod 列表".to_owned()], + note: None, + })]; + finding.evidence = vec![Evidence::new(SignalOrigin::HsErr, 1, report.summary())]; + out.push(finding); + } + HsErrKind::InternalError => { + let mut finding = base( + "hs_err.internal_error", + RuleCategory::Jvm, + Severity::Warning, + 0.5, + 62, + "diagnose.cause.hs_err.internal_error", + "JVM 内部致命错误:优先换官方 HotSpot Java,其次怀疑原生库(natives)损坏", + ); + finding.fix_actions = vec![FixAction::ChangeJava(ChangeJava { + vendor: JavaVendor::Hotspot, + ..ChangeJava::default() + })]; + finding.evidence = vec![Evidence::new(SignalOrigin::HsErr, 1, report.summary())]; + out.push(finding); + } + HsErrKind::Other => {} + } + out +} + +fn base( + rule_id: &str, + category: RuleCategory, + severity: Severity, + confidence: f32, + priority: u8, + message_key: &str, + message: &str, +) -> Finding { + Finding::skeleton( + rule_id, + category, + severity, + confidence, + priority, + message_key, + message, + ) +} diff --git a/crates/core/src/download/adoptium.rs b/crates/core/src/download/adoptium.rs new file mode 100644 index 0000000..a08f6ca --- /dev/null +++ b/crates/core/src/download/adoptium.rs @@ -0,0 +1,204 @@ +//! Adoptium(Temurin)JDK 的院校镜像展开。 +//! +//! D13 定案:国内模式下 JDK 按健康度在 TUNA / 华为云 等镜像之间选, +//! 全部不可达才回官方。难点是**镜像路径与官方下载链接不同形**: +//! +//! 官方(api.adoptium.net 会 302 到这里): +//! `https://github.com/adoptium/temurin17-binaries/releases/download/JDK17.0.13+11/OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip` +//! 清华 TUNA: +//! `https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jdk/x64/windows/OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip` +//! +//! 所以这里按产物文件名反解 `(major, flavor, arch, os, filename)`,再按各镜像的模板拼目标 URL。 +//! **`flavor` 必须是 `jdk` 或 `jre`**:院校镜像的目录结构是 `/{major}/{jdk|jre}/{arch}/{os}/`, +//! JRE 请求若错拼成 `/jdk/` 会 404 打满所有镜像,最后退回官方直链(镜像加速白费)。 +//! 解析失败不报错——只保留官方源,下载照常能完成。 +//! +//! # 清单可用性(M33 实测,2026-10-30) +//! +//! 按 `{base}/{21}/jdk/x64/windows/` 逐个打点: +//! +//! | 镜像 | 结果 | +//! | --- | --- | +//! | 清华 TUNA `mirrors.tuna.tsinghua.edu.cn/Adoptium` | 200(真实下载 205 MB / 22 秒成功) | +//! | 中科大 USTC | **404**——`/adoptium/` 下只有 `deb/` `rpm/` `releases/`,没有这套目录树 | +//! | 上交 SJTU / 北外 BFSU | **404**(同样没有该目录树) | +//! | 华为云 | **假阳性**:门户对任意路径都回 `200` + 12 KB HTML(M34 实测,`/v1/repositories` 里没有 adoptium) | +//! +//! 因此 `mirrors.json` 里这四项全是 `enabled: false`:留着当「将来恢复」的登记, +//! 但不让它们每次安装都白跑一趟(健康度虽然会冷却,首轮仍要付代价)。 +//! **判可用性别只看状态码**:真仓库目录页含 `
`,门户兜底页没有——状态码 200 说明不了什么。
+//!
+//! # 4xx 不是「源坏」
+//!
+//! TUNA 每个 `{major}/{jdk|jre}/{arch}/{os}/` 目录只保留**最新一个补丁**(实测 21.0.12 → 404,
+//! 21.0.13 → 200 且大小与 Adoptium API 一致)。所以 `NotFound` 在引擎里走
+//! `HealthRegistry::record_missing`:不计失败、不进冷却,只留诊断痕迹。
+
+use serde::{Deserialize, Serialize};
+
+use super::mirrors::MirrorTable;
+use super::types::Candidate;
+
+const fn default_true() -> bool {
+    true
+}
+
+/// 一个 Adoptium 院校镜像。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AdoptiumMirror {
+    /// 源标识(`tuna` / `ustc` / `sjtu` / `bfsu`)。
+    pub id: String,
+    /// 展示名。
+    pub name: String,
+    /// 镜像根(形如 `https://mirrors.tuna.tsinghua.edu.cn/Adoptium`)。
+    pub base: String,
+    /// 是否启用。
+    #[serde(default = "default_true")]
+    pub enabled: bool,
+    /// 该源只保留「最新补丁」(TUNA 实测:每个 `{major}/{jdk|jre}/{arch}/{os}/` 只有 1 个归档)。
+    ///
+    /// 标记本身不改 URL 形态,只给诊断与 `java vendors` 提示用:旧补丁在这里必然 404,
+    /// 引擎侧把 4xx 当「正常降级」而不是「源坏」(见 `download::health::record_missing`)。
+    #[serde(default)]
+    pub latest_only: bool,
+}
+
+impl MirrorTable {
+    /// Adoptium 类 URL 展开成院校镜像候选(按清单顺序;健康度排序由引擎负责)。
+    #[must_use]
+    pub fn adoptium_candidates(&self, url: &str) -> Vec {
+        let Some(artifact) = parse_adoptium_artifact(url) else {
+            return Vec::new();
+        };
+        self.adoptium
+            .iter()
+            .filter(|mirror| mirror.enabled)
+            .map(|mirror| {
+                let base = mirror.base.trim_end_matches('/');
+                Candidate::new(mirror.id.clone(), artifact.mirror_url(base))
+            })
+            .collect()
+    }
+}
+
+/// Adoptium 产物形态:JDK 还是 JRE(决定镜像的 `{major}/jdk|jre/` 路径段)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum JdkFlavor {
+    /// 完整开发包(`OpenJDK17U-jdk_...`)。
+    Jdk,
+    /// 运行时(`OpenJDK17U-jre_...`)。
+    Jre,
+}
+
+impl JdkFlavor {
+    /// 镜像路径里的段名。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Jdk => "jdk",
+            Self::Jre => "jre",
+        }
+    }
+
+    /// 从产物文件名判形态:`OpenJDK17U-jdk_` / `OpenJDK17U-jre_` / `*-jre-17…` 兜底。
+    #[must_use]
+    fn from_filename(filename: &str) -> Self {
+        if filename.contains("-jre") || filename.contains("_jre") {
+            Self::Jre
+        } else {
+            Self::Jdk
+        }
+    }
+}
+
+/// 解析出来的 Adoptium 产物。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AdoptiumArtifact {
+    /// 大版本号(`17` / `21`)。
+    pub major: String,
+    /// JDK 还是 JRE。
+    pub flavor: JdkFlavor,
+    /// CPU 架构(`x64` / `aarch64` / …)。
+    pub arch: String,
+    /// 目标系统(`windows` / `linux` / `mac` / …)。
+    pub os: String,
+    /// 产物文件名(含扩展名)。
+    pub filename: String,
+}
+
+impl AdoptiumArtifact {
+    /// 拼某个镜像根下的下载地址:`{base}/{major}/{jdk|jre}/{arch}/{os}/{filename}`。
+    #[must_use]
+    pub fn mirror_url(&self, base: &str) -> String {
+        format!(
+            "{}/{}/{}/{}/{}/{}",
+            base.trim_end_matches('/'),
+            self.major,
+            self.flavor.as_str(),
+            self.arch,
+            self.os,
+            self.filename
+        )
+    }
+}
+
+/// 解析 Adoptium 产物名。
+///
+/// 输入形如 `https://github.com/adoptium/temurin17-binaries/releases/download/JDK17.0.13%2B11/OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip`。
+/// 解析失败返回 `None`(此时只保留官方源,不影响下载)。
+#[must_use]
+pub fn parse_adoptium_artifact(url: &str) -> Option {
+    let filename = url.rsplit('/').next()?;
+    if filename.is_empty()
+        || (!filename.contains("OpenJDK") && !filename.contains("jdk") && !filename.contains("jre"))
+    {
+        return None;
+    }
+    let mut major: Option = None;
+    let mut arch: Option<&str> = None;
+    let mut os: Option<&str> = None;
+    for token in filename.split('_') {
+        if let Some(stripped) = token.strip_prefix("OpenJDK") {
+            let digits: String = stripped.chars().take_while(char::is_ascii_digit).collect();
+            if !digits.is_empty() {
+                major = Some(digits);
+            }
+        }
+        arch = arch.or(match token {
+            "x64" => Some("x64"),
+            "x86-32" | "x86" => Some("x86-32"),
+            "aarch64" => Some("aarch64"),
+            "arm" => Some("arm"),
+            "ppc64le" => Some("ppc64le"),
+            "s390x" => Some("s390x"),
+            _ => None,
+        });
+        os = os.or(match token {
+            "windows" => Some("windows"),
+            "linux" => Some("linux"),
+            "mac" => Some("mac"),
+            "alpine-linux" => Some("alpine-linux"),
+            "aix" => Some("aix"),
+            _ => None,
+        });
+    }
+    let major = match major {
+        Some(major) => major,
+        None => {
+            // 兜底形态:`jdk-17.0.13+11_...` 之类。
+            let rest = filename.split("jdk-").nth(1)?;
+            let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
+            if digits.is_empty() {
+                return None;
+            }
+            digits
+        }
+    };
+    Some(AdoptiumArtifact {
+        major,
+        flavor: JdkFlavor::from_filename(filename),
+        arch: arch?.to_string(),
+        os: os?.to_string(),
+        filename: filename.to_string(),
+    })
+}
diff --git a/crates/core/src/download/atomic.rs b/crates/core/src/download/atomic.rs
new file mode 100644
index 0000000..1a09327
--- /dev/null
+++ b/crates/core/src/download/atomic.rs
@@ -0,0 +1,384 @@
+//! 原子落盘:临时文件守卫 + `tmp → rename`。
+//!
+//! 设计文档 §4 的 HMCL 坑之一:没有「先校验后落盘」,崩溃/取消会留下「看起来存在但内容坏了」
+//! 的文件,之后所有「存在即跳过」的判断全部失效。这里的规矩:
+//!
+//! * 下载永远写进同级中间文件,校验通过才 `rename` 到目标路径;
+//! * `PartFile` 在 `Drop` 时自动清理(除非已 `commit`),取消/early-return 都不会留垃圾;
+//! * 小文件(索引、健康度)走 `write_atomic`,同样先写临时文件再 `rename`。
+//!
+//! # 中间文件的身份
+//!
+//! 中间文件名是 `.ullpart--`:`session` 是进程号,`seq` 是进程内
+//! 自增序号(一个 `PartFile` 一个)。旧版命名只由目标路径决定(`.ullpart`),
+//! 同一数据根同时跑两个启动器进程时两边共用一个中间文件:先提交的一方 `rename` 走,
+//! 另一方就拿到 `os error 2`(M35 真机:两个 `ull install` 并行装同一数据根,
+//! 各失败 819 / 1116 个资源)。
+//!
+//! 单进程语义(幂等 / 断点续传 / `current_bytes` 跳过判定)刻意保持不变:
+//!
+//! * [`PartFile::for_dest`] 先在目标目录里找这个目标的旧中间文件并**收养**(本进程的、
+//!   旧版无主的、以及外进程但静置超过 [`ADOPT_MIN_AGE`] 的),改名成本次路径;后续
+//!   续传判定完全照旧,跨进程/跨版本中断的下载仍然续得上。
+//! * 孤儿清理只碰两类:本会话留下又没人在用的副本,和无主(旧版命名)且超过
+//!   [`ORPHAN_MAX_AGE`] 的残留。本进程正在用的路径登记在活跃表里一律让开;外进程的
+//!   中间文件除了「静置超过 [`ORPHAN_MAX_AGE`] 且同一个目标」的,一概不删——没有进程
+//!   存活性探测就无法证明那边已经死了,宁可再生点垃圾也不重犯 `os error 2`。
+
+use std::collections::HashSet;
+use std::ffi::OsString;
+use std::fs::{self, File, OpenOptions};
+use std::io::{self, Write};
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Mutex, OnceLock};
+use std::time::{Duration, SystemTime};
+
+/// 下载中间文件后缀(中间文件家族的前缀:`.ullpart` 或 `.ullpart--`)。
+pub const PART_SUFFIX: &str = ".ullpart";
+/// 续传元数据后缀(挂在中间文件路径后面)。
+pub const META_SUFFIX: &str = ".ullmeta.json";
+/// 收养阈值:别的进程留下的中间文件静置这么久才敢接管。
+///
+/// 活着的下载每读到一个块就写一次(读超时默认 30s 就报错退出),所以「10 分钟没写」
+/// 基本等价于「那边已经死了」。
+pub const ADOPT_MIN_AGE: Duration = Duration::from_secs(10 * 60);
+/// 孤儿阈值:无主(旧版命名)中间文件超过这个岁数才清。
+pub const ORPHAN_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
+
+/// 同进程内中间文件/临时文件的去重序号。
+static SEQ: AtomicU64 = AtomicU64::new(0);
+
+fn sibling(dest: &Path, suffix: &str) -> PathBuf {
+    let mut name = dest.file_name().map_or_else(OsString::new, OsString::from);
+    name.push(suffix);
+    match dest.parent() {
+        Some(parent) => parent.join(name),
+        None => PathBuf::from(name),
+    }
+}
+
+/// 本进程序号(中间文件与 `write_atomic` 的临时文件共用)。
+fn next_seq() -> u64 {
+    SEQ.fetch_add(1, Ordering::Relaxed)
+}
+
+/// 本进程此刻仍在用的中间文件路径(清理与收养都必须让开)。
+fn live_parts() -> &'static Mutex> {
+    static LIVE: OnceLock>> = OnceLock::new();
+    LIVE.get_or_init(|| Mutex::new(HashSet::new()))
+}
+
+fn mark_live(path: &Path, live: bool) {
+    let mut set = live_parts()
+        .lock()
+        .unwrap_or_else(std::sync::PoisonError::into_inner);
+    if live {
+        set.insert(path.to_path_buf());
+    } else {
+        set.remove(path);
+    }
+}
+
+fn is_live(path: &Path) -> bool {
+    live_parts()
+        .lock()
+        .unwrap_or_else(std::sync::PoisonError::into_inner)
+        .contains(path)
+}
+
+/// 中间文件属主(从文件名解析)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Owner {
+    /// 旧版命名:没有进程标识(加标识之前留下的续传材料)。
+    Legacy,
+    /// 某个进程(会话)留下的中间文件。
+    Session(u32),
+}
+
+fn strip_meta(name: &str) -> &str {
+    name.strip_suffix(META_SUFFIX).unwrap_or(name)
+}
+
+/// 名字里指向的中间文件属主;不是我们认识的名字形状 → `None`(一概不碰)。
+fn owner_of(file_name: &str) -> Option {
+    let base = strip_meta(file_name);
+    let index = base.rfind(PART_SUFFIX)?;
+    if index == 0 {
+        return None;
+    }
+    let tail = &base[index + PART_SUFFIX.len()..];
+    if tail.is_empty() {
+        return Some(Owner::Legacy);
+    }
+    let mut pieces = tail.strip_prefix('-')?.split('-');
+    let session = pieces.next()?.parse::().ok()?;
+    pieces.next()?.parse::().ok()?;
+    if pieces.next().is_some() {
+        return None;
+    }
+    Some(Owner::Session(session))
+}
+
+/// 本次会话的中间文件路径:`.ullpart--`。
+fn part_path(dest: &Path, session: u32, seq: u64) -> PathBuf {
+    let mut name = dest.file_name().map_or_else(OsString::new, OsString::from);
+    name.push(format!("{PART_SUFFIX}-{session}-{seq}"));
+    match dest.parent() {
+        Some(parent) => parent.join(name),
+        None => PathBuf::from(name),
+    }
+}
+
+/// 文件距上次修改多久(取不到就当刚写过:清理与收养一律保守)。
+fn age_of(path: &Path) -> Duration {
+    fs::metadata(path)
+        .and_then(|meta| meta.modified())
+        .ok()
+        .and_then(|modified| SystemTime::now().duration_since(modified).ok())
+        .unwrap_or_default()
+}
+
+/// 删掉一个中间文件连同它挂的续传元数据。
+fn remove_family(part: &Path) {
+    let _ = fs::remove_file(part);
+    let _ = fs::remove_file(sibling(part, META_SUFFIX));
+}
+
+/// 目录扫描的三类结果。
+#[derive(Default)]
+struct Scan {
+    /// 可收养的旧中间文件(最年轻的那个)。
+    adopted: Option,
+    /// 本目标的全部中间文件副本(含被收养者)。
+    leftovers: Vec<(PathBuf, Owner, Duration)>,
+    /// 无主且超龄的孤儿。
+    orphans: Vec,
+}
+
+fn scan(dest: &Path, session: u32) -> Scan {
+    let mut scan = Scan::default();
+    let Some(dir) = dest
+        .parent()
+        .filter(|parent| !parent.as_os_str().is_empty())
+    else {
+        return scan;
+    };
+    let Some(dest_name) = dest.file_name() else {
+        return scan;
+    };
+    let family = format!("{}{PART_SUFFIX}", dest_name.to_string_lossy());
+    let Ok(entries) = fs::read_dir(dir) else {
+        return scan;
+    };
+    let mut newest: Option = None;
+    for entry in entries.flatten() {
+        let name = entry.file_name().to_string_lossy().into_owned();
+        if name.ends_with(META_SUFFIX) {
+            continue; // 元数据跟着它挂的中间文件一起处理
+        }
+        let Some(owner) = owner_of(&name) else {
+            continue;
+        };
+        let file = entry.path();
+        if !file.is_file() {
+            continue;
+        }
+        let age = age_of(&file);
+        if !name.starts_with(&family) {
+            if owner == Owner::Legacy && age >= ORPHAN_MAX_AGE {
+                scan.orphans.push(file);
+            }
+            continue;
+        }
+        let adoptable = match owner {
+            // 本会话的旧副本:没人在用就能接管(保留「同一进程内重跑可续传」)。
+            Owner::Session(owner_session) if owner_session == session => !is_live(&file),
+            // 外进程的:静置够久才敢动手,否则可能正在被写。
+            Owner::Session(_) => age >= ADOPT_MIN_AGE,
+            Owner::Legacy => true,
+        };
+        if adoptable && newest.is_none_or(|best| age < best) {
+            newest = Some(age);
+            scan.adopted = Some(file.clone());
+        }
+        scan.leftovers.push((file, owner, age));
+    }
+    scan
+}
+
+/// 为本次下载准备中间文件:收养可续传的旧文件,清掉本会话多余副本与无主孤儿。
+///
+/// 全程尽力而为:清理失败绝不影响下载本身。
+fn prepare(dest: &Path, path: &Path, session: u32) {
+    let scan = scan(dest, session);
+
+    if let Some(found) = scan.adopted {
+        // 同一目录内改名,跨进程也是原子的:谁先动手谁拿到这份续传材料。
+        if found != *path && fs::rename(&found, path).is_ok() {
+            let meta = sibling(&found, META_SUFFIX);
+            if meta.is_file() {
+                let _ = fs::rename(&meta, sibling(path, META_SUFFIX));
+            }
+        }
+    }
+
+    for (leftover, owner, age) in scan.leftovers {
+        if leftover == *path || is_live(&leftover) {
+            continue;
+        }
+        // 本会话落败的副本留着没意义;外进程的只有超龄才敢清。
+        if owner == Owner::Session(session) || age >= ORPHAN_MAX_AGE {
+            remove_family(&leftover);
+        }
+    }
+
+    for orphan in scan.orphans {
+        remove_family(&orphan);
+    }
+}
+
+/// 下载中间文件的守卫。
+#[derive(Debug)]
+pub struct PartFile {
+    path: PathBuf,
+    committed: bool,
+    preserved: bool,
+}
+
+impl PartFile {
+    /// 为目标路径派生中间文件(名字带进程标识,并顺手收养旧的续传材料)。
+    #[must_use]
+    pub fn for_dest(dest: &Path) -> Self {
+        let session = std::process::id();
+        let path = part_path(dest, session, next_seq());
+        mark_live(&path, true);
+        prepare(dest, &path, session);
+        Self {
+            path,
+            committed: false,
+            preserved: false,
+        }
+    }
+
+    /// 中间文件路径。
+    #[must_use]
+    pub fn path(&self) -> &Path {
+        &self.path
+    }
+
+    /// 续传元数据路径。
+    #[must_use]
+    pub fn meta_path(&self) -> PathBuf {
+        sibling(&self.path, META_SUFFIX)
+    }
+
+    /// 中间文件是否存在。
+    #[must_use]
+    pub fn exists(&self) -> bool {
+        self.path.is_file()
+    }
+
+    /// 已写入的字节数(不存在时为 0)。
+    #[must_use]
+    pub fn len(&self) -> u64 {
+        fs::metadata(&self.path).map_or(0, |meta| meta.len())
+    }
+
+    /// 是否为空(续传判定要区分「文件不存在」和「文件存在但 0 字节」)。
+    #[must_use]
+    pub fn is_empty(&self) -> bool {
+        self.len() == 0
+    }
+
+    /// 以追加方式打开(续传)。
+    pub fn open_append(&self) -> io::Result {
+        ensure_parent(&self.path)?;
+        OpenOptions::new()
+            .create(true)
+            .append(true)
+            .open(&self.path)
+    }
+
+    /// 以截断方式打开(从头下载)。
+    pub fn open_truncate(&self) -> io::Result {
+        ensure_parent(&self.path)?;
+        OpenOptions::new()
+            .create(true)
+            .write(true)
+            .truncate(true)
+            .open(&self.path)
+    }
+
+    /// 校验通过后提交:把中间文件 `rename` 到目标路径(同一目录,跨文件系统不会发生)。
+    pub fn commit(mut self, dest: &Path) -> io::Result<()> {
+        ensure_parent(dest)?;
+        fs::rename(&self.path, dest)?;
+        self.committed = true;
+        // rename 后中间文件已不存在,元数据是纯冗余,顺手清掉。
+        let _ = fs::remove_file(self.meta_path());
+        Ok(())
+    }
+
+    /// 主动丢弃中间文件(换源/校验失败)。
+    pub fn discard(&mut self) {
+        remove_family(&self.path);
+    }
+
+    /// 确保中间文件的父目录存在(异步版)。
+    ///
+    /// 单连接路径由 [`Self::open_append`] / [`Self::open_truncate`] 顺手建目录,
+    /// 但**分块路径**是先 `tokio::fs::OpenOptions` 预分配整文件、再并发写段——
+    /// 它不走那两个入口,所以得显式补这一步,否则「下到还不存在的目录」会全部源失败
+    /// (现场故障:`data/java-managed/.cache/` 尚未创建时 6 个候选源瞬间全挂)。
+    pub(crate) async fn ensure_parent_async(&self) -> io::Result<()> {
+        let Some(parent) = self.path.parent() else {
+            return Ok(());
+        };
+        if parent.as_os_str().is_empty() {
+            return Ok(());
+        }
+        tokio::fs::create_dir_all(parent).await
+    }
+
+    /// 保留中间文件(跨会话续传用):`Drop` 时不再清理。
+    pub fn preserve(&mut self) {
+        self.preserved = true;
+    }
+}
+
+impl Drop for PartFile {
+    fn drop(&mut self) {
+        if !self.committed && !self.preserved {
+            remove_family(&self.path);
+        }
+        mark_live(&self.path, false);
+    }
+}
+
+/// 原子写小文件:`.tmp--` → `rename(path)`。
+///
+/// 临时名同样带进程标识:两个进程同时写同一份小文件(健康度、续传元数据)时
+/// 不会互相把对方的临时文件 `rename` 走。
+pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
+    ensure_parent(path)?;
+    let tmp = sibling(path, &format!(".tmp-{}-{}", std::process::id(), next_seq()));
+    {
+        let mut file = File::create(&tmp)?;
+        file.write_all(bytes)?;
+        file.flush()?;
+        file.sync_all()?;
+    }
+    fs::rename(&tmp, path).inspect_err(|_| {
+        let _ = fs::remove_file(&tmp);
+    })
+}
+
+fn ensure_parent(path: &Path) -> io::Result<()> {
+    if let Some(parent) = path.parent() {
+        if !parent.as_os_str().is_empty() {
+            fs::create_dir_all(parent)?;
+        }
+    }
+    Ok(())
+}
diff --git a/crates/core/src/download/backoff.rs b/crates/core/src/download/backoff.rs
new file mode 100644
index 0000000..980f42c
--- /dev/null
+++ b/crates/core/src/download/backoff.rs
@@ -0,0 +1,120 @@
+//! 指数退避 + 抖动。
+//!
+//! 修 HMCL 的坑(analyse/03 §7.6 第 5 点):它的重试循环里没有 sleep,5xx 会变成
+//! 「6 次瞬时连打」,把弱网镜像直接压垮。这里给出 1s / 2s / 4s… 上限 30s,
+//! ±30% 抖动打散多实例/多文件的同步重试风暴。
+//!
+//! 抖动源是自带的 xorshift64*,不引 `rand`(体积 + Win7 依赖面)。
+
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+use serde::{Deserialize, Serialize};
+
+/// 退避策略(可序列化进设置)。
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub struct BackoffPolicy {
+    /// 首次退避(毫秒)。
+    pub base_ms: u64,
+    /// 单次退避上限(毫秒)。
+    pub max_ms: u64,
+    /// 抖动比例(0.0~1.0,0.3 = ±30%)。
+    pub jitter_ratio: f64,
+    /// 每个源的尝试次数上限。
+    pub max_attempts: u32,
+}
+
+impl BackoffPolicy {
+    /// 不重试(一次失败立刻换源)。
+    #[must_use]
+    pub const fn none() -> Self {
+        Self {
+            base_ms: 0,
+            max_ms: 0,
+            jitter_ratio: 0.0,
+            max_attempts: 1,
+        }
+    }
+
+    /// 第 `attempt` 次重试前的**无抖动**等待时间(`attempt` 从 0 起)。
+    #[must_use]
+    pub fn delay(&self, attempt: u32) -> Duration {
+        let factor = 1u64.checked_shl(attempt.min(32)).unwrap_or(u64::MAX).max(1);
+        Duration::from_millis(self.base_ms.saturating_mul(factor).min(self.max_ms))
+    }
+
+    /// 第 `attempt` 次重试前的等待时间,`rng` 为 `[0,1)` 的抖动量(可复现,便于单测)。
+    ///
+    /// 结果永不超过 `max_ms`,且永不低于 `base_ms`(当 `base_ms <= max_ms` 时)。
+    #[must_use]
+    pub fn delay_jittered(&self, attempt: u32, rng: f64) -> Duration {
+        let base_ms = self.delay(attempt).as_millis() as f64;
+        let ratio = self.jitter_ratio.clamp(0.0, 1.0);
+        let factor = 1.0 + ratio * (2.0 * rng.clamp(0.0, 1.0) - 1.0);
+        let ceiling = (self.max_ms as f64).max(base_ms);
+        let jittered_ms = (base_ms * factor).clamp(0.0, ceiling);
+        Duration::from_millis(jittered_ms as u64)
+    }
+
+    /// 用抖动源取一次等待时间。
+    #[must_use]
+    pub fn next_delay(&self, attempt: u32, jitter: &mut Jitter) -> Duration {
+        self.delay_jittered(attempt, jitter.next_fraction())
+    }
+}
+
+impl Default for BackoffPolicy {
+    fn default() -> Self {
+        Self {
+            base_ms: 1_000,
+            max_ms: 30_000,
+            jitter_ratio: 0.3,
+            max_attempts: 3,
+        }
+    }
+}
+
+/// xorshift64* 抖动源:确定性、无外部依赖。
+#[derive(Debug, Clone)]
+pub struct Jitter {
+    state: u64,
+}
+
+impl Jitter {
+    /// 固定种子构造(同种子的序列可复现 → 单测友好)。
+    #[must_use]
+    pub const fn new(seed: u64) -> Self {
+        Self {
+            state: if seed == 0 {
+                0x9E37_79B9_7F4A_7C15
+            } else {
+                seed
+            },
+        }
+    }
+
+    /// 下一个 64 位随机数。
+    pub fn next_u64(&mut self) -> u64 {
+        let mut x = self.state;
+        x ^= x << 13;
+        x ^= x >> 7;
+        x ^= x << 17;
+        self.state = x;
+        x.wrapping_mul(0x2545_F491_4F6C_DD1D)
+    }
+
+    /// 下一个 `[0,1)` 的浮点。
+    pub fn next_fraction(&mut self) -> f64 {
+        // 取高 53 位映射到 [0,1),与 f64 的尾数精度一致。
+        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
+    }
+}
+
+impl Default for Jitter {
+    fn default() -> Self {
+        let seed = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .map(|d| d.as_nanos() as u64)
+            .unwrap_or(0x1234_5678_9ABC_DEF0);
+        Self::new(seed)
+    }
+}
diff --git a/crates/core/src/download/blocking.rs b/crates/core/src/download/blocking.rs
new file mode 100644
index 0000000..1a3c4f2
--- /dev/null
+++ b/crates/core/src/download/blocking.rs
@@ -0,0 +1,55 @@
+//! 同步桥:让**非 async 宿主**驱动异步下载引擎。
+//!
+//! `core::download` 的引擎是异步的(tokio + reqwest),GUI 侧自带运行时;
+//! 但 headless 消费者(`crates/cli` 的 `ull`)不依赖 tokio,也不该为一次安装
+//! 把整个异步运行时依赖树拉进 CLI 的 manifest(D18:CLI 依赖树保持干净)。
+//!
+//! 因此把「建一个短命运行时 + 跑完整段 future」这两件事收在 core 里:
+//!
+//! ```ignore
+//! let outcome = unlucky_core::download::blocking::block_on(async { /* 规划 + 下载 */ });
+//! ```
+//!
+//! 约定:**整段**安装流程包在一次 `block_on` 里(同一个运行时),不要每次下载都建一个——
+//! reqwest 的连接池绑定在创建它的运行时上,跨运行时会退化甚至报「dispatch task is gone」。
+//!
+//! 这是给「单线程 CLI」用的逃生口,不是通用设施:app 层请直接用原生异步。
+
+use std::future::Future;
+
+/// 在一个临时 tokio 多线程运行时上跑完 `future`。
+///
+/// 运行时随调用结束销毁,因此适合「一条命令一次阻塞」的 CLI 生命周期。
+///
+/// # Panics
+///
+/// 运行时建不起来(系统资源耗尽)时 panic:此时进程已无法继续,早点暴露比静默降级好。
+/// 在已有 tokio 运行时上下文里调用同样会 panic(tokio 不允许嵌套 `block_on`)。
+pub fn block_on(future: F) -> F::Output {
+    let runtime = tokio::runtime::Builder::new_multi_thread()
+        .worker_threads(2)
+        .thread_name("ull-blocking")
+        .enable_all()
+        .build()
+        .expect("创建 tokio 运行时失败(内存/线程资源不足)");
+    runtime.block_on(future)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::block_on;
+
+    #[test]
+    fn drives_a_plain_future_to_completion() {
+        assert_eq!(block_on(async { 1 + 1 }), 2);
+    }
+
+    #[test]
+    fn drives_tokio_timers() {
+        let start = std::time::Instant::now();
+        block_on(async {
+            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
+        });
+        assert!(start.elapsed() >= std::time::Duration::from_millis(5));
+    }
+}
diff --git a/crates/core/src/download/bytes.rs b/crates/core/src/download/bytes.rs
new file mode 100644
index 0000000..f191f51
--- /dev/null
+++ b/crates/core/src/download/bytes.rs
@@ -0,0 +1,138 @@
+//! 内存下载:版本清单 / 版本 JSON / 资源索引这类「小文本」资源。
+//!
+//! 与文件下载共用候选源、退避与健康度逻辑,但不续传、不分块——它们要么很小,
+//! 要么强时效(`ETag` 语义比续传更合适)。
+
+use std::time::Instant;
+
+use super::cache::now_ms;
+use super::engine::{millis, DownloadEngine};
+use super::error::{DownloadError, DownloadErrorKind};
+use super::http;
+use super::progress::ProgressEvent;
+use super::types::{Candidate, DownloadId, DownloadRequest};
+use super::verify;
+
+/// 内存下载的上限(manifest 类资源不该有几十 MB;防御恶意镜像把人撑爆)。
+pub const MAX_IN_MEMORY_BYTES: u64 = 64 * 1024 * 1024;
+
+impl DownloadEngine {
+    /// 内存下载(版本清单 / 版本 JSON / 索引等小文本资源)。
+    pub async fn download_bytes(&self, req: DownloadRequest) -> Result, DownloadError> {
+        let started = Instant::now();
+        let id = DownloadId::next();
+        req.validate()?;
+        let plan = self.plan(&req, now_ms());
+        self.announce(&id, &req, plan.len());
+        if plan.is_empty() {
+            return Err(DownloadError::new(
+                DownloadErrorKind::InvalidRequest,
+                format!("没有可用候选源:{}", req.url),
+            ));
+        }
+
+        let _permit = self.acquire().await?;
+        let mut suppressed: Vec = Vec::new();
+
+        for candidate in &plan {
+            // 与文件下载同一套规则:冷却中/刚出冷却的源只给一次探测机会。
+            let max_attempts = self.attempts_for(&candidate.source, req.kind, now_ms());
+            for attempt in 0..max_attempts {
+                match self.fetch_bytes(&req, candidate).await {
+                    Ok(bytes) => {
+                        self.health.record_success(
+                            &candidate.source,
+                            req.kind,
+                            started.elapsed(),
+                            now_ms(),
+                        );
+                        self.emit(ProgressEvent::Completed {
+                            id,
+                            bytes: bytes.len() as u64,
+                            from_cache: false,
+                            source: candidate.source.clone(),
+                            elapsed_ms: millis(started.elapsed()),
+                        });
+                        self.flush_health();
+                        return Ok(bytes);
+                    }
+                    Err(err) => {
+                        self.health.record_failure(
+                            &candidate.source,
+                            req.kind,
+                            &err.message,
+                            now_ms(),
+                        );
+                        let retry = err.kind.is_retryable() && attempt + 1 < max_attempts;
+                        let delay = self.next_delay(attempt);
+                        self.emit(ProgressEvent::SourceFailed {
+                            id,
+                            source: candidate.source.clone(),
+                            reason: err.message.clone(),
+                            attempt,
+                            will_retry: retry,
+                            retry_in_ms: if retry { millis(delay) } else { 0 },
+                        });
+                        if !retry {
+                            suppressed.push(format!("{}: {}", candidate.source, err.message));
+                            break;
+                        }
+                        tokio::time::sleep(delay).await;
+                    }
+                }
+            }
+        }
+
+        self.flush_health();
+        Err(DownloadError::new(
+            DownloadErrorKind::AllSourcesFailed,
+            format!("所有候选源都取不到文本资源:{}", req.url),
+        )
+        .with_suppressed(suppressed))
+    }
+
+    async fn fetch_bytes(
+        &self,
+        req: &DownloadRequest,
+        candidate: &Candidate,
+    ) -> Result, DownloadError> {
+        let response = self
+            .http
+            .get(&candidate.url)
+            .send()
+            .await
+            .map_err(|err| http::classify_reqwest(&err, &candidate.url))?;
+        let status = response.status();
+        if !status.is_success() {
+            return Err(http::classify_status(status.as_u16(), &candidate.url)
+                .with_source(candidate.source.as_str()));
+        }
+        if let Some(len) = http::content_length(response.headers()) {
+            if len > MAX_IN_MEMORY_BYTES {
+                return Err(DownloadError::new(
+                    DownloadErrorKind::InvalidRequest,
+                    format!(
+                        "文本资源过大({len} 字节),拒绝载入内存:{}",
+                        candidate.url
+                    ),
+                )
+                .with_source(candidate.source.as_str()));
+            }
+        }
+        let body = response
+            .bytes()
+            .await
+            .map_err(|err| http::classify_reqwest(&err, &candidate.url))?;
+        if let Some(integrity) = &req.integrity {
+            let actual = verify::digest_bytes(integrity.algo, &body);
+            if !verify::matches(&integrity.hex, &actual) {
+                return Err(DownloadError::new(
+                    DownloadErrorKind::Integrity,
+                    format!("校验不符:期望 {} 实得 {actual}", integrity.hex),
+                )
+                .with_source(candidate.source.as_str()));
+            }
+        }
+        Ok(body.to_vec())
+    }
+}
diff --git a/crates/core/src/download/cache.rs b/crates/core/src/download/cache.rs
new file mode 100644
index 0000000..7676e83
--- /dev/null
+++ b/crates/core/src/download/cache.rs
@@ -0,0 +1,285 @@
+//! 内容寻址缓存:两级分片 + 「记录信任」命中快路径。
+//!
+//! 布局承设计文档 `04 §2` 与 analyse/03 §5.7:`/sha1//`(两级分片,
+//! 避开 NTFS 单目录几十万条目塌陷)。
+//!
+//! 修 HMCL 的坑(analyse/03 §8.2 第 7 点):它每次命中都重算整个文件的 SHA-1,
+//! 3000 个资源对象意味着启动期几百 MB 的无谓读盘。这里写入时记录 `{hash, size, mtime}`,
+//! **此后信任记录**:size/mtime 都没变就直接命中;只有元数据变了才重算一次摘要并刷新记录。
+
+use std::collections::BTreeMap;
+use std::io;
+use std::path::{Path, PathBuf};
+use std::sync::Mutex;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use serde::{Deserialize, Serialize};
+
+use super::atomic::write_atomic;
+use super::types::{HashAlgo, Integrity};
+use super::verify;
+
+/// 索引文件名(放在缓存根下)。
+pub const INDEX_FILE: &str = "index.json";
+/// 索引格式版本。
+pub const INDEX_VERSION: u32 = 1;
+
+/// 一条缓存记录。
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CacheRecord {
+    /// 算法。
+    pub algo: HashAlgo,
+    /// 摘要(小写十六进制)。
+    pub hash: String,
+    /// 字节数。
+    pub size: u64,
+    /// 文件修改时间(unix 毫秒)——记录信任的锚点。
+    pub mtime_ms: u64,
+    /// 写入缓存时间。
+    pub cached_at_ms: u64,
+    /// 最近一次真正校验的时间(0 = 从未校验,走记录信任)。
+    pub last_verified_ms: u64,
+}
+
+/// 缓存统计(诊断页)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct CacheStats {
+    /// 条目数。
+    pub entries: usize,
+    /// 总字节数。
+    pub total_bytes: u64,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct CacheIndex {
+    version: u32,
+    #[serde(default)]
+    entries: BTreeMap,
+}
+
+impl Default for CacheIndex {
+    fn default() -> Self {
+        Self {
+            version: INDEX_VERSION,
+            entries: BTreeMap::new(),
+        }
+    }
+}
+
+/// 内容寻址缓存。
+#[derive(Debug)]
+pub struct ContentCache {
+    root: PathBuf,
+    index: Mutex,
+}
+
+impl ContentCache {
+    /// 打开(或初始化)缓存根目录;索引损坏时从空表重建(缓存随时可丢,不阻断启动)。
+    #[must_use]
+    pub fn open(root: impl Into) -> Self {
+        let root = root.into();
+        let index_path = root.join(INDEX_FILE);
+        let index = std::fs::read(&index_path)
+            .ok()
+            .and_then(|bytes| serde_json::from_slice::(&bytes).ok())
+            .unwrap_or_default();
+        Self {
+            root,
+            index: Mutex::new(index),
+        }
+    }
+
+    /// 缓存根目录。
+    #[must_use]
+    pub fn root(&self) -> &Path {
+        &self.root
+    }
+
+    /// 某个摘要对应的缓存路径(两级分片)。
+    #[must_use]
+    pub fn path_for(&self, algo: HashAlgo, hash: &str) -> PathBuf {
+        let shard = hash.get(..2).unwrap_or(hash);
+        self.root.join(algo.as_str()).join(shard).join(hash)
+    }
+
+    fn lock(&self) -> std::sync::MutexGuard<'_, CacheIndex> {
+        self.index
+            .lock()
+            .unwrap_or_else(|poisoned| poisoned.into_inner())
+    }
+
+    fn key(algo: HashAlgo, hash: &str) -> String {
+        format!("{}:{hash}", algo.as_str())
+    }
+
+    /// 命中查询(记录信任:size + mtime 未变则**不重算**摘要)。
+    #[must_use]
+    pub fn lookup(&self, integrity: &Integrity, now_ms: u64) -> Option {
+        let path = self.path_for(integrity.algo, &integrity.hex);
+        let record = self
+            .lock()
+            .entries
+            .get(&Self::key(integrity.algo, &integrity.hex))
+            .cloned()?;
+        let Ok(meta) = std::fs::metadata(&path) else {
+            // 文件被删:记录作废,避免每次命中都白查一次磁盘。
+            self.forget(integrity);
+            return None;
+        };
+        if !meta.is_file() || meta.len() != record.size {
+            // 文件被删/被截断:记录作废。
+            self.forget(integrity);
+            return None;
+        }
+        let mtime_ms = mtime_ms(&meta);
+        if mtime_ms == record.mtime_ms {
+            return Some(path);
+        }
+        // 元数据变了(外部动过文件):重算一次摘要再决定。
+        let Ok(actual) = verify::digest_file(&path, integrity.algo) else {
+            self.forget(integrity);
+            return None;
+        };
+        if !verify::matches(&integrity.hex, &actual) {
+            self.forget(integrity);
+            return None;
+        }
+        self.refresh_record(integrity, &meta, now_ms);
+        Some(path)
+    }
+
+    /// 强制校验的命中查询(抽样/定期后台校验用,慢但可信)。
+    #[must_use]
+    pub fn lookup_verified(&self, integrity: &Integrity, now_ms: u64) -> Option {
+        let path = self.path_for(integrity.algo, &integrity.hex);
+        let meta = std::fs::metadata(&path).ok()?;
+        if !meta.is_file() {
+            self.forget(integrity);
+            return None;
+        }
+        let actual = verify::digest_file(&path, integrity.algo).ok()?;
+        if !verify::matches(&integrity.hex, &actual) {
+            self.forget(integrity);
+            return None;
+        }
+        self.refresh_record(integrity, &meta, now_ms);
+        Some(path)
+    }
+
+    /// 把已校验通过的文件放进缓存(硬链接优先,跨卷退化为复制)。
+    pub fn insert(&self, source: &Path, integrity: &Integrity, now_ms: u64) -> io::Result {
+        let target = self.path_for(integrity.algo, &integrity.hex);
+        if let Some(parent) = target.parent() {
+            std::fs::create_dir_all(parent)?;
+        }
+        if !target.is_file() {
+            link_or_copy(source, &target)?;
+        }
+        self.register(&target, integrity, now_ms)?;
+        Ok(target)
+    }
+
+    /// 登记一个已在缓存位的文件(`assets` 已在游戏目录里的补登记场景)。
+    pub fn register(&self, path: &Path, integrity: &Integrity, now_ms: u64) -> io::Result<()> {
+        let meta = std::fs::metadata(path)?;
+        let record = CacheRecord {
+            algo: integrity.algo,
+            hash: integrity.hex.clone(),
+            size: meta.len(),
+            mtime_ms: mtime_ms(&meta),
+            cached_at_ms: now_ms,
+            last_verified_ms: now_ms,
+        };
+        self.lock()
+            .entries
+            .insert(Self::key(integrity.algo, &integrity.hex), record);
+        Ok(())
+    }
+
+    /// 删除一条缓存(含文件)。
+    pub fn remove(&self, integrity: &Integrity) -> io::Result<()> {
+        let path = self.path_for(integrity.algo, &integrity.hex);
+        self.forget(integrity);
+        match std::fs::remove_file(&path) {
+            Ok(()) => Ok(()),
+            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
+            Err(err) => Err(err),
+        }
+    }
+
+    fn forget(&self, integrity: &Integrity) {
+        self.lock()
+            .entries
+            .remove(&Self::key(integrity.algo, &integrity.hex));
+    }
+
+    fn refresh_record(&self, integrity: &Integrity, meta: &std::fs::Metadata, now_ms: u64) {
+        if let Some(record) = self
+            .lock()
+            .entries
+            .get_mut(&Self::key(integrity.algo, &integrity.hex))
+        {
+            record.size = meta.len();
+            record.mtime_ms = mtime_ms(meta);
+            record.last_verified_ms = now_ms;
+        }
+    }
+
+    /// 统计。
+    #[must_use]
+    pub fn stats(&self) -> CacheStats {
+        let index = self.lock();
+        CacheStats {
+            entries: index.entries.len(),
+            total_bytes: index.entries.values().map(|record| record.size).sum(),
+        }
+    }
+
+    /// 索引条目数(`stats` 的便捷版本)。
+    #[must_use]
+    pub fn len(&self) -> usize {
+        self.lock().entries.len()
+    }
+
+    /// 索引是否为空。
+    #[must_use]
+    pub fn is_empty(&self) -> bool {
+        self.len() == 0
+    }
+
+    /// 原子持久化索引(`cache/index.json`)。
+    pub fn persist(&self) -> io::Result<()> {
+        let json = {
+            let index = self.lock();
+            serde_json::to_vec_pretty(&*index).map_err(io::Error::other)?
+        };
+        write_atomic(&self.root.join(INDEX_FILE), &json)
+    }
+}
+
+/// 硬链接优先,失败退化为复制(跨卷/权限不足时)。
+fn link_or_copy(source: &Path, target: &Path) -> io::Result<()> {
+    match std::fs::hard_link(source, target) {
+        Ok(()) => Ok(()),
+        Err(_) => std::fs::copy(source, target).map(|_| ()),
+    }
+}
+
+fn mtime_ms(meta: &std::fs::Metadata) -> u64 {
+    meta.modified()
+        .ok()
+        .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
+        .map_or(0, |since| {
+            u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
+        })
+}
+
+/// 当前 unix 毫秒(引擎统一取一次再往下传,避免测试里到处读时钟)。
+#[must_use]
+pub fn now_ms() -> u64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map_or(0, |since| {
+            u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
+        })
+}
diff --git a/crates/core/src/download/chunked.rs b/crates/core/src/download/chunked.rs
new file mode 100644
index 0000000..61853e3
--- /dev/null
+++ b/crates/core/src/download/chunked.rs
@@ -0,0 +1,296 @@
+//! 大文件多连接分块下载:分块规划、`Content-Range` 解析、Range 能力判定与单块抓取。
+//!
+//! HMCL 只做「单文件单连接 + 全局并发闸门」(analyse/03 §5.5),大 jar 会抢占许可并形成
+//! 头阻塞。这里对「大文件 + 支持 Range」的资源开 N 条连接并行取段(默认 N=4),
+//! 每条连接写进同一个**预分配**中间文件的不同偏移;服务器不支持 Range(返回 200 而不是 206)
+//! 时由引擎自动退化为单连接。
+
+use std::path::Path;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::Duration;
+
+use super::error::{DownloadError, DownloadErrorKind};
+use super::http;
+use super::types::ResourceKind;
+
+/// 默认分块连接数(设计文档 Q19 定案 N=4)。
+pub const DEFAULT_CONNECTIONS: usize = 4;
+/// 分块连接数上限(再多收益递减且容易被镜像限流)。
+pub const MAX_CONNECTIONS: usize = 16;
+/// 默认开启分块的最小文件大小(8 MiB)。
+pub const DEFAULT_MIN_SIZE: u64 = 8 * 1024 * 1024;
+
+/// 分块下载配置。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ChunkedConfig {
+    /// 总开关(设置项)。
+    pub enabled: bool,
+    /// 并行连接数。
+    pub connections: usize,
+    /// 小于该大小不做分块。
+    pub min_size: u64,
+}
+
+impl Default for ChunkedConfig {
+    fn default() -> Self {
+        Self {
+            enabled: true,
+            connections: DEFAULT_CONNECTIONS,
+            min_size: DEFAULT_MIN_SIZE,
+        }
+    }
+}
+
+impl ChunkedConfig {
+    /// 关闭分块。
+    #[must_use]
+    pub const fn disabled() -> Self {
+        Self {
+            enabled: false,
+            connections: 1,
+            min_size: DEFAULT_MIN_SIZE,
+        }
+    }
+
+    /// 该资源当前是否走分块。
+    #[must_use]
+    pub fn applies_to(&self, kind: ResourceKind, size: Option) -> bool {
+        self.enabled
+            && self.connections > 1
+            && kind.prefers_chunked()
+            && size.is_some_and(|size| size >= self.min_size)
+    }
+
+    /// 实际生效的连接数(夹在 `1..=MAX_CONNECTIONS`)。
+    #[must_use]
+    pub fn effective_connections(&self) -> usize {
+        self.connections.clamp(1, MAX_CONNECTIONS)
+    }
+}
+
+/// 闭区间字节段(HTTP Range 的语义是闭区间,这里保持一致免得 +1/-1 到处飞)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ByteRange {
+    /// 起始偏移(含)。
+    pub start: u64,
+    /// 结束偏移(**含**)。
+    pub end: u64,
+}
+
+impl ByteRange {
+    /// 构造。
+    #[must_use]
+    pub const fn new(start: u64, end: u64) -> Self {
+        Self { start, end }
+    }
+
+    /// 段长度(字节)。
+    #[must_use]
+    pub const fn len(&self) -> u64 {
+        if self.end < self.start {
+            0
+        } else {
+            self.end - self.start + 1
+        }
+    }
+
+    /// 空段。
+    #[must_use]
+    pub const fn is_empty(&self) -> bool {
+        self.end < self.start
+    }
+
+    /// `Range` 头的值。
+    #[must_use]
+    pub fn header_value(&self) -> String {
+        format!("bytes={}-{}", self.start, self.end)
+    }
+}
+
+/// 把 `total` 均分成不超过 `connections` 段(纯函数,单测重点)。
+///
+/// 保证:段序列连续、无重叠、并集恰为 `[0, total)`;段数 = `min(connections, total)`。
+#[must_use]
+pub fn plan(total: u64, connections: usize) -> Vec {
+    if total == 0 {
+        return Vec::new();
+    }
+    let parts = connections.clamp(1, MAX_CONNECTIONS) as u64;
+    let count = parts.min(total);
+    let base = total / count;
+    let remainder = total % count;
+
+    let mut ranges = Vec::with_capacity(count as usize);
+    let mut offset = 0_u64;
+    for index in 0..count {
+        // 余数摊到前几段,避免最后一段特别大。
+        let extra = u64::from(index < remainder);
+        let len = base + extra;
+        if len == 0 {
+            continue;
+        }
+        ranges.push(ByteRange::new(offset, offset + len - 1));
+        offset += len;
+    }
+    ranges
+}
+
+/// `Content-Range: bytes 0-99/1000` 的解析结果。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ContentRange {
+    /// 起始。
+    pub start: u64,
+    /// 结束(含)。
+    pub end: u64,
+    /// 总长度(`*` 时为 `None`)。
+    pub total: Option,
+}
+
+/// 解析 `Content-Range`(畸形一律 `None`,调用方退化处理)。
+#[must_use]
+pub fn parse_content_range(value: &str) -> Option {
+    let rest = value.trim().strip_prefix("bytes")?.trim_start();
+    let (range, total) = rest.split_once('/')?;
+    let (start, end) = range.split_once('-')?;
+    let start: u64 = start.trim().parse().ok()?;
+    let end: u64 = end.trim().parse().ok()?;
+    if end < start {
+        return None;
+    }
+    let total = match total.trim() {
+        "*" => None,
+        other => Some(other.parse().ok()?),
+    };
+    Some(ContentRange { start, end, total })
+}
+
+/// 服务器对 Range 的支持度。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RangeSupport {
+    /// 支持(`Accept-Ranges: bytes` 或正常返回 206)。
+    Supported,
+    /// 明确不支持。
+    Unsupported,
+    /// 未知(既没头也没探测)。
+    Unknown,
+}
+
+/// 判定 Range 支持度。
+#[must_use]
+pub fn range_support(accept_ranges: Option<&str>, status: u16) -> RangeSupport {
+    if status == 206 {
+        return RangeSupport::Supported;
+    }
+    match accept_ranges {
+        Some(value) if value.to_ascii_lowercase().contains("bytes") => RangeSupport::Supported,
+        Some(value) if value.to_ascii_lowercase().contains("none") => RangeSupport::Unsupported,
+        Some(_) => RangeSupport::Unsupported,
+        None => RangeSupport::Unknown,
+    }
+}
+
+/// 206 响应的 `Content-Range` 是否与请求段一致。
+///
+/// * 缺头 → 宽松放过(个别服务器省略该头,后面的长度校验仍能兜底);
+/// * 有头 → 起止必须严格等于请求段,畸形头一律判不符。
+///
+/// 这条防御与单连接路径(`transfer.rs` 里比对 `content_range.start != offset`)对齐:
+/// 只在「长度恰好」时坏文件才会溜过长度校验,起止比对把这条路堵死。
+#[must_use]
+pub fn content_range_matches(requested: ByteRange, value: Option<&str>) -> bool {
+    let Some(value) = value else {
+        return true;
+    };
+    parse_content_range(value)
+        .is_some_and(|parsed| parsed.start == requested.start && parsed.end == requested.end)
+}
+
+/// 抓一个段,写到预分配文件的 `range.start` 偏移处,并把字节数累加进 `progress`。
+///
+/// 服务器若对带 `Range` 的请求返回 200(无视 Range),立刻返回 `RangeUnsupported`
+/// —— 引擎据此退化单连接,而不是把整份文件重复写 N 遍。
+pub async fn fetch_chunk(
+    client: &reqwest::Client,
+    url: &str,
+    range: ByteRange,
+    part: &Path,
+    read_timeout: Duration,
+    progress: &AtomicU64,
+) -> Result {
+    use tokio::io::{AsyncSeekExt, AsyncWriteExt};
+
+    let request = client
+        .get(url)
+        .header(reqwest::header::RANGE, range.header_value());
+    let response = request
+        .send()
+        .await
+        .map_err(|err| http::classify_reqwest(&err, url))?;
+
+    let status = response.status().as_u16();
+    if status == 200 {
+        return Err(DownloadError::new(
+            DownloadErrorKind::RangeUnsupported,
+            format!("服务器忽略 Range 请求({url})"),
+        ));
+    }
+    if status != 206 {
+        return Err(http::classify_status(status, url));
+    }
+    // 服务器可能「回 206 但段不对」(CDN 缓存错位、Range 实现有 bug)。
+    // 判不符时按 RangeUnsupported 处理:引擎会丢弃中间文件、退化单连接重下,
+    // 这比在同一条坏路径上重试更划算。
+    let content_range = http::header_str(response.headers(), reqwest::header::CONTENT_RANGE);
+    if !content_range_matches(range, content_range) {
+        return Err(DownloadError::new(
+            DownloadErrorKind::RangeUnsupported,
+            format!(
+                "Content-Range 与请求段不符({url}):请求 {},响应 {}",
+                range.header_value(),
+                content_range.unwrap_or("<缺失>")
+            ),
+        ));
+    }
+
+    let mut file = tokio::fs::OpenOptions::new()
+        .write(true)
+        .open(part)
+        .await
+        .map_err(|err| io_error(&err, "打开分块中间文件失败"))?;
+    file.seek(std::io::SeekFrom::Start(range.start))
+        .await
+        .map_err(|err| io_error(&err, "定位分块偏移失败"))?;
+
+    let mut stream = response.bytes_stream();
+    let mut written = 0_u64;
+    loop {
+        let next = tokio::time::timeout(read_timeout, futures_util::StreamExt::next(&mut stream))
+            .await
+            .map_err(|_| {
+                DownloadError::new(DownloadErrorKind::Timeout, format!("分块读取超时({url})"))
+            })?;
+        let Some(chunk) = next else { break };
+        let chunk = chunk.map_err(|err| http::classify_reqwest(&err, url))?;
+        file.write_all(&chunk)
+            .await
+            .map_err(|err| io_error(&err, "写入分块失败"))?;
+        let len = chunk.len() as u64;
+        written += len;
+        progress.fetch_add(len, Ordering::Relaxed);
+    }
+    file.flush()
+        .await
+        .map_err(|err| io_error(&err, "刷新分块失败"))?;
+
+    if written != range.len() {
+        return Err(DownloadError::new(
+            DownloadErrorKind::Network,
+            format!("分块长度不符:期望 {} 实得 {written}({url})", range.len()),
+        ));
+    }
+    Ok(written)
+}
+
+fn io_error(err: &std::io::Error, context: &str) -> DownloadError {
+    DownloadError::new(DownloadErrorKind::Io, format!("{context}: {err}"))
+}
diff --git a/crates/core/src/download/config.rs b/crates/core/src/download/config.rs
new file mode 100644
index 0000000..8fcf892
--- /dev/null
+++ b/crates/core/src/download/config.rs
@@ -0,0 +1,46 @@
+//! 引擎配置:设置层(core::setting)直接映射到这里。
+
+use std::time::Duration;
+
+use super::backoff::BackoffPolicy;
+use super::chunked::ChunkedConfig;
+use super::types::ResumePolicy;
+
+/// 引擎配置。
+#[derive(Debug, Clone)]
+pub struct EngineConfig {
+    /// 全局并发许可数(tokio `Semaphore`),默认 64。
+    pub max_concurrency: usize,
+    /// 每个源的尝试次数上限,默认 3。
+    pub max_attempts_per_source: u32,
+    /// 连接超时,默认 10s。
+    pub connect_timeout: Duration,
+    /// 单次读超时(读空闲判定,不是整体超时),默认 30s。
+    pub read_timeout: Duration,
+    /// User-Agent。
+    pub user_agent: String,
+    /// 进度事件节流间隔,默认 250ms。
+    pub progress_throttle: Duration,
+    /// 退避策略。
+    pub backoff: BackoffPolicy,
+    /// 分块下载配置。
+    pub chunked: ChunkedConfig,
+    /// 默认续传策略(请求级 `Never` 优先级更高)。
+    pub resume: ResumePolicy,
+}
+
+impl Default for EngineConfig {
+    fn default() -> Self {
+        Self {
+            max_concurrency: 64,
+            max_attempts_per_source: 3,
+            connect_timeout: Duration::from_secs(10),
+            read_timeout: Duration::from_secs(30),
+            user_agent: concat!("UnluckyLauncher/", env!("CARGO_PKG_VERSION")).to_string(),
+            progress_throttle: Duration::from_millis(250),
+            backoff: BackoffPolicy::default(),
+            chunked: ChunkedConfig::default(),
+            resume: ResumePolicy::Conservative,
+        }
+    }
+}
diff --git a/crates/core/src/download/engine.rs b/crates/core/src/download/engine.rs
new file mode 100644
index 0000000..1c73895
--- /dev/null
+++ b/crates/core/src/download/engine.rs
@@ -0,0 +1,397 @@
+//! 下载引擎:编排层。
+//!
+//! 一次 `download()` 的固定剧情:
+//!
+//! ```text
+//! 校验请求 → 内容寻址缓存直取(零网络)
+//!          → 候选规划(镜像表展开 + 健康度排序)
+//!          → Semaphore 限流
+//!          → 候选源外循环(换源),每源内循环(指数退避 + 抖动)
+//!              → 续传判定(保守)→ 单连接 / 多连接分块 → 流式校验 → 原子落盘
+//!          → 失败则保留中间文件供下次续传
+//! ```
+//!
+//! 最后一步保留的是**本会话命名的**中间文件(`.ullpart--`,不再固定
+//! 叫 `.ullpart`):下次(甚至下次启动、重启后再下同一个文件)由 `PartFile::for_dest`
+//! 在目标目录里认领它并改名成新的会话名,续传判定照旧。详细身份规则见 `atomic.rs` 模块头的
+//! 「中间文件的身份」。
+//!
+//! 具体传输细节在 `transfer.rs`,错误分类在 `http.rs`,本文件只负责顺序与策略。
+
+use std::path::Path;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use tokio::sync::Semaphore;
+
+use super::atomic::PartFile;
+use super::backoff::Jitter;
+use super::cache::{now_ms, ContentCache};
+use super::config::EngineConfig;
+use super::error::{DownloadError, DownloadErrorKind};
+use super::health::HealthRegistry;
+use super::http;
+use super::mirrors::MirrorTable;
+use super::progress::{ProgressEmitter, ProgressEvent};
+use super::region::RegionProfile;
+use super::types::{
+    Candidate, DownloadId, DownloadOutcome, DownloadRequest, ResourceKind, ResumePolicy,
+    SOURCE_OFFICIAL,
+};
+
+/// 一次成功尝试的产物描述。
+pub(crate) struct AttemptOk {
+    /// 文件总字节数。
+    pub bytes: u64,
+    /// 续传起点(0 = 从头下)。
+    pub resumed_from: u64,
+}
+
+/// 下载引擎(`Arc` 后全局共享;内部无状态以外的可变数据只有抖动源)。
+#[derive(Debug)]
+pub struct DownloadEngine {
+    pub(crate) http: reqwest::Client,
+    pub(crate) cfg: EngineConfig,
+    pub(crate) progress: ProgressEmitter,
+    pub(crate) health: Arc,
+    pub(crate) cache: Arc,
+    pub(crate) jitter: std::sync::Mutex,
+    mirrors: Arc,
+    region: Arc,
+    sem: Arc,
+}
+
+impl DownloadEngine {
+    /// 构造引擎(用 `with_progress` 挂事件流)。
+    #[must_use]
+    pub fn new(
+        cfg: EngineConfig,
+        mirrors: Arc,
+        region: Arc,
+        health: Arc,
+        cache: Arc,
+    ) -> Self {
+        let http = http::build_client(&cfg).unwrap_or_else(|_| reqwest::Client::new());
+        let sem = Arc::new(Semaphore::new(cfg.max_concurrency.max(1)));
+        let seed = now_ms() ^ (u64::from(cfg.max_concurrency as u32) << 32);
+        Self {
+            http,
+            cfg,
+            progress: ProgressEmitter::silent(),
+            health,
+            cache,
+            jitter: std::sync::Mutex::new(Jitter::new(seed)),
+            mirrors,
+            region,
+            sem,
+        }
+    }
+
+    /// 挂上进度事件发射器。
+    #[must_use]
+    pub fn with_progress(mut self, progress: ProgressEmitter) -> Self {
+        self.progress = progress;
+        self
+    }
+
+    /// 配置。
+    #[must_use]
+    pub fn config(&self) -> &EngineConfig {
+        &self.cfg
+    }
+
+    /// HTTP 客户端(同一条连接池与超时配置)。
+    ///
+    /// 给「一锤子查询」用——例如 Adoptium 的版本查询(`core::game::java::api`):
+    /// 没必要为了一个 GET 再造一个 client,代理/超时/证书栈都在这里统一。
+    #[must_use]
+    pub fn client(&self) -> &reqwest::Client {
+        &self.http
+    }
+
+    /// 当前地区档案(设置页展示)。
+    #[must_use]
+    pub fn region(&self) -> &RegionProfile {
+        &self.region
+    }
+
+    /// 镜像表(`game::java` 的 Oracle / MS / Zulu 分发表在这里查)。
+    #[must_use]
+    pub fn mirrors(&self) -> &MirrorTable {
+        &self.mirrors
+    }
+
+    /// 健康度注册表(诊断页)。
+    #[must_use]
+    pub fn health(&self) -> &HealthRegistry {
+        &self.health
+    }
+
+    /// 内容寻址缓存。
+    #[must_use]
+    pub fn cache(&self) -> &ContentCache {
+        &self.cache
+    }
+
+    /// 候选规划:镜像展开 → 官方位置保持不变 → 其余按健康度排序。
+    #[must_use]
+    pub fn plan(&self, req: &DownloadRequest, now_ms: u64) -> Vec {
+        let expanded = self.mirrors.expand(&req.url, req.kind, &self.region);
+        let official_first = expanded
+            .first()
+            .is_none_or(|candidate| candidate.source == SOURCE_OFFICIAL);
+        let mut official = Vec::new();
+        let mut mirrors = Vec::new();
+        for candidate in expanded {
+            if candidate.source == SOURCE_OFFICIAL {
+                official.push(candidate);
+            } else {
+                mirrors.push(candidate);
+            }
+        }
+        let ranked = self.health.rank(&mirrors, req.kind, now_ms);
+        if official_first {
+            official.extend(ranked);
+            official
+        } else {
+            let mut out = ranked;
+            out.extend(official);
+            out
+        }
+    }
+
+    /// 单文件下载。
+    pub async fn download(
+        &self,
+        mut req: DownloadRequest,
+    ) -> Result {
+        let started = Instant::now();
+        let id = DownloadId::next();
+        req.validate()?;
+        if self.cfg.resume == ResumePolicy::Never {
+            req.resume = ResumePolicy::Never;
+        }
+
+        if let Some(integrity) = req.integrity.clone() {
+            if let Some(cached) = self.cache.lookup(&integrity, now_ms()) {
+                let bytes = materialize(&cached, &req.dest).map_err(|err| {
+                    DownloadError::new(
+                        DownloadErrorKind::Io,
+                        format!("缓存直取失败({}):{err}", req.dest.display()),
+                    )
+                })?;
+                self.emit(ProgressEvent::Completed {
+                    id,
+                    bytes,
+                    from_cache: true,
+                    source: "cache".to_string(),
+                    elapsed_ms: millis(started.elapsed()),
+                });
+                return Ok(DownloadOutcome {
+                    id,
+                    path: req.dest,
+                    bytes,
+                    from_cache: true,
+                    source: "cache".to_string(),
+                    resumed_from: 0,
+                    elapsed: started.elapsed(),
+                });
+            }
+        }
+
+        let plan = self.plan(&req, now_ms());
+        self.announce(&id, &req, plan.len());
+        if plan.is_empty() {
+            return Err(DownloadError::new(
+                DownloadErrorKind::InvalidRequest,
+                format!("没有可用候选源:{}", req.url),
+            ));
+        }
+
+        let _permit = self.acquire().await?;
+        let mut part = PartFile::for_dest(&req.dest);
+        let mut suppressed: Vec = Vec::new();
+        let mut previous: Option = None;
+
+        for candidate in &plan {
+            if let Some(from) = previous.take() {
+                self.emit(ProgressEvent::SourceSwitched {
+                    id,
+                    from,
+                    to: candidate.source.clone(),
+                });
+            }
+            // 冷却中 / 刚出冷却的源只给一次机会(那就是「恢复探测」)。
+            let max_attempts = self.attempts_for(&candidate.source, req.kind, now_ms());
+
+            for attempt in 0..max_attempts {
+                let attempt_started = Instant::now();
+                let outcome = self.attempt(id, &req, candidate, &mut part).await;
+                match outcome {
+                    Ok(ok) => {
+                        self.health.record_success(
+                            &candidate.source,
+                            req.kind,
+                            attempt_started.elapsed(),
+                            now_ms(),
+                        );
+                        let outcome = self.finish(&req, part, id, candidate, ok, started)?;
+                        self.flush_health();
+                        return Ok(outcome);
+                    }
+                    Err(err) => {
+                        // 4xx(NotFound)不算「源坏」:院校镜像只留最新补丁,请求旧补丁必然 404,
+                        // 记成失败会把唯一可用镜像冷却掉(M33/M34 实测:TUNA 每个目录只保 1 个归档)。
+                        if err.kind == DownloadErrorKind::NotFound {
+                            self.health.record_missing(
+                                &candidate.source,
+                                req.kind,
+                                &err.message,
+                                now_ms(),
+                            );
+                        } else {
+                            self.health.record_failure(
+                                &candidate.source,
+                                req.kind,
+                                &err.message,
+                                now_ms(),
+                            );
+                        }
+                        let retry = err.kind.is_retryable() && attempt + 1 < max_attempts;
+                        let delay = self.next_delay(attempt);
+                        self.emit(ProgressEvent::SourceFailed {
+                            id,
+                            source: candidate.source.clone(),
+                            reason: err.message.clone(),
+                            attempt,
+                            will_retry: retry,
+                            retry_in_ms: if retry { millis(delay) } else { 0 },
+                        });
+                        if !retry {
+                            suppressed.push(format!("{}: {}", candidate.source, err.message));
+                            break;
+                        }
+                        tokio::time::sleep(delay).await;
+                    }
+                }
+            }
+            previous = Some(candidate.source.clone());
+        }
+
+        // 全部候选失败:保留中间文件与元数据,下次(甚至下次启动)可续传。
+        part.preserve();
+        self.flush_health();
+        Err(DownloadError::new(
+            DownloadErrorKind::AllSourcesFailed,
+            format!("{} 个候选源全部失败:{}", plan.len(), req.url),
+        )
+        .with_suppressed(suppressed))
+    }
+
+    fn finish(
+        &self,
+        req: &DownloadRequest,
+        part: PartFile,
+        id: DownloadId,
+        candidate: &Candidate,
+        ok: AttemptOk,
+        started: Instant,
+    ) -> Result {
+        if let Some(integrity) = &req.integrity {
+            // 缓存写入失败不影响交付(缓存纯属加速)。
+            let _ = self.cache.insert(part.path(), integrity, now_ms());
+            let _ = self.cache.persist();
+        }
+        part.commit(&req.dest).map_err(|err| {
+            DownloadError::new(
+                DownloadErrorKind::Io,
+                format!("原子落盘失败({}):{err}", req.dest.display()),
+            )
+        })?;
+        let elapsed = started.elapsed();
+        self.emit(ProgressEvent::Completed {
+            id,
+            bytes: ok.bytes,
+            from_cache: false,
+            source: candidate.source.clone(),
+            elapsed_ms: millis(elapsed),
+        });
+        Ok(DownloadOutcome {
+            id,
+            path: req.dest.clone(),
+            bytes: ok.bytes,
+            from_cache: false,
+            source: candidate.source.clone(),
+            resumed_from: ok.resumed_from,
+            elapsed,
+        })
+    }
+
+    pub(crate) fn announce(&self, id: &DownloadId, req: &DownloadRequest, candidates: usize) {
+        self.emit(ProgressEvent::Started {
+            id: *id,
+            kind: req.kind,
+            url: req.url.clone(),
+            dest: req.dest.display().to_string(),
+            candidates,
+            total_bytes: req.expected_size,
+        });
+    }
+
+    pub(crate) async fn acquire(&self) -> Result {
+        Arc::clone(&self.sem).acquire_owned().await.map_err(|_| {
+            DownloadError::new(DownloadErrorKind::Io, "并发闸门已关闭(引擎正在销毁)")
+        })
+    }
+
+    /// 该源此刻给几次尝试:
+    ///
+    /// * 冷却中 → 1 次(这次就是「恢复探测」,失败会重新冷却);
+    /// * 刚出冷却、还没洗白 → 也只给 1 次(`is_probe()`,别让死源每次到期都吃满 3 次);
+    /// * 正常 → 配置的 `max_attempts_per_source`。
+    pub(crate) fn attempts_for(&self, source: &str, kind: ResourceKind, now_ms: u64) -> u32 {
+        let health = self.health.health_of(source, kind);
+        if health.is_cooling_down(now_ms) || health.is_probe(now_ms) {
+            1
+        } else {
+            self.cfg.max_attempts_per_source.max(1)
+        }
+    }
+
+    /// 一次下载收尾时把健康度节流落盘(对齐 `cache.persist()` 的位置,但带节流)。
+    pub(crate) fn flush_health(&self) {
+        let _ = self.health.flush(now_ms());
+    }
+
+    pub(crate) fn next_delay(&self, attempt: u32) -> Duration {
+        let mut jitter = self
+            .jitter
+            .lock()
+            .unwrap_or_else(|poisoned| poisoned.into_inner());
+        self.cfg.backoff.next_delay(attempt, &mut jitter)
+    }
+
+    pub(crate) fn emit(&self, event: ProgressEvent) {
+        self.progress.emit(event);
+    }
+}
+
+/// 缓存命中时把文件变成工作副本(硬链接优先,跨卷退化为复制)。
+fn materialize(source: &Path, dest: &Path) -> std::io::Result {
+    if let Some(parent) = dest.parent() {
+        if !parent.as_os_str().is_empty() {
+            std::fs::create_dir_all(parent)?;
+        }
+    }
+    if dest.exists() {
+        let _ = std::fs::remove_file(dest);
+    }
+    if std::fs::hard_link(source, dest).is_err() {
+        std::fs::copy(source, dest)?;
+    }
+    Ok(std::fs::metadata(dest)?.len())
+}
+
+pub(crate) fn millis(duration: Duration) -> u64 {
+    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
+}
diff --git a/crates/core/src/download/error.rs b/crates/core/src/download/error.rs
new file mode 100644
index 0000000..ed5f6cc
--- /dev/null
+++ b/crates/core/src/download/error.rs
@@ -0,0 +1,125 @@
+//! 下载错误:三态分类(同源重试 / 立刻换源 / 整体失败)+ 失败摘要。
+//!
+//! core 不引 `thiserror`(P0 阶段少一个依赖),手写 `Display`/`Error` 足够。
+
+use std::fmt;
+
+use serde::{Deserialize, Serialize};
+
+/// 错误分类:决定「同源重试」「立刻换源」还是「整体失败」。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum DownloadErrorKind {
+    /// 请求本身不合法(不重试)。
+    InvalidRequest,
+    /// 404/410/403:该源没有这个资源,立刻换源。
+    NotFound,
+    /// 5xx / 429:同源退避重试。
+    ServerError,
+    /// 超时:同源退避重试,多次后换源。
+    Timeout,
+    /// 连接/传输层错误。
+    Network,
+    /// 校验不符:同源重试一次后换源(可能是镜像内容不一致)。
+    Integrity,
+    /// 服务器不支持 Range(内部用于退化单连接,不向上暴露为失败)。
+    RangeUnsupported,
+    /// 本地 IO 错误(不重试)。
+    Io,
+    /// 所有候选源都失败。
+    AllSourcesFailed,
+}
+
+impl DownloadErrorKind {
+    /// 稳定字符串 id。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::InvalidRequest => "invalid_request",
+            Self::NotFound => "not_found",
+            Self::ServerError => "server_error",
+            Self::Timeout => "timeout",
+            Self::Network => "network",
+            Self::Integrity => "integrity",
+            Self::RangeUnsupported => "range_unsupported",
+            Self::Io => "io",
+            Self::AllSourcesFailed => "all_sources_failed",
+        }
+    }
+
+    /// 是否值得在同源上退避重试。
+    #[must_use]
+    pub const fn is_retryable(self) -> bool {
+        matches!(
+            self,
+            Self::ServerError | Self::Timeout | Self::Network | Self::Integrity
+        )
+    }
+}
+
+/// 下载错误。
+#[derive(Debug, Clone)]
+pub struct DownloadError {
+    /// 分类。
+    pub kind: DownloadErrorKind,
+    /// 出错的源(整体失败时为 `None`)。
+    pub source: Option,
+    /// 人类可读描述。
+    pub message: String,
+    /// 已经尝试的次数(含本次)。
+    pub attempts: usize,
+    /// 其它候选源的失败摘要,便于诊断「到底哪一层挂了」。
+    pub suppressed: Vec,
+}
+
+impl DownloadError {
+    /// 构造错误。
+    #[must_use]
+    pub fn new(kind: DownloadErrorKind, message: impl Into) -> Self {
+        Self {
+            kind,
+            source: None,
+            message: message.into(),
+            attempts: 0,
+            suppressed: Vec::new(),
+        }
+    }
+
+    /// 标注出错的源。
+    #[must_use]
+    pub fn with_source(mut self, source: impl Into) -> Self {
+        self.source = Some(source.into());
+        self
+    }
+
+    /// 标注尝试次数。
+    #[must_use]
+    pub fn with_attempts(mut self, attempts: usize) -> Self {
+        self.attempts = attempts;
+        self
+    }
+
+    /// 汇总其它源的失败摘要。
+    #[must_use]
+    pub fn with_suppressed(mut self, suppressed: Vec) -> Self {
+        self.suppressed = suppressed;
+        self
+    }
+}
+
+impl fmt::Display for DownloadError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match &self.source {
+            Some(source) => write!(
+                f,
+                "[{}] {}(源 {})",
+                self.kind.as_str(),
+                self.message,
+                source
+            ),
+            None => write!(f, "[{}] {}", self.kind.as_str(), self.message),
+        }
+    }
+}
+
+impl std::error::Error for DownloadError {}
diff --git a/crates/core/src/download/health.rs b/crates/core/src/download/health.rs
new file mode 100644
index 0000000..d3f6169
--- /dev/null
+++ b/crates/core/src/download/health.rs
@@ -0,0 +1,399 @@
+//! 源健康度记忆:`源 × 资源类型` 的 EMA RTT / 失败率 / 连续失败。
+//!
+//! HMCL 没有这一层(analyse/03 §8.4 的「下载快」一栏是纯并发 + 缓存),于是同一台机器上
+//! 一个常年 502 的镜像会被每个文件重试一遍。这里的规则:
+//!
+//! * 连续 **3** 次失败 → 冷却 **10 分钟**,期间该源排到最后(仍保留为兜底候选);
+//! * 冷却结束后自动进入**恢复探测**:下一次尝试就是一次探测,成功即清零回队,失败则重新冷却;
+//! * 记录持久化到 `cache/health.json`(原子写),进程重启后仍然生效:
+//!   **冷却状态迁移时立刻落盘**(进入/退出冷却这种罕见事件),其余变化标脏,
+//!   由引擎在一次 `download()` / `download_bytes()` 收尾时 `flush()` 节流落盘,
+//!   避免「批量下 3000 个资源 = 3000 次原子写」;
+//! * 排序打分 = EMA RTT + 失败率惩罚 + 连续失败惩罚,冷却中的源加一个大惩罚。
+
+use std::collections::HashMap;
+use std::io;
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::Mutex;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+use serde::{Deserialize, Serialize};
+
+use super::health_store;
+use super::types::{Candidate, ResourceKind, SOURCE_OFFICIAL};
+
+/// 连续失败多少次进入冷却。
+pub const FAILURE_THRESHOLD: u32 = 3;
+/// 冷却时长(毫秒):连续失败后的冷板凳时间。
+pub const COOLDOWN_MS: u64 = 10 * 60 * 1000;
+/// EMA 的平滑系数(0.3 意味着新样本占 30%)。
+pub const EMA_ALPHA: f64 = 0.3;
+/// 冷板凳/未知源的基础打分。
+pub const SCORE_UNKNOWN: f64 = 400.0;
+/// 落盘节流:两次 flush 之间的最小间隔(毫秒)。
+pub const SAVE_THROTTLE_MS: u64 = 5_000;
+/// 冷却中的惩罚项。
+pub const SCORE_COOLDOWN_PENALTY: f64 = 1_000.0;
+
+/// 健康度的记忆键:一个源在一种资源类型上的表现是独立的。
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct HealthKey {
+    /// 源标识(`official` / `bmclapi` / `tuna` …)。
+    pub source: String,
+    /// 资源类型。
+    pub kind: ResourceKind,
+}
+
+impl HealthKey {
+    /// 构造键。
+    #[must_use]
+    pub fn new(source: impl Into, kind: ResourceKind) -> Self {
+        Self {
+            source: source.into(),
+            kind,
+        }
+    }
+}
+
+/// 单个 `源 × 资源类型` 的健康记录。
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct SourceHealth {
+    /// 成功请求的 RTT 指数移动平均(毫秒);无样本时为 0。
+    pub ema_rtt_ms: f64,
+    /// 参与 EMA 的样本数。
+    pub samples: u64,
+    /// 累计成功次数。
+    pub successes: u64,
+    /// 累计失败次数。
+    pub failures: u64,
+    /// 累计「该源没有这个资源」(4xx)。**不计失败、不进冷却**——院校镜像通常只留最新补丁,
+    /// 请求旧补丁必然 404,把这种情况算成「源坏」会在一次安装里把唯一可用镜像冷却掉。
+    #[serde(default)]
+    pub not_found: u64,
+    /// 连续失败次数。
+    pub consecutive_failures: u32,
+    /// 冷却截止时间(unix 毫秒,0 = 未冷却)。
+    pub cooldown_until_ms: u64,
+    /// 最近一次失败原因(诊断页用)。
+    pub last_error: Option,
+    /// 最后更新时间(unix 毫秒)。
+    pub updated_at_ms: u64,
+}
+
+impl Default for SourceHealth {
+    fn default() -> Self {
+        Self {
+            ema_rtt_ms: 0.0,
+            samples: 0,
+            successes: 0,
+            failures: 0,
+            not_found: 0,
+            consecutive_failures: 0,
+            cooldown_until_ms: 0,
+            last_error: None,
+            updated_at_ms: 0,
+        }
+    }
+}
+
+impl SourceHealth {
+    /// 记录一次成功:重置连续失败与冷却,更新 EMA RTT。
+    pub fn record_success(&mut self, rtt_ms: u64, now_ms: u64) {
+        self.successes += 1;
+        self.consecutive_failures = 0;
+        self.cooldown_until_ms = 0;
+        self.last_error = None;
+        self.updated_at_ms = now_ms;
+        let rtt = rtt_ms as f64;
+        self.ema_rtt_ms = if self.samples == 0 {
+            rtt
+        } else {
+            EMA_ALPHA * rtt + (1.0 - EMA_ALPHA) * self.ema_rtt_ms
+        };
+        self.samples += 1;
+    }
+
+    /// 记录一次失败:达到阈值即进入冷却。
+    pub fn record_failure(&mut self, reason: &str, now_ms: u64) {
+        self.failures += 1;
+        self.consecutive_failures = self.consecutive_failures.saturating_add(1);
+        self.updated_at_ms = now_ms;
+        self.last_error = Some(reason.to_string());
+        if self.consecutive_failures >= FAILURE_THRESHOLD {
+            self.cooldown_until_ms = now_ms.saturating_add(COOLDOWN_MS);
+        }
+    }
+
+    /// 记录一次「该源没有这个资源」(HTTP 4xx):只留诊断痕迹,不动失败率与冷却。
+    pub fn record_missing(&mut self, reason: &str, now_ms: u64) {
+        self.not_found = self.not_found.saturating_add(1);
+        self.updated_at_ms = now_ms;
+        self.last_error = Some(reason.to_string());
+    }
+
+    /// 是否处于冷却期。
+    #[must_use]
+    pub fn is_cooling_down(&self, now_ms: u64) -> bool {
+        self.cooldown_until_ms > now_ms
+    }
+
+    /// 上次冷却是否已到期(此时这次尝试就是「恢复探测」)。
+    #[must_use]
+    pub fn is_probe(&self, now_ms: u64) -> bool {
+        self.cooldown_until_ms != 0 && self.cooldown_until_ms <= now_ms
+    }
+
+    /// 失败率(0.0~1.0)。
+    #[must_use]
+    pub fn failure_rate(&self) -> f64 {
+        let total = self.successes + self.failures;
+        if total == 0 {
+            0.0
+        } else {
+            self.failures as f64 / total as f64
+        }
+    }
+
+    /// 排序打分(越小越优先)。
+    #[must_use]
+    pub fn score(&self, now_ms: u64) -> f64 {
+        if self.is_cooling_down(now_ms) {
+            return SCORE_COOLDOWN_PENALTY + self.ema_rtt_ms;
+        }
+        if self.samples == 0 && self.failures == 0 {
+            return SCORE_UNKNOWN;
+        }
+        self.ema_rtt_ms + self.failure_rate() * 500.0 + f64::from(self.consecutive_failures) * 50.0
+    }
+}
+
+/// 健康度注册表(进程内共享,`Arc` 后传给引擎)。
+#[derive(Debug)]
+pub struct HealthRegistry {
+    path: Option,
+    entries: Mutex>,
+    dirty: AtomicBool,
+    last_save_ms: AtomicU64,
+}
+
+impl Default for HealthRegistry {
+    fn default() -> Self {
+        Self::in_memory()
+    }
+}
+
+impl HealthRegistry {
+    /// 纯内存实例(测试用 / 用户关掉健康度记忆时)。
+    #[must_use]
+    pub fn in_memory() -> Self {
+        Self {
+            path: None,
+            entries: Mutex::new(HashMap::new()),
+            dirty: AtomicBool::new(false),
+            last_save_ms: AtomicU64::new(0),
+        }
+    }
+
+    /// 打开持久化文件(不存在则从空表开始;损坏/超龄记录则忽略)。
+    #[must_use]
+    pub fn open(path: impl Into, max_age_ms: u64, now_ms: u64) -> Self {
+        let path = path.into();
+        let entries = health_store::load(&path, max_age_ms, now_ms);
+        Self {
+            path: Some(path),
+            entries: Mutex::new(entries),
+            dirty: AtomicBool::new(false),
+            last_save_ms: AtomicU64::new(now_ms),
+        }
+    }
+
+    /// 按 `cache/` 根目录打开(**生产接线用这一条**):文件固定在 `/health.json`,
+    /// 保留期用默认值。
+    #[must_use]
+    pub fn open_in(cache_root: impl AsRef, now_ms: u64) -> Self {
+        Self::open(
+            cache_root.as_ref().join(health_store::FILE_NAME),
+            health_store::MAX_AGE_MS,
+            now_ms,
+        )
+    }
+
+    /// 持久化文件路径(诊断页展示用)。
+    #[must_use]
+    pub fn path(&self) -> Option<&Path> {
+        self.path.as_deref()
+    }
+
+    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> {
+        self.entries
+            .lock()
+            .unwrap_or_else(|poisoned| poisoned.into_inner())
+    }
+
+    /// 读取某个键的记录(不存在则返回默认值)。
+    #[must_use]
+    pub fn health_of(&self, source: &str, kind: ResourceKind) -> SourceHealth {
+        self.lock()
+            .get(&HealthKey::new(source, kind))
+            .cloned()
+            .unwrap_or_default()
+    }
+
+    /// 记录成功;返回冷却状态是否发生变化(变化即立刻落盘)。
+    pub fn record_success(
+        &self,
+        source: &str,
+        kind: ResourceKind,
+        rtt: Duration,
+        now_ms: u64,
+    ) -> bool {
+        let changed = {
+            let mut entries = self.lock();
+            let health = entries.entry(HealthKey::new(source, kind)).or_default();
+            let before = health.cooldown_until_ms;
+            health.record_success(rtt.as_millis() as u64, now_ms);
+            before != health.cooldown_until_ms
+        };
+        self.after_record(changed);
+        changed
+    }
+
+    /// 记录失败;返回冷却状态是否发生变化(第 3 次连续失败会进入冷却 → 立刻落盘)。
+    pub fn record_failure(
+        &self,
+        source: &str,
+        kind: ResourceKind,
+        reason: &str,
+        now_ms: u64,
+    ) -> bool {
+        let changed = {
+            let mut entries = self.lock();
+            let health = entries.entry(HealthKey::new(source, kind)).or_default();
+            let before = health.cooldown_until_ms;
+            health.record_failure(reason, now_ms);
+            before != health.cooldown_until_ms
+        };
+        self.after_record(changed);
+        changed
+    }
+
+    /// 记录「该源没有这个资源」(4xx):不计失败、不进冷却,只更新诊断字段。
+    pub fn record_missing(&self, source: &str, kind: ResourceKind, reason: &str, now_ms: u64) {
+        {
+            let mut entries = self.lock();
+            let health = entries.entry(HealthKey::new(source, kind)).or_default();
+            health.record_missing(reason, now_ms);
+        }
+        self.after_record(false);
+    }
+
+    /// 记录后:标脏,冷却状态迁移则立刻落盘(这种事件很少,不会打爆磁盘)。
+    fn after_record(&self, cooldown_changed: bool) {
+        self.dirty.store(true, Ordering::Relaxed);
+        if cooldown_changed {
+            let _ = self.save();
+        }
+    }
+
+    /// 该源当前是否在冷却期。
+    #[must_use]
+    pub fn is_cooling_down(&self, source: &str, kind: ResourceKind, now_ms: u64) -> bool {
+        self.health_of(source, kind).is_cooling_down(now_ms)
+    }
+
+    /// 按健康度重排候选序列:健康好的在前,冷却中的排到最后(仍保留为兜底)。
+    #[must_use]
+    pub fn rank(
+        &self,
+        candidates: &[Candidate],
+        kind: ResourceKind,
+        now_ms: u64,
+    ) -> Vec {
+        let entries = self.lock();
+        let mut scored: Vec<(f64, usize, Candidate)> = candidates
+            .iter()
+            .enumerate()
+            .map(|(index, candidate)| {
+                let score = entries
+                    .get(&HealthKey::new(candidate.source.clone(), kind))
+                    .map_or(SCORE_UNKNOWN, |health| health.score(now_ms));
+                (score, index, candidate.clone())
+            })
+            .collect();
+        scored.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
+        scored
+            .into_iter()
+            .map(|(_, _, candidate)| candidate)
+            .collect()
+    }
+
+    /// 快照(诊断页/测试用),按源名排序保证稳定。
+    #[must_use]
+    pub fn snapshot(&self) -> Vec<(HealthKey, SourceHealth)> {
+        let entries = self.lock();
+        let mut rows: Vec<(HealthKey, SourceHealth)> = entries
+            .iter()
+            .map(|(key, health)| (key.clone(), health.clone()))
+            .collect();
+        rows.sort_by(|a, b| {
+            a.0.source
+                .cmp(&b.0.source)
+                .then(a.0.kind.as_str().cmp(b.0.kind.as_str()))
+        });
+        rows
+    }
+
+    /// 清空(设置页「重置下载源健康度」)。
+    pub fn clear(&self) {
+        self.lock().clear();
+    }
+
+    /// 无条件原子持久化到 `cache/health.json`(纯内存实例上是空操作)。
+    pub fn save(&self) -> io::Result<()> {
+        let Some(path) = self.path.clone() else {
+            self.dirty.store(false, Ordering::Relaxed);
+            return Ok(());
+        };
+        health_store::store(&path, &self.lock())?;
+        self.dirty.store(false, Ordering::Relaxed);
+        self.last_save_ms.store(now_wall_ms(), Ordering::Relaxed);
+        Ok(())
+    }
+
+    /// 下载收尾时的节流落盘:只有「脏」且距上次落盘超过 [`SAVE_THROTTLE_MS`] 才写。
+    ///
+    /// 引擎在每个 `download()` / `download_bytes()` 结束时调它——批量下载因此最多
+    /// 每 5 秒写一次盘,而不是每个文件一次。
+    pub fn flush(&self, now_ms: u64) -> io::Result<()> {
+        if self.path.is_none() || !self.dirty.load(Ordering::Relaxed) {
+            return Ok(());
+        }
+        let last = self.last_save_ms.load(Ordering::Relaxed);
+        if now_ms.saturating_sub(last) < SAVE_THROTTLE_MS {
+            return Ok(());
+        }
+        self.save()
+    }
+
+    /// 是否有未落盘的改动(诊断页/测试用)。
+    #[must_use]
+    pub fn is_dirty(&self) -> bool {
+        self.dirty.load(Ordering::Relaxed)
+    }
+}
+
+/// 官方源的空健康记录便捷构造(game 层拼候选时常用)。
+#[must_use]
+pub fn official_key(kind: ResourceKind) -> HealthKey {
+    HealthKey::new(SOURCE_OFFICIAL, kind)
+}
+
+/// 记录落盘时刻用的墙钟(不为它引入时钟抽象,测试走显式 `now_ms` 参数)。
+fn now_wall_ms() -> u64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map_or(0, |since| {
+            u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
+        })
+}
diff --git a/crates/core/src/download/health_store.rs b/crates/core/src/download/health_store.rs
new file mode 100644
index 0000000..179d8fa
--- /dev/null
+++ b/crates/core/src/download/health_store.rs
@@ -0,0 +1,80 @@
+//! 健康度的落盘格式与读盘/写盘(`cache/health.json`)。
+//!
+//! 与 `health.rs` 分开的原因很实际:序列化格式与文件 IO 的细节会随版本增长(字段增删、
+//! 迁移、按源分文件…),把这些从「打分/冷却」的纯逻辑里挪出来,两边都好读好改。
+
+use std::collections::HashMap;
+use std::io;
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+
+use super::atomic::write_atomic;
+use super::health::{HealthKey, SourceHealth};
+use super::types::ResourceKind;
+
+/// 健康度文件名(放在 `cache/` 下,与 `index.json` 同级)。
+pub const FILE_NAME: &str = "health.json";
+/// 记录保留期:超过这个时间没更新过的源直接丢掉(缓存目录可能被放很久)。
+pub const MAX_AGE_MS: u64 = 7 * 24 * 60 * 60 * 1000;
+
+/// 持久化的条目形态(`HealthKey` 拍平,便于 JSON 阅读)。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct PersistedEntry {
+    source: String,
+    kind: ResourceKind,
+    health: SourceHealth,
+}
+
+/// 持久化文件的容器。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct PersistedFile {
+    version: u32,
+    #[serde(default)]
+    entries: Vec,
+}
+
+/// 读盘:不存在/损坏/超龄都只丢掉对应记录,绝不阻断启动。
+pub(crate) fn load(path: &Path, max_age_ms: u64, now_ms: u64) -> HashMap {
+    let mut entries = HashMap::new();
+    let Ok(bytes) = std::fs::read(path) else {
+        return entries;
+    };
+    match serde_json::from_slice::(&bytes) {
+        Ok(file) => {
+            for entry in file.entries {
+                if entry.health.updated_at_ms.saturating_add(max_age_ms) >= now_ms {
+                    entries.insert(HealthKey::new(entry.source, entry.kind), entry.health);
+                }
+            }
+        }
+        Err(err) => warn(&format!("健康度文件解析失败,按空表处理:{err}")),
+    }
+    entries
+}
+
+/// 写盘:原子写(`tmp → rename`),失败由调用方决定是记日志还是忽略。
+pub(crate) fn store(path: &Path, entries: &HashMap) -> io::Result<()> {
+    let file = PersistedFile {
+        version: 1,
+        entries: entries
+            .iter()
+            .map(|(key, health)| PersistedEntry {
+                source: key.source.clone(),
+                kind: key.kind,
+                health: health.clone(),
+            })
+            .collect(),
+    };
+    let json = serde_json::to_vec_pretty(&file).map_err(io::Error::other)?;
+    write_atomic(path, &json)
+}
+
+/// core 不引 tracing(P0 阶段日志由 app 层接管),这里只做一次静默的 stderr 兜底。
+fn warn(message: &str) {
+    if cfg!(debug_assertions) {
+        eprintln!("[ull::download] {message}");
+    } else {
+        let _ = message;
+    }
+}
diff --git a/crates/core/src/download/http.rs b/crates/core/src/download/http.rs
new file mode 100644
index 0000000..6e6fd66
--- /dev/null
+++ b/crates/core/src/download/http.rs
@@ -0,0 +1,183 @@
+//! HTTP 薄封装:客户端构造、错误分类、响应头读取。
+//!
+//! 关键取舍:
+//! * **`Accept-Encoding: identity`** —— 下载要按字节对齐做 Range/校验,gzip 会让
+//!   `Content-Length` 与实际写入量打架(HMCL 为此专门写了按解压后偏移计数的
+//!   `HttpResumeContext`,analyse/03 §5.4),我们直接从协议层绕开这个问题;
+//! * 重定向上限 20 跳(承 HMCL),跳转后仍按最终 URL 做 host 校验由调用方负责;
+//! * 错误分类只做「同源重试 / 立刻换源 / 整体失败」三态,不做细颗粒映射。
+
+use reqwest::header::{
+    HeaderMap, HeaderName, ACCEPT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, ETAG, LAST_MODIFIED,
+};
+use reqwest::redirect::Policy;
+
+use super::config::EngineConfig;
+use super::error::{DownloadError, DownloadErrorKind};
+
+/// 重定向上限(承 HMCL 的 20 跳)。
+pub const MAX_REDIRECTS: usize = 20;
+
+/// 构造共享的 `reqwest::Client`(连接池、超时、重定向策略一次定好)。
+pub fn build_client(cfg: &EngineConfig) -> Result {
+    let mut headers = HeaderMap::new();
+    headers.insert(
+        ACCEPT_ENCODING,
+        reqwest::header::HeaderValue::from_static("identity"),
+    );
+    reqwest::Client::builder()
+        .user_agent(cfg.user_agent.clone())
+        .connect_timeout(cfg.connect_timeout)
+        .redirect(Policy::limited(MAX_REDIRECTS))
+        .default_headers(headers)
+        .build()
+        .map_err(|err| {
+            DownloadError::new(
+                DownloadErrorKind::Network,
+                format!("构造 HTTP 客户端失败:{err}"),
+            )
+        })
+}
+
+/// 把 `reqwest::Error` 映射成我们的错误分类。
+#[must_use]
+pub fn classify_reqwest(err: &reqwest::Error, url: &str) -> DownloadError {
+    let kind = if err.is_timeout() {
+        DownloadErrorKind::Timeout
+    } else if let Some(status) = err.status() {
+        return classify_status(status.as_u16(), url);
+    } else {
+        DownloadErrorKind::Network
+    };
+    DownloadError::new(kind, format!("{err}")).with_source(url)
+}
+
+/// HTTP 状态码 → 错误分类。
+#[must_use]
+pub fn classify_status(status: u16, url: &str) -> DownloadError {
+    let kind = match status {
+        404 | 410 => DownloadErrorKind::NotFound,
+        // 403/401 对镜像来说基本等于「这条路不能走」,直接换源比退避更有用。
+        401 | 403 => DownloadErrorKind::NotFound,
+        408 => DownloadErrorKind::Timeout,
+        416 => DownloadErrorKind::RangeUnsupported,
+        429 => DownloadErrorKind::ServerError,
+        500..=599 => DownloadErrorKind::ServerError,
+        _ => DownloadErrorKind::ServerError,
+    };
+    DownloadError::new(kind, format!("HTTP {status}")).with_source(url)
+}
+
+/// 读一个头的字符串值。
+#[must_use]
+pub fn header_str(headers: &HeaderMap, name: HeaderName) -> Option<&str> {
+    headers.get(name).and_then(|value| value.to_str().ok())
+}
+
+/// `Content-Length`。
+#[must_use]
+pub fn content_length(headers: &HeaderMap) -> Option {
+    header_str(headers, CONTENT_LENGTH).and_then(|value| value.trim().parse().ok())
+}
+
+/// `ETag`(续传的强校验器)。
+#[must_use]
+pub fn etag(headers: &HeaderMap) -> Option {
+    header_str(headers, ETAG).map(str::to_string)
+}
+
+/// `Last-Modified`(弱校验器,ETag 缺失时的兜底)。
+#[must_use]
+pub fn last_modified(headers: &HeaderMap) -> Option {
+    header_str(headers, LAST_MODIFIED).map(str::to_string)
+}
+
+/// `Content-Type`。
+#[must_use]
+pub fn content_type(headers: &HeaderMap) -> Option<&str> {
+    header_str(headers, CONTENT_TYPE)
+}
+
+/// 判断一个 2xx 响应是不是「伪装的档案」(镜像门户的假阳性兜底页)。
+///
+/// 出处:research/06 §0.2 / §5.3 第 2 条——`mirrors.huaweicloud.com` 对**任意不存在的路径**
+/// 都回 `200 + 12 434 字节 Angular 门户 HTML`,只看状态码会把死源当活源。
+///
+/// 两条判据(都只在证据充分时才拒):
+/// 1. `Content-Type` 含 `html` → 一定是门户页,不是归档;
+/// 2. 已知期望大小、且是整文件请求(`offset == 0`)、响应带 `Content-Length` 却对不上
+///    → 长度对拍失败,同样不是我们要的那个文件。
+///
+/// 返回 `Some(原因)` 时调用方应当**换源**(归到 `NotFound` 类:不算源坏、不进冷却)。
+#[must_use]
+pub fn html_mirror_reason(
+    headers: &HeaderMap,
+    expected_size: Option,
+    offset: u64,
+) -> Option<&'static str> {
+    if content_type(headers).is_some_and(|value| value.to_ascii_lowercase().contains("html")) {
+        return Some("源返回 HTML(镜像门户兜底页,假阳性:该路径没有真实归档)");
+    }
+    if offset == 0 {
+        if let (Some(expected), Some(actual)) = (expected_size, content_length(headers)) {
+            if expected > 0 && actual != expected {
+                return Some("源返回的 Content-Length 与期望大小不符(疑似门户页/截断响应)");
+            }
+        }
+    }
+    None
+}
+
+#[cfg(test)]
+mod tests {
+    use reqwest::header::{HeaderMap, HeaderValue, CONTENT_LENGTH, CONTENT_TYPE};
+
+    use super::{html_mirror_reason, MAX_REDIRECTS};
+
+    fn headers(content_type: Option<&str>, length: Option) -> HeaderMap {
+        let mut map = HeaderMap::new();
+        if let Some(value) = content_type {
+            map.insert(CONTENT_TYPE, HeaderValue::from_str(value).expect("头值"));
+        }
+        if let Some(value) = length {
+            map.insert(CONTENT_LENGTH, HeaderValue::from(value));
+        }
+        map
+    }
+
+    #[test]
+    fn html_portal_page_is_rejected_even_with_200() {
+        // 华为云实测:任意不存在的路径 → 200 + text/html + 12434 字节。
+        let map = headers(Some("text/html;charset=UTF-8"), Some(12_434));
+        assert_eq!(
+            html_mirror_reason(&map, Some(201_328_186), 0),
+            Some("源返回 HTML(镜像门户兜底页,假阳性:该路径没有真实归档)")
+        );
+        // 只有 Content-Type 也能判(没有期望大小时)。
+        assert!(html_mirror_reason(&map, None, 0).is_some());
+    }
+
+    #[test]
+    fn real_archive_matching_the_expected_size_passes() {
+        let map = headers(Some("application/zip"), Some(201_328_186));
+        assert_eq!(html_mirror_reason(&map, Some(201_328_186), 0), None);
+        // 镜像没给 Content-Type 也照放行(有 sha256 兜底)。
+        let bare = headers(None, Some(201_328_186));
+        assert_eq!(html_mirror_reason(&bare, Some(201_328_186), 0), None);
+    }
+
+    #[test]
+    fn mismatched_length_is_rejected_but_resumed_reads_are_left_alone() {
+        let map = headers(Some("application/octet-stream"), Some(973_920));
+        assert!(html_mirror_reason(&map, Some(205_421_281), 0).is_some());
+        // 续传片段当然比全量小:不能按整文件口径对拍。
+        assert_eq!(html_mirror_reason(&map, Some(205_421_281), 973_920), None);
+        // 未知期望大小 → 不做长度判定。
+        assert_eq!(html_mirror_reason(&map, None, 0), None);
+    }
+
+    #[test]
+    fn redirect_cap_is_still_twenty() {
+        assert_eq!(MAX_REDIRECTS, 20);
+    }
+}
diff --git a/crates/core/src/download/jdk_sources.rs b/crates/core/src/download/jdk_sources.rs
new file mode 100644
index 0000000..428f3e8
--- /dev/null
+++ b/crates/core/src/download/jdk_sources.rs
@@ -0,0 +1,178 @@
+//! JDK 分发源表(`mirrors.json` v3):Oracle OpenJDK / Microsoft OpenJDK / Azul Zulu。
+//!
+//! v2 只有 `adoptium`(Temurin)一张表,因为当时只做一家发行版。D23 定下三家 + 第四备选后,
+//! 三家的分发形态各不相同,表结构也就各有一套(research/06 §5.2 条目样例照抄):
+//!
+//! | 发行版 | 枚举方式 | 下载形态 |
+//! | --- | --- | --- |
+//! | Oracle OpenJDK | Apache 目录页(华为云 / 南大,与 `download.java.net` 同构) | `{base}/{ver}/{filename}`,hash 段不可推导 |
+//! | Microsoft OpenJDK | 内置版本快照(官方只有人读 HTML 表) | `aka.ms` 模板 301 → VS CDN,校验走成对的 `.sha256sum.txt` |
+//! | Zulu | 免登录 JSON API(两跳:列表 → 详情) | `download_url` 直出,只信 `sha256_hash` |
+//!
+//! 表本身只是「数据」:怎么用由 `game::java` 的 `oracle` / `microsoft` / `zulu` 决定。
+//! v3 兼容读 v2:新表全部 `#[serde(default)]`,老文件照常解析(新表为空,回退链自然退化成只有 Temurin)。
+
+use serde::{Deserialize, Serialize};
+
+use super::mirrors::MirrorTable;
+
+const fn default_true() -> bool {
+    true
+}
+
+/// 一个 Oracle OpenJDK 镜像(`download.java.net/openjdk` 的忠实镜像)。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OracleMirror {
+    /// 源标识(`huawei` / `nju`)。
+    pub id: String,
+    /// 展示名。
+    pub name: String,
+    /// 目录根(形如 `https://mirrors.huaweicloud.com/openjdk`)。
+    pub base: String,
+    /// 是否启用。
+    #[serde(default = "default_true")]
+    pub enabled: bool,
+    /// 实测吞吐(展示用,不参与决策)。
+    #[serde(default)]
+    pub speed: String,
+    /// 备注(展示用)。
+    #[serde(default)]
+    pub note: String,
+}
+
+impl OracleMirror {
+    /// 目录根(去掉尾斜杠,拼路径时不会再出双斜杠)。
+    #[must_use]
+    pub fn base(&self) -> &str {
+        self.base.trim_end_matches('/')
+    }
+}
+
+/// Microsoft OpenJDK 的分发表:官方模板 + 内置版本快照。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MsOpenJdkTable {
+    /// 人读清单页(刷新快照时抓的页面,仅供排查)。
+    #[serde(default)]
+    pub list: String,
+    /// 下载模板:`microsoft-jdk-{version}-{os}-{arch}.zip`。
+    pub template: String,
+    /// 校验模板:同一文件名 + `.sha256sum.txt`。
+    pub sha256_template: String,
+    /// 官方在发的 GA 大版本(没有 8——微软已停发,官方页面让去用 Temurin 8)。
+    #[serde(default)]
+    pub majors: Vec,
+    /// 国内镜像(实测为空数组:三家云都没有 MS OpenJDK)。
+    #[serde(default)]
+    pub domestic_mirrors: Vec,
+    /// 实测吞吐(展示用)。
+    #[serde(default)]
+    pub measured_speed: String,
+}
+
+/// Zulu(Azul)的元数据表。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ZuluTable {
+    /// 包列表 API 根。
+    pub api: String,
+    /// 包详情 API(`{uuid}` 占位)——`latest=true` 的响应不含 sha256,必须再取一次。
+    pub detail: String,
+    /// CDN host(`cdn.azul.com`,展示用)。
+    #[serde(default)]
+    pub cdn_host: String,
+    /// 实测吞吐(展示用)。
+    #[serde(default)]
+    pub measured_speed: String,
+    /// `size` 字段是否可与真实字节数对拍(实测 `false`,差 133 B)。
+    #[serde(default)]
+    pub size_field_reliable: bool,
+}
+
+impl MirrorTable {
+    /// 启用的 Oracle OpenJDK 镜像(清单顺序 = 默认尝试顺序)。
+    pub fn oracle_mirrors(&self) -> impl Iterator {
+        self.oracle_openjdk.iter().filter(|mirror| mirror.enabled)
+    }
+
+    /// 按 id 找 Oracle 镜像(诊断 / `java vendors` 用;停用的也能查到)。
+    #[must_use]
+    pub fn oracle_mirror(&self, id: &str) -> Option<&OracleMirror> {
+        self.oracle_openjdk
+            .iter()
+            .find(|mirror| mirror.id.eq_ignore_ascii_case(id))
+    }
+
+    /// Microsoft OpenJDK 分发表(v2 清单没有 → `None`)。
+    #[must_use]
+    pub fn microsoft_openjdk(&self) -> Option<&MsOpenJdkTable> {
+        self.microsoft_openjdk.as_ref()
+    }
+
+    /// Zulu 元数据表。
+    #[must_use]
+    pub fn zulu(&self) -> Option<&ZuluTable> {
+        self.zulu.as_ref()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::MirrorTable;
+
+    #[test]
+    fn builtin_table_is_v3_with_all_four_source_families() {
+        let table = MirrorTable::builtin();
+        assert_eq!(table.version, 3);
+        // Temurin:只剩 TUNA 一家启用,且带 latest_only 标记。
+        let enabled: Vec<&str> = table
+            .adoptium
+            .iter()
+            .filter(|mirror| mirror.enabled)
+            .map(|mirror| mirror.id.as_str())
+            .collect();
+        assert_eq!(enabled, vec!["tuna"]);
+        assert!(table
+            .adoptium
+            .iter()
+            .filter(|m| m.enabled)
+            .all(|m| m.latest_only));
+        // 停用的登记项要在(将来恢复时不需要重写清单)。
+        for id in ["ustc", "sjtu", "bfsu", "nju", "zju", "163"] {
+            assert!(
+                table.adoptium.iter().any(|mirror| mirror.id == id),
+                "{id} 应登记为 disabled"
+            );
+        }
+        assert!(table.oracle_mirrors().any(|mirror| mirror.id == "huawei"));
+        let microsoft = table.microsoft_openjdk().expect("v3 应带 MS 表");
+        assert_eq!(microsoft.majors, vec![25, 21, 17, 11]);
+        assert!(microsoft.template.contains("{version}"));
+        assert!(microsoft.sha256_template.ends_with(".sha256sum.txt"));
+        let zulu = table.zulu().expect("v3 应带 Zulu 表");
+        assert!(zulu.detail.contains("{uuid}"));
+        assert!(!zulu.size_field_reliable, "Zulu 的 size 字段不可对拍");
+    }
+
+    #[test]
+    fn v2_table_still_parses() {
+        // v2 清单(没有三张新表)必须照常解析,回退链退化为「只有 Temurin」。
+        let v2 = r#"{
+            "version": 2,
+            "adoptium": [
+                { "id": "tuna", "name": "清华 TUNA",
+                  "base": "https://mirrors.tuna.tsinghua.edu.cn/Adoptium", "enabled": true }
+            ],
+            "gh_proxies": [],
+            "rules": []
+        }"#;
+        let table = MirrorTable::parse(v2).expect("v2 应兼容");
+        assert_eq!(table.version, 2);
+        assert_eq!(table.adoptium.len(), 1);
+        assert!(
+            !table.adoptium[0].latest_only,
+            "v2 没有 latest_only → 默认 false"
+        );
+        assert!(table.microsoft_openjdk().is_none());
+        assert!(table.zulu().is_none());
+        assert_eq!(table.oracle_mirrors().count(), 0);
+    }
+}
diff --git a/crates/core/src/download/mirrors.json b/crates/core/src/download/mirrors.json
new file mode 100644
index 0000000..8075108
--- /dev/null
+++ b/crates/core/src/download/mirrors.json
@@ -0,0 +1,169 @@
+{
+  "version": 3,
+  "notes": [
+    "v3(D23/M36):adoptium 表收敛到清华 TUNA 一家(其余院校实测 404,登记保留但 enabled=false);新增 oracle_openjdk / microsoft_openjdk / zulu 三张表,供 game::java 的三发行版回退链使用。",
+    "华为云 / 腾讯云 / 阿里云没有 Adoptium 镜像:华为云门户对任意不存在的路径都回 200 + 12KB HTML(假阳性,M34 实测),腾讯云 /v1/source.js 与华为云 /v1/repositories 的权威清单里都没有 JDK 项。据此不再登记 adoptium.huaweicloud。",
+    "oracle_openjdk 的目录与文件名与 download.java.net 完全同构,但 hash 段(f2283984656d49d69e91c558476027ac)不可推导——候选 URL 只能来自对镜像目录页的解析,禁止拼装。",
+    "microsoft_openjdk 没有国内镜像,也没有 GitHub 资产(api.github.com 的 microsoft/openjdk releases 里 assets 全空);唯一渠道是 aka.ms 模板 301 → download.visualstudio.microsoft.com(国内约 400 KB/s)。",
+    "zulu 的 API size 字段与真实字节数差 133 B,只能用于展示;校验一律用 sha256_hash。"
+  ],
+  "gh_proxies": [
+    {
+      "id": "gh-proxy",
+      "name": "gh-proxy.com",
+      "prefix": "https://gh-proxy.com/",
+      "enabled": true
+    }
+  ],
+  "adoptium": [
+    {
+      "id": "tuna",
+      "name": "清华 TUNA",
+      "base": "https://mirrors.tuna.tsinghua.edu.cn/Adoptium",
+      "enabled": true,
+      "latest_only": true
+    },
+    {
+      "id": "ustc",
+      "name": "中科大 USTC(无 Adoptium 目录树:只有 deb/rpm/releases,实测 404)",
+      "base": "https://mirrors.ustc.edu.cn/adoptium",
+      "enabled": false
+    },
+    {
+      "id": "sjtu",
+      "name": "上交 SJTU(无 Adoptium 目录树,实测 404)",
+      "base": "https://mirror.sjtu.edu.cn/adoptium",
+      "enabled": false
+    },
+    {
+      "id": "bfsu",
+      "name": "北外 BFSU(无 Adoptium 目录树,实测 404)",
+      "base": "https://mirrors.bfsu.edu.cn/Adoptium",
+      "enabled": false
+    },
+    {
+      "id": "nju",
+      "name": "南大 NJU(adoptium 路径实测 404;/openjdk/ 走 oracle_openjdk 表)",
+      "base": "https://mirror.nju.edu.cn/adoptium",
+      "enabled": false
+    },
+    {
+      "id": "zju",
+      "name": "浙大 ZJU(无 Adoptium 目录树,实测 404 搜索页)",
+      "base": "https://mirrors.zju.edu.cn/adoptium",
+      "enabled": false
+    },
+    {
+      "id": "163",
+      "name": "网易 163(无 Adoptium 目录树,实测 404 且 18 秒才回)",
+      "base": "https://mirrors.163.com/adoptium",
+      "enabled": false
+    }
+  ],
+  "oracle_openjdk": [
+    {
+      "id": "huawei",
+      "name": "华为云",
+      "base": "https://mirrors.huaweicloud.com/openjdk",
+      "enabled": true,
+      "speed": "11.5MB/s",
+      "note": "逐字节忠实镜像(21.0.2 = 201328186 B 与官方一致);目录页是 Apache 列表,带 
;归档旁有 .sha256"
+    },
+    {
+      "id": "nju",
+      "name": "南大 NJU",
+      "base": "https://mirror.nju.edu.cn/openjdk",
+      "enabled": true,
+      "speed": "8.3MB/s",
+      "note": "需先吃一次 bcheck cookie(裸 GET 是 302 回自己);健康度会把它排在华为云之后,失败即降级"
+    }
+  ],
+  "microsoft_openjdk": {
+    "list": "https://learn.microsoft.com/java/openjdk/download",
+    "template": "https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip",
+    "sha256_template": "https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip.sha256sum.txt",
+    "majors": [25, 21, 17, 11],
+    "domestic_mirrors": [],
+    "measured_speed": "398KB/s"
+  },
+  "zulu": {
+    "api": "https://api.azul.com/metadata/v1/zulu/packages/",
+    "detail": "https://api.azul.com/metadata/v1/zulu/packages/{uuid}",
+    "cdn_host": "cdn.azul.com",
+    "measured_speed": "3.3MB/s",
+    "size_field_reliable": false
+  },
+  "rules": [
+    {
+      "id": "bmclapi-launchermeta",
+      "from": "https://launchermeta.mojang.com",
+      "to": "https://bmclapi2.bangbang93.com"
+    },
+    {
+      "id": "bmclapi-piston-meta",
+      "from": "https://piston-meta.mojang.com",
+      "to": "https://bmclapi2.bangbang93.com"
+    },
+    {
+      "id": "bmclapi-piston-data",
+      "from": "https://piston-data.mojang.com",
+      "to": "https://bmclapi2.bangbang93.com"
+    },
+    {
+      "id": "bmclapi-launcher",
+      "from": "https://launcher.mojang.com",
+      "to": "https://bmclapi2.bangbang93.com"
+    },
+    {
+      "id": "bmclapi-libraries",
+      "from": "https://libraries.minecraft.net",
+      "to": "https://bmclapi2.bangbang93.com"
+    },
+    {
+      "id": "bmclapi-assets-objects",
+      "from": "https://resources.download.minecraft.net",
+      "to": "https://bmclapi2.bangbang93.com",
+      "inject": "/assets"
+    },
+    {
+      "id": "bmclapi-forge-maven",
+      "from": "https://files.minecraftforge.net/maven",
+      "to": "https://bmclapi2.bangbang93.com/maven"
+    },
+    {
+      "id": "bmclapi-forge-maven-net",
+      "from": "https://maven.minecraftforge.net",
+      "to": "https://bmclapi2.bangbang93.com/maven"
+    },
+    {
+      "id": "bmclapi-neoforge",
+      "from": "https://maven.neoforged.net/releases",
+      "to": "https://bmclapi2.bangbang93.com/maven"
+    },
+    {
+      "id": "bmclapi-fabric-meta",
+      "from": "https://meta.fabricmc.net",
+      "to": "https://bmclapi2.bangbang93.com/fabric-meta"
+    },
+    {
+      "id": "bmclapi-fabric-maven",
+      "from": "https://maven.fabricmc.net",
+      "to": "https://bmclapi2.bangbang93.com/maven"
+    },
+    {
+      "id": "bmclapi-quilt-meta",
+      "from": "https://meta.quiltmc.org",
+      "to": "https://bmclapi2.bangbang93.com/quilt-meta"
+    },
+    {
+      "id": "bmclapi-liteloader",
+      "from": "https://dl.liteloader.com/versions",
+      "to": "https://bmclapi2.bangbang93.com/maven"
+    },
+    {
+      "id": "bmclapi-authlib-injector",
+      "from": "https://authlib-injector.yushi.moe",
+      "to": "https://bmclapi2.bangbang93.com/mirrors/authlib-injector"
+    }
+  ]
+}
diff --git a/crates/core/src/download/mirrors.rs b/crates/core/src/download/mirrors.rs
new file mode 100644
index 0000000..e16b6cb
--- /dev/null
+++ b/crates/core/src/download/mirrors.rs
@@ -0,0 +1,386 @@
+//! 镜像清单与 URL 前缀重写。
+//!
+//! 内置清单 `mirrors.json` 随源码嵌入(`include_str!`),发行后可由设置层追加自定义规则。
+//! 重写语义承 analyse/03 §5.2 的「前缀替换而非全量重写」——保留子路径,因此 `/libraries`、
+//! `/maven`、`/fabric-meta` 这类子路径天然兼容;`inject` 用于补上路径形状不同的场景
+//! (资源对象:官方 `/`,BMCLAPI `/assets//`)。
+//!
+//! gh-proxy 规则(用户定案):**只有国内模式 + GitHub host** 才套前缀,其它资源不受影响。
+
+use serde::{Deserialize, Serialize};
+
+pub use super::adoptium::AdoptiumMirror;
+use super::jdk_sources::{MsOpenJdkTable, OracleMirror, ZuluTable};
+use super::region::{RegionMode, RegionProfile};
+use super::types::{Candidate, ResourceKind, SOURCE_OFFICIAL};
+
+/// gh-proxy 候选的源标识。
+pub const SOURCE_GH_PROXY: &str = "gh_proxy";
+
+const fn default_true() -> bool {
+    true
+}
+
+/// 一条前缀重写规则。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PrefixRule {
+    /// 规则 id(日志/诊断用)。
+    pub id: String,
+    /// 匹配的 URL 前缀(含 scheme)。
+    pub from: String,
+    /// 替换成的前缀。
+    pub to: String,
+    /// 插在 `to` 与剩余路径之间的内容(默认空)。
+    #[serde(default)]
+    pub inject: String,
+    /// 「仅加速不替代」:镜像优先模式下仍排在官方之后。
+    #[serde(default)]
+    pub fallback: bool,
+    /// 只对这些资源类型生效(空 = 全部)。
+    #[serde(default)]
+    pub kinds: Vec,
+    /// 是否启用(设置层可关)。
+    #[serde(default = "default_true")]
+    pub enabled: bool,
+}
+
+impl PrefixRule {
+    /// 构造一条最简规则。
+    #[must_use]
+    pub fn new(id: impl Into, from: impl Into, to: impl Into) -> Self {
+        Self {
+            id: id.into(),
+            from: from.into(),
+            to: to.into(),
+            inject: String::new(),
+            fallback: false,
+            kinds: Vec::new(),
+            enabled: true,
+        }
+    }
+
+    /// 设一个「仅加速」标记。
+    #[must_use]
+    pub fn fallback(mut self) -> Self {
+        self.fallback = true;
+        self
+    }
+
+    /// 设置注入片段。
+    #[must_use]
+    pub fn with_inject(mut self, inject: impl Into) -> Self {
+        self.inject = inject.into();
+        self
+    }
+
+    /// 是否命中该 URL(前缀匹配 + 资源类型过滤 + 启用位)。
+    #[must_use]
+    pub fn matches(&self, url: &str, kind: ResourceKind) -> bool {
+        self.enabled
+            && url.starts_with(&self.from)
+            && (self.kinds.is_empty() || self.kinds.contains(&kind))
+    }
+
+    /// 生成镜像候选。
+    #[must_use]
+    pub fn apply(&self, url: &str) -> Option {
+        if !url.starts_with(&self.from) {
+            return None;
+        }
+        let rest = &url[self.from.len()..];
+        let rewritten = format!("{}{}{}", self.to, self.inject, rest);
+        Some(Candidate::new(source_id_for(&rewritten), rewritten))
+    }
+}
+
+/// 一个 gh-proxy 类前缀。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct GhProxy {
+    /// 源标识。
+    pub id: String,
+    /// 展示名。
+    pub name: String,
+    /// 前缀(形如 `https://gh-proxy.com/`)。
+    pub prefix: String,
+    /// 是否启用。
+    #[serde(default = "default_true")]
+    pub enabled: bool,
+}
+
+/// 镜像表:内置规则 + 用户自定义规则。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MirrorTable {
+    /// 清单版本(便于诊断页显示「镜像表是哪一版」)。
+    pub version: u32,
+    /// 清单自带的说明(假阳性成因、不可推导项等,给运维看的原地注释)。
+    #[serde(default)]
+    pub notes: Vec,
+    /// 前缀重写规则(前面的优先)。
+    #[serde(default)]
+    pub rules: Vec,
+    /// Adoptium 院校镜像。
+    #[serde(default)]
+    pub adoptium: Vec,
+    /// Oracle OpenJDK 镜像(v3 新增)。
+    #[serde(default)]
+    pub oracle_openjdk: Vec,
+    /// Microsoft OpenJDK 分发模板(v3 新增)。
+    #[serde(default)]
+    pub microsoft_openjdk: Option,
+    /// Zulu 元数据(v3 新增)。
+    #[serde(default)]
+    pub zulu: Option,
+    /// gh-proxy 类前缀。
+    #[serde(default)]
+    pub gh_proxies: Vec,
+}
+
+impl Default for MirrorTable {
+    fn default() -> Self {
+        Self::builtin()
+    }
+}
+
+impl MirrorTable {
+    /// 内置清单(编译期嵌入)。
+    #[must_use]
+    pub fn builtin() -> Self {
+        Self::parse(include_str!("mirrors.json")).unwrap_or_else(|err| {
+            debug_note(&format!("内置 mirrors.json 解析失败(不应发生):{err}"));
+            Self {
+                version: 0,
+                notes: Vec::new(),
+                rules: Vec::new(),
+                adoptium: Vec::new(),
+                oracle_openjdk: Vec::new(),
+                microsoft_openjdk: None,
+                zulu: None,
+                gh_proxies: Vec::new(),
+            }
+        })
+    }
+
+    /// 从 JSON 解析。
+    pub fn parse(json: &str) -> Result {
+        serde_json::from_str(json)
+    }
+
+    /// 序列化成 JSON(诊断页/用户自定义文件回写用)。
+    pub fn to_json_pretty(&self) -> Result {
+        serde_json::to_string_pretty(self)
+    }
+
+    /// 追加用户自定义规则(插到最前,优先于内置规则)。
+    pub fn add_custom_rule(&mut self, rule: PrefixRule) {
+        self.rules.insert(0, rule);
+    }
+
+    /// 覆盖 gh-proxy 前缀(设置项);传 `None` 表示不默认启用。
+    pub fn set_gh_proxy(&mut self, prefix: Option<&str>) {
+        match prefix {
+            Some(prefix) => {
+                if let Some(proxy) = self.gh_proxies.first_mut() {
+                    proxy.prefix = prefix.to_string();
+                    proxy.enabled = true;
+                } else {
+                    self.gh_proxies.push(GhProxy {
+                        id: "gh-proxy".to_string(),
+                        name: "自定义 gh-proxy".to_string(),
+                        prefix: prefix.to_string(),
+                        enabled: true,
+                    });
+                }
+            }
+            None => {
+                for proxy in &mut self.gh_proxies {
+                    proxy.enabled = false;
+                }
+            }
+        }
+    }
+
+    /// 找到第一条命中的前缀规则。
+    #[must_use]
+    pub fn prefix_rule_for(&self, url: &str, kind: ResourceKind) -> Option<&PrefixRule> {
+        self.rules.iter().find(|rule| rule.matches(url, kind))
+    }
+
+    /// 清单里第一个启用的 gh-proxy 前缀(默认值来源;设置层用它初始化地区档案)。
+    #[must_use]
+    pub fn default_gh_proxy(&self) -> Option<&str> {
+        self.gh_proxies
+            .iter()
+            .find(|proxy| proxy.enabled && !proxy.prefix.is_empty())
+            .map(|proxy| proxy.prefix.as_str())
+    }
+
+    /// GitHub host 且允许时套 gh-proxy 前缀。
+    ///
+    /// **生效前缀的唯一真相源是 `region.gh_proxy`**(设置层/地区判定写入,`None` = 用户关闭)。
+    /// 表格里的 `gh_proxies` 只是「已知可用前缀」清单,配合 `default_gh_proxy()` 给设置做默认值。
+    #[must_use]
+    pub fn gh_proxy_candidates(&self, url: &str, region: &RegionProfile) -> Vec {
+        if region.mode != RegionMode::MirrorFirst {
+            return Vec::new();
+        }
+        let Some(prefix) = region
+            .gh_proxy
+            .as_deref()
+            .map(str::trim)
+            .filter(|prefix| !prefix.is_empty())
+        else {
+            return Vec::new();
+        };
+        let Some(host) = host_of(url) else {
+            return Vec::new();
+        };
+        if !is_github_host(host) {
+            return Vec::new();
+        }
+        vec![Candidate::new(SOURCE_GH_PROXY, format!("{prefix}{url}"))]
+    }
+
+    /// 展开候选序列:镜像优先模式下镜像在前、官方兜底;直连模式下官方在前、镜像兜底。
+    #[must_use]
+    pub fn expand(&self, url: &str, kind: ResourceKind, region: &RegionProfile) -> Vec {
+        let official = Candidate::new(direct_source_id(url), url);
+        let mut mirrors: Vec = Vec::new();
+        let mut official_first = false;
+
+        for candidate in self.adoptium_candidates(url) {
+            push_unique(&mut mirrors, url, candidate);
+        }
+        if let Some(rule) = self.prefix_rule_for(url, kind) {
+            if let Some(candidate) = rule.apply(url) {
+                // 「仅加速不替代」的规则在镜像优先模式下也排官方之后。
+                official_first = rule.fallback;
+                push_unique(&mut mirrors, url, candidate);
+            }
+        }
+        for candidate in self.gh_proxy_candidates(url, region) {
+            push_unique(&mut mirrors, url, candidate);
+        }
+
+        if region.mode == RegionMode::MirrorFirst && !official_first {
+            mirrors.push(official);
+            mirrors
+        } else {
+            let mut out = Vec::with_capacity(mirrors.len() + 1);
+            out.push(official);
+            out.append(&mut mirrors);
+            out
+        }
+    }
+}
+
+fn push_unique(list: &mut Vec, official_url: &str, candidate: Candidate) {
+    if candidate.url == official_url || list.iter().any(|known| known.url == candidate.url) {
+        return;
+    }
+    list.push(candidate);
+}
+
+/// 直连候选的源标识。
+///
+/// 绝大多数资源是 Mojang/CDN 的「官方源」,标 `official`;但 JDK 的四家发行版里,
+/// Oracle(国内镜像)/ MS(VS CDN)/ Zulu(Azul CDN)的**直连 URL 本身就是某个厂商的 CDN**,
+/// 标成 `official` 会让用户和健康度记忆都看错(「来源 official」其实来自华为云)。
+/// 所以:认得的厂商/镜像 host 用它的稳定 id,其余一律 `official`。
+#[must_use]
+pub fn direct_source_id(url: &str) -> String {
+    let id = source_id_for(url);
+    if MIRROR_SOURCE_IDS.contains(&id.as_str()) {
+        id
+    } else {
+        SOURCE_OFFICIAL.to_string()
+    }
+}
+
+/// 会被 `direct_source_id` 认出来的厂商/镜像源 id(都是「非官方 CDN/镜像」那一类)。
+pub const MIRROR_SOURCE_IDS: &[&str] = &[
+    "bmclapi",
+    "tuna",
+    "ustc",
+    "sjtu",
+    "bfsu",
+    "nju",
+    "huawei",
+    "azul",
+    "microsoft",
+    SOURCE_GH_PROXY,
+];
+
+/// 从 URL 里抠出 host(无 `url` 依赖)。
+#[must_use]
+pub fn host_of(url: &str) -> Option<&str> {
+    let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
+    let authority = rest.split(['/', '?', '#']).next()?;
+    let host_port = authority.rsplit('@').next()?;
+    let host = host_port.split(':').next()?;
+    if host.is_empty() {
+        None
+    } else {
+        Some(host)
+    }
+}
+
+/// 是否是 GitHub 系 host(gh-proxy 只对这些套前缀)。
+#[must_use]
+pub fn is_github_host(host: &str) -> bool {
+    matches!(
+        host,
+        "github.com"
+            | "raw.githubusercontent.com"
+            | "objects.githubusercontent.com"
+            | "codeload.github.com"
+            | "gist.github.com"
+            | "gist.githubusercontent.com"
+    ) || host.ends_with(".githubusercontent.com")
+}
+
+/// URL 的源标识(健康度按此归并;不知名 host 直接用 host)。
+#[must_use]
+pub fn source_id_for(url: &str) -> String {
+    let Some(host) = host_of(url) else {
+        return SOURCE_OFFICIAL.to_string();
+    };
+    if host.ends_with("bangbang93.com") {
+        return "bmclapi".to_string();
+    }
+    if host.contains("tuna.tsinghua.edu.cn") {
+        return "tuna".to_string();
+    }
+    if host.contains("ustc.edu.cn") {
+        return "ustc".to_string();
+    }
+    if host.contains("sjtu.edu.cn") {
+        return "sjtu".to_string();
+    }
+    if host.contains("bfsu.edu.cn") {
+        return "bfsu".to_string();
+    }
+    if host.contains("nju.edu.cn") {
+        return "nju".to_string();
+    }
+    if host.contains("huaweicloud.com") {
+        return "huawei".to_string();
+    }
+    if host.contains("azul.com") {
+        return "azul".to_string();
+    }
+    if host.contains("visualstudio.microsoft.com") || host == "aka.ms" {
+        return "microsoft".to_string();
+    }
+    if host.contains("gh-proxy") {
+        return SOURCE_GH_PROXY.to_string();
+    }
+    host.to_string()
+}
+
+fn debug_note(message: &str) {
+    if cfg!(debug_assertions) {
+        eprintln!("[ull::download] {message}");
+    } else {
+        let _ = message;
+    }
+}
diff --git a/crates/core/src/download/mod.rs b/crates/core/src/download/mod.rs
new file mode 100644
index 0000000..a66155a
--- /dev/null
+++ b/crates/core/src/download/mod.rs
@@ -0,0 +1,90 @@
+//! 下载引擎:多源健康度、指数退避、分层下载(大文件分块)、边下边算、原子落盘。
+//!
+//! 设计定稿:`DevDocs/PlanDoc/05-download-and-mirrors.md`(决策 D7)。
+//! 镜像清单随更新可刷新(BMCLAPI / TUNA / USTC / SJTU / gh-proxy),
+//! 地区自动判定 = 时区 + 语言 + 网络探测组合信号,可手动覆盖。
+//!
+//! 与其它模块的契约:`DownloadRequest` / 进度事件的类型需与 game、agent 对齐
+//! (`DevDocs/SubAgentsTalk/README.md` 的对齐点)。
+//!
+//! # 公共面
+//!
+//! 调用方只需要三件套:构造 [`DownloadRequest`] → [`DownloadEngine::download`] →
+//! 消费 [`ProgressEvent`]。「用哪个镜像、要不要续传、开几条连接、失败重试几次」
+//! 全部由引擎内部决定,调用方既不拼镜像 URL 也不写重试循环。
+//!
+//! 其余构件(镜像表 / 地区档案 / 健康度 / 内容寻址缓存 / 续传元数据)都作为
+//! 可注入对象暴露出来:设置层要把它们接进 schema,诊断页要直接读它们的状态,
+//! 测试要能把它们换成确定性的假件。
+//!
+//! # 分层
+//!
+//! ```text
+//! engine        编排:缓存直取 → 候选规划 → 限流 → 候选外循环 / 退避内循环
+//! transfer      单连接尝试(续传 + 流式校验)
+//! transfer_chunk 多连接分块编排
+//! chunked       分块规划 / Range 语义 / 单块抓取
+//! mirrors       镜像清单 + URL 前缀重写      adoptium  JDK 院校镜像
+//! region        时区 + 语言 + 探测 → 直连/镜像优先
+//! health        源 × 资源类型的健康度记忆
+//! cache         内容寻址缓存(记录信任)      atomic    落盘守卫 / 原子写
+//! resume        保守续传判定                  verify    流式摘要
+//! progress      事件流                        error     错误分类
+//! ```
+//!
+//! 落盘守卫([`PartFile`])中间文件名是 `.ullpart--`:`session` 是
+//! 进程号,`seq` 是进程内序号。同一数据根跑两个启动器进程时不会互相搬走对方的中间文件
+//! (M35 真机:并行两次 `ull install` 各失败数百个资源,`os error 2`);旧续传材料由
+//! `PartFile::for_dest` 收养,单进程语义不变。
+//!
+//! 依赖纪律:本模块只用 `reqwest` / `tokio` / `futures-util` / `sha1` / `sha2` /
+//! `serde` / `serde_json`,不触碰任何 UI 框架(`crates/core` 的硬约束)。
+
+// 对外可用的构件(设置层 / 诊断页 / 其它模块直接消费)。
+pub mod adoptium;
+pub mod backoff;
+pub mod blocking;
+pub mod cache;
+pub mod chunked;
+pub mod config;
+pub mod engine;
+pub mod error;
+pub mod health;
+pub mod jdk_sources;
+pub mod mirrors;
+pub mod progress;
+pub mod region;
+pub mod region_system;
+pub mod resume;
+pub mod types;
+pub mod verify;
+
+// 内部实现细节:只在 download 模块内(含 crate 内)可见,不承诺兼容性。
+pub(crate) mod atomic;
+pub(crate) mod bytes;
+pub(crate) mod health_store;
+pub(crate) mod http;
+pub(crate) mod transfer;
+pub(crate) mod transfer_chunk;
+
+pub use self::adoptium::{AdoptiumArtifact, JdkFlavor};
+pub use self::atomic::{PartFile, ADOPT_MIN_AGE, META_SUFFIX, ORPHAN_MAX_AGE, PART_SUFFIX};
+pub use self::backoff::{BackoffPolicy, Jitter};
+pub use self::blocking::block_on;
+pub use self::cache::{CacheRecord, CacheStats, ContentCache};
+pub use self::chunked::{ByteRange, ChunkedConfig, RangeSupport};
+pub use self::config::EngineConfig;
+pub use self::engine::DownloadEngine;
+pub use self::error::{DownloadError, DownloadErrorKind};
+pub use self::health::{HealthKey, HealthRegistry, SourceHealth};
+pub use self::jdk_sources::{MsOpenJdkTable, OracleMirror, ZuluTable};
+pub use self::mirrors::{direct_source_id, AdoptiumMirror, GhProxy, MirrorTable, PrefixRule};
+pub use self::progress::{ProgressEmitter, ProgressEvent, ProgressThrottle};
+pub use self::region::{RegionMode, RegionProfile, RegionTrigger, Signals};
+pub use self::region_system::detect_signals;
+pub use self::resume::{RestartReason, ResumeDecision, ResumeMeta};
+pub use self::types::{
+    Candidate, DownloadId, DownloadOutcome, DownloadRequest, HashAlgo, Integrity, ResourceKind,
+    ResumePolicy, SOURCE_OFFICIAL,
+};
+pub use self::verify::Hasher;
diff --git a/crates/core/src/download/progress.rs b/crates/core/src/download/progress.rs
new file mode 100644
index 0000000..4dea5f0
--- /dev/null
+++ b/crates/core/src/download/progress.rs
@@ -0,0 +1,197 @@
+//! 进度事件与发射器。
+//!
+//! 事件是「字节级 + 文件级」双层的载体(设计文档 §5):文件级由 `Started/Completed/Failed`
+//! 表达,字节级由节流后的 `Progress` 表达。`total_bytes` 未知时给 `None`——不做假进度。
+
+use std::time::{Duration, Instant};
+
+use serde::{Deserialize, Serialize};
+use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
+
+use super::error::DownloadErrorKind;
+use super::types::{DownloadId, HashAlgo, ResourceKind};
+
+/// 一条下载进度事件。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "event", rename_all = "snake_case")]
+pub enum ProgressEvent {
+    /// 开始一次下载(含候选数量与总字节预算)。
+    Started {
+        /// 关联 id。
+        id: DownloadId,
+        /// 资源类型。
+        kind: ResourceKind,
+        /// 逻辑 URL。
+        url: String,
+        /// 落盘路径。
+        dest: String,
+        /// 候选源数量。
+        candidates: usize,
+        /// 总字节数(未知为 `None`)。
+        total_bytes: Option,
+    },
+    /// 字节级进度(节流后)。
+    Progress {
+        /// 关联 id。
+        id: DownloadId,
+        /// 已下载字节。
+        downloaded: u64,
+        /// 总字节数(未知为 `None`)。
+        total_bytes: Option,
+    },
+    /// 某个源失败(单次尝试)。
+    SourceFailed {
+        /// 关联 id。
+        id: DownloadId,
+        /// 源标识。
+        source: String,
+        /// 失败原因(短描述)。
+        reason: String,
+        /// 第几次尝试(从 0 起)。
+        attempt: u32,
+        /// 是否还会在同源重试。
+        will_retry: bool,
+        /// 下次重试的等待毫秒数。
+        retry_in_ms: u64,
+    },
+    /// 切换到下一个候选源。
+    SourceSwitched {
+        /// 关联 id。
+        id: DownloadId,
+        /// 原源。
+        from: String,
+        /// 新源。
+        to: String,
+    },
+    /// 进入校验阶段。
+    Verifying {
+        /// 关联 id。
+        id: DownloadId,
+        /// 算法。
+        algo: HashAlgo,
+        /// 期望摘要前缀(8 位,够定位又不太啰嗦)。
+        expected_prefix: String,
+    },
+    /// 下载成功。
+    Completed {
+        /// 关联 id。
+        id: DownloadId,
+        /// 字节数。
+        bytes: u64,
+        /// 是否命中内容寻址缓存。
+        from_cache: bool,
+        /// 成功的源。
+        source: String,
+        /// 耗时毫秒。
+        elapsed_ms: u64,
+    },
+    /// 下载失败(所有候选源都失败或不可重试)。
+    Failed {
+        /// 关联 id。
+        id: DownloadId,
+        /// 错误分类。
+        kind: DownloadErrorKind,
+        /// 错误描述。
+        message: String,
+    },
+}
+
+impl ProgressEvent {
+    /// 事件归属的下载 id。
+    #[must_use]
+    pub const fn id(&self) -> DownloadId {
+        match self {
+            Self::Started { id, .. }
+            | Self::Progress { id, .. }
+            | Self::SourceFailed { id, .. }
+            | Self::SourceSwitched { id, .. }
+            | Self::Verifying { id, .. }
+            | Self::Completed { id, .. }
+            | Self::Failed { id, .. } => *id,
+        }
+    }
+}
+
+/// 进度发射器:没有接收端时退化为空操作(core 不关心谁在听)。
+#[derive(Debug, Clone, Default)]
+pub struct ProgressEmitter {
+    tx: Option>,
+}
+
+impl ProgressEmitter {
+    /// 静默发射器(测试、后台静默下载用)。
+    #[must_use]
+    pub const fn silent() -> Self {
+        Self { tx: None }
+    }
+
+    /// 建一对(发射器, 接收端);接收端由 app 层转成 IPC DTO。
+    #[must_use]
+    pub fn channel() -> (Self, UnboundedReceiver) {
+        let (tx, rx) = mpsc::unbounded_channel();
+        (Self { tx: Some(tx) }, rx)
+    }
+
+    /// 发一条事件;接收端已关闭则静默丢弃。
+    pub fn emit(&self, event: ProgressEvent) {
+        if let Some(tx) = &self.tx {
+            let _ = tx.send(event);
+        }
+    }
+
+    /// 是否没有接收端。
+    #[must_use]
+    pub const fn is_silent(&self) -> bool {
+        self.tx.is_none()
+    }
+}
+
+/// 进度节流器:距上次发射不足 `interval` 且未完成时压掉本次事件。
+#[derive(Debug, Clone)]
+pub struct ProgressThrottle {
+    interval: Duration,
+    last_emit: Option,
+}
+
+impl ProgressThrottle {
+    /// 构造节流器。
+    #[must_use]
+    pub const fn new(interval: Duration) -> Self {
+        Self {
+            interval,
+            last_emit: None,
+        }
+    }
+
+    /// 节流间隔。
+    #[must_use]
+    pub const fn interval(&self) -> Duration {
+        self.interval
+    }
+
+    /// 是否应该发射本次进度(`finished = true` 时无条件放行)。
+    pub fn should_emit(&mut self, now: Instant, finished: bool) -> bool {
+        if finished {
+            self.last_emit = Some(now);
+            return true;
+        }
+        match self.last_emit {
+            Some(last) if now.duration_since(last) < self.interval => false,
+            _ => {
+                self.last_emit = Some(now);
+                true
+            }
+        }
+    }
+
+    /// 复位(换源/重试时重新计时)。
+    pub fn reset(&mut self) {
+        self.last_emit = None;
+    }
+}
+
+impl Default for ProgressThrottle {
+    fn default() -> Self {
+        Self::new(Duration::from_millis(250))
+    }
+}
diff --git a/crates/core/src/download/region.rs b/crates/core/src/download/region.rs
new file mode 100644
index 0000000..5dc7c09
--- /dev/null
+++ b/crates/core/src/download/region.rs
@@ -0,0 +1,343 @@
+//! 地区自动判定:时区 + 系统语言 + 网络探测 → 直连 / 镜像优先。
+//!
+//! 设计文档 05 §3.2(用户 Q19 定案):
+//!
+//! * 信号①时区 ∈ 大中华区列表,或 信号②系统语言 zh-* → 「大中华区候选」;
+//! * 信号③网络探测(google/baidu,各 3s 超时,结果缓存 24h,可关);
+//! * 判定:大中华区 && google 不可达 → **镜像优先**(并启用 gh-proxy);
+//!   google 可达(已有全局代理)→ **直连**(尊重用户代理);非大中华区 → 直连;
+//! * 探测被关闭时,中文环境默认镜像优先(用户拍板的默认值)。
+//!
+//! 判定结果可由用户在设置里钉死(`RegionTrigger::UserPinned`),此时不参与自动重判。
+
+use std::path::Path;
+use std::time::Duration;
+
+use serde::{Deserialize, Serialize};
+
+use super::atomic::write_atomic;
+
+/// 大中华区时区(IANA 名)。
+pub const GREATER_CHINA_TIMEZONES: &[&str] = &[
+    "Asia/Shanghai",
+    "Asia/Urumqi",
+    "Asia/Chongqing",
+    "Asia/Harbin",
+    "Asia/Hong_Kong",
+    "Asia/Macau",
+    "Asia/Taipei",
+];
+
+/// 中文语言前缀(大小写不敏感的前缀匹配)。
+pub const GREATER_CHINA_LANGUAGE_PREFIXES: &[&str] = &["zh", "wuu", "yue", "lzh"];
+
+/// 默认 gh-proxy 前缀(2026-10 实测可用;设置可改,`None` 表示不默认启用)。
+pub const DEFAULT_GH_PROXY: &str = "https://gh-proxy.com/";
+
+/// 探测超时(设计文档定死 3s)。
+pub const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
+
+/// 自动判定的有效期(24h)。
+pub const DECISION_TTL_MS: u64 = 24 * 60 * 60 * 1000;
+
+/// google 可达性探测点(204 短响应)。
+pub const GOOGLE_PROBE_URL: &str = "https://www.gstatic.com/generate_204";
+
+/// 百度可达性探测点。
+pub const BAIDU_PROBE_URL: &str = "https://www.baidu.com/robots.txt";
+
+/// 源选择模式。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RegionMode {
+    /// 直连:官方在前,镜像只做兜底。
+    Direct,
+    /// 镜像优先:镜像在前,官方兜底;GitHub 资源套 gh-proxy。
+    MirrorFirst,
+}
+
+impl RegionMode {
+    /// 稳定字符串 id。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Direct => "direct",
+            Self::MirrorFirst => "mirror_first",
+        }
+    }
+}
+
+/// 判定来源:自动 or 用户钉死。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RegionTrigger {
+    /// 自动判定(24h 后可重判)。
+    Auto,
+    /// 用户手动钉死(不参与自动重判)。
+    UserPinned,
+}
+
+/// 地区档案:引擎唯一需要知道的「用哪套源顺序」。
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct RegionProfile {
+    /// 直连 / 镜像优先。
+    pub mode: RegionMode,
+    /// 判定来源。
+    pub trigger: RegionTrigger,
+    /// 是否落在「大中华区候选」。
+    pub greater_china: bool,
+    /// gh-proxy 前缀(`None` = 不启用)。
+    pub gh_proxy: Option,
+    /// 是否做过网络探测(设置里可关)。
+    pub probes_enabled: bool,
+    /// 判定时间(unix 毫秒)。
+    pub decided_at_ms: u64,
+}
+
+impl Default for RegionProfile {
+    fn default() -> Self {
+        Self::direct(0)
+    }
+}
+
+impl RegionProfile {
+    /// 显式直连。
+    #[must_use]
+    pub fn direct(now_ms: u64) -> Self {
+        Self {
+            mode: RegionMode::Direct,
+            trigger: RegionTrigger::Auto,
+            greater_china: false,
+            gh_proxy: None,
+            probes_enabled: false,
+            decided_at_ms: now_ms,
+        }
+    }
+
+    /// 镜像优先(并启用默认 gh-proxy)。
+    #[must_use]
+    pub fn mirror_first(now_ms: u64) -> Self {
+        Self {
+            mode: RegionMode::MirrorFirst,
+            trigger: RegionTrigger::Auto,
+            greater_china: true,
+            gh_proxy: Some(DEFAULT_GH_PROXY.to_string()),
+            probes_enabled: false,
+            decided_at_ms: now_ms,
+        }
+    }
+
+    /// 用户钉死某个模式(设置页「直连 / 镜像优先」)。
+    #[must_use]
+    pub fn pinned(mode: RegionMode, now_ms: u64) -> Self {
+        let mut profile = match mode {
+            RegionMode::Direct => Self::direct(now_ms),
+            RegionMode::MirrorFirst => Self::mirror_first(now_ms),
+        };
+        profile.trigger = RegionTrigger::UserPinned;
+        profile
+    }
+
+    /// gh-proxy 是否真的会生效(镜像优先 + 配了前缀)。
+    #[must_use]
+    pub fn gh_proxy_enabled(&self) -> bool {
+        self.mode == RegionMode::MirrorFirst
+            && self
+                .gh_proxy
+                .as_deref()
+                .is_some_and(|prefix| !prefix.is_empty())
+    }
+
+    /// 是否按「国内」口径选源(镜像优先,或判定落在大中华区)。
+    ///
+    /// 给 JDK 发行版默认优先级用(D23:国内 Temurin→Oracle→MS,国外 Temurin→MS→Oracle):
+    /// 用户把模式钉成直连但机器仍在国内时,仍然应该吃国内镜像更快的那个顺序。
+    #[must_use]
+    pub fn is_domestic(&self) -> bool {
+        self.mode == RegionMode::MirrorFirst || self.greater_china
+    }
+
+    /// 覆盖 gh-proxy 前缀。
+    #[must_use]
+    pub fn with_gh_proxy(mut self, prefix: Option<&str>) -> Self {
+        self.gh_proxy = prefix
+            .map(str::trim)
+            .filter(|prefix| !prefix.is_empty())
+            .map(str::to_string);
+        self
+    }
+
+    /// 标记是否允许网络探测。
+    #[must_use]
+    pub fn with_probes(mut self, enabled: bool) -> Self {
+        self.probes_enabled = enabled;
+        self
+    }
+
+    /// 自动判定是否已过期(用户钉死永不过期)。
+    #[must_use]
+    pub fn is_stale(&self, now_ms: u64) -> bool {
+        self.trigger == RegionTrigger::Auto
+            && now_ms.saturating_sub(self.decided_at_ms) > DECISION_TTL_MS
+    }
+
+    /// 设置页展示用的中文标签。
+    #[must_use]
+    pub const fn label(&self) -> &'static str {
+        match self.mode {
+            RegionMode::Direct => "直连",
+            RegionMode::MirrorFirst => "镜像优先",
+        }
+    }
+}
+
+/// 判定输入信号。`None` 表示「未知/未探测」。
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct Signals {
+    /// 时区名(IANA)。
+    pub timezone: Option,
+    /// 系统语言列表(BCP-47 或 POSIX locale,取首个非空即可)。
+    pub languages: Vec,
+    /// google 可达性(`None` = 未探测/探测被关)。
+    pub google_reachable: Option,
+    /// baidu 可达性(诊断信息,暂不参与判定)。
+    pub baidu_reachable: Option,
+}
+
+impl Signals {
+    /// 从环境变量读(尽力而为:Windows 上通常没有 `TZ`,此时由 platform 层补精确值)。
+    #[must_use]
+    pub fn from_env() -> Self {
+        let timezone = std::env::var("TZ").ok().filter(|tz| !tz.trim().is_empty());
+        let mut languages = Vec::new();
+        for key in ["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"] {
+            if let Ok(value) = std::env::var(key) {
+                for part in value.split([':', ';']) {
+                    let part = part.trim();
+                    if !part.is_empty() && !languages.iter().any(|known: &String| known == part) {
+                        languages.push(part.to_string());
+                    }
+                }
+            }
+        }
+        Self {
+            timezone,
+            languages,
+            google_reachable: None,
+            baidu_reachable: None,
+        }
+    }
+
+    /// 时区是否落在大中华区。
+    #[must_use]
+    pub fn timezone_in_greater_china(&self) -> bool {
+        self.timezone.as_deref().is_some_and(|tz| {
+            GREATER_CHINA_TIMEZONES
+                .iter()
+                .any(|known| known.eq_ignore_ascii_case(tz.trim()))
+        })
+    }
+
+    /// 系统语言是否是中文(含 `zh_CN.UTF-8` 这类 POSIX 形态)。
+    #[must_use]
+    pub fn language_in_greater_china(&self) -> bool {
+        self.languages.iter().any(|language| {
+            let lower = language.to_ascii_lowercase().replace('_', "-");
+            GREATER_CHINA_LANGUAGE_PREFIXES
+                .iter()
+                .any(|prefix| lower.starts_with(prefix))
+        })
+    }
+
+    /// 是否是大中华区候选(信号① 或 信号②)。
+    #[must_use]
+    pub fn is_greater_china(&self) -> bool {
+        self.timezone_in_greater_china() || self.language_in_greater_china()
+    }
+}
+
+/// 纯判定函数(不发请求 → 好测)。
+#[must_use]
+pub fn decide_from(signals: &Signals, now_ms: u64) -> RegionProfile {
+    let greater_china = signals.is_greater_china();
+    let mode = match (greater_china, signals.google_reachable) {
+        // 非大中华区:一律直连。
+        (false, _) => RegionMode::Direct,
+        // 大中华区但 google 可达:用户已有全局代理,尊重直连。
+        (true, Some(true)) => RegionMode::Direct,
+        // 大中华区且 google 不可达:镜像优先。
+        (true, Some(false)) => RegionMode::MirrorFirst,
+        // 探测被关:中文环境默认镜像优先(用户拍板)。
+        (true, None) => RegionMode::MirrorFirst,
+    };
+    let mut profile = match mode {
+        RegionMode::Direct => RegionProfile::direct(now_ms),
+        RegionMode::MirrorFirst => RegionProfile::mirror_first(now_ms),
+    };
+    profile.greater_china = greater_china;
+    profile.probes_enabled = signals.google_reachable.is_some();
+    if mode == RegionMode::Direct {
+        profile.gh_proxy = None;
+    }
+    profile
+}
+
+/// 单点可达性探测(3s 超时,任何错误都算不可达)。
+pub async fn probe_reachable(client: &reqwest::Client, url: &str) -> bool {
+    let request = client.get(url).header(reqwest::header::RANGE, "bytes=0-0");
+    match tokio::time::timeout(PROBE_TIMEOUT, request.send()).await {
+        Ok(Ok(response)) => {
+            let status = response.status();
+            status.is_success() || status.is_redirection() || status.as_u16() == 204
+        }
+        _ => false,
+    }
+}
+
+/// 并发探测 google 与 baidu(两个都 3s 上限,总耗时约 3s)。
+pub async fn probe_signals(client: &reqwest::Client) -> (Option, Option) {
+    let (google, baidu) = tokio::join!(
+        probe_reachable(client, GOOGLE_PROBE_URL),
+        probe_reachable(client, BAIDU_PROBE_URL)
+    );
+    (Some(google), Some(baidu))
+}
+
+/// 完整判定:读信号 →(可选)探测 → 决定。
+pub async fn detect(client: &reqwest::Client, probes_enabled: bool, now_ms: u64) -> RegionProfile {
+    let mut signals = Signals::from_env();
+    if probes_enabled {
+        let (google, baidu) = probe_signals(client).await;
+        signals.google_reachable = google;
+        signals.baidu_reachable = baidu;
+    }
+    decide_from(&signals, now_ms)
+}
+
+/// 判定结果的落盘形态。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct PersistedProfile {
+    version: u32,
+    profile: RegionProfile,
+}
+
+/// 读 24h 缓存(过期或损坏返回 `None`)。
+#[must_use]
+pub fn load_cached(path: &Path, now_ms: u64) -> Option {
+    let bytes = std::fs::read(path).ok()?;
+    let persisted: PersistedProfile = serde_json::from_slice(&bytes).ok()?;
+    if persisted.profile.is_stale(now_ms) {
+        return None;
+    }
+    Some(persisted.profile)
+}
+
+/// 写 24h 缓存(原子写)。
+pub fn store_cached(path: &Path, profile: &RegionProfile) -> std::io::Result<()> {
+    let persisted = PersistedProfile {
+        version: 1,
+        profile: profile.clone(),
+    };
+    let json = serde_json::to_vec_pretty(&persisted).map_err(std::io::Error::other)?;
+    write_atomic(path, &json)
+}
diff --git a/crates/core/src/download/region_system.rs b/crates/core/src/download/region_system.rs
new file mode 100644
index 0000000..eb60e48
--- /dev/null
+++ b/crates/core/src/download/region_system.rs
@@ -0,0 +1,158 @@
+//! 地区判定的**系统级**信号补充:环境变量之外的那一半(D23「国内用户默认都走镜像」)。
+//!
+//! 为什么需要它:`Signals::from_env` 只读 `TZ` / `LANG` / `LC_*`。Windows 上这三个通常**全是空的**
+//! ——中文版 Windows 既不设 `TZ` 也不设 `LANG`,于是「国内机器」被误判成国外,
+//! D23 定下的国内默认序(Temurin→Oracle→MS)就拿不到了。本机实测就是这个现象。
+//!
+//! 补法(只在环境变量没给出答案时才做,且读不到就静默跳过):
+//!
+//! | 平台 | 时区 | 语言 |
+//! | --- | --- | --- |
+//! | Windows | `tzutil /g` → `China Standard Time` | `reg query` 的 `LocaleName` → `zh-CN` |
+//! | 其它 | 不补(Unix 一般都有 `LANG`,`/etc/localtime` 解析成本不划算) | 同左 |
+//!
+//! 两个纪律:①**不引依赖**——`tzutil` / `reg` 都是系统自带,`discover.rs` 早就有 `reg query`
+//! 的先例;②**不做网络探测**——探测(google 可达性)仍由调用方决定开不开。
+
+use std::process::Command;
+
+use super::region::{Signals, GREATER_CHINA_TIMEZONES};
+
+/// Windows 时区显示名里能判成「大中华区」的关键词(`tzutil /g` 的输出形态)。
+const GREATER_CHINA_ZONE_HINTS: &[&str] = &[
+    "China Standard Time",
+    "Taipei Standard Time",
+    "Hong Kong Standard Time",
+    "Ulaanbaatar",
+];
+
+/// 系统级信号(环境变量之外;读不到就是空信号)。
+#[must_use]
+pub fn system_signals() -> Signals {
+    let mut signals = Signals::default();
+    if !cfg!(windows) {
+        return signals;
+    }
+    if let Some(zone) = windows_timezone() {
+        if zone_in_greater_china(&zone) {
+            // 折成 IANA 名,和 `Signals::from_env` 的取值空间保持一致。
+            signals.timezone = Some("Asia/Shanghai".to_string());
+        }
+    }
+    if let Some(locale) = windows_locale() {
+        signals.languages.push(locale);
+    }
+    signals
+}
+
+/// 环境变量信号 + 系统级信号(系统级只补空缺,不覆盖环境变量)。
+#[must_use]
+pub fn detect_signals() -> Signals {
+    let mut signals = Signals::from_env();
+    let system = system_signals();
+    if signals.timezone.is_none() {
+        signals.timezone = system.timezone;
+    }
+    for language in system.languages {
+        if !signals.languages.contains(&language) {
+            signals.languages.push(language);
+        }
+    }
+    signals
+}
+
+/// `tzutil /g` 的输出(失败为 `None`)。
+#[must_use]
+pub fn windows_timezone() -> Option {
+    let output = Command::new("tzutil").arg("/g").output().ok()?;
+    if !output.status.success() {
+        return None;
+    }
+    let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
+    (!text.is_empty()).then_some(text)
+}
+
+/// 系统的 locale 名(`HKCU\Control Panel\International` 的 `LocaleName`)。
+#[must_use]
+pub fn windows_locale() -> Option {
+    let output = Command::new("reg")
+        .args([
+            "query",
+            "HKCU\\Control Panel\\International",
+            "/v",
+            "LocaleName",
+        ])
+        .output()
+        .ok()?;
+    if !output.status.success() {
+        return None;
+    }
+    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
+    parse_reg_locale_name(&stdout)
+}
+
+/// 从 `reg query` 的输出里取 `LocaleName` 的值(`    LocaleName    REG_SZ    zh-CN`)。
+#[must_use]
+pub fn parse_reg_locale_name(stdout: &str) -> Option {
+    for line in stdout.lines() {
+        let line = line.trim();
+        if !line.starts_with("LocaleName") {
+            continue;
+        }
+        let value = line.rsplit("REG_SZ").next()?.trim();
+        if !value.is_empty() {
+            return Some(value.to_string());
+        }
+    }
+    None
+}
+
+/// Windows 时区显示名是否落在大中华区。
+#[must_use]
+pub fn zone_in_greater_china(zone: &str) -> bool {
+    let zone = zone.trim();
+    GREATER_CHINA_ZONE_HINTS
+        .iter()
+        .any(|hint| zone.eq_ignore_ascii_case(hint))
+        || GREATER_CHINA_TIMEZONES
+            .iter()
+            .any(|known| known.eq_ignore_ascii_case(zone))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{detect_signals, parse_reg_locale_name, zone_in_greater_china};
+
+    #[test]
+    fn windows_zone_names_map_to_the_greater_china_list() {
+        assert!(zone_in_greater_china("China Standard Time"));
+        assert!(zone_in_greater_china("Taipei Standard Time"));
+        assert!(zone_in_greater_china("Asia/Shanghai"), "IANA 名同样要认");
+        assert!(!zone_in_greater_china("Pacific Standard Time"));
+        assert!(!zone_in_greater_china(""));
+    }
+
+    #[test]
+    fn reg_output_yields_the_locale_name() {
+        // 真机 `reg query "HKCU\Control Panel\International" /v LocaleName` 的形态。
+        let stdout = "\r\nHKEY_CURRENT_USER\\Control Panel\\International\r\n    \
+                      LocaleName    REG_SZ    zh-CN\r\n\r\n";
+        assert_eq!(parse_reg_locale_name(stdout).as_deref(), Some("zh-CN"));
+        assert_eq!(
+            parse_reg_locale_name("    LocaleName    REG_SZ    \r\n"),
+            None
+        );
+        assert_eq!(parse_reg_locale_name("没有这一行"), None);
+    }
+
+    #[test]
+    fn detect_layers_system_signals_under_the_environment() {
+        // 环境变量优先:这里能断言的只有「不 panic 且返回一个 Signals」,
+        // 具体取值取决于跑测试的机器(CI 上没有 tzutil/reg 也要安静退化)。
+        let signals = detect_signals();
+        // 无论哪种情况,都不该凭空造出「大中华区」以外的时区值。
+        if let Some(zone) = &signals.timezone {
+            assert!(!zone.trim().is_empty());
+        }
+    }
+}
diff --git a/crates/core/src/download/resume.rs b/crates/core/src/download/resume.rs
new file mode 100644
index 0000000..976cf57
--- /dev/null
+++ b/crates/core/src/download/resume.rs
@@ -0,0 +1,235 @@
+//! 断点续传的**保守判定**。
+//!
+//! 承 analyse/03 §5.4 与设计文档 §4 最后一行:「拒绝『能续传但会坏』场景」是 HMCL 做对的事。
+//! 我们把它写成纯函数,判定条件全部显式:
+//!
+//! 1. 策略不是 `Never`;
+//! 2. 中间文件存在且长度 > 0;
+//! 3. 有上一次的元数据(URL、摘要、总长、校验器);
+//! 4. URL 与期望摘要都没变;
+//! 5. 总长已知且 `0 < 已下 < 总长`;
+//! 6. 有 ETag 或 Last-Modified 作为校验器(否则服务器换了内容我们也发现不了)。
+//!
+//! 任何一条不满足就**从头下**——重下 100MB 也比交付一个坏文件便宜。
+//! 另外单独处理 416(`Requested Range Not Satisfiable`):已下满则直接进入校验,
+//! 否则丢弃中间文件重来。
+
+use std::io;
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+
+use super::atomic::write_atomic;
+use super::types::{HashAlgo, ResumePolicy};
+
+/// 上一次下载留下的续传元数据。
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ResumeMeta {
+    /// 当时请求的 URL(镜像可能换掉,URL 变了就不续传)。
+    pub url: String,
+    /// 响应 `ETag`。
+    pub etag: Option,
+    /// 响应 `Last-Modified`。
+    pub last_modified: Option,
+    /// 响应告知的总长度。
+    pub total: Option,
+    /// 摘要算法。
+    pub algo: HashAlgo,
+    /// 期望摘要。
+    pub hash: String,
+    /// 保存时间(unix 毫秒)。
+    pub saved_at_ms: u64,
+}
+
+impl ResumeMeta {
+    /// 从磁盘读(不存在/损坏 → `None`)。
+    #[must_use]
+    pub fn load(path: &Path) -> Option {
+        let bytes = std::fs::read(path).ok()?;
+        serde_json::from_slice(&bytes).ok()
+    }
+
+    /// 原子写。
+    pub fn save(&self, path: &Path) -> io::Result<()> {
+        let json = serde_json::to_vec_pretty(self).map_err(io::Error::other)?;
+        write_atomic(path, &json)
+    }
+
+    /// 删除(校验通过或放弃续传时)。
+    pub fn remove(path: &Path) {
+        let _ = std::fs::remove_file(path);
+    }
+
+    /// 本记录可用的校验器。
+    #[must_use]
+    pub fn validator(&self) -> Validator {
+        Validator {
+            etag: self.etag.clone(),
+            last_modified: self.last_modified.clone(),
+        }
+    }
+}
+
+/// 续传用的校验器(`If-Range` 头的取值来源)。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Validator {
+    /// 强校验器。
+    pub etag: Option,
+    /// 弱校验器。
+    pub last_modified: Option,
+}
+
+impl Validator {
+    /// `If-Range` 头的值(ETag 优先,其次 Last-Modified)。
+    #[must_use]
+    pub fn if_range_value(&self) -> Option<&str> {
+        self.etag.as_deref().or(self.last_modified.as_deref())
+    }
+}
+
+/// 不复续传的原因(日志与诊断页用)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RestartReason {
+    /// 策略要求从不续传。
+    PolicyNever,
+    /// 没有中间文件。
+    NoPart,
+    /// 中间文件是 0 字节。
+    EmptyPart,
+    /// 没有元数据(无法证明中间文件是同一次下载的产物)。
+    NoMeta,
+    /// URL 变了(换源后偏移不可信)。
+    UrlChanged,
+    /// 期望摘要变了(目标文件已经不是同一个东西)。
+    HashChanged,
+    /// 总长未知(无法判断是否已下满)。
+    TotalUnknown,
+    /// 已下字节超过总长(中间文件被污染)。
+    Oversize,
+    /// 没有 ETag/Last-Modified,无法证明远端内容没变。
+    NoValidator,
+    /// 服务器 416 且未下满。
+    RangeUnsatisfiable,
+}
+
+impl RestartReason {
+    /// 稳定字符串 id。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::PolicyNever => "policy_never",
+            Self::NoPart => "no_part",
+            Self::EmptyPart => "empty_part",
+            Self::NoMeta => "no_meta",
+            Self::UrlChanged => "url_changed",
+            Self::HashChanged => "hash_changed",
+            Self::TotalUnknown => "total_unknown",
+            Self::Oversize => "oversize",
+            Self::NoValidator => "no_validator",
+            Self::RangeUnsatisfiable => "range_unsatisfiable",
+        }
+    }
+}
+
+/// 续传决策。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ResumeDecision {
+    /// 可以续传:从 `offset` 起发 `Range` + `If-Range`。
+    Resume {
+        /// 已下字节数。
+        offset: u64,
+        /// 校验器。
+        validator: Validator,
+    },
+    /// 从头下载。
+    Restart {
+        /// 原因。
+        reason: RestartReason,
+    },
+    /// 中间文件已经是完整长度:跳过传输,直接校验。
+    Complete {
+        /// 文件长度。
+        size: u64,
+    },
+}
+
+impl ResumeDecision {
+    /// 是否要丢掉中间文件(`Complete` 不能丢)。
+    #[must_use]
+    pub const fn should_drop_part(&self) -> bool {
+        matches!(self, Self::Restart { .. })
+    }
+}
+
+/// 判定是否续传(纯函数)。
+#[must_use]
+pub fn decide(
+    policy: ResumePolicy,
+    part_len: u64,
+    meta: Option<&ResumeMeta>,
+    url: &str,
+    integrity: Option<(HashAlgo, &str)>,
+    expected_size: Option,
+) -> ResumeDecision {
+    if policy == ResumePolicy::Never {
+        return ResumeDecision::Restart {
+            reason: RestartReason::PolicyNever,
+        };
+    }
+    if part_len == 0 {
+        return ResumeDecision::Restart {
+            reason: RestartReason::EmptyPart,
+        };
+    }
+    let Some(meta) = meta else {
+        return ResumeDecision::Restart {
+            reason: RestartReason::NoMeta,
+        };
+    };
+    if meta.url != url {
+        return ResumeDecision::Restart {
+            reason: RestartReason::UrlChanged,
+        };
+    }
+    if let Some((algo, hash)) = integrity {
+        if meta.algo != algo || !meta.hash.eq_ignore_ascii_case(hash) {
+            return ResumeDecision::Restart {
+                reason: RestartReason::HashChanged,
+            };
+        }
+    }
+    let Some(total) = meta.total.or(expected_size) else {
+        return ResumeDecision::Restart {
+            reason: RestartReason::TotalUnknown,
+        };
+    };
+    if part_len > total {
+        return ResumeDecision::Restart {
+            reason: RestartReason::Oversize,
+        };
+    }
+    if part_len == total {
+        return ResumeDecision::Complete { size: total };
+    }
+    let validator = meta.validator();
+    if validator.if_range_value().is_none() {
+        return ResumeDecision::Restart {
+            reason: RestartReason::NoValidator,
+        };
+    }
+    ResumeDecision::Resume {
+        offset: part_len,
+        validator,
+    }
+}
+
+/// 收到 416 后的决策。
+#[must_use]
+pub fn on_range_unsatisfiable(part_len: u64, total: Option) -> ResumeDecision {
+    match total {
+        Some(total) if part_len == total => ResumeDecision::Complete { size: total },
+        _ => ResumeDecision::Restart {
+            reason: RestartReason::RangeUnsatisfiable,
+        },
+    }
+}
diff --git a/crates/core/src/download/transfer.rs b/crates/core/src/download/transfer.rs
new file mode 100644
index 0000000..42f27ee
--- /dev/null
+++ b/crates/core/src/download/transfer.rs
@@ -0,0 +1,305 @@
+//! 传输细节:单连接(含续传)+ 多连接分块,产出「已校验的完整中间文件」。
+//!
+//! 本文件只管「一个候选源的一次尝试」——换源、退避、限流在 `engine.rs`。
+
+use std::time::Instant;
+
+use tokio::io::AsyncWriteExt;
+
+use super::atomic::PartFile;
+use super::cache::now_ms;
+use super::chunked;
+use super::engine::{AttemptOk, DownloadEngine};
+use super::error::{DownloadError, DownloadErrorKind};
+use super::http;
+use super::progress::{ProgressEvent, ProgressThrottle};
+use super::resume::{self, RestartReason, ResumeDecision, ResumeMeta};
+use super::types::{Candidate, DownloadId, DownloadRequest, HashAlgo};
+use super::verify;
+
+/// 续传起点(无续传时全为默认值)。
+struct ResumeCut {
+    /// 已下字节数。
+    offset: u64,
+    /// 续传起点(事件字段)。
+    resumed_from: u64,
+    /// `If-Range` 的取值。
+    if_range: Option,
+}
+
+impl ResumeCut {
+    /// 从头下载。
+    const fn none() -> Self {
+        Self {
+            offset: 0,
+            resumed_from: 0,
+            if_range: None,
+        }
+    }
+}
+
+impl DownloadEngine {
+    /// 一次尝试:续传判定 → 分块 or 单连接 → 流式校验。
+    pub(crate) async fn attempt(
+        &self,
+        id: DownloadId,
+        req: &DownloadRequest,
+        candidate: &Candidate,
+        part: &mut PartFile,
+    ) -> Result {
+        let integrity = req.integrity.as_ref();
+        let meta_path = part.meta_path();
+        let previous = ResumeMeta::load(&meta_path);
+        // 「没有中间文件」和「有但 0 字节」是两种不同的原因,分开报出来便于诊断。
+        let decision = if !part.exists() {
+            ResumeDecision::Restart {
+                reason: RestartReason::NoPart,
+            }
+        } else if part.is_empty() {
+            ResumeDecision::Restart {
+                reason: RestartReason::EmptyPart,
+            }
+        } else {
+            resume::decide(
+                req.resume,
+                part.len(),
+                previous.as_ref(),
+                &candidate.url,
+                integrity.map(|item| (item.algo, item.hex.as_str())),
+                req.expected_size,
+            )
+        };
+
+        let mut cut = ResumeCut::none();
+        match decision {
+            ResumeDecision::Complete { size } => {
+                self.verify_part(part, integrity, id)?;
+                return Ok(AttemptOk {
+                    bytes: size,
+                    resumed_from: size,
+                });
+            }
+            ResumeDecision::Resume { offset, validator } => {
+                if let Some(value) = validator.if_range_value() {
+                    cut = ResumeCut {
+                        offset,
+                        resumed_from: offset,
+                        if_range: Some(value.to_string()),
+                    };
+                }
+            }
+            ResumeDecision::Restart { .. } => {}
+        }
+
+        if cut.offset == 0 && self.cfg.chunked.applies_to(req.kind, req.expected_size) {
+            match self
+                .attempt_chunked(id, req, candidate, part, req.expected_size)
+                .await
+            {
+                Ok(ok) => return Ok(ok),
+                // Range 不支持/切不出足够段:静默退化为单连接(不算源失败)。
+                Err(err) if err.kind == DownloadErrorKind::RangeUnsupported => {}
+                Err(err) => return Err(err),
+            }
+        }
+
+        self.attempt_single(id, req, candidate, part, cut).await
+    }
+
+    async fn attempt_single(
+        &self,
+        id: DownloadId,
+        req: &DownloadRequest,
+        candidate: &Candidate,
+        part: &mut PartFile,
+        cut: ResumeCut,
+    ) -> Result {
+        let integrity = req.integrity.as_ref();
+        let mut offset = cut.offset;
+        let mut resumed_from = cut.resumed_from;
+        let mut request = self.http.get(&candidate.url);
+        if offset > 0 {
+            match cut.if_range.as_deref() {
+                Some(value) => {
+                    request = request
+                        .header(reqwest::header::RANGE, format!("bytes={offset}-"))
+                        .header(reqwest::header::IF_RANGE, value);
+                }
+                // 没有校验器就不能保证远端内容没变,直接从头下。
+                None => {
+                    offset = 0;
+                    resumed_from = 0;
+                }
+            }
+        }
+
+        let response = request.send().await.map_err(|err| {
+            http::classify_reqwest(&err, &candidate.url).with_source(candidate.source.as_str())
+        })?;
+        let status = response.status();
+
+        if status.as_u16() == 416 {
+            return match resume::on_range_unsatisfiable(part.len(), req.expected_size) {
+                ResumeDecision::Complete { size } => {
+                    self.verify_part(part, integrity, id)?;
+                    Ok(AttemptOk {
+                        bytes: size,
+                        resumed_from: size,
+                    })
+                }
+                _ => {
+                    part.discard();
+                    Err(DownloadError::new(
+                        DownloadErrorKind::RangeUnsupported,
+                        "416:丢弃中间文件重下",
+                    )
+                    .with_source(candidate.source.as_str()))
+                }
+            };
+        }
+        if !status.is_success() {
+            return Err(http::classify_status(status.as_u16(), &candidate.url)
+                .with_source(candidate.source.as_str()));
+        }
+
+        // 2xx 不等于「拿到归档」:镜像门户对任意路径都回 200 + HTML(华为云实测)。
+        // 归到 NotFound(换源,不算源坏不进冷却),否则假阳性源会一直挡在队首。
+        if let Some(reason) =
+            http::html_mirror_reason(response.headers(), req.expected_size, offset)
+        {
+            return Err(DownloadError::new(DownloadErrorKind::NotFound, reason)
+                .with_source(candidate.source.as_str()));
+        }
+
+        // 服务器无视了 Range(200 而非 206)→ 从头写。
+        if offset > 0 && status.as_u16() != 206 {
+            offset = 0;
+            resumed_from = 0;
+        }
+
+        let content_range = http::header_str(response.headers(), reqwest::header::CONTENT_RANGE)
+            .and_then(chunked::parse_content_range);
+        let etag = http::etag(response.headers());
+        let last_modified = http::last_modified(response.headers());
+        let content_length = http::content_length(response.headers());
+
+        let mut total = req.expected_size;
+        if let Some(content_range) = content_range {
+            if content_range.start != offset {
+                // 服务器给的起点和我们以为的不一样:偏移不可信,从头来。
+                offset = 0;
+                resumed_from = 0;
+            }
+            total = content_range.total.or(total);
+        }
+        total = total.or_else(|| content_length.map(|len| len + offset));
+
+        // 记下续传元数据(跨进程续传的凭据)。
+        let meta = ResumeMeta {
+            url: candidate.url.clone(),
+            etag,
+            last_modified,
+            total,
+            algo: integrity.map_or(HashAlgo::Sha1, |item| item.algo),
+            hash: integrity.map_or_else(String::new, |item| item.hex.clone()),
+            saved_at_ms: now_ms(),
+        };
+        let _ = meta.save(&part.meta_path());
+
+        let std_file = if offset > 0 {
+            part.open_append()
+        } else {
+            part.open_truncate()
+        }
+        .map_err(|err| io_err("打开中间文件失败", &err))?;
+        let mut file = tokio::fs::File::from_std(std_file);
+
+        let mut hasher = integrity.map(|item| verify::Hasher::new(item.algo));
+        if offset > 0 {
+            if let Some(hasher) = hasher.as_mut() {
+                verify::update_hasher_from_file(hasher, part.path(), offset)
+                    .map_err(|err| io_err("读取续传前缀失败", &err))?;
+            }
+        }
+
+        let mut throttle = ProgressThrottle::new(self.cfg.progress_throttle);
+        let mut written = offset;
+        let mut stream = response.bytes_stream();
+        loop {
+            let next = tokio::time::timeout(
+                self.cfg.read_timeout,
+                futures_util::StreamExt::next(&mut stream),
+            )
+            .await
+            .map_err(|_| {
+                DownloadError::new(
+                    DownloadErrorKind::Timeout,
+                    format!("读取超时({})", candidate.url),
+                )
+                .with_source(candidate.source.as_str())
+            })?;
+            let Some(chunk) = next else { break };
+            let chunk = chunk.map_err(|err| http::classify_reqwest(&err, &candidate.url))?;
+            if let Some(hasher) = hasher.as_mut() {
+                hasher.update(&chunk);
+            }
+            file.write_all(&chunk)
+                .await
+                .map_err(|err| io_err("写入中间文件失败", &err))?;
+            written += chunk.len() as u64;
+            if throttle.should_emit(Instant::now(), false) {
+                self.emit(ProgressEvent::Progress {
+                    id,
+                    downloaded: written,
+                    total_bytes: total,
+                });
+            }
+        }
+        file.flush()
+            .await
+            .map_err(|err| io_err("刷新中间文件失败", &err))?;
+        // Windows 上打开中的文件不能被 rename,必须先关。
+        drop(file);
+
+        if let Some(total) = total {
+            if written != total {
+                return Err(DownloadError::new(
+                    DownloadErrorKind::Network,
+                    format!("长度不符:期望 {total} 实得 {written}({})", candidate.url),
+                )
+                .with_source(candidate.source.as_str()));
+            }
+        }
+
+        if let (Some(integrity), Some(hasher)) = (integrity, hasher) {
+            self.emit(ProgressEvent::Verifying {
+                id,
+                algo: integrity.algo,
+                expected_prefix: integrity.short_prefix().to_string(),
+            });
+            let actual = hasher.finish();
+            if !verify::matches(&integrity.hex, &actual) {
+                part.discard();
+                return Err(DownloadError::new(
+                    DownloadErrorKind::Integrity,
+                    format!("校验不符:期望 {} 实得 {actual}", integrity.hex),
+                )
+                .with_source(candidate.source.as_str()));
+            }
+        }
+
+        self.emit(ProgressEvent::Progress {
+            id,
+            downloaded: written,
+            total_bytes: total,
+        });
+        Ok(AttemptOk {
+            bytes: written,
+            resumed_from,
+        })
+    }
+}
+
+fn io_err(context: &str, err: &std::io::Error) -> DownloadError {
+    DownloadError::new(DownloadErrorKind::Io, format!("{context}: {err}"))
+}
diff --git a/crates/core/src/download/transfer_chunk.rs b/crates/core/src/download/transfer_chunk.rs
new file mode 100644
index 0000000..c9bbe8a
--- /dev/null
+++ b/crates/core/src/download/transfer_chunk.rs
@@ -0,0 +1,163 @@
+//! 大文件多连接分块的编排与校验。
+//!
+//! 分块本身由 `chunked.rs` 负责(纯规划 + 单块抓取),这里负责并发、进度上报、
+//! 失败清理与「整文件重算一次摘要」(分块是乱序写,只能整文件校验)。
+
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::Arc;
+use std::time::Duration;
+
+use super::atomic::PartFile;
+use super::chunked;
+use super::engine::{AttemptOk, DownloadEngine};
+use super::error::{DownloadError, DownloadErrorKind};
+use super::progress::ProgressEvent;
+use super::types::{Candidate, DownloadId, DownloadRequest, Integrity};
+use super::verify;
+
+impl DownloadEngine {
+    pub(crate) async fn attempt_chunked(
+        &self,
+        id: DownloadId,
+        req: &DownloadRequest,
+        candidate: &Candidate,
+        part: &mut PartFile,
+        total: Option,
+    ) -> Result {
+        let Some(total) = total.filter(|total| *total > 0) else {
+            return Err(DownloadError::new(
+                DownloadErrorKind::RangeUnsupported,
+                "总长未知,无法分块",
+            ));
+        };
+        let ranges = chunked::plan(total, self.cfg.chunked.effective_connections());
+        if ranges.len() < 2 {
+            return Err(DownloadError::new(
+                DownloadErrorKind::RangeUnsupported,
+                "可切分段不足,退化为单连接",
+            ));
+        }
+
+        part.discard();
+        part.ensure_parent_async()
+            .await
+            .map_err(|err| io_err("创建下载目录失败", &err))?;
+        {
+            let file = tokio::fs::OpenOptions::new()
+                .create(true)
+                .write(true)
+                .truncate(true)
+                .open(part.path())
+                .await
+                .map_err(|err| io_err("预分配中间文件失败", &err))?;
+            file.set_len(total)
+                .await
+                .map_err(|err| io_err("预分配长度失败", &err))?;
+        }
+
+        let counter = Arc::new(AtomicU64::new(0));
+        let reporter = self.spawn_reporter(id, Arc::clone(&counter), total);
+
+        let mut set = tokio::task::JoinSet::new();
+        for range in ranges {
+            let client = self.http.clone();
+            let url = candidate.url.clone();
+            let path = part.path().to_path_buf();
+            let read_timeout = self.cfg.read_timeout;
+            let progress = Arc::clone(&counter);
+            set.spawn(async move {
+                chunked::fetch_chunk(&client, &url, range, &path, read_timeout, &progress).await
+            });
+        }
+
+        let mut failure: Option = None;
+        while let Some(joined) = set.join_next().await {
+            match joined {
+                Ok(Ok(_)) => {}
+                Ok(Err(err)) => {
+                    failure = Some(err.with_source(candidate.source.as_str()));
+                    set.abort_all();
+                    break;
+                }
+                Err(err) => {
+                    failure = Some(DownloadError::new(
+                        DownloadErrorKind::Network,
+                        format!("分块任务异常:{err}"),
+                    ));
+                    set.abort_all();
+                    break;
+                }
+            }
+        }
+        reporter.abort();
+
+        if let Some(err) = failure {
+            part.discard();
+            return Err(err);
+        }
+
+        // 分块是乱序写,只能整文件重算一次摘要。
+        self.verify_part(part, req.integrity.as_ref(), id)?;
+        self.emit(ProgressEvent::Progress {
+            id,
+            downloaded: total,
+            total_bytes: Some(total),
+        });
+        Ok(AttemptOk {
+            bytes: total,
+            resumed_from: 0,
+        })
+    }
+
+    fn spawn_reporter(
+        &self,
+        id: DownloadId,
+        counter: Arc,
+        total: u64,
+    ) -> tokio::task::JoinHandle<()> {
+        let emitter = self.progress.clone();
+        let interval = self.cfg.progress_throttle.max(Duration::from_millis(50));
+        tokio::spawn(async move {
+            let mut ticker = tokio::time::interval(interval);
+            loop {
+                ticker.tick().await;
+                let downloaded = counter.load(Ordering::Relaxed).min(total);
+                emitter.emit(ProgressEvent::Progress {
+                    id,
+                    downloaded,
+                    total_bytes: Some(total),
+                });
+            }
+        })
+    }
+
+    pub(crate) fn verify_part(
+        &self,
+        part: &mut PartFile,
+        integrity: Option<&Integrity>,
+        id: DownloadId,
+    ) -> Result<(), DownloadError> {
+        let Some(integrity) = integrity else {
+            return Ok(());
+        };
+        self.emit(ProgressEvent::Verifying {
+            id,
+            algo: integrity.algo,
+            expected_prefix: integrity.short_prefix().to_string(),
+        });
+        let actual = verify::digest_file(part.path(), integrity.algo)
+            .map_err(|err| io_err("读取中间文件失败", &err))?;
+        if verify::matches(&integrity.hex, &actual) {
+            return Ok(());
+        }
+        part.discard();
+        Err(DownloadError::new(
+            DownloadErrorKind::Integrity,
+            format!("校验不符:期望 {} 实得 {actual}", integrity.hex),
+        ))
+    }
+}
+
+fn io_err(context: &str, err: &std::io::Error) -> DownloadError {
+    DownloadError::new(DownloadErrorKind::Io, format!("{context}: {err}"))
+}
diff --git a/crates/core/src/download/types.rs b/crates/core/src/download/types.rs
new file mode 100644
index 0000000..722d2a0
--- /dev/null
+++ b/crates/core/src/download/types.rs
@@ -0,0 +1,307 @@
+//! 下载引擎的请求/结果类型:资源类型、摘要、请求、候选源、结果。
+//!
+//! 调用方(game / launch / agent)只需要描述「逻辑资源 + 落盘路径 + 期望校验」,
+//! 镜像展开、地区模式、健康度排序、重试、续传、分块全部由引擎内部完成。
+
+use std::fmt;
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::Duration;
+
+use serde::{Deserialize, Serialize};
+
+/// 资源类型。
+///
+/// 同时用作三件事:健康度统计的第二维度(`源 × 资源类型`)、分块下载的准入条件、
+/// 进度事件的分类标签。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ResourceKind {
+    /// 资源对象(`assets/objects//`),海量小文件。
+    Asset,
+    /// 库文件(MB 级)。
+    Library,
+    /// 客户端 jar(MB~几十 MB)。
+    ClientJar,
+    /// 版本清单 / 版本 JSON(强时效,短 TTL)。
+    VersionManifest,
+    /// 资源索引 `assets/indexes/.json`。
+    AssetIndex,
+    /// JDK 运行时(~180MB),分块下载的主力场景。
+    JavaRuntime,
+    /// GitHub release 产物(自更新 / mod)。
+    GitHubRelease,
+    /// mod 文件(CurseForge / Modrinth)。
+    ModFile,
+    /// 其它。
+    Other,
+}
+
+impl ResourceKind {
+    /// 稳定字符串 id(持久化与日志用,**不要改名**)。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Asset => "asset",
+            Self::Library => "library",
+            Self::ClientJar => "client_jar",
+            Self::VersionManifest => "version_manifest",
+            Self::AssetIndex => "asset_index",
+            Self::JavaRuntime => "java_runtime",
+            Self::GitHubRelease => "github_release",
+            Self::ModFile => "mod_file",
+            Self::Other => "other",
+        }
+    }
+
+    /// 是否默认走多连接分块下载(大文件)。
+    #[must_use]
+    pub const fn prefers_chunked(self) -> bool {
+        matches!(self, Self::JavaRuntime | Self::ModFile)
+    }
+
+    /// 是否属于「小文件」:小文件不开分块(续传开销大于收益)。
+    #[must_use]
+    pub const fn is_small(self) -> bool {
+        matches!(
+            self,
+            Self::Asset | Self::VersionManifest | Self::AssetIndex | Self::Other
+        )
+    }
+}
+
+/// 摘要算法。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum HashAlgo {
+    /// SHA-1(Mojang 全线使用)。
+    Sha1,
+    /// SHA-256(自更新产物与部分镜像)。
+    Sha256,
+}
+
+impl HashAlgo {
+    /// 稳定字符串 id(缓存目录名与索引键用)。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Sha1 => "sha1",
+            Self::Sha256 => "sha256",
+        }
+    }
+
+    /// 十六进制摘要的字符数。
+    #[must_use]
+    pub const fn hex_len(self) -> usize {
+        match self {
+            Self::Sha1 => 40,
+            Self::Sha256 => 64,
+        }
+    }
+}
+
+/// 期望校验值。构造时自动归一化为小写十六进制。
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct Integrity {
+    /// 算法。
+    pub algo: HashAlgo,
+    /// 小写十六进制摘要。
+    pub hex: String,
+}
+
+impl Integrity {
+    /// 构造 SHA-1 校验值。
+    #[must_use]
+    pub fn sha1(hex: impl Into) -> Self {
+        Self {
+            algo: HashAlgo::Sha1,
+            hex: hex.into().trim().to_ascii_lowercase(),
+        }
+    }
+
+    /// 构造 SHA-256 校验值。
+    #[must_use]
+    pub fn sha256(hex: impl Into) -> Self {
+        Self {
+            algo: HashAlgo::Sha256,
+            hex: hex.into().trim().to_ascii_lowercase(),
+        }
+    }
+
+    /// 长度与字符集是否合法(不合法时调用方应显式传 `None`,别让引擎白跑一趟)。
+    #[must_use]
+    pub fn is_well_formed(&self) -> bool {
+        self.hex.len() == self.algo.hex_len() && self.hex.bytes().all(|b| b.is_ascii_hexdigit())
+    }
+
+    /// 给日志/事件用的短前缀(前 8 位)。
+    #[must_use]
+    pub fn short_prefix(&self) -> &str {
+        let end = self.hex.len().min(8);
+        &self.hex[..end]
+    }
+}
+
+/// 断点续传策略。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ResumePolicy {
+    /// 保守判定(默认):长度、ETag/Last-Modified 都对得上才续传(承 HMCL `HttpResumeContext`)。
+    #[default]
+    Conservative,
+    /// 从不续传(强时效资源、或镜像对 Range 支持可疑时)。
+    Never,
+}
+
+/// 单次下载的关联 id(进度事件靠它归并)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+pub struct DownloadId(pub u64);
+
+impl DownloadId {
+    /// 进程内单调递增的 id。
+    #[must_use]
+    pub fn next() -> Self {
+        static NEXT: AtomicU64 = AtomicU64::new(1);
+        Self(NEXT.fetch_add(1, Ordering::Relaxed))
+    }
+
+    /// 原始数值。
+    #[must_use]
+    pub const fn value(self) -> u64 {
+        self.0
+    }
+}
+
+impl fmt::Display for DownloadId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "#{}", self.0)
+    }
+}
+
+/// 一个下载请求:逻辑资源描述,不含任何镜像/重试策略。
+#[derive(Debug, Clone)]
+pub struct DownloadRequest {
+    /// 资源类型。
+    pub kind: ResourceKind,
+    /// **逻辑 URL(官方源)**。镜像按镜像表 + 地区模式展开成候选序列。
+    pub url: String,
+    /// 最终落盘路径(父目录自动创建)。
+    pub dest: PathBuf,
+    /// 期望校验值;`None` 表示只能靠长度兜底(不推荐,清单类资源必须带)。
+    pub integrity: Option,
+    /// 期望大小(进度预算 + 分块规划)。
+    pub expected_size: Option,
+    /// 续传策略。
+    pub resume: ResumePolicy,
+}
+
+impl DownloadRequest {
+    /// 构造请求(默认保守续传、无校验)。
+    #[must_use]
+    pub fn new(kind: ResourceKind, url: impl Into, dest: impl Into) -> Self {
+        Self {
+            kind,
+            url: url.into(),
+            dest: dest.into(),
+            integrity: None,
+            expected_size: None,
+            resume: ResumePolicy::Conservative,
+        }
+    }
+
+    /// 设置 SHA-1 校验。
+    #[must_use]
+    pub fn with_sha1(mut self, hex: impl Into) -> Self {
+        self.integrity = Some(Integrity::sha1(hex));
+        self
+    }
+
+    /// 设置 SHA-256 校验。
+    #[must_use]
+    pub fn with_sha256(mut self, hex: impl Into) -> Self {
+        self.integrity = Some(Integrity::sha256(hex));
+        self
+    }
+
+    /// 设置期望大小。
+    #[must_use]
+    pub fn with_size(mut self, size: u64) -> Self {
+        self.expected_size = Some(size);
+        self
+    }
+
+    /// 设置续传策略。
+    #[must_use]
+    pub fn with_resume(mut self, resume: ResumePolicy) -> Self {
+        self.resume = resume;
+        self
+    }
+
+    /// 请求本身是否自洽(运行期挡住明显错误的请求)。
+    pub fn validate(&self) -> Result<(), super::error::DownloadError> {
+        use super::error::{DownloadError, DownloadErrorKind};
+        if self.url.is_empty() {
+            return Err(DownloadError::new(
+                DownloadErrorKind::InvalidRequest,
+                "url 为空",
+            ));
+        }
+        if !(self.url.starts_with("http://") || self.url.starts_with("https://")) {
+            return Err(DownloadError::new(
+                DownloadErrorKind::InvalidRequest,
+                format!("仅支持 http(s) URL:{}", self.url),
+            ));
+        }
+        if let Some(integrity) = &self.integrity {
+            if !integrity.is_well_formed() {
+                return Err(DownloadError::new(
+                    DownloadErrorKind::InvalidRequest,
+                    format!("校验值不合法:{}", integrity.hex),
+                ));
+            }
+        }
+        Ok(())
+    }
+}
+
+/// 一个候选源(镜像展开产物)。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Candidate {
+    /// 真正要请求的 URL。
+    pub url: String,
+    /// 源标识(`official` / `bmclapi` / `tuna` / `gh_proxy` …),健康度按此归并。
+    pub source: String,
+}
+
+impl Candidate {
+    /// 构造候选。
+    #[must_use]
+    pub fn new(source: impl Into, url: impl Into) -> Self {
+        Self {
+            source: source.into(),
+            url: url.into(),
+        }
+    }
+}
+
+/// 官方源候选的固定 `source` 标识。
+pub const SOURCE_OFFICIAL: &str = "official";
+
+/// 下载成功的结果。
+#[derive(Debug, Clone)]
+pub struct DownloadOutcome {
+    /// 关联 id。
+    pub id: DownloadId,
+    /// 落盘路径。
+    pub path: PathBuf,
+    /// 实际字节数(续传时含已存在的部分)。
+    pub bytes: u64,
+    /// 是否零网络命中内容寻址缓存。
+    pub from_cache: bool,
+    /// 实际成功的源。
+    pub source: String,
+    /// 续传起点(0 = 从头下载)。
+    pub resumed_from: u64,
+    /// 耗时。
+    pub elapsed: Duration,
+}
diff --git a/crates/core/src/download/verify.rs b/crates/core/src/download/verify.rs
new file mode 100644
index 0000000..3eb2859
--- /dev/null
+++ b/crates/core/src/download/verify.rs
@@ -0,0 +1,123 @@
+//! 流式摘要:边下边算,不额外读一遍盘。
+//!
+//! 承 analyse/03 §5.6 的「先校验后落盘」:`MessageDigest` 在写流的同时喂数据,
+//! 流结束时比对,不符就丢掉中间文件。
+
+use std::fs::File;
+use std::io::{self, Read};
+use std::path::Path;
+
+use sha1::Digest;
+
+use super::types::HashAlgo;
+
+/// 小写十六进制编码(不引 `hex` 依赖)。
+#[must_use]
+pub fn to_hex(bytes: &[u8]) -> String {
+    const HEX: &[u8; 16] = b"0123456789abcdef";
+    let mut out = String::with_capacity(bytes.len() * 2);
+    for byte in bytes {
+        out.push(char::from(HEX[usize::from(byte >> 4)]));
+        out.push(char::from(HEX[usize::from(byte & 0x0f)]));
+    }
+    out
+}
+
+/// 增量摘要器。
+#[derive(Debug)]
+pub enum Hasher {
+    /// SHA-1。
+    Sha1(sha1::Sha1),
+    /// SHA-256。
+    Sha256(sha2::Sha256),
+}
+
+impl Hasher {
+    /// 按算法新建。
+    #[must_use]
+    pub fn new(algo: HashAlgo) -> Self {
+        match algo {
+            HashAlgo::Sha1 => Self::Sha1(sha1::Sha1::new()),
+            HashAlgo::Sha256 => Self::Sha256(sha2::Sha256::new()),
+        }
+    }
+
+    /// 算法。
+    #[must_use]
+    pub const fn algo(&self) -> HashAlgo {
+        match self {
+            Self::Sha1(_) => HashAlgo::Sha1,
+            Self::Sha256(_) => HashAlgo::Sha256,
+        }
+    }
+
+    /// 喂一段数据。
+    pub fn update(&mut self, bytes: &[u8]) {
+        match self {
+            Self::Sha1(hasher) => Digest::update(hasher, bytes),
+            Self::Sha256(hasher) => Digest::update(hasher, bytes),
+        }
+    }
+
+    /// 收尾成小写十六进制。
+    #[must_use]
+    pub fn finish(self) -> String {
+        match self {
+            Self::Sha1(hasher) => to_hex(&hasher.finalize()),
+            Self::Sha256(hasher) => to_hex(&hasher.finalize()),
+        }
+    }
+}
+
+/// 一次性算内存里的摘要。
+#[must_use]
+pub fn digest_bytes(algo: HashAlgo, bytes: &[u8]) -> String {
+    let mut hasher = Hasher::new(algo);
+    hasher.update(bytes);
+    hasher.finish()
+}
+
+/// 算整个文件的摘要(`cache` 的抽样校验与 `register` 路径用)。
+pub fn digest_file(path: &Path, algo: HashAlgo) -> io::Result {
+    let mut hasher = Hasher::new(algo);
+    let mut buffer = vec![0_u8; 64 * 1024];
+    let mut file = File::open(path)?;
+    loop {
+        let read = file.read(&mut buffer)?;
+        if read == 0 {
+            break;
+        }
+        hasher.update(&buffer[..read]);
+    }
+    Ok(hasher.finish())
+}
+
+/// 把文件前 `limit` 字节喂进已有摘要器(续传时让「边下边算」接得上)。
+pub fn update_hasher_from_file(hasher: &mut Hasher, path: &Path, limit: u64) -> io::Result<()> {
+    if limit == 0 {
+        return Ok(());
+    }
+    let mut buffer = vec![0_u8; 64 * 1024];
+    let mut reader = File::open(path)?.take(limit);
+    loop {
+        let read = reader.read(&mut buffer)?;
+        if read == 0 {
+            break;
+        }
+        hasher.update(&buffer[..read]);
+    }
+    Ok(())
+}
+
+/// 对已有文件的前 `prefix_len` 字节补算摘要(诊断/抽样校验用)。
+pub fn digest_prefix(path: &Path, algo: HashAlgo, prefix_len: u64) -> io::Result {
+    let mut hasher = Hasher::new(algo);
+    update_hasher_from_file(&mut hasher, path, prefix_len)?;
+    Ok(hasher.finish())
+}
+
+/// 摘要比较(大小写不敏感,避免上游给了大写 hex 就白跑一趟)。
+#[must_use]
+pub fn matches(expected_hex: &str, actual_hex: &str) -> bool {
+    expected_hex.len() == actual_hex.len() && expected_hex.eq_ignore_ascii_case(actual_hex)
+}
diff --git a/crates/core/src/game/arguments.rs b/crates/core/src/game/arguments.rs
new file mode 100644
index 0000000..5317069
--- /dev/null
+++ b/crates/core/src/game/arguments.rs
@@ -0,0 +1,331 @@
+//! 版本 JSON 的 `arguments` 模型:规则(rules)求值与参数展开。
+//!
+//! 依据:`DevDocs/analyse/03-version-download.md` §4.4(`arguments` 的合并语义)、
+//! §4.6(`minecraftArguments` 单串形式与新版 `arguments` 并存)。
+//!
+//! 规则求值语义照抄 Mojang/HMCL(`CompatibilityRule.appliesToCurrentEnvironment`):
+//! 规则列表为空 → 生效;否则**从 DISALLOW 开始**逐条覆盖,最后是 ALLOW 才算生效。
+//! 这是"白名单式"语义,与"最后一条匹配者胜"不同,抄错会漏掉/多带启动参数。
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+use crate::platform::os_info::OsInfo;
+
+/// 规则动作。
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum RuleAction {
+    /// 允许(命中后该参数/库生效)。
+    #[default]
+    Allow,
+    /// 禁止(命中后该参数/库失效)。
+    Disallow,
+}
+
+/// 一条规则。
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Rule {
+    /// 动作;缺省按 `allow` 处理(Mojang 老写法里 rules 只写 os 的情况)。
+    #[serde(default)]
+    pub action: RuleAction,
+    /// 操作系统限制。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub os: Option,
+    /// 特性开关限制(`has_custom_resolution`、`is_demo_user`…)。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub features: Option>,
+}
+
+/// 规则的 OS 限制。`version`/`arch` 在官方 JSON 里是**正则**,见 [`matches_simple_pattern`]。
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct OsRestriction {
+    /// `windows` / `osx` / `linux`;也接受模组包里的 `win-x86` 这种合并写法。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub name: Option,
+    /// OS 版本模式。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub version: Option,
+    /// 架构模式(`x86`、`x86_64`、`arm64`)。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub arch: Option,
+}
+
+/// 求值时的运行环境(OS / 架构 / 特性开关)。
+///
+/// 现由 [`Environment::host`] 从 `core::platform::os_info` 的探测快照构造(同 crate 依赖),
+/// 因此 `os_version` 是真实系统版本而不是空串;单测可以注入假快照做到不碰真机。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Environment {
+    /// Mojang 口径的 OS 名:`windows` / `osx` / `linux`。
+    pub os: String,
+    /// OS 版本字符串(Windows 上是 `10.0.19045` 之类;探测失败时为空串)。
+    pub os_version: String,
+    /// Mojang 口径的架构:`x86` / `x86_64` / `arm64`。
+    pub arch: String,
+    /// 特性开关,缺省全部视为 `false`。
+    pub features: BTreeMap,
+}
+
+impl Default for Environment {
+    fn default() -> Self {
+        Self::host()
+    }
+}
+
+impl Environment {
+    /// 当前主机环境。
+    ///
+    /// `os` / `arch` / `os_version` 全部取自 [`crate::platform::os_info::current`]
+    /// (进程内缓存,探测只做一次):`os_version` 不再是空串,因此带 `os.version` 限制的
+    /// 规则(官方 JSON 里用正则写系统版本)能按真实值求值。
+    pub fn host() -> Self {
+        Self::from_os_info(crate::platform::os_info::current())
+    }
+
+    /// 从一份系统信息快照构造环境(单测注入假快照,不碰真机;也让 app/CLI 能复用已探测结果)。
+    pub fn from_os_info(info: &OsInfo) -> Self {
+        Self {
+            os: info.family.manifest_name().to_owned(),
+            os_version: info.os_version().to_owned(),
+            arch: info.arch.clone(),
+            features: BTreeMap::new(),
+        }
+    }
+
+    /// 打开/关闭一个特性开关(构建器风格,便于 launch 侧串起来)。
+    pub fn with_feature(mut self, name: impl Into, value: bool) -> Self {
+        self.features.insert(name.into(), value);
+        self
+    }
+
+    /// 读一个特性开关;未声明视为 `false`。
+    pub fn feature(&self, name: &str) -> bool {
+        self.features.get(name).copied().unwrap_or(false)
+    }
+}
+
+impl OsRestriction {
+    /// 该限制是否匹配当前环境。
+    pub fn allows(&self, env: &Environment) -> bool {
+        if let Some(name) = &self.name {
+            // 模组包常见 `{ "name": "win-x86" }`:OS 名与架构合写。
+            let (os_name, arch) = match name.split_once('-') {
+                Some((os_part, arch_part)) if is_known_os(os_part) && is_known_arch(arch_part) => {
+                    (os_part, Some(arch_part))
+                }
+                _ => (name.as_str(), None),
+            };
+            if is_known_os(os_name) && os_name != env.os {
+                return false;
+            }
+            if let Some(arch) = arch {
+                if arch != env.arch {
+                    return false;
+                }
+            }
+        }
+        if let Some(version) = &self.version {
+            if !matches_simple_pattern(version, &env.os_version) {
+                return false;
+            }
+        }
+        if let Some(arch) = &self.arch {
+            if !matches_simple_pattern(arch, &env.arch) {
+                return false;
+            }
+        }
+        true
+    }
+}
+
+fn is_known_os(name: &str) -> bool {
+    matches!(name, "windows" | "osx" | "linux")
+}
+
+fn is_known_arch(name: &str) -> bool {
+    matches!(name, "x86" | "x86_64" | "arm64")
+}
+
+/// 极简正则匹配(官方 JSON 里 `os.version`/`os.arch` 用的是正则)。
+///
+/// 只覆盖实际出现过的形态:`^字面量`(前缀)、`字面量`(全等)、`字面量$`、
+/// 以及里面的 `\.`/`.*` 展开。**遇到其它元字符一律放宽为匹配**——
+/// 宁可多带一个参数(例如 Windows 专用 `-Dos.name`),也不要因为不会解析正则而漏掉参数。
+/// 阶段 B 若真有 JSON 用到复杂模式,再引入 `regex` 依赖替换本函数。
+pub fn matches_simple_pattern(pattern: &str, actual: &str) -> bool {
+    let anchored_start = pattern.starts_with('^');
+    let body = pattern.strip_prefix('^').unwrap_or(pattern);
+    let anchored_end = body.ends_with('$');
+    let body = body.strip_suffix('$').unwrap_or(body);
+
+    let mut literal = String::with_capacity(body.len());
+    let mut chars = body.chars();
+    while let Some(c) = chars.next() {
+        match c {
+            // `.*` 视作通配:丢掉两个字符即可(前后锚点仍然生效)。
+            '.' if chars.as_str().starts_with('*') => {
+                chars.next();
+            }
+            // `\.` 展开成字面点号。
+            '\\' => match chars.next() {
+                Some('.') => literal.push('.'),
+                _ => return true,
+            },
+            '.' => literal.push('.'),
+            '|' | '[' | ']' | '(' | ')' | '+' | '?' | '{' | '}' | '^' | '$' => return true,
+            other => literal.push(other),
+        }
+    }
+
+    match (anchored_start, anchored_end) {
+        (true, true) => actual == literal,
+        (true, false) => actual.starts_with(&literal),
+        (false, true) => actual.ends_with(&literal),
+        (false, false) => actual == literal || actual.starts_with(&literal),
+    }
+}
+
+impl Rule {
+    /// 该规则在当前环境下的动作;不匹配环境时返回 `None`。
+    pub fn action_in(&self, env: &Environment) -> Option {
+        if let Some(os) = &self.os {
+            if !os.allows(env) {
+                return None;
+            }
+        }
+        if let Some(features) = &self.features {
+            for (name, expected) in features {
+                if env.feature(name) != *expected {
+                    return None;
+                }
+            }
+        }
+        Some(self.action)
+    }
+}
+
+/// 一组规则的求值(Mojang 白名单语义)。
+pub fn rules_allow(rules: &[Rule], env: &Environment) -> bool {
+    if rules.is_empty() {
+        return true;
+    }
+    let mut action = RuleAction::Disallow;
+    for rule in rules {
+        if let Some(this) = rule.action_in(env) {
+            action = this;
+        }
+    }
+    action == RuleAction::Allow
+}
+
+/// `arguments` 里的一项:纯字符串参数,或带规则的条件参数。
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum Argument {
+    /// 无条件参数。
+    Plain(String),
+    /// 带规则的条件参数。
+    Conditional(ConditionalArgument),
+}
+
+/// 带规则的条件参数。
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ConditionalArgument {
+    /// 规则列表(白名单语义,见 [`rules_allow`])。
+    #[serde(default)]
+    pub rules: Vec,
+    /// 命中后的参数值(单个或多个)。
+    pub value: ArgumentValue,
+}
+
+/// 参数值:单个字符串或多个字符串。
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum ArgumentValue {
+    /// 单个参数。
+    One(String),
+    /// 多个参数。
+    Many(Vec),
+}
+
+impl ArgumentValue {
+    /// 逐个参数借用(`One` 给一个,`Many` 给若干个)。
+    pub fn strings(&self) -> Vec<&str> {
+        match self {
+            Self::One(value) => vec![value.as_str()],
+            Self::Many(values) => values.iter().map(String::as_str).collect(),
+        }
+    }
+}
+
+impl Argument {
+    /// 在当前环境下展开为具体参数(占位符替换归 launch 负责)。
+    pub fn expand_into(&self, env: &Environment, out: &mut Vec) {
+        match self {
+            Self::Plain(value) => out.push(value.clone()),
+            Self::Conditional(cond) => {
+                if rules_allow(&cond.rules, env) {
+                    match &cond.value {
+                        ArgumentValue::One(value) => out.push(value.clone()),
+                        ArgumentValue::Many(values) => out.extend(values.iter().cloned()),
+                    }
+                }
+            }
+        }
+    }
+}
+
+/// 版本 JSON 的 `arguments` 对象。
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Arguments {
+    /// 游戏参数(`mainClass` 之后)。
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub game: Vec,
+    /// JVM 参数(`-Xmx` 之类之外的那部分)。
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub jvm: Vec,
+}
+
+impl Arguments {
+    /// 合并两层参数:**父在前、子在后**(analyse/03 §4.4)。
+    pub fn merge(parent: Option<&Arguments>, child: Option<&Arguments>) -> Option {
+        match (parent, child) {
+            (None, None) => None,
+            (Some(parent), None) => Some(parent.clone()),
+            (None, Some(child)) => Some(child.clone()),
+            (Some(parent), Some(child)) => Some(Arguments {
+                game: parent
+                    .game
+                    .iter()
+                    .chain(child.game.iter())
+                    .cloned()
+                    .collect(),
+                jvm: parent.jvm.iter().chain(child.jvm.iter()).cloned().collect(),
+            }),
+        }
+    }
+
+    /// 按环境展开成扁平参数表(占位符 `--username ${auth_player_name}` 不在这里替换)。
+    pub fn expand(&self, env: &Environment) -> ExpandedArguments {
+        let mut game = Vec::new();
+        let mut jvm = Vec::new();
+        for argument in &self.game {
+            argument.expand_into(env, &mut game);
+        }
+        for argument in &self.jvm {
+            argument.expand_into(env, &mut jvm);
+        }
+        ExpandedArguments { game, jvm }
+    }
+}
+
+/// [`Arguments::expand`] 的结果。
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct ExpandedArguments {
+    /// 展开后的游戏参数。
+    pub game: Vec,
+    /// 展开后的 JVM 参数。
+    pub jvm: Vec,
+}
diff --git a/crates/core/src/game/capability.rs b/crates/core/src/game/capability.rs
new file mode 100644
index 0000000..7247555
--- /dev/null
+++ b/crates/core/src/game/capability.rs
@@ -0,0 +1,83 @@
+//! 版本能力位:从版本 JSON 的**库表**推断「这个版本带不带某个渲染后端」。
+//!
+//! 依据 `DevDocs/PlanDoc/research/05-mc-vulkan.md`(M26)§2.2 与 §4.2:
+//! Vulkan 的三件套(`lwjgl-vulkan` / `lwjgl-shaderc` / `lwjgl-spvc` / `lwjgl-vma`)从
+//! **`26.2-snapshot-1`(2026-04-07)** 起进入官方库列表,26.1 全线(含 `26w14a` 这类周快照)没有。
+//!
+//! # 为什么用库表而不是版本号
+//!
+//! 硬编码 `is_at_least("26.2-snapshot-2")` 对官方主线是对的,但周快照(`26w14a`)、
+//! 继承版本、第三方改版都拿不到可靠的比较结果(`GameVersion::is_known() == false` 时
+//! 门限恒假)。库表是版本自己声明的**事实**,比版本号串稳。
+//!
+//! # ⚠️ 「库存在」≠「参数可用」
+//!
+//! `26.2-snapshot-1` 已经有 `lwjgl-vulkan`,但 `--graphicsBackend` 要到
+//! **`26.2-snapshot-2`(2026-04-09)** 才进 `Main.class`,两者差两天(实测结论)。
+//! 因此能力位只适合做:
+//! - **必要条件的快速否定**(没有库 → 一定没有 Vulkan 后端);
+//! - UI 可用性提示(渲染设置里出不出「图形 API」三选一);
+//! - **无法排序的版本**(周快照)的兜底判据。
+//!
+//! 可解析版本的下发门限仍以 `26.2-snapshot-2` 为准,见
+//! [`crate::launch::game_args::graphics_backend_allowed`]。
+
+use super::manifest::VersionJson;
+
+/// Vulkan 后端的 Maven 坐标(`group:artifact`)。
+pub const VULKAN_LIBRARY_GROUP: &str = "org.lwjgl";
+/// Vulkan 后端的 Maven 坐标(artifact)。
+pub const VULKAN_LIBRARY_ARTIFACT: &str = "lwjgl-vulkan";
+
+/// 一条库名是否是 LWJGL 的 Vulkan 绑定。
+///
+/// 吃 `group:artifact:version[:classifier][@ext]` 全形态;只比对前两段,因此
+/// `org.lwjgl:lwjgl-vulkan:3.4.3`、`org.lwjgl:lwjgl-vulkan:3.4.3:natives-macos`
+/// 与 `org.lwjgl:lwjgl-vulkan:3.4.3@jar` 都算命中。
+#[must_use]
+pub fn is_vulkan_library(name: &str) -> bool {
+    let mut parts = name.split(':');
+    let (Some(group), Some(artifact)) = (parts.next(), parts.next()) else {
+        return false;
+    };
+    // classifier / `@ext` 可能粘在 artifact 上(`lwjgl-vulkan@jar`)。
+    let artifact = artifact.split('@').next().unwrap_or(artifact);
+    group.trim() == VULKAN_LIBRARY_GROUP && artifact.trim() == VULKAN_LIBRARY_ARTIFACT
+}
+
+/// 从一份版本 JSON 推出的能力位(目前只有 Vulkan 一项,后续可加)。
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct VersionCapabilities {
+    /// `libraries` 里存在 `org.lwjgl:lwjgl-vulkan`。
+    ///
+    /// **注意语义**:这是「版本带 Vulkan 后端实现」的**必要条件**,不是
+    /// 「`--graphicsBackend` 可下发」的充分条件(见模块文档)。
+    pub has_vulkan_backend: bool,
+}
+
+impl VersionCapabilities {
+    /// 从版本 JSON 解析能力位(只读 `libraries[].name`,不看下载信息与规则)。
+    #[must_use]
+    pub fn from_json(json: &VersionJson) -> Self {
+        Self {
+            has_vulkan_backend: json
+                .libraries
+                .as_deref()
+                .unwrap_or_default()
+                .iter()
+                .any(|library| is_vulkan_library(&library.name)),
+        }
+    }
+
+    /// 是否带 Vulkan 后端。
+    #[must_use]
+    pub const fn has_vulkan_backend(&self) -> bool {
+        self.has_vulkan_backend
+    }
+}
+
+/// 快捷方式:库名列表里是否有 Vulkan 绑定(launch 侧拿到的库名表也能直接用)。
+#[must_use]
+pub fn library_names_have_vulkan<'a>(names: impl IntoIterator) -> bool {
+    names.into_iter().any(is_vulkan_library)
+}
diff --git a/crates/core/src/game/data/unlisted-versions.json b/crates/core/src/game/data/unlisted-versions.json
new file mode 100644
index 0000000..0a62625
--- /dev/null
+++ b/crates/core/src/game/data/unlisted-versions.json
@@ -0,0 +1,209 @@
+{
+  "versions": [
+    {"id": "1.21.11_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/327be7759157b04495c591dbb721875e341877af/1.21.11_unobfuscated.json", "time": "2025-12-09T12:43:15+00:00", "releaseTime": "2025-12-09T12:43:15+00:00"},
+    {"id": "1.21.11-rc3_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/ce3f7ac6d0e9d23ea4e5f0354b91ff15039d9931/1.21.11-rc3_unobfuscated.json", "time": "2025-12-08T13:59:34+00:00", "releaseTime": "2025-12-08T13:59:34+00:00"},
+    {"id": "1.21.11-rc2_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/9282a3fb154d2a425086c62c11827281308bf93b/1.21.11-rc2_unobfuscated.json", "time": "2025-12-05T11:57:45+00:00", "releaseTime": "2025-12-05T11:57:45+00:00"},
+    {"id": "1.21.11-rc1_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/5d3ee0ef1f0251cf7e073354ca9e085a884a643d/1.21.11-rc1_unobfuscated.json", "time": "2025-12-04T15:56:55+00:00", "releaseTime": "2025-12-04T15:56:55+00:00"},
+    {"id": "1.21.11-pre5_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/1028441ca6d288bbf2103e773196bf524f7260fd/1.21.11-pre5_unobfuscated.json", "time": "2025-12-03T13:34:06+00:00", "releaseTime": "2025-12-03T13:34:06+00:00"},
+    {"id": "1.21.11-pre4_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/410ce37a2506adcfd54ef7d89168cfbe89cac4cb/1.21.11-pre4_unobfuscated.json", "time": "2025-12-01T13:40:12+00:00", "releaseTime": "2025-12-01T13:40:12+00:00"},
+    {"id": "1.21.11-pre3_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/579bf3428f72b5ea04883d202e4831bfdcb2aa8d/1.21.11-pre3_unobfuscated.json", "time": "2025-11-25T14:14:30+00:00", "releaseTime": "2025-11-25T14:14:30+00:00"},
+    {"id": "1.21.11-pre2_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/2955ce0af0512fdfe53ff0740b017344acf6f397/1.21.11-pre2_unobfuscated.json", "time": "2025-11-21T12:07:21+00:00", "releaseTime": "2025-11-21T12:07:21+00:00"},
+    {"id": "1.21.11-pre1_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/9c267f8dda2728bae55201a753cdd07b584709f1/1.21.11-pre1_unobfuscated.json", "time": "2025-11-19T08:30:46+00:00", "releaseTime": "2025-11-19T08:30:46+00:00"},
+    {"id": "25w46a_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/314ade2afeada364047798e163ef8e82427c69e1/25w46a_unobfuscated.json", "time": "2025-11-11T13:20:54+00:00", "releaseTime": "2025-11-11T13:20:54+00:00"},
+    {"id": "25w45a_unobfuscated", "type": "unobfuscated", "url": "https://piston-meta.mojang.com/v1/packages/7a3c149f148b6aa5ac3af48c4f701adea7e5b615/25w45a_unobfuscated.json", "time": "2025-11-04T14:07:08+00:00", "releaseTime": "2025-11-04T14:07:08+00:00"},
+    {"id": "1.19_deep_dark_experimental_snapshot-1", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_19_deep_dark_experimental_snapshot-1/1_19_deep_dark_experimental_snapshot-1.json", "time": "2022-02-17T13:55:59+00:00", "releaseTime": "2022-02-17T13:55:59+00:00"},
+    {"id": "1.18_experimental-snapshot-7", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-7/1_18_experimental-snapshot-7.json", "time": "2021-09-08T12:33:23+00:00", "releaseTime": "2021-09-08T12:33:23+00:00"},
+    {"id": "1.18_experimental-snapshot-6", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-6/1_18_experimental-snapshot-6.json", "time": "2021-09-01T12:53:14+00:00", "releaseTime": "2021-09-01T12:53:14+00:00"},
+    {"id": "1.18_experimental-snapshot-5", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-5/1_18_experimental-snapshot-5.json", "time": "2021-08-25T14:41:57+00:00", "releaseTime": "2021-08-25T14:41:57+00:00"},
+    {"id": "1.18_experimental-snapshot-4", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-4/1_18_experimental-snapshot-4.json", "time": "2021-08-17T16:10:25+00:00", "releaseTime": "2021-08-17T16:10:25+00:00"},
+    {"id": "1.18_experimental-snapshot-3", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-3/1_18_experimental-snapshot-3.json", "time": "2021-08-10T12:42:45+00:00", "releaseTime": "2021-08-10T12:42:45+00:00"},
+    {"id": "1.18_experimental-snapshot-2", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-2/1_18_experimental-snapshot-2.json", "time": "2021-07-20T13:35:08+00:00", "releaseTime": "2021-07-20T13:35:08+00:00"},
+    {"id": "1.18_experimental-snapshot-1", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_18_experimental-snapshot-1/1_18_experimental-snapshot-1.json", "time": "2021-07-13T12:54:19+00:00", "releaseTime": "2021-07-13T12:54:19+00:00"},
+    {"id": "1.16_combat-6", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-6/1_16_combat-6.json", "time": "2020-08-26T06:24:28+00:00", "releaseTime": "2020-08-26T06:24:28+00:00"},
+    {"id": "1.16_combat-5", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-5/1_16_combat-5.json", "time": "2020-08-21T09:23:13+00:00", "releaseTime": "2020-08-21T09:23:13+00:00"},
+    {"id": "1.16_combat-4", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-4/1_16_combat-4.json", "time": "2020-08-19T11:14:58+00:00", "releaseTime": "2020-08-19T11:14:58+00:00"},
+    {"id": "1.16_combat-3", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-3/1_16_combat-3.json", "time": "2020-08-14T09:02:15+00:00", "releaseTime": "2020-08-14T09:02:15+00:00"},
+    {"id": "1.16_combat-2", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-2/1_16_combat-2.json", "time": "2020-08-13T11:56:30+00:00", "releaseTime": "2020-08-13T11:56:30+00:00"},
+    {"id": "1.16_combat-1", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-1/1_16_combat-1.json", "time": "2020-08-12T14:07:25+00:00", "releaseTime": "2020-08-12T14:07:25+00:00"},
+    {"id": "1.16_combat-0", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_16_combat-0/1_16_combat-0.json", "time": "2020-08-07T10:44:47+00:00", "releaseTime": "2020-08-07T10:44:47+00:00"},
+    {"id": "1.15_combat-6", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_15_combat-6/1_15_combat-6.json", "time": "2020-01-15T09:46:35+00:00", "releaseTime": "2020-01-15T09:46:35+00:00"},
+    {"id": "1.15_combat-1", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_15_combat-1/1_15_combat-1.json", "time": "2019-11-29T15:41:39+00:00", "releaseTime": "2019-11-29T15:41:39+00:00"},
+    {"id": "1.14_combat-3", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_14_combat-3/1_14_combat-3.json", "time": "2019-10-31T14:31:38+00:00", "releaseTime": "2019-10-31T14:31:38+00:00"},
+    {"id": "1.14_combat-0", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_14_combat-0/1_14_combat-0.json", "time": "2019-08-13T07:33:42+00:00", "releaseTime": "2019-08-13T07:33:42+00:00"},
+    {"id": "1.14_combat-212796", "type": "pending", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_14_combat-212796/1_14_combat-212796.json", "time": "2019-06-20T13:23:44+00:00", "releaseTime": "2019-06-20T13:23:44+00:00"},
+    {"id": "2.0_blue", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/2point0_blue/2point0_blue.json", "time": "2013-08-06T06:00:02-05:00", "releaseTime": "2013-03-20T05:00:02-05:00"},
+    {"id": "2.0_red", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/2point0_red/2point0_red.json", "time": "2013-08-06T06:00:01-05:00", "releaseTime": "2013-03-20T05:00:01-05:00"},
+    {"id": "2.0_purple", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/2point0_purple/2point0_purple.json", "time": "2013-08-06T06:00:00-05:00", "releaseTime": "2013-03-20T05:00:00-05:00"},
+    {"id": "13w12~", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w12-/13w12-.json", "time": "2013-03-19T00:00:00+00:00", "releaseTime": "2013-03-19T00:00:00+00:00"},
+    {"id": "13w10b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w10b/13w10b.json", "time": "2013-03-06T00:00:00+00:00", "releaseTime": "2013-03-06T00:00:00+00:00"},
+    {"id": "13w10a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w10a/13w10a.json", "time": "2013-03-04T00:00:00+00:00", "releaseTime": "2013-03-04T00:00:00+00:00"},
+    {"id": "13w09c", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w09c/13w09c.json", "time": "2013-03-01T00:00:00+00:00", "releaseTime": "2013-03-01T00:00:00+00:00"},
+    {"id": "13w09b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w09b/13w09b.json", "time": "2013-02-27T00:00:00+00:00", "releaseTime": "2013-02-27T00:00:00+00:00"},
+    {"id": "13w09a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w09a/13w09a.json", "time": "2013-02-26T00:00:00+00:00", "releaseTime": "2013-02-26T00:00:00+00:00"},
+    {"id": "13w07a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w07a/13w07a.json", "time": "2013-02-14T00:00:00+00:00", "releaseTime": "2013-02-14T00:00:00+00:00"},
+    {"id": "13w11a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w11a/13w11a.json", "time": "2013-02-14T00:00:00+00:00", "releaseTime": "2013-02-14T00:00:00+00:00"},
+    {"id": "13w06a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w06a/13w06a.json", "time": "2013-02-07T00:00:00+00:00", "releaseTime": "2013-02-07T00:00:00+00:00"},
+    {"id": "13w05b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w05b/13w05b.json", "time": "2013-02-01T00:00:00+00:00", "releaseTime": "2013-02-01T00:00:00+00:00"},
+    {"id": "13w05a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w05a/13w05a.json", "time": "2013-01-31T00:00:00+00:00", "releaseTime": "2013-01-31T00:00:00+00:00"},
+    {"id": "13w04a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w04a/13w04a.json", "time": "2013-01-24T00:00:00+00:00", "releaseTime": "2013-01-24T00:00:00+00:00"},
+    {"id": "13w03a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w03a/13w03a.json", "time": "2013-01-17T00:00:00+00:00", "releaseTime": "2013-01-17T00:00:00+00:00"},
+    {"id": "13w02b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w02b/13w02b.json", "time": "2013-01-11T00:00:00+00:00", "releaseTime": "2013-01-11T00:00:00+00:00"},
+    {"id": "13w02a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w02a/13w02a.json", "time": "2013-01-10T00:00:00+00:00", "releaseTime": "2013-01-10T00:00:00+00:00"},
+    {"id": "13w01b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w01b/13w01b.json", "time": "2013-01-04T00:00:00+00:00", "releaseTime": "2013-01-04T00:00:00+00:00"},
+    {"id": "13w01a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/13w01a/13w01a.json", "time": "2013-01-03T00:00:00+00:00", "releaseTime": "2013-01-03T00:00:00+00:00"},
+    {"id": "12w50b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w50b/12w50b.json", "time": "2012-12-14T00:00:00+00:00", "releaseTime": "2012-12-14T00:00:00+00:00"},
+    {"id": "12w50a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w50a/12w50a.json", "time": "2012-12-13T00:00:00+00:00", "releaseTime": "2012-12-13T00:00:00+00:00"},
+    {"id": "12w49a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w49a/12w49a.json", "time": "2012-12-07T00:00:00+00:00", "releaseTime": "2012-12-07T00:00:00+00:00"},
+    {"id": "12w42b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w42b/12w42b.json", "time": "2012-10-18T00:00:00+00:00", "releaseTime": "2012-10-18T00:00:00+00:00"},
+    {"id": "12w42a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w42a/12w42a.json", "time": "2012-10-17T00:00:00+00:00", "releaseTime": "2012-10-17T00:00:00+00:00"},
+    {"id": "12w41b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w41b/12w41b.json", "time": "2012-10-12T00:00:00+00:00", "releaseTime": "2012-10-12T00:00:00+00:00"},
+    {"id": "12w41a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w41a/12w41a.json", "time": "2012-10-11T00:00:00+00:00", "releaseTime": "2012-10-11T00:00:00+00:00"},
+    {"id": "12w40b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w40b/12w40b.json", "time": "2012-10-05T00:00:00+00:00", "releaseTime": "2012-10-05T00:00:00+00:00"},
+    {"id": "12w40a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w40a/12w40a.json", "time": "2012-10-04T00:00:00+00:00", "releaseTime": "2012-10-04T00:00:00+00:00"},
+    {"id": "12w39b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w39b/12w39b.json", "time": "2012-09-28T00:00:00+00:00", "releaseTime": "2012-09-28T00:00:00+00:00"},
+    {"id": "12w39a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w39a/12w39a.json", "time": "2012-09-27T00:00:00+00:00", "releaseTime": "2012-09-27T00:00:00+00:00"},
+    {"id": "12w38b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w38b/12w38b.json", "time": "2012-09-21T00:00:00+00:00", "releaseTime": "2012-09-21T00:00:00+00:00"},
+    {"id": "12w38a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w38a/12w38a.json", "time": "2012-09-20T00:00:00+00:00", "releaseTime": "2012-09-20T00:00:00+00:00"},
+    {"id": "12w37a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w37a/12w37a.json", "time": "2012-09-13T00:00:00+00:00", "releaseTime": "2012-09-13T00:00:00+00:00"},
+    {"id": "12w36a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w36a/12w36a.json", "time": "2012-09-06T00:00:00+00:00", "releaseTime": "2012-09-06T00:00:00+00:00"},
+    {"id": "12w34b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w34b/12w34b.json", "time": "2012-08-24T00:00:00+00:00", "releaseTime": "2012-08-24T00:00:00+00:00"},
+    {"id": "12w34a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w34a/12w34a.json", "time": "2012-08-23T00:00:00+00:00", "releaseTime": "2012-08-23T00:00:00+00:00"},
+    {"id": "12w32a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w32a/12w32a.json", "time": "2012-08-09T00:00:00+00:00", "releaseTime": "2012-08-09T00:00:00+00:00"},
+    {"id": "12w30d", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w30d/12w30d.json", "time": "2012-07-25T00:00:00+00:00", "releaseTime": "2012-07-25T00:00:00+00:00"},
+    {"id": "12w30e", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w30e/12w30e.json", "time": "2012-07-25T00:00:00+00:00", "releaseTime": "2012-07-25T00:00:00+00:00"},
+    {"id": "12w30c", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w30c/12w30c.json", "time": "2012-07-24T00:00:00+00:00", "releaseTime": "2012-07-24T00:00:00+00:00"},
+    {"id": "12w30a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w30a/12w30a.json", "time": "2012-07-23T00:00:00+00:00", "releaseTime": "2012-07-23T00:00:00+00:00"},
+    {"id": "12w30b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w30b/12w30b.json", "time": "2012-07-23T00:00:00+00:00", "releaseTime": "2012-07-23T00:00:00+00:00"},
+    {"id": "12w27a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w27a/12w27a.json", "time": "2012-07-05T00:00:00+00:00", "releaseTime": "2012-07-05T00:00:00+00:00"},
+    {"id": "12w26a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w26a/12w26a.json", "time": "2012-06-27T00:00:00+00:00", "releaseTime": "2012-06-27T00:00:00+00:00"},
+    {"id": "12w25a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w25a/12w25a.json", "time": "2012-06-21T00:00:00+00:00", "releaseTime": "2012-06-21T00:00:00+00:00"},
+    {"id": "12w24a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w24a/12w24a.json", "time": "2012-06-14T00:00:00+00:00", "releaseTime": "2012-06-14T00:00:00+00:00"},
+    {"id": "12w23a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w23a/12w23a.json", "time": "2012-06-07T00:00:00+00:00", "releaseTime": "2012-06-07T00:00:00+00:00"},
+    {"id": "12w23b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w23b/12w23b.json", "time": "2012-06-07T00:00:00+00:00", "releaseTime": "2012-06-07T00:00:00+00:00"},
+    {"id": "12w22a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w22a/12w22a.json", "time": "2012-05-31T00:00:00+00:00", "releaseTime": "2012-05-31T00:00:00+00:00"},
+    {"id": "12w21b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w21b/12w21b.json", "time": "2012-05-25T00:00:00+00:00", "releaseTime": "2012-05-25T00:00:00+00:00"},
+    {"id": "12w21a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w21a/12w21a.json", "time": "2012-05-24T00:00:00+00:00", "releaseTime": "2012-05-24T00:00:00+00:00"},
+    {"id": "12w19a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w19a/12w19a.json", "time": "2012-05-10T00:00:00+00:00", "releaseTime": "2012-05-10T00:00:00+00:00"},
+    {"id": "12w18a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w18a/12w18a.json", "time": "2012-05-03T00:00:00+00:00", "releaseTime": "2012-05-03T00:00:00+00:00"},
+    {"id": "12w17a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w17a/12w17a.json", "time": "2012-04-26T00:00:00+00:00", "releaseTime": "2012-04-26T00:00:00+00:00"},
+    {"id": "12w16a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w16a/12w16a.json", "time": "2012-04-19T00:00:00+00:00", "releaseTime": "2012-04-19T00:00:00+00:00"},
+    {"id": "1.2", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1_2/1_2.json", "time": "2012-02-29T00:00:00+00:00", "releaseTime": "2012-02-29T00:00:00+00:00"},
+    {"id": "12w08a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w08a/12w08a.json", "time": "2012-02-23T00:00:00+00:00", "releaseTime": "2012-02-23T00:00:00+00:00"},
+    {"id": "12w07a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w07a/12w07a.json", "time": "2012-02-15T00:00:00+00:00", "releaseTime": "2012-02-15T00:00:00+00:00"},
+    {"id": "12w07b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w07b/12w07b.json", "time": "2012-02-15T00:00:00+00:00", "releaseTime": "2012-02-15T00:00:00+00:00"},
+    {"id": "12w06a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w06a/12w06a.json", "time": "2012-02-09T00:00:00+00:00", "releaseTime": "2012-02-09T00:00:00+00:00"},
+    {"id": "12w05b", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w05b/12w05b.json", "time": "2012-02-03T00:00:00+00:00", "releaseTime": "2012-02-03T00:00:00+00:00"},
+    {"id": "12w05a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w05a/12w05a.json", "time": "2012-02-02T00:00:00+00:00", "releaseTime": "2012-02-02T00:00:00+00:00"},
+    {"id": "12w04a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w04a/12w04a.json", "time": "2012-01-26T00:00:00+00:00", "releaseTime": "2012-01-26T00:00:00+00:00"},
+    {"id": "12w03a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w03a/12w03a.json", "time": "2012-01-19T00:00:00+00:00", "releaseTime": "2012-01-19T00:00:00+00:00"},
+    {"id": "12w01a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/12w01a/12w01a.json", "time": "2012-01-05T00:00:00+00:00", "releaseTime": "2012-01-05T00:00:00+00:00"},
+    {"id": "11w50a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/11w50a/11w50a.json", "time": "2011-12-15T00:00:00+00:00", "releaseTime": "2011-12-15T00:00:00+00:00"},
+    {"id": "11w49a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/11w49a/11w49a.json", "time": "2011-12-08T00:00:00+00:00", "releaseTime": "2011-12-08T00:00:00+00:00"},
+    {"id": "11w48a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/11w48a/11w48a.json", "time": "2011-12-01T00:00:00+00:00", "releaseTime": "2011-12-01T00:00:00+00:00"},
+    {"id": "11w47a", "type": "snapshot", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/11w47a/11w47a.json", "time": "2011-11-24T00:00:00+00:00", "releaseTime": "2011-11-24T00:00:00+00:00"},
+    {"id": "1.0.0-rc2-3", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1.0.0-rc2-3/1.0.0-rc2-3.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2011-11-14T15:00:00-07:00"},
+    {"id": "1.0.0-rc2-2", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1.0.0-rc2-2/1.0.0-rc2-2.json", "time": "2019-04-21T21:04:24+00:00", "releaseTime": "2011-11-14T00:00:00+00:00"},
+    {"id": "1.0.0-rc2-1", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1.0.0-rc2-1/1.0.0-rc2-1.json", "time": "2019-11-21T22:37:45+00:00", "releaseTime": "2011-11-13T17:33:00+00:00"},
+    {"id": "1.0.0-rc1", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/1.0.0-rc1/1.0.0-rc1.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2011-11-13T15:00:00-07:00"},
+    {"id": "b1.9-pre6", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre6/b1_9-pre6.json", "time": "2011-11-11T00:00:00+00:00", "releaseTime": "2011-11-11T00:00:00+00:00"},
+    {"id": "b1.9-pre5", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre5/b1_9-pre5.json", "time": "2011-10-27T00:00:00+00:00", "releaseTime": "2011-10-27T00:00:00+00:00"},
+    {"id": "b1.9-pre4", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre4/b1_9-pre4.json", "time": "2011-10-13T00:00:00+00:00", "releaseTime": "2011-10-13T00:00:00+00:00"},
+    {"id": "b1.9-pre3", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre3/b1_9-pre3.json", "time": "2011-10-06T00:00:00+00:00", "releaseTime": "2011-10-06T00:00:00+00:00"},
+    {"id": "b1.9-pre2", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre2/b1_9-pre2.json", "time": "2011-09-29T00:00:00+00:00", "releaseTime": "2011-09-29T00:00:00+00:00"},
+    {"id": "b1.9-pre1", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_9-pre1/b1_9-pre1.json", "time": "2011-09-22T00:00:00+00:00", "releaseTime": "2011-09-22T00:00:00+00:00"},
+    {"id": "b1.8-pre2", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_8-pre2/b1_8-pre2.json", "time": "2011-09-13T00:00:00+00:00", "releaseTime": "2011-09-13T00:00:00+00:00"},
+    {"id": "b1.8-pre1-2", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1_8-pre1/b1_8-pre1.json", "time": "2011-09-09T00:00:00+00:00", "releaseTime": "2011-09-09T00:00:00+00:00"},
+    {"id": "b1.8-pre1-1", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1.8-pre1-1/b1.8-pre1-1.json", "time": "2011-09-09T00:00:00+00:00", "releaseTime": "2011-09-09T00:00:00+00:00"},
+    {"id": "b1.6-tb3", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1.6-tb3/b1.6-tb3.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2011-05-24T15:00:00-07:00"},
+    {"id": "b1.1-1", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1.1-1/b1.1-1.json", "time": "2016-02-02T15:37:47+00:00", "releaseTime": "2010-12-21T22:00:00+00:00"},
+    {"id": "b1.1-2", "type": "old_beta", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/b1.1-2/b1.1-2.json", "time": "2016-02-02T15:37:47+00:00", "releaseTime": "2010-12-21T22:00:00+00:00"},
+    {"id": "a1.1.1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.1.1/a1.1.1.json", "time": "2010-09-18T19:29:56+02:00", "releaseTime": "2010-09-18T19:29:56+02:00"},
+    {"id": "a1.1.0-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.1.0-2/a1.1.0-2.json", "time": "2016-02-02T15:37:47+00:00", "releaseTime": "2010-09-12T22:00:00+00:00"},
+    {"id": "a1.1.0-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.1.0-1/a1.1.0-1.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-09-10T00:00:00+00:00"},
+    {"id": "a1.0.17_03", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.17_03/a1.0.17_03.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-08-23T00:00:00+00:00"},
+    {"id": "a1.0.16_02", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.16_02/a1.0.16_02.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-08-13T00:00:00+00:00"},
+    {"id": "a1.0.16_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.16_01/a1.0.16_01.json", "time": "2013-08-06T04:00:00-07:00", "releaseTime": "2010-08-12T15:00:00-07:00"},
+    {"id": "a1.0.14-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.14-1/a1.0.14-1.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-30T00:00:00+00:00"},
+    {"id": "a1.0.14-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.14-2/a1.0.14-2.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-30T00:00:00+00:00"},
+    {"id": "a1.0.13_01-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.13_01-1/a1.0.13_01-1.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-29T00:00:00+00:00"},
+    {"id": "a1.0.13_01-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.13_01-2/a1.0.13_01-2.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-29T00:00:00+00:00"},
+    {"id": "a1.0.13", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.13/a1.0.13.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-28T00:00:00+00:00"},
+    {"id": "a1.0.12", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.12/a1.0.12.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-26T00:00:00+00:00"},
+    {"id": "a1.0.10", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.10/a1.0.10.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-22T00:00:00+00:00"},
+    {"id": "a1.0.9", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.9/a1.0.9.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-21T00:00:00+00:00"},
+    {"id": "a1.0.7", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.7/a1.0.7.json", "time": "2013-08-06T04:00:00-07:00", "releaseTime": "2010-07-19T15:00:00-07:00"},
+    {"id": "a1.0.8_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.8_01/a1.0.8_01.json", "time": "2013-08-06T04:00:00-07:00", "releaseTime": "2010-07-19T15:00:00-07:00"},
+    {"id": "a1.0.6_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.6_01/a1.0.6_01.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-17T00:00:00+00:00"},
+    {"id": "a1.0.6_03", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.6_03/a1.0.6_03.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-17T00:00:00+00:00"},
+    {"id": "a1.0.6", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.6/a1.0.6.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-16T00:00:00+00:00"},
+    {"id": "a1.0.5", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.5/a1.0.5.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-13T00:00:00+00:00"},
+    {"id": "a1.0.3", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.3/a1.0.3.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-07T00:00:00+00:00"},
+    {"id": "a1.0.2_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.2_01/a1.0.2_01.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-06T00:00:00+00:00"},
+    {"id": "a1.0.2_02", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.2_02/a1.0.2_02.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-06T00:00:00+00:00"},
+    {"id": "a1.0.1_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/a1.0.1_01/a1.0.1_01.json", "time": "2017-10-22T05:26:50+00:00", "releaseTime": "2010-07-03T22:00:00+00:00"},
+    {"id": "inf-20100630-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100630-1/inf-20100630-1.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-30T00:00:00-00:00"},
+    {"id": "inf-20100630-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100630-2/inf-20100630-2.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-30T00:00:00-00:00"},
+    {"id": "inf-20100629", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100629/inf-20100629.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-29T00:00:00-00:00"},
+    {"id": "inf-20100627", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100627/inf-20100627.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-27T15:00:00-07:00"},
+    {"id": "inf-20100625-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100625-1/inf-20100625-1.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-25T00:00:00-00:00"},
+    {"id": "inf-20100625-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100625-2/inf-20100625-2.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-25T00:00:00-00:00"},
+    {"id": "inf-20100624", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100624/inf-20100624.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-24T00:00:00-00:00"},
+    {"id": "inf-20100617-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100617-2/inf-20100617-2.json", "time": "2010-06-17T00:00:00-00:00", "releaseTime": "2010-06-17T00:00:00-00:00"},
+    {"id": "inf-20100617-3", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100617-3/inf-20100617-3.json", "time": "2010-06-17T00:00:00-00:00", "releaseTime": "2010-06-17T00:00:00-00:00"},
+    {"id": "inf-20100615", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100615/inf-20100615.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-15T00:00:00-00:00"},
+    {"id": "inf-20100611", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100611/inf-20100611.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-11T00:00:00-00:00"},
+    {"id": "inf-20100608", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100608/inf-20100608.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-08T15:00:00-07:00"},
+    {"id": "inf-20100607", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100607/inf-20100607.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-06-07T00:00:00-00:00"},
+    {"id": "inf-20100420", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100420/inf-20100420.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-04-20T15:00:00-07:00"},
+    {"id": "inf-20100415", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100415/inf-20100415.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-04-15T15:00:00-07:00"},
+    {"id": "inf-20100414", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100414/inf-20100414.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-04-14T00:00:00-00:00"},
+    {"id": "inf-20100413", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100413/inf-20100413.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-04-13T00:00:00-00:00"},
+    {"id": "inf-20100330", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100330/inf-20100330.json", "time": "2010-03-30T00:00:00-00:00", "releaseTime": "2010-03-30T00:00:00-00:00"},
+    {"id": "inf-20100327", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100327/inf-20100327.json", "time": "2010-03-27T00:00:00-00:00", "releaseTime": "2010-03-27T00:00:00-00:00"},
+    {"id": "inf-20100325", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100325/inf-20100325.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-03-25T00:00:00-00:00"},
+    {"id": "inf-20100321", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100321/inf-20100321.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-03-21T00:00:00-00:00"},
+    {"id": "inf-20100320", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100320/inf-20100320.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-03-20T00:00:00-00:00"},
+    {"id": "inf-20100316", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100316/inf-20100316.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-03-16T00:00:00-00:00"},
+    {"id": "inf-20100313", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/inf-20100313/inf-20100313.json", "time": "2015-04-13T07:33:30-07:00", "releaseTime": "2010-03-13T00:00:00-00:00"},
+    {"id": "in-20100223", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100223/in-20100223.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2010-02-23T15:00:00-07:00"},
+    {"id": "in-20100219", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100219/in-20100219.json", "time": "2017-10-24T00:00:00+00:00", "releaseTime": "2010-02-19T00:00:00+00:00"},
+    {"id": "in-20100218", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100218/in-20100218.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-18T00:00:00+00:00"},
+    {"id": "in-20100214-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100214-2/in-20100214-2.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-14T00:00:01+00:00"},
+    {"id": "in-20100214-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100214-1/in-20100214-1.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-14T00:00:00+00:00"},
+    {"id": "in-20100212-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100212-2/in-20100212-2.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-12T00:00:01+00:00"},
+    {"id": "in-20100212-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100212-1/in-20100212-1.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-12T00:00:00+00:00"},
+    {"id": "in-20100207-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100207-2/in-20100207-2.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-07T00:00:01+00:00"},
+    {"id": "in-20100207-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100207-1/in-20100207-1.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-02-07T00:00:00+00:00"},
+    {"id": "in-20100206-2103", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100206-2103/in-20100206-2103.json", "time": "2020-10-14T18:51:02+00:00", "releaseTime": "2010-02-06T21:03:00+00:00"},
+    {"id": "in-20100203", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100203/in-20100203.json", "time": "2017-09-20T19:21:48+02:00", "releaseTime": "2010-02-03T00:30:00+02:00"},
+    {"id": "in-20100201-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100201-2/in-20100201-2.json", "time": "2017-09-20T19:21:48+02:00", "releaseTime": "2010-02-01T00:00:00+00:00"},
+    {"id": "in-20100201-3", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100201-3/in-20100201-3.json", "time": "2017-09-20T19:21:48+02:00", "releaseTime": "2010-02-01T00:00:00+00:00"},
+    {"id": "in-20100130", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100130/in-20100130.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2010-01-30T00:00:00-00:00"},
+    {"id": "in-20100129", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100129/in-20100129.json", "time": "2010-01-29T00:00:00-00:00", "releaseTime": "2010-01-29T00:00:00-00:00"},
+    {"id": "in-20100128-2304", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100128-2304/in-20100128-2304.json", "time": "2010-01-28T23:04:00-00:00", "releaseTime": "2010-01-28T23:04:00-00:00"},
+    {"id": "in-20100125-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100125-2/in-20100125-2.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-01-25T15:00:01-07:00"},
+    {"id": "in-20100125-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100125-1/in-20100125-1.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-01-25T15:00:00-07:00"},
+    {"id": "in-20100111-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100111-1/in-20100111-1.json", "time": "2017-09-20T00:00:00+00:00", "releaseTime": "2010-01-11T15:00:00-07:00"},
+    {"id": "in-20100105", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20100105/in-20100105.json", "time": "2013-08-06T07:33:30-07:00", "releaseTime": "2010-01-05T00:00:00-00:00"},
+    {"id": "in-20091231-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20091231-2/in-20091231-2.json", "time": "2021-06-26T22:48:05+00:00", "releaseTime": "2009-12-31T23:55:00+00:00"},
+    {"id": "in-20091223-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/in-20091223-2/in-20091223-2.json", "time": "2009-12-23T00:00:00-00:00", "releaseTime": "2009-12-23T00:00:00-00:00"},
+    {"id": "c0.30-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.30-2/c0.30-2.json", "time": "2013-08-06T17:33:30+03:00", "releaseTime": "2009-11-10T00:00:01+02:00"},
+    {"id": "c0.30-1", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.30-1/c0.30-1.json", "time": "2013-08-06T17:33:30+03:00", "releaseTime": "2009-11-10T00:00:00+02:00"},
+    {"id": "c0.29_02", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.29_02/c0.29_02.json", "time": "2017-09-20T03:05:00+01:00", "releaseTime": "2009-10-30T00:00:00+00:00"},
+    {"id": "c0.29_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.29_01/c0.29_01.json", "time": "2021-06-26T12:16:50+00:00", "releaseTime": "2009-10-29T00:00:00+00:00"},
+    {"id": "c0.28_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.28_01/c0.28_01.json", "time": "2017-09-20T03:19:48+01:00", "releaseTime": "2009-10-27T00:00:00+00:00"},
+    {"id": "c0.27_st", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.27_st/c0.27_st.json", "time": "2017-09-17T16:33:30+02:00", "releaseTime": "2009-10-24T24:00:00+00:00"},
+    {"id": "c0.25_05_st", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.25_05_st/c0.25_05_st.json", "time": "2009-09-03T00:00:00+00:00", "releaseTime": "2009-09-03T00:00:00+00:00"},
+    {"id": "c0.24_st_03", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.24_st_03/c0.24_st_03.json", "time": "2009-09-01T00:00:00+00:00", "releaseTime": "2009-09-01T00:00:00+00:00"},
+    {"id": "c0.0.22a_05", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.22a_05/c0.0.22a_05.json", "time": "2009-06-30T00:00:00+00:00", "releaseTime": "2009-06-30T00:00:00+00:00"},
+    {"id": "c0.0.21a", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.21a/c0.0.21a.json", "time": "2017-09-20T03:12:34+01:00", "releaseTime": "2009-06-22T23:11:11+01:00"},
+    {"id": "c0.0.20a_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.20a_01/c0.0.20a_01.json", "time": "2020-10-14T23:51:10+00:00", "releaseTime": "2009-06-20T23:10:00+00:00"},
+    {"id": "c0.0.19a_06-2", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.19a_06-2/c0.0.19a_06-2.json", "time": "2020-10-17T19:47:58-00:00", "releaseTime": "2009-06-20T01:37:00-00:00"},
+    {"id": "c0.0.18a_02", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.18a_02/c0.0.18a_02.json", "time": "2020-10-15T18:45:15-00:00", "releaseTime": "2009-06-14T10:53:00-00:00"},
+    {"id": "c0.0.17a", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.17a/c0.0.17a.json", "time": "2021-04-17T21:49:29-00:00", "releaseTime": "2009-06-10T20:14:00-00:00"},
+    {"id": "c0.0.16a_02", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.16a_02/c0.0.16a_02.json", "time": "2019-10-20T21:42:41+00:00", "releaseTime": "2009-06-08T12:47:00+00:00"},
+    {"id": "c0.0.23a_01", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.23a_01/c0.0.23a_01.json", "time": "2013-08-06T15:33:30+01:00", "releaseTime": "2009-05-28T23:11:11+01:00"},
+    {"id": "c0.0.14a_08", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.14a_08/c0.0.14a_08.json", "time": "2019-10-20T21:41:28+00:00", "releaseTime": "2009-05-28T22:37:00+00:00"},
+    {"id": "c0.0.12a_03", "type": "old_alpha", "url": "https://zkitefly.github.io/unlisted-versions-of-minecraft/files/c0.0.12a_03/c0.0.12a_03.json", "time": "2021-04-26T19:18:00-00:00", "releaseTime": "2009-05-20T00:00:00-00:00"}
+  ]
+}
diff --git a/crates/core/src/game/error.rs b/crates/core/src/game/error.rs
new file mode 100644
index 0000000..f2601e1
--- /dev/null
+++ b/crates/core/src/game/error.rs
@@ -0,0 +1,124 @@
+//! `core::game` 的错误类型。
+//!
+//! 采用手写 `Display`/`Error` 而不用 `thiserror`:core 的依赖面越窄越好(D3 模块纪律),
+//! 且这里的变体数量固定、不需要派生宏的收益。
+
+use std::fmt;
+use std::path::PathBuf;
+
+/// `core::game` 的失败集合。
+#[derive(Debug)]
+pub enum GameError {
+    /// JSON 解析/序列化失败(版本清单、版本 JSON、profiles)。
+    Json {
+        /// 出错的文档种类,用于给用户看上下文(如 `version_manifest_v2`)。
+        context: String,
+        /// 底层 serde_json 错误。
+        source: serde_json::Error,
+    },
+    /// 清单或版本 JSON 缺少必需的 `id`。
+    MissingVersionId,
+    /// `inheritsFrom` 指向的父版本在本仓库中不存在。
+    MissingParent {
+        /// 缺失的父版本 id。
+        parent: String,
+        /// 发起继承的子版本 id。
+        required_by: String,
+    },
+    /// `inheritsFrom` 成环。
+    CircularInheritance {
+        /// 从根到环的 id 链(首尾为同一个 id)。
+        chain: Vec,
+    },
+    /// Maven 坐标(`group:artifact:version[:classifier][@ext]`)不合法。
+    InvalidCoordinate {
+        /// 原始字符串。
+        raw: String,
+        /// 不合法的原因。
+        reason: &'static str,
+    },
+    /// 资源对象哈希不合法(长度不足以做两级分片)。
+    InvalidAssetHash {
+        /// 原始哈希。
+        hash: String,
+    },
+    /// 文件系统错误。
+    Io {
+        /// 出错的路径。
+        path: PathBuf,
+        /// 底层 IO 错误。
+        source: std::io::Error,
+    },
+    /// 已有草稿持有写权,同一仓库同时只允许一个写会话。
+    DraftBusy,
+    /// 实例隔离档位是 `custom`,但没有可用的自定义目录(不猜默认值)。
+    CustomIsolationWithoutDir {
+        /// 实例 id(可能为空:调用方没给实例上下文)。
+        instance: String,
+    },
+}
+
+impl fmt::Display for GameError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Json { context, source } => write!(f, "解析 {context} 失败: {source}"),
+            Self::MissingVersionId => write!(f, "版本 JSON 缺少 id"),
+            Self::MissingParent {
+                parent,
+                required_by,
+            } => write!(
+                f,
+                "版本 {required_by} 继承的父版本 {parent} 不存在(缺失前置版本)"
+            ),
+            Self::CircularInheritance { chain } => {
+                write!(f, "版本继承成环: {}", chain.join(" -> "))
+            }
+            Self::InvalidCoordinate { raw, reason } => {
+                write!(f, "Maven 坐标不合法({reason}): {raw}")
+            }
+            Self::InvalidAssetHash { hash } => {
+                write!(f, "资源哈希不合法(短于两个字符): {hash}")
+            }
+            Self::Io { path, source } => write!(f, "IO 失败 {}: {source}", path.display()),
+            Self::DraftBusy => write!(f, "已有草稿持有写权(单写者约束)"),
+            Self::CustomIsolationWithoutDir { instance } => {
+                if instance.is_empty() {
+                    write!(f, "隔离档位为 custom,但没有指定自定义运行目录")
+                } else {
+                    write!(
+                        f,
+                        "实例 {instance} 的隔离档位为 custom,但没有指定自定义运行目录"
+                    )
+                }
+            }
+        }
+    }
+}
+
+impl std::error::Error for GameError {
+    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+        match self {
+            Self::Json { source, .. } => Some(source),
+            Self::Io { source, .. } => Some(source),
+            _ => None,
+        }
+    }
+}
+
+impl GameError {
+    /// 包装 serde_json 错误并带上文档上下文。
+    pub fn json(context: impl Into, source: serde_json::Error) -> Self {
+        Self::Json {
+            context: context.into(),
+            source,
+        }
+    }
+
+    /// 包装 IO 错误并带上路径。
+    pub fn io(path: impl Into, source: std::io::Error) -> Self {
+        Self::Io {
+            path: path.into(),
+            source,
+        }
+    }
+}
diff --git a/crates/core/src/game/inherit.rs b/crates/core/src/game/inherit.rs
new file mode 100644
index 0000000..2760ac4
--- /dev/null
+++ b/crates/core/src/game/inherit.rs
@@ -0,0 +1,198 @@
+//! `inheritsFrom` 继承解析、逐字段合并语义、patch 优先级排序。
+//!
+//! 依据:`DevDocs/analyse/03-version-download.md` §4.3(`resolve()`)、§4.4(merge 语义表)、
+//! §4.5(patch 生命周期)。
+//!
+//! 与 HMCL 的三处**有意差异**(都在 §4.3 的对照里):
+//! 1. **成环即报错**([`GameError::CircularInheritance`])而不是"断链后降级启动":
+//!    静默降级会让用户拿到"能启动但缺库"的实例,宁可明确告知清单坏了。
+//! 2. **缺失父版本报错**([`GameError::MissingParent`]),与 HMCL 的
+//!    `NoSuchGameInstanceException` 一致。
+//! 3. 不实现 `toPatch()` 的"把已解析父层固化进 patches"优化——P0 实例数量小,
+//!    每次启动重算继承链(毫秒级);等有性能证据再加缓存。
+
+use std::collections::BTreeMap;
+
+use super::arguments::Arguments;
+use super::error::GameError;
+use super::manifest::VersionJson;
+
+/// 父版本来源(仓库快照或测试里的内存表)。
+pub trait VersionSource {
+    /// 按 id 取一份版本 JSON;不存在返回 `None`。
+    ///
+    /// 实现方应自行处理 IO 错误——把失败转成 `None` 会让"父版本缺失"和"读盘失败"
+    /// 混为一谈,因此读盘失败的实现应当在调用 [`resolve`] 前就把它暴露给用户。
+    fn load(&self, id: &str) -> Option;
+}
+
+impl VersionSource for BTreeMap {
+    fn load(&self, id: &str) -> Option {
+        self.get(id).cloned()
+    }
+}
+
+impl VersionSource for F
+where
+    F: Fn(&str) -> Option,
+{
+    fn load(&self, id: &str) -> Option {
+        self(id)
+    }
+}
+
+/// 解析继承链,返回可交给 launch/download 的扁平清单。
+///
+/// 返回的清单保证:`inherits_from == None`、`patches == None`、`jar` 已回退为实例 id、
+/// `root == Some(true)`(见 [`VersionJson::is_resolved`])。
+///
+/// # Errors
+///
+/// - 清单缺 `id` → [`GameError::MissingVersionId`]
+/// - 父版本缺失 → [`GameError::MissingParent`]
+/// - 继承成环 → [`GameError::CircularInheritance`]
+pub fn resolve(
+    source: &S,
+    manifest: &VersionJson,
+) -> Result {
+    let root_id = manifest.require_id()?.to_owned();
+
+    // 1. 自根向祖先收集层(迭代而非递归:深层整合包的链可以很长)。
+    let mut chain = vec![root_id.clone()];
+    let mut layers = vec![manifest.clone()];
+    while let Some(parent_id) = layers.last().and_then(|l| l.inherits_from.clone()) {
+        if chain.iter().any(|id| id == &parent_id) {
+            chain.push(parent_id);
+            return Err(GameError::CircularInheritance { chain });
+        }
+        let required_by = layers
+            .last()
+            .and_then(|l| l.id.clone())
+            .unwrap_or_else(|| root_id.clone());
+        let parent = source
+            .load(&parent_id)
+            .ok_or_else(|| GameError::MissingParent {
+                parent: parent_id.clone(),
+                required_by,
+            })?;
+        chain.push(parent_id);
+        layers.push(parent);
+    }
+
+    // 2. 自最祖先向根折叠:每一层先与父层合并,再合并它自己的 patches。
+    let mut folded: Option = None;
+    for layer in layers.into_iter().rev() {
+        let merged = match &folded {
+            Some(parent) => merge(&layer, parent),
+            None => merge(&layer, &VersionJson::default()),
+        };
+        folded = Some(apply_patches(
+            merged,
+            &layer.patches.clone().unwrap_or_default(),
+        ));
+    }
+
+    // 3. 收尾:清继承标记、jar 兜底、补 id。
+    let mut resolved = folded.unwrap_or_default();
+    resolved.inherits_from = None;
+    resolved.patches = None;
+    resolved.priority = None;
+    resolved.root = Some(true);
+    if resolved.id.is_none() {
+        resolved.id = Some(root_id.clone());
+    }
+    if resolved.jar.is_none() {
+        resolved.jar = Some(root_id);
+    }
+    Ok(resolved)
+}
+
+/// 按 `priority` 升序排好的 patch 列表(`None` 视作最小值,稳定排序保持插入顺序)。
+pub fn sorted_patches(patches: &[VersionJson]) -> Vec<&VersionJson> {
+    let mut sorted: Vec<&VersionJson> = patches.iter().collect();
+    sorted.sort_by_key(|p| p.priority.unwrap_or(i32::MIN));
+    sorted
+}
+
+/// 把 patch 层按优先级依次叠加到基底清单上(先原版、后 loader:`0 < 30000`)。
+pub fn apply_patches(base: VersionJson, patches: &[VersionJson]) -> VersionJson {
+    let mut out = base;
+    for patch in sorted_patches(patches) {
+        out = merge(patch, &out);
+    }
+    out
+}
+
+/// 合并两层清单:**子(或后合并的层)覆盖父**,语义逐字段照 analyse/03 §4.4。
+///
+/// 非直觉的两点,测试里各有一条守门:
+/// - `downloads`/`logging` **整体覆盖**:子层只写 `server` 会让父层的 `client` 消失;
+/// - `compliance_level` **只取子**:父层的值被丢弃(缺则是 `None`)。
+///
+/// 这两点**与 analyse/03 §8.3 第 15 条建议相左**(该条主张逐字段/逐 key 合并)。
+/// 保留现状的理由是生态兼容:现实 modpack/loader 的版本 JSON 按 HMCL/原版语义编写,
+/// 逐 key 合并会让"子层想整份换掉 downloads"的场景语义漂移。经 tower 裁决 2026-10-29 维持。
+pub fn merge(child: &VersionJson, parent: &VersionJson) -> VersionJson {
+    let mut extra = parent.extra.clone();
+    extra.extend(child.extra.iter().map(|(k, v)| (k.clone(), v.clone())));
+
+    VersionJson {
+        id: child.id.clone().or_else(|| parent.id.clone()),
+        name: child.name.clone().or_else(|| parent.name.clone()),
+        // 解析后不再带继承。
+        inherits_from: None,
+        jar: child.jar.clone().or_else(|| parent.jar.clone()),
+        main_class: child
+            .main_class
+            .clone()
+            .or_else(|| parent.main_class.clone()),
+        minecraft_arguments: child
+            .minecraft_arguments
+            .clone()
+            .or_else(|| parent.minecraft_arguments.clone()),
+        arguments: Arguments::merge(parent.arguments.as_ref(), child.arguments.as_ref()),
+        assets: child.assets.clone().or_else(|| parent.assets.clone()),
+        asset_index: child
+            .asset_index
+            .clone()
+            .or_else(|| parent.asset_index.clone()),
+        compliance_level: child.compliance_level,
+        java_version: child
+            .java_version
+            .clone()
+            .or_else(|| parent.java_version.clone()),
+        libraries: match (&parent.libraries, &child.libraries) {
+            (None, None) => None,
+            (Some(parent), None) => Some(parent.clone()),
+            (None, Some(child)) => Some(child.clone()),
+            // 父在前、子在后追加(重复项由使用方按需去重)。
+            (Some(parent), Some(child)) => {
+                Some(parent.iter().chain(child.iter()).cloned().collect())
+            }
+        },
+        // 整体覆盖,不逐 key 合并。
+        downloads: child.downloads.clone().or_else(|| parent.downloads.clone()),
+        logging: child.logging.clone().or_else(|| parent.logging.clone()),
+        version_type: child.version_type.or(parent.version_type),
+        time: child.time.clone().or_else(|| parent.time.clone()),
+        release_time: child
+            .release_time
+            .clone()
+            .or_else(|| parent.release_time.clone()),
+        minimum_launcher_version: match (
+            parent.minimum_launcher_version,
+            child.minimum_launcher_version,
+        ) {
+            (Some(a), Some(b)) => Some(a.max(b)),
+            (a, None) => a,
+            (None, b) => b,
+        },
+        // 合并结果一律是"根清单"。
+        root: Some(true),
+        hidden: child.hidden,
+        // patch 生命周期由 resolve/apply_patches 管,合并结果不带 patches。
+        patches: None,
+        priority: None,
+        extra,
+    }
+}
diff --git a/crates/core/src/game/install/assets.rs b/crates/core/src/game/install/assets.rs
new file mode 100644
index 0000000..fefcd83
--- /dev/null
+++ b/crates/core/src/game/install/assets.rs
@@ -0,0 +1,190 @@
+//! 资源索引(`assets/indexes/.json`)的模型与逐对象展开。
+//!
+//! 依据:`DevDocs/analyse/03-version-download.md` §6.5(索引下载)、§6.6(逐对象下载)。
+//!
+//! 对象是**内容寻址**的:路径 `assets/objects//`、URL
+//! `https://resources.download.minecraft.net//`,校验值就是哈希本身。
+//! 因此索引里不需要 `sha1` 字段——文件名即摘要,这也是"两个实例共用同一资源"零成本的原因。
+//!
+//! 本模块只做"索引 → 待下载对象"的纯计算,不碰网络;落盘路径一律问
+//! [`GameLayout`],不在本地拼字符串。
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+use super::super::error::GameError;
+use super::super::layout::GameLayout;
+use super::plan::{FileRole, PlanFile};
+use crate::download::ResourceKind;
+
+/// 资源对象的官方源基址。
+pub const ASSET_OBJECT_BASE: &str = "https://resources.download.minecraft.net";
+
+/// 一个资源对象。
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AssetObject {
+    /// 内容 SHA-1(同时是文件名)。
+    #[serde(default)]
+    pub hash: String,
+    /// 字节数。
+    #[serde(default)]
+    pub size: u64,
+}
+
+impl AssetObject {
+    /// 相对 `assets/objects/` 的路径 `xx/`。
+    #[must_use]
+    pub fn location(&self) -> String {
+        let shard = self.hash.get(0..2).unwrap_or_default();
+        format!("{shard}/{}", self.hash)
+    }
+
+    /// 官方下载地址(镜像由下载引擎按前缀规则展开)。
+    #[must_use]
+    pub fn url(&self) -> String {
+        format!("{ASSET_OBJECT_BASE}/{}", self.location())
+    }
+
+    /// 哈希是否够做两级分片(短于两个字符说明索引坏了)。
+    #[must_use]
+    pub fn is_well_formed(&self) -> bool {
+        self.hash.len() >= 2 && self.hash.bytes().all(|b| b.is_ascii_hexdigit())
+    }
+}
+
+/// 一份资源索引。
+///
+/// `virtual` / `map_to_resources` 只影响**启动期**的目录映射(`assets/virtual/`、
+/// `resources/`),不影响下载,这里保真存下但不消费。
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AssetIndex {
+    /// 对象表:键是资源名(`minecraft/sounds/...ogg`),值是内容描述。
+    #[serde(default)]
+    pub objects: BTreeMap,
+    /// 旧版:是否要在启动期铺到 `assets/virtual//`。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub r#virtual: Option,
+    /// 旧版:是否要映射到存档目录旁的 `resources/`。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub map_to_resources: Option,
+}
+
+impl AssetIndex {
+    /// 解析索引 JSON。
+    ///
+    /// # Errors
+    ///
+    /// JSON 非法(例如镜像返回了 HTML 错误页)时返回 [`GameError::Json`]。
+    pub fn from_json_slice(bytes: &[u8]) -> Result {
+        serde_json::from_slice(bytes).map_err(|e| GameError::json("asset_index", e))
+    }
+
+    /// 对象数量。
+    #[must_use]
+    pub fn len(&self) -> usize {
+        self.objects.len()
+    }
+
+    /// 是否没有对象。
+    #[must_use]
+    pub fn is_empty(&self) -> bool {
+        self.objects.is_empty()
+    }
+
+    /// 全部对象的总字节数(进度预算与 `--dry-run` 总大小用)。
+    #[must_use]
+    pub fn total_bytes(&self) -> u64 {
+        self.objects.values().map(|object| object.size).sum()
+    }
+}
+
+/// 把索引展开成待下载文件清单(按资源名排序,保证计划可复现)。
+///
+/// # Errors
+///
+/// 某个对象哈希不合法(短于两字符 / 非十六进制)时返回 [`GameError::InvalidAssetHash`]。
+pub fn plan(layout: &GameLayout, index: &AssetIndex) -> Result, GameError> {
+    let mut files = Vec::with_capacity(index.objects.len());
+    for (name, object) in &index.objects {
+        if !object.is_well_formed() {
+            return Err(GameError::InvalidAssetHash {
+                hash: object.hash.clone(),
+            });
+        }
+        files.push(PlanFile {
+            role: FileRole::Asset,
+            name: name.clone(),
+            url: object.url(),
+            sha1: Some(object.hash.clone()),
+            size: Some(object.size),
+            dest: layout.asset_object(&object.hash)?,
+            resource: ResourceKind::Asset,
+        });
+    }
+    Ok(files)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{plan, AssetIndex, AssetObject};
+    use crate::game::layout::GameLayout;
+
+    const HASH: &str = "0f9d6b1c0d5f4e3b2a1908070605040302010f0e";
+
+    #[test]
+    fn location_and_url_use_two_char_shard() {
+        let object = AssetObject {
+            hash: HASH.to_string(),
+            size: 7,
+        };
+        assert_eq!(object.location(), format!("0f/{HASH}"));
+        assert_eq!(
+            object.url(),
+            format!("https://resources.download.minecraft.net/0f/{HASH}")
+        );
+    }
+
+    #[test]
+    fn parses_index_and_plans_objects() {
+        let json = format!(
+            r#"{{"objects":{{"minecraft/lang/zh_cn.json":{{"hash":"{HASH}","size":42}}}}}}"#
+        );
+        let index = AssetIndex::from_json_slice(json.as_bytes()).expect("索引应可解析");
+        assert_eq!(index.len(), 1);
+        assert_eq!(index.total_bytes(), 42);
+
+        let layout = GameLayout::new("/games/.minecraft");
+        let files = plan(&layout, &index).expect("展开应成功");
+        assert_eq!(files.len(), 1);
+        let file = &files[0];
+        assert_eq!(file.sha1.as_deref(), Some(HASH));
+        assert_eq!(file.size, Some(42));
+        assert!(file.dest.ends_with(format!("objects/0f/{HASH}")));
+    }
+
+    #[test]
+    fn rejects_short_hash() {
+        let mut index = AssetIndex::default();
+        index.objects.insert(
+            "a".to_string(),
+            AssetObject {
+                hash: "a".to_string(),
+                size: 1,
+            },
+        );
+        let layout = GameLayout::new("/games/.minecraft");
+        assert!(plan(&layout, &index).is_err());
+    }
+
+    #[test]
+    fn keeps_asset_keys_as_names() {
+        let json = format!(
+            r#"{{"objects":{{"icons/icon_16x16.png":{{"hash":"{HASH}","size":1}},"lang/en_us.json":{{"hash":"{HASH}","size":2}}}}}}"#
+        );
+        let index = AssetIndex::from_json_slice(json.as_bytes()).expect("索引应可解析");
+        let files = plan(&GameLayout::new("/g"), &index).expect("展开应成功");
+        let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
+        assert_eq!(names, vec!["icons/icon_16x16.png", "lang/en_us.json"]);
+    }
+}
diff --git a/crates/core/src/game/install/execute.rs b/crates/core/src/game/install/execute.rs
new file mode 100644
index 0000000..ad8586c
--- /dev/null
+++ b/crates/core/src/game/install/execute.rs
@@ -0,0 +1,361 @@
+//! 落盘执行:跳过判定 → 并发下载(引擎负责镜像/退避/续传/校验)→ 原子写版本 JSON。
+//!
+//! 依据:`DevDocs/analyse/03-version-download.md` §6.9(完整性修复路径的"存在即信"分级)、
+//! `DevDocs/PlanDoc/05-download-and-mirrors.md` §2(按资源类型分层)、§5(静态展开并行度)。
+//!
+//! # 跳过判定(幂等的地基)
+//!
+//! 判据分三级,与 analyse/03 §6.9 的思路一致:
+//! - 有 sha1 → **重算摘要比对**(唯一可信判据;代价是读一遍盘);
+//! - 无 sha1 有大小 → 只比大小(清单没给摘要时退一步);
+//! - 两者都没有 → 存在即信(HMCL 同类做法,留给后续"一键修复"补校验)。
+//!
+//! 跳过不写盘、不进网络,因此"重复 install 同版本"第二次几乎零成本。
+//!
+//! # 失败策略
+//!
+//! **不整体回滚**:已下好的共享层文件(libraries/assets)留在原地复用,失败信息逐文件结构化
+//! 返回,由调用方决定是重试、还是只提示"缺哪几个文件"。这与 analyse/03 §6.3 的"宁可给出
+//! 可修复的骨架"一致,但**版本清单与实例登记只在全量成功后才写**,不会出现半实例。
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use futures_util::stream::{self, StreamExt};
+
+use super::plan::{FileRole, PlanFile};
+use super::InstallPlan;
+use crate::download::verify;
+use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind, HashAlgo};
+use crate::platform::atomic::write_atomic;
+
+/// 单个文件的结局。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FileStatus {
+    /// 本次真的下载(或从缓存直取)并落盘。
+    Downloaded,
+    /// 已存在且校验通过,跳过。
+    Skipped,
+    /// 失败(原因在 [`FileOutcome::failure`])。
+    Failed,
+}
+
+impl FileStatus {
+    /// 稳定字符串 id。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Downloaded => "downloaded",
+            Self::Skipped => "skipped",
+            Self::Failed => "failed",
+        }
+    }
+}
+
+/// 一次失败的分类信息(调用方据此给 `install_network` / `install_integrity` 等稳定码)。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct FileFailure {
+    /// 下载错误分类。
+    pub kind: DownloadErrorKind,
+    /// 人读说明。
+    pub message: String,
+}
+
+/// 一个文件的执行结果。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct FileOutcome {
+    /// 角色。
+    pub role: FileRole,
+    /// 人读标识。
+    pub name: String,
+    /// 落盘路径。
+    pub dest: std::path::PathBuf,
+    /// 结局。
+    pub status: FileStatus,
+    /// 实际字节数(跳过时为盘上大小)。
+    pub bytes: u64,
+    /// 成功的源标识(`bmclapi` / `official` / `cache` / `metadata`)。
+    pub source: Option,
+    /// 失败详情。
+    pub failure: Option,
+}
+
+impl FileOutcome {
+    /// 是否失败。
+    #[must_use]
+    pub fn failed(&self) -> bool {
+        self.status == FileStatus::Failed
+    }
+}
+
+/// 进度回调:每完成一个文件调用一次(可跨线程,由执行器持有 `Arc`)。
+///
+/// CLI 用它往 stderr 打实时进度;GUI 侧直接消费 `ProgressEvent` 事件流即可,不必用这个。
+pub type ProgressSink = Arc;
+
+/// 一次安装的执行结果。
+#[derive(Debug, Clone)]
+pub struct InstallReport {
+    /// 逐文件结果(顺序不保证,按角色/大小汇总即可)。
+    pub files: Vec,
+    /// 版本清单落盘路径;`None` = 有文件失败,故意没写(见 [`execute`] 的说明)。
+    pub version_json: Option,
+    /// 计划阶段带来的告警(缺件降级说明)。
+    pub warnings: Vec,
+}
+
+impl InstallReport {
+    /// 真正下载的文件数。
+    #[must_use]
+    pub fn downloaded(&self) -> usize {
+        self.files
+            .iter()
+            .filter(|file| file.status == FileStatus::Downloaded)
+            .count()
+    }
+
+    /// 跳过(已存在且校验通过)的文件数。
+    #[must_use]
+    pub fn skipped(&self) -> usize {
+        self.files
+            .iter()
+            .filter(|file| file.status == FileStatus::Skipped)
+            .count()
+    }
+
+    /// 失败的文件数。
+    #[must_use]
+    pub fn failed(&self) -> usize {
+        self.files.iter().filter(|file| file.failed()).count()
+    }
+
+    /// 本次写入的字节数(只算真正下载的)。
+    #[must_use]
+    pub fn bytes(&self) -> u64 {
+        self.files
+            .iter()
+            .filter(|file| file.status == FileStatus::Downloaded)
+            .map(|file| file.bytes)
+            .sum()
+    }
+
+    /// 是否全部就绪(无失败)。
+    #[must_use]
+    pub fn is_complete(&self) -> bool {
+        self.failed() == 0
+    }
+
+    /// 失败文件列表(最多取前 `limit` 条,给 CLI 拼提示用)。
+    #[must_use]
+    pub fn failures(&self, limit: usize) -> Vec<&FileOutcome> {
+        self.files
+            .iter()
+            .filter(|file| file.failed())
+            .take(limit)
+            .collect()
+    }
+
+    /// 失败里最"严重"的大类:有校验不符就先报它,其余按网络/IO 归。
+    #[must_use]
+    pub fn failure_kind(&self) -> Option {
+        let mut fallback = None;
+        for file in self.files.iter().filter(|file| file.failed()) {
+            let Some(failure) = &file.failure else {
+                continue;
+            };
+            if failure.kind == DownloadErrorKind::Integrity {
+                return Some(DownloadErrorKind::Integrity);
+            }
+            fallback.get_or_insert(failure.kind);
+        }
+        fallback
+    }
+}
+
+/// 执行安装计划。
+///
+/// 具体步骤:
+/// 1. 规划期已取到的元数据(资源索引,见 [`InstallPlan::prefetch`])先原子落盘,零网络;
+/// 2. 其余文件并发下载(`concurrency` 上限,引擎内部另有信号量),每个文件带进度回调;
+/// 3. **全量就绪**才写 `versions//.json`:这份清单是"已安装"的判据
+///    ([`GameLayout::is_version_installed`]),半成品写进去会把缺件的版本报成已安装。
+///    共享层里已下好的文件留着复用,重跑 `ull install` 会补齐剩下的。
+///
+/// # Errors
+///
+/// 版本 JSON 落盘失败时返回 IO 错误;文件下载失败**不**返回错误,而是进
+/// [`InstallReport`](调用方按 [`InstallReport::is_complete`] 决策)。
+pub async fn execute(
+    engine: &DownloadEngine,
+    plan: &InstallPlan,
+    concurrency: usize,
+    sink: Option,
+) -> Result {
+    let mut outcomes: Vec = Vec::with_capacity(plan.files.len());
+    let mut prefetched: Vec<&PlanFile> = Vec::new();
+
+    for payload in &plan.prefetched {
+        let Some(file) = plan.files.iter().find(|file| file.dest == payload.dest) else {
+            continue;
+        };
+        // 盘上已是同一份内容就不再重写(规划期为了枚举对象必须联网取索引,
+        // 但那不等于"需要重新落盘"——重装的"下载 0 个"要名副其实)。
+        if let Some(bytes) = current_bytes(file) {
+            let outcome = FileOutcome {
+                role: file.role,
+                name: file.name.clone(),
+                dest: file.dest.clone(),
+                status: FileStatus::Skipped,
+                bytes,
+                source: None,
+                failure: None,
+            };
+            if let Some(sink) = &sink {
+                sink(&outcome);
+            }
+            outcomes.push(outcome);
+            prefetched.push(file);
+            continue;
+        }
+        let written = write_atomic(&payload.dest, &payload.bytes).map_err(|error| {
+            super::InstallError::new(super::InstallErrorKind::Io, error.to_string())
+        });
+        let outcome = match written {
+            Ok(()) => FileOutcome {
+                role: file.role,
+                name: file.name.clone(),
+                dest: file.dest.clone(),
+                status: FileStatus::Downloaded,
+                bytes: payload.bytes.len() as u64,
+                source: Some("metadata".to_string()),
+                failure: None,
+            },
+            Err(error) => FileOutcome {
+                role: file.role,
+                name: file.name.clone(),
+                dest: file.dest.clone(),
+                status: FileStatus::Failed,
+                bytes: 0,
+                source: None,
+                failure: Some(FileFailure {
+                    kind: DownloadErrorKind::Io,
+                    message: error.to_string(),
+                }),
+            },
+        };
+        if let Some(sink) = &sink {
+            sink(&outcome);
+        }
+        outcomes.push(outcome);
+        prefetched.push(file);
+    }
+
+    let pending: Vec<&PlanFile> = plan
+        .files
+        .iter()
+        .filter(|file| !prefetched.iter().any(|done| done.dest == file.dest))
+        .collect();
+
+    let downloaded: Vec = stream::iter(pending)
+        .map(|file| {
+            let sink = sink.clone();
+            async move {
+                let outcome = install_one(engine, file).await;
+                if let Some(sink) = &sink {
+                    sink(&outcome);
+                }
+                outcome
+            }
+        })
+        .buffer_unordered(concurrency.max(1))
+        .collect()
+        .await;
+    outcomes.extend(downloaded);
+
+    // 版本清单只在**全量就绪**时落盘:`is_version_installed()` 以清单存在为判据,
+    // 半成品写进去会让 `instances list` 把"缺 jar 的版本"报成已安装。
+    let complete = outcomes.iter().all(|outcome| !outcome.failed());
+    let version_json = if complete {
+        write_version_json(&plan.version_json_path, plan)?;
+        Some(plan.version_json_path.clone())
+    } else {
+        None
+    };
+
+    Ok(InstallReport {
+        files: outcomes,
+        version_json,
+        warnings: plan.warnings.clone(),
+    })
+}
+
+/// 单文件:先判"已就绪",否则走引擎下载。
+async fn install_one(engine: &DownloadEngine, file: &PlanFile) -> FileOutcome {
+    if let Some(bytes) = current_bytes(file) {
+        return FileOutcome {
+            role: file.role,
+            name: file.name.clone(),
+            dest: file.dest.clone(),
+            status: FileStatus::Skipped,
+            bytes,
+            source: None,
+            failure: None,
+        };
+    }
+    match engine.download(file.request()).await {
+        Ok(outcome) => FileOutcome {
+            role: file.role,
+            name: file.name.clone(),
+            dest: file.dest.clone(),
+            status: FileStatus::Downloaded,
+            bytes: outcome.bytes,
+            source: Some(outcome.source),
+            failure: None,
+        },
+        Err(error) => FileOutcome {
+            role: file.role,
+            name: file.name.clone(),
+            dest: file.dest.clone(),
+            status: FileStatus::Failed,
+            bytes: 0,
+            source: None,
+            failure: Some(describe(&error)),
+        },
+    }
+}
+
+/// 文件已就绪则返回盘上大小(跳过判定,见模块文档的三级分级)。
+#[must_use]
+pub fn current_bytes(file: &PlanFile) -> Option {
+    if !file.dest.is_file() {
+        return None;
+    }
+    let size = std::fs::metadata(&file.dest).ok()?.len();
+    match file.sha1.as_deref().filter(|hex| !hex.is_empty()) {
+        Some(expected) => {
+            let actual = verify::digest_file(&file.dest, HashAlgo::Sha1).ok()?;
+            verify::matches(expected, &actual).then_some(size)
+        }
+        None => match file.size {
+            Some(expected) => (size == expected).then_some(size),
+            // 没有摘要也没有大小:存在即信(后续"一键修复"再补校验)。
+            None => Some(size),
+        },
+    }
+}
+
+/// 写解析后的版本清单(原子写)。
+fn write_version_json(path: &Path, plan: &InstallPlan) -> Result<(), super::InstallError> {
+    let text = plan.version_json.to_json_string().map_err(|error| {
+        super::InstallError::new(super::InstallErrorKind::Metadata, error.to_string())
+    })?;
+    write_atomic(path, text.as_bytes())
+        .map_err(|error| super::InstallError::new(super::InstallErrorKind::Io, error.to_string()))
+}
+
+fn describe(error: &DownloadError) -> FileFailure {
+    FileFailure {
+        kind: error.kind,
+        message: error.message.clone(),
+    }
+}
diff --git a/crates/core/src/game/install/instance.rs b/crates/core/src/game/install/instance.rs
new file mode 100644
index 0000000..8fa0aa3
--- /dev/null
+++ b/crates/core/src/game/install/instance.rs
@@ -0,0 +1,351 @@
+//! 实例登记:`config/profiles.json` + `config/instances/.json`(稀疏、幂等)。
+//!
+//! 依据:`DevDocs/PlanDoc/04-data-and-config.md` §4(D20 实例模型)、§2(`.ULL/config/`)、
+//! `DevDocs/PlanDoc/research/04-launcher-configs.md` §5(实例键草案)。
+//!
+//! # 为什么是两份文件
+//!
+//! - `profiles.json` = **档案列表**(UI 顺序、展示名、绑定版本),是"有哪些实例"的真相源;
+//! - `instances/.json` = **每实例设置**,稀疏:只写用户显式改过的键。
+//!
+//! §5 的关键结论:`isolation` 默认 `inherit` 且**不落盘**——这样改全局模式对存量实例立即生效,
+//! 直接避开 PCL2"默认值被固化成实例值"的缺陷。因此 [`InstanceConfig::new`] 的
+//! `isolation` 是 `None`(序列化时整个键消失);档位的**派生与切换检测**在
+//! [`crate::game::isolation`](M30 落地),本模块只管结构与落盘。
+//!
+//! # 幂等
+//!
+//! 同一版本重复 `install`:按 `version_id` + 展示名复用已有档案,返回 `created = false`,
+//! 不产生第二个实例、不改已有实例的 id(id 一旦写进档案就是稳定的)。
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+use serde::{Deserialize, Serialize};
+
+use super::super::error::GameError;
+use super::super::isolation::{IsolationMode, ModeChange, ModeChangeWarning};
+use super::super::profiles::{InstanceProfile, ProfileList};
+use crate::platform::atomic::write_atomic;
+use crate::platform::layout::LauncherLayout;
+
+/// 每实例设置文件的 schema 版本。
+///
+/// 与 `setting::instance_keys::instance_version` 的 minor 对应:D20 加了 `lastModeChange`,
+/// 老文件缺这个键即「没切换过」,不需要迁移步骤。
+pub const INSTANCE_CONFIG_VERSION: u32 = 1;
+
+/// 版本隔离档位(`isolation`)。
+///
+/// 缺省 `inherit` **不是**这个枚举的一个值,而是"键不存在"([`InstanceConfig::isolation`]
+/// 为 `None`)——这样稀疏序列化天然表达"跟随全局"。
+///
+/// 派生规则见 [`crate::game::isolation::derive_game_dir`]:`isolated` → `versions//`、
+/// `shared` → 游戏根 `.minecraft/`、`custom` → 用户指定的 `gameDir`。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Isolation {
+    /// 独立游戏目录(派生 `versions//`)。
+    #[serde(alias = "per_version")]
+    Isolated,
+    /// 与全局共用 `.minecraft`(HMCL 传统形态)。
+    Shared,
+    /// 用户指定的目录(此时 `gameDir` 必须有值)。
+    Custom,
+}
+
+impl Isolation {
+    /// 稳定字符串 id。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Isolated => "isolated",
+            Self::Shared => "shared",
+            Self::Custom => "custom",
+        }
+    }
+
+    /// 从字符串解析(未知值返回 `None`,由调用方决定是报错还是忽略)。
+    ///
+    /// `per_version` 是 M12 键草案里的旧字面量,读兼容映射到 `isolated`(语义相同)。
+    #[must_use]
+    pub fn parse(raw: &str) -> Option {
+        match raw.trim() {
+            "isolated" | "per_version" => Some(Self::Isolated),
+            "shared" => Some(Self::Shared),
+            "custom" => Some(Self::Custom),
+            _ => None,
+        }
+    }
+}
+
+/// 实例可覆盖的设置键(enum 白名单)。
+///
+/// research/04 §5 的教训:**不要用字符串集合**,拼错会静默不生效;这里用枚举,
+/// 后续 M28 接语义时编译器会逼着把每个键处理干净。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum OverrideKey {
+    /// 最大堆内存。
+    MaxMemory,
+    /// Java 运行时选择。
+    JavaType,
+    /// 工作目录覆盖。
+    RunningDirectory,
+}
+
+/// 每实例设置(`config/instances/.json`)。
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct InstanceConfig {
+    /// schema 版本(迁移链入口)。
+    #[serde(default = "default_version")]
+    pub schema_version: u32,
+    /// 实例 id(稳定 UUID,与文件名一致)。
+    pub id: String,
+    /// 绑定的版本 id(`versions//`)。
+    pub version_id: String,
+    /// 隔离档位;`None` = `inherit`(跟随全局,不落盘)。
+    ///
+    /// 显式写 `"inherit"` 也照读(归一成 `None`)——见
+    /// [`deserialize_isolation`]:schema 白名单允许用户/AI 写这个字面量,
+    /// 读路径必须闭环,否则「显式写回默认值」的实例文件会让启动直接报 unknown variant。
+    #[serde(
+        default,
+        skip_serializing_if = "Option::is_none",
+        deserialize_with = "deserialize_isolation"
+    )]
+    pub isolation: Option,
+    /// 自定义游戏目录(仅 `isolation = custom` 时有值)。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub game_dir: Option,
+    /// 该实例覆盖了哪些设置键。
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub overrides: Vec,
+    /// 上次隔离档位切换的记录(`lastModeChange`;没切过就不落盘)。
+    ///
+    /// 由 [`crate::game::isolation::observe_mode`] 维护,只记事实(含双侧探测),**不迁移数据**。
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub last_mode_change: Option,
+    /// 未识别字段原样保留(向前兼容)。
+    #[serde(flatten)]
+    pub extra: BTreeMap,
+}
+
+fn default_version() -> u32 {
+    INSTANCE_CONFIG_VERSION
+}
+
+/// `isolation` 的反序列化:显式 `"inherit"` 归一成「键不存在」(`None`)。
+///
+/// 为什么需要:`setting::instance_keys` 的白名单里有 `inherit`(读/显示要用),
+/// 用户或 AI 把档位写回默认值时就会落一个 `"isolation": "inherit"`;从枚举角度它
+/// 是**没有变体**的(`inherit` 的真相是稀疏 —— 键缺失),所以在这里折叠成 `None`,
+/// 顺带把 `per_version` 旧字面量也接住(`Isolation::parse` 负责映射)。
+fn deserialize_isolation<'de, D>(deserializer: D) -> Result, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    match Option::::deserialize(deserializer)? {
+        None => Ok(None),
+        Some(text) if text.trim() == "inherit" => Ok(None),
+        Some(text) => Isolation::parse(&text).map(Some).ok_or_else(|| {
+            serde::de::Error::unknown_variant(&text, &["inherit", "isolated", "shared", "custom"])
+        }),
+    }
+}
+
+impl InstanceConfig {
+    /// 新建一份最小设置:`isolation` 留空(= inherit,不落盘)。
+    #[must_use]
+    pub fn new(id: impl Into, version_id: impl Into) -> Self {
+        Self {
+            schema_version: INSTANCE_CONFIG_VERSION,
+            id: id.into(),
+            version_id: version_id.into(),
+            isolation: None,
+            game_dir: None,
+            overrides: Vec::new(),
+            last_mode_change: None,
+            extra: BTreeMap::new(),
+        }
+    }
+
+    /// 是否跟随全局隔离模式(`isolation` 键缺失)。
+    #[must_use]
+    pub fn inherits_isolation(&self) -> bool {
+        self.isolation.is_none()
+    }
+
+    /// 生效隔离档位(覆盖链第二层落到全局第一层,`inherit` 在这里被解析掉)。
+    #[must_use]
+    pub fn effective_isolation(&self, global: IsolationMode) -> Isolation {
+        self.isolation.unwrap_or_else(|| global.as_isolation())
+    }
+
+    /// 取走待展示的切换提示(取走后 `warned` 置真,调用方负责落盘)。
+    pub fn take_mode_change_warning(&mut self) -> Option {
+        self.last_mode_change.as_mut()?.take_warning()
+    }
+
+    /// 解析。
+    ///
+    /// # Errors
+    ///
+    /// JSON 非法时返回 [`GameError::Json`]。
+    pub fn from_json_str(json: &str) -> Result {
+        serde_json::from_str(json).map_err(|e| GameError::json("instance_config", e))
+    }
+
+    /// 序列化(格式化,便于用户手改与 diff)。
+    ///
+    /// # Errors
+    ///
+    /// 序列化失败时返回 [`GameError::Json`]。
+    pub fn to_json_string(&self) -> Result {
+        serde_json::to_string_pretty(self).map_err(|e| GameError::json("instance_config", e))
+    }
+}
+
+/// 一次登记的结果。
+#[derive(Debug, Clone)]
+pub struct Registration {
+    /// 档案(新建或复用)。
+    pub profile: InstanceProfile,
+    /// 每实例设置文件路径。
+    pub config_path: PathBuf,
+    /// 是否新建(`false` = 已有同版本同名的实例,直接复用)。
+    pub created: bool,
+}
+
+/// 登记一个实例(幂等)。
+///
+/// 行为:
+/// 1. 已有"同 `version_id` + 同展示名"的档案 → 复用(`created = false`),只补写缺失的设置文件;
+/// 2. 否则新建档案(稳定 UUID)→ 写 `profiles.json` → 写 `config/instances/.json`。
+///
+/// `created_at` 由调用方给(core::game 不碰时钟格式;CLI 用 `launch::time` 的 RFC3339)。
+///
+/// # Errors
+///
+/// 读/写 `profiles.json`、实例 id 不合法、原子写失败。
+pub fn register(
+    layout: &LauncherLayout,
+    name: &str,
+    version_id: &str,
+    created_at: Option<&str>,
+) -> Result {
+    let mut profiles = read_profiles(layout)?;
+    let existing = profiles
+        .profiles
+        .iter()
+        .find(|profile| profile.version_id == version_id && profile.name == name)
+        .cloned();
+
+    if let Some(profile) = existing {
+        let config_path = instance_config_path(layout, profile.id.as_str())?;
+        // 老档案可能还没有设置文件(早期 CLI 只写 profiles.json),这里补齐。
+        if !config_path.is_file() {
+            write_instance_config(
+                layout,
+                &InstanceConfig::new(profile.id.as_str(), version_id),
+            )?;
+        }
+        return Ok(Registration {
+            profile,
+            config_path,
+            created: false,
+        });
+    }
+
+    let mut profile = InstanceProfile::new(name, version_id);
+    profile.created_at = created_at.map(str::to_string);
+    let id = profile.id.as_str().to_string();
+    profiles.upsert(profile.clone());
+    write_profiles(layout, &profiles)?;
+
+    let config_path = write_instance_config(layout, &InstanceConfig::new(id, version_id))?;
+    Ok(Registration {
+        profile,
+        config_path,
+        created: true,
+    })
+}
+
+/// 读实例清单(文件缺失 = 空列表,不算错误)。
+///
+/// # Errors
+///
+/// 读盘失败、非 UTF-8 或 JSON 非法。
+pub fn read_profiles(layout: &LauncherLayout) -> Result {
+    let path = layout.profiles_config();
+    let bytes = match std::fs::read(&path) {
+        Ok(bytes) => bytes,
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+            return Ok(ProfileList::default());
+        }
+        Err(error) => return Err(GameError::io(&path, error)),
+    };
+    let Ok(text) = String::from_utf8(bytes) else {
+        return Err(GameError::io(
+            &path,
+            std::io::Error::new(std::io::ErrorKind::InvalidData, "profiles.json 不是 UTF-8"),
+        ));
+    };
+    ProfileList::from_json_str(&text)
+}
+
+/// 写实例清单(原子写)。
+///
+/// # Errors
+///
+/// 序列化失败或原子写失败。
+pub fn write_profiles(layout: &LauncherLayout, profiles: &ProfileList) -> Result<(), GameError> {
+    let text = profiles.to_json_string()?;
+    write_atomic(&layout.profiles_config(), text.as_bytes())
+        .map_err(|error| GameError::io(layout.profiles_config(), std::io::Error::other(error)))
+}
+
+/// 写每实例设置(原子写)。
+///
+/// # Errors
+///
+/// 实例 id 不合法、序列化失败或原子写失败。
+pub fn write_instance_config(
+    layout: &LauncherLayout,
+    config: &InstanceConfig,
+) -> Result {
+    let path = instance_config_path(layout, &config.id)?;
+    let text = config.to_json_string()?;
+    write_atomic(&path, text.as_bytes())
+        .map_err(|error| GameError::io(&path, std::io::Error::other(error)))?;
+    Ok(path)
+}
+
+/// 读每实例设置;缺失返回 `None`。
+///
+/// # Errors
+///
+/// 实例 id 不合法、读盘失败或 JSON 非法。
+pub fn read_instance_config(
+    layout: &LauncherLayout,
+    id: &str,
+) -> Result, GameError> {
+    let path = instance_config_path(layout, id)?;
+    match std::fs::read_to_string(&path) {
+        Ok(text) => InstanceConfig::from_json_str(&text).map(Some),
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
+        Err(error) => Err(GameError::io(&path, error)),
+    }
+}
+
+/// `config/instances/.json`(实例 id 白名单校验在布局层,挡掉 `../` 穿越)。
+///
+/// # Errors
+///
+/// 实例 id 不合法时返回 [`GameError::Io`](`PlatformError` 没有对应变体)。
+fn instance_config_path(layout: &LauncherLayout, id: &str) -> Result {
+    layout
+        .instance_config(id)
+        .map_err(|error| GameError::io(layout.config(), std::io::Error::other(error)))
+}
diff --git a/crates/core/src/game/install/libraries.rs b/crates/core/src/game/install/libraries.rs
new file mode 100644
index 0000000..6638c38
--- /dev/null
+++ b/crates/core/src/game/install/libraries.rs
@@ -0,0 +1,258 @@
+//! 依赖库展开:主构件 + natives 分类器构件 → 待下载文件。
+//!
+//! 依据:`DevDocs/analyse/03-version-download.md` §6.4(libraries 含 natives 规则)、
+//! §6.8(目录布局速查)。
+//!
+//! 三条容易踩的规则:
+//! 1. **主构件与 natives 构件都要下**:老版本(lwjgl 2.x)一条库里同时有
+//!    `downloads.artifact` 与 `natives` 表,HMCL 也是两者都取;
+//! 2. **路径优先用 JSON 给的 `path`**(Mojang 权威值),缺了才按 Maven 坐标推导;
+//! 3. **`hint=local` 的库落到实例私有目录** `versions//libraries/`,其余一律进
+//!    恒共享的 `libraries/`(D20:任何模式都不按实例复制)。
+
+use std::path::{Path, PathBuf};
+
+use super::super::arguments::Environment;
+use super::super::layout::GameLayout;
+use super::super::library::Library;
+use super::plan::{FileRole, PlanFile};
+use crate::download::ResourceKind;
+
+/// 没写 `url` 的库兜底用的 Maven 基址(Mojang 官方库仓)。
+pub const DEFAULT_MAVEN_BASE: &str = "https://libraries.minecraft.net/";
+
+/// 展开一层(已合并)清单里的库列表。
+///
+/// 返回(文件清单,人读告警)。告警不致命:缺下载地址的库会被跳过并告知,
+/// 让用户知道"缺哪个文件"而不是拿到一个静默不完整的实例。
+#[must_use]
+pub fn plan(
+    layout: &GameLayout,
+    version_id: &str,
+    libraries: &[Library],
+    environment: &Environment,
+) -> (Vec, Vec) {
+    let mut files = Vec::new();
+    let mut warnings = Vec::new();
+
+    for library in libraries {
+        if !library.applies(environment) {
+            continue;
+        }
+        if library.coordinate().is_err() {
+            warnings.push(format!("库坐标不合法,已跳过:{}", library.name));
+            continue;
+        }
+        let Ok(relative) = library.relative_path() else {
+            warnings.push(format!("库路径推导失败,已跳过:{}", library.name));
+            continue;
+        };
+        let dest = library_dest(layout, version_id, library, &relative);
+
+        match library.artifact() {
+            Some(artifact) if artifact.has_url() => files.push(PlanFile {
+                role: FileRole::Library,
+                name: library.name.clone(),
+                url: artifact.url.clone().unwrap_or_default(),
+                sha1: artifact.sha1.clone(),
+                size: artifact.size,
+                dest,
+                resource: ResourceKind::Library,
+            }),
+            // natives-only 库(1.6 时代的 `lwjgl-platform` / `jinput-platform`)**没有主构件**,
+            // 只要 `downloads.classifiers`。这里若按坐标硬拼 URL 会拼出一个不存在的 jar
+            // (实测:官方与 BMCLAPI 双双 404)。判据是 `natives` 在场。
+            None if library.natives.is_some() => {}
+            // 魔改/现代 loader 的库常见"只给 name 不给 downloads",按坐标拼仓库地址。
+            _ => files.push(PlanFile {
+                role: FileRole::Library,
+                name: library.name.clone(),
+                url: maven_url(library, &relative),
+                sha1: None,
+                size: None,
+                dest,
+                resource: ResourceKind::Library,
+            }),
+        }
+
+        // natives 分类器构件(存在才下;解压发生在启动期,不归安装管)。
+        let Some(classifier) = library.native_classifier(environment) else {
+            continue;
+        };
+        let Some(artifact) = library.native_artifact(&classifier) else {
+            warnings.push(format!(
+                "{} 的 natives 分类器 {classifier} 没有下载信息,已跳过",
+                library.name
+            ));
+            continue;
+        };
+        let Ok(native_relative) = library.native_relative_path(&classifier) else {
+            warnings.push(format!("{} 的 natives 路径推导失败,已跳过", library.name));
+            continue;
+        };
+        if !artifact.has_url() {
+            warnings.push(format!(
+                "{} 的 natives 构件 {classifier} 没有下载地址,已跳过",
+                library.name
+            ));
+            continue;
+        }
+        files.push(PlanFile {
+            role: FileRole::Native,
+            name: format!("{}:{classifier}", library.name),
+            url: artifact.url.clone().unwrap_or_default(),
+            sha1: artifact.sha1.clone(),
+            size: artifact.size,
+            dest: layout.libraries_dir().join(native_relative),
+            resource: ResourceKind::Library,
+        });
+    }
+
+    (files, warnings)
+}
+
+/// 库文件落点:实例私有(`hint=local`)或恒共享 `libraries/`。
+fn library_dest(
+    layout: &GameLayout,
+    version_id: &str,
+    library: &Library,
+    relative: &Path,
+) -> PathBuf {
+    if library.is_local() {
+        layout.version_libraries_dir(version_id).join(relative)
+    } else {
+        layout.libraries_dir().join(relative)
+    }
+}
+
+/// 按库声明(或默认)的 Maven 基址拼下载地址。
+fn maven_url(library: &Library, relative: &Path) -> String {
+    let base = library
+        .url
+        .as_deref()
+        .map(str::trim)
+        .filter(|base| !base.is_empty())
+        .unwrap_or(DEFAULT_MAVEN_BASE);
+    let path = relative.to_string_lossy().replace('\\', "/");
+    if base.ends_with('/') {
+        format!("{base}{path}")
+    } else {
+        format!("{base}/{path}")
+    }
+}
+
+/// 供上层判错用:库展开不产生硬失败,缺地址一律降级成告警。
+#[cfg(test)]
+mod tests {
+    use super::plan;
+    use crate::game::arguments::Environment;
+    use crate::game::layout::GameLayout;
+    use crate::game::library::Library;
+
+    fn environment() -> Environment {
+        Environment {
+            os: "windows".to_string(),
+            os_version: String::new(),
+            arch: "x86_64".to_string(),
+            ..Environment::host()
+        }
+    }
+
+    fn library_with_artifact(json: &str) -> Library {
+        serde_json::from_str(json).expect("库 JSON 应可解析")
+    }
+
+    #[test]
+    fn plans_artifact_at_maven_path() {
+        let library = library_with_artifact(
+            r#"{"name":"org.ow2.asm:asm:9.6","downloads":{"artifact":{"path":"org/ow2/asm/asm/9.6/asm-9.6.jar","sha1":"aa","size":122,"url":"https://libraries.minecraft.net/org/ow2/asm/asm/9.6/asm-9.6.jar"}}}"#,
+        );
+        let layout = GameLayout::new("/g/.minecraft");
+        let (files, warnings) = plan(&layout, "1.20.1", &[library], &environment());
+        assert!(warnings.is_empty());
+        assert_eq!(files.len(), 1);
+        assert_eq!(files[0].sha1.as_deref(), Some("aa"));
+        assert_eq!(files[0].size, Some(122));
+        assert!(files[0]
+            .dest
+            .ends_with("libraries/org/ow2/asm/asm/9.6/asm-9.6.jar"));
+    }
+
+    #[test]
+    fn natives_only_library_plans_no_artifact() {
+        // 1.6 时代的 lwjgl-platform / jinput-platform:有 natives 表、只有 classifiers,
+        // **没有 downloads.artifact**。按坐标硬拼主构件会拼出不存在的 jar(实测双源 404)。
+        let library = library_with_artifact(
+            r#"{"name":"org.lwjgl.lwjgl:lwjgl-platform:2.9.0",
+                "natives":{"windows":"natives-windows","linux":"natives-linux","osx":"natives-osx"},
+                "downloads":{"classifiers":{"natives-windows":{"path":"org/lwjgl/lwjgl/lwjgl-platform/2.9.0/lwjgl-platform-2.9.0-natives-windows.jar","sha1":"3f11","size":609967,"url":"https://libraries.minecraft.net/natives-windows.jar"}}}}"#,
+        );
+        let layout = GameLayout::new("/g/.minecraft");
+        let (files, warnings) = plan(&layout, "1.6.4", &[library], &environment());
+        assert!(warnings.is_empty(), "不该有告警:{warnings:?}");
+        assert_eq!(files.len(), 1, "只应有 natives 构件:{files:?}");
+        assert_eq!(files[0].role.as_str(), "native");
+        assert_eq!(files[0].sha1.as_deref(), Some("3f11"));
+    }
+
+    #[test]
+    fn plans_both_artifact_and_native_jar() {
+        let library = library_with_artifact(
+            r#"{"name":"org.lwjgl.lwjgl:lwjgl-platform:2.9.4","natives":{"windows":"natives-windows"},
+                "downloads":{"artifact":{"path":"org/lwjgl/lwjgl/lwjgl-platform/2.9.4/lwjgl-platform-2.9.4.jar","sha1":"bb","size":1,"url":"https://libraries.minecraft.net/a.jar"},
+                "classifiers":{"natives-windows":{"path":"org/lwjgl/lwjgl/lwjgl-platform/2.9.4/lwjgl-platform-2.9.4-natives-windows.jar","sha1":"cc","size":2,"url":"https://libraries.minecraft.net/b.jar"}}}}"#,
+        );
+        let layout = GameLayout::new("/g/.minecraft");
+        let (files, _) = plan(&layout, "1.7.10", &[library], &environment());
+        assert_eq!(files.len(), 2);
+        let native = files
+            .iter()
+            .find(|f| f.role.as_str() == "native")
+            .expect("应有 natives 构件");
+        assert_eq!(native.sha1.as_deref(), Some("cc"));
+        assert!(native
+            .dest
+            .ends_with("lwjgl-platform-2.9.4-natives-windows.jar"));
+    }
+
+    #[test]
+    fn derives_url_for_libraries_without_downloads() {
+        let library = library_with_artifact(
+            r#"{"name":"net.minecraftforge:forge:1.20.1-47.2.0:universal","url":"https://maven.minecraftforge.net/"}"#,
+        );
+        let layout = GameLayout::new("/g/.minecraft");
+        let (files, warnings) = plan(&layout, "1.20.1-forge", &[library], &environment());
+        assert!(warnings.is_empty());
+        assert_eq!(files.len(), 1);
+        assert_eq!(
+            files[0].url,
+            "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-universal.jar"
+        );
+        assert!(files[0].sha1.is_none());
+    }
+
+    #[test]
+    fn skips_libraries_filtered_by_rules() {
+        let library = library_with_artifact(
+            r#"{"name":"org.lwjgl:lwjgl:3.3.2","rules":[{"action":"allow","os":{"name":"linux"}}],
+                "downloads":{"artifact":{"url":"https://libraries.minecraft.net/x.jar","sha1":"dd"}}}"#,
+        );
+        let layout = GameLayout::new("/g/.minecraft");
+        let (files, _) = plan(&layout, "1.20.1", &[library], &environment());
+        assert!(files.is_empty());
+    }
+
+    #[test]
+    fn local_hint_libraries_land_in_instance_dir() {
+        let library = library_with_artifact(
+            r#"{"name":"net.minecraftforge:forge:1.20.1-47.2.0","hint":"local",
+                "downloads":{"artifact":{"path":"net/minecraftforge/forge/1.20.1-47.2.0.jar","url":"https://libraries.minecraft.net/y.jar"}}}"#,
+        );
+        let layout = GameLayout::new("/g/.minecraft");
+        let (files, _) = plan(&layout, "inst", &[library], &environment());
+        assert_eq!(files.len(), 1);
+        assert!(files[0]
+            .dest
+            .ends_with("versions/inst/libraries/net/minecraftforge/forge/1.20.1-47.2.0.jar"));
+    }
+}
diff --git a/crates/core/src/game/install/mod.rs b/crates/core/src/game/install/mod.rs
new file mode 100644
index 0000000..903bdae
--- /dev/null
+++ b/crates/core/src/game/install/mod.rs
@@ -0,0 +1,186 @@
+//! 安装层:版本链 → 下载计划 → 分区落盘 → 实例登记(P2 核心闭环「装一个版本并启动它」)。
+//!
+//! 设计输入:`DevDocs/analyse/03-version-download.md` §6(逐类资源拆解)、
+//! `DevDocs/PlanDoc/04-data-and-config.md` §4(D20 实例模型)、§2(`.ULL/` 四分区)、
+//! `DevDocs/PlanDoc/05-download-and-mirrors.md` §2(分层下载策略)、
+//! `DevDocs/PlanDoc/research/04-launcher-configs.md` §5(实例键草案)。
+//!
+//! # 分工(每层只做一件事,谁都不越界)
+//!
+//! ```text
+//! source    网络:版本清单(带 TTL 缓存)→ 版本 JSON 链 → 资源索引      (异步)
+//! plan      纯计算:清单 + 索引 → InstallPlan(文件清单 / 总大小 / 角色)  (同步、可 dry-run)
+//! execute   落盘:逐文件跳过判定 + 下载引擎 + 原子写版本 JSON            (异步)
+//! instance  登记:profiles.json + config/instances/.json(稀疏、幂等)(同步)
+//! ```
+//!
+//! # 三条硬约定
+//!
+//! 1. **恒共享层**(D20):`versions/` `assets/` `libraries/` 一律进游戏根,任何模式都不按实例复制;
+//! 2. **幂等**:重复装同一版本不重复下载(按 sha1 跳过)、不重复建实例(按 `versionId` 复用档案);
+//! 3. **如实告知**:清单缺件(旧版无 `downloads.client` / 无 `assetIndex`)只降级成计划告警,
+//!    不静默假装成功,也不编造 URL。
+//!
+//! # 同步宿主怎么用
+//!
+//! `crates/cli` 不依赖 tokio:整段「规划 + 下载」包在一次
+//! [`crate::download::blocking::block_on`] 里,见 `ull install` 的实现。
+
+pub mod assets;
+pub mod execute;
+pub mod instance;
+pub mod libraries;
+pub mod plan;
+pub mod plan_view;
+pub mod source;
+
+use std::fmt;
+
+pub use assets::{AssetIndex, AssetObject, ASSET_OBJECT_BASE};
+pub use execute::{execute, FileFailure, FileOutcome, FileStatus, InstallReport, ProgressSink};
+pub use instance::{register, InstanceConfig, Registration};
+pub use plan::{FileRole, InstallPlan, PlanFile, PlanInput};
+pub use source::{ManifestFreshness, RemoteSource, MANIFEST_TTL_MS};
+
+use crate::download::{DownloadError, DownloadErrorKind};
+use crate::game::error::GameError;
+
+/// 安装失败的大类(CLI 据此给稳定错误码,脚本可分流)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum InstallErrorKind {
+    /// 版本 id 在官方清单与本地仓库里都不存在。
+    VersionNotFound,
+    /// 网络侧失败(所有候选源都失败 / 超时 / 连接错误)。
+    Network,
+    /// 内容校验不符(镜像给了错内容)。
+    Integrity,
+    /// 本地 IO(写盘失败、目录权限)。
+    Io,
+    /// 元数据解析失败(清单/版本 JSON/资源索引坏了)。
+    Metadata,
+}
+
+impl InstallErrorKind {
+    /// 稳定字符串 id(进 `--json` 的 `error.code`)。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::VersionNotFound => "install_version_not_found",
+            Self::Network => "install_network",
+            Self::Integrity => "install_integrity",
+            Self::Io => "install_io",
+            Self::Metadata => "install_metadata",
+        }
+    }
+}
+
+/// 安装期错误:大类 + 人读说明 + 可选提示。
+#[derive(Debug, Clone)]
+pub struct InstallError {
+    kind: InstallErrorKind,
+    message: String,
+    hint: Option,
+}
+
+impl InstallError {
+    /// 构造。
+    #[must_use]
+    pub fn new(kind: InstallErrorKind, message: impl Into) -> Self {
+        Self {
+            kind,
+            message: message.into(),
+            hint: None,
+        }
+    }
+
+    /// 版本不存在。
+    #[must_use]
+    pub fn version_not_found(id: &str) -> Self {
+        Self::new(
+            InstallErrorKind::VersionNotFound,
+            format!("版本 {id} 不在版本清单里,也不在本地 versions/ 下"),
+        )
+        .with_hint("用 `ull --json install ` 前先确认版本 id(如 26.3 / 1.21.4);快照版本 id 形如 24w14a")
+    }
+
+    /// 补一句提示。
+    #[must_use]
+    pub fn with_hint(mut self, hint: impl Into) -> Self {
+        self.hint = Some(hint.into());
+        self
+    }
+
+    /// 大类。
+    #[must_use]
+    pub const fn kind(&self) -> InstallErrorKind {
+        self.kind
+    }
+
+    /// 人读说明。
+    #[must_use]
+    pub fn message(&self) -> &str {
+        &self.message
+    }
+
+    /// 下一步提示。
+    #[must_use]
+    pub fn hint(&self) -> Option<&str> {
+        self.hint.as_deref()
+    }
+}
+
+impl fmt::Display for InstallError {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        formatter.write_str(&self.message)
+    }
+}
+
+impl std::error::Error for InstallError {}
+
+impl From for InstallError {
+    fn from(error: DownloadError) -> Self {
+        let kind = match error.kind {
+            DownloadErrorKind::Integrity => InstallErrorKind::Integrity,
+            DownloadErrorKind::Io => InstallErrorKind::Io,
+            DownloadErrorKind::InvalidRequest => InstallErrorKind::Metadata,
+            DownloadErrorKind::NotFound
+            | DownloadErrorKind::ServerError
+            | DownloadErrorKind::Timeout
+            | DownloadErrorKind::Network
+            | DownloadErrorKind::RangeUnsupported
+            | DownloadErrorKind::AllSourcesFailed => InstallErrorKind::Network,
+        };
+        let mut message = error.message.clone();
+        if let Some(source) = &error.source {
+            message.push_str(&format!("(源 {source})"));
+        }
+        Self::new(kind, message)
+    }
+}
+
+impl From for InstallError {
+    fn from(error: GameError) -> Self {
+        let kind = match &error {
+            GameError::Io { .. } => InstallErrorKind::Io,
+            GameError::Json { .. } | GameError::InvalidAssetHash { .. } => {
+                InstallErrorKind::Metadata
+            }
+            GameError::MissingVersionId
+            | GameError::MissingParent { .. }
+            | GameError::CircularInheritance { .. }
+            | GameError::InvalidCoordinate { .. }
+            | GameError::CustomIsolationWithoutDir { .. }
+            | GameError::DraftBusy => InstallErrorKind::Metadata,
+        };
+        Self::new(kind, error.to_string())
+    }
+}
+
+impl From for InstallError {
+    fn from(error: crate::platform::error::PlatformError) -> Self {
+        Self::new(InstallErrorKind::Io, error.to_string())
+    }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/core/src/game/install/plan.rs b/crates/core/src/game/install/plan.rs
new file mode 100644
index 0000000..8310353
--- /dev/null
+++ b/crates/core/src/game/install/plan.rs
@@ -0,0 +1,342 @@
+//! 安装计划:把「已解析的版本清单 + 资源索引」翻译成一份可下载文件清单。
+//!
+//! 依据:`DevDocs/analyse/03-version-download.md` §6(安装期资源清单逐类拆解)、
+//! `DevDocs/PlanDoc/05-download-and-mirrors.md`(镜像由下载引擎展开)。
+//!
+//! 本模块是**纯计算**:输入已解析清单([`VersionJson`])与已取到的资源索引,输出
+//! [`InstallPlan`];不发请求、不写文件。网络在 [`super::source`]、落盘在 [`super::execute`]。
+//! 这样 `--dry-run`、测试、以及 GUI 的"安装前确认页"共用同一份计划真相。
+//!
+//! 恒共享层(D20):`versions/`、`assets/`、`libraries/` 一律进游戏根,不按实例复制;
+//! 只有 `hint=local` 的库落实例私有目录(见 [`super::libraries`])。
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+
+use super::super::arguments::Environment;
+use super::super::error::GameError;
+use super::super::layout::GameLayout;
+use super::super::manifest::{Logging, VersionJson};
+use super::assets::{self, AssetIndex};
+use super::libraries;
+use crate::download::{DownloadRequest, ResourceKind};
+
+/// 一个待落盘文件的角色(进度分组、`--dry-run` 汇总、错误定位都用它)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum FileRole {
+    /// 客户端 jar `versions//.jar`。
+    ClientJar,
+    /// 共享库 `libraries/...`。
+    Library,
+    /// natives 分类器构件(也进 `libraries/`,启动期解压)。
+    Native,
+    /// 资源索引 `assets/indexes/.json`。
+    AssetIndex,
+    /// 资源对象 `assets/objects//`。
+    Asset,
+    /// 日志配置 `assets/log_configs/`。
+    LogConfig,
+}
+
+impl FileRole {
+    /// 稳定字符串 id(JSON 输出与日志用,**不要改名**)。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::ClientJar => "client_jar",
+            Self::Library => "library",
+            Self::Native => "native",
+            Self::AssetIndex => "asset_index",
+            Self::Asset => "asset",
+            Self::LogConfig => "log_config",
+        }
+    }
+
+    /// 人读名称(汇总行用)。
+    #[must_use]
+    pub const fn label(self) -> &'static str {
+        match self {
+            Self::ClientJar => "客户端 jar",
+            Self::Library => "依赖库",
+            Self::Native => "natives",
+            Self::AssetIndex => "资源索引",
+            Self::Asset => "资源对象",
+            Self::LogConfig => "日志配置",
+        }
+    }
+}
+
+/// 清单里的一个待下载文件。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PlanFile {
+    /// 角色。
+    pub role: FileRole,
+    /// 人读标识(库坐标 / 资源名 / 索引 id / jar id)。
+    pub name: String,
+    /// 官方逻辑 URL(镜像展开由下载引擎负责)。
+    pub url: String,
+    /// 期望 SHA-1(`None` = 清单没给,只能靠大小/存在性兜底)。
+    pub sha1: Option,
+    /// 期望字节数。
+    pub size: Option,
+    /// 最终落盘路径。
+    pub dest: PathBuf,
+    /// 资源类型(健康度统计与分块准入的第二维度)。
+    pub resource: ResourceKind,
+}
+
+impl PlanFile {
+    /// 转成下载引擎的请求(校验值/大小原样透传)。
+    #[must_use]
+    pub fn request(&self) -> DownloadRequest {
+        let mut request = DownloadRequest::new(self.resource, self.url.clone(), self.dest.clone());
+        request.integrity = self
+            .sha1
+            .clone()
+            .filter(|hex| !hex.is_empty())
+            .map(crate::download::Integrity::sha1);
+        request.expected_size = self.size;
+        request
+    }
+
+    /// `--dry-run` 的单行描述。
+    #[must_use]
+    pub fn to_json(&self) -> Value {
+        json!({
+            "role": self.role.as_str(),
+            "name": self.name,
+            "url": self.url,
+            "sha1": self.sha1,
+            "size": self.size,
+            "dest": self.dest.display().to_string(),
+            "resource": self.resource.as_str(),
+        })
+    }
+}
+
+/// 规划阶段已经取到内容、但因 `--dry-run` / 时序原因还没落盘的元数据文件。
+///
+/// 典型场景:资源索引——不先取到它就无法枚举对象,所以规划期已经下载在内存里;
+/// 执行期直接原子写盘,不再重复走一次网络。
+#[derive(Debug, Clone)]
+pub struct Prefetched {
+    /// 目标路径(必须与计划里的某个 [`PlanFile::dest`] 一致)。
+    pub dest: PathBuf,
+    /// 内容字节。
+    pub bytes: Vec,
+}
+
+/// 一次安装的完整计划。
+#[derive(Debug, Clone)]
+pub struct InstallPlan {
+    /// 目标版本 id(`versions//`)。
+    pub version_id: String,
+    /// 实际提供 jar 的版本 id(继承链上可能是父版本)。
+    pub jar_id: String,
+    /// 游戏根目录(恒共享层的根)。
+    pub game_root: PathBuf,
+    /// 版本清单落盘路径 `versions//.json`。
+    pub version_json_path: PathBuf,
+    /// 待下载文件(按 dest 去重、按角色排序)。
+    pub files: Vec,
+    /// 已解析的扁平清单(落盘时写进 `versions//.json`)。
+    pub version_json: VersionJson,
+    /// 规划期已取到内容的文件(见 [`Prefetched`])。
+    pub prefetched: Vec,
+    /// 人读告警(缺哪类资源一律如实告知,不静默降级)。
+    pub warnings: Vec,
+}
+
+/// [`InstallPlan::build`] 的输入。
+pub struct PlanInput<'a> {
+    /// 游戏目录布局。
+    pub layout: &'a GameLayout,
+    /// 目标版本 id。
+    pub version_id: &'a str,
+    /// 已 resolve 的扁平清单。
+    pub resolved: &'a VersionJson,
+    /// 已取到并解析的资源索引(缺 = 老版本没有 `assetIndex`)。
+    pub asset_index: Option<&'a AssetIndex>,
+    /// 规则求值环境(OS / 架构 / 特性开关)。
+    pub environment: &'a Environment,
+}
+
+impl InstallPlan {
+    /// 组计划。
+    ///
+    /// # Errors
+    ///
+    /// 资源对象的哈希不合法(索引坏了)时返回 [`GameError::InvalidAssetHash`]。
+    pub fn build(input: PlanInput<'_>) -> Result {
+        let layout = input.layout;
+        let version_id = input.version_id;
+        let resolved = input.resolved;
+        let mut files: Vec = Vec::new();
+        let mut warnings: Vec = Vec::new();
+
+        let jar_id = resolved
+            .jar
+            .clone()
+            .unwrap_or_else(|| version_id.to_string());
+
+        // ① 客户端 jar:老版本(pre-1.6)没有 `downloads.client`,如实告知而不是编 URL。
+        match resolved.client_jar() {
+            Some(info) if info.has_url() => files.push(PlanFile {
+                role: FileRole::ClientJar,
+                name: jar_id.clone(),
+                url: info.url.clone().unwrap_or_default(),
+                sha1: info.sha1.clone(),
+                size: info.size,
+                dest: layout.version_jar(&jar_id),
+                resource: ResourceKind::ClientJar,
+            }),
+            _ => warnings.push(format!(
+                "版本 {version_id} 的清单没有 downloads.client:客户端 jar 无法自动获取(pre-1.6 旧清单待补兜底)"
+            )),
+        }
+
+        // ② 依赖库 + natives。
+        let (library_files, library_warnings) = libraries::plan(
+            layout,
+            version_id,
+            resolved.libraries.as_deref().unwrap_or_default(),
+            input.environment,
+        );
+        files.extend(library_files);
+        warnings.extend(library_warnings);
+
+        // ③ 资源索引 + 逐对象。
+        match resolved.asset_index.as_ref() {
+            Some(reference) if reference.url.is_some() => files.push(PlanFile {
+                role: FileRole::AssetIndex,
+                name: reference.id.clone(),
+                url: reference.url.clone().unwrap_or_default(),
+                sha1: reference.sha1.clone(),
+                size: reference.size,
+                dest: layout.asset_index(&reference.id),
+                resource: ResourceKind::AssetIndex,
+            }),
+            Some(reference) => warnings.push(format!(
+                "资源索引 {} 没有下载地址,跳过(对象无法枚举)",
+                reference.id
+            )),
+            None => warnings.push(format!(
+                "版本 {version_id} 的清单没有 assetIndex:资源对象无法枚举(旧版 sha1 反查表待补)"
+            )),
+        }
+        if let Some(index) = input.asset_index {
+            files.extend(assets::plan(layout, index)?);
+        }
+
+        // ④ 日志配置(官方 manifest 带的 appender 配置;有就下,没有就用启动器的内置模板)。
+        if let Some(logging) = resolved.logging.as_ref().and_then(Logging::client) {
+            if logging.file.has_url() {
+                let file_id = logging
+                    .file
+                    .id
+                    .clone()
+                    .unwrap_or_else(|| format!("{version_id}-client"));
+                files.push(PlanFile {
+                    role: FileRole::LogConfig,
+                    name: file_id.clone(),
+                    url: logging.file.url.clone().unwrap_or_default(),
+                    sha1: logging.file.sha1.clone(),
+                    size: logging.file.size,
+                    dest: layout.log_config(&file_id),
+                    resource: ResourceKind::Other,
+                });
+            }
+        }
+
+        let files = dedupe(files);
+
+        Ok(Self {
+            version_id: version_id.to_string(),
+            jar_id,
+            game_root: layout.root().to_path_buf(),
+            version_json_path: layout.version_json(version_id),
+            files,
+            version_json: resolved.clone(),
+            prefetched: Vec::new(),
+            warnings,
+        })
+    }
+
+    /// 挂一份"规划期已取到内容"的文件(内容必须与计划里的对应文件同一落点)。
+    pub fn prefetch(&mut self, dest: PathBuf, bytes: Vec) {
+        self.prefetched.push(Prefetched { dest, bytes });
+    }
+
+    /// 客户端 jar 的待下载项(`None` = 旧版清单没给下载信息)。
+    #[must_use]
+    pub fn client_jar(&self) -> Option<&PlanFile> {
+        self.files
+            .iter()
+            .find(|file| file.role == FileRole::ClientJar)
+    }
+
+    /// 这份计划能否真的装出一个可启动的版本(判据:有客户端 jar)。
+    ///
+    /// 旧版(pre-1.6)清单没有 `downloads.client`,计划里就没有 jar——此时"装成功"
+    /// 等于骗人,调用方应当拒绝执行而不是登记一个起不来的实例。
+    #[must_use]
+    pub fn is_installable(&self) -> bool {
+        self.client_jar().is_some()
+    }
+
+    /// 文件数。
+    #[must_use]
+    pub fn file_count(&self) -> usize {
+        self.files.len()
+    }
+
+    /// 总字节数(已知大小的合计;`unknown_size` 给出"有几个文件大小未知")。
+    #[must_use]
+    pub fn total_bytes(&self) -> (u64, usize) {
+        let mut total = 0_u64;
+        let mut unknown = 0_usize;
+        for file in &self.files {
+            match file.size {
+                Some(size) => total += size,
+                None => unknown += 1,
+            }
+        }
+        (total, unknown)
+    }
+
+    /// 按角色统计(角色 → (文件数, 字节数))。
+    #[must_use]
+    pub fn by_role(&self) -> BTreeMap {
+        let mut out: BTreeMap = BTreeMap::new();
+        for file in &self.files {
+            let entry = out.entry(file.role).or_insert((0, 0));
+            entry.0 += 1;
+            entry.1 += file.size.unwrap_or(0);
+        }
+        out
+    }
+}
+
+/// 按落盘路径去重:同一目标只留一条,优先保留带 sha1 的那条(后出现的更可信时覆盖)。
+fn dedupe(files: Vec) -> Vec {
+    let mut by_dest: BTreeMap = BTreeMap::new();
+    for file in files {
+        match by_dest.get(&file.dest) {
+            Some(existing) if existing.sha1.is_some() || file.sha1.is_none() => {}
+            _ => {
+                by_dest.insert(file.dest.clone(), file);
+            }
+        }
+    }
+    let mut out: Vec = by_dest.into_values().collect();
+    out.sort_by(|left, right| {
+        left.role
+            .cmp(&right.role)
+            .then_with(|| left.dest.cmp(&right.dest))
+    });
+    out
+}
diff --git a/crates/core/src/game/install/plan_view.rs b/crates/core/src/game/install/plan_view.rs
new file mode 100644
index 0000000..119df0e
--- /dev/null
+++ b/crates/core/src/game/install/plan_view.rs
@@ -0,0 +1,72 @@
+//! 计划的 JSON 视图与源选择样本(`--dry-run` 的输出形状)。
+//!
+//! 单独一份是为了让 [`InstallPlan`](super::plan::InstallPlan) 的"计算"与"呈现"分开:
+//! 形状变了只动这里,计划算法不受影响;也让 `plan.rs` 不至于越过 400 行硬约束。
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use serde_json::{json, Value};
+
+use super::plan::{FileRole, InstallPlan, PlanFile};
+use crate::download::DownloadEngine;
+
+impl InstallPlan {
+    /// 计划摘要(`--dry-run` 与日志共用;不含逐文件明细)。
+    #[must_use]
+    pub fn summary_json(&self) -> Value {
+        let (total, unknown) = self.total_bytes();
+        let by_role: BTreeMap = self
+            .by_role()
+            .into_iter()
+            .map(|(role, (count, bytes))| {
+                (
+                    role.as_str().to_string(),
+                    json!({"files": count, "bytes": bytes, "label": role.label()}),
+                )
+            })
+            .collect();
+        json!({
+            "version_id": self.version_id,
+            "jar_id": self.jar_id,
+            "game_root": self.game_root.display().to_string(),
+            "file_count": self.file_count(),
+            "total_bytes": total,
+            "unknown_size_files": unknown,
+            "by_role": by_role,
+            "warnings": self.warnings,
+        })
+    }
+
+    /// 逐文件明细(`--dry-run` 的"列清单"用它;真实安装不输出,避免上千行噪声)。
+    #[must_use]
+    pub fn files_json(&self) -> Vec {
+        self.files.iter().map(PlanFile::to_json).collect()
+    }
+
+    /// 每个角色取第一个文件,[`DownloadEngine::plan`] 给出候选源顺序("源选择")。
+    ///
+    /// 只取每类的**首个**文件做样本:3000 个资源对象的候选源是一样的,
+    /// 逐文件列源选择既没用也没法读。
+    #[must_use]
+    pub fn source_samples(&self, engine: &DownloadEngine, now_ms: u64) -> Vec {
+        let mut seen: BTreeSet = BTreeSet::new();
+        let mut samples = Vec::new();
+        for file in &self.files {
+            if !seen.insert(file.role) {
+                continue;
+            }
+            let candidates: Vec = engine
+                .plan(&file.request(), now_ms)
+                .into_iter()
+                .map(|candidate| json!({"source": candidate.source, "url": candidate.url}))
+                .collect();
+            samples.push(json!({
+                "role": file.role.as_str(),
+                "label": file.role.label(),
+                "sample": file.name,
+                "candidates": candidates,
+            }));
+        }
+        samples
+    }
+}
diff --git a/crates/core/src/game/install/source.rs b/crates/core/src/game/install/source.rs
new file mode 100644
index 0000000..e9e5b4d
--- /dev/null
+++ b/crates/core/src/game/install/source.rs
@@ -0,0 +1,353 @@
+//! 远端元数据:版本清单(带 TTL 缓存与过期回退)、版本 JSON 链、资源索引。
+//!
+//! 依据:`DevDocs/analyse/03-version-download.md` §2.6(清单缓存)、§4.3(继承解析)、
+//! §6.2(版本 JSON)、§6.5(资源索引);`DevDocs/PlanDoc/05-download-and-mirrors.md` §2
+//! (清单类资源"短 TTL + 失败立切源")。
+//!
+//! 三条与 HMCL 有意的差异(都是 analyse/03 §8 点名的上游坑):
+//! 1. **清单与版本 JSON 一律校验 sha1**(v2 端点自带),镜像篡改/截断在下载阶段就暴露;
+//! 2. **清单缓存过期自动刷新**,且刷新失败时**回退到过期缓存**并标注
+//!    [`ManifestFreshness::StaleFallback`]——离线机器上也能装已缓存的清单;
+//! 3. 版本 JSON 链**远端优先、本地兜底**:装已存在的 loader 实例时不会因为"官方清单里没有
+//!    这个魔改 id"就直接失败。
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+use super::super::error::GameError;
+use super::super::inherit;
+use super::super::layout::GameLayout;
+use super::super::manifest::{AssetIndexRef, VersionJson};
+use super::super::unlisted;
+use super::super::version::{RemoteVersion, VersionList, VERSION_MANIFEST_V2_URL};
+use super::assets::AssetIndex;
+use super::InstallError;
+use super::InstallErrorKind;
+use crate::download::{DownloadEngine, DownloadRequest, ResourceKind};
+use crate::platform::atomic::write_atomic;
+
+/// 版本清单缓存有效期(12 小时)。过期后自动重新拉取;拉取失败退回旧缓存。
+pub const MANIFEST_TTL_MS: u64 = 12 * 60 * 60 * 1000;
+
+/// 清单缓存文件名(落在 `.ULL/cache/meta/`)。
+pub const MANIFEST_CACHE_FILE: &str = "version_manifest_v2.json";
+
+/// 继承链最大层数(防御恶意/损坏清单造成的无限展开;`resolve` 另有成环检测)。
+const MAX_CHAIN_DEPTH: usize = 16;
+
+/// 清单内容的来源(可观测性:诊断页/事件流要能说明"这次用的是缓存还是新拉的")。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ManifestFreshness {
+    /// 本次真实拉取。
+    Fetched,
+    /// 命中未过期的本地缓存。
+    Cached,
+    /// 拉取失败,退回过期缓存(离线可用)。
+    StaleFallback,
+}
+
+impl ManifestFreshness {
+    /// 稳定字符串 id。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Fetched => "fetched",
+            Self::Cached => "cached",
+            Self::StaleFallback => "stale_fallback",
+        }
+    }
+}
+
+/// 落盘的缓存形态(带拉取时刻,TTL 判定用)。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct ManifestCache {
+    fetched_at_ms: u64,
+    list: VersionList,
+}
+
+/// 远端元数据来源。
+pub struct RemoteSource<'a> {
+    engine: &'a DownloadEngine,
+    game: &'a GameLayout,
+    meta_cache: &'a Path,
+    manifest_url: String,
+    now_ms: u64,
+}
+
+impl<'a> RemoteSource<'a> {
+    /// 绑定引擎、游戏布局与缓存目录。
+    #[must_use]
+    pub fn new(
+        engine: &'a DownloadEngine,
+        game: &'a GameLayout,
+        meta_cache: &'a Path,
+        now_ms: u64,
+    ) -> Self {
+        Self {
+            engine,
+            game,
+            meta_cache,
+            manifest_url: VERSION_MANIFEST_V2_URL.to_string(),
+            now_ms,
+        }
+    }
+
+    /// 覆盖清单地址(内网专有清单/测试用)。
+    #[must_use]
+    pub fn with_manifest_url(mut self, url: impl Into) -> Self {
+        self.manifest_url = url.into();
+        self
+    }
+
+    /// 取版本清单(带 TTL 缓存;`refresh = true` 强制重新拉取)。
+    ///
+    /// # Errors
+    ///
+    /// 无可用缓存且网络失败、或清单 JSON/校验失败时返回 [`InstallError`]。
+    pub async fn manifest(
+        &self,
+        refresh: bool,
+    ) -> Result<(VersionList, ManifestFreshness), InstallError> {
+        let path = self.meta_cache.join(MANIFEST_CACHE_FILE);
+        let cached = read_cache(&path);
+        if !refresh {
+            if let Some(cache) = &cached {
+                if self.now_ms.saturating_sub(cache.fetched_at_ms) < MANIFEST_TTL_MS {
+                    return Ok((cache.list.clone(), ManifestFreshness::Cached));
+                }
+            }
+        }
+        match self.fetch_manifest(&path).await {
+            Ok(list) => Ok((list, ManifestFreshness::Fetched)),
+            Err(error) => match cached {
+                Some(cache) => Ok((cache.list, ManifestFreshness::StaleFallback)),
+                None => Err(error),
+            },
+        }
+    }
+
+    /// 解析目标版本的扁平清单(远端取版本 JSON,递归拉父层,再走
+    /// [`inherit::resolve`])。
+    ///
+    /// # Errors
+    ///
+    /// 版本不存在([`InstallError::version_not_found`])、网络失败、继承链断裂/成环、
+    /// JSON 非法。
+    pub async fn resolve(&self, version_id: &str) -> Result {
+        let (manifest, _) = self.manifest(false).await?;
+        let root = self.load_one(version_id, &manifest).await?;
+
+        let mut table: BTreeMap = BTreeMap::new();
+        table.insert(version_id.to_string(), root.clone());
+        let mut visited: BTreeSet = BTreeSet::new();
+        visited.insert(version_id.to_string());
+        let mut pending = vec![root.clone()];
+        let mut depth = 0_usize;
+
+        while let Some(layer) = pending.pop() {
+            let Some(parent_id) = layer.inherits_from.clone() else {
+                continue;
+            };
+            depth += 1;
+            if depth > MAX_CHAIN_DEPTH {
+                return Err(InstallError::new(
+                    InstallErrorKind::Metadata,
+                    format!("版本 {version_id} 的继承链超过 {MAX_CHAIN_DEPTH} 层,疑似清单损坏"),
+                ));
+            }
+            // 环不在这里报:交给 resolve 给出完整 id 链(信息更全)。
+            if !visited.insert(parent_id.clone()) {
+                continue;
+            }
+            let parent = self.load_one(&parent_id, &manifest).await?;
+            pending.push(parent.clone());
+            table.insert(parent_id, parent);
+        }
+
+        inherit::resolve(&table, &root)
+            .map_err(|error| InstallError::new(InstallErrorKind::Metadata, error.to_string()))
+    }
+
+    /// 取资源索引,返回(索引模型,原始字节)。
+    ///
+    /// 原始字节一并返回,是因为**规划必须先有索引内容**才能枚举对象;这份字节随后由
+    /// [`super::execute`] 原子落到 `assets/indexes/.json`,不重复下载。
+    ///
+    /// # Errors
+    ///
+    /// 索引没有下载地址、网络失败、sha1 不符或 JSON 非法。
+    pub async fn asset_index(
+        &self,
+        reference: &AssetIndexRef,
+    ) -> Result<(AssetIndex, Vec), InstallError> {
+        let url = reference.url.clone().ok_or_else(|| {
+            InstallError::new(
+                InstallErrorKind::Metadata,
+                format!("资源索引 {} 没有下载地址", reference.id),
+            )
+        })?;
+        let bytes = self
+            .fetch_bytes(ResourceKind::AssetIndex, &url, reference.sha1.as_deref())
+            .await?;
+        let index = AssetIndex::from_json_slice(&bytes)?;
+        Ok((index, bytes))
+    }
+
+    /// 拉清单并落缓存(成功才写,失败不动旧缓存)。
+    async fn fetch_manifest(&self, cache_path: &Path) -> Result {
+        let request = DownloadRequest::new(
+            ResourceKind::VersionManifest,
+            self.manifest_url.clone(),
+            cache_path,
+        );
+        let bytes = self.engine.download_bytes(request).await?;
+        let mut list =
+            VersionList::from_json_str(std::str::from_utf8(&bytes).map_err(|_| {
+                InstallError::new(InstallErrorKind::Metadata, "版本清单不是 UTF-8")
+            })?)
+            .map_err(|error| InstallError::new(InstallErrorKind::Metadata, error.to_string()))?;
+        // 官方清单没有的远古版本/魔改条目由内嵌补充清单兜底(analyse/03 §2.3)。
+        unlisted::merge_into(&mut list);
+        let cache = ManifestCache {
+            fetched_at_ms: self.now_ms,
+            list: list.clone(),
+        };
+        let json = serde_json::to_vec(&cache).map_err(|error| {
+            InstallError::new(
+                InstallErrorKind::Metadata,
+                format!("序列化清单缓存失败:{error}"),
+            )
+        })?;
+        write_atomic(cache_path, &json)?;
+        Ok(list)
+    }
+
+    /// 取一份版本 JSON:远端优先(带 v2 的 sha1 校验),本地 `versions//.json` 兜底。
+    async fn load_one(
+        &self,
+        id: &str,
+        manifest: &VersionList,
+    ) -> Result {
+        let remote = match manifest.find(id) {
+            Some(entry) => Some(self.fetch_version_json(entry).await),
+            None => None,
+        };
+        match remote {
+            Some(Ok(version)) => Ok(version),
+            Some(Err(remote_error)) => {
+                let local = self.read_local(id)?;
+                match local {
+                    Some(version) => Ok(version),
+                    None => Err(remote_error),
+                }
+            }
+            None => match self.read_local(id)? {
+                Some(version) => Ok(version),
+                None => Err(InstallError::version_not_found(id)),
+            },
+        }
+    }
+
+    async fn fetch_version_json(&self, entry: &RemoteVersion) -> Result {
+        let bytes = self
+            .fetch_bytes(
+                ResourceKind::VersionManifest,
+                &entry.url,
+                entry.sha1.as_deref(),
+            )
+            .await?;
+        VersionJson::from_json_slice(&bytes).map_err(InstallError::from)
+    }
+
+    async fn fetch_bytes(
+        &self,
+        kind: ResourceKind,
+        url: &str,
+        sha1: Option<&str>,
+    ) -> Result, InstallError> {
+        let mut request = DownloadRequest::new(kind, url, self.meta_cache.join("download.bin"));
+        if let Some(hex) = sha1.filter(|hex| !hex.is_empty()) {
+            request = request.with_sha1(hex);
+        }
+        Ok(self.engine.download_bytes(request).await?)
+    }
+
+    /// 读本地版本 JSON(`versions//.json`);不存在返回 `Ok(None)`。
+    fn read_local(&self, id: &str) -> Result, InstallError> {
+        let path: PathBuf = self.game.version_json(id);
+        match std::fs::read(&path) {
+            Ok(bytes) => VersionJson::from_json_slice(&bytes)
+                .map(Some)
+                .map_err(|error| InstallError::new(InstallErrorKind::Metadata, error.to_string())),
+            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
+            Err(error) => Err(InstallError::new(
+                InstallErrorKind::Io,
+                format!("读 {} 失败:{error}", path.display()),
+            )),
+        }
+    }
+}
+
+/// 读清单缓存(文件缺失/损坏都当没有,不报错——缓存坏了就重新拉)。
+fn read_cache(path: &Path) -> Option {
+    let bytes = std::fs::read(path).ok()?;
+    serde_json::from_slice(&bytes).ok()
+}
+
+/// 当前毫秒时间戳(缓存 TTL 判定用;与下载引擎同一个时钟来源)。
+pub use crate::download::cache::now_ms;
+
+/// 把 [`GameError`] 转成"缺元数据"错误(供上层统一处理)。
+#[must_use]
+pub fn metadata_error(error: GameError) -> InstallError {
+    InstallError::new(InstallErrorKind::Metadata, error.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{metadata_error, now_ms, read_cache, ManifestCache, MANIFEST_TTL_MS};
+    use crate::game::error::GameError;
+    use crate::game::version::VersionList;
+
+    #[test]
+    fn ttl_constant_is_half_a_day() {
+        assert_eq!(MANIFEST_TTL_MS, 43_200_000);
+    }
+
+    #[test]
+    fn now_ms_is_a_plausible_wall_clock() {
+        // 2020-01-01 之后、2100 年之前:只用来挡住"单位错成秒"这类退化。
+        let now = now_ms();
+        assert!(now > 1_577_836_800_000, "now_ms 太小:{now}");
+        assert!(now < 4_102_444_800_000, "now_ms 太大:{now}");
+    }
+
+    #[test]
+    fn cache_roundtrip_and_corruption_tolerance() {
+        let dir = std::env::temp_dir().join(format!("ull-manifest-cache-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).expect("建临时目录");
+        let path = dir.join("version_manifest_v2.json");
+
+        let cache = ManifestCache {
+            fetched_at_ms: 123,
+            list: VersionList::default(),
+        };
+        std::fs::write(&path, serde_json::to_vec(&cache).expect("序列化")).expect("写");
+        let read = read_cache(&path).expect("应读回");
+        assert_eq!(read.fetched_at_ms, 123);
+
+        std::fs::write(&path, b"not json").expect("写坏内容");
+        assert!(read_cache(&path).is_none());
+
+        assert!(read_cache(&dir.join("missing.json")).is_none());
+        std::fs::remove_dir_all(&dir).ok();
+    }
+
+    #[test]
+    fn game_error_becomes_metadata_error() {
+        let error = metadata_error(GameError::MissingVersionId);
+        assert_eq!(error.kind().as_str(), "install_metadata");
+    }
+}
diff --git a/crates/core/src/game/install/tests/execute.rs b/crates/core/src/game/install/tests/execute.rs
new file mode 100644
index 0000000..80d5271
--- /dev/null
+++ b/crates/core/src/game/install/tests/execute.rs
@@ -0,0 +1,270 @@
+//! 落盘执行:跳过判定(三级)、幂等重装、预取元数据落盘、失败分类聚合。
+//!
+//! 全部用例都**已把文件放好**或**只做纯函数判定**,因此不发网络请求。
+
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
+use std::sync::Arc;
+
+use crate::download::verify;
+use crate::download::ResourceKind;
+use crate::download::{
+    ContentCache, DownloadEngine, DownloadErrorKind, EngineConfig, HashAlgo, HealthRegistry,
+    MirrorTable, RegionProfile,
+};
+use crate::game::arguments::Environment;
+use crate::game::install::assets::{AssetIndex, AssetObject};
+use crate::game::install::execute::{current_bytes, execute, FileOutcome, FileStatus};
+use crate::game::install::plan::{FileRole, InstallPlan, PlanFile, PlanInput};
+use crate::game::layout::GameLayout;
+use crate::game::manifest::VersionJson;
+
+fn temp_root(tag: &str) -> PathBuf {
+    static COUNTER: AtomicU64 = AtomicU64::new(0);
+    let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
+    std::env::temp_dir().join(format!(
+        "ull-install-exec-{}-{tag}-{unique}",
+        std::process::id()
+    ))
+}
+
+fn engine(root: &Path) -> DownloadEngine {
+    DownloadEngine::new(
+        EngineConfig::default(),
+        Arc::new(MirrorTable::builtin()),
+        Arc::new(RegionProfile::direct(0)),
+        Arc::new(HealthRegistry::in_memory()),
+        Arc::new(ContentCache::open(root.join("cache"))),
+    )
+}
+
+fn sha1_of(bytes: &[u8]) -> String {
+    verify::digest_bytes(HashAlgo::Sha1, bytes)
+}
+
+fn write_file(path: &Path, bytes: &[u8]) {
+    std::fs::create_dir_all(path.parent().expect("有父目录")).expect("建目录");
+    std::fs::write(path, bytes).expect("写文件");
+}
+
+#[test]
+fn current_bytes_matches_by_sha1_then_size_then_existence() {
+    let root = temp_root("current");
+    let payload = b"client jar payload";
+    let dest: PathBuf = root.join("versions/1.20.1/1.20.1.jar");
+    write_file(&dest, payload);
+
+    let with_sha1 = PlanFile {
+        role: FileRole::ClientJar,
+        name: "1.20.1".to_string(),
+        url: "https://example.invalid/x.jar".to_string(),
+        sha1: Some(sha1_of(payload)),
+        size: Some(payload.len() as u64),
+        dest: dest.clone(),
+        resource: ResourceKind::ClientJar,
+    };
+    assert_eq!(current_bytes(&with_sha1), Some(payload.len() as u64));
+
+    let wrong_sha1 = PlanFile {
+        sha1: Some("0".repeat(40)),
+        ..with_sha1.clone()
+    };
+    assert_eq!(current_bytes(&wrong_sha1), None, "sha1 不符必须重下");
+
+    let size_only = PlanFile {
+        sha1: None,
+        size: Some(payload.len() as u64),
+        ..with_sha1.clone()
+    };
+    assert_eq!(current_bytes(&size_only), Some(payload.len() as u64));
+
+    let size_mismatch = PlanFile {
+        sha1: None,
+        size: Some(1),
+        ..with_sha1.clone()
+    };
+    assert_eq!(current_bytes(&size_mismatch), None);
+
+    let bare = PlanFile {
+        sha1: None,
+        size: None,
+        ..with_sha1.clone()
+    };
+    assert_eq!(current_bytes(&bare), Some(payload.len() as u64), "存在即信");
+
+    let missing = PlanFile {
+        dest: root.join("nope.jar"),
+        ..with_sha1
+    };
+    assert_eq!(current_bytes(&missing), None);
+    std::fs::remove_dir_all(&root).ok();
+}
+
+/// 一份"客户端 jar 已存在"的清单 + 计划,用于幂等与跳过用例。
+fn plan_with_existing_jar(root: &Path) -> (InstallPlan, Vec) {
+    let payload = b"already installed client jar".to_vec();
+    let layout = GameLayout::new(root);
+    let sha1 = sha1_of(&payload);
+    write_file(&layout.version_jar("1.20.1"), &payload);
+
+    let resolved = VersionJson::from_json_str(&format!(
+        r#"{{"id": "1.20.1", "mainClass": "net.minecraft.client.main.Main",
+            "downloads": {{"client": {{"sha1": "{sha1}", "size": {}, "url": "https://piston-data.mojang.com/v1/objects/{sha1}/client.jar"}}}}}}"#,
+        payload.len()
+    ))
+    .expect("清单应可解析");
+
+    let plan = InstallPlan::build(PlanInput {
+        layout: &layout,
+        version_id: "1.20.1",
+        resolved: &resolved,
+        asset_index: None,
+        environment: &Environment::host(),
+    })
+    .expect("组计划应成功");
+    (plan, payload)
+}
+
+#[tokio::test]
+async fn skips_existing_files_and_writes_version_json() {
+    let root = temp_root("skip");
+    let (plan, payload) = plan_with_existing_jar(&root);
+    let engine = engine(&root);
+    let calls = Arc::new(AtomicUsize::new(0));
+    let sink_calls = Arc::clone(&calls);
+
+    let report = execute(
+        &engine,
+        &plan,
+        4,
+        Some(Arc::new(move |_outcome: &FileOutcome| {
+            sink_calls.fetch_add(1, Ordering::Relaxed);
+        })),
+    )
+    .await
+    .expect("执行应成功");
+
+    assert!(report.is_complete());
+    assert_eq!(report.downloaded(), 0, "已存在且校验通过的文件不该重下");
+    assert_eq!(report.skipped(), 1);
+    assert_eq!(report.bytes(), 0);
+    assert_eq!(calls.load(Ordering::Relaxed), 1, "每个文件回调一次");
+
+    // 版本清单必须落盘且能被重新解析。
+    let version_json = report.version_json.clone().expect("全量成功应写版本清单");
+    let text = std::fs::read_to_string(&version_json).expect("版本清单应已写盘");
+    let written = VersionJson::from_json_str(&text).expect("写出的清单应可解析");
+    assert_eq!(written.id.as_deref(), Some("1.20.1"));
+    assert_eq!(
+        written.main_class.as_deref(),
+        Some("net.minecraft.client.main.Main")
+    );
+
+    // 盘上内容没被改动(跳过 = 不动文件)。
+    assert_eq!(
+        std::fs::read(GameLayout::new(&root).version_jar("1.20.1")).expect("读 jar"),
+        payload
+    );
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[tokio::test]
+async fn reinstalling_twice_is_idempotent() {
+    let root = temp_root("idempotent");
+    let (plan, _) = plan_with_existing_jar(&root);
+    let engine = engine(&root);
+    let first = execute(&engine, &plan, 2, None).await.expect("第一次");
+    let second = execute(&engine, &plan, 2, None).await.expect("第二次");
+
+    assert!(first.is_complete() && second.is_complete());
+    assert_eq!(second.downloaded(), 0);
+    assert_eq!(second.skipped(), second.files.len());
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[tokio::test]
+async fn prefetched_metadata_is_written_without_network() {
+    let root = temp_root("prefetch");
+    let layout = GameLayout::new(&root);
+    let hash = "0f9d6b1c0d5f4e3b2a1908070605040302010f0e";
+    // 规划期"已经下载到内存"的资源索引字节(sha1 必须与清单声明一致,否则跳过判定不成立)。
+    let index_bytes = br#"{"objects":{}}"#.to_vec();
+    let index_sha1 = sha1_of(&index_bytes);
+    let resolved = VersionJson::from_json_str(&format!(
+        r#"{{"id": "1.20.1", "assetIndex": {{"id": "17", "sha1": "{index_sha1}", "size": {}, "url": "https://piston-meta.mojang.com/v1/packages/{index_sha1}/17.json"}}}}"#,
+        index_bytes.len()
+    ))
+    .expect("清单应可解析");
+    let mut index = AssetIndex::default();
+    index.objects.insert(
+        "minecraft/lang/zh_cn.json".to_string(),
+        AssetObject {
+            hash: hash.to_string(),
+            size: 3,
+        },
+    );
+
+    let mut plan = InstallPlan::build(PlanInput {
+        layout: &layout,
+        version_id: "1.20.1",
+        resolved: &resolved,
+        asset_index: Some(&index),
+        environment: &Environment::host(),
+    })
+    .expect("组计划应成功");
+
+    let index_dest = layout.asset_index("17");
+    plan.prefetch(index_dest.clone(), index_bytes.clone());
+
+    // 只跑索引这一个文件:把资源对象从计划里摘掉,避免真发网络请求。
+    plan.files.retain(|file| file.role != FileRole::Asset);
+    let engine = engine(&root);
+    let report = execute(&engine, &plan, 2, None).await.expect("执行应成功");
+
+    assert!(report.is_complete(), "不该有失败:{:?}", report.failures(3));
+    assert_eq!(report.downloaded(), 1);
+    assert_eq!(
+        std::fs::read(&index_dest).expect("索引应已写盘"),
+        index_bytes
+    );
+    let outcome = report
+        .files
+        .iter()
+        .find(|file| file.dest == index_dest)
+        .expect("应有索引结果");
+    assert_eq!(outcome.source.as_deref(), Some("metadata"));
+
+    // 重跑:索引已在盘上且 sha1 一致 → 跳过,不重写、不算"下载"。
+    let again = execute(&engine, &plan, 2, None).await.expect("第二次");
+    assert_eq!(again.downloaded(), 0, "重装不该再写索引");
+    assert_eq!(again.skipped(), 1);
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn failure_kind_prefers_integrity() {
+    let failed = |kind: DownloadErrorKind| FileOutcome {
+        role: FileRole::Asset,
+        name: "x".to_string(),
+        dest: PathBuf::from("/x"),
+        status: FileStatus::Failed,
+        bytes: 0,
+        source: None,
+        failure: Some(crate::game::install::FileFailure {
+            kind,
+            message: "boom".to_string(),
+        }),
+    };
+    let report = crate::game::install::InstallReport {
+        files: vec![
+            failed(DownloadErrorKind::AllSourcesFailed),
+            failed(DownloadErrorKind::Integrity),
+        ],
+        version_json: None,
+        warnings: Vec::new(),
+    };
+    assert_eq!(report.failed(), 2);
+    assert!(!report.is_complete());
+    assert_eq!(report.failure_kind(), Some(DownloadErrorKind::Integrity));
+    assert_eq!(report.failures(1).len(), 1);
+}
diff --git a/crates/core/src/game/install/tests/instance.rs b/crates/core/src/game/install/tests/instance.rs
new file mode 100644
index 0000000..797859c
--- /dev/null
+++ b/crates/core/src/game/install/tests/instance.rs
@@ -0,0 +1,122 @@
+//! 实例登记:幂等(同版本同名复用)、`isolation: inherit` 不落盘、两份文件各就各位。
+
+use std::path::PathBuf;
+
+use crate::game::install::instance::{
+    read_instance_config, read_profiles, register, InstanceConfig, Isolation,
+};
+use crate::platform::layout::LauncherLayout;
+
+fn temp_layout(tag: &str) -> (LauncherLayout, PathBuf) {
+    let unique = format!(
+        "{}-{}-{tag}",
+        std::process::id(),
+        std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .map(|d| d.as_nanos())
+            .unwrap_or(0)
+    );
+    let root = std::env::temp_dir().join(format!("ull-install-instance-{unique}"));
+    let layout = LauncherLayout::new(&root);
+    layout.ensure().expect("建四分区");
+    (layout, root)
+}
+
+#[test]
+fn registers_profile_and_sparse_instance_config() {
+    let (layout, root) = temp_layout("new");
+    let registration = register(
+        &layout,
+        "1.20.1",
+        "1.20.1",
+        Some("2026-10-30T00:00:00.000Z"),
+    )
+    .expect("登记应成功");
+    assert!(registration.created);
+    assert!(
+        registration.profile.id.is_uuid_shaped(),
+        "实例 id 必须是稳定 UUID 形状"
+    );
+    assert!(registration.config_path.is_file());
+
+    let profiles = read_profiles(&layout).expect("读实例清单");
+    assert_eq!(profiles.len(), 1);
+    assert_eq!(profiles.profiles[0].version_id, "1.20.1");
+    assert_eq!(
+        profiles.profiles[0].created_at.as_deref(),
+        Some("2026-10-30T00:00:00.000Z")
+    );
+
+    // 每实例设置:id / versionId / schemaVersion 在,isolation 缺省不落盘。
+    let text = std::fs::read_to_string(®istration.config_path).expect("读设置文件");
+    assert!(
+        text.contains("\"versionId\": \"1.20.1\""),
+        "键名用 camelCase:{text}"
+    );
+    assert!(
+        !text.contains("isolation"),
+        "inherit 是默认值且不落盘:{text}"
+    );
+    assert!(!text.contains("gameDir"), "非 custom 不写 gameDir:{text}");
+
+    let config = read_instance_config(&layout, registration.profile.id.as_str())
+        .expect("读设置")
+        .expect("设置文件应存在");
+    assert!(config.inherits_isolation());
+    assert_eq!(config.id, registration.profile.id.as_str());
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn reinstall_same_version_reuses_instance() {
+    let (layout, root) = temp_layout("idempotent");
+    let first = register(&layout, "1.20.1", "1.20.1", None).expect("第一次登记");
+    let second = register(&layout, "1.20.1", "1.20.1", None).expect("第二次登记");
+
+    assert!(!second.created, "同版本同名应复用");
+    assert_eq!(second.profile.id.as_str(), first.profile.id.as_str());
+    assert_eq!(read_profiles(&layout).expect("读清单").len(), 1);
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn distinct_names_create_distinct_instances_of_same_version() {
+    let (layout, root) = temp_layout("names");
+    let a = register(&layout, "生存", "1.20.1", None).expect("A");
+    let b = register(&layout, "创造", "1.20.1", None).expect("B");
+
+    assert!(a.created && b.created);
+    assert_ne!(a.profile.id.as_str(), b.profile.id.as_str());
+    assert_eq!(read_profiles(&layout).expect("读清单").len(), 2);
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn instance_config_roundtrip_keeps_explicit_isolation() {
+    let mut config = InstanceConfig::new("11111111-2222-4333-8444-555555555555", "1.20.1");
+    config.isolation = Some(Isolation::Isolated);
+    let text = config.to_json_string().expect("序列化");
+    assert!(text.contains("\"isolation\": \"isolated\""));
+    let back = InstanceConfig::from_json_str(&text).expect("反序列化");
+    assert_eq!(back.isolation, Some(Isolation::Isolated));
+    assert!(!back.inherits_isolation());
+    assert_eq!(Isolation::parse("custom"), Some(Isolation::Custom));
+    assert_eq!(Isolation::parse("inherit"), None, "inherit 不是显式值");
+}
+
+#[test]
+fn registers_legacy_profile_without_config_file() {
+    let (layout, root) = temp_layout("legacy");
+    // 早期 CLI 只写 profiles.json(没有 instances/.json)。
+    let profile = crate::game::profiles::InstanceProfile::new("老实例", "1.20.1");
+    let id = profile.id.as_str().to_string();
+    let mut profiles = crate::game::profiles::ProfileList::default();
+    profiles.upsert(profile);
+    super::super::instance::write_profiles(&layout, &profiles).expect("写清单");
+
+    let registration = register(&layout, "老实例", "1.20.1", None).expect("补登记");
+    assert!(!registration.created, "已有档案应复用");
+    assert_eq!(registration.profile.id.as_str(), id);
+    assert!(registration.config_path.is_file(), "缺失的设置文件应补齐");
+    std::fs::remove_dir_all(&root).ok();
+}
diff --git a/crates/core/src/game/install/tests/mod.rs b/crates/core/src/game/install/tests/mod.rs
new file mode 100644
index 0000000..21cecbe
--- /dev/null
+++ b/crates/core/src/game/install/tests/mod.rs
@@ -0,0 +1,8 @@
+//! `game::install` 的单元测试:计划正确性 / 跳过与幂等 / 实例登记。
+//!
+//! 刻意**不打真实网络**:计划是纯计算,跳过判定只看盘,实例登记只看文件系统。
+//! 需要"引擎"的用例只要求它"不联网也能跑"(全部文件已就位 → 全跳过)。
+
+mod execute;
+mod instance;
+mod plan;
diff --git a/crates/core/src/game/install/tests/plan.rs b/crates/core/src/game/install/tests/plan.rs
new file mode 100644
index 0000000..dddf582
--- /dev/null
+++ b/crates/core/src/game/install/tests/plan.rs
@@ -0,0 +1,285 @@
+//! 计划正确性:多级继承折叠后的文件清单、URL/sha1/落点逐项、JSON 形状。
+
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use crate::game::arguments::Environment;
+use crate::game::inherit::resolve;
+use crate::game::install::assets::{AssetIndex, AssetObject};
+use crate::game::install::plan::{FileRole, InstallPlan, PlanInput};
+use crate::game::layout::GameLayout;
+use crate::game::manifest::VersionJson;
+
+fn temp_root(tag: &str) -> std::path::PathBuf {
+    static COUNTER: AtomicU64 = AtomicU64::new(0);
+    let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
+    std::env::temp_dir().join(format!(
+        "ull-install-plan-{}-{tag}-{unique}",
+        std::process::id()
+    ))
+}
+
+/// 三层继承:base(客户端 jar + 资源索引 + 库)← mid(库)← top(库)。
+fn resolved_three_levels() -> VersionJson {
+    let base = VersionJson::from_json_str(
+        r#"{
+            "id": "base",
+            "mainClass": "net.minecraft.client.main.Main",
+            "downloads": {"client": {"sha1": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 100, "url": "https://piston-data.mojang.com/v1/objects/aa/client.jar"}},
+            "assetIndex": {"id": "17", "sha1": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "size": 200, "totalSize": 300, "url": "https://piston-meta.mojang.com/v1/packages/bb/17.json"},
+            "assets": "17",
+            "libraries": [
+                {"name": "org.ow2.asm:asm:9.6", "downloads": {"artifact": {"path": "org/ow2/asm/asm/9.6/asm-9.6.jar", "sha1": "c1", "size": 11, "url": "https://libraries.minecraft.net/org/ow2/asm/asm/9.6/asm-9.6.jar"}}},
+                {"name": "org.lwjgl:lwjgl:3.3.2", "rules": [{"action": "allow", "os": {"name": "linux"}}], "downloads": {"artifact": {"url": "https://libraries.minecraft.net/lwjgl-linux-only.jar", "sha1": "c2", "size": 5}}}
+            ]
+        }"#,
+    )
+    .expect("base 清单应可解析");
+    let mid = VersionJson::from_json_str(
+        r#"{"id": "mid", "inheritsFrom": "base", "libraries": [
+            {"name": "net.minecraftforge:forge:1.20.1-47.2.0", "downloads": {"artifact": {"path": "net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0.jar", "sha1": "c3", "size": 22, "url": "https://maven.minecraftforge.net/forge.jar"}}}
+        ]}"#,
+    )
+    .expect("mid 清单应可解析");
+    let top = VersionJson::from_json_str(
+        r#"{"id": "top", "inheritsFrom": "mid", "libraries": [
+            {"name": "top.patch:core:1.0", "downloads": {"artifact": {"path": "top/patch/core/1.0/core-1.0.jar", "sha1": "c4", "size": 33, "url": "https://example.invalid/core-1.0.jar"}}}
+        ], "logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}", "file": {"id": "client-1.12.xml", "sha1": "dd", "size": 44, "url": "https://piston-data.mojang.com/v1/objects/dd/client-1.12.xml"}}}}"#,
+    )
+    .expect("top 清单应可解析");
+
+    let mut table = std::collections::BTreeMap::new();
+    table.insert("base".to_string(), base);
+    table.insert("mid".to_string(), mid);
+    table.insert("top".to_string(), top.clone());
+    resolve(&table, &top).expect("三层继承应解析成功")
+}
+
+fn asset_index() -> AssetIndex {
+    let mut index = AssetIndex::default();
+    index.objects.insert(
+        "minecraft/lang/zh_cn.json".to_string(),
+        AssetObject {
+            hash: "0f9d6b1c0d5f4e3b2a1908070605040302010f0e".to_string(),
+            size: 55,
+        },
+    );
+    index
+}
+
+fn build(layout: &GameLayout, resolved: &VersionJson, index: Option<&AssetIndex>) -> InstallPlan {
+    InstallPlan::build(PlanInput {
+        layout,
+        version_id: "top",
+        resolved,
+        asset_index: index,
+        environment: &Environment::host(),
+    })
+    .expect("组计划应成功")
+}
+
+#[test]
+fn folds_inheritance_chain_into_one_plan() {
+    let root = temp_root("chain");
+    let layout = GameLayout::new(&root);
+    let resolved = resolved_three_levels();
+    // 三层库都保留(父在前、子在后,去重后按角色/路径排序)。
+    assert_eq!(resolved.libraries.as_ref().map(Vec::len), Some(4));
+
+    let index = asset_index();
+    let plan = build(&layout, &resolved, Some(&index));
+
+    // 客户端 jar 1 + 库 4(linux 规则那条在 Windows 上被过滤)+ 索引 1 + 资源 1 + 日志配置 1。
+    let host_linux = Environment::host().os == "linux";
+    let expected_libraries = if host_linux { 4 } else { 3 };
+    assert_eq!(
+        plan.by_role()
+            .get(&FileRole::ClientJar)
+            .map(|entry| entry.0),
+        Some(1)
+    );
+    assert_eq!(
+        plan.by_role().get(&FileRole::Library).map(|entry| entry.0),
+        Some(expected_libraries)
+    );
+    assert_eq!(
+        plan.by_role()
+            .get(&FileRole::AssetIndex)
+            .map(|entry| entry.0),
+        Some(1)
+    );
+    assert_eq!(
+        plan.by_role().get(&FileRole::Asset).map(|entry| entry.0),
+        Some(1)
+    );
+    assert_eq!(
+        plan.by_role()
+            .get(&FileRole::LogConfig)
+            .map(|entry| entry.0),
+        Some(1)
+    );
+    assert!(plan.warnings.is_empty(), "不应有告警:{:?}", plan.warnings);
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn client_jar_lands_in_version_dir_with_manifest_hash() {
+    let root = temp_root("jar");
+    let layout = GameLayout::new(&root);
+    let resolved = resolved_three_levels();
+    let plan = build(&layout, &resolved, None);
+
+    let jar = plan
+        .files
+        .iter()
+        .find(|file| file.role == FileRole::ClientJar)
+        .expect("应有客户端 jar");
+    assert_eq!(plan.jar_id, "top");
+    assert!(jar.dest.ends_with("versions/top/top.jar"));
+    assert_eq!(
+        jar.sha1.as_deref(),
+        Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
+    );
+    assert_eq!(jar.size, Some(100));
+    assert_eq!(
+        jar.url,
+        "https://piston-data.mojang.com/v1/objects/aa/client.jar"
+    );
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn asset_object_paths_and_hashes_are_exact() {
+    let root = temp_root("assets");
+    let layout = GameLayout::new(&root);
+    let resolved = resolved_three_levels();
+    let index = asset_index();
+    let plan = build(&layout, &resolved, Some(&index));
+
+    let object = plan
+        .files
+        .iter()
+        .find(|file| file.role == FileRole::Asset)
+        .expect("应有资源对象");
+    assert_eq!(
+        object.url,
+        "https://resources.download.minecraft.net/0f/0f9d6b1c0d5f4e3b2a1908070605040302010f0e"
+    );
+    assert_eq!(
+        object.sha1.as_deref(),
+        Some("0f9d6b1c0d5f4e3b2a1908070605040302010f0e")
+    );
+    assert_eq!(object.size, Some(55));
+    assert!(object
+        .dest
+        .ends_with("assets/objects/0f/0f9d6b1c0d5f4e3b2a1908070605040302010f0e"));
+
+    let index_file = plan
+        .files
+        .iter()
+        .find(|file| file.role == FileRole::AssetIndex)
+        .expect("应有资源索引");
+    assert!(index_file.dest.ends_with("assets/indexes/17.json"));
+    assert_eq!(
+        index_file.sha1.as_deref(),
+        Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
+    );
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn shares_libraries_across_versions_and_sums_totals() {
+    let root = temp_root("totals");
+    let layout = GameLayout::new(&root);
+    let resolved = resolved_three_levels();
+    let index = asset_index();
+    let plan = build(&layout, &resolved, Some(&index));
+
+    let (total, unknown) = plan.total_bytes();
+    // jar 100 + 索引 200 + 资源 55 + 日志 44 + 各库大小(host 上生效的那几条)。
+    assert_eq!(unknown, 0);
+    assert!(total >= 399, "总大小应至少含 jar+索引+资源+日志:{total}");
+
+    // 库落恒共享层(不是实例私有)。
+    for file in plan
+        .files
+        .iter()
+        .filter(|file| file.role == FileRole::Library)
+    {
+        assert!(file.dest.starts_with(root.join("libraries")));
+    }
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn warns_instead_of_inventing_urls_for_legacy_manifests() {
+    let root = temp_root("legacy");
+    let layout = GameLayout::new(&root);
+    let legacy = VersionJson::from_json_str(r#"{"id": "c0.30_01c", "assets": "legacy"}"#)
+        .expect("旧清单应可解析");
+    let plan = build(&layout, &legacy, None);
+
+    assert!(plan.files.is_empty(), "旧清单没有可下文件");
+    assert_eq!(
+        plan.warnings.len(),
+        2,
+        "应分别告警缺 jar 与缺索引:{:?}",
+        plan.warnings
+    );
+    assert!(plan.warnings.iter().any(|w| w.contains("downloads.client")));
+    assert!(plan.warnings.iter().any(|w| w.contains("assetIndex")));
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn json_shape_is_stable_for_dry_run_consumers() {
+    let root = temp_root("json");
+    let layout = GameLayout::new(&root);
+    let resolved = resolved_three_levels();
+    let index = asset_index();
+    let plan = build(&layout, &resolved, Some(&index));
+
+    let summary = plan.summary_json();
+    assert_eq!(summary["version_id"], "top");
+    assert_eq!(summary["jar_id"], "top");
+    assert_eq!(summary["file_count"], plan.file_count());
+    assert!(summary["total_bytes"].as_u64().unwrap_or(0) > 0);
+    assert!(summary["by_role"]["client_jar"]["files"].as_u64() == Some(1));
+    assert!(summary["warnings"].is_array());
+
+    let files = plan.files_json();
+    assert_eq!(files.len(), plan.file_count());
+    for row in &files {
+        assert!(row["role"].as_str().is_some());
+        assert!(row["url"].as_str().is_some());
+        assert!(row["dest"].as_str().is_some());
+        assert!(row["resource"].as_str().is_some());
+    }
+    std::fs::remove_dir_all(&root).ok();
+}
+
+#[test]
+fn deduplicates_same_destination_and_prefers_hashed_copy() {
+    let root = temp_root("dedupe");
+    let layout = GameLayout::new(&root);
+    let resolved = VersionJson::from_json_str(
+        r#"{"id": "1.20.1", "libraries": [
+            {"name": "a.b:c:1", "downloads": {"artifact": {"path": "a/b/c/1/c-1.jar", "url": "https://libraries.minecraft.net/c-1.jar"}}},
+            {"name": "a.b:c:1", "downloads": {"artifact": {"path": "a/b/c/1/c-1.jar", "sha1": "ff", "size": 9, "url": "https://libraries.minecraft.net/c-1.jar"}}}
+        ]}"#,
+    )
+    .expect("清单应可解析");
+    let plan = build(&layout, &resolved, None);
+
+    let libraries: Vec<_> = plan
+        .files
+        .iter()
+        .filter(|file| file.role == FileRole::Library)
+        .collect();
+    assert_eq!(libraries.len(), 1, "同一落点只应留一条");
+    assert_eq!(
+        libraries[0].sha1.as_deref(),
+        Some("ff"),
+        "应保留带 sha1 的那条"
+    );
+    std::fs::remove_dir_all(&root).ok();
+}
diff --git a/crates/core/src/game/isolation.rs b/crates/core/src/game/isolation.rs
new file mode 100644
index 0000000..e575962
--- /dev/null
+++ b/crates/core/src/game/isolation.rs
@@ -0,0 +1,345 @@
+//! 实例隔离档位的**派生**与**切换检测**(D20 定案)。
+//!
+//! 依据:`DevDocs/PlanDoc/research/04-launcher-configs.md` §5.1-§5.2(键草案 + 切换检测)、
+//! `DevDocs/analyse/07-settings-packaging-update.md` §2.4(HMCL 用 `runningDirectory` 覆盖表达隔离)、
+//! `DevDocs/PlanDoc/04-data-and-config.md` §4(实例模型)。
+//!
+//! # D20 的语义(用户定案,不可改)
+//!
+//! - **默认隔离**:全局 `instances.mode` 默认 `isolated`,用户可手动切 `shared`;
+//! - **恒共享层不可配**:`versions/` `assets/` `libraries/` 永远在游戏根,隔离只改**运行目录**
+//!   (= `--gameDir` 的取值 + 进程 CWD),见 [`SHARED_LAYERS`];
+//! - **切换不迁移 + 双侧内容提示**:切档只改路径语义,不动任何数据;两侧都有内容时提示
+//!   ([`ModeChange::take_warning`]),避免「存档消失」错觉。
+//!
+//! # 覆盖链(三层,左优先)
+//!
+//! ```text
+//! 临时启动参数(--game-dir 之类)  >  实例 isolation  >  全局 instances.mode
+//! ```
+//!
+//! `isolation` 的 `inherit` 不是第四个档位,而是「键不存在」→ 落到全局([`IsolationMode`]);
+//! 这样改全局对存量实例立即生效,不需要迁移(research/04 §5.3 的 PCL2 反面教材)。
+//!
+//! 本模块只做**纯路径计算 + 只读探测**:不建目录、不搬文件、不写配置。
+
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+use super::error::GameError;
+use super::install::instance::{InstanceConfig, Isolation};
+use super::layout::GameLayout;
+
+/// 恒共享层(任何隔离档位都不按实例复制;natives 例外,按实例解包)。
+pub const SHARED_LAYERS: &[&str] = &["versions", "assets", "libraries"];
+
+/// 切换检测探测的三类内容目录(两家启动器一致:只关心玩家数据)。
+pub const PROBED_CONTENT_DIRS: &[&str] = &["mods", "saves", "config"];
+
+/// 全局默认隔离模式(`launcher.json` 的 `instances.mode`)。
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum IsolationMode {
+    /// 每实例运行目录 = `versions//`(D20 默认)。
+    #[default]
+    Isolated,
+    /// 所有实例共用 `.minecraft` 根(HMCL 传统单目录形态)。
+    Shared,
+}
+
+impl IsolationMode {
+    /// 稳定字符串 id(与 `setting::keys::INSTANCE_MODES` 同字面量)。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Isolated => "isolated",
+            Self::Shared => "shared",
+        }
+    }
+
+    /// 从字符串解析(未知值返回 `None`,由调用方决定报错还是取默认)。
+    #[must_use]
+    pub fn parse(raw: &str) -> Option {
+        match raw.trim() {
+            "isolated" => Some(Self::Isolated),
+            "shared" => Some(Self::Shared),
+            _ => None,
+        }
+    }
+
+    /// 折算成实例档位(全局模式只有两档,`inherit`/`custom` 不存在)。
+    #[must_use]
+    pub const fn as_isolation(self) -> Isolation {
+        match self {
+            Self::Isolated => Isolation::Isolated,
+            Self::Shared => Isolation::Shared,
+        }
+    }
+}
+
+/// 派生结论的来源(诊断/日志用,`mode` 变了要能解释为什么)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum IsolationSource {
+    /// 临时启动参数覆盖(最高优先)。
+    Temporary,
+    /// 实例文件里的 `isolation`。
+    Instance,
+    /// 全局 `instances.mode`(实例 `inherit` 或没有实例文件)。
+    Global,
+}
+
+impl IsolationSource {
+    /// 稳定名。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Temporary => "temporary",
+            Self::Instance => "instance",
+            Self::Global => "global",
+        }
+    }
+}
+
+/// 一次派生(覆盖链求值)的结论。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Derivation {
+    /// 生效档位(`inherit` 已被解析掉)。
+    pub isolation: Isolation,
+    /// 生效运行目录(喂 `--gameDir` 与进程 CWD)。
+    pub game_dir: PathBuf,
+    /// 档位来自哪一层。
+    pub source: IsolationSource,
+}
+
+/// 按覆盖链派生运行目录(纯计算,不碰盘)。
+///
+/// - `temporary`:临时启动参数给的目录(空路径视为未给);
+/// - `instance`:实例设置;`isolation` 为 `None`(= `inherit`)或没有实例时落到 `global_mode`;
+/// - `custom` 却没有 `gameDir` → [`GameError::CustomIsolationWithoutDir`](不猜默认目录)。
+///
+/// # Errors
+///
+/// `custom` 档位缺 `gameDir` 时返回 [`GameError::CustomIsolationWithoutDir`]。
+pub fn derive_game_dir(
+    layout: &GameLayout,
+    version_id: &str,
+    global_mode: IsolationMode,
+    instance: Option<&InstanceConfig>,
+    temporary: Option<&Path>,
+) -> Result {
+    let (isolation, source) = match instance.and_then(|config| config.isolation) {
+        Some(isolation) => (isolation, IsolationSource::Instance),
+        None => (global_mode.as_isolation(), IsolationSource::Global),
+    };
+    if let Some(path) = temporary.filter(|path| !path.as_os_str().is_empty()) {
+        return Ok(Derivation {
+            isolation,
+            game_dir: path.to_path_buf(),
+            source: IsolationSource::Temporary,
+        });
+    }
+    let game_dir = match isolation {
+        Isolation::Isolated => layout.version_dir(version_id),
+        Isolation::Shared => layout.root().to_path_buf(),
+        Isolation::Custom => match instance.and_then(|config| config.game_dir.clone()) {
+            Some(path) if !path.as_os_str().is_empty() => path,
+            _ => {
+                return Err(GameError::CustomIsolationWithoutDir {
+                    instance: instance.map_or_else(String::new, |config| config.id.clone()),
+                })
+            }
+        },
+    };
+    Ok(Derivation {
+        isolation,
+        game_dir,
+        source,
+    })
+}
+
+/// 一侧目录的内容探测结果。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SideContent {
+    /// 探测的目录(隔离侧 = `versions//`,共享侧 = `.minecraft/`)。
+    pub path: PathBuf,
+    /// `mods` `saves` `config` 三类内容的条目数合计。
+    pub entries: u32,
+}
+
+impl SideContent {
+    /// 是否有玩家数据(条目数 > 0)。
+    #[must_use]
+    pub const fn occupied(&self) -> bool {
+        self.entries > 0
+    }
+}
+
+/// 切换检测的双侧快照(落进 `lastModeChange.probe`)。
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ModeChangeProbe {
+    /// 隔离侧条目数。
+    pub isolated_side: u32,
+    /// 共享侧条目数。
+    pub shared_side: u32,
+}
+
+impl ModeChangeProbe {
+    /// 两侧都有内容(切换后玩家会以为「存档消失」的条件)。
+    #[must_use]
+    pub const fn both_occupied(&self) -> bool {
+        self.isolated_side > 0 && self.shared_side > 0
+    }
+}
+
+/// 一次隔离档位切换的记录(实例文件的 `lastModeChange`,启动器写入)。
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ModeChange {
+    /// 切换时间(RFC-3339,由调用方给——core::game 不碰时钟格式)。
+    pub at: String,
+    /// 切换前生效档位。
+    pub from: Isolation,
+    /// 切换后生效档位。
+    pub to: Isolation,
+    /// 切换当时的双侧内容探测(只探测,不迁移)。
+    #[serde(default)]
+    pub probe: ModeChangeProbe,
+    /// 提示是否已展示过(保证「切换后首次启动提示一次」而不是每次启动都刷)。
+    #[serde(default)]
+    pub warned: bool,
+}
+
+impl ModeChange {
+    /// 是否是一次**真的**切换(`from != to`;首启建基线时两者相同)。
+    #[must_use]
+    pub fn is_real_change(&self) -> bool {
+        self.from != self.to
+    }
+
+    /// 取走待展示的提示:真切换 + 两侧都有内容 + 还没提示过。
+    ///
+    /// 取走后把 `warned` 置真(调用方负责落盘),因此同一次切换只提示一次。
+    pub fn take_warning(&mut self) -> Option {
+        if self.warned || !self.is_real_change() || !self.probe.both_occupied() {
+            return None;
+        }
+        self.warned = true;
+        Some(ModeChangeWarning {
+            from: self.from,
+            to: self.to,
+            probe: self.probe,
+        })
+    }
+}
+
+/// 「切换后两侧都有内容」的提示(D20:**只提示不迁移**)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ModeChangeWarning {
+    /// 切换前档位。
+    pub from: Isolation,
+    /// 切换后档位。
+    pub to: Isolation,
+    /// 双侧探测结果。
+    pub probe: ModeChangeProbe,
+}
+
+impl ModeChangeWarning {
+    /// 一句给人看的文案(含两侧条目数与「不自动搬迁」的说明)。
+    #[must_use]
+    pub fn message(&self) -> String {
+        format!(
+            "隔离档位已从 {} 切到 {}:隔离侧 {} 条、共享侧 {} 条,两侧都有内容。\
+             ULL 不会自动搬数据——旧内容仍在原来那一侧,需要的话请手动把 mods/saves/config 挪到新目录。",
+            self.from.as_str(),
+            self.to.as_str(),
+            self.probe.isolated_side,
+            self.probe.shared_side,
+        )
+    }
+}
+
+/// 探测一侧目录的内容条目数(`mods` `saves` `config` 的直接子项合计)。
+///
+/// 目录不存在按 0 计(新实例、纯新的隔离目录都是常态,不是错误)。
+///
+/// # Errors
+///
+/// 目录存在但**读不了**(权限/被占用)时返回 [`GameError::Io`]——这种情况不能当成「空」,
+/// 否则切换检测会给出错误的「那侧没内容」结论。
+pub fn probe_side(path: &Path) -> Result {
+    let mut entries: u32 = 0;
+    for name in PROBED_CONTENT_DIRS {
+        let dir = path.join(name);
+        let reader = match std::fs::read_dir(&dir) {
+            Ok(reader) => reader,
+            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
+            Err(error) => return Err(GameError::io(&dir, error)),
+        };
+        for entry in reader {
+            entry.map_err(|error| GameError::io(&dir, error))?;
+            entries = entries.saturating_add(1);
+        }
+    }
+    Ok(SideContent {
+        path: path.to_path_buf(),
+        entries,
+    })
+}
+
+/// 同时探测隔离侧(`versions//`)与共享侧(游戏根)。
+///
+/// # Errors
+///
+/// 任一侧读不了时返回 [`GameError::Io`]。
+pub fn probe_both_sides(
+    layout: &GameLayout,
+    version_id: &str,
+) -> Result<(SideContent, SideContent), GameError> {
+    Ok((
+        probe_side(&layout.version_dir(version_id))?,
+        probe_side(layout.root())?,
+    ))
+}
+
+/// 启动时刷新切换记录(幂等):生效档位与记录里的 `to` 不同才记一次。
+///
+/// 返回 `true` 表示 `config` 被改动(调用方负责落盘——本模块不写文件)。
+///
+/// 首次观察(没有记录)会建一条基线:`from == to`,`warned = true`——这样老实例
+/// 升级到 D20 后不会平白弹一次「两侧都有内容」。
+///
+/// # Errors
+///
+/// 探测读盘失败时返回 [`GameError::Io`]。
+pub fn observe_mode(
+    config: &mut InstanceConfig,
+    layout: &GameLayout,
+    at: &str,
+    effective: Isolation,
+) -> Result {
+    if config
+        .last_mode_change
+        .as_ref()
+        .is_some_and(|change| change.to == effective)
+    {
+        return Ok(false);
+    }
+    let from = config
+        .last_mode_change
+        .as_ref()
+        .map_or(effective, |change| change.to);
+    let (isolated_side, shared_side) = probe_both_sides(layout, &config.version_id)?;
+    config.last_mode_change = Some(ModeChange {
+        at: at.to_string(),
+        from,
+        to: effective,
+        probe: ModeChangeProbe {
+            isolated_side: isolated_side.entries,
+            shared_side: shared_side.entries,
+        },
+        warned: from == effective,
+    });
+    Ok(true)
+}
diff --git a/crates/core/src/game/java/api.rs b/crates/core/src/game/java/api.rs
new file mode 100644
index 0000000..c04b47e
--- /dev/null
+++ b/crates/core/src/game/java/api.rs
@@ -0,0 +1,344 @@
+//! Adoptium(Eclipse Temurin)版本查询:`api.adoptium.net` → 一个可下载的 JDK 归档。
+//!
+//! 只用 v3 的 `assets/latest/{major}/hotspot` 这一条接口(D13:自动下载只装最新 GA):
+//!
+//! ```text
+//! GET https://api.adoptium.net/v3/assets/latest/21/hotspot?os=windows&arch=x64&image_type=jdk&vendor=eclipse
+//!     → [ { release_name, version{major,semver}, binary{os,architecture,image_type,package{name,link,checksum,size}} } ]
+//! ```
+//!
+//! 两个实测坑(都在 Windows x64 / Linux x64 上确认过):
+//!
+//! 1. **`arch` 参数不参与过滤**:`os=windows&arch=x64` 也会把 `aarch64` 的条目一起返回,
+//!    必须自己按 `binary.architecture` 再筛一遍,否则 ARM 包会被当成 x64 装下来;
+//! 2. **`package.link` 是 GitHub release 直链**(`github.com/adoptium/temurin21-binaries/releases/download/…`),
+//!    这正是 [`crate::download::adoptium`] 的镜像展开所认识的形态——所以院校镜像加速
+//!    不需要本模块拼任何 URL。
+
+use serde::Deserialize;
+
+use super::error::{JavaError, JavaErrorKind};
+use super::release::version_from_release_name;
+use super::target::AdoptiumTarget;
+use super::vendor::JdkVendor;
+
+/// Adoptium API 根。
+pub const ADOPTIUM_API_BASE: &str = "https://api.adoptium.net/v3";
+
+/// 托管 JDK 的默认供应商标识(D13 v1 的目录名 `jdk--vendor` 沿用它)。
+pub const JDK_VENDOR: &str = "temurin";
+
+/// 归档与查询结果的结构定义在 [`super::release`](四家发行版共用);这里重导出以保持
+/// `api::JdkRelease` / `api::JdkArchive` 这两个老路径可用。
+pub use super::release::{JdkArchive, JdkRelease};
+
+/// 拼 `latest/{major}` 的查询地址。
+#[must_use]
+pub fn latest_url(major: u32, target: AdoptiumTarget) -> String {
+    format!(
+        "{ADOPTIUM_API_BASE}/assets/latest/{major}/hotspot?os={}&arch={}&image_type=jdk&vendor=eclipse",
+        target.os, target.arch
+    )
+}
+
+/// 解析 `assets/latest` 的响应体,挑出与目标平台匹配的那一条。
+///
+/// # Errors
+///
+/// 响应体不是 JSON、列表为空、没有匹配 `os` + `arch` 的 jdk 条目、sha256 不合法时返回错误。
+pub fn parse_latest(json: &str, target: AdoptiumTarget) -> Result {
+    let assets: Vec = serde_json::from_str(json).map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Metadata,
+            format!("Adoptium 响应不是预期 JSON:{error}"),
+        )
+    })?;
+    let matched = assets
+        .into_iter()
+        .find(|asset| asset.matches(target))
+        .ok_or_else(|| {
+            JavaError::new(
+                JavaErrorKind::Metadata,
+                format!(
+                    "Adoptium 没有 {}/{} 的 jdk 产物(响应里 {} 条都不匹配)",
+                    target.os, target.arch, "全部"
+                ),
+            )
+            .with_hint("可能该大版本只有别的架构;用 `ull java list` 挑已有 JDK")
+        })?;
+    matched.into_release()
+}
+
+/// 请求一次「某大版本的最新 GA」。
+///
+/// # Errors
+///
+/// 网络不可达、HTTP 非 2xx、响应体不认识、没有对应平台产物时返回错误。
+pub async fn fetch_latest(
+    client: &reqwest::Client,
+    major: u32,
+    target: AdoptiumTarget,
+) -> Result {
+    let url = latest_url(major, target);
+    let response = client.get(&url).send().await.map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Network,
+            format!("连不上 Adoptium API:{error}"),
+        )
+        .with_hint("检查网络/代理;已装的 JDK 仍可用 `ull java list` 看到")
+    })?;
+    let status = response.status();
+    if status == reqwest::StatusCode::NOT_FOUND {
+        return Err(JavaError::new(
+            JavaErrorKind::Metadata,
+            format!("Adoptium 没有 {major} 的 GA 产物(HTTP 404)"),
+        )
+        .with_hint("可自动下载的一般是 8 / 11 / 17 / 21 / 25 这类 GA 大版本"));
+    }
+    if !status.is_success() {
+        return Err(JavaError::new(
+            JavaErrorKind::Network,
+            format!("Adoptium API 返回 HTTP {status}({url})"),
+        ));
+    }
+    let body = response.text().await.map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Network,
+            format!("读 Adoptium 响应失败:{error}"),
+        )
+    })?;
+    parse_latest(&body, target)
+}
+
+/// 响应里的 `package` 子对象。
+#[derive(Debug, Deserialize)]
+struct ApiPackage {
+    name: String,
+    link: String,
+    #[serde(default)]
+    checksum: Option,
+    #[serde(default)]
+    size: u64,
+}
+
+/// 响应里的 `binary` 子对象。
+#[derive(Debug, Deserialize)]
+struct ApiBinary {
+    #[serde(default)]
+    os: Option,
+    #[serde(default)]
+    architecture: Option,
+    #[serde(default)]
+    image_type: Option,
+    #[serde(default)]
+    package: Option,
+}
+
+/// 响应里的 `version` 子对象。
+#[derive(Debug, Deserialize)]
+struct ApiVersion {
+    #[serde(default)]
+    major: Option,
+    #[serde(default)]
+    semver: Option,
+}
+
+/// 响应列表里的一条。
+#[derive(Debug, Deserialize)]
+struct ApiAsset {
+    #[serde(default)]
+    release_name: String,
+    #[serde(default)]
+    version: Option,
+    #[serde(default)]
+    binary: Option,
+}
+
+impl ApiAsset {
+    /// 是否就是目标平台的 jdk 归档(`arch` 参数不过滤,必须自己判)。
+    fn matches(&self, target: AdoptiumTarget) -> bool {
+        let Some(binary) = &self.binary else {
+            return false;
+        };
+        let same_os = binary.os.as_deref().is_some_and(|os| os == target.os);
+        let same_arch = binary
+            .architecture
+            .as_deref()
+            .is_some_and(|arch| arch == target.arch);
+        let is_jdk = binary.image_type.as_deref() != Some("jre");
+        same_os && same_arch && is_jdk && binary.package.is_some()
+    }
+
+    /// 转成对外结果(校验 sha256 形态)。
+    fn into_release(self) -> Result {
+        let package = self
+            .binary
+            .and_then(|binary| binary.package)
+            .ok_or_else(|| {
+                JavaError::new(JavaErrorKind::Metadata, "Adoptium 条目缺少 package 子对象")
+            })?;
+        let sha256 = package
+            .checksum
+            .as_deref()
+            .map(|hex| hex.trim().to_ascii_lowercase())
+            .filter(|hex| hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
+            .ok_or_else(|| {
+                JavaError::new(
+                    JavaErrorKind::Integrity,
+                    format!("Adoptium 没给可用的 sha256({})", package.name),
+                )
+                .with_hint("换一个大版本,或手动装 JDK 后设 JAVA_HOME")
+            })?;
+        let version = self.version;
+        let major = version
+            .as_ref()
+            .and_then(|version| version.major)
+            .or_else(|| major_from_name(&package.name))
+            .ok_or_else(|| {
+                JavaError::new(
+                    JavaErrorKind::Metadata,
+                    format!("Adoptium 条目里读不出大版本号({})", package.name),
+                )
+            })?;
+        let native_version = version_from_release_name(&self.release_name)
+            .or_else(|| version.as_ref().and_then(|version| version.semver.clone()))
+            .unwrap_or_else(|| major.to_string());
+        Ok(JdkRelease {
+            vendor: JdkVendor::Temurin,
+            major,
+            version: native_version,
+            semver: version.and_then(|version| version.semver),
+            release_name: self.release_name,
+            archive: JdkArchive {
+                name: package.name,
+                url: package.link,
+                sha256,
+                size: package.size,
+            },
+        })
+    }
+}
+
+/// 从产物名兜底读大版本(`OpenJDK21U-jdk_x64_windows_hotspot_…` → `21`)。
+fn major_from_name(name: &str) -> Option {
+    let rest = name.split_once("OpenJDK")?.1;
+    let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
+    digits.parse().ok()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{latest_url, parse_latest, ApiAsset};
+    use crate::game::java::error::JavaErrorKind;
+    use crate::game::java::target::AdoptiumTarget;
+
+    const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
+
+    fn sample() -> String {
+        // 实测:`arch` 参数不过滤,响应里会混进别的架构。
+        r#"[
+          {
+            "release_name": "jdk-21.0.13+9",
+            "version": {"major": 21, "semver": "21.0.13+9.0.LTS"},
+            "binary": {
+              "os": "windows", "architecture": "aarch64", "image_type": "jdk",
+              "package": {
+                "name": "OpenJDK21U-jdk_aarch64_windows_hotspot_21.0.12.1_1.zip",
+                "link": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_aarch64_windows_hotspot_21.0.12.1_1.zip",
+                "checksum": "9dcbb8ebe24aa9ee5a47a7fab2106a7c8988d7acee461260fef107411df91dc5",
+                "size": 179666944
+              }
+            }
+          },
+          {
+            "release_name": "jdk-21.0.13+9",
+            "version": {"major": 21, "semver": "21.0.13+9.0.LTS"},
+            "binary": {
+              "os": "windows", "architecture": "x64", "image_type": "jdk",
+              "package": {
+                "name": "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip",
+                "link": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.13%2B9/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip",
+                "checksum": "a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
+                "size": 190000000
+              }
+            }
+          }
+        ]"#
+        .to_string()
+    }
+
+    #[test]
+    fn picks_the_matching_arch_and_keeps_the_github_link() {
+        let release = parse_latest(&sample(), WINDOWS).expect("应解析出 x64 条目");
+        assert_eq!(release.major, 21);
+        assert_eq!(
+            release.vendor,
+            crate::game::java::vendor::JdkVendor::Temurin
+        );
+        assert_eq!(release.version, "21.0.13+9", "版本串取自 release_name");
+        assert_eq!(release.release_name, "jdk-21.0.13+9");
+        assert_eq!(release.semver.as_deref(), Some("21.0.13+9.0.LTS"));
+        assert_eq!(
+            release.archive.name,
+            "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip"
+        );
+        assert_eq!(release.archive.size, 190_000_000);
+        assert!(release.archive.url.contains("temurin21-binaries"));
+        assert_eq!(
+            crate::download::adoptium::parse_adoptium_artifact(&release.archive.url)
+                .map(|artifact| (artifact.major, artifact.arch, artifact.os)),
+            Some(("21".to_string(), "x64".to_string(), "windows".to_string()))
+        );
+    }
+
+    #[test]
+    fn missing_arch_is_an_error_not_a_guess() {
+        let error = parse_latest(&sample(), AdoptiumTarget::new("windows", "x86-32"))
+            .expect_err("x86-32 应没有条目");
+        assert_eq!(error.kind(), JavaErrorKind::Metadata);
+    }
+
+    #[test]
+    fn bad_checksum_is_rejected() {
+        let body = sample().replace(
+            "a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
+            "deadbeef",
+        );
+        let error = parse_latest(&body, WINDOWS).expect_err("短 sha256 应被拒");
+        assert_eq!(error.kind(), JavaErrorKind::Integrity);
+    }
+
+    #[test]
+    fn latest_url_carries_os_arch_and_flavor() {
+        let url = latest_url(17, AdoptiumTarget::new("linux", "aarch64"));
+        assert!(url.starts_with("https://api.adoptium.net/v3/assets/latest/17/hotspot?"));
+        assert!(url.contains("os=linux"));
+        assert!(url.contains("arch=aarch64"));
+        assert!(url.contains("image_type=jdk"));
+    }
+
+    #[test]
+    fn non_json_body_is_metadata_error() {
+        let error = parse_latest("502", WINDOWS).expect_err("HTML 应被拒");
+        assert_eq!(error.kind(), JavaErrorKind::Metadata);
+    }
+
+    #[test]
+    fn major_falls_back_to_the_artifact_name() {
+        let body = r#"[{"release_name":"jdk-17.0.13+11",
+            "binary":{"os":"windows","architecture":"x64","image_type":"jdk",
+            "package":{"name":"OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip",
+            "link":"https://example.invalid/x.zip",
+            "checksum":"a4dbaa05fca4bd89d84584e58c459bd732efebb1722af80ab13c6516832004f9",
+            "size":1}}}]"#;
+        let release = parse_latest(body, WINDOWS).expect("应解析");
+        assert_eq!(release.major, 17);
+        assert_eq!(release.semver, None);
+    }
+
+    #[test]
+    fn asset_without_binary_never_matches() {
+        let asset: ApiAsset = serde_json::from_str(r#"{"release_name":"x"}"#).expect("最小条目");
+        assert!(!asset.matches(WINDOWS));
+    }
+}
diff --git a/crates/core/src/game/java/discover.rs b/crates/core/src/game/java/discover.rs
new file mode 100644
index 0000000..ded92af
--- /dev/null
+++ b/crates/core/src/game/java/discover.rs
@@ -0,0 +1,270 @@
+//! 标准安装位置扫描 + 全部发现源的合并去重(`java list` 的「系统发现」半边)。
+//!
+//! 只认 `JAVA_HOME`/`PATH` 会漏掉最常见的一类用户:**用 MSI 装的 Temurin / Microsoft OpenJDK
+//! 不设环境变量**(安装器只登记开始菜单)。HMCL 的 `JavaManager.searchAllJavaInRepository`
+//! 与 PCL 的 `JavaUtils.SearchFolders` 都扫这些位置,本模块照同一思路:
+//!
+//! | 平台 | 扫描位置 |
+//! | --- | --- |
+//! | Windows | `%ProgramFiles%\{Java, Eclipse Adoptium, Microsoft, Zulu, Amazon Corretto, BellSoft, Semeru}\*`(含 `ProgramFiles(x86)` / `ProgramW6432` 变体,WOW64 才看得到 32 位安装)、`%APPDATA%\.minecraft\runtime\*`(**官方启动器装的运行时**,跨产品兼容)、`HKLM\SOFTWARE\JavaSoft\JDK`(老 Oracle 写的,新版发行版通常没有→容错跳过) |
+//! | Linux | `/usr/lib/jvm/*`、`/usr/java/*` |
+//! | macOS | `/Library/Java/JavaVirtualMachines/*.jdk/Contents/Home` |
+//!
+//! 三个纪律:
+//!
+//! 1. **目录不存在是常态**(本机实测 `%APPDATA%\.minecraft\runtime` 与 `JavaSoft\JDK` 都没有),
+//!    一律静默跳过,绝不报错;
+//! 2. **同一路径多来源命中只列一次**,保留优先级高者(`JAVA_HOME` 指向 Program Files 里的
+//!    JDK 时,用户看到的是 `JAVA_HOME` 而不是 `system`);
+//! 3. **扫描只在需要时才做**:`java list` 全扫;启动路径上先看托管+环境,不满足才扫盘
+//!    (别为了一次启动白跑 N 次 `java -version`)。
+
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use super::extract::java_binary_name;
+use super::probe::{self, discover_managed_roots, system_candidates, JavaOrigin, JavaRuntime};
+
+/// 全部发现源(托管 → `JAVA_HOME` → `PATH` → 标准安装位置),已按路径去重。
+#[must_use]
+pub fn candidates(managed_root: &Path) -> Vec {
+    candidates_in_roots(std::slice::from_ref(&managed_root.to_path_buf()))
+}
+
+/// 同上,但托管区可以是**多个安装根**(Portable 数据根 + Standard/Custom 落点,D23)。
+#[must_use]
+pub fn candidates_in_roots(managed_roots: &[PathBuf]) -> Vec {
+    let mut all = discover_managed_roots(managed_roots);
+    all.extend(system_candidates());
+    all.extend(standard_candidates());
+    dedupe(all)
+}
+
+/// 标准安装位置扫出来的运行时(逐个探测)。
+#[must_use]
+pub fn standard_candidates() -> Vec {
+    scan_java_binaries()
+        .into_iter()
+        .map(|binary| probe::probe(&binary, JavaOrigin::System))
+        .collect()
+}
+
+/// 扫描标准安装位置,返回 `bin/java[.exe]` 路径表(稳定排序,未探测)。
+#[must_use]
+pub fn scan_java_binaries() -> Vec {
+    let mut found: Vec = Vec::new();
+    for root in standard_roots() {
+        let binary = root.join("bin").join(java_binary_name());
+        if binary.is_file() {
+            found.push(binary);
+        }
+    }
+    for home in registry_homes() {
+        let binary = home.join("bin").join(java_binary_name());
+        if binary.is_file() {
+            found.push(binary);
+        }
+    }
+    found.sort();
+    found.dedup();
+    found
+}
+
+/// 候选 JDK 根目录(`bin/java` 的父目录的父目录)。
+#[must_use]
+pub fn standard_roots() -> Vec {
+    let mut roots = Vec::new();
+    if cfg!(windows) {
+        for variable in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
+            let Some(base) = std::env::var_os(variable) else {
+                continue;
+            };
+            let base = PathBuf::from(base);
+            for vendor in WINDOWS_VENDOR_DIRS {
+                roots.extend(child_dirs(&base.join(vendor)));
+            }
+        }
+        if let Some(appdata) = std::env::var_os("APPDATA") {
+            roots.extend(child_dirs(
+                &PathBuf::from(appdata).join(".minecraft").join("runtime"),
+            ));
+        }
+    } else if cfg!(target_os = "macos") {
+        for entry in child_dirs(Path::new("/Library/Java/JavaVirtualMachines")) {
+            // `*.jdk/Contents/Home`(`java_home` 语义)。
+            let home = entry.join("Contents").join("Home");
+            roots.push(if home.is_dir() { home } else { entry });
+        }
+    } else {
+        for base in ["/usr/lib/jvm", "/usr/java"] {
+            roots.extend(child_dirs(Path::new(base)));
+        }
+    }
+    roots.sort();
+    roots.dedup();
+    roots
+}
+
+/// Windows 各发行版的安装父目录(每种下面按子目录名当版本)。
+const WINDOWS_VENDOR_DIRS: &[&str] = &[
+    "Java",
+    "Eclipse Adoptium",
+    "Microsoft",
+    "Zulu",
+    "Amazon Corretto",
+    "BellSoft",
+    "Semeru",
+];
+
+/// 列一个目录的直接子目录(不存在/无权限一律空)。
+fn child_dirs(dir: &Path) -> Vec {
+    let Ok(entries) = std::fs::read_dir(dir) else {
+        return Vec::new();
+    };
+    entries
+        .flatten()
+        .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
+        .map(|entry| entry.path())
+        .collect()
+}
+
+/// 注册表里登记的 `JavaHome`(只查老 Oracle 的 `JavaSoft\JDK`;查不到返回空)。
+///
+/// 不走额外依赖:`reg.exe` 在 Win10+ 恒定存在,输出格式也稳定
+/// (`    JavaHome    REG_SZ    C:\Program Files\Java\jdk-17`)。
+#[must_use]
+pub fn registry_homes() -> Vec {
+    if !cfg!(windows) {
+        return Vec::new();
+    }
+    let mut homes = Vec::new();
+    for view in ["64", "32"] {
+        let Ok(output) = Command::new("reg")
+            .args(["query", "HKLM\\SOFTWARE\\JavaSoft\\JDK", "/s", "/reg:"])
+            .arg(view)
+            .output()
+        else {
+            continue;
+        };
+        if !output.status.success() {
+            continue;
+        }
+        homes.extend(parse_java_home(&String::from_utf8_lossy(&output.stdout)));
+    }
+    homes.sort();
+    homes.dedup();
+    homes
+}
+
+/// 从 `reg query /s` 的输出里抠出 `JavaHome` 的值(纯函数,便于用真实输出做单测)。
+#[must_use]
+pub fn parse_java_home(output: &str) -> Vec {
+    output
+        .lines()
+        .filter_map(|line| {
+            let rest = line.trim().strip_prefix("JavaHome")?;
+            let (_, value) = rest.split_once("REG_SZ")?;
+            let home = value.trim();
+            (!home.is_empty()).then(|| PathBuf::from(home))
+        })
+        .collect()
+}
+
+/// 按路径去重,保留**先出现**的那个(调用方按优先级排好序)。
+#[must_use]
+pub fn dedupe(runtimes: Vec) -> Vec {
+    let mut seen: Vec = Vec::with_capacity(runtimes.len());
+    let mut kept = Vec::with_capacity(runtimes.len());
+    for runtime in runtimes {
+        let key = path_key(&runtime.binary);
+        if seen.contains(&key) {
+            continue;
+        }
+        seen.push(key);
+        kept.push(runtime);
+    }
+    kept
+}
+
+/// 去重键:去掉 Windows 的 `\\?\` 前缀、统一分隔符、大小写不敏感(Windows)。
+#[must_use]
+pub fn path_key(path: &Path) -> String {
+    let text = path.display().to_string();
+    let text = text.strip_prefix(r"\\?\").unwrap_or(&text).to_string();
+    if cfg!(windows) {
+        text.replace('/', "\\").to_ascii_lowercase()
+    } else {
+        text
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{dedupe, parse_java_home, path_key, standard_roots};
+    use crate::game::java::probe::{JavaOrigin, JavaRuntime};
+
+    fn runtime(path: &str, origin: JavaOrigin) -> JavaRuntime {
+        JavaRuntime {
+            binary: std::path::PathBuf::from(path),
+            origin,
+            version: None,
+            major: Some(21),
+            bits: 64,
+            vendor: None,
+        }
+    }
+
+    #[test]
+    fn dedupe_keeps_the_higher_priority_source() {
+        let list = vec![
+            runtime(
+                "C:/Program Files/Java/jdk-21/bin/java.exe",
+                JavaOrigin::JavaHome,
+            ),
+            runtime(
+                "C:\\Program Files\\Java\\jdk-21\\bin\\java.exe",
+                JavaOrigin::System,
+            ),
+            runtime(
+                "C:/Program Files/Zulu/zulu-17/bin/java.exe",
+                JavaOrigin::System,
+            ),
+        ];
+        let kept = dedupe(list);
+        assert_eq!(kept.len(), 2);
+        assert_eq!(kept[0].origin, JavaOrigin::JavaHome, "重复路径保留高优先级");
+    }
+
+    #[test]
+    fn path_key_normalises_separators() {
+        let left = path_key(std::path::Path::new("C:/a/b/java.exe"));
+        let right = path_key(std::path::Path::new("C:\\a\\b\\java.exe"));
+        assert_eq!(left, right);
+        assert!(!path_key(std::path::Path::new(r"\\?\C:\a\java.exe")).starts_with(r"\\"));
+    }
+
+    #[test]
+    fn parses_real_reg_query_output() {
+        // 实测形态(`reg query HKLM\SOFTWARE\JavaSoft\JDK /s`)。
+        let output = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\r\n    CurrentVersion    REG_SZ    1.8\r\n\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\JavaSoft\\JDK\\1.8\r\n    JavaHome    REG_SZ    C:\\Program Files\\Java\\jdk1.8.0_402\r\n";
+        let homes = parse_java_home(output);
+        assert_eq!(
+            homes,
+            vec![std::path::PathBuf::from(
+                r"C:\Program Files\Java\jdk1.8.0_402"
+            )]
+        );
+    }
+
+    #[test]
+    fn missing_registry_key_is_not_an_error() {
+        assert!(parse_java_home("错误: 系统找不到指定的注册表项或值。\r\n").is_empty());
+    }
+
+    #[test]
+    fn standard_roots_never_panic_on_missing_directories() {
+        // 本机大概率缺若干目录;这里只要求「能跑、不 panic」。
+        let roots = standard_roots();
+        assert!(roots.iter().all(|root| !root.as_os_str().is_empty()));
+    }
+}
diff --git a/crates/core/src/game/java/error.rs b/crates/core/src/game/java/error.rs
new file mode 100644
index 0000000..0cdc3d5
--- /dev/null
+++ b/crates/core/src/game/java/error.rs
@@ -0,0 +1,138 @@
+//! Java 管理模块的错误形状:稳定分类 + 人读说明 + 下一步提示。
+//!
+//! 与 `core::game::install::InstallError` 同一个套路(分类进 `--json` 的 `error.code`,
+//! 说明给人看,提示告诉用户下一步做什么),只是没有复用那个类型——安装层的错误语义
+//! 绑在版本本体上,JDK 下载的失败面(API 不可达 / 无对应产物 / 解压后跑不起来)不一样。
+
+use std::fmt;
+
+use crate::download::{DownloadError, DownloadErrorKind};
+
+/// 失败大类(CLI 映射成稳定错误码,脚本可分流)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum JavaErrorKind {
+    /// 网络侧:Adoptium API 不可达,或所有候选镜像源都失败。
+    Network,
+    /// 元数据不认识:API 返回体缺字段、空列表、要的大版本没有 GA 产物。
+    Metadata,
+    /// 摘要校验不符(镜像给了错内容)或 API 没给可用的 sha256。
+    Integrity,
+    /// 本地 IO:建目录 / 解压 / 落盘。
+    Io,
+    /// 当前平台没有对应的 Temurin 产物(架构不认识等)。
+    Unsupported,
+    /// 装完探测不到可用 java(解压成功但跑不起来 / 版本不符)。
+    Probe,
+}
+
+impl JavaErrorKind {
+    /// 稳定字符串 id(进 `--json` 的 `error.code`)。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Network => "java_network",
+            Self::Metadata => "java_metadata",
+            Self::Integrity => "java_integrity",
+            Self::Io => "java_io",
+            Self::Unsupported => "java_unsupported",
+            Self::Probe => "java_probe",
+        }
+    }
+}
+
+/// Java 管理期错误。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct JavaError {
+    kind: JavaErrorKind,
+    message: String,
+    hint: Option,
+}
+
+impl JavaError {
+    /// 构造。
+    #[must_use]
+    pub fn new(kind: JavaErrorKind, message: impl Into) -> Self {
+        Self {
+            kind,
+            message: message.into(),
+            hint: None,
+        }
+    }
+
+    /// 补一句「下一步怎么办」。
+    #[must_use]
+    pub fn with_hint(mut self, hint: impl Into) -> Self {
+        self.hint = Some(hint.into());
+        self
+    }
+
+    /// 分类。
+    #[must_use]
+    pub const fn kind(&self) -> JavaErrorKind {
+        self.kind
+    }
+
+    /// 稳定错误码。
+    #[must_use]
+    pub const fn code(&self) -> &'static str {
+        self.kind.as_str()
+    }
+
+    /// 人读说明。
+    #[must_use]
+    pub fn message(&self) -> &str {
+        &self.message
+    }
+
+    /// 下一步提示。
+    #[must_use]
+    pub fn hint(&self) -> Option<&str> {
+        self.hint.as_deref()
+    }
+
+    /// 在说明前面加一段上下文(保留分类与提示)。
+    #[must_use]
+    pub fn with_context(mut self, context: impl fmt::Display) -> Self {
+        self.message = format!("{context}:{}", self.message);
+        self
+    }
+}
+
+impl fmt::Display for JavaError {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        formatter.write_str(&self.message)
+    }
+}
+
+impl std::error::Error for JavaError {}
+
+impl From for JavaError {
+    fn from(error: DownloadError) -> Self {
+        let kind = match error.kind {
+            DownloadErrorKind::Integrity => JavaErrorKind::Integrity,
+            DownloadErrorKind::Io => JavaErrorKind::Io,
+            DownloadErrorKind::InvalidRequest => JavaErrorKind::Metadata,
+            DownloadErrorKind::NotFound
+            | DownloadErrorKind::ServerError
+            | DownloadErrorKind::Timeout
+            | DownloadErrorKind::Network
+            | DownloadErrorKind::RangeUnsupported
+            | DownloadErrorKind::AllSourcesFailed => JavaErrorKind::Network,
+        };
+        let mut message = error.message.clone();
+        if let Some(source) = &error.source {
+            message.push_str(&format!("(源 {source})"));
+        }
+        // 逐源失败摘要:没有这一段,「全部失败」在现场只能靠复现去猜(本轮真机验收就踩到了)。
+        if !error.suppressed.is_empty() {
+            message.push_str(&format!(";逐源明细:{}", error.suppressed.join(" / ")));
+        }
+        Self::new(kind, message)
+    }
+}
+
+/// IO 错误包装(带上「在做什么 / 哪个路径」,日志里才追得动)。
+#[must_use]
+pub fn io_error(op: &'static str, path: impl fmt::Display, source: &std::io::Error) -> JavaError {
+    JavaError::new(JavaErrorKind::Io, format!("{op} 失败({path}):{source}"))
+}
diff --git a/crates/core/src/game/java/extract.rs b/crates/core/src/game/java/extract.rs
new file mode 100644
index 0000000..44b392f
--- /dev/null
+++ b/crates/core/src/game/java/extract.rs
@@ -0,0 +1,318 @@
+//! 归档解压:zip(Windows)/ tar.gz(Linux、macOS 的 Temurin 只发这种)+ 幂等戳记。
+//!
+//! # 为什么不用「解压到最终目录」了事
+//!
+//! Temurin 的归档里所有条目都在一个顶层目录下(`jdk-21.0.13+9/bin/java`),
+//! 而托管布局要的是 `java-managed/jdk-21-temurin/bin/java`。裸解压会多出一层,
+//! 于是这里先在**暂存目录**里解完,再把那个唯一的顶层目录(或暂存目录本身)
+//! 提升成最终目录——这样终态恒定,`bin/java` 的位置不会因为厂商换打包方式而漂移。
+//!
+//! # 幂等
+//!
+//! 戳记 `.ull-jdk.json` 记下「这份目录是用哪个 sha256 的归档解出来的 + 文件数」。
+//! 重复 `ull java install 21` 时 sha256 相同就**一个字节都不动**(返回 `skipped`);
+//! 官方发了新版(sha256 变)才重解。戳记缺失/损坏一律当「没装过」。
+//!
+//! # 安全
+//!
+//! zip 走 `enclosed_name()`(拒绝 `..` 与绝对路径),tar 走 `unpack_in()`
+//! (同样拒绝越界),两者都不会把文件写到目标目录之外(zip-slip)。
+//! tar 里的符号链接条目跳过(Windows 建不了;Temurin 的 JDK 归档不依赖它们)。
+
+use std::fs::{self, File};
+use std::io;
+use std::path::{Path, PathBuf};
+
+use flate2::read::GzDecoder;
+use serde::{Deserialize, Serialize};
+use zip::ZipArchive;
+
+use super::error::{io_error, JavaError, JavaErrorKind};
+
+/// 戳记文件名(放在托管 JDK 根目录里)。
+pub const STAMP_FILE: &str = ".ull-jdk.json";
+
+/// 戳记 schema 版本(改字段语义才升)。
+const STAMP_VERSION: u8 = 1;
+
+/// 暂存目录前缀(解压在它里面进行,成功后提升成最终目录)。
+const STAGING_PREFIX: &str = ".staging-";
+
+/// 托管 JDK 的戳记。
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct JdkStamp {
+    /// schema 版本。
+    pub v: u8,
+    /// 归档文件名。
+    pub archive: String,
+    /// 归档的 SHA-256(幂等判据)。
+    pub sha256: String,
+    /// 大版本号。
+    pub major: u32,
+    /// 解出来的文件数。
+    pub files: usize,
+}
+
+/// 一次解压的结果。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ExtractReport {
+    /// 本次落盘的文件数(`skipped` 时为 0)。
+    pub files: usize,
+    /// 是否因为戳记命中而整体跳过。
+    pub skipped: bool,
+}
+
+impl ExtractReport {
+    /// 是否真的写盘了。
+    #[must_use]
+    pub const fn wrote(self) -> bool {
+        !self.skipped
+    }
+}
+
+/// 托管 JDK 的 `java` 可执行文件路径。
+#[must_use]
+pub fn java_binary(dir: &Path) -> PathBuf {
+    dir.join("bin").join(java_binary_name())
+}
+
+/// 平台上的 java 可执行文件名。
+#[must_use]
+pub const fn java_binary_name() -> &'static str {
+    if cfg!(windows) {
+        "java.exe"
+    } else {
+        "java"
+    }
+}
+
+/// 读戳记(不存在 / 损坏 / 版本不符都当作「没有」)。
+#[must_use]
+pub fn read_stamp(dir: &Path) -> Option {
+    let text = fs::read_to_string(dir.join(STAMP_FILE)).ok()?;
+    let stamp: JdkStamp = serde_json::from_str(&text).ok()?;
+    (stamp.v == STAMP_VERSION).then_some(stamp)
+}
+
+/// 这个目录里是不是已经躺着一份用**同一个归档**解出来的 JDK。
+#[must_use]
+pub fn is_ready(dir: &Path, sha256: &str) -> bool {
+    let Some(stamp) = read_stamp(dir) else {
+        return false;
+    };
+    stamp.sha256 == sha256.to_ascii_lowercase() && java_binary(dir).is_file()
+}
+
+/// 解压一个 JDK 归档到 `dest`(幂等)。
+///
+/// # Errors
+///
+/// 归档打不开/格式不认识、解压写盘失败、`dest` 已存在却不由我们管理
+/// (没有戳记)时返回错误。
+pub fn extract_archive(
+    archive: &Path,
+    sha256: &str,
+    major: u32,
+    dest: &Path,
+) -> Result {
+    if is_ready(dest, sha256) {
+        return Ok(ExtractReport {
+            files: 0,
+            skipped: true,
+        });
+    }
+    let parent = dest.parent().ok_or_else(|| {
+        JavaError::new(
+            JavaErrorKind::Io,
+            format!("托管目录没有父目录:{}", dest.display()),
+        )
+    })?;
+    fs::create_dir_all(parent)
+        .map_err(|error| io_error("创建托管目录", parent.display(), &error))?;
+
+    let folder = dest
+        .file_name()
+        .map(|name| name.to_string_lossy().into_owned())
+        .unwrap_or_else(|| "jdk".to_string());
+    let staging = parent.join(format!("{STAGING_PREFIX}{folder}"));
+    if staging.exists() {
+        fs::remove_dir_all(&staging)
+            .map_err(|error| io_error("清理暂存目录", staging.display(), &error))?;
+    }
+    fs::create_dir_all(&staging)
+        .map_err(|error| io_error("创建暂存目录", staging.display(), &error))?;
+
+    let extracted = if is_zip(archive) {
+        extract_zip(archive, &staging)
+    } else {
+        extract_tar_gz(archive, &staging)
+    };
+    let files = match extracted {
+        Ok(files) => files,
+        Err(error) => {
+            let _ = fs::remove_dir_all(&staging);
+            return Err(error);
+        }
+    };
+
+    let root = single_top_level_dir(&staging).unwrap_or_else(|| staging.clone());
+    if dest.exists() {
+        // 只回收「我们自己装的」目录:有戳记,或空目录。其余一律不碰。
+        let ours = read_stamp(dest).is_some() || is_empty_dir(dest);
+        if !ours {
+            let _ = fs::remove_dir_all(&staging);
+            return Err(JavaError::new(
+                JavaErrorKind::Io,
+                format!("目标目录已存在且不是托管 JDK:{}", dest.display()),
+            )
+            .with_hint("换个目录(`--data-dir`),或先手动清掉它"));
+        }
+        fs::remove_dir_all(dest)
+            .map_err(|error| io_error("清空旧托管目录", dest.display(), &error))?;
+    }
+    fs::rename(&root, dest).map_err(|error| {
+        io_error(
+            "提升暂存目录",
+            format!("{} → {}", root.display(), dest.display()),
+            &error,
+        )
+    })?;
+    if root != staging {
+        let _ = fs::remove_dir_all(&staging);
+    }
+    if !java_binary(dest).is_file() {
+        return Err(JavaError::new(
+            JavaErrorKind::Probe,
+            format!("解压完成但找不到 bin/{}", java_binary_name()),
+        )
+        .with_hint("归档形态与平台不匹配(例如把 Linux 包当成 Windows 包装)"));
+    }
+
+    let stamp = JdkStamp {
+        v: STAMP_VERSION,
+        archive: archive
+            .file_name()
+            .map(|name| name.to_string_lossy().into_owned())
+            .unwrap_or_default(),
+        sha256: sha256.to_ascii_lowercase(),
+        major,
+        files,
+    };
+    let text = serde_json::to_string_pretty(&stamp).map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Metadata,
+            format!("序列化 JDK 戳记失败:{error}"),
+        )
+    })?;
+    let stamp_path = dest.join(STAMP_FILE);
+    fs::write(&stamp_path, text)
+        .map_err(|error| io_error("写 JDK 戳记", stamp_path.display(), &error))?;
+    Ok(ExtractReport {
+        files,
+        skipped: false,
+    })
+}
+
+/// 归档是不是 zip(魔数优先,读不到才退回扩展名)。
+///
+/// 不按扩展名一刀切:镜像把 `.zip` 存成 `.zip.tmp` 之类时魔数仍然可靠,
+/// 反过来把 tar.gz 命名成 `.zip` 也不会被错解。
+#[must_use]
+pub fn is_zip(archive: &Path) -> bool {
+    let mut head = [0u8; 4];
+    let magic = File::open(archive)
+        .and_then(|mut file| io::Read::read_exact(&mut file, &mut head).map(|()| head));
+    if let Ok(head) = magic {
+        // `PK\x03\x04` 普通条目 / `PK\x05\x06` 空归档。
+        return head == *b"PK\x03\x04" || head == *b"PK\x05\x06";
+    }
+    archive
+        .file_name()
+        .map(|name| name.to_string_lossy().to_ascii_lowercase())
+        .is_some_and(|name| name.ends_with(".zip"))
+}
+
+/// zip:逐条目落盘,`enclosed_name` 挡路径穿越。
+fn extract_zip(archive: &Path, dir: &Path) -> Result {
+    let file = File::open(archive)
+        .map_err(|error| io_error("打开 JDK 归档", archive.display(), &error))?;
+    let mut zip = ZipArchive::new(file).map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Io,
+            format!("JDK 归档不是合法 zip({}):{error}", archive.display()),
+        )
+    })?;
+    let mut files = 0;
+    for index in 0..zip.len() {
+        let mut entry = zip.by_index(index).map_err(|error| {
+            JavaError::new(
+                JavaErrorKind::Io,
+                format!("读 zip 条目失败({}):{error}", archive.display()),
+            )
+        })?;
+        if entry.is_dir() {
+            continue;
+        }
+        let Some(relative) = entry.enclosed_name() else {
+            continue;
+        };
+        let dest = dir.join(&relative);
+        if let Some(parent) = dest.parent() {
+            fs::create_dir_all(parent)
+                .map_err(|error| io_error("创建解压子目录", parent.display(), &error))?;
+        }
+        let mut out = File::create(&dest)
+            .map_err(|error| io_error("创建解压文件", dest.display(), &error))?;
+        io::copy(&mut entry, &mut out)
+            .map_err(|error| io_error("写解压文件", dest.display(), &error))?;
+        files += 1;
+    }
+    Ok(files)
+}
+
+/// tar.gz:`unpack_in` 挡路径穿越,符号链接条目跳过。
+fn extract_tar_gz(archive: &Path, dir: &Path) -> Result {
+    let file = File::open(archive)
+        .map_err(|error| io_error("打开 JDK 归档", archive.display(), &error))?;
+    let mut tar = tar::Archive::new(GzDecoder::new(file));
+    let entries = tar.entries().map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Io,
+            format!("JDK 归档不是合法 tar.gz({}):{error}", archive.display()),
+        )
+    })?;
+    let mut files = 0;
+    for entry in entries {
+        let mut entry = entry.map_err(|error| {
+            JavaError::new(
+                JavaErrorKind::Io,
+                format!("读 tar 条目失败({}):{error}", archive.display()),
+            )
+        })?;
+        if !entry.header().entry_type().is_file() {
+            continue;
+        }
+        let unpacked = entry
+            .unpack_in(dir)
+            .map_err(|error| io_error("解压 tar 条目", dir.display(), &error))?;
+        if unpacked {
+            files += 1;
+        }
+    }
+    Ok(files)
+}
+
+/// 暂存目录里是不是只有一个顶层目录(Temurin 的 `jdk-21.0.13+9/`)。
+fn single_top_level_dir(dir: &Path) -> Option {
+    let mut entries = fs::read_dir(dir).ok()?;
+    let first = entries.next()?.ok()?;
+    if entries.next().is_some() {
+        return None;
+    }
+    let path = first.path();
+    path.is_dir().then_some(path)
+}
+
+fn is_empty_dir(dir: &Path) -> bool {
+    fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none())
+}
diff --git a/crates/core/src/game/java/install.rs b/crates/core/src/game/java/install.rs
new file mode 100644
index 0000000..654dbc6
--- /dev/null
+++ b/crates/core/src/game/java/install.rs
@@ -0,0 +1,379 @@
+//! 托管 JDK 安装编排:发行版回退链 → 下载引擎 → 解压 → `java -version` 验证。
+//!
+//! ```text
+//! vendor::order(用户优先级, 地区)                  D23 的尝试顺序
+//!   → source::fetch(vendor, major)                查到该家最新 GA(Temurin/Oracle/MS/Zulu)
+//!   → location::JavaInstallRoot::jdk_dir          按落点模式算目录
+//!   → [戳记 sha256 相同 && bin/java 在] 复用      幂等的第一层
+//!   → engine.download(JavaRuntime 请求)           镜像/健康度/退避/续传/边下边算全在引擎
+//!   → extract::extract_archive                    zip / tar.gz + 幂等戳记
+//!   → probe::probe(bin/java -version)             幂等的第二层,也是「装完真能用」的证据
+//! ```
+//!
+//! **回退的粒度是发行版**(D23):该家没有这个大版本、镜像全 4xx、sha256 取不到、下载校验失败,
+//! 都记一条降级原因然后换下一家;只有全部失败才整体报错,错误里带上每一跳的原因。
+//!
+//! 这一步**不拼任何镜像 URL**(Oracle 的哈希段不可推导,见 `oracle` 模块):Temurin 的
+//! GitHub 直链交给 `MirrorTable::expand` 展开成院校镜像,Oracle/MS/Zulu 的链接由各自模块
+//! 从目录页/模板/API 拿到最终形态,引擎只负责「把这个 URL 下下来并校验」。
+
+use std::path::{Path, PathBuf};
+
+use super::error::{io_error, JavaError, JavaErrorKind};
+use super::extract;
+use super::location::{JavaInstallRoot, LocationMode};
+use super::probe::{self, JavaOrigin, JavaRuntime};
+use super::release::JdkRelease;
+use super::source::{self, ChainLog};
+use super::target::AdoptiumTarget;
+use super::vendor::{self, JdkVendor};
+use crate::download::{DownloadEngine, DownloadRequest, ResourceKind};
+
+/// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。
+pub use super::location::CACHE_DIR;
+
+/// 一次安装请求。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct InstallRequest {
+    /// 目标大版本。
+    pub major: u32,
+    /// 钉死某一家发行版(`--vendor`);`None` = 按回退链自己选。
+    pub vendor: Option,
+    /// 用户优先级(`java.vendorPriority`,空 = 按地区默认序)。
+    pub priority: Vec,
+}
+
+impl InstallRequest {
+    /// 只要大版本(走回退链,优先级用地区默认)。
+    #[must_use]
+    pub const fn new(major: u32) -> Self {
+        Self {
+            major,
+            vendor: None,
+            priority: Vec::new(),
+        }
+    }
+
+    /// 钉死发行版。
+    #[must_use]
+    pub const fn with_vendor(major: u32, vendor: JdkVendor) -> Self {
+        Self {
+            major,
+            vendor: Some(vendor),
+            priority: Vec::new(),
+        }
+    }
+
+    /// 带上用户优先级(只影响没有 `--vendor` 的情形)。
+    #[must_use]
+    pub fn with_priority(mut self, priority: Vec) -> Self {
+        self.priority = priority;
+        self
+    }
+
+    /// 实际尝试顺序(`--vendor` 钉死时链里只放它)。
+    #[must_use]
+    pub fn order(&self, domestic: bool) -> Vec {
+        match self.vendor {
+            Some(vendor) => vec![vendor],
+            None => vendor::order(&self.priority, domestic),
+        }
+    }
+}
+
+/// 会真正进入回退链的发行版(顺序 + 能力过滤)。
+///
+/// 与 [`install`] 的循环同源,但**纯**:不联网、不落盘,给 `--dry-run` 与离线测试用。
+/// 过滤条件只有一条——该家发不发这个 GA 大版本(MS 没有 8、Oracle 从 9 起)。
+/// `--vendor` 钉死时**不过滤**:链里就它一个,由 `install` 明确报「不支持 Java X」,
+/// 而不是悄悄把用户点名的发行版换成别的。
+#[must_use]
+pub fn chain_order(request: &InstallRequest, domestic: bool) -> Vec {
+    if request.vendor.is_some() {
+        return request.order(domestic);
+    }
+    request
+        .order(domestic)
+        .into_iter()
+        .filter(|vendor| vendor.supports(request.major))
+        .collect()
+}
+
+/// 一次成功安装的结果。
+#[derive(Debug, Clone)]
+pub struct InstalledJdk {
+    /// 大版本号。
+    pub major: u32,
+    /// 实际装上的发行版。
+    pub vendor: JdkVendor,
+    /// 版本查询结果(含归档名与 sha256)。
+    pub release: JdkRelease,
+    /// 托管目录(Portable:`java-managed/jdk-21-temurin`;Standard:`Java/oracle/21.0.2`)。
+    pub root: PathBuf,
+    /// 落点模式(报告里要说清「装到哪」)。
+    pub mode: LocationMode,
+    /// 归档路径(缓存)。
+    pub archive: PathBuf,
+    /// 探测到的运行时。
+    pub runtime: JavaRuntime,
+    /// 实际下载的源标识(`None` = 复用已装,没走网络)。
+    pub downloaded: Option,
+    /// 本次下载字节数(复用已装时为 0)。
+    pub bytes: u64,
+    /// 本次解压的文件数(复用已装时可能为 0)。
+    pub files: usize,
+    /// 本次是否真的解压了。
+    pub extracted: bool,
+    /// 回退链记录(命中与降级原因)。
+    pub chain: ChainLog,
+}
+
+impl InstalledJdk {
+    /// 是否完全复用了已装的那一份。
+    #[must_use]
+    pub fn reused(&self) -> bool {
+        self.downloaded.is_none()
+    }
+
+    /// `bin/java[.exe]` 路径。
+    #[must_use]
+    pub fn binary(&self) -> PathBuf {
+        extract::java_binary(&self.root)
+    }
+
+    /// 实际落点(人读一行)。
+    #[must_use]
+    pub fn location_line(&self) -> String {
+        format!("{} · {}", self.mode.as_str(), self.root.display())
+    }
+}
+
+/// 托管目录路径(Portable 布局 `java-managed/jdk--`)。
+#[must_use]
+pub fn managed_root_dir(managed_root: &Path, major: u32, vendor: &str) -> PathBuf {
+    managed_root.join(probe::managed_dir_name(major, vendor))
+}
+
+/// 归档缓存路径(`/.cache/<归档名>`)。
+#[must_use]
+pub fn archive_path(root: &Path, release: &JdkRelease) -> PathBuf {
+    root.join(CACHE_DIR).join(&release.archive.name)
+}
+
+/// 归档下载请求(`ResourceKind::JavaRuntime` + sha256 + 期望大小)。
+#[must_use]
+pub fn archive_request(release: &JdkRelease, dest: PathBuf) -> DownloadRequest {
+    let request =
+        DownloadRequest::new(ResourceKind::JavaRuntime, release.archive.url.clone(), dest)
+            .with_sha256(release.archive.sha256.clone());
+    if release.archive.size > 0 {
+        request.with_size(release.archive.size)
+    } else {
+        request
+    }
+}
+
+/// 按优先级链装一个托管 JDK(幂等:同一份归档装过就直接复用)。
+///
+/// # Errors
+///
+/// 全部发行版都失败(没有该大版本 / 平台无产物 / 网络 / 校验 / 解压 / 装完跑不起来)时
+/// 返回错误,`message()` 里带着每一跳的降级原因。
+pub async fn install(
+    engine: &DownloadEngine,
+    location: &JavaInstallRoot,
+    request: InstallRequest,
+) -> Result {
+    let target = AdoptiumTarget::host()?;
+    let domestic = engine.region().is_domestic();
+    let priority = request.order(domestic);
+    let mut chain = ChainLog::new();
+    let mut reasons: Vec = Vec::new();
+    // 清掉上次中断留下的 .staging-* 与空壳(只碰我们自己的形状,见 location 模块)。
+    let _ = super::location::cleanup_orphans(&location.root);
+
+    for candidate in &priority {
+        let vendor = *candidate;
+        if !vendor.supports(request.major) {
+            let reason = format!(
+                "不支持 Java {}({} 只发 {})",
+                request.major,
+                vendor.id(),
+                majors_text(vendor)
+            );
+            chain.skip(vendor, reason.clone());
+            reasons.push(format!("{}:{reason}", vendor.id()));
+            continue;
+        }
+        let release = match source::fetch(engine, vendor, request.major, target).await {
+            Ok(release) => release,
+            Err(error) => {
+                chain.failed(vendor, error.message().to_string());
+                reasons.push(format!("{}:{}", vendor.id(), error.message()));
+                continue;
+            }
+        };
+        match install_one(engine, location, &release).await {
+            Ok(prepared) => {
+                chain.hit(vendor, format!("命中 {}", release.summary()));
+                return Ok(InstalledJdk {
+                    major: release.major,
+                    vendor,
+                    release,
+                    root: prepared.root,
+                    mode: location.mode,
+                    archive: prepared.archive,
+                    runtime: prepared.runtime,
+                    downloaded: prepared.downloaded,
+                    bytes: prepared.bytes,
+                    files: prepared.files,
+                    extracted: prepared.extracted,
+                    chain,
+                });
+            }
+            Err(error) => {
+                chain.failed(vendor, error.message().to_string());
+                reasons.push(format!("{}:{}", vendor.id(), error.message()));
+            }
+        }
+    }
+
+    Err(JavaError::new(
+        JavaErrorKind::Metadata,
+        format!(
+            "没有装成 Java {}(试过 {}):{}",
+            request.major,
+            priority.len(),
+            reasons.join(";")
+        ),
+    )
+    .with_hint("`ull java vendors` 看各发行版的可用性;或手动装 JDK 后设 JAVA_HOME"))
+}
+
+/// 某一家的中间结果(把七元组元组换成具名结构,读起来才不费劲)。
+struct Prepared {
+    root: PathBuf,
+    archive: PathBuf,
+    runtime: JavaRuntime,
+    downloaded: Option,
+    bytes: u64,
+    files: usize,
+    extracted: bool,
+}
+
+/// 装某一家的那一份(下载 → 解压 → 探测)。
+async fn install_one(
+    engine: &DownloadEngine,
+    location: &JavaInstallRoot,
+    release: &JdkRelease,
+) -> Result {
+    let root = location.jdk_dir(release.major, release.vendor, &release.version);
+    let binary = extract::java_binary(&root);
+    let archive = archive_path(&location.root, release);
+
+    // 幂等第一层:戳记里的 sha256 与本次一致,且 bin/java 在 → 一个字节都不动。
+    if extract::is_ready(&root, release.sha256()) {
+        let runtime = probe::probe(&binary, JavaOrigin::Managed);
+        if runtime.major == Some(release.major) {
+            let mut runtime = runtime;
+            runtime.vendor = Some(release.vendor.id().to_string());
+            return Ok(Prepared {
+                root,
+                archive,
+                runtime,
+                downloaded: None,
+                bytes: 0,
+                files: 0,
+                extracted: false,
+            });
+        }
+        note(&format!(
+            "托管目录 {} 里的 java 跑不起来,重装一份",
+            root.display()
+        ));
+    }
+
+    if let Some(parent) = archive.parent() {
+        std::fs::create_dir_all(parent)
+            .map_err(|error| io_error("创建归档缓存目录", parent.display(), &error))?;
+    }
+    let outcome = engine
+        .download(archive_request(release, archive.clone()))
+        .await?;
+    let report = extract::extract_archive(&archive, release.sha256(), release.major, &root)?;
+    let mut runtime = probe::probe(&binary, JavaOrigin::Managed);
+    runtime.vendor = Some(release.vendor.id().to_string());
+    if runtime.major != Some(release.major) {
+        return Err(JavaError::new(
+            JavaErrorKind::Probe,
+            format!(
+                "装好的 JDK 探测结果是 Java {},与请求的 {} 不符({})",
+                runtime
+                    .major
+                    .map_or_else(|| "未知".to_string(), |major| major.to_string()),
+                release.major,
+                binary.display()
+            ),
+        )
+        .with_hint("删掉该托管目录后重跑 `ull java install`;或手动设 JAVA_HOME"));
+    }
+    Ok(Prepared {
+        root,
+        archive,
+        runtime,
+        downloaded: Some(outcome.source),
+        bytes: outcome.bytes,
+        files: report.files,
+        extracted: report.wrote(),
+    })
+}
+
+/// 只做规划不落盘:返回将要安装的版本、目标目录与「是否已经装好」。
+///
+/// # Errors
+///
+/// 与 [`install`] 的查询段相同(平台不支持 / 网络失败 / 所有发行版都没有该大版本)。
+pub async fn plan(
+    engine: &DownloadEngine,
+    location: &JavaInstallRoot,
+    major: u32,
+) -> Result<(JdkRelease, PathBuf, bool), JavaError> {
+    let target = AdoptiumTarget::host()?;
+    let domestic = engine.region().is_domestic();
+    let mut reasons: Vec = Vec::new();
+    for vendor in vendor::order(&[], domestic) {
+        if !vendor.supports(major) {
+            reasons.push(format!("{}:不支持 Java {major}", vendor.id()));
+            continue;
+        }
+        match source::fetch(engine, vendor, major, target).await {
+            Ok(release) => {
+                let root = location.jdk_dir(release.major, release.vendor, &release.version);
+                let ready = extract::is_ready(&root, release.sha256());
+                return Ok((release, root, ready));
+            }
+            Err(error) => reasons.push(format!("{}:{}", vendor.id(), error.message())),
+        }
+    }
+    Err(JavaError::new(
+        JavaErrorKind::Metadata,
+        format!("没有可用的 Java {major}({} )", reasons.join(";")),
+    ))
+}
+
+fn majors_text(vendor: JdkVendor) -> String {
+    vendor
+        .hint_majors()
+        .iter()
+        .map(u32::to_string)
+        .collect::>()
+        .join("/")
+}
+
+fn note(message: &str) {
+    if cfg!(debug_assertions) {
+        eprintln!("[ull::game::java] {message}");
+    } else {
+        let _ = message;
+    }
+}
diff --git a/crates/core/src/game/java/location.rs b/crates/core/src/game/java/location.rs
new file mode 100644
index 0000000..21fac8b
--- /dev/null
+++ b/crates/core/src/game/java/location.rs
@@ -0,0 +1,335 @@
+//! 托管 JDK 的落点模式(D23 三模式,与 D6 数据目录三模式对齐)。
+//!
+//! | 模式 | 落点 | 说明 |
+//! | --- | --- | --- |
+//! | Portable | `<数据根>/.ULL/data/java-managed/jdk--/` | v1 既有布局,M33/M35 的托管根解析语义不变 |
+//! | Standard | Windows `%APPDATA%\Java\\\`;macOS `~/Library/Application Support/Java//`;Linux `$XDG_DATA_HOME/java//`(回落 `~/.local/share/java/…`) | 用户原话「安装到 %APPDATA%\Java\发行方\Java版本\」 |
+//! | Custom | 设置键 `java.installRoot` 指定的目录,同样按 `/` 分层 | 覆盖前两种模式 |
+//!
+//! 三条纪律:
+//!
+//! 1. **不猜平台目录**:`%APPDATA%` / `XDG_DATA_HOME` / `HOME` 全走 `platform::paths::EnvSource`,
+//!    测试能注入假环境确定性地覆盖各 OS 落点;
+//! 2. **Portable 布局永不改**:`.ULL/data/java-managed/jdk--` 是 M33 定下的,
+//!    改成 `/` 会让既有托管 JDK 变成孤儿——迁移语义靠「两种形状都扫得到」兜住;
+//! 3. **清理只碰自己的形状**:`cleanup_orphans` 只删 `.staging-*` 半成品与空目录,
+//!    没有戳记的非空目录一律不动(用户可能自己往里塞了 JDK)。
+
+use std::path::{Path, PathBuf};
+
+use crate::platform::data_dir::DataMode;
+use crate::platform::layout::LauncherLayout;
+use crate::platform::paths::{home_dir, EnvSource, Platform};
+
+use super::error::{JavaError, JavaErrorKind};
+use super::vendor::JdkVendor;
+
+/// Standard 模式下 JDK 的父目录名(Windows 是 `%APPDATA%\Java`)。
+pub const JAVA_DIR_NAME: &str = "Java";
+
+/// 归档缓存目录名(在安装根下;托管目录扫描跳过它)。
+pub const CACHE_DIR: &str = ".cache";
+
+/// 解压暂存目录前缀(中断后残留的就是孤儿)。
+pub const STAGING_PREFIX: &str = ".staging-";
+
+/// 落地模式。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum LocationMode {
+    /// 数据根内(v1 布局)。
+    Portable,
+    /// OS 标准应用数据目录。
+    Standard,
+    /// `java.installRoot` 指定。
+    Custom,
+}
+
+impl LocationMode {
+    /// 稳定名(JSON / 日志)。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Portable => "portable",
+            Self::Standard => "standard",
+            Self::Custom => "custom",
+        }
+    }
+
+    /// 从配置字符串解析。
+    #[must_use]
+    pub fn parse(text: &str) -> Option {
+        match text {
+            "portable" => Some(Self::Portable),
+            "standard" => Some(Self::Standard),
+            "custom" => Some(Self::Custom),
+            _ => None,
+        }
+    }
+}
+
+/// 解析好的安装根 + 它是怎么来的。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct JavaInstallRoot {
+    /// 落地模式。
+    pub mode: LocationMode,
+    /// 安装根目录(各 JDK 目录在它下面)。
+    pub root: PathBuf,
+}
+
+impl JavaInstallRoot {
+    /// Portable:`<数据根>/.ULL/data/java-managed`。
+    #[must_use]
+    pub fn portable(launcher: &LauncherLayout) -> Self {
+        Self {
+            mode: LocationMode::Portable,
+            root: launcher.java_managed(),
+        }
+    }
+
+    /// Standard:OS 标准应用数据目录 + `Java`。
+    ///
+    /// 平台约定与 `platform::paths::standard_launcher_home` 同源:
+    /// - Windows:`%APPDATA%\Java`(回落 `%USERPROFILE%\AppData\Roaming\Java`)
+    /// - macOS:`~/Library/Application Support/Java`
+    /// - Linux / 其它:`$XDG_DATA_HOME/java`(回落 `~/.local/share/java`)
+    #[must_use]
+    pub fn standard(platform: Platform, env: &dyn EnvSource) -> Option {
+        standard_java_root(platform, env).map(|root| Self {
+            mode: LocationMode::Standard,
+            root,
+        })
+    }
+
+    /// Custom:`java.installRoot` 指定的目录(相对路径按数据根解析)。
+    #[must_use]
+    pub fn custom(root: impl Into) -> Self {
+        Self {
+            mode: LocationMode::Custom,
+            root: root.into(),
+        }
+    }
+
+    /// 按设置解析出真正的落点。
+    ///
+    /// 顺序:`java.installRoot`(设了就用,模式 Custom)→ Standard(数据目录 Standard 模式)
+    /// → Portable(数据根内,含 `--data-dir` 的 Custom 数据模式)。
+    ///
+    /// # Errors
+    ///
+    /// 数据目录是 Standard 但拿不到系统标准目录时返回 [`JavaErrorKind::Unsupported`]
+    /// (不静默落到别处——否则用户会在 `%APPDATA%\Java` 里找不到刚装的 JDK)。
+    pub fn resolve(
+        data_mode: DataMode,
+        launcher: &LauncherLayout,
+        override_root: Option<&Path>,
+        platform: Platform,
+        env: &dyn EnvSource,
+    ) -> Result {
+        if let Some(root) = override_root.filter(|root| !root.as_os_str().is_empty()) {
+            let root = if root.is_absolute() {
+                root.to_path_buf()
+            } else {
+                launcher.home().join(root)
+            };
+            return Ok(Self::custom(root));
+        }
+        if data_mode == DataMode::Standard {
+            return Self::standard(platform, env).ok_or_else(|| {
+                JavaError::new(
+                    JavaErrorKind::Unsupported,
+                    "Standard 模式下拿不到系统标准目录(%APPDATA% / HOME 都读不到)",
+                )
+                .with_hint(
+                    "设置 `java.installRoot` 指定一个目录,或把 `core.data_mode` 改回 portable",
+                )
+            });
+        }
+        Ok(Self::portable(launcher))
+    }
+
+    /// 某个 JDK 的目录。
+    ///
+    /// Standard / Custom 用 `/`(D23 用户原话的 `<发行方>\\`),
+    /// Portable 保持 v1 的 `jdk--`(改形状会让既有托管 JDK 变孤儿)。
+    #[must_use]
+    pub fn jdk_dir(&self, major: u32, vendor: JdkVendor, version: &str) -> PathBuf {
+        match self.mode {
+            LocationMode::Standard | LocationMode::Custom => {
+                self.root.join(vendor.id()).join(sanitize_version(version))
+            }
+            LocationMode::Portable => self
+                .root
+                .join(super::probe::managed_dir_name(major, vendor.id())),
+        }
+    }
+
+    /// 归档缓存目录(`/.cache`)。
+    #[must_use]
+    pub fn cache_dir(&self) -> PathBuf {
+        self.root.join(CACHE_DIR)
+    }
+
+    /// 人读描述(`Standard(%APPDATA%\Java)` 这种)。
+    #[must_use]
+    pub fn describe(&self) -> String {
+        format!("{}({})", self.mode.as_str(), self.root.display())
+    }
+}
+
+/// OS 标准 JDK 根(纯函数,测试注入环境)。
+///
+/// 注意 `HOME` 不用 `platform::paths::home_dir`:那个函数按**编译目标**分叉(Windows 上读
+/// `USERPROFILE`),会让「在 Windows 上断言 macOS/Linux 落点」这种测试无法成立。
+/// 这里按传入的 `platform` 参数走,语义与 `paths::standard_launcher_home` 的落点一致。
+#[must_use]
+pub fn standard_java_root(platform: Platform, env: &dyn EnvSource) -> Option {
+    match platform {
+        Platform::Windows => roaming_app_data(env).map(|dir| dir.join(JAVA_DIR_NAME)),
+        Platform::MacOs => {
+            unix_home(env).map(|home| home.join("Library/Application Support").join(JAVA_DIR_NAME))
+        }
+        Platform::Linux | Platform::Other => env
+            .var("XDG_DATA_HOME")
+            .map(PathBuf::from)
+            .filter(|dir| dir.is_absolute())
+            .or_else(|| unix_home(env).map(|home| home.join(".local/share")))
+            .map(|dir| dir.join("java")),
+    }
+}
+
+/// Unix 家目录(`HOME`,缺了才退回 `platform::paths::home_dir`)。
+fn unix_home(env: &dyn EnvSource) -> Option {
+    env.var("HOME").map(PathBuf::from).or_else(|| home_dir(env))
+}
+
+/// `%APPDATA%`,回落 `%USERPROFILE%\AppData\Roaming`。
+fn roaming_app_data(env: &dyn EnvSource) -> Option {
+    env.var("APPDATA").map(PathBuf::from).or_else(|| {
+        env.var("USERPROFILE")
+            .map(|profile| PathBuf::from(profile).join("AppData/Roaming"))
+    })
+}
+
+/// 版本号 → 目录名(只保留文件系统安全的字符)。
+///
+/// Temurin `21.0.13+9`、MS `21.0.13.0.2`、Zulu `21.0.13+3` 都原样可作目录名;
+/// 遇到 `~` / `/` 这类才替换(Zulu 的 `21.0.13+3-x86_64` 之类不会出现,但别赌)。
+#[must_use]
+pub fn sanitize_version(version: &str) -> String {
+    let cleaned: String = version
+        .trim()
+        .chars()
+        .map(|ch| {
+            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | '+') {
+                ch
+            } else {
+                '_'
+            }
+        })
+        .collect();
+    if cleaned.is_empty() {
+        "unknown".to_string()
+    } else {
+        cleaned
+    }
+}
+
+/// `java list` 要扫的全部安装根(Portable 数据根在前,去重)。
+#[must_use]
+pub fn scan_roots(
+    data_mode: DataMode,
+    launcher: &LauncherLayout,
+    override_root: Option<&Path>,
+    platform: Platform,
+    env: &dyn EnvSource,
+) -> Vec {
+    let mut roots = vec![launcher.java_managed()];
+    if let Ok(resolved) =
+        JavaInstallRoot::resolve(data_mode, launcher, override_root, platform, env)
+    {
+        if !roots.contains(&resolved.root) {
+            roots.push(resolved.root);
+        }
+    }
+    // 数据目录是 Standard 时,便携目录里的老装法仍然可能还在(用户从便携切过来的)。
+    if let Some(standard) = standard_java_root(platform, env) {
+        if !roots.contains(&standard) {
+            roots.push(standard);
+        }
+    }
+    roots
+}
+
+/// 清理结果。
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct CleanupReport {
+    /// 删掉的路径(暂存目录、空壳目录)。
+    pub removed: Vec,
+    /// 保留下来的条目数(都是我们没资格动的)。
+    pub kept: usize,
+}
+
+impl CleanupReport {
+    /// 是否什么都没删。
+    #[must_use]
+    pub fn is_noop(&self) -> bool {
+        self.removed.is_empty()
+    }
+}
+
+/// 清掉安装根下的半成品与空壳。
+///
+/// 只碰两种东西:①`.staging-*`(解压中断的残留,永远可以删)②空目录(含空 vendor 目录)。
+/// 其余一律保留并计入 `kept`——**宁可不删,也不误删用户手工放进来的 JDK**。
+#[must_use]
+pub fn cleanup_orphans(root: &Path) -> CleanupReport {
+    let mut report = CleanupReport::default();
+    let Ok(entries) = std::fs::read_dir(root) else {
+        return report;
+    };
+    for entry in entries.flatten() {
+        let path = entry.path();
+        let name = entry.file_name().to_string_lossy().into_owned();
+        if !path.is_dir() || name == CACHE_DIR {
+            report.kept += 1;
+            continue;
+        }
+        if name.starts_with(STAGING_PREFIX) {
+            if std::fs::remove_dir_all(&path).is_ok() {
+                report.removed.push(path);
+            } else {
+                report.kept += 1;
+            }
+            continue;
+        }
+        if is_empty_dir(&path) {
+            if std::fs::remove_dir(&path).is_ok() {
+                report.removed.push(path);
+            } else {
+                report.kept += 1;
+            }
+            continue;
+        }
+        // 两层的 Standard/Custom 布局:`/` 里的空目录也要收。
+        if JdkVendor::parse(&name).is_some() {
+            for child in std::fs::read_dir(&path).into_iter().flatten().flatten() {
+                let child_path = child.path();
+                if child_path.is_dir()
+                    && is_empty_dir(&child_path)
+                    && std::fs::remove_dir(&child_path).is_ok()
+                {
+                    report.removed.push(child_path);
+                }
+            }
+            if is_empty_dir(&path) && std::fs::remove_dir(&path).is_ok() {
+                report.removed.push(path);
+            }
+        }
+        report.kept += 1;
+    }
+    report
+}
+
+fn is_empty_dir(dir: &Path) -> bool {
+    std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none())
+}
diff --git a/crates/core/src/game/java/mapping.rs b/crates/core/src/game/java/mapping.rs
new file mode 100644
index 0000000..930b889
--- /dev/null
+++ b/crates/core/src/game/java/mapping.rs
@@ -0,0 +1,376 @@
+//! MC 版本 ↔ Java 版本映射(平移 HMCL 约束体系)+ 不符检查。
+//!
+//! 依据 `DevDocs/analyse/02-launch-pipeline.md` §5.4(HMCL `GameJavaVersion.getMinimumJavaVersion`)
+//! 与 §3.1 的强制约束清单(`JavaVersionConstraint` 的 `isMandatory` 项)。两条权威叠加:
+//!
+//! | MC 版本 | 最低 | 最高 | 出处 |
+//! | --- | --- | --- | --- |
+//! | ≥ 26.1 | 25 | — | `java-runtime-epsilon` |
+//! | ≥ 1.20.5 | 21 | — | `java-runtime-delta` |
+//! | ≥ 1.18 | 17 | — | `java-runtime-beta` |
+//! | ≥ 1.17 | 16 | — | `java-runtime-alpha` |
+//! | ≥ 1.13 | 8 | — | `jre-legacy` |
+//! | < 1.13 | 8 | **8** | `LAUNCH_WRAPPER`:LaunchWrapper 的 `URLClassLoader` 只能吃 Java 8 |
+//!
+//! **版本 JSON 优先**:装了 1.17 的玩家看的是 Mojang 自己写的 `javaVersion.majorVersion`
+//! (1.17 就是 16),静态表只在清单没写时兜底——这也是 HMCL 的 `GAME_JSON` 强制项语义。
+//!
+//! 上限(`maximum`)只对老版本存在:1.12.2 用 Java 17 会直接
+//! `UnsupportedClassVersionError`-级别的失败(启动器侧是 LaunchWrapper 假设),
+//! 所以「1.12.2 + Java 21」要被判成不符,而不是「19 以上都能跑」。
+
+use std::cmp::Ordering;
+
+use super::super::inherit::resolve;
+use super::super::layout::GameLayout;
+use super::super::manifest::{JavaVersion, VersionJson};
+
+/// 要求来源(`--json` 的 `requirement_source`)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RequirementSource {
+    /// 版本 JSON 的 `javaVersion`(含继承链解析)。
+    Manifest,
+    /// 静态表(HMCL 约束体系的平移)。
+    Table,
+}
+
+impl RequirementSource {
+    /// 稳定字符串 id。
+    #[must_use]
+    pub const fn as_str(self) -> &'static str {
+        match self {
+            Self::Manifest => "manifest",
+            Self::Table => "table",
+        }
+    }
+}
+
+/// 一个版本对 Java 的要求(闭区间)。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct JavaRequirement {
+    /// 最低主版本号(`21`)。
+    pub minimum: u32,
+    /// 最高主版本号(`Some(8)` = 只能用 8 及以下)。
+    pub maximum: Option,
+    /// 来源。
+    pub source: RequirementSource,
+}
+
+impl JavaRequirement {
+    /// 构造。
+    #[must_use]
+    pub const fn new(minimum: u32, maximum: Option, source: RequirementSource) -> Self {
+        Self {
+            minimum,
+            maximum,
+            source,
+        }
+    }
+
+    /// 静态表来源的构造。
+    #[must_use]
+    pub const fn table(minimum: u32, maximum: Option) -> Self {
+        Self::new(minimum, maximum, RequirementSource::Table)
+    }
+
+    /// 某主版本号是否满足要求。
+    #[must_use]
+    pub fn satisfied_by(&self, major: u32) -> bool {
+        major >= self.minimum && self.maximum.is_none_or(|maximum| major <= maximum)
+    }
+
+    /// 人读描述(`Java 21+` / `Java 8` / `Java 8–16`)。
+    #[must_use]
+    pub fn describe(&self) -> String {
+        match self.maximum {
+            Some(maximum) if maximum == self.minimum => format!("Java {}", self.minimum),
+            Some(maximum) => format!("Java {}–{}", self.minimum, maximum),
+            None => format!("Java {}+", self.minimum),
+        }
+    }
+}
+
+/// 不符的两种形态(D13:只提示,不阻断)。
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum JavaMismatch {
+    /// Java 太低。
+    TooOld {
+        /// 需要的最低主版本。
+        required: u32,
+        /// 实际主版本。
+        actual: u32,
+    },
+    /// Java 太高(老版本只能用 8)。
+    TooNew {
+        /// 允许的最高主版本。
+        maximum: u32,
+        /// 实际主版本。
+        actual: u32,
+    },
+}
+
+impl JavaMismatch {
+    /// 稳定字符串 id(`--json` 的 `mismatch.code`)。
+    #[must_use]
+    pub const fn code(self) -> &'static str {
+        match self {
+            Self::TooOld { .. } => "java_too_old",
+            Self::TooNew { .. } => "java_too_new",
+        }
+    }
+
+    /// 人读说明(GUI 弹窗 P1 后直接复用这句)。
+    #[must_use]
+    pub fn message(self) -> String {
+        match self {
+            Self::TooOld { required, actual } => format!(
+                "该版本需要 Java {required} 及以上,当前用的是 Java {actual}(大概率起不来)"
+            ),
+            Self::TooNew { maximum, actual } => format!(
+                "该版本只能用 Java {maximum} 及以下,当前用的是 Java {actual}(老加载器吃不了新 class 文件)"
+            ),
+        }
+    }
+}
+
+/// 判需求与实际是否相符(`None` = 相符)。
+#[must_use]
+pub fn check(actual: u32, required: &JavaRequirement) -> Option {
+    if actual < required.minimum {
+        return Some(JavaMismatch::TooOld {
+            required: required.minimum,
+            actual,
+        });
+    }
+    if let Some(maximum) = required.maximum {
+        if actual > maximum {
+            return Some(JavaMismatch::TooNew { maximum, actual });
+        }
+    }
+    None
+}
+
+/// 静态表:MC 版本 → Java 要求。
+///
+/// 无法解析的版本(周快照 `20w14a`、魔改 id)只给下限 `8`、不设上限——
+/// 宁可漏判一次,也不要把「Java 21 + 1.20 快照」误锁成「必须降到 8」。
+#[must_use]
+pub fn requirement_for(mc_version: &str) -> JavaRequirement {
+    let parts = version_parts(mc_version);
+    if parts.is_empty() {
+        return JavaRequirement::table(8, None);
+    }
+    if at_least(&parts, &[26, 1]) {
+        return JavaRequirement::table(25, None);
+    }
+    if at_least(&parts, &[1, 20, 5]) {
+        return JavaRequirement::table(21, None);
+    }
+    if at_least(&parts, &[1, 18]) {
+        return JavaRequirement::table(17, None);
+    }
+    if at_least(&parts, &[1, 17]) {
+        return JavaRequirement::table(16, None);
+    }
+    if at_least(&parts, &[1, 13]) {
+        return JavaRequirement::table(8, None);
+    }
+    JavaRequirement::table(8, Some(8))
+}
+
+/// 静态表的最低位(`recommended_major("1.20.5") == 21`)。
+#[must_use]
+pub fn recommended_major(mc_version: &str) -> u32 {
+    requirement_for(mc_version).minimum
+}
+
+/// 版本 JSON 的 `javaVersion` → 要求(字段缺失或为 0 时返回 `None`)。
+///
+/// Mojang 这个字段给的是**最低**要求(`majorVersion: 21` 表示 21 起跳),
+/// 不声明上限——所以这里 `maximum` 恒为 `None`;上限由 [`requirement_for`] 补
+/// (见 [`requirement_for_version`] 的合成说明)。
+#[must_use]
+pub fn requirement_from_manifest(java: &JavaVersion) -> Option {
+    let major = java.major_version.filter(|major| *major > 0)?;
+    Some(JavaRequirement::new(
+        major,
+        None,
+        RequirementSource::Manifest,
+    ))
+}
+
+/// 已安装版本的 Java 要求:解析继承链 → 版本 JSON 的 `javaVersion` 优先 → 静态表兜底。
+///
+/// **下限与上限来源不同**:Mojang 的 `javaVersion` 只声明「最低要多少」,
+/// 而「1.12.2 及更早吃不了 Java 9+」这条知识来自 HMCL 的 `LAUNCH_WRAPPER` 约束,
+/// 不在清单里。所以合成时下限取清单、上限取静态表——否则「1.12.2 + Java 21」
+/// 会被判成完全合法(真机上必崩)。
+///
+/// 版本 JSON 读不到(未安装 / 损坏)时直接走静态表,不报错——这是「启动前提示」的路径,
+/// 不该因为清单缺件就拦住用户(真正拦人的是 `snapshot::prepare`)。
+#[must_use]
+pub fn requirement_for_version(layout: &GameLayout, version_id: &str) -> JavaRequirement {
+    let table = requirement_for(version_id);
+    let read = |id: &str| -> Option {
+        let text = std::fs::read_to_string(layout.version_json(id)).ok()?;
+        VersionJson::from_json_str(&text).ok()
+    };
+    let Some(manifest) = read(version_id) else {
+        return table;
+    };
+    let resolved = resolve(&read, &manifest).unwrap_or(manifest);
+    let Some(declared) = resolved
+        .java_version
+        .as_ref()
+        .and_then(requirement_from_manifest)
+    else {
+        // 清单没写 javaVersion:按 id 的静态表(继承来的 id 可能是魔改名)。
+        return match resolved.id.as_deref() {
+            Some(id) => requirement_for(id),
+            None => table,
+        };
+    };
+    JavaRequirement {
+        minimum: declared.minimum,
+        maximum: table.maximum,
+        source: RequirementSource::Manifest,
+    }
+}
+
+/// 版本串 → 数字分量(`1.20.5-pre1` → `[1, 20, 5]`;周快照 → 空)。
+fn version_parts(raw: &str) -> Vec {
+    let trimmed = raw.trim();
+    if is_weekly_snapshot(trimmed) {
+        return Vec::new();
+    }
+    let mut parts = Vec::new();
+    for piece in trimmed.split('.') {
+        let digits: String = piece.chars().take_while(char::is_ascii_digit).collect();
+        match digits.parse::() {
+            Ok(value) => parts.push(value),
+            Err(_) => break,
+        }
+    }
+    parts
+}
+
+/// `20w14a` / `26w14a` 形态(与 `launch::game_version` 同一套判据)。
+fn is_weekly_snapshot(raw: &str) -> bool {
+    let bytes = raw.as_bytes();
+    bytes.len() == 6
+        && bytes[..2].iter().all(u8::is_ascii_digit)
+        && bytes[2].eq_ignore_ascii_case(&b'w')
+        && bytes[3..5].iter().all(u8::is_ascii_digit)
+        && bytes[5].is_ascii_alphabetic()
+}
+
+/// `parts >= floor`(缺位按 0:`1.20` < `1.20.5`)。
+fn at_least(parts: &[u32], floor: &[u32]) -> bool {
+    let len = parts.len().max(floor.len());
+    for index in 0..len {
+        let left = parts.get(index).copied().unwrap_or(0);
+        let right = floor.get(index).copied().unwrap_or(0);
+        match left.cmp(&right) {
+            Ordering::Greater => return true,
+            Ordering::Less => return false,
+            Ordering::Equal => {}
+        }
+    }
+    true
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{check, recommended_major, requirement_for, JavaMismatch, JavaRequirement};
+
+    #[test]
+    fn table_covers_the_hmcl_boundaries() {
+        let cases: &[(&str, u32, Option)] = &[
+            ("1.6.4", 8, Some(8)),
+            ("1.12.2", 8, Some(8)),
+            ("1.13", 8, None),
+            ("1.15.2", 8, None),
+            ("1.16.5", 8, None),
+            ("1.17", 16, None),
+            ("1.17.1", 16, None),
+            ("1.18", 17, None),
+            ("1.19.4", 17, None),
+            ("1.20.1", 17, None),
+            ("1.20.4", 17, None),
+            ("1.20.5", 21, None),
+            ("1.21.4", 21, None),
+            ("26.1", 25, None),
+            ("26.3", 25, None),
+            ("26.3-snapshot-1", 25, None),
+        ];
+        for (version, minimum, maximum) in cases {
+            let requirement = requirement_for(version);
+            assert_eq!(
+                (requirement.minimum, requirement.maximum),
+                (*minimum, *maximum),
+                "{version}"
+            );
+            assert_eq!(recommended_major(version), *minimum, "{version}");
+        }
+    }
+
+    #[test]
+    fn weekly_snapshots_get_no_upper_bound() {
+        // 20w14a 的前导数字是 20,绝不能被当成「26.x」或误锁上限。
+        let requirement = requirement_for("20w14a");
+        assert_eq!(requirement.minimum, 8);
+        assert_eq!(requirement.maximum, None);
+        assert!(requirement.satisfied_by(21));
+    }
+
+    #[test]
+    fn pre_release_suffixes_do_not_change_the_floor() {
+        assert_eq!(requirement_for("1.20.5-pre1").minimum, 21);
+        assert_eq!(requirement_for("1.20.4-rc1").minimum, 17);
+    }
+
+    #[test]
+    fn check_reports_both_directions() {
+        let modern = JavaRequirement::table(21, None);
+        assert_eq!(check(21, &modern), None);
+        assert_eq!(check(25, &modern), None);
+        assert_eq!(
+            check(17, &modern),
+            Some(JavaMismatch::TooOld {
+                required: 21,
+                actual: 17
+            })
+        );
+        let legacy = JavaRequirement::table(8, Some(8));
+        assert_eq!(check(8, &legacy), None);
+        assert_eq!(
+            check(21, &legacy),
+            Some(JavaMismatch::TooNew {
+                maximum: 8,
+                actual: 21
+            })
+        );
+        assert_eq!(legacy.describe(), "Java 8");
+        assert_eq!(modern.describe(), "Java 21+");
+        assert_eq!(JavaRequirement::table(8, Some(16)).describe(), "Java 8–16");
+    }
+
+    #[test]
+    fn mismatch_messages_name_both_sides() {
+        let message = JavaMismatch::TooOld {
+            required: 21,
+            actual: 8,
+        }
+        .message();
+        assert!(message.contains("Java 21"));
+        assert!(message.contains("Java 8"));
+        assert_eq!(
+            JavaMismatch::TooNew {
+                maximum: 8,
+                actual: 21
+            }
+            .code(),
+            "java_too_new"
+        );
+    }
+}
diff --git a/crates/core/src/game/java/microsoft.rs b/crates/core/src/game/java/microsoft.rs
new file mode 100644
index 0000000..957c59c
--- /dev/null
+++ b/crates/core/src/game/java/microsoft.rs
@@ -0,0 +1,261 @@
+//! Microsoft OpenJDK:`aka.ms` 模板下载 + 成对 `.sha256sum.txt` 校验。
+//!
+//! 分发形态与 Temurin / Oracle 都不同(research/06 §3.2):
+//!
+//! ```text
+//! 下载   https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip
+//!        → 301 → https://download.visualstudio.microsoft.com/download/pr/{guid}/{hash}/{同名}
+//! 校验   https://aka.ms/download-jdk/<同一文件名>.sha256sum.txt   # "  "
+//! ```
+//!
+//! 三条硬事实(都影响实现):
+//!
+//! 1. **版本是四段/五段**(`21.0.13.0.2` = JDK 21.0.13+2;11 线是 `11.0.32.1`),
+//!    不能拿 Temurin 的三段号直接拼——所以这里有自己的 [`parse_version`] 与内置快照表;
+//! 2. **没有 8**(微软已停发,官方页面让去下 Temurin 8),`supports()` 也这么判;
+//! 3. **GitHub 侧没有任何资产**(`microsoft/openjdk` 的 release `assets` 全空),
+//!    所以 gh-proxy 对这条链毫无用处——`domestic_mirrors` 实测为空数组。
+
+use super::error::{JavaError, JavaErrorKind};
+use super::net;
+use super::release::{parse_sha256_text, JdkArchive, JdkRelease};
+use super::target::AdoptiumTarget;
+use super::vendor::JdkVendor;
+use crate::download::MsOpenJdkTable;
+
+/// 内置版本快照(`learn.microsoft.com/java/openjdk/download` 2026-10-30 的表,research/06 §1.3)。
+///
+/// 官方只有人读 HTML 表、没有 JSON API,逐版本探测又要 `aka.ms` 301(0.3–0.8 s/次),
+/// 所以按 research/06 §3.3 的建议 A 内置快照;`size` 与实测字节数一致,用于长度对拍。
+pub const SNAPSHOT: &[(u32, &str, u64)] = &[
+    (25, "25.0.5.0.2", 220_869_659),
+    (21, "21.0.13.0.2", 201_450_315),
+    (17, "17.0.21.0.2", 187_236_595),
+    (11, "11.0.32.1", 191_250_397),
+];
+
+/// 四段/五段版本号(`21.0.13.0.2` / `11.0.32.1`)。
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MsVersion {
+    /// 大版本(`21`)。
+    pub major: u32,
+    /// 各数字段(长度 3..=5)。
+    pub segments: Vec,
+}
+
+impl MsVersion {
+    /// 原样的版本串。
+    #[must_use]
+    pub fn text(&self) -> String {
+        self.segments
+            .iter()
+            .map(u32::to_string)
+            .collect::>()
+            .join(".")
+    }
+}
+
+/// 解析 MS 的版本串;段数必须 3..=5(MS 的语义:`major.update.patch[.build[.rev]]`)。
+#[must_use]
+pub fn parse_version(text: &str) -> Option {
+    let mut segments = Vec::new();
+    for segment in text.trim().split('.') {
+        segments.push(segment.parse::().ok()?);
+    }
+    if segments.len() < 3 || segments.len() > 5 {
+        return None;
+    }
+    Some(MsVersion {
+        major: segments[0],
+        segments,
+    })
+}
+
+/// MS 的 os 段(`windows` / `macos` / `linux`)。
+#[must_use]
+pub fn ms_os(target: AdoptiumTarget) -> &'static str {
+    match target.os {
+        "mac" => "macos",
+        other => other,
+    }
+}
+
+/// MS 的 arch 段(`x64` / `aarch64`)。
+#[must_use]
+pub fn ms_arch(target: AdoptiumTarget) -> &'static str {
+    target.arch
+}
+
+/// 归档文件名(模板:`microsoft-jdk-{version}-{os}-{arch}.zip`,全平台都是 zip)。
+#[must_use]
+pub fn archive_name(version: &str, target: AdoptiumTarget) -> String {
+    format!(
+        "microsoft-jdk-{version}-{}-{}.zip",
+        ms_os(target),
+        ms_arch(target)
+    )
+}
+
+/// 把模板里的 `{version}` / `{os}` / `{arch}` 换掉。
+#[must_use]
+pub fn expand_template(template: &str, version: &str, target: AdoptiumTarget) -> String {
+    template
+        .replace("{version}", version)
+        .replace("{os}", ms_os(target))
+        .replace("{arch}", ms_arch(target))
+}
+
+/// 快照里该大版本的版本串(没有 → `None`)。
+#[must_use]
+pub fn snapshot_version(major: u32) -> Option<&'static str> {
+    SNAPSHOT
+        .iter()
+        .find(|(known, _, _)| *known == major)
+        .map(|(_, version, _)| *version)
+}
+
+/// 快照里该大版本的字节数。
+#[must_use]
+pub fn snapshot_size(major: u32) -> Option {
+    SNAPSHOT
+        .iter()
+        .find(|(known, _, _)| *known == major)
+        .map(|(_, _, size)| *size)
+}
+
+/// 查一次「某大版本的最新 MS OpenJDK」。
+///
+/// 两跳:内置快照挑版本 → 取 `.sha256sum.txt` 拿哈希。校验文件取不到就整家失败
+/// (宁可降级到 Zulu,也不下一个没法验证的 200 MB 归档)。
+///
+/// # Errors
+///
+/// 表里没配模板、该大版本微软不发(含 8)、校验文件取不到或格式不对时返回错误。
+pub async fn fetch_latest(
+    client: &reqwest::Client,
+    table: &MsOpenJdkTable,
+    major: u32,
+    target: AdoptiumTarget,
+) -> Result {
+    let version = snapshot_version(major).ok_or_else(|| {
+        JavaError::new(
+            JavaErrorKind::Metadata,
+            format!(
+                "Microsoft OpenJDK 没有 {major} 的 GA(只发 {})",
+                majors_text()
+            ),
+        )
+        .with_hint("8 用 temurin;其它大版本用 oracle / zulu")
+    })?;
+    let filename = archive_name(version, target);
+    let sha_url = expand_template(&table.sha256_template, version, target);
+    let sha_text = net::get_text(client, &sha_url, "Microsoft OpenJDK 校验文件").await?;
+    let sha256 = parse_sha256_text(&sha_text).ok_or_else(|| {
+        JavaError::new(
+            JavaErrorKind::Integrity,
+            format!("{filename} 的 .sha256sum.txt 里没有 64 位哈希"),
+        )
+    })?;
+    Ok(JdkRelease {
+        vendor: JdkVendor::Microsoft,
+        major,
+        version: version.to_string(),
+        semver: None,
+        release_name: version.to_string(),
+        archive: JdkArchive {
+            name: filename.clone(),
+            url: expand_template(&table.template, version, target),
+            sha256,
+            size: snapshot_size(major).unwrap_or(0),
+        },
+    })
+}
+
+fn majors_text() -> String {
+    SNAPSHOT
+        .iter()
+        .map(|(major, _, _)| major.to_string())
+        .collect::>()
+        .join(" / ")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{
+        archive_name, expand_template, ms_os, parse_version, snapshot_size, snapshot_version,
+        SNAPSHOT,
+    };
+    use crate::game::java::target::AdoptiumTarget;
+
+    const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64");
+
+    #[test]
+    fn snapshot_covers_the_four_majors_microsoft_ships() {
+        assert_eq!(snapshot_version(21), Some("21.0.13.0.2"));
+        assert_eq!(snapshot_version(11), Some("11.0.32.1"));
+        assert_eq!(snapshot_version(8), None, "微软已停发 8");
+        assert_eq!(snapshot_size(17), Some(187_236_595));
+        assert_eq!(
+            SNAPSHOT
+                .iter()
+                .map(|(major, _, _)| *major)
+                .collect::>(),
+            vec![25, 21, 17, 11]
+        );
+    }
+
+    #[test]
+    fn four_and_five_segment_versions_have_their_own_semantics() {
+        // 21.0.13.0.2 = JDK 21.0.13+2(五段);11 线只有四段。
+        let ms = parse_version("21.0.13.0.2").expect("五段应解析");
+        assert_eq!(ms.major, 21);
+        assert_eq!(ms.segments.len(), 5);
+        assert_eq!(ms.text(), "21.0.13.0.2");
+        let eleven = parse_version("11.0.32.1").expect("四段应解析");
+        assert_eq!(eleven.major, 11);
+        assert_eq!(eleven.segments.len(), 4);
+        // Temurin 的三段号形态在这里同样成立,但语义由快照表定义,不共用 Temurin 的解析。
+        assert_eq!(
+            parse_version("17.0.21").map(|version| version.major),
+            Some(17)
+        );
+        assert_eq!(parse_version("21"), None);
+        assert_eq!(parse_version("21.0"), None);
+        assert_eq!(parse_version("21.0.13.0.2.1"), None);
+        assert_eq!(parse_version("21.0.x.2"), None);
+    }
+
+    #[test]
+    fn archive_names_and_templates_expand_per_platform() {
+        assert_eq!(
+            archive_name("21.0.13.0.2", WINDOWS),
+            "microsoft-jdk-21.0.13.0.2-windows-x64.zip"
+        );
+        let template = "https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip";
+        assert_eq!(
+            expand_template(template, "17.0.21.0.2", WINDOWS),
+            "https://aka.ms/download-jdk/microsoft-jdk-17.0.21.0.2-windows-x64.zip"
+        );
+        assert_eq!(
+            expand_template(
+                "https://aka.ms/download-jdk/microsoft-jdk-{version}-{os}-{arch}.zip.sha256sum.txt",
+                "21.0.13.0.2",
+                AdoptiumTarget::new("linux", "aarch64")
+            ),
+            "https://aka.ms/download-jdk/microsoft-jdk-21.0.13.0.2-linux-aarch64.zip.sha256sum.txt"
+        );
+        assert_eq!(ms_os(AdoptiumTarget::new("mac", "x64")), "macos");
+    }
+
+    #[test]
+    fn builtin_table_uses_the_official_templates() {
+        let table = crate::download::MirrorTable::builtin();
+        let microsoft = table.microsoft_openjdk().expect("内置表应带 MS 分发");
+        assert!(microsoft
+            .template
+            .starts_with("https://aka.ms/download-jdk/"));
+        assert!(microsoft.sha256_template.ends_with(".sha256sum.txt"));
+        assert!(microsoft.domestic_mirrors.is_empty(), "MS 无国内镜像");
+        assert_eq!(microsoft.majors, vec![25, 21, 17, 11]);
+    }
+}
diff --git a/crates/core/src/game/java/mod.rs b/crates/core/src/game/java/mod.rs
new file mode 100644
index 0000000..45e0110
--- /dev/null
+++ b/crates/core/src/game/java/mod.rs
@@ -0,0 +1,90 @@
+//! Java 运行时管理(D13 v1 → D23 v2):托管 JDK 的查询 / 下载 / 解压 / 探测,以及 MC↔Java 映射。
+//!
+//! ```text
+//! target    宿主平台 → Adoptium 的 os/arch(Win=zip、Linux/mac=tar.gz)
+//! vendor    四家发行版与用户可调的优先级链(国内 Temurin→Oracle→MS / 国外 Temurin→MS→Oracle)
+//! api       Temurin:api.adoptium.net 查询最新 GA → (github 直链, sha256, size)
+//! oracle    Oracle OpenJDK:镜像目录页解析(hash 段不可推导,禁止拼 URL)+ .sha256
+//! microsoft MS OpenJDK:aka.ms 模板 + 内置四段版本快照 + .sha256sum.txt
+//! zulu      Azul Zulu:两跳 JSON API(列表 → 详情)+ CDN 直链(第四备选)
+//! source    发行版分派 + 回退链记录(命中与降级原因进事件流)
+//! location  落点三模式(Portable / Standard / Custom)+ 孤儿目录清理
+//! release   一次版本查询的统一产物(vendor 原生版本串 + 归档 + sha256 + 大小)
+//! net       版本查询用的极小 GET 文本工具(下载不走这里)
+//! install   编排:回退链 → 下载引擎(镜像/健康度/退避)→ 解压 → 探测验证
+//! extract   zip / tar.gz 解压 + 顶层目录剥离 + 幂等戳记 + zip-slip 防护
+//! probe     java -version 探测、托管目录扫描(两种布局都认)
+//! discover  标准安装位置扫描(Program Files 系 / 官方启动器 runtime / 注册表 / /usr/lib/jvm)+ 去重
+//! select    启动选 Java 的**纯**优先链(满足要求优先,再按来源)
+//! mapping   MC↔Java 静态表(HMCL `GameJavaVersion`)+ 版本 JSON 优先 + 不符检查
+//! ```
+//!
+//! # 三条边界
+//!
+//! 1. **不碰网络细节**:包下载一律交 `core::download` 的引擎,本模块不写重试循环
+//!    (`MirrorTable::expand` 负责 Temurin 的镜像展开;Oracle 的目录解析在 `oracle`);
+//! 2. **不弹窗**:不符检查只产出数据([`JavaMismatch`]),提示交给 CLI 的告警行与 GUI;
+//! 3. **不改启动策略**:「用哪个 Java 启动」是启动期的派生结果,不落盘;落盘的是**装到哪**
+//!    ([`JavaInstallRoot`],由 `java.installRoot` + 数据目录模式决定)。
+//!
+//! # 托管布局(D23 后两种并存)
+//!
+//! ```text
+//! Portable:data/java-managed/
+//! ├── .cache/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip   归档缓存(可删)
+//! └── jdk-21-temurin/                                          一个 major+vendor 一份(永不改)
+//!     ├── .ull-jdk.json                                        戳记(幂等判据)
+//!     └── bin/java[.exe]
+//!
+//! Standard / Custom:%APPDATA%\Java\\\     (mac/Linux 见 location 模块)
+//! ```
+
+pub mod api;
+pub mod discover;
+pub mod error;
+pub mod extract;
+pub mod install;
+pub mod location;
+pub mod mapping;
+pub mod microsoft;
+pub mod net;
+pub mod oracle;
+pub mod probe;
+pub mod release;
+pub mod select;
+pub mod source;
+pub mod target;
+pub mod vendor;
+pub mod zulu;
+
+pub use self::api::{
+    latest_url, parse_latest, JdkArchive, JdkRelease, ADOPTIUM_API_BASE, JDK_VENDOR,
+};
+pub use self::discover::{
+    candidates, candidates_in_roots, scan_java_binaries, standard_candidates, standard_roots,
+};
+pub use self::error::{JavaError, JavaErrorKind};
+pub use self::extract::{
+    extract_archive, is_ready, java_binary, java_binary_name, read_stamp, ExtractReport, JdkStamp,
+    STAMP_FILE,
+};
+pub use self::install::{
+    archive_path, archive_request, chain_order, install, managed_root_dir, plan, InstallRequest,
+    InstalledJdk,
+};
+pub use self::location::{
+    cleanup_orphans, sanitize_version, scan_roots, CleanupReport, JavaInstallRoot, LocationMode,
+};
+pub use self::mapping::{
+    check, recommended_major, requirement_for, requirement_for_version, JavaMismatch,
+    JavaRequirement, RequirementSource,
+};
+pub use self::probe::{
+    discover_managed, discover_managed_roots, managed_dir_name, parse_managed_dir, probe,
+    resolve_on_path, system_candidates, JavaOrigin, JavaRuntime, DEFAULT_MAJOR,
+};
+pub use self::release::parse_sha256_text;
+pub use self::select::{pick, resolve, JavaResolution};
+pub use self::source::{ChainLog, VendorAttempt};
+pub use self::target::AdoptiumTarget;
+pub use self::vendor::{order as vendor_order, JdkVendor, VENDOR_IDS};
diff --git a/crates/core/src/game/java/net.rs b/crates/core/src/game/java/net.rs
new file mode 100644
index 0000000..991d994
--- /dev/null
+++ b/crates/core/src/game/java/net.rs
@@ -0,0 +1,60 @@
+//! 版本查询用的极小 HTTP 取文本工具(四家发行版的「一锤子 GET」共用)。
+//!
+//! 只有一件事:GET → 状态分类 → 整段文本。**下载不走这里**(那归 `core::download` 引擎),
+//! 所以没有重试、没有镜像展开、没有进度事件——查清单失败就直接把错误抛给发行版回退链,
+//! 让它换下一家(这是 D23「vendor 级失败自动降下一 vendor」的最短路径)。
+
+use super::error::{JavaError, JavaErrorKind};
+
+/// 取一段文本(`what` 是给用户看的名词,例如「华为云 openjdk 目录」)。
+///
+/// # Errors
+///
+/// 连不上 / 超时 → [`JavaErrorKind::Network`];404 或 410 → [`JavaErrorKind::Metadata`]
+/// (「这个东西上游没有」比「网络坏了」更准确,回退链据此换 vendor 而不是重试同一条)。
+pub async fn get_text(
+    client: &reqwest::Client,
+    url: &str,
+    what: &str,
+) -> Result {
+    let response = client.get(url).send().await.map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Network,
+            format!("取 {what} 失败:{error}({url})"),
+        )
+    })?;
+    let status = response.status();
+    if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
+        return Err(JavaError::new(
+            JavaErrorKind::Metadata,
+            format!("{what} 不存在(HTTP {status}):{url}"),
+        ));
+    }
+    if !status.is_success() {
+        return Err(JavaError::new(
+            JavaErrorKind::Network,
+            format!("{what} 返回 HTTP {status}:{url}"),
+        ));
+    }
+    let content_type = response
+        .headers()
+        .get(reqwest::header::CONTENT_TYPE)
+        .and_then(|value| value.to_str().ok())
+        .unwrap_or_default()
+        .to_string();
+    let body = response.text().await.map_err(|error| {
+        JavaError::new(
+            JavaErrorKind::Network,
+            format!("读 {what} 响应失败:{error}"),
+        )
+    })?;
+    // 门户兜底页会把「路径不存在」伪装成 200 + HTML;版本清单页面本身可能也是 HTML,
+    // 所以这里不做内容判定,只看调用方能不能解析出来(解析失败会给出更准的原因)。
+    if body.trim().is_empty() {
+        return Err(JavaError::new(
+            JavaErrorKind::Metadata,
+            format!("{what} 返回空响应(Content-Type: {content_type}):{url}"),
+        ));
+    }
+    Ok(body)
+}
diff --git a/crates/core/src/game/java/oracle.rs b/crates/core/src/game/java/oracle.rs
new file mode 100644
index 0000000..08530f8
--- /dev/null
+++ b/crates/core/src/game/java/oracle.rs
@@ -0,0 +1,344 @@
+//! Oracle OpenJDK(`download.java.net` GA):**枚举与下载都走国内镜像目录页**。
+//!
+//! 为什么不能像 Temurin 那样「先拿官方链接、再反解镜像路径」:Oracle 的直链里有一整段
+//! 构建哈希(`/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip`),
+//! 那段**不可推导**(research/06 §4.1 实测)。于是本模块的纪律是:
+//!
+//! > 候选 URL 的 `{ver}` 段与文件名段都必须来自对镜像目录页的解析(`parse_links`),
+//! > 代码里没有任何一处把哈希段拼出来。
+//!
+//! 目录形状(华为云 / 南大与官方完全同构,§4.2):
+//!
+//! ```text
+//! {base}/                      → Apache 列表页:21/ 21.0.1/ 21.0.2/ 27.0.1.0.3/ …
+//! {base}/{ver}/                → 文件列表:openjdk-21.0.2_windows-x64_bin.zip …
+//! {base}/{ver}/{filename}      → 归档本体(大小与官方逐字节一致)
+//! {base}/{ver}/{filename}.sha256 → 64 位哈希纯文本(成对提供)
+//! ```
+//!
+//! 三处实测坑(都写进了实现):
+//!
+//! 1. **华为云对任意不存在的路径都回 200 + 12 434 B 门户 HTML**(假阳性)——所以本模块只信
+//!    「目录页里真的出现了这个文件名」,不信状态码;下载侧另有 `download::http::html_mirror_reason`
+//!    兜一层(Content-Type 非 HTML + Content-Length 对拍)。
+//! 2. **只发「首个 GA + ≤2 个 update」**:21 线最高只到 21.0.2,别当补丁源用。
+//! 3. **目录页给的大小是四舍五入的**(`192 MB`)→ 不可当 `expected_size`,本模块一律填 0,
+//!    校验交给成对的 `.sha256`。
+
+use super::error::{JavaError, JavaErrorKind};
+use super::net;
+use super::release::{parse_sha256_text, JdkArchive, JdkRelease};
+use super::target::AdoptiumTarget;
+use super::vendor::JdkVendor;
+use crate::download::MirrorTable;
+
+/// Oracle 的 os 段(Adoptium 的 `mac` 在 Oracle 里叫 `macos`)。
+#[must_use]
+pub fn oracle_os(target: AdoptiumTarget) -> &'static str {
+    match target.os {
+        "mac" => "macos",
+        other => other,
+    }
+}
+
+/// Oracle 的 arch 段(`x86-32` → `x86`)。
+#[must_use]
+pub fn oracle_arch(target: AdoptiumTarget) -> &'static str {
+    match target.arch {
+        "x86-32" => "x86",
+        other => other,
+    }
+}
+
+/// 目录页 → 本级条目名列表(去掉尾部 `/`、跳过 `../` 与绝对链接,去重保序)。
+#[must_use]
+pub fn parse_links(html: &str) -> Vec {
+    let mut out: Vec = Vec::new();
+    let mut rest = html;
+    while let Some(start) = rest.find("href=\"") {
+        let after = &rest[start + "href=\"".len()..];
+        let Some(end) = after.find('"') else {
+            break;
+        };
+        let value = after[..end].trim();
+        rest = &after[end..];
+        if value.is_empty()
+            || value.starts_with("http")
+            || value.starts_with("//")
+            || value.starts_with('?')
+            || value.contains('#')
+        {
+            continue;
+        }
+        let name = value.trim_end_matches('/');
+        let Some(name) = name.rsplit('/').next() else {
+            continue;
+        };
+        if name.is_empty() || name == "." || name == ".." {
+            continue;
+        }
+        if !out.iter().any(|known| known == name) {
+            out.push(name.to_string());
+        }
+    }
+    out
+}
+
+/// 版本串 → 可比较的数字段(`21.0.2` → `[21, 0, 2]`,`27.0.1.0.3` → `[27,0,1,0,3]`)。
+#[must_use]
+pub fn version_key(version: &str) -> Option> {
+    let mut parts = Vec::new();
+    for segment in version.split('.') {
+        parts.push(segment.parse::().ok()?);
+    }
+    (!parts.is_empty()).then_some(parts)
+}
+
+/// 目录页里属于 `major` 的版本串,按版本**降序**(最新在前)。
+#[must_use]
+pub fn versions_for(html: &str, major: u32) -> Vec {
+    let mut found: Vec<(Vec, String)> = parse_links(html)
+        .into_iter()
+        .filter_map(|name| {
+            let key = version_key(&name)?;
+            (key.first() == Some(&u64::from(major))).then_some((key, name))
+        })
+        .collect();
+    found.sort_by(|left, right| right.0.cmp(&left.0));
+    found.into_iter().map(|(_, name)| name).collect()
+}
+
+/// 取 `major` 线的最新 GA 版本串。
+#[must_use]
+pub fn pick_version(html: &str, major: u32) -> Option {
+    versions_for(html, major).into_iter().next()
+}
+
+/// 期望文件名(Oracle 的命名是**模板**,哈希只在目录层级上,不在文件名里)。
+#[must_use]
+pub fn archive_name(version: &str, target: AdoptiumTarget) -> String {
+    format!(
+        "openjdk-{version}_{}-{}_bin.{}",
+        oracle_os(target),
+        oracle_arch(target),
+        target.archive_ext()
+    )
+}
+
+/// `{base}/{ver}/{filename}`。
+#[must_use]
+pub fn archive_url(base: &str, version: &str, filename: &str) -> String {
+    format!("{}/{version}/{filename}", base.trim_end_matches('/'))
+}
+
+/// 归档旁的 `.sha256` 地址。
+#[must_use]
+pub fn checksum_url(base: &str, version: &str, filename: &str) -> String {
+    format!("{}.sha256", archive_url(base, version, filename))
+}
+
+/// 目录页里是否有这个文件(**唯一**的「存在」判据:不信状态码)。
+#[must_use]
+pub fn find_archive(html: &str, filename: &str) -> bool {
+    parse_links(html).iter().any(|name| name == filename)
+}
+
+/// 查一次「某大版本的最新 Oracle OpenJDK GA」。
+///
+/// 按 `mirrors.json` 里 `oracle_openjdk` 的启用顺序逐个镜像试(国内:华为云 → 南大),
+/// 每个镜像三跳:目录页 → 版本目录页 → `.sha256`。任何一跳失败就记下原因换下一个镜像。
+///
+/// # Errors
+///
+/// 所有镜像都失败(没有该大版本 / 目录页不可解析 / 缺 sha256)时返回错误,
+/// 由回退链决定降级到下一家发行版。
+pub async fn fetch_latest(
+    client: &reqwest::Client,
+    table: &MirrorTable,
+    major: u32,
+    target: AdoptiumTarget,
+) -> Result {
+    let mirrors: Vec<_> = table.oracle_mirrors().collect();
+    if mirrors.is_empty() {
+        return Err(JavaError::new(
+            JavaErrorKind::Metadata,
+            "镜像清单里没有启用的 oracle_openjdk 源",
+        )
+        .with_hint("`mirrors.json` 的 `oracle_openjdk` 表需要至少一个 enabled 条目"));
+    }
+    let mut reasons: Vec = Vec::new();
+    for mirror in mirrors {
+        match fetch_from(client, mirror.base(), major, target).await {
+            Ok(release) => return Ok(release),
+            Err(error) => reasons.push(format!("{}:{}", mirror.name, error.message())),
+        }
+    }
+    Err(JavaError::new(
+        JavaErrorKind::Network,
+        format!(
+            "Oracle OpenJDK {major} 在所有镜像上都取不到:{}",
+            reasons.join(";")
+        ),
+    )
+    .with_hint("Oracle 的 21 线只到 21.0.2(只发首个 GA + ≤2 update);换 17/25 或改用 temurin"))
+}
+
+/// 单个镜像的完整三跳查询。
+async fn fetch_from(
+    client: &reqwest::Client,
+    base: &str,
+    major: u32,
+    target: AdoptiumTarget,
+) -> Result {
+    let index = net::get_text(
+        client,
+        &format!("{}/", base.trim_end_matches('/')),
+        "Oracle 镜像根目录",
+    )
+    .await?;
+    let version = pick_version(&index, major).ok_or_else(|| {
+        JavaError::new(
+            JavaErrorKind::Metadata,
+            format!(
+                "镜像目录里没有 {major} 线的 Oracle OpenJDK(目录页 {} 项)",
+                parse_links(&index).len()
+            ),
+        )
+    })?;
+    let filename = archive_name(&version, target);
+    let listing = net::get_text(
+        client,
+        &format!("{}/{version}/", base.trim_end_matches('/')),
+        "Oracle 版本目录",
+    )
+    .await?;
+    if !find_archive(&listing, &filename) {
+        return Err(JavaError::new(
+            JavaErrorKind::Metadata,
+            format!("{version} 目录里没有 {filename}(该平台没有产物,或目录页不可解析)"),
+        ));
+    }
+    let sha_text = net::get_text(
+        client,
+        &checksum_url(base, &version, &filename),
+        "Oracle 归档 sha256",
+    )
+    .await?;
+    let sha256 = parse_sha256_text(&sha_text).ok_or_else(|| {
+        JavaError::new(
+            JavaErrorKind::Integrity,
+            format!("{filename} 的 .sha256 不是 64 位十六进制"),
+        )
+    })?;
+    Ok(JdkRelease {
+        vendor: JdkVendor::Oracle,
+        major,
+        version: version.clone(),
+        semver: None,
+        release_name: version.clone(),
+        archive: JdkArchive {
+            name: filename.clone(),
+            url: archive_url(base, &version, &filename),
+            sha256,
+            // 目录页给的是四舍五入的大小(192 MB),不能当 expected_size。
+            size: 0,
+        },
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{
+        archive_name, archive_url, checksum_url, find_archive, oracle_os, parse_links,
+        pick_version, version_key, versions_for,
+    };
+    use crate::game::java::target::AdoptiumTarget;
+
+    /// 华为云 `/openjdk/` 的真实目录页片段(2026-10-30 抓取,`
` 列表)。
+    const INDEX: &str = r#"
Name         Last modified     Size

+10/          22-Aug-2021 15:18 -
+17.0.2/      22-Aug-2021 15:19 -
+21/          22-Aug-2021 15:19 -
+21.0.1/      22-Aug-2021 15:19 -
+21.0.2/      06-Jan-2024 15:26 -
+27.0.1.0.3/ 06-Jan-2026 15:26 -
+../
+
"#; + + /// 华为云 `/openjdk/21.0.2/` 的真实文件列表片段。 + const LISTING: &str = r#"
../
+openjdk-21.0.2_linux-x64_bin.tar.gz 194.18 MB
+openjdk-21.0.2_windows-x64_bin.zip 192 MB
+
"#; + + const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64"); + + #[test] + fn directory_links_are_scraped_without_any_hash_segment() { + let links = parse_links(INDEX); + assert!(links.contains(&"21.0.2".to_string())); + assert!(!links.iter().any(|name| name == "..")); + // Oracle 直链里的构建哈希(f2283984656d49d69e91c558476027ac)在镜像目录里根本不存在: + // 目录层级只有 `{ver}/`,所以实现里没有任何拼哈希的位置。 + assert!(!links.iter().any(|name| name.len() == 32)); + } + + #[test] + fn newest_version_of_a_line_wins_and_ordering_is_numeric() { + assert_eq!( + versions_for(INDEX, 21), + vec!["21.0.2".to_string(), "21.0.1".to_string(), "21".to_string()] + ); + assert_eq!(pick_version(INDEX, 21).as_deref(), Some("21.0.2")); + // 27.0.1.0.3 是四段+,比 27.0.1 新(字符串比较会得出相反结论)。 + assert_eq!(version_key("27.0.1.0.3"), Some(vec![27, 0, 1, 0, 3])); + assert_eq!(version_key("27.0.1"), Some(vec![27, 0, 1])); + assert!(version_key("27.0.1") < version_key("27.0.1.0.3")); + assert_eq!(pick_version(INDEX, 8), None); + } + + #[test] + fn filename_template_matches_the_archive_on_every_platform() { + assert_eq!( + archive_name("21.0.2", WINDOWS), + "openjdk-21.0.2_windows-x64_bin.zip" + ); + assert_eq!( + archive_name("21.0.2", AdoptiumTarget::new("linux", "aarch64")), + "openjdk-21.0.2_linux-aarch64_bin.tar.gz" + ); + assert_eq!( + archive_name("17.0.2", AdoptiumTarget::new("mac", "x64")), + "openjdk-17.0.2_macos-x64_bin.tar.gz" + ); + assert_eq!(oracle_os(AdoptiumTarget::new("mac", "x64")), "macos"); + assert_eq!(oracle_os(WINDOWS), "windows"); + } + + #[test] + fn archive_is_only_published_when_it_really_appears_in_the_listing() { + let filename = archive_name("21.0.2", WINDOWS); + assert!(find_archive(LISTING, &filename)); + // 华为云的门户兜底页(任意路径 200 + HTML)里没有这个链接 → 必须判为不存在。 + assert!(!find_archive( + "mirrors 门户", + &filename + )); + assert_eq!( + archive_url( + "https://mirrors.huaweicloud.com/openjdk/", + "21.0.2", + &filename + ), + format!("https://mirrors.huaweicloud.com/openjdk/21.0.2/{filename}") + ); + assert_eq!( + checksum_url( + "https://mirrors.huaweicloud.com/openjdk", + "21.0.2", + &filename + ), + format!("https://mirrors.huaweicloud.com/openjdk/21.0.2/{filename}.sha256") + ); + } +} diff --git a/crates/core/src/game/java/probe.rs b/crates/core/src/game/java/probe.rs new file mode 100644 index 0000000..5dee6eb --- /dev/null +++ b/crates/core/src/game/java/probe.rs @@ -0,0 +1,370 @@ +//! Java 运行时探测:跑一次 `java -version`、扫托管目录、列系统候选。 +//! +//! 探测动作只有两种:读托管目录(`.ULL/data/java-managed/jdk--/`) +//! 与跑 `java -version`(输出在 stderr,主版本号从引号里的版本号解析, +//! `1.8.0_402` 这种老形态换算成 8)。 +//! +//! 「选哪个 Java 启动」不在这里,而在 [`super::select`]——那边是**纯函数**, +//! 候选运行时由调用方注入,测试因此不需要真的装三个 JDK。 + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use super::extract::{java_binary_name, read_stamp}; +use super::vendor::JdkVendor; + +/// 缺省 Java 主版本(探测不到任何东西时的保守值,D13:JDK 17 基准)。 +pub const DEFAULT_MAJOR: u32 = 17; + +/// 托管目录名前缀。 +pub const MANAGED_PREFIX: &str = "jdk-"; + +/// 一个 Java 运行时的来源。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum JavaOrigin { + /// 实例覆盖(`--java` 参数或实例档案的 `javaPath`)。 + Instance, + /// 托管目录(`ull java install` 装的)。 + Managed, + /// `JAVA_HOME`。 + JavaHome, + /// `PATH` 上的 `java`。 + Path, + /// 标准安装位置扫出来的(MSI 装的 Temurin / 官方启动器的 `runtime/` / 注册表等)。 + System, +} + +impl JavaOrigin { + /// 稳定字符串 id(`--json` 的 `source` 字段)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Instance => "instance", + Self::Managed => "managed", + Self::JavaHome => "JAVA_HOME", + Self::Path => "PATH", + Self::System => "system", + } + } + + /// 展示类别(`--json` 的 `source_category`):GUI 用它分组,不必区分 `JAVA_HOME`/`PATH`。 + #[must_use] + pub const fn category(self) -> &'static str { + match self { + Self::Instance => "instance", + Self::Managed => "managed", + Self::JavaHome | Self::Path => "env", + Self::System => "system", + } + } + + /// 同档内的优先序(小者优先)。 + /// + /// 「环境变量」先于「扫盘」:用户显式设过的 `JAVA_HOME`/`PATH` 是意图表达, + /// 盘上扫出来的只是候选(HMCL/PCL 也是这个顺序)。 + #[must_use] + pub const fn rank(self) -> u8 { + match self { + Self::Instance => 0, + Self::Managed => 1, + Self::JavaHome => 2, + Self::Path => 3, + Self::System => 4, + } + } +} + +/// 一个探测到的 Java 运行时。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JavaRuntime { + /// 可执行文件。 + pub binary: PathBuf, + /// 来源。 + pub origin: JavaOrigin, + /// `java -version` 的首行(含 `version` 字样)。 + pub version: Option, + /// 主版本号(`1.8.0_402` → 8)。 + pub major: Option, + /// 位数(32 / 64;探测不到时按宿主)。 + pub bits: u32, + /// 托管目录里的供应商(`temurin`);其它来源为 `None`。 + pub vendor: Option, +} + +impl JavaRuntime { + /// 探测不出来时给个「版本未知」的占位。 + #[must_use] + pub fn unknown(binary: impl Into, origin: JavaOrigin) -> Self { + Self { + binary: binary.into(), + origin, + version: None, + major: None, + bits: host_bits(), + vendor: None, + } + } + + /// 主版本号(未知时退回 [`DEFAULT_MAJOR`])。 + #[must_use] + pub fn major_or_default(&self) -> u32 { + self.major.unwrap_or(DEFAULT_MAJOR) + } +} + +/// 跑一次 `java -version` 填满运行时信息(探测失败不报错,只留空字段)。 +/// +/// 失败不报错的理由:`ull java list` 要能列出「探测不到的项」而不是整体失败, +/// 而启动路径上探测失败会自然退化为系统默认 Java(见 [`super::select::resolve`])。 +#[must_use] +pub fn probe(binary: &Path, origin: JavaOrigin) -> JavaRuntime { + let mut runtime = JavaRuntime::unknown(binary.to_path_buf(), origin); + let Ok(output) = Command::new(binary).arg("-version").output() else { + return runtime; + }; + let mut text = String::from_utf8_lossy(&output.stderr).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stdout)); + if let Some((line, major)) = parse_version_output(&text) { + runtime.major = Some(major); + runtime.version = Some(line); + runtime.bits = bits_of(&text); + } + runtime +} + +/// 扫描一个安装根里的托管 JDK,逐个探测。 +/// +/// **两种布局都要认**(D23 之后同时存在): +/// +/// ```text +/// /jdk--/bin/java Portable(v1 布局,M33 定下,永不改) +/// ///bin/java Standard / Custom(D23 的 <发行方>/<版本>) +/// ``` +/// +/// 只认带戳记的目录:托管区是我们自己写的地方,但没有戳记的子目录可能是用户塞进来的 +/// 别的东西,不去猜它。`major` 一律以 `java -version` 为准(戳记只用于幂等判定)。 +#[must_use] +pub fn discover_managed(managed_root: &Path) -> Vec { + let mut found: Vec = Vec::new(); + let Ok(entries) = std::fs::read_dir(managed_root) else { + return found; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if !path.is_dir() || name.starts_with('.') { + continue; + } + if let Some(runtime) = probe_managed_dir(&path, parse_managed_dir(&name).map(|(_, v)| v)) { + found.push(runtime); + continue; + } + // 两层布局:`/`——vendor 名取自父目录名。 + if JdkVendor::parse(&name).is_some() { + let Ok(children) = std::fs::read_dir(&path) else { + continue; + }; + for child in children.flatten() { + let child_path = child.path(); + if let Some(runtime) = probe_managed_dir(&child_path, Some(&name)) { + found.push(runtime); + } + } + } + } + sort_runtimes(&mut found); + found +} + +/// 扫多个安装根(Portable 数据根 + Standard/Custom 落点),按 `bin` 路径去重。 +#[must_use] +pub fn discover_managed_roots(roots: &[PathBuf]) -> Vec { + let mut found: Vec = Vec::new(); + for root in roots { + for runtime in discover_managed(root) { + if !found.iter().any(|known| known.binary == runtime.binary) { + found.push(runtime); + } + } + } + sort_runtimes(&mut found); + found +} + +/// 探测一个候选托管目录(有戳记 + `bin/java` 才算数)。 +fn probe_managed_dir(path: &Path, vendor: Option<&str>) -> Option { + if !path.is_dir() || read_stamp(path).is_none() { + return None; + } + let binary = path.join("bin").join(java_binary_name()); + if !binary.is_file() { + return None; + } + let mut runtime = probe(&binary, JavaOrigin::Managed); + runtime.vendor = vendor.map(str::to_string); + Some(runtime) +} + +/// 大版本升序,输出稳定(同 major 按路径)。 +fn sort_runtimes(found: &mut [JavaRuntime]) { + found.sort_by(|left, right| { + left.major + .cmp(&right.major) + .then_with(|| left.binary.cmp(&right.binary)) + }); +} + +/// 系统来源的候选:`JAVA_HOME` 与 `PATH` 上的 `java`(都探测一次)。 +/// +/// `PATH` 上的那一项会先**解析成绝对路径**再探测:否则它与扫盘结果(绝对路径)看起来是 +/// 两个不同的文件,`java list` 会把同一个 JDK 列两次。 +#[must_use] +pub fn system_candidates() -> Vec { + let mut found = Vec::new(); + if let Some(home) = std::env::var_os("JAVA_HOME") { + let binary = PathBuf::from(home).join("bin").join(java_binary_name()); + if binary.is_file() { + found.push(probe(&binary, JavaOrigin::JavaHome)); + } + } + let name = java_binary_name(); + let binary = resolve_on_path(name).unwrap_or_else(|| PathBuf::from(name)); + found.push(probe(&binary, JavaOrigin::Path)); + found +} + +/// 在 `PATH` 里找某个可执行文件的绝对路径(找不到返回 `None`)。 +#[must_use] +pub fn resolve_on_path(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join(name)) + .find(|candidate| candidate.is_file()) +} + +/// 托管目录名(`jdk-21-temurin`)。 +#[must_use] +pub fn managed_dir_name(major: u32, vendor: &str) -> String { + format!("{MANAGED_PREFIX}{major}-{vendor}") +} + +/// 反解托管目录名 →(大版本,供应商)。 +#[must_use] +pub fn parse_managed_dir(name: &str) -> Option<(u32, &str)> { + let rest = name.strip_prefix(MANAGED_PREFIX)?; + let (major, vendor) = rest.split_once('-')?; + let major: u32 = major.parse().ok()?; + (!vendor.is_empty()).then_some((major, vendor)) +} + +/// 从 `java -version` 输出里取(含 `version` 的首行,主版本号)。 +#[must_use] +pub fn parse_version_output(text: &str) -> Option<(String, u32)> { + let line = text + .lines() + .find(|line| line.contains("version")) + .or_else(|| text.lines().find(|line| !line.trim().is_empty()))? + .trim(); + let version = quoted_version(line).or_else(|| bare_version(line))?; + let major = major_of(&version)?; + Some((line.to_string(), major)) +} + +/// 引号里的版本号(`openjdk version "21.0.13" 2026-01-20 LTS`)。 +fn quoted_version(line: &str) -> Option { + let start = line.find('"')? + 1; + let rest = &line[start..]; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} + +/// 无引号形态(`openjdk 25.0.3 2026-04-21`)。 +/// +/// 「含 `version` 的那一段之后逐个试」优先——避开 `Java(TM) SE Runtime` 这类噪声; +/// 没有 `version` 字样时从头试(部分精简发行版就这么打印)。 +fn bare_version(line: &str) -> Option { + let tokens: Vec<&str> = line.split_whitespace().collect(); + let start = tokens + .iter() + .position(|token| token.contains("version")) + .map_or(0, |position| position + 1); + tokens[start..].iter().find_map(|token| { + let candidate = token.trim_matches(['"', ',', '(', ')']); + (candidate.contains('.') && major_of(candidate).is_some()).then(|| candidate.to_string()) + }) +} + +/// 版本号 → 主版本(`1.8.0_402` → 8,`21.0.13` → 21)。 +#[must_use] +pub fn major_of(version: &str) -> Option { + let mut parts = version.split(['.', '_', '-']); + let first = parts.next()?.parse::().ok()?; + if first == 1 { + parts.next()?.parse::().ok() + } else { + Some(first) + } +} + +/// `java -version` 输出里的位数(`64-Bit` / `32-Bit`;读不到按宿主)。 +#[must_use] +pub fn bits_of(text: &str) -> u32 { + let lowered = text.to_ascii_lowercase(); + if lowered.contains("64-bit") { + 64 + } else if lowered.contains("32-bit") { + 32 + } else { + host_bits() + } +} + +/// 宿主指针宽度(32 / 64)。 +#[must_use] +pub const fn host_bits() -> u32 { + if cfg!(target_pointer_width = "64") { + 64 + } else { + 32 + } +} + +#[cfg(test)] +mod tests { + use super::{bits_of, major_of, managed_dir_name, parse_managed_dir, parse_version_output}; + + #[test] + fn parses_modern_and_legacy_version_lines() { + let modern = + parse_version_output("openjdk version \"21.0.13\" 2026-01-20 LTS\nOpenJDK 64-Bit"); + assert_eq!(modern.map(|(_, major)| major), Some(21)); + let legacy = parse_version_output("java version \"1.8.0_402\"\nJava(TM) SE Runtime"); + assert_eq!(legacy.map(|(_, major)| major), Some(8)); + let bare = parse_version_output("openjdk 25.0.3 2026-04-21\nOpenJDK 64-Bit Server VM"); + assert_eq!(bare.map(|(_, major)| major), Some(25)); + assert!(parse_version_output("no java here").is_none()); + } + + #[test] + fn parses_bit_width() { + assert_eq!(bits_of("OpenJDK 64-Bit Server VM (build 25.0.3)"), 64); + assert_eq!(bits_of("Java HotSpot(TM) Client VM (32-Bit)"), 32); + } + + #[test] + fn major_of_handles_legacy_scheme() { + assert_eq!(major_of("1.8.0_402"), Some(8)); + assert_eq!(major_of("17.0.13+11"), Some(17)); + assert_eq!(major_of("not-a-version"), None); + } + + #[test] + fn managed_dir_names_round_trip() { + assert_eq!(parse_managed_dir("jdk-21-temurin"), Some((21, "temurin"))); + assert_eq!(parse_managed_dir("jdk-8-temurin"), Some((8, "temurin"))); + assert_eq!(parse_managed_dir(".staging-jdk-21-temurin"), None); + assert_eq!(parse_managed_dir("java-21-temurin"), None); + assert_eq!(parse_managed_dir("jdk-x-temurin"), None); + assert_eq!(managed_dir_name(25, "temurin"), "jdk-25-temurin"); + } +} diff --git a/crates/core/src/game/java/release.rs b/crates/core/src/game/java/release.rs new file mode 100644 index 0000000..8e80ec3 --- /dev/null +++ b/crates/core/src/game/java/release.rs @@ -0,0 +1,170 @@ +//! 一次版本查询的统一产物:一个可下载的 JDK 归档(四家发行版共用形态)。 +//! +//! 各家的**版本号语义不同**(research/06 §5.3 第 3 条),所以这里只存「vendor 原生的版本串」 +//! 而不是硬拆成 semver 四段: +//! +//! | 发行版 | 版本串 | 出处 | +//! | --- | --- | --- | +//! | Temurin | `21.0.13+9`(三段+构建号) | Adoptium `release_name` | +//! | Oracle OpenJDK | `21.0.2`(三段,只到 GA + ≤2 update) | 镜像目录名 | +//! | Microsoft OpenJDK | `21.0.13.0.2`(四段/五段),11 线是 `11.0.32.1` | `aka.ms` 文件名 | +//! | Azul Zulu | `21.0.13+3`(三段+构建号,文件名里还有 `21.0.13.0.3` 另一种写法) | Zulu API | +//! +//! 目录名、戳记、`--json` 都直接用这个串(`location::sanitize_version` 只做字符白名单)。 + +use super::vendor::JdkVendor; + +/// 一个可下载的 JDK 归档。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JdkArchive { + /// 产物文件名(`OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip`)。 + pub name: String, + /// 官方下载链接(镜像展开的输入;Oracle / MS / Zulu 已经是最终链接)。 + pub url: String, + /// SHA-256(小写十六进制)。 + pub sha256: String, + /// 字节数(`0` = 上游没给可信大小,引擎跳过长度对拍只验哈希)。 + pub size: u64, +} + +/// 一次版本查询的结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JdkRelease { + /// 发行版。 + pub vendor: JdkVendor, + /// 大版本号(`21`)。 + pub major: u32, + /// vendor 原生的完整版本串(目录名与展示都用它)。 + pub version: String, + /// Temurin 系才有的 semver(其它家为 `None`)。 + pub semver: Option, + /// release 名(Temurin `jdk-21.0.13+9`;其它家用版本串)。 + pub release_name: String, + /// 归档。 + pub archive: JdkArchive, +} + +impl JdkRelease { + /// 是否就是这个发行版 + 大版本的产物。 + #[must_use] + pub fn matches(&self, vendor: JdkVendor, major: u32) -> bool { + self.vendor == vendor && self.major == major + } + + /// sha256(幂等判据用)。 + #[must_use] + pub fn sha256(&self) -> &str { + &self.archive.sha256 + } + + /// 人读一行(`Temurin 21.0.13+9 · OpenJDK21U-…zip`)。 + #[must_use] + pub fn summary(&self) -> String { + format!( + "{} {} · {}", + self.vendor.display(), + self.version, + self.archive.name + ) + } + + /// `--json` 的归档块(四家共用一个形状)。 + #[must_use] + pub fn archive_json(&self) -> serde_json::Value { + serde_json::json!({ + "vendor": self.vendor.id(), + "version": self.version, + "name": self.archive.name, + "url": self.archive.url, + "sha256": self.archive.sha256, + "size": self.archive.size, + }) + } +} + +/// 从 `release_name`(Temurin 的 `jdk-21.0.13+9`)取版本串。 +#[must_use] +pub fn version_from_release_name(release_name: &str) -> Option { + let trimmed = release_name.trim(); + let stripped = trimmed.strip_prefix("jdk-").unwrap_or(trimmed); + (!stripped.is_empty()).then(|| stripped.to_string()) +} + +/// 解析 sha256 文本(`.sha256` / `.sha256sum.txt` / 裸哈希都吃),校验 64 位十六进制。 +#[must_use] +pub fn parse_sha256_text(text: &str) -> Option { + for token in text.split_whitespace() { + let lowered = token.trim().to_ascii_lowercase(); + let candidate = lowered.strip_prefix('*').unwrap_or(lowered.as_str()); + if candidate.len() == 64 && candidate.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Some(candidate.to_string()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::{parse_sha256_text, version_from_release_name, JdkArchive, JdkRelease}; + use crate::game::java::vendor::JdkVendor; + + fn release() -> JdkRelease { + JdkRelease { + vendor: JdkVendor::Oracle, + major: 21, + version: "21.0.2".to_string(), + semver: None, + release_name: "21.0.2".to_string(), + archive: JdkArchive { + name: "openjdk-21.0.2_windows-x64_bin.zip".to_string(), + url: "https://mirrors.huaweicloud.com/openjdk/21.0.2/openjdk-21.0.2_windows-x64_bin.zip" + .to_string(), + sha256: "b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664" + .to_string(), + size: 0, + }, + } + } + + #[test] + fn release_carries_vendor_and_native_version() { + let item = release(); + assert!(item.matches(JdkVendor::Oracle, 21)); + assert!(!item.matches(JdkVendor::Microsoft, 21)); + assert!(item.summary().contains("Oracle OpenJDK 21.0.2")); + assert_eq!(item.archive_json()["vendor"], "oracle"); + } + + #[test] + fn sha256_texts_from_every_vendor_parse_the_same_way() { + // Huawei 的 .sha256:只有 64 位哈希,没有文件名。 + assert_eq!( + parse_sha256_text("b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664\n") + .as_deref(), + Some("b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664") + ); + // MS 的 .sha256sum.txt:` `,并且带大写与 CRLF。 + assert_eq!( + parse_sha256_text( + "6ECFA864AE0A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4E5F60718293A4 microsoft-jdk-21.0.13-windows-x64.zip\r\n" + ) + .as_deref(), + Some("6ecfa864ae0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4") + ); + assert_eq!(parse_sha256_text("404"), None); + assert_eq!(parse_sha256_text("deadbeef"), None); + } + + #[test] + fn version_falls_back_to_the_release_name() { + assert_eq!( + version_from_release_name("jdk-21.0.13+9").as_deref(), + Some("21.0.13+9") + ); + assert_eq!( + version_from_release_name("21.0.13+9").as_deref(), + Some("21.0.13+9") + ); + assert_eq!(version_from_release_name(""), None); + } +} diff --git a/crates/core/src/game/java/select.rs b/crates/core/src/game/java/select.rs new file mode 100644 index 0000000..496a0fd --- /dev/null +++ b/crates/core/src/game/java/select.rs @@ -0,0 +1,160 @@ +//! 「启动选哪个 Java」的**纯**优先链(D13)。 +//! +//! 优先级:实例覆盖(`--java` / 档案 `javaPath`)> 托管目录里**满足要求**的 JDK > +//! `JAVA_HOME` > `PATH`。实例覆盖由调用方单独判(用户明确指定了就不参与打分), +//! 这里只处理「一堆已探测的候选 + 一个要求」的挑选。 +//! +//! 挑选规则(比「机械按来源排序」更贴近用户预期): +//! +//! 1. **满足要求的候选优先**——1.12.2 只能用 Java 8,托管的 21 再新也不能选; +//! 2. 同档内按来源:托管 > `JAVA_HOME` > `PATH`(D13:托管目录是启动器的地盘, +//! 用户 PATH 上的 java 可能是另一个 JDK 的残留); +//! 3. 全都不满足时仍给一个(按来源优先取最好那个),并报不符——**提示而不是拒绝**。 +//! +//! 抽成纯函数是为了可测:测试注入「托管 21 + PATH 8」这类组合,不必真装 JDK。 + +use super::mapping::{check, JavaMismatch, JavaRequirement}; +use super::probe::{JavaRuntime, DEFAULT_MAJOR}; + +/// 一次启动的 Java 选择结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JavaResolution { + /// 选中的运行时(一个候选都没有时 `None`)。 + pub runtime: Option, + /// 目标版本对 Java 的要求。 + pub required: JavaRequirement, + /// 不符说明(`None` = 满足要求,或压根没探测到 Java)。 + pub mismatch: Option, +} + +impl JavaResolution { + /// 生效的主版本号(没有运行时按缺省值)。 + #[must_use] + pub fn major(&self) -> u32 { + self.runtime + .as_ref() + .map_or(DEFAULT_MAJOR, JavaRuntime::major_or_default) + } + + /// 是否满足要求(没有运行时视为「不知道」,返回 `true`)。 + #[must_use] + pub fn is_satisfied(&self) -> bool { + self.mismatch.is_none() + } +} + +/// 从候选里挑一个:满足要求的优先,同档内按来源优先序。 +#[must_use] +pub fn pick<'a>( + candidates: &'a [JavaRuntime], + required: &JavaRequirement, +) -> Option<&'a JavaRuntime> { + candidates.iter().min_by_key(|runtime| { + let ok = runtime + .major + .is_some_and(|major| required.satisfied_by(major)); + // 先排「不满足」(`ok=false` 的 key 大),再排来源优先序。 + (!ok, runtime.origin.rank()) + }) +} + +/// 挑 + 判不符。 +#[must_use] +pub fn resolve(candidates: &[JavaRuntime], required: &JavaRequirement) -> JavaResolution { + let runtime = pick(candidates, required).cloned(); + let mismatch = runtime + .as_ref() + .and_then(|runtime| runtime.major) + .and_then(|major| check(major, required)); + JavaResolution { + runtime, + required: required.clone(), + mismatch, + } +} + +#[cfg(test)] +mod tests { + use super::resolve; + use crate::game::java::mapping::{JavaRequirement, RequirementSource}; + use crate::game::java::probe::{JavaOrigin, JavaRuntime, DEFAULT_MAJOR}; + + fn runtime(major: u32, origin: JavaOrigin) -> JavaRuntime { + JavaRuntime { + binary: std::path::PathBuf::from(format!("java{major}")), + origin, + version: Some(format!("openjdk version \"{major}.0.1\"")), + major: Some(major), + bits: 64, + vendor: None, + } + } + + fn at_least(minimum: u32) -> JavaRequirement { + JavaRequirement::new(minimum, None, RequirementSource::Table) + } + + #[test] + fn satisfying_managed_runtime_beats_mismatching_system_java() { + let candidates = [ + runtime(8, JavaOrigin::Path), + runtime(21, JavaOrigin::Managed), + ]; + let picked = resolve(&candidates, &at_least(17)); + assert_eq!(picked.major(), 21); + assert!(picked.is_satisfied()); + } + + #[test] + fn satisfying_system_java_beats_mismatching_managed() { + // 1.12.2 只能 Java 8:托管的 21 不满足,PATH 上的 8 满足 → 选 8。 + let required = JavaRequirement::new(8, Some(8), RequirementSource::Table); + let candidates = [ + runtime(21, JavaOrigin::Managed), + runtime(8, JavaOrigin::Path), + ]; + let picked = resolve(&candidates, &required); + assert_eq!(picked.major(), 8); + assert!(picked.is_satisfied()); + } + + #[test] + fn managed_wins_when_both_satisfy() { + let candidates = [ + runtime(21, JavaOrigin::Path), + runtime(17, JavaOrigin::JavaHome), + runtime(17, JavaOrigin::Managed), + ]; + assert_eq!(resolve(&candidates, &at_least(17)).major(), 17); + } + + #[test] + fn java_home_beats_path_when_both_satisfy() { + let candidates = [ + runtime(21, JavaOrigin::Path), + runtime(21, JavaOrigin::JavaHome), + ]; + let picked = resolve(&candidates, &at_least(21)); + assert_eq!( + picked.runtime.map(|runtime| runtime.origin), + Some(JavaOrigin::JavaHome) + ); + } + + #[test] + fn no_candidate_keeps_the_default_and_reports_no_mismatch() { + let picked = resolve(&[], &at_least(21)); + assert_eq!(picked.major(), DEFAULT_MAJOR); + assert!(picked.runtime.is_none()); + assert!(picked.is_satisfied()); + } + + #[test] + fn mismatch_is_reported_when_nothing_fits() { + let candidates = [runtime(8, JavaOrigin::Path)]; + let picked = resolve(&candidates, &at_least(21)); + assert_eq!(picked.major(), 8); + let mismatch = picked.mismatch.expect("应报不符"); + assert_eq!(mismatch.code(), "java_too_old"); + } +} diff --git a/crates/core/src/game/java/source.rs b/crates/core/src/game/java/source.rs new file mode 100644 index 0000000..112691f --- /dev/null +++ b/crates/core/src/game/java/source.rs @@ -0,0 +1,199 @@ +//! 版本查询的发行版分派 + 回退链记录(D23)。 +//! +//! 两件事: +//! +//! 1. [`fetch`]:把一次「某发行版某大版本的最新 GA」查询分派给对应实现—— +//! Temurin 走 `api.adoptium.net`(v1 既有),Oracle / MS / Zulu 各走自己的源表; +//! 2. [`ChainLog`]:按用户优先级依次尝试时,**每一跳的结果与降级原因**都记下来。 +//! 这份记录直接进 `--json` 与事件流,是「为什么最后装的是 MS 而不是 Temurin」的唯一解释。 +//! +//! 回退的粒度是**发行版级**(D23 原话):版本不存在、镜像全 4xx、校验失败都算这一家失败, +//! 换下一家;同一家内部的镜像换源(TUNA→官方、华为云→南大)仍然归 `core::download` 引擎管。 + +use super::api; +use super::error::JavaError; +use super::microsoft; +use super::oracle; +use super::release::JdkRelease; +use super::target::AdoptiumTarget; +use super::vendor::JdkVendor; +use super::zulu; +use crate::download::DownloadEngine; + +/// 查一次「某发行版某大版本的最新 GA」。 +/// +/// # Errors +/// +/// 平台无产物、清单不可达、该家没有这个大版本、校验缺失时返回错误——调用方据此降级。 +pub async fn fetch( + engine: &DownloadEngine, + vendor: JdkVendor, + major: u32, + target: AdoptiumTarget, +) -> Result { + let client = engine.client(); + let table = engine.mirrors(); + match vendor { + JdkVendor::Temurin => api::fetch_latest(client, major, target).await, + JdkVendor::Oracle => oracle::fetch_latest(client, table, major, target).await, + JdkVendor::Microsoft => { + let table = table.microsoft_openjdk().ok_or_else(|| { + JavaError::new( + super::error::JavaErrorKind::Metadata, + "镜像清单里没有 microsoft_openjdk 表", + ) + .with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin") + })?; + microsoft::fetch_latest(client, table, major, target).await + } + JdkVendor::Zulu => { + let table = table.zulu().ok_or_else(|| { + JavaError::new( + super::error::JavaErrorKind::Metadata, + "镜像清单里没有 zulu 表", + ) + .with_hint("升级 mirrors.json 到 v3,或改用 --vendor temurin") + })?; + zulu::fetch_latest(client, table, major, target).await + } + } +} + +/// 一跳的结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VendorAttempt { + /// 尝试的发行版。 + pub vendor: JdkVendor, + /// 是否命中(成功拿到可下载的归档)。 + pub ok: bool, + /// 命中/降级的一句话原因(`"命中"` / `"没有 8 的 GA(只发 11/17/21/25)"`)。 + pub detail: String, +} + +impl VendorAttempt { + /// `--json` 形状。 + #[must_use] + pub fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "vendor": self.vendor.id(), + "ok": self.ok, + "detail": self.detail, + }) + } +} + +/// 一条回退链的执行记录。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ChainLog { + attempts: Vec, +} + +impl ChainLog { + /// 空记录。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 记一次「跳过」(该家不支持这个大版本,根本没查网络)。 + pub fn skip(&mut self, vendor: JdkVendor, detail: impl Into) { + self.attempts.push(VendorAttempt { + vendor, + ok: false, + detail: detail.into(), + }); + } + + /// 记一次失败(查到了但拿不到、或下载/校验失败)。 + pub fn failed(&mut self, vendor: JdkVendor, detail: impl Into) { + self.attempts.push(VendorAttempt { + vendor, + ok: false, + detail: detail.into(), + }); + } + + /// 记一次命中。 + pub fn hit(&mut self, vendor: JdkVendor, detail: impl Into) { + self.attempts.push(VendorAttempt { + vendor, + ok: true, + detail: detail.into(), + }); + } + + /// 是否已经命中(命中后不再往下试)。 + #[must_use] + pub fn hit_vendor(&self) -> Option { + self.attempts + .iter() + .find(|attempt| attempt.ok) + .map(|attempt| attempt.vendor) + } + + /// 全部记录。 + #[must_use] + pub fn attempts(&self) -> &[VendorAttempt] { + &self.attempts + } + + /// 降级说明(「已跳过」的那些;给事件流与人读告警用)。 + #[must_use] + pub fn degradations(&self) -> Vec { + self.attempts + .iter() + .filter(|attempt| !attempt.ok) + .map(|attempt| format!("{}:{}", attempt.vendor.id(), attempt.detail)) + .collect() + } + + /// 实际尝试了几家(不含跳过)。 + #[must_use] + pub fn tried(&self) -> usize { + self.attempts.len() + } + + /// `--json` 形状。 + #[must_use] + pub fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "hit": self.hit_vendor().map(JdkVendor::id), + "attempts": self.attempts.iter().map(VendorAttempt::to_json).collect::>(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::ChainLog; + use crate::game::java::vendor::JdkVendor; + + #[test] + fn chain_skips_unsupported_majors_and_records_the_reason() { + let mut log = ChainLog::new(); + log.skip(JdkVendor::Oracle, "不支持 8(Oracle archive 从 9 起)"); + log.skip(JdkVendor::Microsoft, "没有 8 的 GA(只发 11/17/21/25)"); + log.hit(JdkVendor::Temurin, "命中 8.0.504+1"); + assert_eq!(log.hit_vendor(), Some(JdkVendor::Temurin)); + let degradations = log.degradations(); + assert_eq!(degradations.len(), 2); + assert!(degradations[1].contains("microsoft")); + assert_eq!(log.attempts().len(), 3); + let json = log.to_json(); + assert_eq!(json["hit"], "temurin"); + assert_eq!(json["attempts"][0]["vendor"], "oracle"); + } + + #[test] + fn failure_reasons_are_kept_for_the_event_stream() { + let mut log = ChainLog::new(); + log.failed(JdkVendor::Temurin, "所有镜像都 4xx"); + log.hit(JdkVendor::Oracle, "命中 21.0.2"); + assert_eq!( + log.degradations(), + vec!["temurin:所有镜像都 4xx".to_string()], + "降级原因必须原样可读(用户要能看懂为什么换了发行版)" + ); + assert_eq!(log.tried(), 2); + } +} diff --git a/crates/core/src/game/java/target.rs b/crates/core/src/game/java/target.rs new file mode 100644 index 0000000..29fe094 --- /dev/null +++ b/crates/core/src/game/java/target.rs @@ -0,0 +1,85 @@ +//! 宿主平台 → Adoptium 请求参数,以及归档形态(Win = zip,Linux/mac = tar.gz)。 +//! +//! Adoptium 的 `os` / `arch` 取值与 Rust 的 `target_os` / `target_arch` 不同形 +//! (`windows`/`mac`/`linux` × `x64`/`aarch64`/`x86-32`……),这里做唯一一次翻译。 +//! 平台差异仍然按项目纪律收敛在 `core::platform` 的语义里,本文件只做**命名映射**, +//! 不写任何能力探测。 + +use super::error::{JavaError, JavaErrorKind}; + +/// 一次 Adoptium 请求的目标。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AdoptiumTarget { + /// Adoptium 的 `os` 段(`windows` / `linux` / `mac`)。 + pub os: &'static str, + /// Adoptium 的 `arch` 段(`x64` / `aarch64` / `x86-32` …)。 + pub arch: &'static str, +} + +impl AdoptiumTarget { + /// 宿主平台(不认识的架构直接拒绝,不猜一个错的产物下来)。 + /// + /// # Errors + /// + /// 当前 OS / 架构没有 Temurin 产物时返回 [`JavaErrorKind::Unsupported`]。 + pub fn host() -> Result { + let os = if cfg!(windows) { + "windows" + } else if cfg!(target_os = "macos") { + "mac" + } else if cfg!(target_os = "linux") { + "linux" + } else { + return Err(JavaError::new( + JavaErrorKind::Unsupported, + format!("当前系统({})没有 Temurin 产物", std::env::consts::OS), + ) + .with_hint("手动指定 JAVA_HOME,或用 `ull launch --java ` 挑已有 JDK")); + }; + let arch = arch_name(std::env::consts::ARCH).ok_or_else(|| { + JavaError::new( + JavaErrorKind::Unsupported, + format!("当前架构({})没有 Temurin 产物", std::env::consts::ARCH), + ) + .with_hint("手动指定 JAVA_HOME,或用 `ull launch --java ` 挑已有 JDK") + })?; + Ok(Self { os, arch }) + } + + /// 构造(测试注入用)。 + #[must_use] + pub const fn new(os: &'static str, arch: &'static str) -> Self { + Self { os, arch } + } + + /// 该平台的产物是不是 zip(Windows 是;Linux/mac 的 Temurin 只发 `.tar.gz`)。 + #[must_use] + pub fn is_zip(self) -> bool { + self.os == "windows" + } + + /// 归档扩展名(拼「期望文件名」与给出「不支持的形态」提示时用)。 + #[must_use] + pub fn archive_ext(self) -> &'static str { + if self.is_zip() { + "zip" + } else { + "tar.gz" + } + } +} + +/// Rust 架构名 → Adoptium 架构名(不认识返回 `None`)。 +#[must_use] +pub fn arch_name(arch: &str) -> Option<&'static str> { + match arch { + "x86_64" => Some("x64"), + "aarch64" => Some("aarch64"), + "x86" | "i686" => Some("x86-32"), + "arm" => Some("arm"), + "ppc64le" => Some("ppc64le"), + "s390x" => Some("s390x"), + "riscv64" => Some("riscv64"), + _ => None, + } +} diff --git a/crates/core/src/game/java/vendor.rs b/crates/core/src/game/java/vendor.rs new file mode 100644 index 0000000..76dd270 --- /dev/null +++ b/crates/core/src/game/java/vendor.rs @@ -0,0 +1,219 @@ +//! JDK 发行版(vendor)抽象与用户可调优先级链(D23)。 +//! +//! 四家发行版的**默认顺序按地区分区**(用户定案 + research/06 §5.1 实测修正): +//! +//! ```text +//! 国内:Temurin(TUNA) → Oracle OpenJDK(华为云→南大) → MS OpenJDK(境外 CDN) → Zulu +//! 国外:Temurin → MS OpenJDK → Oracle OpenJDK → Zulu +//! ``` +//! +//! 国内把 Oracle 提到 MS 之前的依据是实测差 28×(Oracle 国内 11.5 MB/s vs MS 410 KB/s); +//! 用户原话是「Temurin → MS → Oracle」,但同一句话里给了「优先级可调」,所以这只是默认值, +//! 设置键 `java.vendorPriority` 可以整体覆盖(D23 定稿)。 +//! +//! 这里的表只描述**能力与顺序**,不碰网络:怎么查、怎么下在 `oracle` / `microsoft` / +//! `zulu` / `api`(Temurin)四个模块里。 + +/// 设置键 `java.vendorPriority` 的允许值(顺序即默认序的覆盖来源)。 +pub const VENDOR_IDS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"]; + +/// 一家 JDK 发行版。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JdkVendor { + /// Eclipse Temurin(Adoptium),v1 唯一实现。 + Temurin, + /// Oracle OpenJDK(`download.java.net` GA,只发首个 GA + ≤2 个 update)。 + Oracle, + /// Microsoft OpenJDK(`aka.ms` 模板 + VS CDN,无 8)。 + Microsoft, + /// Azul Zulu(第四备选,API 元数据 + CDN 直链)。 + Zulu, +} + +impl JdkVendor { + /// 全部发行版(枚举顺序,不是优先级顺序)。 + pub const ALL: [Self; 4] = [Self::Temurin, Self::Oracle, Self::Microsoft, Self::Zulu]; + + /// 稳定 id(目录名段、`--vendor` 取值、设置值、JSON 字段)。 + #[must_use] + pub const fn id(self) -> &'static str { + match self { + Self::Temurin => "temurin", + Self::Oracle => "oracle", + Self::Microsoft => "microsoft", + Self::Zulu => "zulu", + } + } + + /// 展示名(人读输出 + `java vendors`)。 + #[must_use] + pub const fn display(self) -> &'static str { + match self { + Self::Temurin => "Eclipse Temurin", + Self::Oracle => "Oracle OpenJDK", + Self::Microsoft => "Microsoft OpenJDK", + Self::Zulu => "Azul Zulu", + } + } + + /// 解析 `--vendor` / 设置值(容忍常见别名,大小写不敏感)。 + #[must_use] + pub fn parse(text: &str) -> Option { + match text.trim().to_ascii_lowercase().as_str() { + "temurin" | "adoptium" | "eclipse" | "eclipse-temurin" => Some(Self::Temurin), + "oracle" | "oracle_openjdk" | "openjdk" | "java.net" => Some(Self::Oracle), + "microsoft" | "ms" | "msopenjdk" | "ms_openjdk" | "microsoft-openjdk" => { + Some(Self::Microsoft) + } + "zulu" | "azul" => Some(Self::Zulu), + _ => None, + } + } + + /// 该发行版能不能装这个大版本。 + /// + /// Temurin / Zulu 覆盖 8 起的全部 GA;Oracle 的 archive 从 9 起(8 由 Temurin 负责); + /// MS 只发 11/17/21/25(**已停发 8**,官方页面直接让去下 Temurin 8)。 + #[must_use] + pub const fn supports(self, major: u32) -> bool { + match self { + Self::Temurin | Self::Zulu => major >= 8, + Self::Oracle => major >= 9, + Self::Microsoft => matches!(major, 11 | 17 | 21 | 25), + } + } + + /// 已知在发的 GA 大版本(`java vendors` 展示与设置页提示用;不是硬上限)。 + #[must_use] + pub const fn hint_majors(self) -> &'static [u32] { + match self { + Self::Temurin => &[8, 11, 17, 21, 25], + Self::Oracle => &[17, 21, 25], + Self::Microsoft => &[11, 17, 21, 25], + Self::Zulu => &[8, 11, 17, 21, 25], + } + } + + /// 国内默认序(用户定案 + 实测修正)。 + #[must_use] + pub const fn domestic_default() -> [Self; 4] { + [Self::Temurin, Self::Oracle, Self::Microsoft, Self::Zulu] + } + + /// 国外默认序(用户原话顺序)。 + #[must_use] + pub const fn global_default() -> [Self; 4] { + [Self::Temurin, Self::Microsoft, Self::Oracle, Self::Zulu] + } + + /// 按地区取默认序。 + #[must_use] + pub const fn default_order(domestic: bool) -> [Self; 4] { + if domestic { + Self::domestic_default() + } else { + Self::global_default() + } + } +} + +/// 用户优先级(设置值)→ 实际尝试顺序。 +/// +/// 语义刻意做成「**补集追加**」而不是「必须写全」:用户写了 `["oracle"]` 就只把 oracle 提上来, +/// 其余按地区默认序跟在后面。理由——写全四家是负担,漏写更不该等于「其余不试」。 +#[must_use] +pub fn order(priority: &[JdkVendor], domestic: bool) -> Vec { + let mut out: Vec = Vec::with_capacity(JdkVendor::ALL.len()); + for vendor in priority { + if !out.contains(vendor) { + out.push(*vendor); + } + } + for vendor in JdkVendor::default_order(domestic) { + if !out.contains(&vendor) { + out.push(vendor); + } + } + out +} + +/// 解析设置值里的字符串数组(未知项忽略;顺序保留)。 +#[must_use] +pub fn parse_priority(items: &[String]) -> Vec { + let mut out = Vec::with_capacity(items.len()); + for item in items { + if let Some(vendor) = JdkVendor::parse(item) { + if !out.contains(&vendor) { + out.push(vendor); + } + } + } + out +} + +/// 尝试顺序 → 稳定 id 列表(JSON / 人读输出)。 +#[must_use] +pub fn ids(vendors: &[JdkVendor]) -> Vec<&'static str> { + vendors.iter().map(|vendor| vendor.id()).collect() +} + +#[cfg(test)] +mod tests { + use super::{ids, order, parse_priority, JdkVendor}; + + #[test] + fn domestic_and_global_defaults_follow_d23() { + assert_eq!( + ids(&order(&[], true)), + vec!["temurin", "oracle", "microsoft", "zulu"], + "国内:Oracle 在 MS 之前(实测差 28×)" + ); + assert_eq!( + ids(&order(&[], false)), + vec!["temurin", "microsoft", "oracle", "zulu"], + "国外:维持用户原话 Temurin→MS→Oracle" + ); + } + + #[test] + fn user_priority_leads_and_the_rest_follows_as_a_complement() { + let priority = parse_priority(&["microsoft".to_string()]); + assert_eq!( + ids(&order(&priority, true)), + vec!["microsoft", "temurin", "oracle", "zulu"], + "只写一家也要把其余按默认序补齐" + ); + let full = parse_priority(&["zulu".to_string(), "oracle".to_string(), "zulu".to_string()]); + assert_eq!( + ids(&order(&full, true)), + vec!["zulu", "oracle", "temurin", "microsoft"], + "重复项去重,未列出的追加在后面" + ); + } + + #[test] + fn unknown_setting_values_are_ignored() { + let parsed = parse_priority(&["graalvm".to_string(), "AZUL".to_string()]); + assert_eq!(parsed, vec![JdkVendor::Zulu]); + } + + #[test] + fn vendor_ids_majors_and_aliases() { + assert_eq!(JdkVendor::parse("Adoptium"), Some(JdkVendor::Temurin)); + assert_eq!(JdkVendor::parse("ms"), Some(JdkVendor::Microsoft)); + assert_eq!(JdkVendor::parse("graalvm"), None); + // MS 没有 8:这是「回退链要跳过某家」的真实判据。 + assert!(!JdkVendor::Microsoft.supports(8)); + assert!(JdkVendor::Microsoft.supports(21)); + assert!(JdkVendor::Oracle.supports(9)); + assert!(!JdkVendor::Oracle.supports(8), "8 交给 Temurin"); + assert!(JdkVendor::Temurin.supports(8)); + for vendor in JdkVendor::ALL { + assert!( + vendor.hint_majors().contains(&21), + "{} 应发 21", + vendor.id() + ); + } + } +} diff --git a/crates/core/src/game/java/zulu.rs b/crates/core/src/game/java/zulu.rs new file mode 100644 index 0000000..b572d7f --- /dev/null +++ b/crates/core/src/game/java/zulu.rs @@ -0,0 +1,275 @@ +//! Azul Zulu:**第四备选**(D23)。API 元数据 + CDN 直链,实现刻意简化。 +//! +//! 为什么排最后:国内没有任何 Zulu 镜像(三家云 + 四家校园源全无 `zulu`/`azul` 目录), +//! 只能吃 `cdn.azul.com` 的 3.3 MB/s(211 MB ≈ 64 s);而前三家在国内都有更好的路。 +//! 留这一家的价值是「三家全挂时的最后一条活路」,以及 Temurin/Oracle/MS 都缺某版本时的兜底。 +//! +//! 两跳(research/06 §4.3): +//! +//! ```text +//! GET {api}?java_version=21&os=windows&arch=x64&java_package_type=jdk&archive_type=zip&latest=true +//! → [{ package_uuid, name, java_version, download_url }] # 注意:不含 sha256 +//! GET {detail({uuid} 占位)} → { sha256_hash, size(不可信,差 133 B) } +//! ``` +//! +//! 三条实测约束:①`latest=true` 的响应**没有** `sha256_hash`,必须再取一次详情; +//! ②`size` 字段与真实字节数差 133 B → 一律不信,只填 0;③文件名必须照抄 API 返回值 +//! (`zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip` 里混着三段与四段两种写法,拼不出来)。 + +use serde::Deserialize; + +use super::error::{JavaError, JavaErrorKind}; +use super::net; +use super::release::{JdkArchive, JdkRelease}; +use super::target::AdoptiumTarget; +use super::vendor::JdkVendor; +use crate::download::ZuluTable; + +/// Zulu 的 os 段(`windows` / `macos` / `linux`)。 +#[must_use] +pub fn zulu_os(target: AdoptiumTarget) -> &'static str { + match target.os { + "mac" => "macos", + other => other, + } +} + +/// 包列表查询地址(`latest=true` 只返回最新一个)。 +#[must_use] +pub fn api_url(table: &ZuluTable, major: u32, target: AdoptiumTarget) -> String { + format!( + "{}?java_version={major}&os={}&arch={}&java_package_type=jdk&archive_type=zip&latest=true", + table.api, + zulu_os(target), + target.arch + ) +} + +/// 包详情地址(`{uuid}` 占位替换)。 +#[must_use] +pub fn detail_url(table: &ZuluTable, uuid: &str) -> String { + table.detail.replace("{uuid}", uuid) +} + +/// 列表响应里我们关心的字段。 +/// +/// ⚠️ 真机形态(2026-10-30 实测):`latest=true` **不止返回一条**,而且 `java_version` 是 +/// **数字数组**(`[21,0,13,0,3]`)而不是字符串;同一批里还混着 `-crac-`(CRaC)与 +/// `-fx-`(JavaFX)变体。所以这里按「字段宽进 + 只挑纯 JDK 包」处理,见 [`parse_packages`]。 +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct ZuluPackage { + /// 包 id(取详情用)。 + pub package_uuid: String, + /// 归档文件名(照抄,不拼)。 + pub name: String, + /// JDK 版本(`[21,0,13,0,3]`,Azul 用数组给)。 + #[serde(default)] + pub java_version: Vec, + /// CDN 直链。 + pub download_url: String, +} + +impl ZuluPackage { + /// 版本串(`[21,0,13,0,3]` → `21.0.13.0.3`;空数组时退回文件名)。 + #[must_use] + pub fn version_text(&self) -> String { + if self.java_version.is_empty() { + return self.name.clone(); + } + self.java_version + .iter() + .map(u32::to_string) + .collect::>() + .join(".") + } +} + +/// 是否是「纯 JDK」包(排除 CRaC / JavaFX / JRE 变体)。 +#[must_use] +pub fn is_plain_jdk(name: &str) -> bool { + name.contains("-jdk") + && !name.contains("-jre") + && !name.contains("crac") + && !name.contains("-fx") +} + +/// 列表响应 → 纯 JDK 包(`latest=true` 会给多条,必须自己挑)。 +/// +/// # Errors +/// +/// 响应不是 JSON、列表为空时返回错误。 +pub fn parse_packages(json: &str) -> Result { + let packages: Vec = serde_json::from_str(json).map_err(|error| { + JavaError::new( + JavaErrorKind::Metadata, + format!("Zulu 列表响应不是预期 JSON:{error}"), + ) + })?; + let picked = packages + .iter() + .find(|package| is_plain_jdk(&package.name)) + .or_else(|| packages.first()) + .ok_or_else(|| { + JavaError::new( + JavaErrorKind::Metadata, + "Zulu 没有该版本/平台的 JDK 包(列表为空)", + ) + })?; + Ok(picked.clone()) +} + +/// 详情响应里的 `sha256_hash`(只信这一个字段,`size` 不可对拍)。 +/// +/// # Errors +/// +/// 响应不是 JSON 或缺可用哈希时返回错误。 +pub fn parse_detail_sha256(json: &str) -> Result { + #[derive(Deserialize)] + struct Detail { + #[serde(default)] + sha256_hash: Option, + } + let detail: Detail = serde_json::from_str(json).map_err(|error| { + JavaError::new( + JavaErrorKind::Metadata, + format!("Zulu 详情响应不是预期 JSON:{error}"), + ) + })?; + detail + .sha256_hash + .map(|hex| hex.trim().to_ascii_lowercase()) + .filter(|hex| hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit())) + .ok_or_else(|| { + JavaError::new( + JavaErrorKind::Integrity, + "Zulu 详情里没有可用的 sha256_hash", + ) + }) +} + +/// 查一次「某大版本的最新 Zulu JDK」。 +/// +/// # Errors +/// +/// 表里没配 Zulu、API 不可达/无包、详情缺哈希时返回错误(回退链到此为止,报总失败)。 +pub async fn fetch_latest( + client: &reqwest::Client, + table: &ZuluTable, + major: u32, + target: AdoptiumTarget, +) -> Result { + let list = net::get_text(client, &api_url(table, major, target), "Zulu 包列表").await?; + let package = parse_packages(&list)?; + let detail = net::get_text( + client, + &detail_url(table, &package.package_uuid), + "Zulu 包详情", + ) + .await?; + let sha256 = parse_detail_sha256(&detail)?; + // Zulu 的版本串用 `java_version` 数组拼(真机是数组不是字符串);文件名照抄, + // 因为文件名里 `jdk21.0.13.0.3` 这类四段/五段写法拼不出来。 + Ok(JdkRelease { + vendor: JdkVendor::Zulu, + major, + version: package.version_text(), + semver: None, + release_name: package.name.clone(), + archive: JdkArchive { + name: package.name, + url: package.download_url, + sha256, + // size 字段不可信(实测差 133 B)→ 填 0,交给 sha256 校验。 + size: 0, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::{api_url, detail_url, is_plain_jdk, parse_detail_sha256, parse_packages, zulu_os}; + use crate::download::MirrorTable; + use crate::game::java::target::AdoptiumTarget; + + const WINDOWS: AdoptiumTarget = AdoptiumTarget::new("windows", "x64"); + + fn table() -> crate::download::ZuluTable { + MirrorTable::builtin() + .zulu() + .expect("内置表应带 Zulu") + .clone() + } + + #[test] + fn urls_carry_every_filter_the_api_needs() { + let table = table(); + let url = api_url(&table, 21, WINDOWS); + assert!(url.starts_with("https://api.azul.com/metadata/v1/zulu/packages/?")); + for needle in [ + "java_version=21", + "os=windows", + "arch=x64", + "java_package_type=jdk", + "archive_type=zip", + "latest=true", + ] { + assert!(url.contains(needle), "{needle} 应在 {url}"); + } + assert_eq!( + detail_url(&table, "abc-123"), + "https://api.azul.com/metadata/v1/zulu/packages/abc-123" + ); + assert_eq!(zulu_os(AdoptiumTarget::new("mac", "aarch64")), "macos"); + } + + #[test] + fn latest_listing_gives_the_uuid_and_the_exact_filename() { + // 真机响应(2026-10-30):`latest=true` 返回多条,`java_version` 是数组, + // 并且混着 `-crac-` / `-fx-` 变体——必须挑出纯 JDK 那条。 + let json = r#"[ + {"package_uuid":"4ee30e3a-b26a-40d3-9d04-2182eae4075e", + "name":"zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip", + "java_version":[21,0,13,0,3], + "download_url":"https://cdn.azul.com/zulu/bin/zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip"}, + {"package_uuid":"2781fb21-9401-40d3-a88e-cf8f36541e3c", + "name":"zulu21.52.203-ca-crac-jdk21.0.12.1-win_x64.zip", + "java_version":[21,0,12,1], + "download_url":"https://cdn.azul.com/zulu/bin/zulu21.52.203-ca-crac-jdk21.0.12.1-win_x64.zip"}, + {"package_uuid":"49851aa2-4fd2-4e54-a903-323989cdf711", + "name":"zulu21.52.203-ca-fx-jdk21.0.12.1-win_x64.zip", + "java_version":[21,0,12,1], + "download_url":"https://cdn.azul.com/zulu/bin/zulu21.52.203-ca-fx-jdk21.0.12.1-win_x64.zip"} + ]"#; + let package = parse_packages(json).expect("应解析"); + assert_eq!(package.package_uuid, "4ee30e3a-b26a-40d3-9d04-2182eae4075e"); + assert_eq!( + package.name, "zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip", + "文件名必须照抄 API(三段/四段混写,拼不出来)" + ); + assert_eq!(package.version_text(), "21.0.13.0.3"); + assert!(is_plain_jdk(&package.name)); + assert!(!is_plain_jdk( + "zulu21.52.203-ca-crac-jdk21.0.12.1-win_x64.zip" + )); + assert!(!is_plain_jdk( + "zulu21.52.203-ca-fx-jdk21.0.12.1-win_x64.zip" + )); + assert!( + parse_packages("[]").is_err(), + "空列表要报错而不是造一个空包" + ); + assert!(parse_packages("502").is_err()); + } + + #[test] + fn detail_gives_a_trustworthy_sha256_only() { + let json = r#"{"package_uuid":"x","size":211151000, + "sha256_hash":"6B0E2B0F4C1D2A3E4F5061728394A5B6C7D8E9F0A1B2C3D4E5F60718293A4B5C"}"#; + assert_eq!( + parse_detail_sha256(json).expect("大写哈希应被接受并转小写"), + "6b0e2b0f4c1d2a3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c" + ); + assert!(parse_detail_sha256(r#"{"size":1}"#).is_err()); + assert!(parse_detail_sha256("not json").is_err()); + } +} diff --git a/crates/core/src/game/layout.rs b/crates/core/src/game/layout.rs new file mode 100644 index 0000000..408473e --- /dev/null +++ b/crates/core/src/game/layout.rs @@ -0,0 +1,165 @@ +//! `.minecraft` 实例目录布局:`versions/` `libraries/` `assets/` + ULL 扩展 `.ull/`。 +//! +//! 依据:`DevDocs/analyse/03-version-download.md` §6.8(目录布局速查)、 +//! `DevDocs/PlanDoc/04-data-and-config.md` §2(`.minecraft/` 遵循 Mojang 标准 + +//! ULL 扩展 `.ull/`)。 +//! +//! 布局是**纯路径计算**:不建目录、不写文件。所有路径规则集中在这里, +//! 这样将来要支持 MultiMC 家族布局(`instances//minecraft/`)只需再加一个实现 +//! (analyse/01 §3.3 的 `GameRepositoryLayout` 抽象意义)。 + +use std::path::{Path, PathBuf}; + +use super::error::GameError; +use super::library::MavenCoordinate; + +/// 一个游戏根目录(通常就是 `.minecraft/`)的布局规则。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GameLayout { + root: PathBuf, +} + +impl GameLayout { + /// 以给定根目录构造布局。 + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + /// 根目录。 + pub fn root(&self) -> &Path { + &self.root + } + + /// `versions/`。 + pub fn versions_dir(&self) -> PathBuf { + self.root.join("versions") + } + + /// `libraries/`。 + pub fn libraries_dir(&self) -> PathBuf { + self.root.join("libraries") + } + + /// `assets/`。 + pub fn assets_dir(&self) -> PathBuf { + self.root.join("assets") + } + + /// `assets/indexes/`。 + pub fn indexes_dir(&self) -> PathBuf { + self.assets_dir().join("indexes") + } + + /// `assets/objects/`(两级分片,避开 NTFS 单目录塌陷)。 + pub fn objects_dir(&self) -> PathBuf { + self.assets_dir().join("objects") + } + + /// `assets/log_configs/`(日志配置缓存)。 + pub fn log_configs_dir(&self) -> PathBuf { + self.assets_dir().join("log_configs") + } + + /// 实例根目录 `versions//`。 + pub fn version_dir(&self, id: &str) -> PathBuf { + self.versions_dir().join(id) + } + + /// 实例清单 `versions//.json`。 + pub fn version_json(&self, id: &str) -> PathBuf { + self.version_dir(id).join(format!("{id}.json")) + } + + /// 实例 jar `versions//.jar`。 + pub fn version_jar(&self, id: &str) -> PathBuf { + self.version_dir(id).join(format!("{id}.jar")) + } + + /// natives 解压目录 `versions//natives/`(ULL 约定,HMCL 用 `natives/`)。 + pub fn natives_dir(&self, id: &str) -> PathBuf { + self.version_dir(id).join("natives") + } + + /// 实例私有状态目录 `versions//.ull/`(上次会话日志索引、安装收据等)。 + pub fn version_state_dir(&self, id: &str) -> PathBuf { + self.version_dir(id).join(".ull") + } + + /// 实例私有库目录 `versions//libraries/`(HMCL `hint=local` 的落点)。 + pub fn version_libraries_dir(&self, id: &str) -> PathBuf { + self.version_dir(id).join("libraries") + } + + /// 启动器全局私有目录 `/.ull/`。 + pub fn ull_root(&self) -> PathBuf { + self.root.join(".ull") + } + + /// 资源索引 `assets/indexes/.json`。 + pub fn asset_index(&self, assets_id: &str) -> PathBuf { + self.indexes_dir().join(format!("{assets_id}.json")) + } + + /// 资源对象 `assets/objects//`。 + /// + /// # Errors + /// + /// 哈希短于两个字符时返回 [`GameError::InvalidAssetHash`]。 + pub fn asset_object(&self, hash: &str) -> Result { + let shard = hash.get(0..2).ok_or_else(|| GameError::InvalidAssetHash { + hash: hash.to_owned(), + })?; + Ok(self.objects_dir().join(shard).join(hash)) + } + + /// 公共库 `libraries//-[-].`。 + pub fn library(&self, coordinate: &MavenCoordinate) -> PathBuf { + self.libraries_dir().join(coordinate.relative_path()) + } + + /// 实例私有库 `versions//libraries/...`。 + pub fn version_library(&self, version_id: &str, coordinate: &MavenCoordinate) -> PathBuf { + self.version_libraries_dir(version_id) + .join(coordinate.relative_path()) + } + + /// 日志配置文件 `assets/log_configs/`。 + pub fn log_config(&self, file_id: &str) -> PathBuf { + self.log_configs_dir().join(file_id) + } + + /// 该版本是否已安装(判据:清单文件存在)。 + pub fn is_version_installed(&self, id: &str) -> bool { + self.version_json(id).is_file() + } + + /// 列出已安装的版本 id(有 `versions//.json` 的目录),按字典序排列。 + /// + /// # Errors + /// + /// 读 `versions/` 失败时返回 [`GameError::Io`];目录不存在视为空列表。 + pub fn installed_versions(&self) -> Result, GameError> { + let dir = self.versions_dir(); + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(GameError::io(&dir, e)), + }; + let mut ids = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| GameError::io(&dir, e))?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if path.join(format!("{name}.json")).is_file() { + ids.push(name.to_owned()); + } + } + ids.sort(); + Ok(ids) + } +} diff --git a/crates/core/src/game/library.rs b/crates/core/src/game/library.rs new file mode 100644 index 0000000..230c4b9 --- /dev/null +++ b/crates/core/src/game/library.rs @@ -0,0 +1,213 @@ +//! 依赖库模型:Maven 坐标、natives 选择、库文件相对路径。 +//! +//! 依据:`DevDocs/analyse/03-version-download.md` §6.4(libraries 含 natives 规则)、 +//! §6.8(目录布局速查)。 +//! +//! 这里只做"数据 → 路径/候选"的纯计算,**不碰网络与文件系统**: +//! 真正的下载归 `core::download`,放置归 [`super::layout`]。 + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use super::arguments::{rules_allow, Environment, Rule}; +use super::error::GameError; +use super::manifest::DownloadInfo; + +/// 一条依赖库。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Library { + /// Maven 坐标 `group:artifact:version[:classifier][@ext]`。 + #[serde(default)] + pub name: String, + /// 下载信息;老 JSON/魔改 JSON 可能没有。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub downloads: Option, + /// natives 选择表:`{ "windows": "natives-windows-${arch}" }`。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub natives: Option>, + /// 生效规则(空 = 生效)。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, + /// natives 解压规则。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extract: Option, + /// Maven 仓库基地址覆盖。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// HMCL 私有:`local` 表示库放在实例私有的 `versions//libraries/` 下。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hint: Option, + /// 未识别字段原样保留。 + #[serde(flatten)] + pub extra: BTreeMap, +} + +/// 一条依赖库的下载表。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LibraryDownloads { + /// 主构件。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact: Option, + /// 分类器构件(natives 走这里)。 + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub classifiers: BTreeMap, +} + +/// natives 解压规则:命中 `exclude` 的条目不解压。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Extract { + /// 排除的路径模式(`META-INF/`)。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub exclude: Vec, +} + +/// Maven 坐标(`group:artifact:version[:classifier][@ext]`)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MavenCoordinate { + /// group,如 `org.lwjgl`。 + pub group: String, + /// artifact,如 `lwjgl`。 + pub artifact: String, + /// version,如 `3.3.2`。 + pub version: String, + /// classifier(natives 常用),如 `natives-windows`。 + pub classifier: Option, + /// 扩展名,缺省 `jar`。 + pub extension: String, +} + +impl MavenCoordinate { + /// 解析坐标字符串。 + pub fn parse(raw: &str) -> Result { + let invalid = |reason: &'static str| GameError::InvalidCoordinate { + raw: raw.to_owned(), + reason, + }; + let (body, extension) = match raw.split_once('@') { + Some((body, ext)) if !ext.is_empty() => (body, ext.to_owned()), + Some(_) => return Err(invalid("扩展名为空")), + None => (raw, String::from("jar")), + }; + let parts: Vec<&str> = body.split(':').collect(); + if parts.len() < 3 { + return Err(invalid("至少需要 group:artifact:version 三段")); + } + if parts.iter().any(|p| p.is_empty()) { + return Err(invalid("存在空字段")); + } + match parts.len() { + 3 => Ok(Self { + group: parts[0].to_owned(), + artifact: parts[1].to_owned(), + version: parts[2].to_owned(), + classifier: None, + extension, + }), + 4 => Ok(Self { + group: parts[0].to_owned(), + artifact: parts[1].to_owned(), + version: parts[2].to_owned(), + classifier: Some(parts[3].to_owned()), + extension, + }), + _ => Err(invalid("字段多于四段")), + } + } + + /// group 转目录形式(`org.lwjgl` → `org/lwjgl`)。 + pub fn group_path(&self) -> String { + self.group.replace('.', "/") + } + + /// 文件名:`-[-].`。 + pub fn file_name(&self) -> String { + match &self.classifier { + Some(classifier) => format!( + "{}-{}-{}.{}", + self.artifact, self.version, classifier, self.extension + ), + None => format!("{}-{}.{}", self.artifact, self.version, self.extension), + } + } + + /// 相对 `libraries/` 的路径: + /// `///-[-].`。 + pub fn relative_path(&self) -> PathBuf { + PathBuf::from(self.group_path()) + .join(&self.artifact) + .join(&self.version) + .join(self.file_name()) + } + + /// 换一个 classifier 的副本(natives 用)。 + pub fn with_classifier(&self, classifier: impl Into) -> Self { + Self { + classifier: Some(classifier.into()), + ..self.clone() + } + } +} + +impl Library { + /// 解析本库的 Maven 坐标。 + pub fn coordinate(&self) -> Result { + MavenCoordinate::parse(&self.name) + } + + /// 该库在当前环境下是否生效。 + pub fn applies(&self, env: &Environment) -> bool { + rules_allow(&self.rules, env) + } + + /// 是否是实例私有库(HMCL `hint=local`)。 + pub fn is_local(&self) -> bool { + self.hint.as_deref() == Some("local") + } + + /// 主构件下载信息。 + pub fn artifact(&self) -> Option<&DownloadInfo> { + self.downloads.as_ref()?.artifact.as_ref() + } + + /// natives 分类器名:按 OS 查 `natives` 表并展开 `${arch}`(64 位为 `64`)。 + pub fn native_classifier(&self, env: &Environment) -> Option { + let raw = self.natives.as_ref()?.get(&env.os)?; + let arch = if matches!(env.arch.as_str(), "x86_64" | "arm64") { + "64" + } else { + "" + }; + Some(raw.replace("${arch}", arch)) + } + + /// natives 构件下载信息(按分类器查 `downloads.classifiers`)。 + pub fn native_artifact(&self, classifier: &str) -> Option<&DownloadInfo> { + self.downloads.as_ref()?.classifiers.get(classifier) + } + + /// 主构件相对 `libraries/` 的路径。 + /// + /// 优先用 JSON 里给出的 `artifact.path`(Mojang 官方的权威值),否则按坐标推导。 + pub fn relative_path(&self) -> Result { + if let Some(path) = self.artifact().and_then(|a| a.path.as_deref()) { + return Ok(PathBuf::from(path)); + } + Ok(self.coordinate()?.relative_path()) + } + + /// natives 构件相对 `libraries/` 的路径。 + pub fn native_relative_path(&self, classifier: &str) -> Result { + if let Some(path) = self + .native_artifact(classifier) + .and_then(|a| a.path.as_deref()) + { + return Ok(PathBuf::from(path)); + } + Ok(self + .coordinate()? + .with_classifier(classifier) + .relative_path()) + } +} diff --git a/crates/core/src/game/manifest.rs b/crates/core/src/game/manifest.rs new file mode 100644 index 0000000..0fca0d6 --- /dev/null +++ b/crates/core/src/game/manifest.rs @@ -0,0 +1,265 @@ +//! 版本 JSON(`versions//.json`)的数据模型。 +//! +//! 依据:`DevDocs/analyse/03-version-download.md` §4.1(Manifest + Patch 两层模型)、 +//! §4.2(宽松解析 + `rawJson` 保真)、§4.6(旧版 JSON 的四个特殊分支)。 +//! +//! 一个类型吃两种形态:磁盘上的实例清单(`root`/`hidden`/`patches`)与可合并的补丁层 +//! (多一个 `priority`)。合并语义不在这里,而在 [`super::inherit`]——模型只管形状。 +//! +//! **保真**:未知字段全部落进 `extra`(`#[serde(flatten)]`),回写时原样带出。 +//! 第三方启动器的私有字段(`MMC-*`、`tlauncherVersion`…)因此不会丢。 + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::arguments::Arguments; +use super::error::GameError; +use super::library::Library; +use super::version::ReleaseType; + +/// 单个文件的下载信息(`downloads.client`、库 artifact、资源索引、日志配置都用它)。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DownloadInfo { + /// 内容 id(资源索引/日志配置用;例如日志配置的 `client-1.12.xml`)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// SHA-1 校验值。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha1: Option, + /// 字节数。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + /// 下载地址(老版本的版本 JSON 可能完全没有)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// 库文件相对 `libraries/` 的路径(Mojang 在 artifact 里给出)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +impl DownloadInfo { + /// URL 是否可用。 + pub fn has_url(&self) -> bool { + self.url.as_deref().is_some_and(|u| !u.is_empty()) + } +} + +/// 版本 JSON 的 `downloads` 对象:`client` / `server` / `client_mappings` / `server_mappings`。 +/// +/// 用"字符串键 + 映射"而不是枚举,是因为魔改 JSON 里会出现未知键 +/// (analyse/03 §4.2:HMCL 对未知键 `catch` 后忽略),枚举键会让整份 JSON 解析失败。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Downloads(pub BTreeMap); + +impl Downloads { + /// 客户端 jar 的下载信息。 + pub fn client(&self) -> Option<&DownloadInfo> { + self.0.get("client") + } + + /// 服务端 jar 的下载信息。 + pub fn server(&self) -> Option<&DownloadInfo> { + self.0.get("server") + } + + /// 按原始键取。 + pub fn get(&self, key: &str) -> Option<&DownloadInfo> { + self.0.get(key) + } + + /// 条目数。 + pub fn len(&self) -> usize { + self.0.len() + } + + /// 是否为空。 + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +/// 日志配置(`logging.client`)。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LoggingInfo { + /// JVM 参数模板,含 `${path}` 占位符。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub argument: Option, + /// 配置文件本体。 + #[serde(default)] + pub file: DownloadInfo, + /// `log4j2-xml` 之类。 + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +/// 版本 JSON 的 `logging` 对象(键通常是 `client`)。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Logging(pub BTreeMap); + +impl Logging { + /// 客户端日志配置。 + pub fn client(&self) -> Option<&LoggingInfo> { + self.0.get("client") + } +} + +/// `javaVersion`:组件名 + 主版本号(D13 的 MC↔Java 映射用)。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaVersion { + /// 官方 Java 运行时组件名,例如 `java-runtime-gamma`。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub component: Option, + /// 主版本号,例如 `17`。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub major_version: Option, +} + +/// `assetIndex`:资源索引的引用。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetIndexRef { + /// 索引 id(`1.20`、`legacy`…),对应 `assets/indexes/.json`。 + #[serde(default)] + pub id: String, + /// 索引文件 SHA-1。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha1: Option, + /// 索引文件字节数。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + /// 全部对象的总字节数(`totalSize`,进度估算用)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_size: Option, + /// 索引下载地址。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// 一份版本 JSON(磁盘上的 `.json`,或一个可合并的补丁层)。 +/// +/// 字段几乎全部可空——这是"容忍残缺清单"的前提(analyse/03 §4.1:HMCL 22 个字段全可空)。 +/// 解析后的不变量(由 [`super::inherit::resolve`] 保证):`inherits_from == None`、 +/// `jar` 已回退成实例 id、`patches` 已清空、`root == Some(true)`。 +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct VersionJson { + /// 版本 id。 + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// 展示名(部分整合包写入)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// 继承的父版本 id。 + #[serde(skip_serializing_if = "Option::is_none")] + pub inherits_from: Option, + /// 启动用的 jar 所在版本 id;`None` 表示用本实例的 jar。 + #[serde(skip_serializing_if = "Option::is_none")] + pub jar: Option, + /// 主类。 + #[serde(skip_serializing_if = "Option::is_none")] + pub main_class: Option, + /// 旧版单串游戏参数(pre-1.13),与 `arguments` 并存。 + #[serde(skip_serializing_if = "Option::is_none")] + pub minecraft_arguments: Option, + /// 新版结构化参数。 + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option, + /// 资源版本名(`1.20`、`legacy`…);缺失按 `legacy` 处理(analyse/03 §4.6)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub assets: Option, + /// 资源索引引用。 + #[serde(skip_serializing_if = "Option::is_none")] + pub asset_index: Option, + /// 兼容级别。 + #[serde(skip_serializing_if = "Option::is_none")] + pub compliance_level: Option, + /// 需要的 Java 版本。 + #[serde(skip_serializing_if = "Option::is_none")] + pub java_version: Option, + /// 依赖库列表。 + #[serde(skip_serializing_if = "Option::is_none")] + pub libraries: Option>, + /// 下载信息。 + #[serde(skip_serializing_if = "Option::is_none")] + pub downloads: Option, + /// 日志配置。 + #[serde(skip_serializing_if = "Option::is_none")] + pub logging: Option, + /// 版本分类。 + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub version_type: Option, + /// 最后修改时间。 + #[serde(skip_serializing_if = "Option::is_none")] + pub time: Option, + /// 发布时间。 + #[serde(skip_serializing_if = "Option::is_none")] + pub release_time: Option, + /// 启动器最低版本要求。 + #[serde(skip_serializing_if = "Option::is_none")] + pub minimum_launcher_version: Option, + /// 是否为根清单(HMCL 私有字段)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub root: Option, + /// 是否在 UI 中隐藏(HMCL 私有字段)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub hidden: Option, + /// 补丁层,按 `priority` 升序合并(HMCL 私有字段)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub patches: Option>, + /// 补丁优先级;`None` 视作最小值(最先合并,analyse/03 §4.1)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, + /// 未识别的字段,原样保留(`rawJson` 保真)。 + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl VersionJson { + /// 解析一份版本 JSON。 + pub fn from_json_str(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| GameError::json("version_json", e)) + } + + /// 从字节解析(直接吃 HTTP 响应体)。 + pub fn from_json_slice(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|e| GameError::json("version_json", e)) + } + + /// 序列化回 JSON。 + pub fn to_json_string(&self) -> Result { + serde_json::to_string(self).map_err(|e| GameError::json("version_json", e)) + } + + /// 取 `id`,缺失则报错。 + pub fn require_id(&self) -> Result<&str, GameError> { + self.id.as_deref().ok_or(GameError::MissingVersionId) + } + + /// 客户端 jar 的下载信息;老版本 JSON 没有 `downloads.client`(analyse/03 §4.6), + /// 兜底 URL 由 download/launch 侧按镜像表拼,不在模型里硬编码 BMCLAPI。 + pub fn client_jar(&self) -> Option<&DownloadInfo> { + self.downloads.as_ref().and_then(Downloads::client) + } + + /// 资源版本名,缺失时按 `legacy`(analyse/03 §4.6 第 3 条)。 + pub fn assets_id(&self) -> &str { + self.assets.as_deref().unwrap_or("legacy") + } + + /// 是否已是被解析过的扁平清单。 + pub fn is_resolved(&self) -> bool { + self.inherits_from.is_none() && self.patches.is_none() && self.jar.is_some() + } + + /// 版本是否带 Vulkan 后端(`libraries` 含 `org.lwjgl:lwjgl-vulkan`)。 + /// + /// 只是**必要条件**:`26.2-snapshot-1` 有库但还没有 `--graphicsBackend` + /// (差两天,实测),下发门限见 [`crate::game::capability`] 的模块文档。 + pub fn has_vulkan_backend(&self) -> bool { + super::capability::VersionCapabilities::from_json(self).has_vulkan_backend() + } +} diff --git a/crates/core/src/game/mod.rs b/crates/core/src/game/mod.rs new file mode 100644 index 0000000..e05e441 --- /dev/null +++ b/crates/core/src/game/mod.rs @@ -0,0 +1,64 @@ +//! 版本与实例仓库:不可变快照 + 单写者草稿、版本 JSON 继承解析、实例目录布局。 +//! +//! 设计定稿:`DevDocs/PlanDoc/04-data-and-config.md` §4(实例模型)、 +//! `DevDocs/PlanDoc/01-tech-stack-and-architecture.md` §3(Snapshot/Draft 纪律), +//! 源证据 `DevDocs/analyse/01-architecture-overview.md` §3.3。 +//! +//! 与其它模块的契约:快照(Snapshot)类型与继承解析结果要与 launch 对齐 +//! (`DevDocs/SubAgentsTalk/README.md` 的对齐点;详细约定见 `SubAgentsTalk/game-接口约定.md`)。 +//! +//! 公共面(M14): +//! - 版本清单:[`VersionList`] / [`RemoteVersion`] / [`ReleaseType`] / [`VersionCategory`], +//! 外加内嵌补充清单 [`unlisted`]; +//! - 版本 JSON 与继承:[`VersionJson`]、[`resolve`]、[`merge`]、[`apply_patches`]; +//! - 参数与库:[`Arguments`] / [`Environment`] / [`Rule`]、[`Library`] / [`MavenCoordinate`]; +//! - 能力位:[`capability`](版本 JSON 库表 → `has_vulkan_backend`); +//! - 实例:目录布局 [`GameLayout`]、档案 [`ProfileList`] / [`InstanceProfile`] / [`ProfileId`]; +//! - 发布:快照仓库 [`Store`] / [`Snapshot`] / [`Draft`]。 +//! +//! 安装层(M27,[`install`]):版本链 → 下载计划 → 分区落盘 → 实例登记。 +//! 它把上面这些"模型"串成可执行的流程,但**自己不碰 UI、不拼镜像 URL**: +//! 下载全部交给 `core::download` 的引擎(镜像/健康度/续传/校验都在那边)。 +//! +//! Java 管理(M33,[`java`]):托管 JDK 的查询/下载/解压/探测(`.ULL/data/java-managed/`) +//! 与 MC↔Java 映射表。同样只借 `core::download` 的引擎做下载,自己不拼镜像 URL。 +//! +//! 模块分工按"数据形状 / 纯计算 / 状态"切开,任何模块都不碰网络与 UI: +//! 下载归 `core::download`、进程归 `core::launch`、配置落盘归 `core::setting`。 + +pub mod arguments; +pub mod capability; +pub mod error; +pub mod inherit; +pub mod install; +pub mod isolation; +pub mod java; +pub mod layout; +pub mod library; +pub mod manifest; +pub mod profiles; +pub mod snapshot; +pub mod unlisted; +pub mod version; + +pub use arguments::{rules_allow, Arguments, Environment, ExpandedArguments, Rule, RuleAction}; +pub use capability::{ + is_vulkan_library, library_names_have_vulkan, VersionCapabilities, VULKAN_LIBRARY_ARTIFACT, + VULKAN_LIBRARY_GROUP, +}; +pub use error::GameError; +pub use inherit::{apply_patches, merge, resolve, sorted_patches, VersionSource}; +pub use isolation::{ + derive_game_dir, observe_mode, probe_both_sides, probe_side, Derivation, IsolationMode, + IsolationSource, ModeChange, ModeChangeProbe, ModeChangeWarning, SideContent, + PROBED_CONTENT_DIRS, SHARED_LAYERS, +}; +pub use layout::GameLayout; +pub use library::{Library, MavenCoordinate}; +pub use manifest::{AssetIndexRef, DownloadInfo, Downloads, JavaVersion, Logging, VersionJson}; +pub use profiles::{InstanceProfile, ProfileId, ProfileList, PROFILE_SCHEMA_VERSION}; +pub use snapshot::{Draft, Snapshot, Store}; +pub use version::{ + LatestVersions, ReleaseType, RemoteVersion, VersionCategory, VersionList, + VERSION_MANIFEST_V2_URL, +}; diff --git a/crates/core/src/game/profiles.rs b/crates/core/src/game/profiles.rs new file mode 100644 index 0000000..db222db --- /dev/null +++ b/crates/core/src/game/profiles.rs @@ -0,0 +1,244 @@ +//! 实例清单(`profiles.json`)模型:稳定 UUID + 实例档案。 +//! +//! 依据:`DevDocs/PlanDoc/04-data-and-config.md` §4(实例模型预告)、§2(`.ULL/config/profiles.json`)。 +//! +//! 两条硬约束(来自 analyse/01 §7.6 的教训): +//! 1. **实例 id 用稳定 UUID,不用路径**——路径会变(用户挪目录、迁移数据目录), +//! 而 id 一旦写进档案就不能变; +//! 2. 档案只存**绑定关系**(版本 id、独立目录、Java 覆盖),不存派生的绝对路径结论; +//! 全局/实例/临时三层设置的合并归 `core::setting`,这里不重复。 +//! +//! 落盘归 `core::setting` 的原子写 + schema 迁移(M12),本模块只提供模型与不变量。 + +use std::collections::BTreeMap; +use std::fmt; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; + +/// `profiles.json` 的 schema 版本(迁移链由 `core::setting` 驱动)。 +pub const PROFILE_SCHEMA_VERSION: u32 = 1; + +/// 实例 id:UUID 形状的稳定标识。 +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProfileId(String); + +impl ProfileId { + /// 生成一个新 id(v4 形状)。 + /// + /// **不是密码学随机**:由纳秒时间戳 + 进程 id + 进程内计数器经 SplitMix64 混合而来, + /// 足以保证单机唯一;删除实例后不复用即可。真要密码学随机,阶段 B 引 `uuid` crate。 + pub fn generate() -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos() as u64); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut seed = nanos ^ u64::from(std::process::id()).rotate_left(17) ^ seq.rotate_left(41); + + let mut bytes = [0u8; 16]; + for chunk in bytes.chunks_mut(8) { + seed = split_mix64(seed); + let source = seed.to_le_bytes(); + chunk.copy_from_slice(&source[..chunk.len()]); + } + // RFC 4122 v4 形状:版本位 + 变体位。 + bytes[6] = (bytes[6] & 0x0F) | 0x40; + bytes[8] = (bytes[8] & 0x3F) | 0x80; + + let mut hex = String::with_capacity(36); + for (index, byte) in bytes.iter().enumerate() { + if matches!(index, 4 | 6 | 8 | 10) { + hex.push('-'); + } + hex.push_str(&format!("{byte:02x}")); + } + Self(hex) + } + + /// 从字符串构造(不校验,用于读回历史档案;校验用 [`ProfileId::is_uuid_shaped`])。 + pub fn new(raw: impl Into) -> Self { + Self(raw.into()) + } + + /// 字符串视图。 + pub fn as_str(&self) -> &str { + &self.0 + } + + /// 是否是 UUID 形状(`8-4-4-4-12` 十六进制)。 + pub fn is_uuid_shaped(&self) -> bool { + let bytes = self.0.as_bytes(); + if bytes.len() != 36 { + return false; + } + bytes.iter().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => *byte == b'-', + _ => byte.is_ascii_hexdigit(), + }) + } +} + +fn split_mix64(mut state: u64) -> u64 { + state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +impl fmt::Display for ProfileId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl FromStr for ProfileId { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(Self(s.to_owned())) + } +} + +impl From for ProfileId { + fn from(value: String) -> Self { + Self(value) + } +} + +/// 一个实例档案。 +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct InstanceProfile { + /// 稳定 id。 + pub id: ProfileId, + /// 展示名。 + #[serde(default)] + pub name: String, + /// 绑定的版本 id(`versions//`)。 + #[serde(default)] + pub version_id: String, + /// 独立游戏目录;`None` 表示使用全局 `.minecraft`。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub game_dir: Option, + /// 工作目录覆盖(HMCL 的"版本隔离")。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub running_dir: Option, + /// Java 路径覆盖。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub java_path: Option, + /// 创建时间(RFC-3339 字符串,由调用方填写)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, + /// 最近启动时间。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_played_at: Option, + /// 未识别字段原样保留(向前兼容)。 + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl InstanceProfile { + /// 建一个最小档案(新实例默认值)。 + pub fn new(name: impl Into, version_id: impl Into) -> Self { + Self { + id: ProfileId::generate(), + name: name.into(), + version_id: version_id.into(), + ..Self::default() + } + } +} + +/// `profiles.json` 的内容。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProfileList { + /// schema 版本。 + #[serde(default = "default_schema_version")] + pub schema_version: u32, + /// 档案列表(顺序即 UI 顺序)。 + #[serde(default)] + pub profiles: Vec, +} + +fn default_schema_version() -> u32 { + PROFILE_SCHEMA_VERSION +} + +impl Default for ProfileList { + fn default() -> Self { + Self { + schema_version: PROFILE_SCHEMA_VERSION, + profiles: Vec::new(), + } + } +} + +impl ProfileList { + /// 按 id 查。 + pub fn get(&self, id: &ProfileId) -> Option<&InstanceProfile> { + self.profiles.iter().find(|p| &p.id == id) + } + + /// 按 id 改。 + pub fn get_mut(&mut self, id: &ProfileId) -> Option<&mut InstanceProfile> { + self.profiles.iter_mut().find(|p| &p.id == id) + } + + /// 插入或替换同 id 档案,返回被替换的旧档案。 + pub fn upsert(&mut self, profile: InstanceProfile) -> Option { + match self.profiles.iter().position(|p| p.id == profile.id) { + Some(index) => Some(std::mem::replace(&mut self.profiles[index], profile)), + None => { + self.profiles.push(profile); + None + } + } + } + + /// 删除档案,返回被删的档案。 + pub fn remove(&mut self, id: &ProfileId) -> Option { + let index = self.profiles.iter().position(|p| &p.id == id)?; + Some(self.profiles.remove(index)) + } + + /// 绑定到某版本的档案(同一版本可以有多个实例:隔离目录不同)。 + pub fn for_version(&self, version_id: &str) -> Vec<&InstanceProfile> { + self.profiles + .iter() + .filter(|p| p.version_id == version_id) + .collect() + } + + /// 档案数。 + pub fn len(&self) -> usize { + self.profiles.len() + } + + /// 是否没有档案。 + pub fn is_empty(&self) -> bool { + self.profiles.is_empty() + } + + /// 解析 `profiles.json`。 + /// + /// # Errors + /// + /// JSON 非法时返回 [`super::error::GameError::Json`]。 + pub fn from_json_str(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| super::error::GameError::json("profiles", e)) + } + + /// 序列化(格式化输出,便于用户手改与 diff)。 + /// + /// # Errors + /// + /// 序列化失败时返回 [`super::error::GameError::Json`]。 + pub fn to_json_string(&self) -> Result { + serde_json::to_string_pretty(self).map_err(|e| super::error::GameError::json("profiles", e)) + } +} diff --git a/crates/core/src/game/snapshot.rs b/crates/core/src/game/snapshot.rs new file mode 100644 index 0000000..c224721 --- /dev/null +++ b/crates/core/src/game/snapshot.rs @@ -0,0 +1,174 @@ +//! 不可变快照 + 单写者草稿(Snapshot / Draft)。 +//! +//! 依据:`DevDocs/analyse/01-architecture-overview.md` §3.3(HMCL 最值得抄的一处设计)、 +//! `DevDocs/PlanDoc/01-tech-stack-and-architecture.md` 公理 2。 +//! +//! 收益:**读端永远看到一整份自洽的数据**,不会出现"遍历到一半列表被改" +//! (HMCL 老式 `synchronized` 实现的典型 bug)。写端同一时刻只允许一个草稿, +//! 发布 = 指针交换,读端持有的旧快照继续有效直到自己放手。 +//! +//! **实现说明**:读路径持一把共享锁,只为把 `Arc` 克隆出来(纳秒级),随后所有遍历都在锁外; +//! 真正的无锁读需要 `arc-swap` 这类依赖,P0 不引入(core 依赖面越窄越好)。 +//! 与 HMCL 的另一处差异:**发布不需要 UI 线程桥接**——发布是纯指针交换, +//! 订阅通知属于 app 层(`crates/app`),core 不做线程模型假设。 + +use std::fmt; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, RwLock}; + +use super::error::GameError; + +/// 一份不可变快照:克隆只是 `Arc` 计数,内容只读。 +pub struct Snapshot { + data: Arc, +} + +impl Snapshot { + /// 用给定内容构造快照。 + pub fn new(data: T) -> Self { + Self { + data: Arc::new(data), + } + } + + /// 读内容。 + pub fn get(&self) -> &T { + &self.data + } + + /// 共享同一份内容的强引用。 + pub fn shared(&self) -> Arc { + Arc::clone(&self.data) + } +} + +impl Clone for Snapshot { + fn clone(&self) -> Self { + Self { + data: Arc::clone(&self.data), + } + } +} + +impl fmt::Debug for Snapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Snapshot").field(&self.data).finish() + } +} + +impl PartialEq for Snapshot { + fn eq(&self, other: &Self) -> bool { + self.data == other.data + } +} + +/// 写权凭证:唯一的写权持有者,`drop` 即释放(发布与放弃都走它)。 +struct WriteToken<'a> { + store: &'a AtomicBool, +} + +impl<'a> WriteToken<'a> { + fn acquire(flag: &'a AtomicBool) -> Result { + if flag + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(GameError::DraftBusy); + } + Ok(Self { store: flag }) + } +} + +impl Drop for WriteToken<'_> { + fn drop(&mut self) { + self.store.store(false, Ordering::Release); + } +} + +/// 快照仓库:持有当前快照,串行化写入。 +pub struct Store { + current: RwLock>, + writing: AtomicBool, +} + +impl Store { + /// 以初始内容建立仓库。 + pub fn new(initial: T) -> Self { + Self { + current: RwLock::new(Arc::new(initial)), + writing: AtomicBool::new(false), + } + } + + /// 取当前快照(读路径:一次共享锁 + `Arc` 克隆,锁内不做别的)。 + pub fn snapshot(&self) -> Snapshot { + let guard = self.current.read().unwrap_or_else(|e| e.into_inner()); + Snapshot { + data: Arc::clone(&guard), + } + } + + /// 打开草稿(写会话)。同一时刻只允许一个,违反单写者约束返回 + /// [`GameError::DraftBusy`]。 + pub fn open_draft(&self) -> Result, GameError> { + let token = WriteToken::acquire(&self.writing)?; + let working = self.snapshot().get().clone(); + Ok(Draft { + store: self, + token, + working, + }) + } + + /// 整表替换(加载完成后一次性发布,仍受单写者约束)。 + pub fn replace(&self, value: T) -> Result, GameError> { + let mut draft = self.open_draft()?; + *draft.edit() = value; + Ok(draft.commit()) + } +} + +/// 草稿:持有写权的可变副本。 +/// +/// 未 [`commit`](Draft::commit) 就 `drop` 会**放弃改动并释放写权**(不会污染快照)。 +pub struct Draft<'a, T> { + store: &'a Store, + token: WriteToken<'a>, + working: T, +} + +impl Draft<'_, T> { + /// 读草稿内容。 + pub fn view(&self) -> &T { + &self.working + } + + /// 改草稿内容。 + pub fn edit(&mut self) -> &mut T { + &mut self.working + } + + /// 发布:把草稿内容换成仓库当前快照,并释放写权。 + pub fn commit(self) -> Snapshot { + let Draft { + store, + token, + working, + } = self; + let snapshot = Snapshot::new(working); + { + let mut guard = store.current.write().unwrap_or_else(|e| e.into_inner()); + *guard = snapshot.shared(); + } + drop(token); + snapshot + } +} + +impl fmt::Debug for Draft<'_, T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Draft") + .field("working", &self.working) + .finish() + } +} diff --git a/crates/core/src/game/unlisted.rs b/crates/core/src/game/unlisted.rs new file mode 100644 index 0000000..6d939bc --- /dev/null +++ b/crates/core/src/game/unlisted.rs @@ -0,0 +1,54 @@ +//! 内嵌补充清单 `unlisted-versions.json`(205 条,编进二进制)。 +//! +//! 官方清单本来就有 `old_beta`/`old_alpha`,但**不完整**:大量 alpha/classic 构建、 +//! `pending` 实验快照、`_unobfuscated` 版本从未进官方清单。 +//! HMCL 的做法是把社区维护的补充清单编进发行包,换取"零网络依赖也能列出旧版", +//! 依据见 `DevDocs/analyse/03-version-download.md` §2.3。 +//! +//! 数据来源:HMCL `HMCLCore/src/main/resources/assets/game/unlisted-versions.json` +//! (实测 205 条:`old_alpha 88` / `old_beta 16` / `snapshot 70` / `pending 20` / +//! `unobfuscated 11`),原样拷进 `game/data/` 由 `include_str!` 内嵌。 +//! +//! **与 HMCL 的差异**:同 id 冲突时我们让**官方条目胜出**。理由是官方 v2 清单带 +//! `sha1`/`size` 且更新鲜;HMCL 因先插 unlisted 进 `TreeSet` 而让社区条目抢了同 id, +//! 会把官方 URL 换成社区镜像 URL(analyse/03 §2.5)。补充清单只用来补官方缺口。 +//! 后续计划(analyse/03 §2.3 建议):把该清单改为可远程更新的数据 + ETag 缓存, +//! 内嵌版本降级为离线回退。 + +use std::sync::OnceLock; + +use super::version::{RemoteVersion, VersionList}; + +/// 内嵌清单的原始 JSON(`crates/core/src/game/data/unlisted-versions.json`)。 +const EMBEDDED_UNLISTED_JSON: &str = include_str!("data/unlisted-versions.json"); + +/// 内嵌清单预期的条目数,用于数据完整性自检与文档口径一致。 +pub const EMBEDDED_VERSION_COUNT: usize = 205; + +static UNLISTED: OnceLock = OnceLock::new(); + +/// 内嵌补充清单(进程内只解析一次)。 +/// +/// # Panics +/// +/// 内嵌数据是编译期常量,可解析性由 `tests/game/unlisted.rs` 守门; +/// 若构建产物被破坏导致解析失败则 panic——这是构建错误,不是运行时输入错误。 +pub fn versions() -> &'static VersionList { + UNLISTED.get_or_init(|| { + VersionList::from_json_str(EMBEDDED_UNLISTED_JSON) + .expect("内嵌 unlisted-versions.json 必须可解析") + }) +} + +/// 内嵌清单的条目(借用)。 +pub fn entries() -> &'static [RemoteVersion] { + &versions().versions +} + +/// 把内嵌补充清单合并进给定清单,返回新增条目数。 +/// +/// 同 id 时**保留已有条目**(官方胜出,理由见模块注释);新条目按数据文件原顺序追加, +/// 调用方如需展示顺序再调用 [`VersionList::sort_newest_first`]。 +pub fn merge_into(target: &mut VersionList) -> usize { + target.merge_missing(entries().iter().cloned()) +} diff --git a/crates/core/src/game/version.rs b/crates/core/src/game/version.rs new file mode 100644 index 0000000..bdd6c91 --- /dev/null +++ b/crates/core/src/game/version.rs @@ -0,0 +1,238 @@ +//! 版本清单(`version_manifest_v2.json`)模型、分类与合并。 +//! +//! 设计依据:`DevDocs/analyse/03-version-download.md` §2(清单处理)、§2.4(三层分类)、 +//! §2.5(排序与相等)、§2.2(v2 的 `sha1`/`size` 校验字段)。 +//! +//! 与 HMCL 的差异(有意为之): +//! - **直接用 v2 端点**并保留 `sha1`/`size`,让"清单被镜像篡改/截断"在下载阶段就暴露; +//! - 分类枚举保留 `pending`/`unobfuscated`/`modified`,不再压进 `OLD` 桶后又让过滤器不一致; +//! - 未知 `type` 值不丢弃整条记录,落到 [`ReleaseType::Unknown`](对应 HMCL 的 null)。 + +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use super::error::GameError; + +/// Mojang 官方清单 v2 端点(带 `sha1`/`size`)。 +pub const VERSION_MANIFEST_V2_URL: &str = + "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; + +/// 版本 JSON 里 `type` 字段的取值,见 analyse/03 §2.4。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub enum ReleaseType { + /// 正式版。 + Release, + /// 快照(含愚人节、`pending` 之外的实验构建)。 + Snapshot, + /// 远古 beta。 + OldBeta, + /// 远古 alpha(含 classic、`rd-`/`inf-` 系列归此)。 + OldAlpha, + /// `pending`:已推送 JSON 但未正式发布的构建。 + Pending, + /// `_unobfuscated` 形式的反混淆构建(官方 CDN 有、普通清单没有)。 + Unobfuscated, + /// 第三方/魔改清单自定义分类。 + Modified, + /// 未知取值——保留记录但无法分类(HMCL 里是 `null`)。 + #[default] + Unknown, +} + +impl ReleaseType { + /// 从 JSON 原始字符串解析(未知值落到 `Unknown`,不报错)。 + pub fn from_json_str(raw: &str) -> Self { + match raw { + "release" => Self::Release, + "snapshot" => Self::Snapshot, + "old_beta" => Self::OldBeta, + "old_alpha" => Self::OldAlpha, + "pending" => Self::Pending, + "unobfuscated" => Self::Unobfuscated, + "modified" => Self::Modified, + _ => Self::Unknown, + } + } + + /// 回到 JSON 原始字符串。 + pub fn as_json_str(self) -> &'static str { + match self { + Self::Release => "release", + Self::Snapshot => "snapshot", + Self::OldBeta => "old_beta", + Self::OldAlpha => "old_alpha", + Self::Pending => "pending", + Self::Unobfuscated => "unobfuscated", + Self::Modified => "modified", + Self::Unknown => "unknown", + } + } + + /// UI 侧的归类(analyse/03 §2.4 的五态,去掉 `uncategorized` 之外的分歧)。 + pub fn category(self) -> VersionCategory { + match self { + Self::Release => VersionCategory::Release, + Self::Snapshot => VersionCategory::Snapshot, + Self::OldBeta | Self::OldAlpha | Self::Modified => VersionCategory::Old, + Self::Pending => VersionCategory::Pending, + Self::Unobfuscated => VersionCategory::Unobfuscated, + Self::Unknown => VersionCategory::Uncategorized, + } + } +} + +impl fmt::Display for ReleaseType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_json_str()) + } +} + +impl Serialize for ReleaseType { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_json_str()) + } +} + +impl<'de> Deserialize<'de> for ReleaseType { + fn deserialize>(deserializer: D) -> Result { + let raw = String::deserialize(deserializer)?; + Ok(Self::from_json_str(&raw)) + } +} + +/// 版本列表的 UI 归类(对应 HMCL `ComponentRemoteVersion.Type`,见 analyse/03 §2.4)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum VersionCategory { + /// 正式版。 + Release, + /// 快照。 + Snapshot, + /// 远古版(old_beta / old_alpha / modified)。 + Old, + /// 未正式发布的 pending 构建。 + Pending, + /// 反混淆构建。 + Unobfuscated, + /// 无法归类。 + Uncategorized, +} + +/// 清单里的 `latest` 对象。Mojang 用它标注"当前最新正式版/快照",与条目列表无关。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LatestVersions { + /// 最新正式版 id。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub release: Option, + /// 最新快照 id。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snapshot: Option, +} + +/// 版本清单里的一条记录。 +/// +/// `sha1`/`size` 只在 v2 端点出现(v1 与内嵌 unlisted 清单没有),因此都是可选的。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteVersion { + /// 版本 id(`1.20.1`、`12w01a`、`a1.1.1`…)。 + pub id: String, + /// 分类。 + #[serde(rename = "type", default)] + pub version_type: ReleaseType, + /// 该版本 JSON 的下载地址。 + pub url: String, + /// 该版本 JSON 的最后修改时间(ISO-8601 字符串)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub time: Option, + /// 该版本的发布时刻(ISO-8601 字符串,排序键)。 + #[serde( + rename = "releaseTime", + default, + skip_serializing_if = "Option::is_none" + )] + pub release_time: Option, + /// 版本 JSON 的 SHA-1(v2 才有)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha1: Option, + /// 版本 JSON 的字节数(v2 才有)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +impl RemoteVersion { + /// 该条目是否带 v2 的校验信息。 + pub fn is_verifiable(&self) -> bool { + self.sha1.is_some() + } +} + +/// 一份版本清单(官方 `version_manifest_v2.json` 或内嵌 unlisted 清单)。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct VersionList { + /// `latest` 对象;unlisted 清单没有。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest: Option, + /// 条目列表。 + #[serde(default)] + pub versions: Vec, +} + +impl VersionList { + /// 解析清单 JSON。 + pub fn from_json_str(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| GameError::json("version_manifest", e)) + } + + /// 序列化回 JSON(便于缓存落盘/测试对拍)。 + pub fn to_json_string(&self) -> Result { + serde_json::to_string(self).map_err(|e| GameError::json("version_manifest", e)) + } + + /// 按 id 查条目。 + pub fn find(&self, id: &str) -> Option<&RemoteVersion> { + self.versions.iter().find(|v| v.id == id) + } + + /// 已存在的版本 id 数。 + pub fn len(&self) -> usize { + self.versions.len() + } + + /// 是否为空清单。 + pub fn is_empty(&self) -> bool { + self.versions.is_empty() + } + + /// 合并补充清单:**同 id 时先插入者胜**(analyse/03 §2.5 的 `TreeSet` 语义)。 + /// + /// 调用方应先合并 unlisted 再合并官方清单,这样社区清单里那些"官方没有/官方 URL 更差" + /// 的条目会被保留。返回新插入的条目数。 + pub fn merge_missing(&mut self, extra: impl IntoIterator) -> usize { + let mut added = 0; + for item in extra { + if self.find(&item.id).is_some() { + continue; + } + self.versions.push(item); + added += 1; + } + added + } + + /// 按发布时间**倒序**排列;同时间再按 id 倒序(analyse/03 §2.5)。 + pub fn sort_newest_first(&mut self) { + self.versions.sort_by(|a, b| { + let at = a.release_time.as_deref().unwrap_or_default(); + let bt = b.release_time.as_deref().unwrap_or_default(); + bt.cmp(at).then_with(|| b.id.cmp(&a.id)) + }); + } + + /// 按 UI 归类过滤(保持当前顺序)。 + pub fn filter_category(&self, category: VersionCategory) -> Vec<&RemoteVersion> { + self.versions + .iter() + .filter(|v| v.version_type.category() == category) + .collect() + } +} diff --git a/crates/core/src/launch/adapter.rs b/crates/core/src/launch/adapter.rs new file mode 100644 index 0000000..0b4729f --- /dev/null +++ b/crates/core/src/launch/adapter.rs @@ -0,0 +1,368 @@ +//! game 快照 → [`LaunchRequest`] 的适配层。 +//! +//! launch 不 import `crate::game` 的**清单类型**(避免对 M14 的模型产生编译期耦合,见 +//! `DevDocs/SubAgentsTalk/launch-接口约定.md` §6):本模块只吃 game 侧**已经算好**的纯数据—— +//! `arguments.expand(&env)` 的展开结果(rules 已在 game 求值)、库文件路径、目录布局, +//! 把它们装配成命令行组装所需的结构。 +//! +//! 唯一例外是 [`crate::game::capability`] 里的 `&str → bool` 纯谓词:能力位的判据属于 +//! 版本 JSON 的语义、必须只有一处实现,抄第二份必然漂移。它不带任何 game 结构体。 +//! +//! 三个职责: +//! 1. classpath 过滤 + **缺失库显式记录**(analyse/02 §8.4-3:不再静默跳过,否则只能靠 +//! `NoClassDefFoundError` 事后猜); +//! 2. features 开关的派生(`has_custom_resolution` / `has_quick_plays_support`); +//! 3. 组装 `LaunchRequest`(内存/账户/加载器/分辨率/QuickPlay/代理/日志配置)。 + +use std::path::PathBuf; + +use crate::game::capability::library_names_have_vulkan; +use crate::launch::argument::ArgumentEntry; +use crate::launch::error::MissingComponent; +use crate::launch::game_version::GameVersion; +use crate::launch::natives::{NativeJar, NativesEra, NativesPlan}; +use crate::launch::request::{ + AuthPlaceholders, FeatureSet, InstanceInfo, JavaSelection, LaunchDirs, LaunchRequest, + LauncherInfo, Log4jOptions, ManifestArgs, MemoryOptions, ModLoaderKind, OptimizationOptions, + QuickPlay, RendererOptions, Resolution, SocksProxy, UserArgs, +}; + +/// 一个待进 classpath 的库(game 侧已按环境过滤)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LibraryInput { + /// 落盘路径(`GameLayout::library(coord)` 的结果)。 + pub path: PathBuf, + /// 坐标或展示名(缺失时报错用,例 `org.lwjgl:lwjgl:3.3.1`)。 + pub name: String, +} + +impl LibraryInput { + /// 构造。 + pub fn new(path: impl Into, name: impl Into) -> Self { + Self { + path: path.into(), + name: name.into(), + } + } +} + +/// 版本快照里 launch 需要的部分(全部是纯数据,不依赖 game 类型)。 +#[derive(Debug, Clone)] +pub struct SnapshotInput<'a> { + /// 版本 id(`versions//`)。 + pub version_id: &'a str, + /// 展示名(缺省用 version_id)。 + pub version_name: Option<&'a str>, + /// 版本类型(`release` / `snapshot`)。 + pub version_type: Option<&'a str>, + /// 游戏版本串(门限判断用;缺省用 version_id)。 + pub game_version: Option<&'a str>, + /// 主类。 + pub main_class: Option<&'a str>, + /// `arguments.jvm` 展开后的 token。 + pub jvm_arguments: &'a [String], + /// `arguments.game` 展开后的 token。 + pub game_arguments: &'a [String], + /// 旧版 `minecraftArguments` 整串。 + pub legacy_arguments: Option<&'a str>, + /// 待进 classpath 的库(已过滤环境、不含 native)。 + pub libraries: &'a [LibraryInput], + /// natives 分类器构件(`natives` 表命中的 jar;1.19+ 为空,natives jar 在 classpath 里)。 + pub natives: &'a [NativeJar], + /// 主 jar 路径。 + pub primary_jar: PathBuf, + /// 运行目录(`${game_directory}`)。 + pub game_dir: PathBuf, + /// 资产索引 id。 + pub assets_index: Option<&'a str>, + /// 原生库目录。 + pub natives_dir: PathBuf, + /// 库根目录。 + pub library_dir: PathBuf, + /// 资产根。 + pub assets_root: PathBuf, + /// 虚拟资产目录(`${game_assets}`;无虚拟化时等于 assets_root)。 + pub game_assets: PathBuf, +} + +impl<'a> SnapshotInput<'a> { + /// 最小构造:只给版本 id 与主类,其余留空(测试/CLI 组装用)。 + pub fn minimal(version_id: &'a str, main_class: &'a str) -> Self { + Self { + version_id, + version_name: None, + version_type: None, + game_version: None, + main_class: Some(main_class), + jvm_arguments: &[], + game_arguments: &[], + legacy_arguments: None, + libraries: &[], + natives: &[], + primary_jar: PathBuf::new(), + game_dir: PathBuf::new(), + assets_index: None, + natives_dir: PathBuf::new(), + library_dir: PathBuf::new(), + assets_root: PathBuf::new(), + game_assets: PathBuf::new(), + } + } + + fn resolved_version(&self) -> GameVersion { + GameVersion::parse(self.game_version.unwrap_or(self.version_id)) + } +} + +/// 适配选项(账户/内存/加载器/一键进入等运行期选择)。 +#[derive(Debug, Clone, Default)] +pub struct AdapterOptions { + /// Java 运行时。 + pub java: JavaSelection, + /// 内存。 + pub memory: MemoryOptions, + /// 账户占位符。 + pub auth: AuthPlaceholders, + /// 启动器名(`${launcher_name}`)。 + pub launcher_name: String, + /// 启动器版本(`${launcher_version}`)。 + pub launcher_version: String, + /// 模组加载器。 + pub mod_loader: ModLoaderKind, + /// 分辨率(`Some` 时自动打开 `has_custom_resolution`)。 + pub resolution: Option, + /// 全屏。 + pub fullscreen: bool, + /// 一键进入。 + pub quick_play: Option, + /// SOCKS 代理。 + pub proxy: Option, + /// 图形后端。 + pub graphics_backend: Option, + /// log4j 配置文件(launch 侧注入)。 + pub log4j_config: Option, + /// `-Dfile.encoding` 指定值。 + pub encoding: Option, + /// 界面语言。 + pub language: String, + /// 额外环境变量。 + pub environment: Vec<(String, String)>, + /// JVM 自动优化。 + pub optimization: OptimizationOptions, + /// 渲染器开关。 + pub renderer: RendererOptions, + /// 用户自定义参数。 + pub user: UserArgs, + /// 是否要求库文件真实存在。 + /// + /// - `true`(默认,严格):缺库/缺主 jar → 记 `AdapterWarning::MissingLibrary` 并填进 + /// `missing_components`,组装前显式失败(analyse/02 §8.4-3); + /// - `false`(宽松,HMCL 行为):**不检查存在性、不跳过、不告警**,库照进 classpath, + /// 交给运行时(`NoClassDefFoundError`)——仅在用户明确选“跳过校验”时用。 + pub check_classpath: bool, +} + +impl AdapterOptions { + /// 默认严格模式:库必须存在(analyse/02 §8.4-3)。 + pub fn strict() -> Self { + Self { + check_classpath: true, + language: "zh-CN".to_string(), + ..Self::default() + } + } +} + +/// 适配告警(非致命)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdapterWarning { + /// 库文件缺失(严格模式下同时会变成 `missing_components` → 组装失败)。 + MissingLibrary { + /// 坐标/展示名。 + name: String, + /// 期望路径。 + path: PathBuf, + }, + /// 既没有展开后的游戏参数,也没有 legacy 整串。 + MissingGameArguments, + /// 没有资产索引 id(`${assets_index_name}` 会是空串)。 + MissingAssetsIndex, + /// 主类缺失(组装一定会报错,这里提前给出中文提示)。 + MissingMainClass, + /// natives 分类器 jar 缺失(严格模式下同时会变成 `missing_components` → 组装失败)。 + MissingNativeJar { + /// 坐标 + 分类器。 + name: String, + /// 期望路径。 + path: PathBuf, + }, +} + +/// 适配结果。 +#[derive(Debug, Clone)] +pub struct AdapterOutput { + /// 组装输入。 + pub request: LaunchRequest, + /// 告警。 + pub warnings: Vec, + /// 实际进 classpath 的条目数(库 + 主 jar)。 + pub classpath_entries: usize, + /// 缺失库清单。 + pub missing: Vec, +} + +/// 把快照 + 选项装配成 [`LaunchRequest`]。 +pub fn build_request(snapshot: &SnapshotInput<'_>, options: AdapterOptions) -> AdapterOutput { + let mut warnings = Vec::new(); + let mut missing = Vec::new(); + let mut classpath = Vec::new(); + for library in snapshot.libraries { + if options.check_classpath && !library.path.is_file() { + warnings.push(AdapterWarning::MissingLibrary { + name: library.name.clone(), + path: library.path.clone(), + }); + missing.push(MissingComponent::at_path( + library.name.clone(), + library.path.clone(), + )); + continue; + } + if options.check_classpath && classpath.contains(&library.path) { + continue; + } + classpath.push(library.path.clone()); + } + if !snapshot.primary_jar.as_os_str().is_empty() + && options.check_classpath + && !snapshot.primary_jar.is_file() + { + let name = snapshot + .primary_jar + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "client.jar".to_string()); + warnings.push(AdapterWarning::MissingLibrary { + name: name.clone(), + path: snapshot.primary_jar.clone(), + }); + missing.push(MissingComponent::at_path( + name, + snapshot.primary_jar.clone(), + )); + } + if snapshot.main_class.is_none() { + warnings.push(AdapterWarning::MissingMainClass); + } + if snapshot.assets_index.is_none() { + warnings.push(AdapterWarning::MissingAssetsIndex); + } + if snapshot.game_arguments.is_empty() && snapshot.legacy_arguments.is_none() { + warnings.push(AdapterWarning::MissingGameArguments); + } + // natives jar 缺失同样属「组件缺失」:解压期才发现会只剩一句 UnsatisfiedLinkError。 + for native in snapshot.natives { + if options.check_classpath && !native.path.is_file() { + warnings.push(AdapterWarning::MissingNativeJar { + name: native.name.clone(), + path: native.path.clone(), + }); + missing.push(MissingComponent::at_path( + native.name.clone(), + native.path.clone(), + )); + } + } + + let version = snapshot.resolved_version(); + let features = FeatureSet { + has_custom_resolution: options.resolution.is_some(), + is_demo_user: false, + has_quick_plays_support: version.is_at_least("1.20"), + }; + let manifest = ManifestArgs { + main_class: snapshot.main_class.map(str::to_string), + jvm: entries(snapshot.jvm_arguments), + game: entries(snapshot.game_arguments), + legacy_arguments: snapshot.legacy_arguments.map(str::to_string), + has_vulkan_backend: library_names_have_vulkan( + snapshot + .libraries + .iter() + .map(|library| library.name.as_str()), + ), + }; + + let classpath_entries = + classpath.len() + usize::from(!snapshot.primary_jar.as_os_str().is_empty()); + let era = NativesEra::detect(snapshot.jvm_arguments.len(), snapshot.natives.len()); + let natives = NativesPlan { + jars: snapshot.natives.to_vec(), + era, + custom: false, + }; + let request = LaunchRequest { + instance: InstanceInfo { + id: snapshot.version_id.to_string(), + name: snapshot + .version_name + .unwrap_or(snapshot.version_id) + .to_string(), + version, + version_type: snapshot.version_type.unwrap_or("release").to_string(), + }, + launcher: LauncherInfo { + name: options.launcher_name, + version: options.launcher_version, + }, + java: options.java, + memory: options.memory, + manifest, + dirs: LaunchDirs { + game_dir: snapshot.game_dir.clone(), + assets_root: snapshot.assets_root.clone(), + game_assets: snapshot.game_assets.clone(), + assets_index: snapshot.assets_index.unwrap_or("legacy").to_string(), + natives_dir: snapshot.natives_dir.clone(), + library_dir: snapshot.library_dir.clone(), + primary_jar: snapshot.primary_jar.clone(), + }, + natives, + auth: options.auth, + user: options.user, + features, + resolution: options.resolution, + quick_play: options.quick_play, + proxy: options.proxy, + graphics_backend: options.graphics_backend, + fullscreen: options.fullscreen, + mod_loader: options.mod_loader, + log4j: Log4jOptions { + enabled: true, + config_file: options.log4j_config, + debug: false, + }, + renderer: options.renderer, + optimization: options.optimization, + encoding: options.encoding, + language: options.language, + classpath, + missing_components: missing.clone(), + extra_jvm: Vec::new(), + ..LaunchRequest::default() + }; + AdapterOutput { + request, + warnings, + classpath_entries, + missing, + } +} + +fn entries(tokens: &[String]) -> Vec { + if tokens.is_empty() { + Vec::new() + } else { + vec![ArgumentEntry::plain(tokens.iter().cloned())] + } +} diff --git a/crates/core/src/launch/args_table.rs b/crates/core/src/launch/args_table.rs new file mode 100644 index 0000000..0cc9370 --- /dev/null +++ b/crates/core/src/launch/args_table.rs @@ -0,0 +1,253 @@ +//! 命令行组装总表:`analyse/02` §4.1 的 33 项顺序表的 Rust 化。 +//! +//! 顺序本身有语义(后加的默认值会被先加的显式值抑制): +//! +//! | # | 内容 | 本文件落点 | +//! |---|------|-----------| +//! | 1~3 | `nice` / 用户包装器 / java 可执行文件 | [`assemble`] | +//! | 4 | 最高优先级 JVM 参数(含 `@argfile` 展开) | [`assemble`] | +//! | 5~7 | `-Xmx`/`-Xms`、Metaspace、用户 JVM 参数(按默认值) | [`assemble`] | +//! | 8~17 | 编码、Log4j、主 jar、代理、自动优化、版本开关、FML、渲染器 | `jvm_args` | +//! | 18~19 | classpath 计算与占位符表(`${classpath}` 等 26 项) | [`assemble`] + `placeholders` | +//! | 20~32 | 清单 JVM 参数、主类、游戏参数、QuickPlay、代理、图形后端、用户参数 | `game_args` | +//! | 33 | 删除被禁止参数(`-Xincgc`) | [`assemble`] | +//! +//! 组装是**纯函数**:给定 `LaunchRequest` + `PlatformCtx` 一定得到同样的命令行, +//! 因此可以整体快照测试(`crates/core/tests/launch/cmdline_snapshot.rs`)。 + +use std::fmt; +use std::path::PathBuf; + +use crate::launch::command_builder::{quote_for_display, CommandBuilder}; +use crate::launch::defaults; +use crate::launch::error::{AssemblyError, Result}; +use crate::launch::game_args; +use crate::launch::jvm_args; +use crate::launch::placeholders::{join_classpath, PlaceholderMap}; +use crate::launch::platform::PlatformCtx; +use crate::launch::request::{LaunchRequest, MemoryOptions}; + +/// 组装过程中的“非致命”告警(不中断启动,但要告诉用户我们改了什么)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AssemblyWarning { + /// 注入了 log4j 参数却没有配置文件。 + Log4jConfigMissing, + /// 用户自己写了代理参数,我们跳过自动代理参数。 + ProxyJvmArgsSkipped, + /// `-Xms` 大于 `-Xmx`,已忽略 `-Xms`。 + MemoryMinIgnored { + /// 请求的最小堆。 + min_mb: u32, + /// 请求的最大堆。 + max_mb: u32, + }, + /// 版本不支持 QuickPlay,已退化成 `--server/--port`。 + QuickPlayDegraded { + /// 原始地址。 + address: String, + }, + /// 服务器地址非法,QuickPlay 被丢弃。 + QuickPlayAddressInvalid { + /// 原始地址。 + address: String, + }, + /// 版本不支持 QuickPlay 且无法退化(存档/Realms)。 + QuickPlayUnsupported { + /// 游戏版本。 + version: String, + }, + /// 版本用不了 `--graphicsBackend`,已丢弃。 + /// + /// 两种来源:可解析版本低于 `26.2-snapshot-2`;或周快照(`26w14a`)但版本库表里 + /// 没有 `lwjgl-vulkan`(见 `game_args::graphics_backend_allowed`)。 + GraphicsBackendSkipped { + /// 游戏版本。 + version: String, + }, +} + +impl fmt::Display for AssemblyWarning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Log4jConfigMissing => f.write_str("log4j 注入已启用但缺少配置文件,已跳过"), + Self::ProxyJvmArgsSkipped => f.write_str("用户已指定代理参数,跳过自动代理 JVM 参数"), + Self::MemoryMinIgnored { min_mb, max_mb } => { + write!(f, "-Xms{min_mb}m 大于 -Xmx{max_mb}m,已忽略 -Xms") + } + Self::QuickPlayDegraded { address } => { + write!(f, "版本不支持 QuickPlay,已退化为 --server {address}") + } + Self::QuickPlayAddressInvalid { address } => { + write!(f, "服务器地址非法,已丢弃 QuickPlay:{address}") + } + Self::QuickPlayUnsupported { version } => { + write!(f, "版本 {version} 不支持该 QuickPlay 目标,已丢弃") + } + Self::GraphicsBackendSkipped { version } => { + write!(f, "版本 {version} 不支持 --graphicsBackend,已丢弃") + } + } + } +} + +/// 组装结果。 +#[derive(Debug, Clone)] +pub struct AssembledCommand { + /// 实际要 spawn 的程序(通常是 java,带包装器时是包装器)。 + pub program: PathBuf, + /// 参数(不含 `program`)。 + pub args: Vec, + /// 完整 classpath(库 + 主 jar 末位)。 + pub classpath: Vec, + /// 日志解码编码(决定 pump 用什么解码子进程输出)。 + pub encoding: String, + /// 内存设置(与 `-Xmx`/`-Xms` 一致)。 + pub memory: MemoryOptions, + /// 环境变量(实例标识/渲染/加载器/用户自定义)。 + pub env: Vec<(String, String)>, + /// 组装告警。 + pub warnings: Vec, + /// 主类。 + pub main_class: String, +} + +impl AssembledCommand { + /// 完整 argv(`program` 在第一个)。 + pub fn argv(&self) -> Vec { + let mut argv = Vec::with_capacity(self.args.len() + 1); + argv.push(self.program.display().to_string()); + argv.extend(self.args.iter().cloned()); + argv + } + + /// 人读形式(含空格的参数加引号),用于日志与快照测试。 + pub fn display(&self) -> String { + let mut parts = Vec::with_capacity(self.args.len() + 1); + parts.push(quote_for_display(&self.program.display().to_string())); + for arg in &self.args { + parts.push(quote_for_display(arg)); + } + parts.join(" ") + } +} + +/// 组装完整命令行。 +pub fn assemble(req: &LaunchRequest, ctx: &PlatformCtx) -> Result { + let main_class = req + .manifest + .main_class + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(AssemblyError::MissingMainClass)? + .to_string(); + if req.java.binary.as_os_str().is_empty() { + return Err(AssemblyError::EmptyJavaPath.into()); + } + if !req.missing_components.is_empty() { + return Err(AssemblyError::MissingComponents(req.missing_components.clone()).into()); + } + let classpath = effective_classpath(req); + if classpath.is_empty() { + return Err(AssemblyError::EmptyClasspath.into()); + } + + // 第 19 项:占位符表(`${classpath}` 用最终 classpath,主 jar 已在末位)。 + let mut placeholders = PlaceholderMap::from_request(req, ctx); + placeholders.insert( + "classpath", + join_classpath(&classpath, ctx.path_separator()), + ); + + // 第 1~3 项:优先级包装 → 用户包装器 → java 可执行文件。 + let mut head: Vec = Vec::new(); + if let Some(nice) = req.nice { + if ctx.os.is_posix() { + head.push("nice".to_string()); + head.push("-n".to_string()); + head.push(nice.to_string()); + } + } + head.extend(req.wrapper.iter().cloned()); + head.push(req.java.binary.display().to_string()); + let program = PathBuf::from(head.remove(0)); + + let mut builder = CommandBuilder::with_placeholders(placeholders); + for token in &head { + builder.add_without_parsing(token.clone()); + } + + // 第 4 项:最高优先级 JVM 参数(原样追加,且登记 @argfile 展开结果)。 + builder.add_all_without_parsing_and_read_external( + &req.user.override_jvm, + &crate::launch::command_builder::read_argfile, + ); + + let mut warnings = Vec::new(); + + // 第 5 项:`-Xmx` / `-Xms`(`-Xms` 必须 ≤ `-Xmx`)。 + if let Some(max_mb) = req.memory.max_mb { + let _ = builder.add_default("-Xmx", &format!("{max_mb}m")); + } + if let Some(min_mb) = req.memory.min_mb { + match req.memory.max_mb { + Some(max_mb) if min_mb > max_mb => { + warnings.push(AssemblyWarning::MemoryMinIgnored { min_mb, max_mb }); + } + _ => { + let _ = builder.add_default("-Xms", &format!("{min_mb}m")); + } + } + } + + // 第 6 项:Metaspace(Java ≥8)或 PermSize(更早)。 + if req.java.major >= 8 { + let _ = builder.add_default("-XX:MetaspaceSize=", "64m"); + } else { + let _ = builder.add_default("-XX:PermSize=", "64m"); + } + + // 第 7 项:用户 JVM 参数——整体按“默认值”处理。 + builder.add_all_default(&req.user.jvm); + + // 第 8~17 项。 + let (encoding, jvm_warnings) = jvm_args::apply_jvm_arguments(req, ctx, &mut builder); + warnings.extend(jvm_warnings); + + // 第 20~32 项。 + warnings.extend(game_args::apply_game_arguments(req, ctx, &mut builder)); + + // 第 33 项:删除禁用参数。 + builder.remove_forbidden(&defaults::forbidden_args(req.java.major)); + + let env = defaults::environment(req, ctx.os); + Ok(AssembledCommand { + program, + args: builder.into_args(), + classpath, + encoding, + memory: req.memory, + env, + warnings, + main_class, + }) +} + +/// 最终 classpath:库按给定顺序去重,主 jar 永远追加在末位(Mojang 惯例, +/// 影响同名类优先级,`analyse/02` §4.5)。 +pub fn effective_classpath(req: &LaunchRequest) -> Vec { + let mut classpath: Vec = Vec::new(); + let candidates = req + .classpath + .iter() + .chain(std::iter::once(&req.dirs.primary_jar)); + for path in candidates { + if path.as_os_str().is_empty() { + continue; + } + if !classpath.contains(path) { + classpath.push(path.clone()); + } + } + classpath +} diff --git a/crates/core/src/launch/argument.rs b/crates/core/src/launch/argument.rs new file mode 100644 index 0000000..351ba52 --- /dev/null +++ b/crates/core/src/launch/argument.rs @@ -0,0 +1,205 @@ +//! 清单参数模型:`arguments.jvm` / `arguments.game` 的 `{rules, value}` 求值。 +//! +//! 平移自 `analyse/02` §4.7:`Argument` 是 `StringArgument | RuledArgument`, +//! `CompatibilityRule` 默认 `DISALLOW`、按顺序取最后一个命中的 action; +//! `os` 不匹配的规则整体“无效”(不是“不满足”),`features` 必须逐项相等。 + +use crate::launch::request::FeatureSet; + +/// 规则动作。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuleAction { + /// 允许。 + Allow, + /// 禁止。 + Disallow, +} + +/// `rules.os` 子句。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OsRule { + /// `windows` / `osx` / `linux`。 + pub name: Option, + /// `x86` / `x86_64` / `arm64`。 + pub arch: Option, + /// 系统版本模式(Mojang 用正则,这里只做前缀/子串匹配)。 + pub version: Option, +} + +/// 单条规则。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rule { + /// 命中后的动作。 + pub action: RuleAction, + /// 系统条件。 + pub os: Option, + /// 特性条件(逐项相等才算命中)。 + pub features: Vec<(String, bool)>, +} + +impl Rule { + /// 允许规则。 + pub fn allow() -> Self { + Self { + action: RuleAction::Allow, + os: None, + features: Vec::new(), + } + } + + /// 禁止规则。 + pub fn disallow() -> Self { + Self { + action: RuleAction::Disallow, + os: None, + features: Vec::new(), + } + } + + /// 加系统条件。 + pub fn with_os_name(mut self, name: impl Into) -> Self { + let mut os = self.os.take().unwrap_or_default(); + os.name = Some(name.into()); + self.os = Some(os); + self + } + + /// 加特性条件。 + pub fn with_feature(mut self, name: impl Into, expected: bool) -> Self { + self.features.push((name.into(), expected)); + self + } + + /// 求值:`Some(true)` 命中且允许,`Some(false)` 命中且禁止,`None` 不适用(规则无效)。 + pub fn evaluate(&self, ctx: &RuleContext<'_>) -> Option { + if let Some(os) = &self.os { + if let Some(name) = &os.name { + if name != ctx.os_name { + return None; + } + } + if let Some(arch) = &os.arch { + if !arch_matches(arch, ctx.os_arch) { + return None; + } + } + if let Some(pattern) = &os.version { + if !version_matches(pattern, ctx.os_version) { + return None; + } + } + } + for (name, expected) in &self.features { + if ctx.features.get(name) != Some(*expected) { + return None; + } + } + Some(self.action == RuleAction::Allow) + } +} + +/// 一个参数项:一组规则 + 一组取值(取值里可含 `${...}` 占位符)。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgumentEntry { + /// 规则(空表示无条件适用)。 + pub rules: Vec, + /// 取值(每个元素是一个独立命令行 token)。 + pub values: Vec, +} + +impl ArgumentEntry { + /// 无条件参数项。 + pub fn plain(values: impl IntoIterator>) -> Self { + Self { + rules: Vec::new(), + values: values.into_iter().map(Into::into).collect(), + } + } + + /// 带规则参数项。 + pub fn ruled(rules: Vec, values: impl IntoIterator>) -> Self { + Self { + rules, + values: values.into_iter().map(Into::into).collect(), + } + } + + /// 是否在当前环境下适用。 + pub fn applies(&self, ctx: &RuleContext<'_>) -> bool { + if self.rules.is_empty() { + return true; + } + let mut allowed = false; + for rule in &self.rules { + if let Some(action) = rule.evaluate(ctx) { + allowed = action; + } + } + allowed + } +} + +/// rules 求值上下文。 +#[derive(Debug, Clone, Copy)] +pub struct RuleContext<'a> { + /// `rules.os.name` 取值。 + pub os_name: &'a str, + /// `rules.os.arch` 取值。 + pub os_arch: &'a str, + /// `rules.os.version` 取值。 + pub os_version: &'a str, + /// 特性开关。 + pub features: &'a FeatureSet, +} + +impl<'a> RuleContext<'a> { + /// 构造。 + pub fn new( + os_name: &'a str, + os_arch: &'a str, + os_version: &'a str, + features: &'a FeatureSet, + ) -> Self { + Self { + os_name, + os_arch, + os_version, + features, + } + } +} + +fn arch_matches(rule_arch: &str, actual: &str) -> bool { + if rule_arch == actual { + return true; + } + // Mojang 用 "x86" 表示 32 位,我们的实际值可能是 "i686" 之类。 + matches!( + (rule_arch, actual), + ("x86", "i686") | ("x86_64", "amd64") | ("arm64", "aarch64") + ) +} + +/// 只做保守匹配:支持 `^10\.` / `^10\.15` 这类前缀模式与纯子串,无法识别的模式按“不匹配”处理 +/// (宁可少加一个参数,也不要给错误平台加参数)。 +/// +/// 处理:去掉前置 `^` 与结尾 `$`、`\.` 反转义成 `.`、去掉结尾的点(`^10\.` 的语义就是 +/// “以 `10.` 开头”,点本身不参与前缀比较)。含 `[ ( { | * + ?` 等真需要正则的模式一律不匹配。 +fn version_matches(pattern: &str, actual: &str) -> bool { + if actual.is_empty() { + return false; + } + let trimmed = pattern.trim(); + let stripped = trimmed.strip_prefix('^').unwrap_or(trimmed); + if stripped.contains(['[', '(', '{', '|', '*', '+', '?']) { + return false; + } + let mut literal = stripped.replace("\\.", "."); + while literal.ends_with(['$', '.']) { + literal.pop(); + } + if literal.is_empty() { + return false; + } + actual.starts_with(&literal) || actual.contains(&literal) +} diff --git a/crates/core/src/launch/command_builder.rs b/crates/core/src/launch/command_builder.rs new file mode 100644 index 0000000..da54304 --- /dev/null +++ b/crates/core/src/launch/command_builder.rs @@ -0,0 +1,335 @@ +//! `CommandBuilder`:命令行组装器与“默认值抑制”语义。 +//! +//! 平移自 `analyse/02` §4.2(`CommandBuilder.java`)——这是 HMCL 命令行正确性的基石, +//! 也是“用户参数永远优先、我们只补缺”的唯一可靠实现: +//! +//! - [`CommandBuilder::add`] 无条件追加(占位符已替换); +//! - [`CommandBuilder::add_without_parsing`] 原样追加(已带引号或变量的串); +//! - [`CommandBuilder::add_default`] 若已存在同前缀参数则不追加(opt=前缀,如 `-Xmx`); +//! - [`CommandBuilder::add_unstable_flag`] / [`CommandBuilder::add_unstable_default`] +//! 按 `-XX:key=value` 与 `-XX:±key` 两种形态判重,避免同时出现 `+UseG1GC` 与 `-UseG1GC`; +//! - [`CommandBuilder::add_all_default`] 把一组参数整体按“默认值”处理(`-D` 拆键值、 +//! `-XX:` 走 unstable 分支、`-Xmx/-Xms/-Xmn/-Xss` 拆前缀、`-cp` 成对处理,其余要求完全相同); +//! - `@argfile` 感知:Java 9+ 的 `@options-file` 展开后的参数登记进 `external`, +//! 让后续默认值同样被抑制。 +//! +//! 与 HMCL 的差异(已记录在 `DevDocs/SubAgentsTalk/launch-接口约定.md` §7):我们直接 spawn +//! 子进程、不经过 shell,因此不做批处理/Shell 转义;转义只在“生成启动脚本”(P1)时需要。 + +use std::path::Path; + +use crate::launch::placeholders::PlaceholderMap; + +/// 命令行组装器。 +#[derive(Debug, Clone, Default)] +pub struct CommandBuilder { + args: Vec, + external: Vec, + placeholders: PlaceholderMap, +} + +impl CommandBuilder { + /// 空构造器。 + pub fn new() -> Self { + Self::default() + } + + /// 带占位符表的构造器。 + pub fn with_placeholders(placeholders: PlaceholderMap) -> Self { + Self { + args: Vec::new(), + external: Vec::new(), + placeholders, + } + } + + /// 占位符表。 + pub fn placeholders(&self) -> &PlaceholderMap { + &self.placeholders + } + + /// 追加一个参数(替换占位符)。 + pub fn add(&mut self, arg: &str) { + let rendered = self.placeholders.render(arg); + self.args.push(rendered); + } + + /// 追加一组参数(替换占位符)。 + pub fn add_all(&mut self, args: &[String]) { + for arg in args { + self.add(arg); + } + } + + /// 原样追加(不替换占位符)。 + pub fn add_without_parsing(&mut self, arg: impl Into) { + self.args.push(arg.into()); + } + + /// 原样追加一组。 + pub fn add_all_without_parsing(&mut self, args: &[String]) { + for arg in args { + self.add_without_parsing(arg.clone()); + } + } + + /// 原样追加一组,并读取其中的 `@argfile`: + /// 文件里展开出来的参数登记为 `external`,后续 [`Self::add_default`] 会被它们抑制。 + pub fn add_all_without_parsing_and_read_external( + &mut self, + args: &[String], + read_argfile: &dyn Fn(&str) -> Option, + ) { + for arg in args { + self.add_without_parsing(arg.clone()); + if let Some(path) = arg.strip_prefix('@') { + if let Some(contents) = read_argfile(path) { + for extra in parse_argfile(&contents) { + self.external.push(extra); + } + } + } + } + } + + /// 已存在以 `prefix` 开头的参数则返回它,否则追加 `prefix + value` 并返回 `None`。 + /// + /// 返回值的语义与 HMCL 一致:`None` = “是我加的”,`Some(已有值)` = “用户已经指定过”。 + pub fn add_default(&mut self, prefix: &str, value: &str) -> Option { + if let Some(existing) = self.find_starting_with(prefix) { + return Some(existing.to_string()); + } + let rendered = self.placeholders.render(&format!("{prefix}{value}")); + self.args.push(rendered); + None + } + + /// `-XX:key=value` 形态的默认值;已存在该 key 的任何形态则不追加。 + pub fn add_unstable_default(&mut self, key: &str, value: &str) -> Option { + if let Some(existing) = self.find_unstable(key) { + return Some(existing.to_string()); + } + let rendered = self.placeholders.render(&format!("-XX:{key}={value}")); + self.args.push(rendered); + None + } + + /// `-XX:+key` / `-XX:-key` 形态的默认值;已存在该 key 的任何形态则不追加。 + pub fn add_unstable_flag(&mut self, key: &str, enabled: bool) -> Option { + if let Some(existing) = self.find_unstable(key) { + return Some(existing.to_string()); + } + let sign = if enabled { '+' } else { '-' }; + self.args.push(format!("-XX:{sign}{key}")); + None + } + + /// 一组参数整体按“默认值”处理。 + pub fn add_all_default(&mut self, args: &[String]) { + let mut index = 0; + while index < args.len() { + let raw = args[index].trim(); + if raw.is_empty() { + index += 1; + continue; + } + let resolved = self.placeholders.render(raw); + if matches!(raw, "-cp" | "-classpath") { + let exists = self.find_starting_with(raw).is_some(); + if !exists { + self.args.push(resolved); + if let Some(value) = args.get(index + 1) { + let value = self.placeholders.render(value); + self.args.push(value); + } + } + index += 2; + continue; + } + if self.try_add_key_value_default(&resolved) { + index += 1; + continue; + } + let exists = self + .args + .iter() + .chain(self.external.iter()) + .any(|arg| arg == &resolved); + if !exists { + self.args.push(resolved); + } + index += 1; + } + } + + /// 已存在的、以 `prefix` 开头的参数。 + pub fn find_starting_with(&self, prefix: &str) -> Option<&str> { + self.args + .iter() + .chain(self.external.iter()) + .find(|arg| arg.starts_with(prefix)) + .map(String::as_str) + } + + /// 已存在的、提到 `key` 的 `-XX:` 参数(两种形态都算)。 + pub fn find_unstable(&self, key: &str) -> Option<&str> { + self.args + .iter() + .chain(self.external.iter()) + .find(|arg| unstable_key(arg) == Some(key)) + .map(String::as_str) + } + + /// 是否已存在完全相同的参数。 + pub fn contains_exact(&self, arg: &str) -> bool { + self.args.iter().any(|existing| existing == arg) + } + + /// 删除禁用参数(例如 Java ≥9 上的 `-Xincgc`),返回被删掉的项。 + pub fn remove_forbidden(&mut self, forbidden: &[&str]) -> Vec { + let mut removed = Vec::new(); + self.args.retain(|arg| { + if forbidden.contains(&arg.as_str()) { + removed.push(arg.clone()); + false + } else { + true + } + }); + removed + } + + /// 当前参数列表。 + pub fn args(&self) -> &[String] { + &self.args + } + + /// 取走参数列表。 + pub fn into_args(self) -> Vec { + self.args + } + + /// 人读形式(含空格的参数加双引号),用于日志与快照测试。 + pub fn display_args(&self) -> String { + self.args + .iter() + .map(|arg| quote_for_display(arg)) + .collect::>() + .join(" ") + } + + fn try_add_key_value_default(&mut self, resolved: &str) -> bool { + if let Some(rest) = resolved.strip_prefix("-D") { + if let Some(eq) = rest.find('=') { + let split = 2 + eq + 1; + let prefix = resolved[..split].to_string(); + let value = resolved[split..].to_string(); + let _ = self.add_default(&prefix, &value); + return true; + } + } + if let Some(rest) = resolved.strip_prefix("-XX:") { + if let Some(eq) = rest.find('=') { + let _ = self.add_unstable_default(&rest[..eq], &rest[eq + 1..]); + return true; + } + if let Some(signless) = rest.strip_prefix(['+', '-']) { + let _ = self.add_unstable_flag(signless, rest.starts_with('+')); + return true; + } + } + for prefix in ["-Xmx", "-Xms", "-Xmn", "-Xss"] { + if let Some(value) = resolved.strip_prefix(prefix) { + let _ = self.add_default(prefix, value); + return true; + } + } + false + } +} + +fn unstable_key(arg: &str) -> Option<&str> { + let rest = arg.strip_prefix("-XX:")?; + let body = rest.strip_prefix(['+', '-']).unwrap_or(rest); + Some(body.split('=').next().unwrap_or(body)) +} + +/// 把 `minecraftArguments` 之类的一整串拆成参数(引号感知,反斜杠转义 `"` 与 `\`)。 +pub fn tokenize(text: &str) -> Vec { + let mut out = Vec::new(); + let mut current = String::new(); + let mut has_token = false; + let mut in_single = false; + let mut in_double = false; + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '\'' if !in_double => { + in_single = !in_single; + has_token = true; + } + '"' if !in_single => { + in_double = !in_double; + has_token = true; + } + '\\' if !in_single => match chars.peek() { + Some(&next) if next == '"' || next == '\\' => { + current.push(next); + chars.next(); + has_token = true; + } + _ => { + current.push(ch); + has_token = true; + } + }, + c if c.is_whitespace() && !in_single && !in_double => { + if has_token { + out.push(std::mem::take(&mut current)); + has_token = false; + } + } + c => { + current.push(c); + has_token = true; + } + } + } + if has_token { + out.push(current); + } + out +} + +/// 解析 `@argfile` 内容:忽略空行与 `#` 注释行,每行再走 [`tokenize`]。 +pub fn parse_argfile(contents: &str) -> Vec { + let mut out = Vec::new(); + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + out.extend(tokenize(line)); + } + out +} + +/// 从磁盘读取 `@argfile`(读不到就当没有;不因用户文件缺失而中断启动)。 +pub fn read_argfile(path: &str) -> Option { + let path = path.strip_prefix('@').unwrap_or(path); + std::fs::read(Path::new(path)) + .ok() + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) +} + +/// 人读引用:含空白或引号的参数加双引号并转义内部引号。 +pub fn quote_for_display(arg: &str) -> String { + if arg.is_empty() { + return "\"\"".to_string(); + } + if arg.contains([' ', '\t', '"']) { + format!("\"{}\"", arg.replace('"', "\\\"")) + } else { + arg.to_string() + } +} diff --git a/crates/core/src/launch/crash_scan.rs b/crates/core/src/launch/crash_scan.rs new file mode 100644 index 0000000..66da94b --- /dev/null +++ b/crates/core/src/launch/crash_scan.rs @@ -0,0 +1,152 @@ +//! 崩溃现场扫描:`crash-reports/*.txt`、`hs_err_pid*.log`、`logs/latest.log`。 +//! +//! 按 `DevDocs/SubAgentsTalk/launch-接口约定.md` §3:`crash_found` 事件的 `path` 是 +//! **权威字段**(M15 按路径自己读盘,`excerpt` 仅供人读)。所以这里只做“找到并给出路径”, +//! 不做解析——解析(hs_err 的失败帧/内存段)是 M15 的 `hs_err` 解析器。 +//! +//! 只收**本次会话之后**动过的文件(`since` = 进程启动时刻),避免把上一局的崩溃报告 +//! 算到这一局头上(这是 HMCL 会犯的错)。 + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +/// 崩溃现场文件来源。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CrashSource { + /// `crash-reports/crash-*.txt`(游戏自己写的权威 Description + 栈)。 + CrashReport, + /// JVM 致命错误 `hs_err_pid*.log`(HMCL 完全不解析的空白区)。 + HsErr, + /// `logs/latest.log`(内存缓冲不够长时的兜底全文)。 + LatestLog, +} + +impl CrashSource { + /// 事件流里的字符串。 + pub fn as_str(self) -> &'static str { + match self { + Self::CrashReport => "crash_report", + Self::HsErr => "hs_err", + Self::LatestLog => "latest_log", + } + } +} + +/// 一个现场文件。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CrashArtifact { + /// 来源类型。 + pub source: CrashSource, + /// 绝对/相对路径(交给 M15)。 + pub path: PathBuf, + /// 供人读的开头片段(`latest.log` 不给,太大)。见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §4(写入前脱敏)。 + pub excerpt: Option, +} + +/// 默认摘录上限(字符)。 +pub const MAX_EXCERPT_CHARS: usize = 2000; + +/// 扫描一次运行目录,返回按修改时间倒序的现场文件(最多 `limit` 个)。 +/// +/// `since` 之前的文件视为上一局遗留,忽略;给 `since` 留 1 秒容差以吸收文件系统时间精度。 +pub fn scan(run_dir: &Path, since: SystemTime, limit: usize) -> Vec { + let since = since.checked_sub(Duration::from_secs(1)).unwrap_or(since); + let mut found: Vec<(SystemTime, CrashArtifact)> = Vec::new(); + + collect_matching( + &run_dir.join("crash-reports"), + CrashSource::CrashReport, + since, + &mut found, + ); + collect_matching(run_dir, CrashSource::HsErr, since, &mut found); + if let Some(artifact) = latest_log(run_dir, since) { + let modified = artifact_time(&artifact.path).unwrap_or(since); + found.push((modified, artifact)); + } + + found.sort_by_key(|item| std::cmp::Reverse(item.0)); + found + .into_iter() + .take(limit) + .map(|(_, artifact)| artifact) + .collect() +} + +fn collect_matching( + dir: &Path, + source: CrashSource, + since: SystemTime, + out: &mut Vec<(SystemTime, CrashArtifact)>, +) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !is_candidate(&path, source) { + continue; + } + let Some(modified) = artifact_time(&path) else { + continue; + }; + if modified < since { + continue; + } + out.push(( + modified, + CrashArtifact { + source, + path: path.clone(), + excerpt: read_excerpt(&path, MAX_EXCERPT_CHARS), + }, + )); + } +} + +fn is_candidate(path: &Path, source: CrashSource) -> bool { + if !path.is_file() { + return false; + } + let name = path + .file_name() + .map(|name| name.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + match source { + CrashSource::CrashReport => name.ends_with(".txt"), + CrashSource::HsErr => name.starts_with("hs_err_pid") && name.ends_with(".log"), + CrashSource::LatestLog => false, + } +} + +fn latest_log(run_dir: &Path, since: SystemTime) -> Option { + let path = run_dir.join("logs").join("latest.log"); + let modified = artifact_time(&path)?; + if modified < since { + return None; + } + Some(CrashArtifact { + source: CrashSource::LatestLog, + path, + excerpt: None, + }) +} + +fn artifact_time(path: &Path) -> Option { + std::fs::metadata(path).ok()?.modified().ok() +} + +/// 读文件开头 `max_chars` 个字符(读不到就 `None`,不因权限/编码问题中断收尾)。 +pub fn read_excerpt(path: &Path, max_chars: usize) -> Option { + let bytes = std::fs::read(path).ok()?; + let text = String::from_utf8_lossy(&bytes); + let mut excerpt = String::new(); + for (count, ch) in text.chars().enumerate() { + if count >= max_chars { + excerpt.push('…'); + break; + } + excerpt.push(ch); + } + Some(excerpt) +} diff --git a/crates/core/src/launch/defaults.rs b/crates/core/src/launch/defaults.rs new file mode 100644 index 0000000..ffcc157 --- /dev/null +++ b/crates/core/src/launch/defaults.rs @@ -0,0 +1,203 @@ +//! 默认参数表:JVM 优化、编码、log4j、禁用参数、环境变量。 +//! +//! 平移自 `analyse/02` §4.3(自动优化)、§4.4(编码/Log4j)、§4.1 #33(禁用参数)、 +//! §4.10(环境变量)。所有函数都是纯函数——同样的输入永远给同样的参数,便于快照测试。 + +use crate::launch::platform::OsFamily; +use crate::launch::request::{LaunchRequest, ModLoaderKind, RendererOptions, Resolution}; + +/// 旧版清单的默认 JVM 参数(`analyse/02` §4.7 的 `DEFAULT_JVM_ARGUMENTS`)。 +/// +/// 现代清单把这两项写在自己的 `arguments.jvm` 里,只有 legacy 需要补。 +pub fn legacy_jvm_arguments() -> Vec { + vec![ + "-Djava.library.path=${natives_directory}".to_string(), + "-cp".to_string(), + "${classpath}".to_string(), + ] +} + +/// stdout/stderr 编码参数。 +/// +/// Java ≥19 用 `-Dstdout.encoding`,更早用 `-Dsun.stdout.encoding`(`analyse/02` §4.4: +/// 用户指定的 `-Dfile.encoding` 会决定日志泵的解码方式,这里必须跟随)。 +pub fn stream_encoding_args(java_major: u32, encoding: &str) -> Vec { + if java_major >= 19 { + vec![ + format!("-Dstdout.encoding={encoding}"), + format!("-Dstderr.encoding={encoding}"), + ] + } else { + vec![ + format!("-Dsun.stdout.encoding={encoding}"), + format!("-Dsun.stderr.encoding={encoding}"), + ] + } +} + +/// Log4j2 RCE 缓解三件套(`analyse/02` §4.1 #9)。 +pub fn log4j_mitigation_args() -> Vec { + vec![ + "-Djava.rmi.server.useCodebaseOnly=true".to_string(), + "-Dcom.sun.jndi.rmi.object.trustURLCodebase=false".to_string(), + "-Dcom.sun.jndi.cosnaming.object.trustURLCodebase=false".to_string(), + ] +} + +/// 自动优化参数(`analyse/02` §4.3)。 +/// +/// `user_specified_gc` 为真时不注入 G1 整组(用户显式挑了 GC 就别打架); +/// 其它每一条都还会被 `CommandBuilder` 的默认值抑制再筛一遍。 +pub fn optimization_args( + java_major: u32, + bits: u32, + physical_memory_mb: Option, + user_specified_gc: bool, +) -> Vec { + let mut args = vec![ + "-XX:+UnlockExperimentalVMOptions".to_string(), + "-XX:+UnlockDiagnosticVMOptions".to_string(), + ]; + if java_major >= 8 && !user_specified_gc { + for arg in [ + "-XX:+UseG1GC", + "-XX:G1MixedGCCountTarget=5", + "-XX:G1NewSizePercent=20", + "-XX:G1ReservePercent=20", + "-XX:MaxGCPauseMillis=50", + "-XX:G1HeapRegionSize=32m", + ] { + args.push(arg.to_string()); + } + } + // 崩溃诊断关键项:关掉“快速抛出时省略栈”,保证日志里有完整堆栈。 + args.push("-XX:-OmitStackTraceInFastThrow".to_string()); + if java_major <= 8 { + args.push("-XX:MaxInlineLevel=15".to_string()); + } + if bits >= 64 && physical_memory_mb.is_some_and(|mb| mb > 4096) { + for arg in [ + "-XX:-DontCompileHugeMethods", + "-XX:MaxNodeLimit=240000", + "-XX:NodeLimitFudgeFactor=8000", + "-XX:TieredCompileTaskTimeout=10000", + "-XX:ReservedCodeCacheSize=400M", + "-XX:NonNMethodCodeHeapSize=12M", + "-XX:ProfiledCodeHeapSize=194M", + "-XX:NmethodSweepActivity=1", + ] { + args.push(arg.to_string()); + } + } + if bits >= 64 && (25..=26).contains(&java_major) { + args.push("-XX:+UseCompactObjectHeaders".to_string()); + } + args +} + +/// 版本特定的 JVM 开关(`analyse/02` §4.1 #15)。 +pub fn version_specific_args(java_major: u32) -> Vec { + match java_major { + 16 => vec!["--illegal-access=permit".to_string()], + 24 | 25 => vec!["--sun-misc-unsafe-memory-access=allow".to_string()], + _ => Vec::new(), + } +} + +/// Forge/NeoForge 的证书校验豁免(`analyse/02` §4.1 #16)。 +pub fn fml_args() -> Vec { + vec![ + "-Dfml.ignoreInvalidMinecraftCertificates=true".to_string(), + "-Dfml.ignorePatchDiscrepancies=true".to_string(), + ] +} + +/// 渲染器相关 `-D` 参数(`analyse/02` §4.1 #17,v1 只做 native 路径覆盖)。 +/// +/// 软件渲染的细节开关走环境变量([`environment`]),因为 Mesa/llvmpipe 读的是 env。 +pub fn renderer_args(renderer: &RendererOptions) -> Vec { + let mut args = Vec::new(); + if let Some(dir) = &renderer.mesa_native_dir { + args.push(format!("-Dorg.lwjgl.librarypath={}", dir.display())); + } + args +} + +/// 被禁止的参数(最后统一清理,`analyse/02` §4.1 #33)。 +pub fn forbidden_args(java_major: u32) -> Vec<&'static str> { + if java_major >= 9 { + vec!["-Xincgc"] + } else { + Vec::new() + } +} + +/// 旧版清单的默认游戏参数(分辨率,`analyse/02` §4.1 #26)。 +pub fn legacy_game_args(resolution: Option<&Resolution>) -> Vec { + let mut args = Vec::new(); + if let Some(res) = resolution { + args.push("--width".to_string()); + args.push(res.width.to_string()); + args.push("--height".to_string()); + args.push(res.height.to_string()); + } + args +} + +/// 进程环境变量(`analyse/02` §4.10)。 +/// +/// 顺序:实例标识 → 渲染器开关 → 加载器标记 → 用户自定义(最后覆盖)。 +pub fn environment(req: &LaunchRequest, os: OsFamily) -> Vec<(String, String)> { + let mut env: Vec<(String, String)> = Vec::new(); + set_env(&mut env, "INST_NAME", &req.instance.name); + set_env(&mut env, "INST_ID", &req.instance.id); + set_env( + &mut env, + "INST_DIR", + &req.dirs.game_dir.display().to_string(), + ); + set_env( + &mut env, + "INST_MC_DIR", + &req.dirs.game_dir.display().to_string(), + ); + set_env( + &mut env, + "INST_JAVA", + &req.java.binary.display().to_string(), + ); + if req.mod_loader == ModLoaderKind::OptiFine { + set_env(&mut env, "INST_OPTIFINE", "1"); + } + if req.renderer.software { + set_env(&mut env, "LIBGL_ALWAYS_SOFTWARE", "1"); + set_env(&mut env, "MESA_LOADER_DRIVER_OVERRIDE", "llvmpipe"); + set_env(&mut env, "GALLIUM_DRIVER", "llvmpipe"); + } + if os == OsFamily::MacOs { + if let Some(icd) = &req.renderer.vulkan_icd { + set_env(&mut env, "VK_ICD_FILENAMES", &icd.display().to_string()); + set_env(&mut env, "VK_DRIVER_FILES", &icd.display().to_string()); + } + } + match req.mod_loader { + ModLoaderKind::Forge => set_env(&mut env, "FORGE_INSTALL", "1"), + ModLoaderKind::NeoForge => set_env(&mut env, "NEOFORGE_INSTALL", "1"), + ModLoaderKind::Fabric => set_env(&mut env, "FABRIC_INSTALL", "1"), + ModLoaderKind::Quilt => set_env(&mut env, "QUILT_INSTALL", "1"), + ModLoaderKind::LiteLoader => set_env(&mut env, "LITELOADER_INSTALL", "1"), + ModLoaderKind::OptiFine | ModLoaderKind::Vanilla => {} + } + for (key, value) in &req.user.environment { + set_env(&mut env, key, value); + } + env +} + +fn set_env(env: &mut Vec<(String, String)>, key: &str, value: &str) { + if let Some(slot) = env.iter_mut().find(|(existing, _)| existing == key) { + slot.1 = value.to_string(); + } else { + env.push((key.to_string(), value.to_string())); + } +} diff --git a/crates/core/src/launch/diagnosis_hook.rs b/crates/core/src/launch/diagnosis_hook.rs new file mode 100644 index 0000000..889c7c6 --- /dev/null +++ b/crates/core/src/launch/diagnosis_hook.rs @@ -0,0 +1,97 @@ +//! 诊断挂钩:launch 与 M15(`core::diagnose`)之间的唯一接缝。 +//! +//! 分工(`DevDocs/SubAgentsTalk/launch-接口约定.md` §3):**launch 报事实、M15 下结论**。 +//! launch 在进程结束后把「退出判定 + 日志环形缓冲 + 崩溃现场文件 + 环境快照」打包成 +//! [`DiagnosisRequest`] 交给挂钩;挂钩返回结构化 payload(`candidates[]` 等),由 launch +//! 脱敏后写进同一个 JSONL(单一真相源)。 +//! +//! 这样 M15 不需要 import launch 的类型,launch 也不需要 import diagnose 的类型—— +//! 阶段 B 的 diagnose 尚未合并,P0 先用 [`NoopDiagnosis`] 占位。 + +use std::path::PathBuf; + +use serde_json::Value; + +use crate::launch::crash_scan::CrashArtifact; +use crate::launch::exit_analysis::ExitAnalysis; +use crate::launch::pump::LogLine; + +/// 诊断挂钩。 +pub trait DiagnosisHook: Send + Sync { + /// 需要诊断时调用;返回 `None` 表示“这次没有结论”(事件流里就不会有 `diagnosis` 事件)。 + fn analyze(&self, request: &DiagnosisRequest) -> Option; +} + +/// 空挂钩:不诊断(占位 / 用户关闭 AI)。 +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopDiagnosis; + +impl DiagnosisHook for NoopDiagnosis { + fn analyze(&self, _request: &DiagnosisRequest) -> Option { + None + } +} + +/// 交给诊断挂钩的完整事实包。 +#[derive(Debug, Clone)] +pub struct DiagnosisRequest { + /// 会话 id(M15 可据此回读 jsonl)。 + pub session_id: String, + /// 实例 id。 + pub instance_id: String, + /// 实例名。 + pub instance_name: String, + /// 游戏版本。 + pub game_version: String, + /// 退出判定。 + pub exit: ExitAnalysis, + /// 存活毫秒数。 + pub uptime_ms: u64, + /// 环形缓冲里的日志行(按时间顺序,已脱敏)。 + pub log_lines: Vec, + /// 崩溃现场文件(crash-report / hs_err / latest.log)。 + pub artifacts: Vec, + /// 脱敏后的完整 argv。 + pub argv: Vec, + /// 最大堆(MiB)。 + pub memory_mb: Option, + /// Java 主版本。 + pub java_major: u32, + /// Java 可执行文件。 + pub java_binary: PathBuf, + /// 运行目录。 + pub game_dir: PathBuf, +} + +impl DiagnosisRequest { + /// 错误级日志行(M15 规则匹配的主输入)。 + pub fn error_lines(&self) -> Vec { + self.log_lines + .iter() + .filter(|line| line.is_error()) + .map(|line| line.text.clone()) + .collect() + } + + /// 全部日志拼成文本(M15 自己的 `LogBuffer` 也能直接用 `log_lines`)。 + pub fn log_text(&self) -> String { + let mut text = String::new(); + for line in &self.log_lines { + text.push_str(&line.text); + text.push('\n'); + } + text + } + + /// 一句话摘要(日志/UI 展示)。 + pub fn summary(&self) -> String { + format!( + "会话 {} 退出:[{}];错误行 {};现场文件 {};需诊断={}", + self.session_id, + self.exit.summary(), + self.error_lines().len(), + self.artifacts.len(), + self.exit.needs_diagnosis + ) + } +} diff --git a/crates/core/src/launch/error.rs b/crates/core/src/launch/error.rs new file mode 100644 index 0000000..1d5d435 --- /dev/null +++ b/crates/core/src/launch/error.rs @@ -0,0 +1,165 @@ +//! launch 模块统一错误类型。 +//! +//! 设计依据:`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §1、`analyse/02` §8.4-3 +//! (缺库/缺组件必须结构化报错并显式失败,而不是交给运行时 `NoClassDefFoundError`)。 + +use std::fmt; +use std::path::PathBuf; + +/// launch 相关操作的统一结果类型。 +pub type Result = std::result::Result; + +/// 缺失的组件(库 / natives / 资产),供 UI 与 agent 直接展示与修复。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MissingComponent { + /// 逻辑名,例如 `org.lwjgl:lwjgl:3.3.1` 或 `libraries/foo/bar.jar`。 + pub name: String, + /// 期望落盘路径(未知时为 `None`)。 + pub path: Option, +} + +impl MissingComponent { + /// 只按名字构造(路径未知)。 + pub fn named(name: impl Into) -> Self { + Self { + name: name.into(), + path: None, + } + } + + /// 带期望路径构造。 + pub fn at_path(name: impl Into, path: impl Into) -> Self { + Self { + name: name.into(), + path: Some(path.into()), + } + } +} + +/// 命令行组装阶段的失败原因(全部是“启动前就能确定”的错误)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AssemblyError { + /// 清单里没有主类,无法启动(HMCL 在此直接抛异常)。 + MissingMainClass, + /// classpath 为空(一个库和主 jar 都没有)。 + EmptyClasspath, + /// Java 可执行文件路径为空。 + EmptyJavaPath, + /// 组件不完整:启动前显式失败,附结构化清单。 + MissingComponents(Vec), + /// 清单参数畸形(例如空参数项)。 + MalformedArgument { + /// 原始参数项文本。 + entry: String, + /// 原因说明。 + reason: &'static str, + }, + /// natives 解压失败(jar 缺失/损坏、目录不可写)。 + /// + /// 归到「启动前失败」而不是运行时错误:native 库没落地时游戏只会给出 + /// `UnsatisfiedLinkError`,那时候已经离「到底哪个 jar 坏了」太远了。 + NativesExtraction { + /// 人读原因(含失败路径)。 + message: String, + }, +} + +impl fmt::Display for AssemblyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingMainClass => f.write_str("版本清单缺少主类(mainClass)"), + Self::EmptyClasspath => f.write_str("classpath 为空:没有任何库或主 jar"), + Self::EmptyJavaPath => f.write_str("Java 可执行文件路径为空"), + Self::MissingComponents(items) => { + write!(f, "组件缺失 {} 项", items.len())?; + for item in items.iter().take(8) { + write!(f, "; {}", item.name)?; + } + Ok(()) + } + Self::MalformedArgument { entry, reason } => { + write!(f, "参数畸形({reason}):{entry}") + } + Self::NativesExtraction { message } => { + write!(f, "natives 解压失败:{message}") + } + } + } +} + +impl std::error::Error for AssemblyError {} + +/// launch 模块的统一错误。 +#[derive(Debug)] +pub enum LaunchError { + /// 命令行组装失败(纯计算,可单测)。 + Assembly(AssemblyError), + /// 文件系统操作失败。 + Io { + /// 操作名(`create_session` / `read_argfile` 等)。 + op: &'static str, + /// 相关路径。 + path: PathBuf, + /// 底层错误。 + source: std::io::Error, + }, + /// 进程派生失败(Java 不存在、权限不足等)。 + Spawn { + /// 被执行的程序。 + program: PathBuf, + /// 底层错误。 + source: std::io::Error, + }, + /// 会话事件流写入/解析失败。 + Session { + /// 会话文件路径。 + path: PathBuf, + /// 原因说明。 + reason: String, + }, + /// 进程树 kill 失败。 + Kill { + /// 目标进程号。 + pid: u32, + /// 底层错误。 + source: std::io::Error, + }, +} + +impl fmt::Display for LaunchError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Assembly(err) => write!(f, "命令行组装失败:{err}"), + Self::Io { op, path, source } => { + write!(f, "{op} 失败({}):{source}", path.display()) + } + Self::Spawn { program, source } => { + write!(f, "启动进程失败({}):{source}", program.display()) + } + Self::Session { path, reason } => { + write!(f, "会话事件流错误({}):{reason}", path.display()) + } + Self::Kill { pid, source } => { + write!(f, "结束进程树失败(pid {pid}):{source}") + } + } + } +} + +impl std::error::Error for LaunchError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Assembly(err) => Some(err), + Self::Io { source, .. } | Self::Spawn { source, .. } | Self::Kill { source, .. } => { + Some(source) + } + Self::Session { .. } => None, + } + } +} + +impl From for LaunchError { + fn from(err: AssemblyError) -> Self { + Self::Assembly(err) + } +} diff --git a/crates/core/src/launch/exit_analysis.rs b/crates/core/src/launch/exit_analysis.rs new file mode 100644 index 0000000..a4bc842 --- /dev/null +++ b/crates/core/src/launch/exit_analysis.rs @@ -0,0 +1,264 @@ +//! 退出分析:退出码语义 + `exit 0` 静默失败检测。 +//! +//! 平移自 `analyse/02` §6.3/§7.2(`ExitWaiter` 的三源判定),并按 §8.4-2 / §7.10-10 做两处升级: +//! +//! 1. **完整退出码表**:不只认 Linux 的 `137`,Windows 的 NTSTATUS +//! (`0xC0000005` 等)也要能说清是什么; +//! 2. **静默失败单独立标**:退出码 0 但存活 <30s 或 stderr 带错误特征时标记 +//! `silent_failure`——HMCL 会判 NORMAL 放过(LaunchWrapper 吞异常后 `exit 0` 是 +//! 真实存在的失败形态)。 + +use crate::launch::pump::LogLine; + +/// 退出判定类型。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExitType { + /// 正常退出。 + Normal, + /// 应用层崩溃(游戏起来了但崩了)。 + ApplicationError, + /// JVM 都没起来(`-Xmx` 过大、JVM 参数非法等)。 + JvmError, + /// 被 `SIGKILL` 结束(POSIX 137)。 + SigKill, + /// 被 `SIGTERM` 结束(POSIX 143)。 + SigTerm, + /// 被启动器主动结束(用户点取消 / 看门狗强制)。 + Killed, + /// 等待被中断。 + Interrupted, +} + +impl ExitType { + /// 事件流里的字符串形式(与 M15 `ExitType` 刻意保持一致)。 + pub fn as_str(self) -> &'static str { + match self { + Self::Normal => "normal", + Self::ApplicationError => "application_error", + Self::JvmError => "jvm_error", + Self::SigKill => "sigkill", + Self::SigTerm => "sigterm", + Self::Killed => "killed", + Self::Interrupted => "interrupted", + } + } + + /// 是否属于“非正常退出”。 + pub fn is_abnormal(self) -> bool { + self != Self::Normal + } +} + +/// JVM 未能启动的三条关键字(`analyse/02` §7.2)。 +pub const JVM_FAILURE_KEYWORDS: &[&str] = &[ + "Could not create the Java Virtual Machine.", + "Error occurred during initialization of VM", + "A fatal exception has occurred. Program will exit.", +]; + +/// 应用层崩溃的五条关键字(`analyse/02` §7.2,覆盖三种崩法)。 +pub const CRASH_KEYWORDS: &[&str] = &[ + "Crash report saved to", + "Could not save crash report to", + "This crash report has been saved to:", + "Unable to launch", + "An exception was thrown, the game will display an error screen and halt.", +]; + +/// 静默失败的存活阈值(毫秒):<30s 视为“没真正跑起来”。 +pub const SHORT_LIVED_MS: u64 = 30_000; + +/// 退出分析的输入。 +#[derive(Debug, Clone, Copy)] +pub struct ExitSignals<'a> { + /// 退出码。 + pub code: i32, + /// 进程存活毫秒数。 + pub uptime_ms: u64, + /// 环形缓冲里的日志行。 + pub lines: &'a [LogLine], + /// 是否启动器主动结束。 + pub killed: bool, + /// 等待是否被中断。 + pub interrupted: bool, + /// 是否 POSIX(决定 137/143 是否按信号解读)。 + pub is_posix: bool, +} + +impl<'a> ExitSignals<'a> { + /// 构造。 + pub fn new(code: i32, uptime_ms: u64, lines: &'a [LogLine]) -> Self { + Self { + code, + uptime_ms, + lines, + killed: false, + interrupted: false, + is_posix: cfg!(unix), + } + } +} + +/// 静默失败标记。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SilentFailure { + /// 存活时间过短。 + pub short_lived: bool, + /// stderr/日志里有错误特征。 + pub error_output: bool, +} + +impl SilentFailure { + /// 人读说明。 + pub fn describe(&self) -> String { + match (self.short_lived, self.error_output) { + (true, true) => "退出码 0,但存活不足 30 秒且输出含错误特征".to_string(), + (true, false) => "退出码 0,但存活不足 30 秒".to_string(), + (false, true) => "退出码 0,但输出含错误特征".to_string(), + (false, false) => "退出码 0".to_string(), + } + } +} + +/// 退出分析结果(`exit` 事件的 payload 来源)。 +#[derive(Debug, Clone)] +pub struct ExitAnalysis { + /// 退出码。 + pub code: i32, + /// 判定类型。 + pub exit_type: ExitType, + /// 存活毫秒数。 + pub uptime_ms: u64, + /// 错误级日志行数。 + pub error_lines: u32, + /// 命中的崩溃关键字。 + pub crash_keywords: Vec, + /// 命中的 JVM 失败关键字。 + pub jvm_keywords: Vec, + /// 静默失败标记(仅 `Normal` 时可能非空)。 + pub silent_failure: Option, + /// Windows NTSTATUS 解释(例 `0xC0000005 ACCESS_VIOLATION`)。 + pub windows_status: Option, + /// 是否应该进入诊断流水线(M15)。 + pub needs_diagnosis: bool, +} + +impl ExitAnalysis { + /// 一句话摘要(UI/agent 可直接展示)。 + pub fn summary(&self) -> String { + let mut text = format!( + "退出码 {}({}),存活 {:.1}s,错误行 {}", + self.code, + self.exit_type.as_str(), + self.uptime_ms as f64 / 1000.0, + self.error_lines + ); + if let Some(status) = &self.windows_status { + text.push_str(&format!(",{status}")); + } + if !self.crash_keywords.is_empty() { + text.push_str(&format!(",崩溃关键字 {}", self.crash_keywords.len())); + } + if let Some(silent) = &self.silent_failure { + text.push_str(&format!(",{}", silent.describe())); + } + text + } +} + +/// 做退出分析。 +pub fn analyze(signals: &ExitSignals<'_>) -> ExitAnalysis { + let error_lines = signals.lines.iter().filter(|line| line.is_error()).count(); + let crash_keywords = matched_keywords(signals.lines, CRASH_KEYWORDS); + let jvm_keywords = matched_keywords(signals.lines, JVM_FAILURE_KEYWORDS); + let error_output = signals + .lines + .iter() + .any(|line| line.is_error() && looks_like_failure(&line.text)); + + let exit_type = if signals.interrupted { + ExitType::Interrupted + } else if signals.killed { + ExitType::Killed + } else if signals.code != 0 && !jvm_keywords.is_empty() { + ExitType::JvmError + } else if signals.is_posix && signals.code == 137 { + ExitType::SigKill + } else if signals.is_posix && signals.code == 143 { + ExitType::SigTerm + } else if signals.code != 0 || !crash_keywords.is_empty() { + ExitType::ApplicationError + } else { + ExitType::Normal + }; + + let silent_failure = if exit_type == ExitType::Normal { + let short_lived = signals.uptime_ms < SHORT_LIVED_MS; + if short_lived || error_output { + Some(SilentFailure { + short_lived, + error_output, + }) + } else { + None + } + } else { + None + }; + + ExitAnalysis { + code: signals.code, + exit_type, + uptime_ms: signals.uptime_ms, + error_lines: u32::try_from(error_lines).unwrap_or(u32::MAX), + crash_keywords: crash_keywords.clone(), + jvm_keywords, + silent_failure, + windows_status: windows_status_name(signals.code), + needs_diagnosis: exit_type.is_abnormal() + || silent_failure.is_some() + || !crash_keywords.is_empty(), + } +} + +/// Windows NTSTATUS → 解释。 +pub fn windows_status_name(code: i32) -> Option { + let (hex, name) = match code { + -1_073_741_819 => ("0xC0000005", "ACCESS_VIOLATION"), + -1_073_740_791 => ("0xC0000409", "STACK_BUFFER_OVERRUN"), + -1_073_741_515 => ("0xC0000135", "DLL_NOT_FOUND"), + -1_073_741_571 => ("0xC00000FD", "STACK_OVERFLOW"), + -1_073_741_510 => ("0xC000013A", "CONTROL_C_EXIT"), + _ => return None, + }; + Some(format!("{hex} {name}")) +} + +/// 退出码的人工提示(附录 C 速查表的裁剪版)。 +pub fn exit_code_hint(code: i32, is_posix: bool) -> Option<&'static str> { + match code { + 0 => Some("正常退出"), + 1 => Some("通用失败(含 JVM 启动失败)"), + 137 if is_posix => Some("SIGKILL:被强杀"), + 143 if is_posix => Some("SIGTERM:被请求退出"), + _ => None, + } +} + +fn matched_keywords(lines: &[LogLine], keywords: &[&str]) -> Vec { + keywords + .iter() + .filter(|keyword| lines.iter().any(|line| line.text.contains(**keyword))) + .map(|keyword| (*keyword).to_string()) + .collect() +} + +/// “像失败的输出”:显式异常/错误特征(不是任何 stderr 输出都算)。 +fn looks_like_failure(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + lower.contains("exception") + || lower.contains("caused by") + || lower.contains("fatal") + || lower.contains("error:") + || lower.contains("failed") +} diff --git a/crates/core/src/launch/game_args.rs b/crates/core/src/launch/game_args.rs new file mode 100644 index 0000000..4d33865 --- /dev/null +++ b/crates/core/src/launch/game_args.rs @@ -0,0 +1,206 @@ +//! 参数后段:清单 JVM 参数尾部(第 20~23 项)与游戏参数(第 24~32 项)。 +//! +//! 顺序本身有语义(`analyse/02` §4.1):清单 JVM 参数在用户 `javaArguments` 之后 +//! **无条件追加**,因此能覆盖用户参数,但覆盖不了用户的 override 参数; +//! 用户自定义游戏参数永远最后追加,是可以覆盖前面一切游戏参数的“逃生门”。 + +use crate::launch::args_table::AssemblyWarning; +use crate::launch::argument::{ArgumentEntry, RuleContext}; +use crate::launch::command_builder::{tokenize, CommandBuilder}; +use crate::launch::defaults; +use crate::launch::game_version::GameVersion; +use crate::launch::platform::PlatformCtx; +use crate::launch::request::{LaunchRequest, QuickPlay}; + +/// `--graphicsBackend` 的最低版本(实测首发:`26.2-snapshot-2`,2026-04-09)。 +pub const GRAPHICS_BACKEND_MIN_VERSION: &str = "26.2-snapshot-2"; + +/// `--graphicsBackend` 能否下发给这个版本。 +/// +/// 两条判据**不要合并**(`research/05-mc-vulkan.md` §3.2/§4.2 的实测结论): +/// +/// - **可解析版本**走精确门限 `26.2-snapshot-2`。`26.2-snapshot-1` 已经有 `lwjgl-vulkan` +/// 库但 `Main.class` 还没声明这个参数,用能力位放行会让它 `UnrecognizedOptionException` +/// 直接起不来——库早了两天。 +/// - **周快照**(`26w14a` 形态)无法与 `26.2-snapshot-N` 排序,只能信版本库表: +/// 带 `lwjgl-vulkan` 就放行(26.1 全线含周快照都没有该库,所以这个推断安全)。 +pub fn graphics_backend_allowed(version: &GameVersion, has_vulkan_backend: bool) -> bool { + if version.is_known() { + return version.is_at_least(GRAPHICS_BACKEND_MIN_VERSION); + } + version.is_weekly_snapshot() && has_vulkan_backend +} + +/// 应用第 20~32 项,返回告警。 +pub(crate) fn apply_game_arguments( + req: &LaunchRequest, + ctx: &PlatformCtx, + builder: &mut CommandBuilder, +) -> Vec { + let mut warnings = Vec::new(); + let rule_ctx = RuleContext::new( + ctx.os.manifest_name(), + &ctx.arch, + &ctx.os_version, + &req.features, + ); + + // 第 20 项:清单 JVM 参数(现代)或 legacy 默认 JVM 参数。 + if req.manifest.jvm.is_empty() { + builder.add_all_default(&defaults::legacy_jvm_arguments()); + } else { + let values = collect_applicable(&req.manifest.jvm, &rule_ctx); + builder.add_all(&values); + } + + // 第 21 项:登录源提供的 JVM 参数(authlib-injector 等)。 + for arg in &req.auth_jvm_args { + builder.add(arg); + } + + // 第 22 项:`-javaagent:` × N。 + for agent in &req.java_agents { + builder.add(&format!("-javaagent:{agent}")); + } + + // 第 23 项:主类(args_table 已保证非空)。 + if let Some(main_class) = &req.manifest.main_class { + builder.add(main_class); + } + + // 第 24 项:legacy `minecraftArguments` 整串分词。 + if let Some(legacy) = &req.manifest.legacy_arguments { + for token in tokenize(legacy) { + builder.add(&token); + } + } + + // 第 25 项:现代 `arguments.game`(带 rules/features)。 + if !req.manifest.game.is_empty() { + let values = collect_applicable(&req.manifest.game, &rule_ctx); + builder.add_all(&values); + } + + // 第 26 项:legacy 默认游戏参数(分辨率)。 + if req.manifest.legacy_arguments.is_some() { + builder.add_all(&defaults::legacy_game_args(req.resolution.as_ref())); + } + + // 第 27 项:登录源提供的游戏参数。 + for arg in &req.auth_game_args { + builder.add(arg); + } + + // 第 28 项:QuickPlay / `--server --port`。 + apply_quick_play(req, builder, &mut warnings); + + // 第 29 项:全屏。 + if req.fullscreen { + builder.add("--fullscreen"); + } + + // 第 30 项:SOCKS 代理的游戏侧参数。 + if let Some(proxy) = &req.proxy { + builder.add("--proxyHost"); + builder.add(&proxy.host); + builder.add("--proxyPort"); + builder.add(&proxy.port.to_string()); + if let Some(username) = &proxy.username { + builder.add("--proxyUser"); + builder.add(username); + } + if let Some(password) = &proxy.password { + builder.add("--proxyPass"); + builder.add(password); + } + } + + // 第 31 项:图形后端(精确门限 ≥26.2-snapshot-2,周快照走库表兜底)。 + if let Some(backend) = &req.graphics_backend { + if graphics_backend_allowed(&req.instance.version, req.manifest.has_vulkan_backend) { + builder.add("--graphicsBackend"); + builder.add(backend); + } else { + warnings.push(AssemblyWarning::GraphicsBackendSkipped { + version: req.instance.version.raw().to_string(), + }); + } + } + + // 第 32 项:用户自定义游戏参数(最后追加,可覆盖一切)。 + builder.add_all(&req.user.game); + + warnings +} + +fn apply_quick_play( + req: &LaunchRequest, + builder: &mut CommandBuilder, + warnings: &mut Vec, +) { + let Some(quick_play) = &req.quick_play else { + return; + }; + let supports = req.instance.version.is_at_least("1.20"); + match quick_play { + QuickPlay::Singleplayer { world } if supports => { + builder.add("--quickPlaySingleplayer"); + builder.add(world); + } + QuickPlay::Multiplayer { address } if supports => { + builder.add("--quickPlayMultiplayer"); + builder.add(address); + } + QuickPlay::Realms { realm_id } if supports => { + builder.add("--quickPlayRealms"); + builder.add(realm_id); + } + QuickPlay::Multiplayer { address } => match parse_server_address(address) { + Some((host, port)) => { + builder.add("--server"); + builder.add(&host); + if let Some(port) = port { + builder.add("--port"); + builder.add(&port.to_string()); + } + warnings.push(AssemblyWarning::QuickPlayDegraded { + address: address.clone(), + }); + } + None => warnings.push(AssemblyWarning::QuickPlayAddressInvalid { + address: address.clone(), + }), + }, + QuickPlay::Singleplayer { .. } | QuickPlay::Realms { .. } => { + warnings.push(AssemblyWarning::QuickPlayUnsupported { + version: req.instance.version.raw().to_string(), + }); + } + } +} + +/// 解析 `host[:port]`;端口非法或为空时该段视为不存在。 +pub fn parse_server_address(address: &str) -> Option<(String, Option)> { + let trimmed = address.trim(); + if trimmed.is_empty() { + return None; + } + match trimmed.rsplit_once(':') { + Some((host, port)) if !host.is_empty() => match port.parse::() { + Ok(port) if port > 0 => Some((host.to_string(), Some(port))), + _ => None, + }, + Some(_) => None, + None => Some((trimmed.to_string(), None)), + } +} + +fn collect_applicable(entries: &[ArgumentEntry], ctx: &RuleContext<'_>) -> Vec { + let mut values = Vec::new(); + for entry in entries { + if entry.applies(ctx) { + values.extend(entry.values.iter().cloned()); + } + } + values +} diff --git a/crates/core/src/launch/game_version.rs b/crates/core/src/launch/game_version.rs new file mode 100644 index 0000000..05e348b --- /dev/null +++ b/crates/core/src/launch/game_version.rs @@ -0,0 +1,178 @@ +//! 游戏版本号解析与比较(供命令行组装的分支门限使用)。 +//! +//! 依据 `analyse/02` §5.2(版本号驱动的分支:legacy/modern 参数模板、log4j 注入、 +//! `--quickPlay*` 门限、`--graphicsBackend` 门限)。 +//! +//! 说明:这是 launch 侧的最小比较器。若 M14(game)后续提供权威 `GameVersion` 比较器, +//! 只需替换本文件内部实现,`is_at_least` / `is_before` 的调用点不用动 +//! (见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §7-3)。 + +use std::cmp::Ordering; +use std::fmt; + +/// 版本的发布阶段。派生 `Ord` 的变体顺序即排序顺序:Snapshot < PreRelease < ReleaseCandidate < Release。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Stage { + /// 快照(`26.2-snapshot-2`),数字为快照序号。 + Snapshot(u32), + /// 预发布(`1.20-pre1`)。 + PreRelease(u32), + /// 候选发布(`1.20-rc1`)。 + ReleaseCandidate(u32), + /// 正式版。 + Release, +} + +/// 游戏版本号。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GameVersion { + raw: String, + parts: Vec, + stage: Stage, +} + +impl GameVersion { + /// 解析版本串;无法识别(如 `20w14a`)时返回 [`GameVersion::unknown`]。 + pub fn parse(raw: &str) -> Self { + let trimmed = raw.trim(); + let (base, stage) = split_stage(trimmed); + let parts = base + .split('.') + .map_while(|piece| piece.parse::().ok()) + .collect::>(); + if parts.is_empty() { + return Self::unknown(trimmed); + } + Self { + raw: trimmed.to_string(), + parts, + stage, + } + } + + /// 未知/无法解析的版本(比较结果为“不满足任何门限”,由调用方给默认分支)。 + pub fn unknown(raw: &str) -> Self { + Self { + raw: raw.to_string(), + parts: Vec::new(), + stage: Stage::Release, + } + } + + /// 原始版本串。 + pub fn raw(&self) -> &str { + &self.raw + } + + /// 是否解析出了数字版本(`false` 表示未知形状,例如周快照)。 + pub fn is_known(&self) -> bool { + !self.parts.is_empty() + } + + /// 是否是**周快照**形态(`20w14a` / `26w14a`)。 + /// + /// 周快照是「年(2 位) + `w` + 周(2 位) + 字母」的独立命名族,与 `26.2-snapshot-N` + /// 分属两套编号,**无法**可靠排序;所以 [`Self::is_known`] 对它恒为 `false` + /// (不猜,免得误触发「老版本」分支去套 legacy 参数模板)。要判能力请改看版本 JSON 的 + /// 库表([`crate::game::capability`]),别在这里编一套排序规则。 + pub fn is_weekly_snapshot(&self) -> bool { + is_weekly_snapshot_str(&self.raw) + } + + /// 主版本号(`1.20.1` → 1,`26.2` → 26)。 + pub fn major(&self) -> u32 { + self.parts.first().copied().unwrap_or(0) + } + + /// 是否 `>= other`。自身或 `other` 不可解析时返回 `false`。 + pub fn is_at_least(&self, other: &str) -> bool { + let other = Self::parse(other); + if !self.is_known() || !other.is_known() { + return false; + } + self.cmp(&other) != Ordering::Less + } + + /// 是否 `< other`。自身不可解析时返回 `false`(未知版本不触发“老版本”分支)。 + pub fn is_before(&self, other: &str) -> bool { + let other = Self::parse(other); + if !self.is_known() || !other.is_known() { + return false; + } + self.cmp(&other) == Ordering::Less + } + + /// 去掉尾部 0 后的分量序列(`1.20.0` 与 `1.20` 等价)。 + fn trimmed_parts(&self) -> &[u32] { + let mut end = self.parts.len(); + while end > 0 && self.parts[end - 1] == 0 { + end -= 1; + } + &self.parts[..end] + } +} + +impl Ord for GameVersion { + fn cmp(&self, other: &Self) -> Ordering { + self.trimmed_parts() + .cmp(other.trimmed_parts()) + .then_with(|| self.stage.cmp(&other.stage)) + } +} + +impl PartialOrd for GameVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl fmt::Display for GameVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.raw) + } +} + +impl Default for GameVersion { + fn default() -> Self { + Self::unknown("") + } +} + +fn split_stage(raw: &str) -> (&str, Stage) { + for marker in ["-snapshot", " snapshot"] { + if let Some(pos) = raw.find(marker) { + let number = trailing_number(&raw[pos + marker.len()..]); + return (&raw[..pos], Stage::Snapshot(number)); + } + } + for marker in ["-pre", "-preview"] { + if let Some(pos) = raw.find(marker) { + let number = trailing_number(&raw[pos + marker.len()..]); + return (&raw[..pos], Stage::PreRelease(number)); + } + } + if let Some(pos) = raw.find("-rc") { + let number = trailing_number(&raw[pos + 3..]); + return (&raw[..pos], Stage::ReleaseCandidate(number)); + } + (raw, Stage::Release) +} + +fn trailing_number(text: &str) -> u32 { + let digits = text.trim_start_matches(['-', '.', '_']); + digits + .split(|ch: char| !ch.is_ascii_digit()) + .next() + .and_then(|piece| piece.parse::().ok()) + .unwrap_or(0) +} + +/// `20w14a` / `26w14a` 形态判定(纯函数,便于单测)。 +fn is_weekly_snapshot_str(raw: &str) -> bool { + let bytes = raw.trim().as_bytes(); + bytes.len() == 6 + && bytes[..2].iter().all(u8::is_ascii_digit) + && bytes[2].eq_ignore_ascii_case(&b'w') + && bytes[3..5].iter().all(u8::is_ascii_digit) + && bytes[5].is_ascii_alphabetic() +} diff --git a/crates/core/src/launch/jvm_args.rs b/crates/core/src/launch/jvm_args.rs new file mode 100644 index 0000000..0794cb2 --- /dev/null +++ b/crates/core/src/launch/jvm_args.rs @@ -0,0 +1,149 @@ +//! JVM 参数段(`analyse/02` §4.1 的第 8~17 项)。 +//! +//! 这一段全部走“默认值抑制”:用户/清单显式写过的键我们一律不覆盖。 +//! 返回最终生效的日志解码编码——它要传给日志泵(`analyse/02` §4.4)。 + +use crate::launch::args_table::AssemblyWarning; +use crate::launch::command_builder::CommandBuilder; +use crate::launch::defaults; +use crate::launch::platform::{OsFamily, PlatformCtx}; +use crate::launch::request::LaunchRequest; + +/// 应用第 8~17 项,返回(日志解码编码,告警)。 +pub(crate) fn apply_jvm_arguments( + req: &LaunchRequest, + ctx: &PlatformCtx, + builder: &mut CommandBuilder, +) -> (String, Vec) { + let mut warnings = Vec::new(); + let java = &req.java; + + // 第 8 项:编码(`-Dfile.encoding=` + stdout/stderr 编码)。 + let requested = match req.encoding.as_deref() { + Some(value) if !value.eq_ignore_ascii_case("COMPAT") => value.to_string(), + _ => ctx.native_charset.clone(), + }; + let existing = builder.add_default("-Dfile.encoding=", &requested); + let mut encoding = existing + .as_deref() + .and_then(|arg| arg.strip_prefix("-Dfile.encoding=")) + .unwrap_or(&requested) + .to_string(); + if encoding.eq_ignore_ascii_case("COMPAT") { + encoding = ctx.native_charset.clone(); + } + builder.add_all_default(&defaults::stream_encoding_args(java.major, &encoding)); + + // 第 9 项:Log4j2 RCE 缓解三件套。 + builder.add_all_default(&defaults::log4j_mitigation_args()); + + // 第 10 项:formatMsgNoLookups + 日志配置注入。 + let lookups = builder.add_default("-Dlog4j2.formatMsgNoLookups=", "true"); + let user_disabled_lookups = lookups.as_deref() == Some("-Dlog4j2.formatMsgNoLookups=false"); + if req.log4j.enabled && (!user_disabled_lookups || req.log4j.debug) { + match &req.log4j.config_file { + Some(path) => { + let value = path.display().to_string(); + let _ = builder.add_default("-Dlog4j.configurationFile=", &value); + } + None => warnings.push(AssemblyWarning::Log4jConfigMissing), + } + } + + // 第 11 项:品牌/特性钩子注入的额外 JVM 参数。 + for arg in &req.extra_jvm { + builder.add(arg); + } + + // 第 12 项:主 jar、macOS dock、`-Duser.home`(仅非 Windows)。 + let primary_jar = req.dirs.primary_jar.display().to_string(); + let _ = builder.add_default("-Dminecraft.client.jar=", &primary_jar); + if ctx.os == OsFamily::MacOs && !req.launcher.name.is_empty() { + let name = req.launcher.name.clone(); + let _ = builder.add_default("-Xdock:name=", &name); + } + if ctx.os != OsFamily::Windows { + if let Some(home) = &ctx.home_dir { + let value = home.display().to_string(); + let _ = builder.add_default("-Duser.home=", &value); + } + } + + // 第 13 项:代理(用户已写任意代理参数则整体跳过,`analyse/02` §4.1 #13)。 + if let Some(proxy) = &req.proxy { + if user_has_proxy_args(req) { + warnings.push(AssemblyWarning::ProxyJvmArgsSkipped); + } else { + apply_proxy(builder, proxy); + } + } + + // 第 14 项:自动优化。 + if req.optimization.enabled { + let args = defaults::optimization_args( + java.major, + java.bits, + req.optimization.physical_memory_mb, + user_specified_gc(req), + ); + builder.add_all_default(&args); + } + + // 第 15 项:版本特定开关。 + builder.add_all_default(&defaults::version_specific_args(java.major)); + + // 第 16 项:FML 证书豁免。 + if req.mod_loader.is_fml_like() { + builder.add_all_default(&defaults::fml_args()); + } + + // 第 17 项:渲染器。 + builder.add_all_default(&defaults::renderer_args(&req.renderer)); + + (encoding, warnings) +} + +fn apply_proxy(builder: &mut CommandBuilder, proxy: &crate::launch::request::SocksProxy) { + let port = proxy.port.to_string(); + for key in ["http.proxyHost", "https.proxyHost", "socksProxyHost"] { + let _ = builder.add_default(&format!("-D{key}="), &proxy.host); + } + for key in ["http.proxyPort", "https.proxyPort", "socksProxyPort"] { + let _ = builder.add_default(&format!("-D{key}="), &port); + } + if let Some(username) = &proxy.username { + for key in ["http.proxyUser", "https.proxyUser"] { + let _ = builder.add_default(&format!("-D{key}="), username); + } + } + if let Some(password) = &proxy.password { + for key in ["http.proxyPassword", "https.proxyPassword"] { + let _ = builder.add_default(&format!("-D{key}="), password); + } + } +} + +fn user_has_proxy_args(req: &LaunchRequest) -> bool { + req.user + .override_jvm + .iter() + .chain(req.user.jvm.iter()) + .any(|arg| arg.starts_with("-D") && arg.to_ascii_lowercase().contains("proxy")) +} + +fn user_specified_gc(req: &LaunchRequest) -> bool { + req.user + .override_jvm + .iter() + .chain(req.user.jvm.iter()) + .chain( + req.manifest + .jvm + .iter() + .flat_map(|entry| entry.values.iter()), + ) + .any(|arg| { + let lower = arg.to_ascii_lowercase(); + lower.starts_with("-xx:+use") || lower.starts_with("-xx:-use") + }) +} diff --git a/crates/core/src/launch/launch_session.rs b/crates/core/src/launch/launch_session.rs new file mode 100644 index 0000000..529a406 --- /dev/null +++ b/crates/core/src/launch/launch_session.rs @@ -0,0 +1,329 @@ +//! 运行中的启动会话与收尾流水线。 +//! +//! 一次启动的生命周期(`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §1 的第 7~10 阶段): +//! `spawn` → 用户可能 [`LaunchSession::stop`] → [`LaunchSession::finish`] +//! (等进程 → 等日志泵(有预算)→ 退出分析 → 扫崩溃现场 → 诊断挂钩 → 写事件 + 索引)。 + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::SystemTime; + +use serde_json::Value; + +use crate::launch::args_table::AssembledCommand; +use crate::launch::crash_scan::{self, CrashArtifact}; +use crate::launch::diagnosis_hook::{DiagnosisHook, DiagnosisRequest}; +use crate::launch::error::Result; +use crate::launch::exit_analysis::{self, ExitAnalysis, ExitSignals}; +use crate::launch::launcher::LauncherConfig; +use crate::launch::process::{self, RunningProcess}; +use crate::launch::pump::{LineSink, LogLine}; +use crate::launch::redact::Redactor; +use crate::launch::request::LaunchRequest; +use crate::launch::session::{EndReason, SessionSink}; +use crate::launch::session_index::SessionIndexEntry; + +/// 一次运行中的启动会话。 +pub struct LaunchSession { + config: LauncherConfig, + hook: Arc, + redactor: Redactor, + request: LaunchRequest, + session_id: String, + log_path: PathBuf, + command: AssembledCommand, + pid: u32, + process: RunningProcess, + sink: Arc, + started_wall: SystemTime, + killed: bool, +} + +impl std::fmt::Debug for LaunchSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LaunchSession") + .field("session_id", &self.session_id) + .field("pid", &self.pid) + .field("log_path", &self.log_path) + .finish() + } +} + +impl LaunchSession { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + config: LauncherConfig, + hook: Arc, + redactor: Redactor, + request: LaunchRequest, + session_id: String, + log_path: PathBuf, + command: AssembledCommand, + pid: u32, + process: RunningProcess, + sink: Arc, + ) -> Self { + Self { + config, + hook, + redactor, + request, + session_id, + log_path, + command, + pid, + process, + sink, + started_wall: SystemTime::now(), + killed: false, + } + } + + /// 进程号。 + pub fn pid(&self) -> u32 { + self.pid + } + + /// 会话 id。 + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// 事件流文件路径。 + pub fn log_path(&self) -> &Path { + &self.log_path + } + + /// 组装结果。 + pub fn command(&self) -> &AssembledCommand { + &self.command + } + + /// 是否已请求停止。 + pub fn is_killed(&self) -> bool { + self.killed + } + + /// 日志 sink(可再叠加 UI/测试消费者,见 [`crate::launch::sink`])。 + pub fn sink(&self) -> Arc { + self.sink.clone() as Arc + } + + /// 已运行毫秒数。 + pub fn uptime_ms(&self) -> u64 { + self.process.uptime_ms() + } + + /// 用户/看门狗请求停止:三段式回收进程树,并记 `killed` 事件。 + pub fn stop(&mut self) -> Result<()> { + if self.killed { + return Ok(()); + } + self.killed = true; + if let Some(result) = self.sink.with_writer(|writer| writer.killed("user", None)) { + let _ = result; + } + self.process.stop() + } + + /// 收尾:等进程 → 等日志泵 → 退出分析 → 现场扫描 → 诊断 → 事件 + 索引。 + pub fn finish(mut self) -> Result { + let status = self.process.wait()?; + let code = process::exit_code(&status); + // uptime 必须在等日志泵之前取(泵的等待预算不算进游戏存活时间)。 + let uptime_ms = self.process.uptime_ms(); + let verdict = self.process.supervise(); + let pump_stalled = !verdict.is_complete(); + let lines = self.process.lines(); + + let mut signals = ExitSignals::new(code, uptime_ms, &lines); + signals.killed = self.killed; + signals.is_posix = self.config.platform.os.is_posix(); + let exit = exit_analysis::analyze(&signals); + + let artifacts = self.collect_artifacts(); + // 脱敏只算一次:诊断包与 `SessionHandle::redacted_argv()` 共用。 + let redacted_argv = self.redactor.scrub_all(&self.command.argv()); + let diagnosis_request = self.build_diagnosis_request( + &exit, + &lines, + &artifacts, + uptime_ms, + redacted_argv.clone(), + ); + let should_diagnose = exit.needs_diagnosis || !artifacts.is_empty(); + let diagnosis = if should_diagnose { + self.hook.analyze(&diagnosis_request) + } else { + None + }; + + let mut event_write_errors = Vec::new(); + let mut diagnostics = None; + let mut index = None; + let end_reason = if self.killed { + EndReason::Killed + } else { + EndReason::Exit + }; + let completed = self.sink.with_writer(|writer| { + if let Err(err) = writer.exit(&exit) { + event_write_errors.push(err.to_string()); + } + for artifact in &artifacts { + let result = writer.crash_found( + artifact.source.as_str(), + &artifact.path, + artifact.excerpt.as_deref(), + ); + if let Err(err) = result { + event_write_errors.push(err.to_string()); + } + } + if let Some(record) = diagnosis { + if let Err(err) = writer.diagnosis(record.clone()) { + event_write_errors.push(err.to_string()); + } + diagnostics = Some(record); + } + match writer.finish(end_reason) { + Ok(entry) => index = Some(entry), + Err(err) => event_write_errors.push(err.to_string()), + } + }); + if completed.is_none() { + event_write_errors.push("会话写入器不可用(锁中毒)".to_string()); + } + + Ok(SessionHandle { + session_id: self.session_id, + log_path: self.log_path, + command: self.command, + redacted_argv, + exit, + diagnostics, + index, + artifacts, + uptime_ms, + pump_stalled, + event_write_errors, + }) + } + + fn collect_artifacts(&self) -> Vec { + if !self.config.scan_crash_reports { + return Vec::new(); + } + let run_dir = self.run_dir(); + crash_scan::scan(&run_dir, self.started_wall, self.config.max_crash_artifacts) + } + + fn build_diagnosis_request( + &self, + exit: &ExitAnalysis, + lines: &[LogLine], + artifacts: &[CrashArtifact], + uptime_ms: u64, + argv: Vec, + ) -> DiagnosisRequest { + DiagnosisRequest { + session_id: self.session_id.clone(), + instance_id: self.request.instance.id.clone(), + instance_name: self.request.instance.name.clone(), + game_version: self.request.instance.version.raw().to_string(), + exit: exit.clone(), + uptime_ms, + log_lines: lines + .iter() + .map(|line| LogLine::new(line.stream, self.redactor.scrub(&line.text).into_owned())) + .collect(), + artifacts: artifacts.to_vec(), + argv, + memory_mb: self.command.memory.max_mb, + java_major: self.request.java.major, + java_binary: self.request.java.binary.clone(), + game_dir: self.run_dir(), + } + } + + fn run_dir(&self) -> PathBuf { + if self.request.dirs.game_dir.as_os_str().is_empty() { + std::env::current_dir().unwrap_or_default() + } else { + self.request.dirs.game_dir.clone() + } + } +} + +/// 一次启动的结果。 +#[derive(Debug, Clone)] +pub struct SessionHandle { + /// 会话 id。 + pub session_id: String, + /// 事件流文件。 + pub log_path: PathBuf, + /// 组装结果(`argv()` 含凭据明文;对外展示/上报请用 [`Self::redacted_argv`])。 + pub command: AssembledCommand, + /// 脱敏后的完整 argv(accessToken → ``,主目录 → ``)。 + redacted_argv: Vec, + /// 退出判定。 + pub exit: ExitAnalysis, + /// 诊断结论(M15 产出,已写入事件流)。 + pub diagnostics: Option, + /// 索引条目(写入 `index.jsonl` 的那一行)。 + pub index: Option, + /// 崩溃现场文件。 + pub artifacts: Vec, + /// 游戏存活毫秒数。 + pub uptime_ms: u64, + /// 日志泵是否超时(watchdog 触发 = 有子进程没关 stdout)。 + pub pump_stalled: bool, + /// 会话写入错误(磁盘问题;不影响进程结果)。 + pub event_write_errors: Vec, +} + +impl SessionHandle { + /// 脱敏后的完整 argv(UI/日志/上报都用它,别直接外发 `command.argv()`)。 + pub fn redacted_argv(&self) -> &[String] { + &self.redacted_argv + } + + /// 脱敏后的一行人读命令行。 + pub fn redacted_display(&self) -> String { + self.redacted_argv + .iter() + .map(|arg| crate::launch::command_builder::quote_for_display(arg)) + .collect::>() + .join(" ") + } + + /// 是否“正常且无异常”:退出判定 NORMAL 且没有静默失败标记。 + pub fn succeeded(&self) -> bool { + !self.exit.exit_type.is_abnormal() && self.exit.silent_failure.is_none() + } + + /// 是否需要进诊断(等价于 `exit.needs_diagnosis`,保留给上层判空)。 + pub fn needs_diagnosis(&self) -> bool { + self.exit.needs_diagnosis + } + + /// 一句话摘要。 + pub fn summary(&self) -> String { + let mut text = format!("会话 {}:{}", self.session_id, self.exit.summary()); + if self.pump_stalled { + text.push_str(";日志泵超时"); + } + if let Some(top) = &self.diagnostics { + if let Some(id) = top + .get("candidates") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("id")) + .and_then(Value::as_str) + { + text.push_str(&format!(";诊断首因 {id}")); + } + } + text + } +} diff --git a/crates/core/src/launch/launcher.rs b/crates/core/src/launch/launcher.rs new file mode 100644 index 0000000..5abe519 --- /dev/null +++ b/crates/core/src/launch/launcher.rs @@ -0,0 +1,298 @@ +//! 顶层启动管线:组装 → 会话 → 进程 → 退出分析 → 诊断 → 索引。 +//! +//! 对应 `DevDocs/PlanDoc/06-launch-and-diagnostics.md` §1 的 10 阶段: +//! 1~2(快照/账户)由调用方与 `adapter` 完成,3(Java 选择)由 D13 给出 `JavaSelection`, +//! 4(组件就绪检查)由 download/game 给出 `missing_components`,6(环境准备的 natives 段) +//! 由 [`crate::launch::natives`] 在本模块收口,5、7~10 见下。 +//! +//! 两个入口: +//! - [`Launcher::dry_run`](D18 接线点):只组装、不 spawn、不写会话文件,返回完整命令行; +//! - [`Launcher::launch`]:完整跑一局,返回 [`SessionHandle`](退出判定 + 诊断结论 + 索引条目)。 +//! +//! 阶段 B 说明:diagnose(M15)尚未合并,诊断走 [`DiagnosisHook`] 接缝,默认 [`NoopDiagnosis`]。 + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; + +use crate::launch::args_table::{assemble, AssembledCommand}; +use crate::launch::diagnosis_hook::{DiagnosisHook, NoopDiagnosis}; +use crate::launch::error::{AssemblyError, Result}; +use crate::launch::launch_session::{LaunchSession, SessionHandle}; +use crate::launch::natives::{self, NativesReport}; +use crate::launch::platform::PlatformCtx; +use crate::launch::process::{ProcessConfig, RunningProcess}; +use crate::launch::redact::Redactor; +use crate::launch::request::LaunchRequest; +use crate::launch::session::{EndReason, SessionMeta, SessionSink, SessionWriter}; + +/// 启动器配置。 +#[derive(Debug, Clone)] +pub struct LauncherConfig { + /// 会话文件根目录(`/agent/sessions`)。 + pub sessions_root: PathBuf, + /// 平台上下文(命令行分支 + 退出码语义)。 + pub platform: PlatformCtx, + /// 进程管理配置(看门狗/宽限期/环形缓冲)。 + pub process: ProcessConfig, + /// 启动器名(`${launcher_name}`,请求里非空时以请求为准)。 + pub launcher_name: String, + /// 启动器版本。 + pub launcher_version: String, + /// 用户主目录(脱敏用)。 + pub home_dir: Option, + /// 结束后是否扫描崩溃现场文件。 + pub scan_crash_reports: bool, + /// 现场文件最多收几个。 + pub max_crash_artifacts: usize, +} + +impl LauncherConfig { + /// 以会话根目录构造(其余取默认)。 + pub fn new(sessions_root: impl Into) -> Self { + let platform = PlatformCtx::current(); + Self { + sessions_root: sessions_root.into(), + home_dir: platform.home_dir.clone(), + platform, + process: ProcessConfig::default(), + launcher_name: "UnluckyLauncher".to_string(), + launcher_version: "0.1.0".to_string(), + scan_crash_reports: true, + max_crash_artifacts: 4, + } + } + + /// 补启动器名/版本。 + pub fn with_launcher(mut self, name: impl Into, version: impl Into) -> Self { + self.launcher_name = name.into(); + self.launcher_version = version.into(); + self + } +} + +/// `--dry-run` 的输出(D18):完整命令行,但**不 spawn、不写会话文件**。 +/// +/// 会做的事与真实启动**一致到「环境准备」为止**:natives 解压照做(幂等,`natives` 字段 +/// 回报结果),因为「装完能不能起」的一半答案就在「native 库有没有落地」上。 +#[derive(Debug, Clone)] +pub struct DryRun { + /// 实际要执行的程序。 + pub program: PathBuf, + /// 参数(已脱敏:accessToken 一定是 ``)。 + pub argv: Vec, + /// 人读单行(可直接 `println!`,含空格的参数带引号)。 + pub display: String, + /// 组装告警。 + pub warnings: Vec, + /// classpath 条目数。 + pub classpath_entries: usize, + /// 日志解码编码。 + pub encoding: String, + /// natives 解压结果(没有 natives 目录、即请求未配置时为 `None`)。 + pub natives: Option, +} + +impl DryRun { + /// 一行命令行(D18 打印用)。 + pub fn line(&self) -> &str { + &self.display + } +} + +/// 启动器。 +#[derive(Clone)] +pub struct Launcher { + config: LauncherConfig, + hook: Arc, +} + +impl std::fmt::Debug for Launcher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Launcher") + .field("config", &self.config) + .field("hook", &"") + .finish() + } +} + +impl Launcher { + /// 构造(无诊断挂钩)。 + pub fn new(config: LauncherConfig) -> Self { + Self { + config, + hook: Arc::new(NoopDiagnosis), + } + } + + /// 接上诊断挂钩(M15 适配器)。 + pub fn with_diagnosis_hook(mut self, hook: Arc) -> Self { + self.hook = hook; + self + } + + /// 配置。 + pub fn config(&self) -> &LauncherConfig { + &self.config + } + + /// 只组装命令行(纯计算,可单测/可透传)。 + pub fn plan(&self, request: &LaunchRequest) -> Result { + assemble(request, &self.config.platform) + } + + /// 环境准备(管线第 6 段):natives 解压。 + /// + /// 目标目录取 `request.dirs.natives_dir`(与 `${natives_directory}` 同一真相源); + /// 目录为空视为调用方不需要,返回 `Ok(None)`。 + fn prepare_environment(&self, request: &LaunchRequest) -> Result> { + if request.dirs.natives_dir.as_os_str().is_empty() { + return Ok(None); + } + natives::extract(&request.dirs.natives_dir, &request.natives) + .map(Some) + .map_err(|err| AssemblyError::NativesExtraction { + message: err.message(), + }) + .map_err(Into::into) + } + + /// D18:组装完整命令行并返回,**不 spawn 进程、不写会话文件**。 + /// + /// 供 `unlucky-launcher --dry-run ` 打印一行命令行后退出; + /// accessToken 已脱敏(绝不把凭据打到终端/日志里)。 + pub fn dry_run(&self, request: &LaunchRequest) -> Result { + let natives = self.prepare_environment(request)?; + let command = self.plan(request)?; + let redactor = self.redactor(request); + let warnings = command + .warnings + .iter() + .map(ToString::to_string) + .collect::>(); + Ok(DryRun { + program: command.program.clone(), + argv: redactor.scrub_all(&command.argv()), + display: redactor.scrub(&command.display()).into_owned(), + warnings, + classpath_entries: command.classpath.len(), + encoding: command.encoding.clone(), + natives, + }) + } + + /// 启动一局并返回会话(可中途 [`LaunchSession::stop`])。 + pub fn spawn(&self, request: &LaunchRequest) -> Result { + let redactor = self.redactor(request); + let meta = self.session_meta(request); + let mut writer = SessionWriter::create(&self.config.sessions_root, meta, redactor.clone())?; + let session_id = writer.session_id().to_string(); + let log_path = writer.path().to_path_buf(); + writer.session_start()?; + + // 阶段 6「环境准备」:natives 必须落到 java.library.path 指向的目录,且在组装前完成 + // (HMCL 同序),失败即启动前失败——native 库缺了游戏只会给 UnsatisfiedLinkError。 + let natives = match self.prepare_environment(request) { + Ok(report) => report, + Err(err) => { + let _ = writer.assembly_failed(&launch_error_assembly(&err)); + let _ = writer.finish(EndReason::AssemblyFailed); + return Err(err); + } + }; + if let Some(report) = &natives { + writer.natives_extracted(report)?; + } + + let command = match self.plan(request) { + Ok(command) => command, + Err(err) => { + // 启动前失败也要留下证据:assembly_failed + session_end + 索引。 + let _ = writer.assembly_failed(&launch_error_assembly(&err)); + let _ = writer.finish(EndReason::AssemblyFailed); + return Err(err); + } + }; + writer.cmd_built(&command)?; + + let sink = Arc::new(SessionSink::with_start(writer, Instant::now())); + let working_dir = if request.dirs.game_dir.as_os_str().is_empty() { + None + } else { + Some(request.dirs.game_dir.as_path()) + }; + let process = + match RunningProcess::spawn(&command, working_dir, sink.clone(), self.config.process) { + Ok(process) => process, + Err(err) => { + let _ = sink.with_writer(|writer| writer.finish(EndReason::SpawnFailed)); + return Err(err); + } + }; + let pid = process.pid(); + if let Some(result) = sink.with_writer(|writer| writer.proc_start(pid)) { + result?; + } + Ok(LaunchSession::new( + self.config.clone(), + self.hook.clone(), + redactor, + request.clone(), + session_id, + log_path, + command, + pid, + process, + sink, + )) + } + + /// 启动并阻塞到进程结束(P0 主入口)。 + pub fn launch(&self, request: &LaunchRequest) -> Result { + self.spawn(request)?.finish() + } + + fn redactor(&self, request: &LaunchRequest) -> Redactor { + let mut redactor = Redactor::new().with_secret(request.auth.access_token.clone()); + if let Some(home) = &self.config.home_dir { + redactor = redactor.with_home(home); + } + if !request.auth.uuid.is_empty() { + redactor = redactor.with_secret(request.auth.uuid.clone()); + } + redactor + } + + fn session_meta(&self, request: &LaunchRequest) -> SessionMeta { + let launcher_name = if request.launcher.name.is_empty() { + self.config.launcher_name.clone() + } else { + request.launcher.name.clone() + }; + let launcher_version = if request.launcher.version.is_empty() { + self.config.launcher_version.clone() + } else { + request.launcher.version.clone() + }; + SessionMeta::new( + request.instance.id.clone(), + request.instance.name.clone(), + request.instance.version.raw().to_string(), + ) + .with_launcher(launcher_name, launcher_version) + .with_platform(&self.config.platform) + } +} + +/// 把错误拆成 `assembly_failed` 事件需要的结构化原因。 +pub(crate) fn launch_error_assembly( + error: &crate::launch::error::LaunchError, +) -> crate::launch::error::AssemblyError { + match error { + crate::launch::error::LaunchError::Assembly(assembly) => assembly.clone(), + other => crate::launch::error::AssemblyError::MalformedArgument { + entry: other.to_string(), + reason: "非组装类失败", + }, + } +} diff --git a/crates/core/src/launch/log_level.rs b/crates/core/src/launch/log_level.rs new file mode 100644 index 0000000..5fa1a95 --- /dev/null +++ b/crates/core/src/launch/log_level.rs @@ -0,0 +1,114 @@ +//! 日志级别猜测与“关键行”判定。 +//! +//! 平移自 `analyse/02` §6.4(`Log4jLevel.guessLevel`):先认 log4j 注入的 +//! `[HH:mm:ss] [thread/LEVEL]` 格式,再退到 JUL 的 `[INFO]` 形态;`stderr` 一律当 +//! ERROR(`[authlib-injector]` 例外)。 +//! +//! 目的有两个:给事件流打 `level`,以及让环形缓冲能“关键行永久保留” +//! (`analyse/02` §7.10-3:2000 行截断会把根因行挤掉)。 + +/// 日志级别(认知顺序:Error 最严重)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Level { + /// 错误。 + Error, + /// 警告。 + Warn, + /// 信息。 + Info, + /// 调试。 + Debug, + /// 无法判断。 + Unknown, +} + +impl Level { + /// 事件流里用的字符串形式。 + pub fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + Self::Debug => "debug", + Self::Unknown => "unknown", + } + } + + /// 是否错误级(退出分析筛行用)。 + pub fn is_error(self) -> bool { + self == Self::Error + } +} + +/// 猜测一行日志的级别。 +pub fn guess(is_stderr: bool, line: &str) -> Level { + let trimmed = line.trim_start(); + if is_stderr { + // authlib-injector 往 stderr 打的都是正常信息(`analyse/02` §6.4)。 + if trimmed.starts_with("[authlib-injector]") { + return Level::Info; + } + return Level::Error; + } + if let Some(level) = log4j_level(trimmed) { + return level; + } + jul_level(trimmed).unwrap_or(Level::Unknown) +} + +/// 是否“关键行”:命中这些特征的行不随环形淘汰(`Exception|Error|Caused by|OutOfMemory`)。 +pub fn is_key_line(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("exception") + || lower.contains("error") + || lower.contains("caused by") + || lower.contains("outofmemory") + || lower.contains("fatal") +} + +/// 从 `[12:34:56] [Render thread/INFO]: ...` 里取级别。 +/// +/// 逐段扫描方括号内容,取第一段形如 `<线程名>/` 的级别,最多看 3 段。 +fn log4j_level(line: &str) -> Option { + let mut rest = line; + for _ in 0..3 { + let open = rest.find('[')?; + let tail = &rest[open + 1..]; + let close = tail.find(']')?; + let segment = &tail[..close]; + if let Some(slash) = segment.rfind('/') { + if let Some(level) = level_from_name(&segment[slash + 1..]) { + return Some(level); + } + } + rest = &tail[close + 1..]; + } + None +} + +fn level_from_name(name: &str) -> Option { + match name.trim() { + "ERROR" | "FATAL" => Some(Level::Error), + "WARN" | "WARNING" => Some(Level::Warn), + "INFO" | "NOTICE" => Some(Level::Info), + "DEBUG" | "TRACE" | "FINE" | "FINER" | "FINEST" => Some(Level::Debug), + _ => None, + } +} + +/// JUL/裸标记形态:`[INFO]`、`[SEVERE]`、`[WARNING]`。 +fn jul_level(line: &str) -> Option { + const MARKERS: &[(&str, Level)] = &[ + ("[SEVERE]", Level::Error), + ("[ERROR]", Level::Error), + ("[WARNING]", Level::Warn), + ("[WARN]", Level::Warn), + ("[INFO]", Level::Info), + ("[DEBUG]", Level::Debug), + ("[FINE]", Level::Debug), + ]; + MARKERS + .iter() + .find(|(marker, _)| line.starts_with(marker)) + .map(|(_, level)| *level) +} diff --git a/crates/core/src/launch/mod.rs b/crates/core/src/launch/mod.rs new file mode 100644 index 0000000..02db942 --- /dev/null +++ b/crates/core/src/launch/mod.rs @@ -0,0 +1,75 @@ +//! 启动管线:10 阶段命令行组装、Java 选择、子进程管理、结构化 JSONL 事件流。 +//! +//! 设计定稿:`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §1(管线)与 §3(事件流), +//! Java 侧约束见 `DevDocs/PlanDoc/01d-language-choice.md` §3(JDK 17 基准 + 版本映射提示)。 +//! +//! 事件流是 core → app 的唯一启动反馈通道(另一条是快照发布), +//! 不做 200ms 批量刷 UI 式的隐式耦合。 +//! +//! # 公共面 +//! +//! - [`Launcher`]:`dry_run`(D18,只组装命令行)、`spawn`/`launch`(完整管线); +//! - [`LaunchRequest`] 是命令行组装的**纯数据输入**,由 [`adapter`] 从 game 快照映射而来; +//! - [`AssembledCommand`] 是组装结果(program + argv + classpath + 环境变量 + 告警); +//! - 事件流([`session`])与索引([`session_index`])是 M15 诊断采集器与 +//! M16 `read_game_log`/`list_game_sessions` 的数据源,schema 见 +//! `DevDocs/SubAgentsTalk/launch-接口约定.md`; +//! - [`DiagnosisHook`] 是接 M15 的唯一接缝(launch 报事实、M15 下结论)。 +//! +//! # 与 HMCL 的三处差异(`analyse/02` §8.5) +//! +//! 1. `exit 0` 静默失败单独立标([`exit_analysis`]); +//! 2. 日志泵 join 有预算([`watchdog`]),进程树三段式回收([`tree_kill`],P1 换 Job Object); +//! 3. 窗口出现检测带启发式名称/耗时/证据([`window_detect`])。 +//! +//! 零 UI 依赖:本模块只产出结构化事件与纯数据,不 import 任何 UI 类型。 + +pub mod adapter; +pub mod args_table; +pub mod argument; +pub mod command_builder; +pub mod crash_scan; +pub mod defaults; +pub mod diagnosis_hook; +pub mod error; +pub mod exit_analysis; +pub mod game_args; +pub mod game_version; +pub mod jvm_args; +pub mod launch_session; +pub mod launcher; +pub mod log_level; +pub mod natives; +pub mod placeholders; +pub mod platform; +pub mod process; +pub mod pump; +pub mod redact; +pub mod request; +pub mod session; +pub mod session_index; +pub mod session_meta; +pub mod session_payload; +pub mod session_sink; +pub mod sink; +pub mod time; +pub mod tree_kill; +pub mod watchdog; +pub mod window_detect; + +pub use adapter::{ + build_request, AdapterOptions, AdapterOutput, AdapterWarning, LibraryInput, SnapshotInput, +}; +pub use args_table::{assemble, AssembledCommand, AssemblyWarning}; +pub use crash_scan::{CrashArtifact, CrashSource}; +pub use diagnosis_hook::{DiagnosisHook, DiagnosisRequest, NoopDiagnosis}; +pub use error::{LaunchError, MissingComponent, Result}; +pub use exit_analysis::{ExitAnalysis, ExitType, SilentFailure}; +pub use launch_session::{LaunchSession, SessionHandle}; +pub use launcher::{DryRun, Launcher, LauncherConfig}; +pub use natives::{NativeJar, NativesEra, NativesPlan, NativesReport}; +pub use process::{ProcessConfig, RunningProcess}; +pub use request::LaunchRequest; +pub use session::{EndReason, SessionMeta, SessionSink, SessionWriter}; +pub use session_index::SessionIndexEntry; +pub use sink::{CompositeSink, CountingSink, FnSink}; diff --git a/crates/core/src/launch/natives/jar.rs b/crates/core/src/launch/natives/jar.rs new file mode 100644 index 0000000..5e8c559 --- /dev/null +++ b/crates/core/src/launch/natives/jar.rs @@ -0,0 +1,250 @@ +//! natives jar 的读取细节:zip 遍历、条目筛选、目录清理、戳记读写。 +//! +//! 与 [`super`] 的分工:`super` 管「计划与报告」,这里管「怎么把一个 jar 里的原生库落到盘上」。 + +use std::fs::{self, File}; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +use serde::{Deserialize, Serialize}; +use zip::ZipArchive; + +use super::{NativeJar, NativesError, NativesReport, STAMP_FILE}; + +/// 戳记 schema 版本(改字段语义才升)。 +const STAMP_VERSION: u8 = 1; + +/// 内置排除前缀:Mojang 清单惯例,manifest 没写 `extract` 规则时也这么办。 +const DEFAULT_EXCLUDES: &[&str] = &["META-INF/"]; + +/// 原生库扩展名白名单(Windows / Linux / macOS;`jnilib` 是 JDK6 时代的老 mac 后缀)。 +const NATIVE_EXTENSIONS: &[&str] = &["dll", "so", "dylib", "jnilib"]; + +/// 签名 / 校验附属文件扩展名:即使在 natives jar 根目录也绝不落地。 +const SIGNATURE_EXTENSIONS: &[&str] = &["sf", "rsa", "dsa", "ec", "asc", "sha1", "sha256", "git"]; + +/// 戳记的一条:源 jar 的「路径 + 字节数 + mtime」指纹。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct JarStamp { + /// 源 jar 绝对路径(同一目录换了别的版本时必须能区分开)。 + pub path: String, + /// 字节数。 + pub len: u64, + /// 修改时间(Unix 毫秒;读不到时 0,等价于「每次都当它变了」)。 + pub mtime_ms: u64, +} + +/// 目录戳记文件内容。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Stamp { + v: u8, + files: usize, + jars: Vec, +} + +impl Stamp { + /// 戳记是否仍然对应当前的 jar 集(版本 + 指纹逐一相等)。 + pub(crate) fn matches(&self, stamps: &[JarStamp]) -> bool { + self.v == STAMP_VERSION && self.jars == stamps + } + + /// 上次解压的文件数。 + pub(crate) fn files(&self) -> usize { + self.files + } +} + +/// 构造 `NativesError::Io`。 +pub(crate) fn io_error( + op: &'static str, + path: impl Into, + source: io::Error, +) -> NativesError { + NativesError::Io { + op, + path: path.into(), + source, + } +} + +/// 解压单个 jar:排除前缀 → 原生库白名单 → zip-slip 防护 → 落盘。 +pub(crate) fn extract_jar( + native: &NativeJar, + dir: &Path, + report: &mut NativesReport, +) -> Result<(), NativesError> { + let file = File::open(&native.path) + .map_err(|source| io_error("open_native_jar", &native.path, source))?; + let mut archive = ZipArchive::new(file).map_err(|err| NativesError::Zip { + path: native.path.clone(), + reason: err.to_string(), + })?; + let excludes: Vec<&str> = DEFAULT_EXCLUDES + .iter() + .copied() + .chain(native.exclude.iter().map(String::as_str)) + .collect(); + + for index in 0..archive.len() { + let mut entry = archive.by_index(index).map_err(|err| NativesError::Zip { + path: native.path.clone(), + reason: err.to_string(), + })?; + if entry.is_dir() { + continue; + } + // `enclosed_name` 拒绝 `..` / 绝对路径条目(zip-slip)。 + let Some(relative) = entry.enclosed_name() else { + report.ignored += 1; + continue; + }; + let name = relative.to_string_lossy().replace('\\', "/"); + if !should_extract(&name, &excludes) { + report.ignored += 1; + continue; + } + let dest = dir.join(&relative); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .map_err(|source| io_error("create_natives_subdir", parent, source))?; + } + let mut out = + File::create(&dest).map_err(|source| io_error("create_native_file", &dest, source))?; + io::copy(&mut entry, &mut out) + .map_err(|source| io_error("write_native_file", &dest, source))?; + report.files += 1; + } + Ok(()) +} + +/// 该条目要不要解压:排除前缀命中、签名文件、非原生库扩展名 → 跳过。 +pub(crate) fn should_extract(name: &str, excludes: &[&str]) -> bool { + if excludes.iter().any(|prefix| name.starts_with(prefix)) { + return false; + } + let lower = name.to_ascii_lowercase(); + let extension = lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or(""); + if SIGNATURE_EXTENSIONS.contains(&extension) { + return false; + } + NATIVE_EXTENSIONS.contains(&extension) +} + +/// 清掉目录里已有的原生库与戳记(尽力而为:删不掉的留着,后面覆盖写会报错)。 +pub(crate) fn clear_natives(dir: &Path) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + continue; + } + let name = path + .file_name() + .map(|name| name.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + if name == STAMP_FILE { + let _ = fs::remove_file(&path); + continue; + } + let is_native = name + .rsplit_once('.') + .map(|(_, ext)| NATIVE_EXTENSIONS.contains(&ext)) + .unwrap_or(false); + if is_native { + let _ = fs::remove_file(&path); + } + } +} + +/// 源 jar 指纹(路径 + 字节数 + mtime)。 +pub(crate) fn jar_stamps(jars: &[NativeJar]) -> Vec { + jars.iter() + .map(|native| { + let metadata = fs::metadata(&native.path); + let len = metadata.as_ref().map(|meta| meta.len()).unwrap_or(0); + let mtime_ms = metadata + .ok() + .and_then(|meta| meta.modified().ok()) + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0); + JarStamp { + path: native.path.display().to_string(), + len, + mtime_ms, + } + }) + .collect() +} + +/// 读戳记(不存在 / 损坏 / 版本不符都当作「没有戳记」)。 +pub(crate) fn read_stamp(dir: &Path) -> Option { + let text = fs::read_to_string(dir.join(STAMP_FILE)).ok()?; + let stamp: Stamp = serde_json::from_str(&text).ok()?; + if stamp.v == STAMP_VERSION { + Some(stamp) + } else { + None + } +} + +/// 写戳记(直接覆盖写:半截文件下次解析失败即视为「没有戳记」,重解一遍就行)。 +pub(crate) fn write_stamp(dir: &Path, files: usize, jars: &[JarStamp]) -> Result<(), NativesError> { + let path = dir.join(STAMP_FILE); + let stamp = Stamp { + v: STAMP_VERSION, + files, + jars: jars.to_vec(), + }; + let text = serde_json::to_string_pretty(&stamp).map_err(|err| NativesError::Zip { + path: path.clone(), + reason: err.to_string(), + })?; + fs::write(&path, text).map_err(|source| io_error("write_natives_stamp", &path, source)) +} + +#[cfg(test)] +mod tests { + use super::{should_extract, DEFAULT_EXCLUDES}; + + #[test] + fn native_library_extensions_pass() { + for name in [ + "lwjgl.dll", + "OpenAL.dll", + "liblwjgl.so", + "liblwjgl.dylib", + "liblwjgl.jnilib", + ] { + assert!(should_extract(name, DEFAULT_EXCLUDES), "{name} 应解压"); + } + } + + #[test] + fn metadata_and_signature_entries_are_skipped() { + for name in [ + "META-INF/MANIFEST.MF", + "META-INF/FOO.SF", + "META-INF/FOO.RSA", + "lwjgl.dll.sha1", + "lwjgl.dll.sha256", + "README.txt", + "LICENSE", + // 无扩展名的文件一律不进 java.library.path。 + "lwjgl", + ] { + assert!(!should_extract(name, DEFAULT_EXCLUDES), "{name} 不该解压"); + } + } + + #[test] + fn manifest_exclude_prefixes_apply_on_top_of_builtin() { + assert!(!should_extract("extra/x.dll", &["extra/"])); + assert!(should_extract("x.dll", &["extra/"])); + // 大小写不敏感只对扩展名生效(排除前缀按清单原样比较,与 HMCL 一致)。 + assert!(should_extract("X.DLL", DEFAULT_EXCLUDES)); + } +} diff --git a/crates/core/src/launch/natives/mod.rs b/crates/core/src/launch/natives/mod.rs new file mode 100644 index 0000000..a57e83c --- /dev/null +++ b/crates/core/src/launch/natives/mod.rs @@ -0,0 +1,355 @@ +//! natives 解压:把版本链里的 natives 分类器构件解到 `versions//natives/`。 +//! +//! 这是 10 阶段管线里**第 6 段「环境准备」**的落盘部分(`PlanDoc/06` §1),发生顺序在 +//! 命令行组装之前(HMCL 也在组装期做:`DefaultLauncher.decompressNatives`, +//! `analyse/03` §6.4-(7)),因此 `--dry-run` 也能验证「装完到底能不能起」。 +//! +//! # 三个时代(`SubAgentsTalk/launch-natives-解压与时代分界.md`,piston-meta 实测) +//! +//! | 版本区间 | natives 形态 | 谁负责 `-Djava.library.path` | +//! |---|---|---| +//! | ≤1.12.2(LWJGL 2.9) | `natives` 表 + `downloads.classifiers` | 启动器补(`defaults::legacy_jvm_arguments`) | +//! | 1.13–1.18.2(LWJGL 3.1/3.2) | 同上(另有同坐标的主构件条目) | 清单自己写 `arguments.jvm` | +//! | 1.19+(LWJGL 3.3) | **没有 `natives` 表**:natives jar 是普通 classpath 库 | 清单写 4 个目录属性,LWJGL 运行期自解压 | +//! +//! 所以三个时代走**同一条代码路径**:有 jar 就解压,没 jar 就只把目录建出来 +//! (1.19+ 的 `-Dorg.lwjgl.system.SharedLibraryExtractPath` 指向它;目录不存在时 +//! LWJGL 会静默走到「找不到 native」)。 +//! +//! # 幂等 +//! +//! 解压完在目录里写一份戳记 [`jar::STAMP_FILE`](每个源 jar 的路径 + 字节数 + mtime)。 +//! 下次启动:戳记与当前 jar 集完全一致 → 目录视为就绪,**不读 jar、不写盘**直接跳过; +//! 不一致(换版本 / 库升级 / 用户删了目录)→ 清掉旧原生库再全量解压,避免两代 dll +//! 混在同一个 `java.library.path` 里(HMCL 也是每次清目录,这里只把「没变就不动」补上)。 + +pub(crate) mod jar; + +use std::path::{Path, PathBuf}; + +use crate::game::arguments::Environment; +use crate::game::layout::GameLayout; +use crate::game::library::Library; +use crate::launch::natives::jar::{ + clear_natives, extract_jar, jar_stamps, read_stamp, write_stamp, +}; + +/// 戳记文件名:放在 natives 目录里,跟着目录一起删 / 建。 +/// +/// 公开出来是为了「一键修复」类功能能认出自己的痕迹(判断目录里哪些文件是我们解出来的)。 +pub const STAMP_FILE: &str = ".ull-natives.json"; + +/// 一个待解压的 natives 构件(版本链里 `natives` 分类器命中的 jar)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeJar { + /// 磁盘路径(`libraries//.../--natives-.jar`)。 + pub path: PathBuf, + /// 坐标 + 分类器(`org.lwjgl:lwjgl:3.2.2:natives-windows`,报错与事件里用)。 + pub name: String, + /// 清单 `extract.exclude` 的排除前缀(`["META-INF/"]`)。 + pub exclude: Vec, +} + +impl NativeJar { + /// 构造(无排除规则)。 + pub fn new(path: impl Into, name: impl Into) -> Self { + Self { + path: path.into(), + name: name.into(), + exclude: Vec::new(), + } + } + + /// 带清单排除规则构造。 + pub fn with_exclude( + path: impl Into, + name: impl Into, + exclude: Vec, + ) -> Self { + Self { + path: path.into(), + name: name.into(), + exclude, + } + } +} + +/// natives 处理的时代(决定「要不要解压」以及「`-Djava.library.path` 从哪来」)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NativesEra { + /// ≤1.12.2:清单没有 `arguments.jvm`,启动器自己补 `-Djava.library.path`。 + #[default] + Legacy, + /// 1.13–1.18.2:清单有 `arguments.jvm`,natives 走分类器 jar(需要解压)。 + Lwjgl3, + /// 1.19+:natives jar 是普通 classpath 库,LWJGL 3.3 运行期自解压(只需目录存在)。 + ClasspathNative, +} + +impl NativesEra { + /// 按「清单有没有 `arguments.jvm`」+「有没有 natives 分类器 jar」判时代。 + pub fn detect(manifest_jvm_args: usize, natives_jars: usize) -> Self { + if manifest_jvm_args == 0 { + Self::Legacy + } else if natives_jars == 0 { + Self::ClasspathNative + } else { + Self::Lwjgl3 + } + } + + /// 事件/日志里的稳定字符串。 + pub fn as_str(self) -> &'static str { + match self { + Self::Legacy => "legacy", + Self::Lwjgl3 => "lwjgl3", + Self::ClasspathNative => "classpath_native", + } + } +} + +/// 解压计划(`LaunchRequest` 的一部分,纯数据)。 +/// +/// 目标目录**不在**这里:唯一真相源是 `LaunchDirs::natives_dir`(`${natives_directory}` 也用它), +/// 免得出现「参数指向 A、dll 解到 B」的静默错位。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativesPlan { + /// 待解压的 natives 分类器构件。 + pub jars: Vec, + /// 时代(只影响报告字段;解压路径三时代统一)。 + pub era: NativesEra, + /// 目录是否用户自定义(事件 payload 的 `custom` 字段,给「自备 dll 被覆盖」留证据)。 + pub custom: bool, +} + +impl Default for NativesPlan { + fn default() -> Self { + Self { + jars: Vec::new(), + era: NativesEra::ClasspathNative, + custom: false, + } + } +} + +impl NativesPlan { + /// 以 jar 清单构造:有 jar 按 [`NativesEra::Lwjgl3`],没有按 [`NativesEra::ClasspathNative`]。 + /// + /// 这一层分不出「≤1.12.2 的 LWJGL 2」与「1.13–1.18.2 的 LWJGL 3」(两者都有分类器 jar), + /// 所以适配层用 [`NativesPlan::with_era`] 明确给值;直接构造只适合测试与脚本。 + pub fn new(jars: Vec) -> Self { + let era = NativesEra::detect(usize::from(!jars.is_empty()), jars.len()); + Self { + jars, + era, + custom: false, + } + } + + /// 指定时代。 + pub fn with_era(mut self, era: NativesEra) -> Self { + self.era = era; + self + } + + /// 标记目录为用户自定义。 + pub fn with_custom(mut self, custom: bool) -> Self { + self.custom = custom; + self + } + + /// 没有任何 natives 构件(1.19+ 的常态)。 + pub fn is_empty(&self) -> bool { + self.jars.is_empty() + } +} + +/// 解压结果(进 `natives_extracted` 事件与 `--dry-run` 输出)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativesReport { + /// 目标目录。 + pub dir: PathBuf, + /// 时代。 + pub era: NativesEra, + /// 参与的 jar 数。 + pub jars: usize, + /// 本次写盘的原生库文件数。 + pub files: usize, + /// 按戳记跳过、未重新解压的文件数(= 上次解压的文件数)。 + pub skipped: usize, + /// 被排除 / 非原生库而忽略的条目数(`META-INF/`、签名文件)。 + pub ignored: usize, + /// 目录是否用户自定义。 + pub custom: bool, + /// 是否按戳记判定「目录已就绪」而整体跳过。 + pub up_to_date: bool, +} + +impl NativesReport { + /// 人读一行(CLI / 日志用)。 + pub fn summary(&self) -> String { + if self.jars == 0 { + return format!( + "natives 目录已就绪(该版本无需解压,LWJGL 运行期自取)→ {}", + self.dir.display() + ); + } + if self.up_to_date { + return format!( + "natives 已就绪({} 个原生库,{} 个 jar 未变)", + self.skipped, self.jars + ); + } + format!( + "natives 解压 {} 个原生库({} 个 jar,忽略 {} 个条目)→ {}", + self.files, + self.jars, + self.ignored, + self.dir.display() + ) + } +} + +/// 解压失败原因。 +#[derive(Debug)] +pub enum NativesError { + /// 目标目录为空(调用方漏填 `natives_dir`)。 + NoDir, + /// 文件系统操作失败。 + Io { + /// 操作名。 + op: &'static str, + /// 相关路径。 + path: PathBuf, + /// 底层错误。 + source: std::io::Error, + }, + /// natives jar 打不开 / 不是合法 zip(下载损坏的最常见形态)。 + Zip { + /// jar 路径。 + path: PathBuf, + /// 原因。 + reason: String, + }, +} + +impl NativesError { + /// 一行人读描述。 + pub fn message(&self) -> String { + match self { + Self::NoDir => "natives 目录未配置(natives_dir 为空)".to_string(), + Self::Io { op, path, source } => { + format!("{op} 失败({}):{source}", path.display()) + } + Self::Zip { path, reason } => { + format!( + "natives jar 无法作为 zip 打开({}):{reason}", + path.display() + ) + } + } + } +} + +impl std::fmt::Display for NativesError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message()) + } +} + +impl std::error::Error for NativesError {} + +/// 从版本链库表收集 natives 分类器构件(adapter / app / CLI 共用这一段,别各写一份)。 +/// +/// 口径与安装层 `game::install::libraries::plan` **除 `hint=local` 落点外**逐条对齐: +/// 1. 先按环境过滤(`Library::applies`); +/// 2. `natives` 表命中当前 OS 才取分类器(1.19+ 没有 `natives` 表 → 返回空表); +/// 3. 落点优先用清单 `downloads.classifiers..path`,缺了才按 Maven 坐标推导 +/// (`Library::native_relative_path`);`hint=local` 的库在实例私有目录里 +/// (`analyse/03` §6.8 的 HMCL 口径)。 +/// +/// ⚠️ 安装层当前的 natives 分类器分支硬编码共享 `libraries/`、不认 `hint=local`(主构件认), +/// 所以「`hint=local` + `natives` 同体」的 manifest(只有 HMCL 补丁导入才会有)会出现 +/// 「装到共享目录、解压去实例目录」→ 由 [`crate::launch::adapter::AdapterWarning::MissingNativeJar`] +/// 转成启动前 `missing_components` 显式失败。此处仍按 HMCL 权威口径实现,等 install 侧对齐。 +pub fn collect( + layout: &GameLayout, + version_id: &str, + libraries: &[Library], + environment: &Environment, +) -> Vec { + let mut natives = Vec::new(); + for library in libraries { + if !library.applies(environment) { + continue; + } + let Some(classifier) = library.native_classifier(environment) else { + continue; + }; + let Ok(relative) = library.native_relative_path(&classifier) else { + continue; + }; + // 安装层当前对 natives 不做这条分支(见上方警告);这里按 HMCL 口径落实例私有目录。 + let base = if library.is_local() { + layout.version_libraries_dir(version_id) + } else { + layout.libraries_dir() + }; + let exclude = library + .extract + .as_ref() + .map(|extract| extract.exclude.clone()) + .unwrap_or_default(); + natives.push(NativeJar::with_exclude( + base.join(relative), + format!("{}:{classifier}", library.name), + exclude, + )); + } + natives +} + +/// 解压一个计划(幂等:目录与源 jar 都没变时零写盘)。 +/// +/// # Errors +/// +/// 目录为空、建目录 / 写文件失败、jar 不是合法 zip 时返回 [`NativesError`]。 +pub fn extract(dir: &Path, plan: &NativesPlan) -> Result { + if dir.as_os_str().is_empty() { + return Err(NativesError::NoDir); + } + let mut report = NativesReport { + dir: dir.to_path_buf(), + era: plan.era, + jars: plan.jars.len(), + files: 0, + skipped: 0, + ignored: 0, + custom: plan.custom, + up_to_date: false, + }; + // 1.19+ 没有 jar 也要建目录:LWJGL 3.3 往 SharedLibraryExtractPath 里解压。 + std::fs::create_dir_all(dir) + .map_err(|source| jar::io_error("create_natives_dir", dir, source))?; + if plan.jars.is_empty() { + report.up_to_date = true; + return Ok(report); + } + + let stamps = jar_stamps(&plan.jars); + if let Some(stamp) = read_stamp(dir) { + if stamp.matches(&stamps) { + report.up_to_date = true; + report.skipped = stamp.files(); + return Ok(report); + } + } + + // 源变了:先清旧原生库(避免两代 dll 混在同一个 java.library.path 里),再全量解。 + clear_natives(dir); + for native in &plan.jars { + extract_jar(native, dir, &mut report)?; + } + write_stamp(dir, report.files, &stamps)?; + Ok(report) +} diff --git a/crates/core/src/launch/placeholders.rs b/crates/core/src/launch/placeholders.rs new file mode 100644 index 0000000..b99f42b --- /dev/null +++ b/crates/core/src/launch/placeholders.rs @@ -0,0 +1,173 @@ +//! 参数占位符字典与替换。 +//! +//! 占位符清单平移自 `analyse/02` §4.8(`DefaultLauncher.getConfigurations()`): +//! 版本清单里的 `arguments.*` 用 `${...}` 引用运行时值,启动器负责在组装时替换。 +//! 替换语义与 HMCL 一致:**单遍、不递归**;未定义的占位符原样保留(便于发现清单漏项)。 + +use std::collections::BTreeMap; +use std::path::Path; + +use crate::launch::platform::PlatformCtx; +use crate::launch::request::LaunchRequest; + +/// 我们支持的占位符键(不含 `${}` 包裹)。 +pub const PLACEHOLDER_KEYS: &[&str] = &[ + "auth_player_name", + "auth_session", + "auth_access_token", + "auth_uuid", + "user_type", + "user_properties", + "version_name", + "version_type", + "profile_name", + "game_directory", + "assets_index_name", + "game_assets", + "assets_root", + "natives_directory", + "classpath", + "classpath_separator", + "launcher_name", + "launcher_version", + "library_directory", + "libraries_directory", + "primary_jar", + "primary_jar_name", + "file_separator", + "resolution_width", + "resolution_height", + "language", +]; + +/// 占位符表。 +#[derive(Debug, Clone, Default)] +pub struct PlaceholderMap { + entries: BTreeMap, +} + +impl PlaceholderMap { + /// 空表。 + pub fn new() -> Self { + Self::default() + } + + /// 插入一个占位符(键不含 `${}`)。 + pub fn insert(&mut self, key: &str, value: impl Into) { + self.entries.insert(key.to_string(), value.into()); + } + + /// 查询。 + pub fn get(&self, key: &str) -> Option<&str> { + self.entries.get(key).map(String::as_str) + } + + /// 已登记的键数。 + pub fn len(&self) -> usize { + self.entries.len() + } + + /// 是否为空。 + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// 单遍替换 `${key}`;未定义键原样保留。 + pub fn render(&self, template: &str) -> String { + if !template.contains("${") { + return template.to_string(); + } + let mut out = String::with_capacity(template.len() + 16); + let mut rest = template; + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + match after.find('}') { + Some(end) => { + let key = &after[..end]; + match self.entries.get(key) { + Some(value) => out.push_str(value), + None => { + out.push_str("${"); + out.push_str(key); + out.push('}'); + } + } + rest = &after[end + 1..]; + } + None => { + out.push_str(&rest[start..]); + rest = ""; + } + } + } + out.push_str(rest); + out + } + + /// 由启动请求构造完整占位符表。 + pub fn from_request(req: &LaunchRequest, ctx: &PlatformCtx) -> Self { + let mut map = Self::new(); + let auth = &req.auth; + map.insert("auth_player_name", auth.player_name.clone()); + map.insert("auth_session", auth.access_token.clone()); + map.insert("auth_access_token", auth.access_token.clone()); + map.insert("auth_uuid", auth.uuid.clone()); + map.insert("user_type", auth.user_type.clone()); + map.insert("user_properties", auth.user_properties.clone()); + + map.insert("version_name", req.instance.name.clone()); + map.insert("version_type", req.instance.version_type.clone()); + map.insert("profile_name", req.instance.id.clone()); + + let dirs = &req.dirs; + map.insert("game_directory", path_str(&dirs.game_dir)); + map.insert("assets_index_name", dirs.assets_index.clone()); + map.insert("game_assets", path_str(&dirs.game_assets)); + map.insert("assets_root", path_str(&dirs.assets_root)); + map.insert("natives_directory", path_str(&dirs.natives_dir)); + map.insert( + "classpath", + join_classpath(&req.classpath, ctx.path_separator()), + ); + map.insert("classpath_separator", ctx.path_separator().to_string()); + map.insert("launcher_name", req.launcher.name.clone()); + map.insert("launcher_version", req.launcher.version.clone()); + map.insert("library_directory", path_str(&dirs.library_dir)); + map.insert("libraries_directory", path_str(&dirs.library_dir)); + map.insert("primary_jar", path_str(&dirs.primary_jar)); + map.insert( + "primary_jar_name", + dirs.primary_jar + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(), + ); + map.insert("file_separator", ctx.file_separator().to_string()); + match &req.resolution { + Some(res) => { + map.insert("resolution_width", res.width.to_string()); + map.insert("resolution_height", res.height.to_string()); + } + None => { + map.insert("resolution_width", String::new()); + map.insert("resolution_height", String::new()); + } + } + map.insert("language", req.language.clone()); + map + } +} + +/// 按平台分隔符拼接 classpath。 +pub fn join_classpath(paths: &[std::path::PathBuf], separator: char) -> String { + paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(&separator.to_string()) +} + +fn path_str(path: &Path) -> String { + path.display().to_string() +} diff --git a/crates/core/src/launch/platform.rs b/crates/core/src/launch/platform.rs new file mode 100644 index 0000000..435b4a0 --- /dev/null +++ b/crates/core/src/launch/platform.rs @@ -0,0 +1,95 @@ +//! 平台上下文:命令行组装与进程管理需要知道的操作系统事实。 +//! +//! 依据 `analyse/02` §4.1(Linux/macOS `nice`)、§4.4(原生编码)、§4.6(原生库路径)、 +//! §4.10(环境变量,`APPDATA` 重定向只在 Windows 有意义)。 +//! +//! `os_version` 不再是空占位:`current()` 从 [`crate::platform::os_info`] 的运行期探测取值, +//! 清单里带 `rules.os.version` 的参数项因此能真正命中(D19 · `PlanDoc/11-os-detection.md` §2)。 + +use std::path::PathBuf; + +pub use crate::platform::os_info::OsFamily; + +/// 平台上下文。 +#[derive(Debug, Clone)] +pub struct PlatformCtx { + /// 操作系统家族。 + pub os: OsFamily, + /// `rules.os.arch` 用的架构名。 + pub arch: String, + /// 系统原生编码(`-Dfile.encoding` 的默认值来源)。 + pub native_charset: String, + /// 主目录(脱敏与 `-Duser.home` 用)。 + pub home_dir: Option, + /// 系统版本串(`rules.os.version` 用,例如 `10.0.19045`)。 + pub os_version: String, +} + +impl PlatformCtx { + /// 按当前编译目标推断,版本串取运行期真实探测值。 + pub fn current() -> Self { + let info = crate::platform::os_info::current(); + Self { + os: info.family, + arch: info.arch.clone(), + native_charset: native_charset().to_string(), + home_dir: home_dir(), + os_version: info.os_version().to_string(), + } + } + + /// 测试用:显式指定系统与架构,版本串留空(规则求值走保守分支)。 + pub fn new(os: OsFamily, arch: &str) -> Self { + Self { + os, + arch: arch.to_string(), + native_charset: native_charset().to_string(), + home_dir: None, + os_version: String::new(), + } + } + + /// 显式指定版本串(测试与适配层注入用)。 + pub fn with_os_version(mut self, version: impl Into) -> Self { + self.os_version = version.into(); + self + } + + /// classpath 分隔符(Windows `;`,POSIX `:`)。 + pub fn path_separator(&self) -> char { + if self.os == OsFamily::Windows { + ';' + } else { + ':' + } + } + + /// 文件名分隔符的字符串形式(供 `${file_separator}` 占位符)。 + pub fn file_separator(&self) -> &'static str { + if self.os == OsFamily::Windows { + "\\" + } else { + "/" + } + } +} + +impl Default for PlatformCtx { + fn default() -> Self { + Self::current() + } +} + +/// 系统原生编码名。 +/// +/// 中文 Windows 上系统编码是 GBK,但 Java 18+ 默认 UTF-8;这里统一给 `UTF-8`, +/// 需要跟随系统时由用户在 `-Dfile.encoding=COMPAT` 语义下覆盖(`analyse/02` §4.4)。 +fn native_charset() -> &'static str { + "UTF-8" +} + +fn home_dir() -> Option { + std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) +} diff --git a/crates/core/src/launch/process.rs b/crates/core/src/launch/process.rs new file mode 100644 index 0000000..aad3409 --- /dev/null +++ b/crates/core/src/launch/process.rs @@ -0,0 +1,222 @@ +//! 进程管理:spawn + stdout/stderr 泵 + 进程树回收。 +//! +//! 对应 `analyse/02` §6 的四条线程模型(`stdout-pump` / `stderr-pump` / `exit-waiter`), +//! 但线程生命周期的收尾由 [`crate::launch::watchdog`] 兜底,不无条件 `join`。 +//! +//! 本模块只管“把命令行跑起来并收集输出”,不做组件检查、不做诊断—— +//! 那些是 `launch` 上层流水线(阶段B)与 M15 的职责。 + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use crate::launch::args_table::AssembledCommand; +use crate::launch::error::{LaunchError, Result}; +use crate::launch::pump::{LineSink, LogLine, PumpGroup, RingBuffer, RingBufferConfig}; +use crate::launch::tree_kill; +use crate::launch::watchdog::{self, PumpVerdict, WatchdogConfig}; + +/// 强制结束后等进程消失的预算。 +const FORCE_KILL_BUDGET: Duration = Duration::from_secs(5); +/// 轮询进程状态的间隔。 +const POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// 进程管理配置。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProcessConfig { + /// 日志泵等待预算。 + pub watchdog: WatchdogConfig, + /// 停止时温和关闭的宽限期。 + pub graceful_stop: Duration, + /// 环形缓冲配置。 + pub buffer: RingBufferConfig, +} + +impl Default for ProcessConfig { + fn default() -> Self { + Self { + watchdog: WatchdogConfig::default(), + graceful_stop: Duration::from_secs(3), + buffer: RingBufferConfig::default(), + } + } +} + +/// 已启动的游戏进程。 +#[derive(Debug)] +pub struct RunningProcess { + child: Child, + pump: PumpGroup, + buffer: Arc>, + started_at: Instant, + pid: u32, + config: ProcessConfig, +} + +impl RunningProcess { + /// 启动进程并接管两条输出流。 + pub fn spawn( + command: &AssembledCommand, + working_dir: Option<&Path>, + sink: Arc, + config: ProcessConfig, + ) -> Result { + let mut process = Command::new(&command.program); + process.args(&command.args); + for (key, value) in &command.env { + process.env(key, value); + } + if let Some(dir) = working_dir { + process.current_dir(dir); + } + process + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + /// 独立进程组:便于 `taskkill /T` 递归回收。 + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + process.creation_flags(CREATE_NEW_PROCESS_GROUP); + } + + let mut child = process.spawn().map_err(|source| LaunchError::Spawn { + program: command.program.clone(), + source, + })?; + let pid = child.id(); + let buffer = Arc::new(Mutex::new(RingBuffer::new(config.buffer))); + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let pump = PumpGroup::spawn(stdout, stderr, buffer.clone(), sink); + Ok(Self { + child, + pump, + buffer, + started_at: Instant::now(), + pid, + config, + }) + } + + /// 进程号。 + pub fn pid(&self) -> u32 { + self.pid + } + + /// 已运行毫秒数。 + pub fn uptime_ms(&self) -> u64 { + u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX) + } + + /// 启动时刻(窗口检测计时用)。 + pub fn started_at(&self) -> Instant { + self.started_at + } + + /// 共享的日志环形缓冲。 + pub fn buffer(&self) -> Arc> { + self.buffer.clone() + } + + /// 当前可读日志快照。 + pub fn lines(&self) -> Vec { + match self.buffer.lock() { + Ok(guard) => guard.lines().cloned().collect(), + Err(_) => Vec::new(), + } + } + + /// 等待进程退出(不等待日志泵)。 + pub fn wait(&mut self) -> Result { + self.child.wait().map_err(|source| LaunchError::Io { + op: "wait_child", + path: PathBuf::from(self.pid.to_string()), + source, + }) + } + + /// 进程退出后,按预算收尾日志泵。 + pub fn supervise(&mut self) -> PumpVerdict { + watchdog::wait_for_pumps(&mut self.pump, &self.config.watchdog) + } + + /// 进程是否仍在运行。 + pub fn is_running(&mut self) -> bool { + matches!(self.child.try_wait(), Ok(None)) + } + + /// 三段式停止整棵进程树(温和 → 宽限 → 强制)。 + /// + /// 判定标准是**进程是否真的没了**,不是 `taskkill`/`kill` 的退出码: + /// 外部命令对“已经退出的进程”会返回非 0(Windows 上 `taskkill` 报 128), + /// 把它当失败会误报——尤其是温和阶段已经把进程关掉的情况。 + pub fn stop(&mut self) -> Result<()> { + if !self.is_running() { + return Ok(()); + } + let _ = tree_kill::terminate(self.pid); + if self.wait_until_exit(self.config.graceful_stop) { + return Ok(()); + } + let _ = tree_kill::force_kill(self.pid); + if self.wait_until_exit(FORCE_KILL_BUDGET) { + return Ok(()); + } + Err(LaunchError::Kill { + pid: self.pid, + source: std::io::Error::other("强制结束后进程仍在运行"), + }) + } + + /// 直接强杀(看门狗/崩溃兜底路径)。 + pub fn force_kill(&mut self) -> Result<()> { + if !self.is_running() { + return Ok(()); + } + let _ = tree_kill::force_kill(self.pid); + if self.wait_until_exit(FORCE_KILL_BUDGET) { + Ok(()) + } else { + Err(LaunchError::Kill { + pid: self.pid, + source: std::io::Error::other("强制结束后进程仍在运行"), + }) + } + } + + fn wait_until_exit(&mut self, budget: Duration) -> bool { + let deadline = Instant::now() + budget; + loop { + match self.child.try_wait() { + Ok(Some(_)) => return true, + Ok(None) => {} + Err(_) => return false, + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(POLL_INTERVAL); + } + } +} + +/// 从退出状态取整数退出码。 +/// +/// POSIX 上被子进程信号杀死没有 `code()`,按 shell 惯例补 `128 + signal` +/// (`SIGKILL` → 137、`SIGTERM` → 143,与 `analyse/02` 附录 C 一致)。 +pub fn exit_code(status: &ExitStatus) -> i32 { + if let Some(code) = status.code() { + return code; + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return 128 + signal; + } + } + -1 +} diff --git a/crates/core/src/launch/pump.rs b/crates/core/src/launch/pump.rs new file mode 100644 index 0000000..f7fd684 --- /dev/null +++ b/crates/core/src/launch/pump.rs @@ -0,0 +1,379 @@ +//! stdout/stderr 泵与日志环形缓冲。 +//! +//! 平移自 `analyse/02` §6.1/§6.2(`StreamPump` + `ManagedProcess.lines`),并补两处改进: +//! 缓冲按“行数 + 字节数”双上限(§8.4-9),且命中 `Exception|Error|Caused by|OutOfMemory` +//! 的关键行**不随环形淘汰**——2000 行截断会把根因行挤掉(§7.10-3)。 +//! +//! 解码:我们强制给子进程下发 `-Dstdout.encoding=UTF-8`(`-Dsun.stdout.encoding` +//! for Java <19),因此泵按 UTF-8 宽松解码;用户把编码改成 GBK 时会出现替换字符, +//! 阶段B 再视需要引入 `encoding_rs`(记录在 `launch-接口约定.md` §7)。 + +use std::collections::VecDeque; +use std::io::{BufRead, BufReader, Read}; +use std::process::{ChildStderr, ChildStdout}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use crate::launch::log_level; + +/// 默认环形缓冲行数(与 HMCL 一致)。 +pub const DEFAULT_MAX_LINES: usize = 2000; +/// 默认环形缓冲字节上限。 +pub const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; +/// 单行最大字符数(超出截断并标注,避免单行撑爆事件流)。 +pub const MAX_LINE_CHARS: usize = 8192; + +/// 输出流。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Stream { + /// 标准输出。 + Stdout, + /// 标准错误。 + Stderr, +} + +impl Stream { + /// 事件流里的字符串形式(`"stdout"` / `"stderr"`)。 + pub fn as_str(self) -> &'static str { + match self { + Self::Stdout => "stdout", + Self::Stderr => "stderr", + } + } + + /// 是否标准错误。 + pub fn is_stderr(self) -> bool { + self == Self::Stderr + } +} + +/// 一行日志。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogLine { + /// 来源流。 + pub stream: Stream, + /// 文本(已去行尾、超长已截断)。 + pub text: String, +} + +impl LogLine { + /// 构造。 + pub fn new(stream: Stream, text: impl Into) -> Self { + Self { + stream, + text: text.into(), + } + } + + /// 猜测的级别。 + pub fn level(&self) -> log_level::Level { + log_level::guess(self.stream.is_stderr(), &self.text) + } + + /// 是否错误级。 + pub fn is_error(&self) -> bool { + self.level().is_error() + } +} + +/// 日志观察者(会话事件流、M15 采集器适配层都实现它)。 +pub trait LineSink: Send + Sync { + /// 收到一行日志。 + fn on_line(&self, line: &LogLine); +} + +/// 丢弃所有行的 sink(测试/无监听场景)。 +#[derive(Debug, Default, Clone, Copy)] +pub struct NullSink; + +impl LineSink for NullSink { + fn on_line(&self, _line: &LogLine) {} +} + +/// 环形缓冲配置。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RingBufferConfig { + /// 行数上限。 + pub max_lines: usize, + /// 字节上限。 + pub max_bytes: usize, +} + +impl Default for RingBufferConfig { + fn default() -> Self { + Self { + max_lines: DEFAULT_MAX_LINES, + max_bytes: DEFAULT_MAX_BYTES, + } + } +} + +/// 环形日志缓冲:近期行 + 永久保留的关键行。 +#[derive(Debug)] +pub struct RingBuffer { + config: RingBufferConfig, + recent: VecDeque, + retained: Vec, + bytes: usize, + pushed: u64, +} + +impl RingBuffer { + /// 构造。 + pub fn new(config: RingBufferConfig) -> Self { + Self { + config, + recent: VecDeque::new(), + retained: Vec::new(), + bytes: 0, + pushed: 0, + } + } + + /// 压入一行,必要时淘汰最早的行(关键行转入 `retained`)。 + pub fn push(&mut self, line: LogLine) { + self.pushed += 1; + let size = line.text.len(); + self.bytes += size; + self.recent.push_back(line); + while self.recent.len() > self.config.max_lines || self.bytes > self.config.max_bytes { + let Some(evicted) = self.recent.pop_front() else { + break; + }; + self.bytes = self.bytes.saturating_sub(evicted.text.len()); + if log_level::is_key_line(&evicted.text) { + self.retained.push(evicted); + } + } + } + + /// 按时间顺序取全部可读行(保留的关键行在前)。 + pub fn lines(&self) -> impl Iterator { + self.retained.iter().chain(self.recent.iter()) + } + + /// 拼接成文本(喂给规则引擎/日志切片)。 + pub fn text(&self) -> String { + let mut out = String::with_capacity(self.bytes.min(64 * 1024)); + for line in self.lines() { + out.push_str(&line.text); + out.push('\n'); + } + out + } + + /// 只要错误级行(退出分析的输入)。 + pub fn error_lines(&self) -> Vec<&LogLine> { + self.lines().filter(|line| line.is_error()).collect() + } + + /// 当前可读行数。 + pub fn len(&self) -> usize { + self.retained.len() + self.recent.len() + } + + /// 是否为空。 + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// 累计压入行数(含已被淘汰的)。 + pub fn total_pushed(&self) -> u64 { + self.pushed + } +} + +impl Default for RingBuffer { + fn default() -> Self { + Self::new(RingBufferConfig::default()) + } +} + +/// 单条流结束的回执。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StreamFinished { + /// 哪条流。 + pub stream: Stream, + /// 累计行数。 + pub line_count: u64, + /// 读取错误(有的话)。 + pub error: Option, +} + +/// 一次等待 PumpGroup 的结果。 +#[derive(Debug, Clone, Default)] +pub struct PumpCompletion { + /// 已结束的流。 + pub finished: Vec, + /// 超时仍未结束的流。 + pub stalled: Vec, +} + +impl PumpCompletion { + /// 是否全部结束。 + pub fn is_complete(&self) -> bool { + self.stalled.is_empty() + } + + /// 累计行数。 + pub fn total_lines(&self) -> u64 { + self.finished.iter().map(|item| item.line_count).sum() + } +} + +/// 一组泵线程(stdout + stderr)。 +#[derive(Debug)] +pub struct PumpGroup { + handles: Vec>, + receiver: Receiver, + streams: Vec, + completed: Vec, +} + +impl PumpGroup { + /// 为子进程的输出流派生泵线程。 + /// + /// `buffer` 与 `sink` 同时被两个泵线程共享:缓冲区用于“最后 N 行”, + /// sink 用于事件流落盘。 + pub fn spawn( + stdout: Option, + stderr: Option, + buffer: Arc>, + sink: Arc, + ) -> Self { + let (sender, receiver) = mpsc::channel(); + let mut handles = Vec::new(); + let mut streams = Vec::new(); + if let Some(stdout) = stdout { + streams.push(Stream::Stdout); + handles.push(spawn_one( + Box::new(stdout), + Stream::Stdout, + buffer.clone(), + sink.clone(), + sender.clone(), + )); + } + if let Some(stderr) = stderr { + streams.push(Stream::Stderr); + handles.push(spawn_one( + Box::new(stderr), + Stream::Stderr, + buffer, + sink, + sender.clone(), + )); + } + drop(sender); + Self { + handles, + receiver, + streams, + completed: Vec::new(), + } + } + + /// 期望的流数。 + pub fn expected(&self) -> usize { + self.streams.len() + } + + /// 等待所有泵结束(带超时,避免 HMCL `pump.join()` 永久挂死的风险)。 + pub fn wait(&mut self, timeout: Duration) -> PumpCompletion { + let deadline = Instant::now() + timeout; + let mut finished = Vec::new(); + while self.completed.len() < self.streams.len() { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + match self.receiver.recv_timeout(remaining) { + Ok(item) => { + if !self.completed.contains(&item.stream) { + self.completed.push(item.stream); + } + finished.push(item); + } + Err(_) => break, + } + } + let stalled = self + .streams + .iter() + .copied() + .filter(|stream| !self.completed.contains(stream)) + .collect(); + PumpCompletion { finished, stalled } + } + + /// 强制 join(可能永久阻塞——只在上层已经确认流已关闭时使用)。 + pub fn join_all(&mut self) { + for handle in self.handles.drain(..) { + let _ = handle.join(); + } + } +} + +type BoxedReader = Box; + +fn spawn_one( + reader: BoxedReader, + stream: Stream, + buffer: Arc>, + sink: Arc, + sender: Sender, +) -> JoinHandle<()> { + thread::Builder::new() + .name(format!("launch-pump-{}", stream.as_str())) + .spawn(move || { + let mut reader = BufReader::new(reader); + let mut raw = Vec::with_capacity(4096); + let mut line_count = 0u64; + let mut error = None; + loop { + raw.clear(); + match reader.read_until(b'\n', &mut raw) { + Ok(0) => break, + Ok(_) => { + let text = decode_line(&raw); + if text.is_empty() { + continue; + } + line_count += 1; + let line = LogLine::new(stream, text); + if let Ok(mut guard) = buffer.lock() { + guard.push(line.clone()); + } + sink.on_line(&line); + } + Err(err) => { + error = Some(err.to_string()); + break; + } + } + } + let _ = sender.send(StreamFinished { + stream, + line_count, + error, + }); + }) + .expect("spawn log pump thread") +} + +fn decode_line(raw: &[u8]) -> String { + let text = String::from_utf8_lossy(raw); + let trimmed = text.trim_end_matches(['\r', '\n']); + truncate_line(trimmed) +} + +fn truncate_line(text: &str) -> String { + if text.chars().count() <= MAX_LINE_CHARS { + return text.to_string(); + } + let head: String = text.chars().take(MAX_LINE_CHARS).collect(); + let extra = text.chars().count() - MAX_LINE_CHARS; + format!("{head}…[truncated {extra} chars]") +} diff --git a/crates/core/src/launch/redact.rs b/crates/core/src/launch/redact.rs new file mode 100644 index 0000000..78b7425 --- /dev/null +++ b/crates/core/src/launch/redact.rs @@ -0,0 +1,299 @@ +//! 日志与事件流的脱敏(写入前执行,落盘即脱敏)。 +//! +//! 规则见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §4: +//! token 整串替换、用户主目录替换、长度 ≥64 的不透明串(base64/hex token)替换。 +//! 依据 `analyse/02` §6.4 / §8.4-5(HMCL 只过滤 access token,我们要求导出前全量脱敏)。 +//! +//! 第四条规则(M40 从 `auth::body` 上提):**带点 token** 整串替换。JWT 形状是 +//! `header.payload.signature`(XSTS/XBL 票据正是这个),点号把连续段切开,单看 +//! 「连续不透明串 ≥64」这条会整串原样放过。规则只在这里实现一份,所有调用方 +//! (launch 会话 / 诊断 / auth 错误摘要 …)共用,避免各自补一套后漂移。 + +use std::borrow::Cow; +use std::path::Path; + +use serde_json::Value; + +/// access token / session token 的替换标记。 +pub const TOKEN_PLACEHOLDER: &str = ""; +/// 用户主目录的替换标记。 +pub const HOME_PLACEHOLDER: &str = ""; +/// 形似 token 的长不透明串的替换标记。 +pub const OPAQUE_PLACEHOLDER: &str = ""; + +/// 判定“像 token”的最短连续长度。 +const MAX_OPAQUE_RUN: usize = 64; +/// 「带点 token」(JWT 三段式)判定的最短总长。 +const DOTTED_TOKEN_MIN_TOTAL: usize = 40; +/// 「带点 token」每一段的最短长度(挡住 `Service.Unavailable.today` 这类正常文案)。 +const DOTTED_TOKEN_MIN_SEGMENT: usize = 8; + +/// 脱敏器:持有需要抹掉的敏感值(token、主目录)。 +#[derive(Debug, Clone, Default)] +pub struct Redactor { + secrets: Vec, + homes: Vec, +} + +impl Redactor { + /// 空脱敏器(不做任何替换)。 + pub fn new() -> Self { + Self::default() + } + + /// 登记一个敏感值(access token、userhash、xuid 等)。 + /// + /// 太短的值(< 6 字节)会被忽略:替换一个 3 字符串会把正常日志搅乱。 + pub fn with_secret(mut self, secret: impl Into) -> Self { + let secret = secret.into(); + if secret.trim().len() >= 6 && !self.secrets.contains(&secret) { + self.secrets.push(secret); + } + self + } + + /// 登记一个需要抹掉的主目录(替换成 ``)。 + pub fn with_home(mut self, home: impl AsRef) -> Self { + let home = home.as_ref().to_string_lossy().into_owned(); + if home.len() > 3 && !self.homes.contains(&home) { + self.homes.push(home); + } + self + } + + /// 登记一个主目录字符串。 + pub fn with_home_str(mut self, home: impl Into) -> Self { + let home = home.into(); + if home.len() > 3 && !self.homes.contains(&home) { + self.homes.push(home); + } + self + } + + /// 是否登记了任何规则。 + pub fn is_empty(&self) -> bool { + self.secrets.is_empty() && self.homes.is_empty() + } + + /// 对文本脱敏;无需修改时返回借用,避免无谓拷贝。 + pub fn scrub<'a>(&self, text: &'a str) -> Cow<'a, str> { + let mut current: Cow<'a, str> = Cow::Borrowed(text); + for secret in &self.secrets { + if current.contains(secret.as_str()) { + current = Cow::Owned(current.replace(secret.as_str(), TOKEN_PLACEHOLDER)); + } + } + for home in &self.homes { + if current.contains(home.as_str()) { + current = Cow::Owned(current.replace(home.as_str(), HOME_PLACEHOLDER)); + } + } + if has_dotted_token(¤t) { + current = Cow::Owned(mask_dotted_tokens(¤t)); + } + if has_opaque_run(¤t) { + current = Cow::Owned(mask_opaque(¤t)); + } + current + } + + /// 对整条命令行脱敏(`cmd_built.argv` 必然含 accessToken,必须走这里)。 + pub fn scrub_all(&self, values: &[String]) -> Vec { + values + .iter() + .map(|value| self.scrub(value).into_owned()) + .collect() + } + + /// 递归脱敏一个 JSON 值。 + /// + /// **只改字符串**:数值/布尔/null 与字段名原样保留——满足「launch 只做脱敏、不改写字段」 + /// 的契约(`DevDocs/SubAgentsTalk/launch-接口约定.md` §9.3)。诊断结论里的证据行常带 + /// 崩溃报告/`hs_err` 的绝对路径,正是要过 `` 规则的地方。 + pub fn scrub_json(&self, value: &Value) -> Value { + match value { + Value::String(text) => Value::String(self.scrub(text).into_owned()), + Value::Array(items) => { + Value::Array(items.iter().map(|item| self.scrub_json(item)).collect()) + } + Value::Object(map) => Value::Object( + map.iter() + .map(|(key, value)| (key.clone(), self.scrub_json(value))) + .collect(), + ), + other => other.clone(), + } + } +} + +/// 文本里是否含长度 ≥64 的“不透明串”(base64/hex token 的特征)。 +pub fn has_opaque_run(text: &str) -> bool { + let mut run = 0usize; + for ch in text.chars() { + if is_opaque_char(ch) { + run += 1; + if run >= MAX_OPAQUE_RUN { + return true; + } + } else { + run = 0; + } + } + false +} + +/// 把长度 ≥64 的不透明串替换成 [`OPAQUE_PLACEHOLDER`]。 +pub fn mask_opaque(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut run_start: Option = None; + for (idx, ch) in text.char_indices() { + if is_opaque_char(ch) { + if run_start.is_none() { + run_start = Some(idx); + } + } else { + if let Some(start) = run_start.take() { + push_masked(&mut out, &text[start..idx]); + } + out.push(ch); + } + } + if let Some(start) = run_start { + push_masked(&mut out, &text[start..]); + } + out +} + +fn push_masked(out: &mut String, run: &str) { + if run.len() >= MAX_OPAQUE_RUN { + out.push_str(OPAQUE_PLACEHOLDER); + } else { + out.push_str(run); + } +} + +fn is_opaque_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '_' | '-') +} + +/// 文本里是否含「带点 token」(≥3 段、每段 ≥8、总长 ≥40)。 +pub fn has_dotted_token(text: &str) -> bool { + let mut start: Option = None; + for (idx, ch) in text.char_indices() { + if is_dotted_token_char(ch) { + start.get_or_insert(idx); + continue; + } + if let Some(from) = start.take() { + if is_dotted_token(&text[from..idx]) { + return true; + } + } + } + start.is_some_and(|from| is_dotted_token(&text[from..])) +} + +/// 把「带点 token」整串替换成 [`TOKEN_PLACEHOLDER`](段外文本原样保留)。 +pub fn mask_dotted_tokens(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut start: Option = None; + for (idx, ch) in text.char_indices() { + if is_dotted_token_char(ch) { + start.get_or_insert(idx); + continue; + } + if let Some(from) = start.take() { + push_dotted(&mut out, &text[from..idx]); + } + out.push(ch); + } + if let Some(from) = start { + push_dotted(&mut out, &text[from..]); + } + out +} + +fn push_dotted(out: &mut String, run: &str) { + if is_dotted_token(run) { + out.push_str(TOKEN_PLACEHOLDER); + } else { + out.push_str(run); + } +} + +/// 「带点 token」的字符集:比不透明串多一个 `.`(JWT 的分段符)。 +fn is_dotted_token_char(ch: char) -> bool { + is_opaque_char(ch) || ch == '.' +} + +/// 单段连写(无空白)的串是否像 JWT:段数 ≥3、每段 ≥8 字节、总长 ≥40。 +fn is_dotted_token(run: &str) -> bool { + run.len() >= DOTTED_TOKEN_MIN_TOTAL + && run.split('.').count() >= 3 + && run + .split('.') + .all(|segment| segment.len() >= DOTTED_TOKEN_MIN_SEGMENT) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 真实形状的 JWT(header.payload.signature,三段各 ≥8)。 + const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g"; + + #[test] + fn jwt_token_is_masked_without_any_registered_secret() { + let scrubbed = Redactor::new().scrub(JWT).into_owned(); + assert!(scrubbed.contains(TOKEN_PLACEHOLDER), "{scrubbed}"); + assert!(!scrubbed.contains("eyJhbGci"), "{scrubbed}"); + + // 嵌在别的内容里也只换 token 本体。 + let masked = Redactor::new() + .scrub(&format!("authorization: Bearer {JWT}, retry=1")) + .into_owned(); + assert_eq!( + masked, + format!("authorization: Bearer {TOKEN_PLACEHOLDER}, retry=1") + ); + } + + #[test] + fn dotted_token_rule_does_not_eat_normal_text() { + // 分段太短、总长不够、段数不够:三种都不算 token。 + for text in [ + "Service.Unavailable.today", + "1.2.0", + "xbl.xsts.entitlements.profile", + "eyJhbGciOiJIUzI1NiJ9.short.abc", + ] { + assert!(!has_dotted_token(text), "{text}"); + assert_eq!(Redactor::new().scrub(text), text, "{text}"); + } + } + + #[test] + fn existing_rules_still_apply() { + let redactor = Redactor::new() + .with_secret("SECRET-TOKEN-123456") + .with_home_str("/home/alice"); + let scrubbed = redactor + .scrub("/home/alice/game.log: token=SECRET-TOKEN-123456") + .into_owned(); + assert!(scrubbed.contains(HOME_PLACEHOLDER), "{scrubbed}"); + assert!(scrubbed.contains(TOKEN_PLACEHOLDER), "{scrubbed}"); + assert!( + !scrubbed.contains("alice") && !scrubbed.contains("SECRET"), + "{scrubbed}" + ); + + // 没有点号的长不透明串仍走 ≥64 规则,替换标记也不同。 + let long = "A".repeat(MAX_OPAQUE_RUN); + assert!(has_opaque_run(&long)); + assert!(!has_dotted_token(&long)); + assert_eq!( + Redactor::new().scrub(&long).into_owned(), + OPAQUE_PLACEHOLDER.to_string() + ); + } +} diff --git a/crates/core/src/launch/request.rs b/crates/core/src/launch/request.rs new file mode 100644 index 0000000..6a4f54d --- /dev/null +++ b/crates/core/src/launch/request.rs @@ -0,0 +1,358 @@ +//! 启动请求:命令行组装的输入模型(纯数据,无 IO,便于快照测试)。 +//! +//! 与 `crate::game` / `crate::download` 的映射关系见 +//! `DevDocs/SubAgentsTalk/launch-接口约定.md` §6:launch 阶段只消费本结构, +//! 由薄适配层把 game 的快照搬进来,避免 launch 对 manifest 类型产生编译期依赖。 + +use std::path::PathBuf; + +use crate::launch::argument::ArgumentEntry; +use crate::launch::error::MissingComponent; +use crate::launch::game_version::GameVersion; +use crate::launch::natives::NativesPlan; + +/// 实例信息。 +#[derive(Debug, Clone, Default)] +pub struct InstanceInfo { + /// 实例/版本 id(稳定 UUID 或版本目录名)。 + pub id: String, + /// 展示名(`${version_name}`)。 + pub name: String, + /// 游戏版本号(分支门限用)。 + pub version: GameVersion, + /// 版本类型(`release` / `snapshot`;`${version_type}`)。 + pub version_type: String, +} + +/// 启动器自身信息(`${launcher_name}` / `${launcher_version}`)。 +#[derive(Debug, Clone, Default)] +pub struct LauncherInfo { + /// 名称。 + pub name: String, + /// 版本。 + pub version: String, +} + +/// 选定的 Java 运行时。 +#[derive(Debug, Clone, Default)] +pub struct JavaSelection { + /// `java` 可执行文件路径。 + pub binary: PathBuf, + /// 主版本号(8 / 17 / 21 / 25 …)。 + pub major: u32, + /// 位数(32 / 64)。 + pub bits: u32, +} + +impl JavaSelection { + /// 构造。 + pub fn new(binary: impl Into, major: u32, bits: u32) -> Self { + Self { + binary: binary.into(), + major, + bits, + } + } + + /// 是否 64 位 JVM。 + pub fn is_64bit(&self) -> bool { + self.bits == 64 + } +} + +/// 内存设置(MiB)。 +#[derive(Debug, Clone, Copy, Default)] +pub struct MemoryOptions { + /// 初始堆 `-Xms`。 + pub min_mb: Option, + /// 最大堆 `-Xmx`。 + pub max_mb: Option, +} + +/// 版本清单里的参数(`arguments.jvm` / `arguments.game` / 旧版 `minecraftArguments`)。 +#[derive(Debug, Clone, Default)] +pub struct ManifestArgs { + /// 主类。 + pub main_class: Option, + /// 现代清单的 JVM 参数(带 rules)。 + pub jvm: Vec, + /// 现代清单的游戏参数(带 rules)。 + pub game: Vec, + /// 旧版清单的整串游戏参数(1.12 及更早)。 + pub legacy_arguments: Option, + /// 版本库表里带 LWJGL 的 Vulkan 绑定(`org.lwjgl:lwjgl-vulkan`)。 + /// + /// 只作「无法排序的版本」(周快照)的 `--graphicsBackend` 兜底判据与 UI 提示; + /// 可解析版本的下发门限仍是 `26.2-snapshot-2`(见 + /// [`graphics_backend_allowed`](crate::launch::game_args::graphics_backend_allowed))。 + pub has_vulkan_backend: bool, +} + +impl ManifestArgs { + /// 是否走旧版参数模板(只有 `minecraftArguments`,没有 `arguments`)。 + pub fn is_legacy(&self) -> bool { + self.jvm.is_empty() && self.legacy_arguments.is_some() + } +} + +/// 目录布局。 +#[derive(Debug, Clone, Default)] +pub struct LaunchDirs { + /// 运行目录(`${game_directory}`,同时是子进程的 CWD)。 + /// + /// D20:这个值是**隔离覆盖链的派生结果**(`game::isolation::derive_game_dir`: + /// 临时参数 > 实例 `isolation` > 全局 `instances.mode`)。launch 侧只消费、不派生, + /// 也不读配置——路径语义的真相在 `core::game`。 + pub game_dir: PathBuf, + /// 资产根。 + pub assets_root: PathBuf, + /// 重建后的虚拟资产目录(`${game_assets}`)。 + pub game_assets: PathBuf, + /// 资产索引 id(`${assets_index_name}`)。 + pub assets_index: String, + /// 原生库目录(按 Java 平台选择,`analyse/02` §4.6)。 + pub natives_dir: PathBuf, + /// 库根目录。 + pub library_dir: PathBuf, + /// 主 jar。 + pub primary_jar: PathBuf, +} + +/// 账户占位符值。 +#[derive(Debug, Clone, Default)] +pub struct AuthPlaceholders { + /// 玩家名。 + pub player_name: String, + /// 无连字符 UUID。 + pub uuid: String, + /// 访问令牌(落盘前必须脱敏)。 + pub access_token: String, + /// `msa` / `legacy`。 + pub user_type: String, + /// `user_properties` JSON 串。 + pub user_properties: String, +} + +/// 用户自定义参数。 +#[derive(Debug, Clone, Default)] +pub struct UserArgs { + /// 最高优先级 JVM 参数(不解析转义,会读 `@argfile`)。 + pub override_jvm: Vec, + /// 普通 JVM 参数(整体按“默认值”处理)。 + pub jvm: Vec, + /// 追加在最后、可覆盖前面一切的 game 参数。 + pub game: Vec, + /// 额外环境变量(用户自定义,最后覆盖)。 + pub environment: Vec<(String, String)>, +} + +/// 清单参数 rules 求值所需的特性开关。 +#[derive(Debug, Clone, Copy, Default)] +pub struct FeatureSet { + /// 用户指定了分辨率(`--width/--height` 的开关)。 + pub has_custom_resolution: bool, + /// 演示模式。 + pub is_demo_user: bool, + /// 支持 QuickPlay(1.20+)。 + pub has_quick_plays_support: bool, +} + +impl FeatureSet { + /// 按特性名查询(未知特性为 `None`)。 + pub fn get(&self, name: &str) -> Option { + match name { + "has_custom_resolution" => Some(self.has_custom_resolution), + "is_demo_user" => Some(self.is_demo_user), + "has_quick_plays_support" => Some(self.has_quick_plays_support), + _ => None, + } + } +} + +/// 窗口分辨率。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Resolution { + /// 宽。 + pub width: u32, + /// 高。 + pub height: u32, +} + +/// 一键进入目标。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QuickPlay { + /// 直入存档。 + Singleplayer { + /// 存档目录名。 + world: String, + }, + /// 直入服务器。 + Multiplayer { + /// `host:port` 形式地址。 + address: String, + }, + /// 直入 Realms。 + Realms { + /// Realm id。 + realm_id: String, + }, +} + +/// SOCKS 代理。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SocksProxy { + /// 主机。 + pub host: String, + /// 端口。 + pub port: u16, + /// 用户名。 + pub username: Option, + /// 密码。 + pub password: Option, +} + +/// 模组加载器种类(决定 Forge 专属参数与环境变量)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ModLoaderKind { + /// 原版。 + #[default] + Vanilla, + /// Forge。 + Forge, + /// NeoForge。 + NeoForge, + /// Fabric。 + Fabric, + /// Quilt。 + Quilt, + /// LiteLoader。 + LiteLoader, + /// OptiFine(独立安装器)。 + OptiFine, +} + +impl ModLoaderKind { + /// 是否使用 FML/LaunchWrapper(决定 `-Dfml.ignore*` 参数)。 + pub fn is_fml_like(self) -> bool { + matches!(self, Self::Forge | Self::NeoForge | Self::LiteLoader) + } +} + +/// Log4j 注入选项。 +#[derive(Debug, Clone)] +pub struct Log4jOptions { + /// 是否注入日志配置(HMCL:版本 ≥1.7 即注入,探测失败按 1.7 兜底)。 + pub enabled: bool, + /// 配置文件路径(`-Dlog4j.configurationFile=`)。 + pub config_file: Option, + /// 调试日志变体。 + pub debug: bool, +} + +impl Default for Log4jOptions { + fn default() -> Self { + Self { + enabled: true, + config_file: None, + debug: false, + } + } +} + +/// 渲染器相关开关(`analyse/02` §4.1 #17、§4.10)。 +#[derive(Debug, Clone, Default)] +pub struct RendererOptions { + /// 软件渲染(Mesa/llvmpipe)。 + pub software: bool, + /// Windows 上 mesa 的 native 目录。 + pub mesa_native_dir: Option, + /// macOS 上的 Vulkan ICD JSON。 + pub vulkan_icd: Option, +} + +/// JVM 自动优化选项。 +#[derive(Debug, Clone)] +pub struct OptimizationOptions { + /// 是否注入自动优化参数(用户可关)。 + pub enabled: bool, + /// 物理内存 MiB(决定 CodeCache 一组参数的开关)。 + pub physical_memory_mb: Option, +} + +impl Default for OptimizationOptions { + fn default() -> Self { + Self { + enabled: true, + physical_memory_mb: None, + } + } +} + +/// 一次启动的完整输入。 +#[derive(Debug, Clone, Default)] +pub struct LaunchRequest { + /// 实例信息。 + pub instance: InstanceInfo, + /// 启动器信息。 + pub launcher: LauncherInfo, + /// Java 运行时。 + pub java: JavaSelection, + /// 内存。 + pub memory: MemoryOptions, + /// 清单参数。 + pub manifest: ManifestArgs, + /// 目录布局。 + pub dirs: LaunchDirs, + /// natives 解压计划(`dirs.natives_dir` 为目标目录,`jars` 为分类器构件清单)。 + /// + /// 解压发生在**组装期**(`Launcher::dry_run`/`spawn` 的「环境准备」段), + /// 见 [`crate::launch::natives`] 与 `SubAgentsTalk/launch-natives-解压与时代分界.md`。 + pub natives: NativesPlan, + /// 账户。 + pub auth: AuthPlaceholders, + /// 用户参数。 + pub user: UserArgs, + /// 特性开关。 + pub features: FeatureSet, + /// 分辨率(`None` 表示不指定)。 + pub resolution: Option, + /// 一键进入。 + pub quick_play: Option, + /// SOCKS 代理。 + pub proxy: Option, + /// 图形后端(`default` / `opengl` / `vulkan`)。 + /// + /// 下发门限见 [`graphics_backend_allowed`](crate::launch::game_args::graphics_backend_allowed): + /// 可解析版本 ≥ `26.2-snapshot-2`,周快照另按版本库表的能力位判。 + pub graphics_backend: Option, + /// 全屏。 + pub fullscreen: bool, + /// 进程优先级(POSIX `nice -n`,Windows 忽略)。 + pub nice: Option, + /// 命令包装器(`optirun`、`prime-run` 等)。 + pub wrapper: Vec, + /// `-javaagent:` 列表。 + pub java_agents: Vec, + /// 登录源提供的 JVM 参数(authlib-injector)。 + pub auth_jvm_args: Vec, + /// 登录源提供的游戏参数。 + pub auth_game_args: Vec, + /// 模组加载器。 + pub mod_loader: ModLoaderKind, + /// Log4j 注入。 + pub log4j: Log4jOptions, + /// 渲染器开关。 + pub renderer: RendererOptions, + /// JVM 自动优化。 + pub optimization: OptimizationOptions, + /// `-Dfile.encoding` 指定值(`COMPAT` 表示跟随系统)。 + pub encoding: Option, + /// 界面语言(`${language}`)。 + pub language: String, + /// 已过滤为“确实存在”的 classpath(主 jar 由组装器追加到末位)。 + pub classpath: Vec, + /// 组件就绪检查发现的缺失项(非空则组装直接失败)。 + pub missing_components: Vec, + /// 额外注入的 JVM 参数(品牌/特性钩子,`appendJvmArgs` 的替代者)。 + pub extra_jvm: Vec, +} diff --git a/crates/core/src/launch/session.rs b/crates/core/src/launch/session.rs new file mode 100644 index 0000000..54c4989 --- /dev/null +++ b/crates/core/src/launch/session.rs @@ -0,0 +1,375 @@ +//! JSONL 会话事件流:`/game/.jsonl`。 +//! +//! schema 见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§3(v1)——M15 诊断采集器与 +//! M16 `read_game_log`/`list_game_sessions` 消费同一份文件,是**单一真相源**。 +//! +//! 两条纪律写死在实现里: +//! 1. 脱敏在写入前做([`Redactor`]),`cmd_built.argv` 也不例外(命令行必含 accessToken); +//! 2. 「launch 报事实、M15 下结论」:`diagnosis` 事件由 M15 产出、由本模块落盘。 + +use std::fs::{self, File, OpenOptions}; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde_json::Value; + +use crate::launch::args_table::AssembledCommand; +use crate::launch::error::{AssemblyError, LaunchError, Result}; +use crate::launch::exit_analysis::ExitAnalysis; +use crate::launch::natives::NativesReport; +use crate::launch::pump::{LogLine, Stream}; +use crate::launch::redact::Redactor; +use crate::launch::session_index::{self, SessionIndexEntry}; +use crate::launch::session_payload as payload; +use crate::launch::time::{self, SessionClock}; +use crate::launch::window_detect::Detection; + +/// 事件 schema 版本。 +pub const SCHEMA_VERSION: u8 = 1; + +/// 事件类型。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EventKind { + /// 会话开始。 + SessionStart, + /// Java 选择结果。 + JavaSelected, + /// 命令行组装完成。 + CmdBuilt, + /// natives 解压结果。 + NativesExtracted, + /// 启动前失败(没有进程)。 + AssemblyFailed, + /// 进程已启动。 + ProcStart, + /// stdout 行。 + Stdout, + /// stderr 行。 + Stderr, + /// 检测到游戏窗口。 + WindowDetected, + /// 进程退出。 + Exit, + /// 发现崩溃报告 / hs_err。 + CrashFound, + /// 诊断结论(M15 产出)。 + Diagnosis, + /// 被主动结束。 + Killed, + /// 会话结束。 + SessionEnd, +} + +impl EventKind { + /// JSONL 里的 `kind` 字符串。 + pub fn as_str(self) -> &'static str { + match self { + Self::SessionStart => "session_start", + Self::JavaSelected => "java_selected", + Self::CmdBuilt => "cmd_built", + Self::NativesExtracted => "natives_extracted", + Self::AssemblyFailed => "assembly_failed", + Self::ProcStart => "proc_start", + Self::Stdout => "stdout", + Self::Stderr => "stderr", + Self::WindowDetected => "window_detected", + Self::Exit => "exit", + Self::CrashFound => "crash_found", + Self::Diagnosis => "diagnosis", + Self::Killed => "killed", + Self::SessionEnd => "session_end", + } + } +} + +/// 会话结束原因。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndReason { + /// 进程自然退出。 + Exit, + /// 被主动结束。 + Killed, + /// 启动前失败(组装/组件检查)。 + AssemblyFailed, + /// 进程派生失败。 + SpawnFailed, +} + +impl EndReason { + /// JSONL 里的 `reason` 字符串。 + pub fn as_str(self) -> &'static str { + match self { + Self::Exit => "exit", + Self::Killed => "killed", + Self::AssemblyFailed => "assembly_failed", + Self::SpawnFailed => "spawn_failed", + } + } +} + +pub use crate::launch::session_meta::SessionMeta; +pub use crate::launch::session_sink::SessionSink; + +#[derive(Serialize)] +struct EventLine<'a> { + v: u8, + seq: u64, + ts: String, + t_ms: u64, + kind: &'a str, + session_id: &'a str, + payload: &'a Value, +} + +/// 会话事件流写入器。 +#[derive(Debug)] +pub struct SessionWriter { + session_id: String, + clock: SessionClock, + file: BufWriter, + path: PathBuf, + index_path: PathBuf, + seq: u64, + event_count: u64, + redactor: Redactor, + meta: SessionMeta, + started_at: String, + window_detected: bool, + crash_found: bool, + exit_code: Option, + exit_type: Option, + silent_failure: bool, + diagnosis_top: Option, + finished: bool, +} + +impl SessionWriter { + /// 新建会话(id 由时间 + pid 生成)。 + pub fn create(sessions_root: &Path, meta: SessionMeta, redactor: Redactor) -> Result { + let session_id = time::make_session_id(time::now(), std::process::id()); + Self::create_with_id(sessions_root, &session_id, meta, redactor) + } + + /// 用指定 id 新建会话(测试与“续写”场景)。 + pub fn create_with_id( + sessions_root: &Path, + session_id: &str, + meta: SessionMeta, + redactor: Redactor, + ) -> Result { + let dir = sessions_root.join("game"); + fs::create_dir_all(&dir).map_err(|source| LaunchError::Io { + op: "create_session_dir", + path: dir.clone(), + source, + })?; + let path = dir.join(format!("{session_id}.jsonl")); + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|source| LaunchError::Io { + op: "create_session_file", + path: path.clone(), + source, + })?; + let clock = SessionClock::start_new(); + let started_at = clock.started_at_iso(); + Ok(Self { + session_id: session_id.to_string(), + clock, + file: BufWriter::new(file), + path, + index_path: session_index::index_path(sessions_root), + seq: 0, + event_count: 0, + redactor, + meta, + started_at, + window_detected: false, + crash_found: false, + exit_code: None, + exit_type: None, + silent_failure: false, + diagnosis_top: None, + finished: false, + }) + } + + /// 会话 id。 + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// 事件文件路径。 + pub fn path(&self) -> &Path { + &self.path + } + + /// 已写入事件数。 + pub fn event_count(&self) -> u64 { + self.event_count + } + + /// 是否已结束。 + pub fn is_finished(&self) -> bool { + self.finished + } + + /// 写一条事件。 + pub fn record(&mut self, kind: EventKind, payload: Value) -> Result<()> { + let seq = self.seq; + let line = EventLine { + v: SCHEMA_VERSION, + seq, + ts: self.clock.now_iso(), + t_ms: self.clock.elapsed_ms(), + kind: kind.as_str(), + session_id: &self.session_id, + payload: &payload, + }; + let serialized = serde_json::to_string(&line).map_err(|err| LaunchError::Session { + path: self.path.clone(), + reason: err.to_string(), + })?; + self.file + .write_all(serialized.as_bytes()) + .and_then(|()| self.file.write_all(b"\n")) + .and_then(|()| self.file.flush()) + .map_err(|source| LaunchError::Io { + op: "write_session_event", + path: self.path.clone(), + source, + })?; + self.seq += 1; + self.event_count += 1; + Ok(()) + } + + /// `session_start`。 + pub fn session_start(&mut self) -> Result<()> { + let payload = payload::session_start(&self.meta, SCHEMA_VERSION); + self.record(EventKind::SessionStart, payload) + } + + /// `cmd_built`:完整 argv(已脱敏)+ classpath 摘要。 + pub fn cmd_built(&mut self, command: &AssembledCommand) -> Result<()> { + let payload = payload::cmd_built(command, &self.redactor); + self.record(EventKind::CmdBuilt, payload) + } + + /// `assembly_failed`:启动前失败,附结构化缺失清单(M15 据此下结论)。 + pub fn assembly_failed(&mut self, error: &AssemblyError) -> Result<()> { + let payload = payload::assembly_failed(error); + self.record(EventKind::AssemblyFailed, payload) + } + + /// `proc_start`。 + pub fn proc_start(&mut self, pid: u32) -> Result<()> { + let payload = payload::proc_start(pid, &self.clock.now_iso()); + self.record(EventKind::ProcStart, payload) + } + + /// `natives_extracted`:natives 解压结果(启动期 `UnsatisfiedLinkError` 的第一嫌疑)。 + pub fn natives_extracted(&mut self, report: &NativesReport) -> Result<()> { + let payload = payload::natives_extracted(report, &self.redactor); + self.record(EventKind::NativesExtracted, payload) + } + + /// `stdout` / `stderr` 行事件。 + pub fn line(&mut self, line: &LogLine) -> Result<()> { + let kind = match line.stream { + Stream::Stdout => EventKind::Stdout, + Stream::Stderr => EventKind::Stderr, + }; + let text = self.redactor.scrub(&line.text).into_owned(); + let payload = payload::log_line(&text, line.level()); + self.record(kind, payload) + } + + /// `window_detected`。 + pub fn window_detected(&mut self, detection: &Detection) -> Result<()> { + self.window_detected = true; + let payload = payload::window_detected(detection, &self.redactor); + self.record(EventKind::WindowDetected, payload) + } + + /// 是否已检测到游戏窗口(索引摘要用)。 + pub fn is_window_detected(&self) -> bool { + self.window_detected + } + + /// `killed`。 + pub fn killed(&mut self, by: &str, signal: Option<&str>) -> Result<()> { + let payload = payload::killed(by, signal); + self.record(EventKind::Killed, payload) + } + + /// `exit`。 + pub fn exit(&mut self, analysis: &ExitAnalysis) -> Result<()> { + self.exit_code = Some(analysis.code); + self.exit_type = Some(analysis.exit_type.as_str().to_string()); + self.silent_failure = analysis.silent_failure.is_some(); + let payload = payload::exit(analysis); + self.record(EventKind::Exit, payload) + } + + /// `crash_found`:以 `path` 为权威字段(M15 自己读盘)。 + pub fn crash_found(&mut self, source: &str, path: &Path, excerpt: Option<&str>) -> Result<()> { + self.crash_found = true; + let payload = payload::crash_found(source, path, excerpt, &self.redactor); + self.record(EventKind::CrashFound, payload) + } + + /// `diagnosis`:M15 产出的结构化结论,由 launch 脱敏后落盘(单一真相源)。 + /// + /// M15 会按 `crash_found.path` 自己读盘再组 payload,崩溃报告/`latest.log` 里的 + /// 用户主目录路径与 token 会由此带进结论,所以这里必须再过一遍 [`Redactor`] + /// (只改字符串,字段名/数值/布尔不动——`launch-接口约定.md` §9.3)。 + pub fn diagnosis(&mut self, record: Value) -> Result<()> { + let record = self.redactor.scrub_json(&record); + if self.diagnosis_top.is_none() { + self.diagnosis_top = record + .get("candidates") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("id")) + .and_then(Value::as_str) + .map(str::to_string); + } + self.record(EventKind::Diagnosis, record) + } + + /// `session_end` + 追加索引,返回索引条目。 + pub fn finish(&mut self, reason: EndReason) -> Result { + let duration_ms = self.clock.elapsed_ms(); + let payload = payload::session_end(reason.as_str(), duration_ms); + self.record(EventKind::SessionEnd, payload)?; + self.finished = true; + let entry = SessionIndexEntry { + v: SCHEMA_VERSION, + session_id: self.session_id.clone(), + instance_id: self.meta.instance_id.clone(), + instance_name: self.meta.instance_name.clone(), + game_version: self.meta.game_version.clone(), + started_at: self.started_at.clone(), + ended_at: self.clock.now_iso(), + duration_ms, + exit_code: self.exit_code, + exit_type: self.exit_type.clone(), + silent_failure: self.silent_failure, + window_detected: self.window_detected, + event_count: self.event_count, + log_path: format!("game/{}.jsonl", self.session_id), + crash_found: self.crash_found, + diagnosis_top: self.diagnosis_top.clone(), + }; + session_index::append(&self.index_path, &entry).map_err(|source| LaunchError::Io { + op: "append_session_index", + path: self.index_path.clone(), + source, + })?; + Ok(entry) + } +} diff --git a/crates/core/src/launch/session_index.rs b/crates/core/src/launch/session_index.rs new file mode 100644 index 0000000..4dfc47d --- /dev/null +++ b/crates/core/src/launch/session_index.rs @@ -0,0 +1,120 @@ +//! 会话记录索引:`list_game_sessions` 的数据源。 +//! +//! 文件:`/game/index.jsonl`,每会话一行摘要,会话结束时追加。 +//! 读取约定(`DevDocs/SubAgentsTalk/launch-接口约定.md` §2):同一 `session_id` +//! 可能出现多行(崩溃后重写索引),读取方**按 id 去重取最后一行**,再按 `started_at` 倒序。 + +use std::fs::{File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// 索引条目的 schema 版本。 +pub const INDEX_VERSION: u8 = 1; + +/// 一次会话的摘要。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionIndexEntry { + /// schema 版本。 + #[serde(default)] + pub v: u8, + /// 会话 id。 + pub session_id: String, + /// 实例 id。 + pub instance_id: String, + /// 实例名。 + pub instance_name: String, + /// 游戏版本。 + pub game_version: String, + /// 开始时间(RFC3339 UTC)。 + pub started_at: String, + /// 结束时间(RFC3339 UTC)。 + pub ended_at: String, + /// 时长毫秒。 + pub duration_ms: u64, + /// 退出码(未跑到进程时为 `None`)。 + pub exit_code: Option, + /// 退出判定字符串。 + pub exit_type: Option, + /// 是否静默失败。 + pub silent_failure: bool, + /// 是否检测到游戏窗口。 + pub window_detected: bool, + /// 事件条数。 + pub event_count: u64, + /// 相对 `game/` 的日志文件名。 + pub log_path: String, + /// 是否抓到崩溃报告/hs_err。 + pub crash_found: bool, + /// 诊断首因(M15 写入的第一个候选 id)。 + pub diagnosis_top: Option, +} + +/// 索引文件路径(`/game/index.jsonl`)。 +pub fn index_path(sessions_root: &Path) -> PathBuf { + sessions_root.join("game").join("index.jsonl") +} + +/// 追加一行摘要。 +pub fn append(index: &Path, entry: &SessionIndexEntry) -> std::io::Result<()> { + if let Some(parent) = index.parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = OpenOptions::new().create(true).append(true).open(index)?; + let line = serde_json::to_string(entry) + .map_err(|err| std::io::Error::other(format!("序列化索引失败:{err}")))?; + file.write_all(line.as_bytes())?; + file.write_all(b"\n")?; + file.flush() +} + +/// 读取全部条目(同一 id 取最后一行,按开始时间倒序)。 +pub fn read_all(index: &Path) -> std::io::Result> { + let file = match File::open(index) { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err), + }; + let mut entries: Vec = Vec::new(); + for line in BufReader::new(file).lines() { + let line = match line { + Ok(line) => line, + Err(_) => break, + }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + // 容忍坏行(半截 JSON):索引是“尽力而为”的数据源,不能因为一行坏数据丢掉整份列表。 + let Ok(entry) = serde_json::from_str::(trimmed) else { + continue; + }; + match entries + .iter_mut() + .find(|item| item.session_id == entry.session_id) + { + Some(slot) => *slot = entry, + None => entries.push(entry), + } + } + entries.sort_by(|left, right| right.started_at.cmp(&left.started_at)); + Ok(entries) +} + +/// `list_game_sessions` 语义:可选实例过滤 + 条数上限,按开始时间倒序。 +pub fn list( + index: &Path, + limit: usize, + instance_id: Option<&str>, +) -> std::io::Result> { + let entries = read_all(index)?; + Ok(entries + .into_iter() + .filter(|entry| match instance_id { + Some(id) => entry.instance_id == id, + None => true, + }) + .take(limit) + .collect()) +} diff --git a/crates/core/src/launch/session_meta.rs b/crates/core/src/launch/session_meta.rs new file mode 100644 index 0000000..d56118e --- /dev/null +++ b/crates/core/src/launch/session_meta.rs @@ -0,0 +1,62 @@ +//! 会话元信息(`session_start` 的 payload 来源 + 索引摘要字段)。 +//! +//! 从 `session.rs` 拆出以守住“单文件 ≤400 行”的硬约束;公开路径仍是 +//! `launch::session::SessionMeta`(在 `session.rs` 里 re-export)。 + +use crate::launch::platform::PlatformCtx; + +/// 会话元信息(`session_start` 的 payload 来源)。 +#[derive(Debug, Clone, Default)] +pub struct SessionMeta { + /// 实例 id。 + pub instance_id: String, + /// 实例名。 + pub instance_name: String, + /// 游戏版本。 + pub game_version: String, + /// 启动器名。 + pub launcher_name: String, + /// 启动器版本。 + pub launcher_version: String, + /// 系统名。 + pub os_name: String, + /// 架构。 + pub os_arch: String, + /// 位数。 + pub os_bits: u32, +} + +impl SessionMeta { + /// 基本构造。 + pub fn new( + instance_id: impl Into, + instance_name: impl Into, + game_version: impl Into, + ) -> Self { + Self { + instance_id: instance_id.into(), + instance_name: instance_name.into(), + game_version: game_version.into(), + ..Self::default() + } + } + + /// 补启动器信息。 + pub fn with_launcher(mut self, name: impl Into, version: impl Into) -> Self { + self.launcher_name = name.into(); + self.launcher_version = version.into(); + self + } + + /// 补平台信息。 + pub fn with_platform(mut self, ctx: &PlatformCtx) -> Self { + self.os_name = ctx.os.manifest_name().to_string(); + self.os_arch = ctx.arch.clone(); + self.os_bits = if cfg!(target_pointer_width = "64") { + 64 + } else { + 32 + }; + self + } +} diff --git a/crates/core/src/launch/session_payload.rs b/crates/core/src/launch/session_payload.rs new file mode 100644 index 0000000..f647a87 --- /dev/null +++ b/crates/core/src/launch/session_payload.rs @@ -0,0 +1,159 @@ +//! 会话事件 payload 构造(schema 见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §3)。 +//! +//! 拆出来是为了让 [`crate::launch::session::SessionWriter`] 只关心“写文件 + seq + 索引”, +//! 字段定义集中在一处,评审时一眼能对完整张表。 + +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::launch::args_table::AssembledCommand; +use crate::launch::error::AssemblyError; +use crate::launch::exit_analysis::ExitAnalysis; +use crate::launch::log_level::Level; +use crate::launch::natives::NativesReport; +use crate::launch::redact::Redactor; +use crate::launch::session::SessionMeta; +use crate::launch::window_detect::Detection; + +/// `session_start`。 +pub(crate) fn session_start(meta: &SessionMeta, schema: u8) -> Value { + json!({ + "instance_id": meta.instance_id, + "instance_name": meta.instance_name, + "game_version": meta.game_version, + "launcher": { + "name": meta.launcher_name, + "version": meta.launcher_version, + }, + "os": { + "name": meta.os_name, + "arch": meta.os_arch, + "bits": meta.os_bits, + }, + "schema": schema, + }) +} + +/// `cmd_built`:完整 argv(脱敏)+ classpath 摘要 + 组装告警。 +pub(crate) fn cmd_built(command: &AssembledCommand, redactor: &Redactor) -> Value { + let argv = redactor.scrub_all(&command.argv()); + let program = redactor + .scrub(&command.program.display().to_string()) + .into_owned(); + json!({ + "program": program, + "argv": argv, + "main_class": command.main_class, + "classpath_entries": command.classpath.len(), + "classpath_missing": Vec::::new(), + "memory": { + "xmx_mb": command.memory.max_mb, + "xms_mb": command.memory.min_mb, + }, + "encoding": command.encoding, + "warnings": command.warnings.iter().map(ToString::to_string).collect::>(), + }) +} + +/// `assembly_failed`:启动前失败的结构化事实(结论由 M15 下)。 +pub(crate) fn assembly_failed(error: &AssemblyError) -> Value { + let (code, components) = match error { + AssemblyError::MissingMainClass => ("missing_main_class", Vec::new()), + AssemblyError::EmptyClasspath => ("empty_classpath", Vec::new()), + AssemblyError::EmptyJavaPath => ("empty_java_path", Vec::new()), + AssemblyError::MalformedArgument { .. } => ("malformed_argument", Vec::new()), + AssemblyError::NativesExtraction { .. } => ("natives_extraction", Vec::new()), + AssemblyError::MissingComponents(items) => ( + "missing_components", + items + .iter() + .map(|item| { + json!({ + "name": item.name, + "path": item.path.as_ref().map(|path| path.display().to_string()), + }) + }) + .collect::>(), + ), + }; + json!({ + "error_code": code, + "message": error.to_string(), + "missing_components": components, + }) +} + +/// `proc_start`。 +pub(crate) fn proc_start(pid: u32, started_at: &str) -> Value { + json!({ "pid": pid, "started_at": started_at }) +} + +/// `natives_extracted`:解压事实(`files`/`skipped`/`custom` 是既有约定字段, +/// 其余为 v1 追加字段,读取方按「未知字段忽略」处理)。 +pub(crate) fn natives_extracted(report: &NativesReport, redactor: &Redactor) -> Value { + json!({ + "dir": redactor.scrub(&report.dir.display().to_string()).into_owned(), + "files": report.files, + "skipped": report.skipped, + "custom": report.custom, + "jars": report.jars, + "ignored": report.ignored, + "up_to_date": report.up_to_date, + "era": report.era.as_str(), + }) +} + +/// `killed`。 +pub(crate) fn killed(by: &str, signal: Option<&str>) -> Value { + json!({ "by": by, "signal": signal }) +} + +/// `stdout` / `stderr` 行事件。 +pub(crate) fn log_line(text: &str, level: Level) -> Value { + json!({ "line": text, "level": level.as_str() }) +} + +/// `window_detected`。 +pub(crate) fn window_detected(detection: &Detection, redactor: &Redactor) -> Value { + json!({ + "heuristic": detection.heuristic, + "took_ms": detection.took_ms, + "evidence": redactor.scrub(&detection.evidence), + }) +} + +/// `exit`。 +pub(crate) fn exit(analysis: &ExitAnalysis) -> Value { + json!({ + "code": analysis.code, + "exit_type": analysis.exit_type.as_str(), + "uptime_ms": analysis.uptime_ms, + "error_lines": analysis.error_lines, + "crash_keywords": analysis.crash_keywords, + "jvm_keywords": analysis.jvm_keywords, + "silent_failure": analysis.silent_failure.is_some(), + "windows_status": analysis.windows_status, + "needs_diagnosis": analysis.needs_diagnosis, + "summary": analysis.summary(), + }) +} + +/// `crash_found`:`path` 是权威字段,`excerpt` 仅供人读。 +pub(crate) fn crash_found( + source: &str, + path: &Path, + excerpt: Option<&str>, + redactor: &Redactor, +) -> Value { + json!({ + "source": source, + "path": path.display().to_string(), + "excerpt": excerpt.map(|text| redactor.scrub(text).into_owned()), + }) +} + +/// `session_end`。 +pub(crate) fn session_end(reason: &str, duration_ms: u64) -> Value { + json!({ "reason": reason, "duration_ms": duration_ms }) +} diff --git a/crates/core/src/launch/session_sink.rs b/crates/core/src/launch/session_sink.rs new file mode 100644 index 0000000..712b28e --- /dev/null +++ b/crates/core/src/launch/session_sink.rs @@ -0,0 +1,64 @@ +//! 把会话事件流接到日志泵上:逐行落盘 + 自动窗口出现检测。 +//! +//! 放在单独文件是为了让 [`crate::launch::session`] 保持“只写事件、只管索引”的单一职责, +//! 也避免单文件超过 400 行的硬约束。 + +use std::sync::Mutex; + +use crate::launch::pump::{LineSink, LogLine}; +use crate::launch::session::SessionWriter; +use crate::launch::window_detect::WindowDetector; + +/// 会话日志 sink。 +#[derive(Debug)] +pub struct SessionSink { + writer: Mutex, + detector: Mutex, +} + +impl SessionSink { + /// 以进程启动时刻为窗口检测起点。 + pub fn new(writer: SessionWriter) -> Self { + Self { + writer: Mutex::new(writer), + detector: Mutex::new(WindowDetector::new()), + } + } + + /// 指定窗口检测计时起点(进程 spawn 时刻)。 + pub fn with_start(writer: SessionWriter, started_at: std::time::Instant) -> Self { + Self { + writer: Mutex::new(writer), + detector: Mutex::new(WindowDetector::with_start(started_at)), + } + } + + /// 借用写入器(退出分析、结束会话等需要可变访问的场合)。 + pub fn with_writer(&self, action: impl FnOnce(&mut SessionWriter) -> R) -> Option { + self.writer.lock().ok().map(|mut guard| action(&mut guard)) + } + + /// 是否已检测到游戏窗口。 + pub fn window_detected(&self) -> bool { + match self.writer.lock() { + Ok(writer) => writer.is_window_detected(), + Err(_) => false, + } + } +} + +impl LineSink for SessionSink { + fn on_line(&self, line: &LogLine) { + let detection = match self.detector.lock() { + Ok(mut detector) => detector.observe(&line.text), + Err(_) => None, + }; + let Ok(mut writer) = self.writer.lock() else { + return; + }; + if let Some(detection) = detection { + let _ = writer.window_detected(&detection); + } + let _ = writer.line(line); + } +} diff --git a/crates/core/src/launch/sink.rs b/crates/core/src/launch/sink.rs new file mode 100644 index 0000000..8b5809a --- /dev/null +++ b/crates/core/src/launch/sink.rs @@ -0,0 +1,138 @@ +//! 日志 sink 组合:把同一个进程的输出同时喂给多个消费者。 +//! +//! 设计依据 `DevDocs/SubAgentsTalk/launch-接口约定.md` §3(单一真相源)与 tower 批复的 +//! “复合 sink”方案:会话事件流(落盘)是必需的一个 sink,M15 采集器 / UI 日志页 / +//! 测试断言各自挂自己的 sink,彼此不感知。 +//! +//! 接 M15 的姿态:diagnose 不解析 jsonl、也不 import launch 类型,所以它只需要一个 +//! `FnSink` 包一层 `LogBuffer::push(stream, line)`;这里只提供通用的组合原语。 + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use crate::launch::pump::{LineSink, LogLine, Stream}; + +/// 把一条日志广播给多个 sink(顺序 = 注册顺序)。 +#[derive(Default)] +pub struct CompositeSink { + sinks: Vec>, +} + +impl CompositeSink { + /// 空组合。 + pub fn new() -> Self { + Self::default() + } + + /// 追加一个 sink(builder 风格)。 + pub fn with(mut self, sink: Arc) -> Self { + self.sinks.push(sink); + self + } + + /// 追加一个 sink。 + pub fn push(&mut self, sink: Arc) { + self.sinks.push(sink); + } + + /// 已注册的 sink 数。 + pub fn len(&self) -> usize { + self.sinks.len() + } + + /// 是否为空。 + pub fn is_empty(&self) -> bool { + self.sinks.is_empty() + } +} + +impl std::fmt::Debug for CompositeSink { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CompositeSink") + .field("sinks", &self.sinks.len()) + .finish() + } +} + +impl LineSink for CompositeSink { + fn on_line(&self, line: &LogLine) { + for sink in &self.sinks { + sink.on_line(line); + } + } +} + +/// 闭包 sink:把 M15 采集器、UI 通道、测试断言接进来的最轻方式。 +/// +/// ```ignore +/// let sink = FnSink::new(move |line: &LogLine| { +/// bundle.log.push(stream_kind(line.stream), &line.text); +/// }); +/// ``` +pub struct FnSink { + action: F, +} + +impl FnSink { + /// 用闭包构造。 + pub fn new(action: F) -> Self { + Self { action } + } +} + +impl FnSink { + /// 转成 `Arc`(可直接塞进 [`CompositeSink`] / `RunningProcess`)。 + pub fn shared(self) -> Arc { + Arc::new(self) + } +} + +impl std::fmt::Debug for FnSink { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("FnSink") + } +} + +impl LineSink for FnSink { + fn on_line(&self, line: &LogLine) { + (self.action)(line); + } +} + +/// 计数 sink:给进度条/测试一个“已收多少行”的廉价探针。 +#[derive(Debug, Default)] +pub struct CountingSink { + stdout: AtomicU64, + stderr: AtomicU64, +} + +impl CountingSink { + /// 新计数器。 + pub fn new() -> Self { + Self::default() + } + + /// 标准输出行数。 + pub fn stdout_lines(&self) -> u64 { + self.stdout.load(Ordering::Relaxed) + } + + /// 标准错误行数。 + pub fn stderr_lines(&self) -> u64 { + self.stderr.load(Ordering::Relaxed) + } + + /// 总行数。 + pub fn total_lines(&self) -> u64 { + self.stdout_lines() + self.stderr_lines() + } +} + +impl LineSink for CountingSink { + fn on_line(&self, line: &LogLine) { + match line.stream { + Stream::Stdout => self.stdout.fetch_add(1, Ordering::Relaxed), + Stream::Stderr => self.stderr.fetch_add(1, Ordering::Relaxed), + }; + } +} diff --git a/crates/core/src/launch/time.rs b/crates/core/src/launch/time.rs new file mode 100644 index 0000000..39f1ead --- /dev/null +++ b/crates/core/src/launch/time.rs @@ -0,0 +1,118 @@ +//! 时间工具:RFC3339(毫秒,UTC)格式化与会话时钟。 +//! +//! 会话事件流的 `ts` / `t_ms` 字段由这里产出(schema 见 +//! `DevDocs/SubAgentsTalk/launch-接口约定.md` §2)。 +//! 只用 `std::time`,不引入 chrono/time 依赖。 + +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +/// 把 `SystemTime` 格式化成 RFC3339 UTC 毫秒精度串,例 `2026-10-29T12:45:01.123Z`。 +/// +/// 早于 Unix 纪元的时间按纪元处理(不会 panic:事件流宁可有 1970 的时间戳也不能丢事件)。 +pub fn format_rfc3339_millis(time: SystemTime) -> String { + let since_epoch = time.duration_since(UNIX_EPOCH).unwrap_or_default(); + let secs = since_epoch.as_secs(); + let millis = since_epoch.subsec_millis(); + let days = i64::try_from(secs / 86_400).unwrap_or(i64::MAX); + let secs_of_day = secs % 86_400; + let (year, month, day) = civil_from_days(days); + let hour = secs_of_day / 3_600; + let minute = (secs_of_day % 3_600) / 60; + let second = secs_of_day % 60; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z") +} + +/// 当前墙上时间(便于测试替换的单一入口)。 +pub fn now() -> SystemTime { + SystemTime::now() +} + +/// 由“自 1970-01-01 起的天数”还原公历年月日(Howard Hinnant 的 civil_from_days)。 +pub fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if month <= 2 { year + 1 } else { year }; + ( + year, + u32::try_from(month).unwrap_or(1), + u32::try_from(day).unwrap_or(1), + ) +} + +/// 会话时钟:同时记录单调时刻(`t_ms`)与墙上时刻(`ts`)。 +#[derive(Debug, Clone)] +pub struct SessionClock { + start_instant: Instant, + started_at: SystemTime, +} + +impl SessionClock { + /// 以“现在”为起点创建时钟。 + pub fn start_new() -> Self { + Self { + start_instant: Instant::now(), + started_at: now(), + } + } + + /// 用给定的墙上时间与单调时刻创建(测试可注入)。 + pub fn started_at(start_instant: Instant, started_at: SystemTime) -> Self { + Self { + start_instant, + started_at, + } + } + + /// 会话开始的墙上时间。 + pub fn started_at_wall(&self) -> SystemTime { + self.started_at + } + + /// 会话开始时刻的 RFC3339 串。 + pub fn started_at_iso(&self) -> String { + format_rfc3339_millis(self.started_at) + } + + /// 距会话开始的毫秒数(`t_ms`)。 + pub fn elapsed_ms(&self) -> u64 { + u64::try_from(self.start_instant.elapsed().as_millis()).unwrap_or(u64::MAX) + } + + /// 当前墙上时间的 RFC3339 串(`ts`)。 + pub fn now_iso(&self) -> String { + format_rfc3339_millis(now()) + } +} + +impl Default for SessionClock { + fn default() -> Self { + Self::start_new() + } +} + +/// 生成会话 id:`YYYYMMDDTHHMMSS-<6 位十六进制>`。 +/// +/// 十六进制段由墙上时间与 pid 混合而来——不引入 rand 依赖,同一秒同一进程内也能区分 +/// (调用方可在冲突时追加序号)。 +pub fn make_session_id(wall: SystemTime, pid: u32) -> String { + let since_epoch = wall.duration_since(UNIX_EPOCH).unwrap_or_default(); + let secs = since_epoch.as_secs(); + let days = i64::try_from(secs / 86_400).unwrap_or(i64::MAX); + let (year, month, day) = civil_from_days(days); + let secs_of_day = secs % 86_400; + let hour = secs_of_day / 3_600; + let minute = (secs_of_day % 3_600) / 60; + let second = secs_of_day % 60; + let mut hash: u64 = u64::from(since_epoch.subsec_nanos()).wrapping_add(u64::from(pid)); + hash ^= secs.rotate_left(17); + hash = hash.wrapping_mul(0x9E37_79B9_7F4A_7C15); + let suffix = (hash >> 40) & 0xFF_FFFF; + format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}-{suffix:06x}") +} diff --git a/crates/core/src/launch/tree_kill.rs b/crates/core/src/launch/tree_kill.rs new file mode 100644 index 0000000..7988cdc --- /dev/null +++ b/crates/core/src/launch/tree_kill.rs @@ -0,0 +1,116 @@ +//! 进程树回收:`SIGTERM → 宽限 → SIGKILL` 三段式关闭。 +//! +//! HMCL 只 `process.destroy()` 单进程(`analyse/02` §6.8),子进程会残留、日志尾部丢失。 +//! 这里做进程**树**回收: +//! +//! - Windows:`taskkill /T /F /PID`(`/T` 递归子树)。P0 不引入 `windows-sys`,因此没有 +//! Job Object——Job Object(`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`)留 P1,替换点就在本文件 +//! (tower 已批准,见 `launch-接口约定.md` §7-1)。 +//! - POSIX:`kill -TERM -`(命不中是常态,因为我们没有 `setsid`——`unsafe` pre_exec 被 +//! workspace `unsafe_code = deny` 挡住)→ 宽限 → `kill -KILL -`,再退到单 pid。 +//! +//! 两个平台都先尝试“温和”,宽限期后再“强制”。 +//! +//! ⚠️ 外部命令的退出码**不能**用来判断“进程是否已被结束”:温和阶段成功关掉进程后, +//! 再发 `taskkill /F` 会得到 128(“没有这个运行实例”)。所以 [`crate::launch::process`] +//! 的实际停止流程以“进程是否真的消失”为准(`try_wait` 轮询),本模块只提供发送动作。 + +use std::io; +use std::process::Command; +use std::thread; +use std::time::Duration; + +/// 温和关闭(Windows 无 `/F`;POSIX `SIGTERM`)。 +pub fn terminate(pid: u32) -> io::Result<()> { + imp::signal(pid, false) +} + +/// 强制关闭(Windows `/F`;POSIX `SIGKILL`),并尽量覆盖整棵进程树。 +pub fn force_kill(pid: u32) -> io::Result<()> { + imp::signal(pid, true) +} + +/// 三段式:温和 → 宽限 → 强制。返回**强制阶段**的结果(温和阶段失败不致命)。 +/// +/// 注意:返回值不代表“进程已经死了”(见模块注释)。需要准确判定请用 +/// [`crate::launch::process::RunningProcess::stop`]。 +pub fn kill_tree(pid: u32, grace: Duration) -> io::Result<()> { + if pid == 0 { + return Err(io::Error::other("无效的 pid 0")); + } + let _ = terminate(pid); + if !grace.is_zero() { + thread::sleep(grace); + } + force_kill(pid) +} + +#[cfg(windows)] +mod imp { + use super::*; + use std::os::windows::process::CommandExt; + + /// 不弹控制台窗口(否则 kill 子进程会闪一个黑框)。 + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> { + let mut command = Command::new("taskkill"); + command.arg("/PID").arg(pid.to_string()).arg("/T"); + if force { + command.arg("/F"); + } + command.creation_flags(CREATE_NO_WINDOW); + let status = command + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status()?; + if status.success() { + Ok(()) + } else { + // taskkill 对已退出的进程返回非 0,这不值得中断关闭流程。 + Err(io::Error::other(format!("taskkill 退出码 {status}"))) + } + } +} + +#[cfg(unix)] +mod imp { + use super::*; + + pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> { + let flag = if force { "-KILL" } else { "-TERM" }; + // 先按进程组(`-`);没有 setsid 时通常失败,再退到单 pid。 + if run_kill(flag, &format!("-{pid}"))?.is_some() { + return Ok(()); + } + match run_kill(flag, &pid.to_string())? { + Some(status) if status.success() => Ok(()), + Some(status) => Err(io::Error::other(format!("kill 退出码 {status}"))), + None => Err(io::Error::other("找不到 kill 命令")), + } + } + + fn run_kill(flag: &str, target: &str) -> io::Result> { + match Command::new("kill") + .arg(flag) + .arg(target) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + { + Ok(status) if status.success() => Ok(Some(status)), + Ok(_) => Ok(None), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } + } +} + +#[cfg(not(any(windows, unix)))] +mod imp { + use super::*; + + pub(super) fn signal(_pid: u32, _force: bool) -> io::Result<()> { + Err(io::Error::other("当前平台不支持进程树 kill")) + } +} diff --git a/crates/core/src/launch/watchdog.rs b/crates/core/src/launch/watchdog.rs new file mode 100644 index 0000000..745ef53 --- /dev/null +++ b/crates/core/src/launch/watchdog.rs @@ -0,0 +1,71 @@ +//! 看门狗:给 pump 的 join 加超时。 +//! +//! HMCL 的 `ExitWaiter` 在 `waitFor()` 之后无条件 `join` 两条泵线程 +//! (`analyse/02` §6.1、§8.5):若子进程自己不关闭 stdout(例如它又拉起了不退出的 +//! 子进程),`onExit` 永远不回调,UI 卡在“正在启动”。这里把“等日志落盘”改成 +//! 有预算的等待——超时就带着已收集到的日志继续做退出分析,绝不无限等待。 + +use std::time::Duration; + +use crate::launch::pump::{PumpCompletion, PumpGroup, Stream}; + +/// 看门狗配置。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WatchdogConfig { + /// 进程退出后等待日志泵结束的预算。 + pub pump_timeout: Duration, +} + +impl Default for WatchdogConfig { + fn default() -> Self { + Self { + pump_timeout: Duration::from_secs(10), + } + } +} + +/// 看门狗判定。 +#[derive(Debug, Clone)] +pub enum PumpVerdict { + /// 两条流都正常结束。 + Complete(PumpCompletion), + /// 超时:仍有流未结束(附带是哪几条)。 + Stalled { + /// 等待结果(含已完成流与行数)。 + completion: PumpCompletion, + /// 卡住的流。 + streams: Vec, + /// 等待了多久。 + waited: Duration, + }, +} + +impl PumpVerdict { + /// 是否一切正常。 + pub fn is_complete(&self) -> bool { + matches!(self, Self::Complete(_)) + } + + /// 等待结果(两种情形都有)。 + pub fn completion(&self) -> &PumpCompletion { + match self { + Self::Complete(completion) => completion, + Self::Stalled { completion, .. } => completion, + } + } +} + +/// 用配置里的预算等待日志泵结束。 +pub fn wait_for_pumps(group: &mut PumpGroup, config: &WatchdogConfig) -> PumpVerdict { + let completion = group.wait(config.pump_timeout); + if completion.is_complete() { + PumpVerdict::Complete(completion) + } else { + let streams = completion.stalled.clone(); + PumpVerdict::Stalled { + completion, + streams, + waited: config.pump_timeout, + } + } +} diff --git a/crates/core/src/launch/window_detect.rs b/crates/core/src/launch/window_detect.rs new file mode 100644 index 0000000..1a1a667 --- /dev/null +++ b/crates/core/src/launch/window_detect.rs @@ -0,0 +1,86 @@ +//! “游戏窗口是否出现”的检测(日志启发式)。 +//! +//! HMCL 只看两条小写子串(`analyse/02` §6.5:`lwjgl version` / `lwjgl openal`), +//! 换日志格式就失效。v1 保持同样的启发式但做成**可扩展模式表 + 命中证据 + 耗时**, +//! 这样 `window_detected` 事件能告诉 AI“靠哪一行判定的、花了多久”; +//! 进程/窗口级信号(Windows `EnumWindows` 按 PID)留 P1(`PlanDoc/06` §1 升级点 3)。 + +use std::time::Instant; + +/// 一次成功检测。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Detection { + /// 启发式名称(事件流 `window_detected.heuristic`)。 + pub heuristic: String, + /// 从进程启动到命中的毫秒数。 + pub took_ms: u64, + /// 命中的日志行(事件流 `evidence`)。 + pub evidence: String, +} + +/// 启发式模式表:(小写子串,启发式名称)。 +pub const HEURISTICS: &[(&str, &str)] = &[ + ("lwjgl version", "lwjgl_version"), + ("lwjgl openal", "lwjgl_openal"), + ("sound engine started", "sound_engine"), +]; + +/// 窗口检测器。 +#[derive(Debug, Clone)] +pub struct WindowDetector { + started_at: Instant, + detection: Option, +} + +impl WindowDetector { + /// 以“现在”为计时起点。 + pub fn new() -> Self { + Self { + started_at: Instant::now(), + detection: None, + } + } + + /// 指定计时起点(进程 spawn 时刻)。 + pub fn with_start(started_at: Instant) -> Self { + Self { + started_at, + detection: None, + } + } + + /// 观察一行日志;首次命中返回检测结果。 + pub fn observe(&mut self, line: &str) -> Option { + if self.detection.is_some() { + return None; + } + let lower = line.to_ascii_lowercase(); + let (needle, name) = HEURISTICS + .iter() + .find(|(needle, _)| lower.contains(needle))?; + let _ = needle; + let detection = Detection { + heuristic: (*name).to_string(), + took_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + evidence: line.trim().to_string(), + }; + self.detection = Some(detection.clone()); + Some(detection) + } + + /// 是否已经检测到窗口。 + pub fn is_detected(&self) -> bool { + self.detection.is_some() + } + + /// 检测结果。 + pub fn detection(&self) -> Option<&Detection> { + self.detection.as_ref() + } +} + +impl Default for WindowDetector { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..16966bf --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,24 @@ +//! Unlucky-Launcher 领域库(`unlucky_core`)。 +//! +//! 本 crate **零 UI 依赖**:UI 框架(Slint)只允许出现在 `crates/app`。 +//! 这是对 HMCL「HMCLCore 34 处 import javafx」架构债的正面修正, +//! 由 `.github/workflows/guards.yml` 的 `core-no-ui` job 在 CI 守门。 +//! +//! core 对 UI 的回调只有三条通道(见 `DevDocs/PlanDoc/01-tech-stack-and-architecture.md` §3): +//! 1. 不可变快照发布;2. 事件流(启动/下载/诊断 JSONL 事件);3. 注入的显示服务接口(进度/确认)。 +//! +//! 设计定稿索引:`DevDocs/PlanDoc/00-master-architecture.md`。 +//! 各模块的设计文档在对应 `mod.rs` 的模块注释里点名。 +//! +//! 当前状态:P0 脚手架,只有模块骨架与设计注释,实现按 M12–M15/M18 分批落地。 + +#![forbid(unsafe_code)] + +pub mod addon; +pub mod auth; +pub mod diagnose; +pub mod download; +pub mod game; +pub mod launch; +pub mod platform; +pub mod setting; diff --git a/crates/core/src/platform/atomic.rs b/crates/core/src/platform/atomic.rs new file mode 100644 index 0000000..9448a84 --- /dev/null +++ b/crates/core/src/platform/atomic.rs @@ -0,0 +1,79 @@ +//! 原子写:临时文件 → fsync → rename。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.6(HMCL `FileUtils.saveSafely`: +//! 写 tmp + `Files.move(REPLACE_EXISTING)`)。配置与下载落盘都该走这里,禁止直接 `fs::write`。 +//! +//! `std::fs::rename` 在 Unix 与 Windows 上都是「替换式」语义 +//! (Windows 侧 std 用 `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`),因此不需要先删目标文件。 + +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::error::{io_at, PlatformError, PlatformResult}; + +/// 同进程内的 tmp 名去重计数器。 +static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// 原子地把 `bytes` 写到 `path`(父目录不存在会创建)。 +/// +/// # Errors +/// 建目录、写临时文件、fsync、rename 任一步失败都返回 [`PlatformError::Io`], +/// 且失败时会尽力清理临时文件,不留垃圾。 +pub fn write_atomic(path: &Path, bytes: &[u8]) -> PlatformResult<()> { + let parent = path.parent().unwrap_or_else(|| Path::new("")); + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(io_at(parent))?; + } + + let tmp = tmp_path(path); + let write_result = (|| -> io::Result<()> { + let mut file = std::fs::File::create(&tmp)?; + file.write_all(bytes)?; + file.sync_all()?; + Ok(()) + })(); + if let Err(source) = write_result { + std::fs::remove_file(&tmp).ok(); + return Err(PlatformError::Io { path: tmp, source }); + } + + if let Err(source) = std::fs::rename(&tmp, path) { + std::fs::remove_file(&tmp).ok(); + return Err(PlatformError::Io { + path: path.to_path_buf(), + source, + }); + } + Ok(()) +} + +/// 读文件;不存在返回 `None`,其它错误照原样上报(用于区分「没配过」和「读不了」)。 +/// +/// # Errors +/// 除 `NotFound` 以外的读失败返回 [`PlatformError::Io`]。 +pub fn read_optional(path: &Path) -> PlatformResult>> { + match std::fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None), + Err(source) => Err(PlatformError::Io { + path: path.to_path_buf(), + source, + }), + } +} + +/// 同目录下的临时文件名(`launcher.json` → `launcher.json.tmp-`)。 +#[must_use] +pub fn tmp_path(path: &Path) -> PathBuf { + let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let name = path.file_name().map_or_else( + || String::from("ull"), + |name| name.to_string_lossy().into_owned(), + ); + let tmp_name = format!(".{name}.tmp{}-{counter}", std::process::id()); + match path.parent() { + Some(parent) => parent.join(tmp_name), + None => PathBuf::from(tmp_name), + } +} diff --git a/crates/core/src/platform/data_dir.rs b/crates/core/src/platform/data_dir.rs new file mode 100644 index 0000000..f1ca7b0 --- /dev/null +++ b/crates/core/src/platform/data_dir.rs @@ -0,0 +1,235 @@ +//! 三模式数据目录解析(Portable 默认 / Standard / Custom)。 +//! +//! 设计出处:`DevDocs/PlanDoc/04-data-and-config.md` §1—— +//! 用户原话定案:「1.默认在运行目录下生成 .ULL .minecraft 目录 2.设置内可以更改到标准的用户应用数据目录 +//! 3.用户可手动指定数据目录」。 +//! +//! 附加约束:`.ULL` 与 `.minecraft` 同根但**可分离**(`launcher_home` / `game_home` 覆盖项), +//! macOS `.app` 只读所以默认走 Standard。 + +use std::fmt; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use super::error::{PlatformError, PlatformResult}; +use super::layout::{GAME_HOME_DIR, LAUNCHER_HOME_DIR}; +use super::paths::{self, EnvSource, Platform, ProcessEnv}; +use super::redirect; + +/// 数据目录三模式。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DataMode { + /// exe 运行目录下生成 `.ULL/` 与 `.minecraft/`(首次运行的默认)。 + #[default] + Portable, + /// 各 OS 标准应用数据目录。 + Standard, + /// 用户手动指定目录。 + Custom, +} + +impl DataMode { + /// 稳定名(写进 launcher.json 与日志)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Portable => "portable", + Self::Standard => "standard", + Self::Custom => "custom", + } + } + + /// 从配置字符串解析。 + #[must_use] + pub fn parse(text: &str) -> Option { + match text { + "portable" => Some(Self::Portable), + "standard" => Some(Self::Standard), + "custom" => Some(Self::Custom), + _ => None, + } + } + + /// 该平台的默认模式:macOS 的 `.app` 内是只读的 → 默认 Standard(§1 细节)。 + #[must_use] + pub const fn default_for(platform: Platform) -> Self { + match platform { + Platform::MacOs => Self::Standard, + _ => Self::Portable, + } + } +} + +impl fmt::Display for DataMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// 设置里关于数据目录的原始输入。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DataDirRequest { + /// 三模式。 + pub mode: DataMode, + /// Custom 模式的根目录(必须绝对路径)。 + pub custom_root: Option, + /// 高级:显式覆盖启动器数据目录(`.ULL` 的等价物)。 + pub launcher_home: Option, + /// 高级:游戏目录与数据目录分离指定(整合包用户:游戏在 D 盘,配置在用户目录)。 + pub game_home: Option, +} + +/// 解析结果:实际生效的两个 home。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedData { + /// 生效模式。 + pub mode: DataMode, + /// 启动器数据目录(四分区之家)。 + pub launcher_home: PathBuf, + /// 游戏目录(`.minecraft`)。 + pub game_home: PathBuf, + /// 经 `redirect.json` 跳转时的原始目录(未跳转为 `None`)。 + pub redirected_from: Option, +} + +impl ResolvedData { + /// 生效的启动器布局视图。 + #[must_use] + pub fn layout(&self) -> super::layout::LauncherLayout { + super::layout::LauncherLayout::new(self.launcher_home.clone()) + } + + /// 是否和另一份解析结果指向同一组目录(迁移判定用)。 + #[must_use] + pub fn same_target(&self, other: &Self) -> bool { + self.launcher_home == other.launcher_home && self.game_home == other.game_home + } +} + +/// 三模式解析器。环境与 exe 目录都可注入,单测里能确定性地覆盖各 OS 落点。 +pub struct Resolver<'env> { + platform: Platform, + exe_dir: Option, + env: &'env dyn EnvSource, +} + +impl Resolver<'static> { + /// 用真实进程环境构造。 + #[must_use] + pub fn new(platform: Platform, exe_dir: Option) -> Self { + Self { + platform, + exe_dir, + env: &ProcessEnv, + } + } +} + +impl<'env> Resolver<'env> { + /// 注入环境来源(测试 / 便携化封装)。 + #[must_use] + pub fn with_env( + platform: Platform, + exe_dir: Option, + env: &'env dyn EnvSource, + ) -> Self { + Self { + platform, + exe_dir, + env, + } + } + + /// 平台。 + #[must_use] + pub const fn platform(&self) -> Platform { + self.platform + } + + /// 该平台的默认模式。 + #[must_use] + pub const fn default_mode(&self) -> DataMode { + DataMode::default_for(self.platform) + } + + /// 按三模式解析出实际目录(不跟随 `redirect.json`)。 + /// + /// # Errors + /// Portable 拿不到 exe 目录、Standard 拿不到标准目录、Custom 没给绝对路径时返回错误。 + pub fn resolve(&self, request: &DataDirRequest) -> PlatformResult { + let (launcher_home, game_home) = match request.mode { + DataMode::Portable => { + let exe_dir = self.exe_dir.clone().ok_or(PlatformError::NoExecutableDir)?; + (exe_dir.join(LAUNCHER_HOME_DIR), exe_dir.join(GAME_HOME_DIR)) + } + DataMode::Standard => { + let launcher = paths::standard_launcher_home(self.platform, self.env).ok_or( + PlatformError::NoStandardDataDir { + platform: self.platform.as_str(), + }, + )?; + let game = paths::standard_game_home(self.platform, self.env).ok_or( + PlatformError::NoStandardDataDir { + platform: self.platform.as_str(), + }, + )?; + (launcher, game) + } + DataMode::Custom => { + let root = request.custom_root.clone().ok_or_else(|| { + PlatformError::InvalidCustomRoot { + root: PathBuf::new(), + } + })?; + if !root.is_absolute() { + return Err(PlatformError::InvalidCustomRoot { root }); + } + (root.join(LAUNCHER_HOME_DIR), root.join(GAME_HOME_DIR)) + } + }; + + Ok(ResolvedData { + mode: request.mode, + launcher_home: request.launcher_home.clone().unwrap_or(launcher_home), + game_home: request.game_home.clone().unwrap_or(game_home), + redirected_from: None, + }) + } + + /// 解析并跟随 `/redirect.json`(便携盘被搬走后旧目录不再自己长出新实例)。 + /// + /// # Errors + /// 解析失败、指针损坏、跳转成环/超限时返回错误。 + pub fn resolve_effective( + &self, + request: &DataDirRequest, + max_hops: usize, + ) -> PlatformResult { + let resolved = self.resolve(request)?; + let followed = redirect::follow(&resolved.launcher_home, max_hops)?; + match followed { + Some(followed) => Ok(ResolvedData { + mode: resolved.mode, + launcher_home: followed.pointer.launcher_home.clone(), + game_home: followed.pointer.game_home.clone(), + redirected_from: Some(resolved.launcher_home), + }), + None => Ok(resolved), + } + } +} + +/// 默认跟随跳数上限(防成环)。 +pub const DEFAULT_REDIRECT_MAX_HOPS: usize = 8; + +impl<'env> Resolver<'env> { + /// [`Self::resolve_effective`] 的默认跳数版本。 + /// + /// # Errors + /// 同 [`Self::resolve_effective`]。 + pub fn resolve_default(&self, request: &DataDirRequest) -> PlatformResult { + self.resolve_effective(request, DEFAULT_REDIRECT_MAX_HOPS) + } +} diff --git a/crates/core/src/platform/dir_migration.rs b/crates/core/src/platform/dir_migration.rs new file mode 100644 index 0000000..7a1a14e --- /dev/null +++ b/crates/core/src/platform/dir_migration.rs @@ -0,0 +1,252 @@ +//! 数据目录迁移:计划 → 执行 → 失败回滚 → 收据 + `redirect.json` 指针。 +//! +//! 设计出处:`DevDocs/PlanDoc/04-data-and-config.md` §1 细节—— +//! 「迁移 = 移动 + 原地留 redirect.json 指针;迁移失败自动回滚(HMCL `MigrationReceipt` 指纹防重放思想)」。 +//! +//! 安全取舍(都是显式拒绝,不猜用户意图): +//! 1. 目标目录已有数据 → 拒绝(不合并,避免半合并状态); +//! 2. 任一步失败 → 逆序回滚已搬的分区;回滚也失败 → [`PlatformError::RollbackFailed`] 要求人工介入; +//! 3. `cache/` 是纯缓存,默认一起搬但可以关掉。 + +use std::io; +use std::path::{Path, PathBuf}; + +use super::data_dir::ResolvedData; +use super::error::{io_at, PlatformError, PlatformResult}; +use super::layout::{LauncherLayout, Partition}; +use super::receipt::{self, MigrationReceipt}; +use super::redirect::{self, RedirectPointer, REDIRECT_SCHEMA_VERSION}; + +/// 迁移选项。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DirMigrationOptions { + /// 是否连 `cache/` 一起搬(关掉则目标侧重建缓存)。 + pub migrate_cache: bool, +} + +impl Default for DirMigrationOptions { + fn default() -> Self { + Self { + migrate_cache: true, + } + } +} + +/// 一步分区搬运。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MoveStep { + /// 分区。 + pub partition: Partition, + /// 源目录。 + pub source: PathBuf, + /// 目标目录。 + pub target: PathBuf, +} + +/// 已校验过的迁移计划。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirMigrationPlan { + /// 源布局。 + pub from: ResolvedData, + /// 目标布局。 + pub to: ResolvedData, + /// 要搬的分区(按搬动顺序:config → data → logs → cache)。 + pub steps: Vec, +} + +/// 迁移结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirMigrationOutcome { + /// 已搬的分区(按搬动顺序)。 + pub moved: Vec, + /// 写入目标的收据。 + pub receipt: MigrationReceipt, + /// 写入旧目录的指针。 + pub pointer: RedirectPointer, + /// 收据落盘路径。 + pub receipt_path: PathBuf, + /// 指针落盘路径。 + pub pointer_path: PathBuf, +} + +/// 搬动顺序:配置文件最重要、缓存最后(且可跳过)。 +const ORDER: [Partition; 4] = [ + Partition::Config, + Partition::Data, + Partition::Logs, + Partition::Cache, +]; + +/// 生成计划(不改动磁盘,只校验)。目标目录已存在且非空 → 拒绝。 +/// +/// # Errors +/// 源目标相同、源目录不存在、目标非空、没有可搬分区时返回 [`PlatformError::MigrationRefused`]。 +pub fn plan( + from: &ResolvedData, + to: &ResolvedData, + options: &DirMigrationOptions, +) -> PlatformResult { + if from.same_target(to) { + return Err(PlatformError::MigrationRefused { + reason: format!("源与目标相同: {}", from.launcher_home.display()), + }); + } + if !from.launcher_home.is_dir() { + return Err(PlatformError::MigrationRefused { + reason: format!("源数据目录不存在: {}", from.launcher_home.display()), + }); + } + if is_non_empty_dir(&to.launcher_home) { + return Err(PlatformError::MigrationRefused { + reason: format!("目标数据目录已有数据: {}", to.launcher_home.display()), + }); + } + + let from_layout = LauncherLayout::new(from.launcher_home.clone()); + let to_layout = LauncherLayout::new(to.launcher_home.clone()); + let mut steps = Vec::new(); + for partition in ORDER { + if partition.is_disposable() && !options.migrate_cache { + continue; + } + let source = from_layout.partition(partition); + if !source.is_dir() { + continue; + } + steps.push(MoveStep { + partition, + source, + target: to_layout.partition(partition), + }); + } + if steps.is_empty() { + return Err(PlatformError::MigrationRefused { + reason: format!( + "源数据目录里没有可搬的分区: {}", + from.launcher_home.display() + ), + }); + } + Ok(DirMigrationPlan { + from: from.clone(), + to: to.clone(), + steps, + }) +} + +/// 执行计划:逐分区搬 → 失败逆序回滚 → 成功后写收据 + 旧目录指针。 +/// +/// # Errors +/// 写/搬失败且回滚成功 → 返回原始错误(磁盘已还原);回滚也失败 → [`PlatformError::RollbackFailed`]。 +pub fn execute(plan: &DirMigrationPlan, now_ms: u64) -> PlatformResult { + let mut applied: Vec<&MoveStep> = Vec::new(); + for step in &plan.steps { + match move_tree(&step.source, &step.target) { + Ok(()) => applied.push(step), + Err(err) => return Err(rollback(&applied, err)), + } + } + + let from_layout = LauncherLayout::new(plan.from.launcher_home.clone()); + let to_layout = LauncherLayout::new(plan.to.launcher_home.clone()); + std::fs::create_dir_all(from_layout.home()).map_err(io_at(from_layout.home()))?; + std::fs::create_dir_all(to_layout.config()).map_err(io_at(to_layout.config()))?; + + let receipt = MigrationReceipt::data_dir_move( + plan.from.launcher_home.clone(), + plan.to.launcher_home.clone(), + now_ms, + ); + let receipt_path = to_layout.migration_receipt(); + receipt::write(&receipt_path, &receipt)?; + + let pointer = RedirectPointer { + schema_version: REDIRECT_SCHEMA_VERSION, + mode: plan.to.mode, + launcher_home: plan.to.launcher_home.clone(), + game_home: plan.to.game_home.clone(), + migrated_at_ms: now_ms, + }; + let pointer_path = redirect::write(from_layout.home(), &pointer)?; + + Ok(DirMigrationOutcome { + moved: plan.steps.iter().map(|step| step.partition).collect(), + receipt, + pointer, + receipt_path, + pointer_path, + }) +} + +/// 逆序回滚已搬分区;回滚本身失败时升级为 [`PlatformError::RollbackFailed`]。 +fn rollback(applied: &[&MoveStep], cause: PlatformError) -> PlatformError { + for step in applied.iter().rev() { + if let Err(rollback_err) = move_tree(&step.target, &step.source) { + return PlatformError::RollbackFailed { + reason: format!( + "分区 {} 搬运失败({cause}),且回滚 {} 失败({rollback_err})", + step.partition.as_str(), + step.partition.as_str() + ), + }; + } + } + cause +} + +/// 搬一棵目录树:优先 `rename`(同盘瞬时);跨盘失败回落「复制 + 校验 + 删源」。 +/// +/// 目标已存在则拒绝(绝不合并)。源不存在视为无事可做。 +fn move_tree(source: &Path, target: &Path) -> PlatformResult<()> { + if !source.exists() { + return Ok(()); + } + if target.exists() { + return Err(PlatformError::MigrationRefused { + reason: format!("目标已存在,拒绝覆盖: {}", target.display()), + }); + } + let parent = target.parent().unwrap_or_else(|| Path::new("")); + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(io_at(parent))?; + } + + match std::fs::rename(source, target) { + Ok(()) => Ok(()), + Err(_) => { + // 跨盘(便携盘 → 内置盘)时 rename 会失败,改用复制 + 删源。 + copy_tree(source, target).map_err(|err| { + std::fs::remove_dir_all(target).ok(); + PlatformError::Io { + path: target.to_path_buf(), + source: err, + } + })?; + std::fs::remove_dir_all(source).map_err(io_at(source)) + } + } +} + +/// 递归复制(不跟随符号链接,避免把外部大目录拖进来)。 +fn copy_tree(from: &Path, to: &Path) -> io::Result<()> { + std::fs::create_dir_all(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let file_type = entry.file_type()?; + let destination = to.join(entry.file_name()); + if file_type.is_dir() { + copy_tree(&entry.path(), &destination)?; + } else if file_type.is_file() { + std::fs::copy(entry.path(), &destination)?; + } + } + Ok(()) +} + +/// 目录存在且非空。 +fn is_non_empty_dir(path: &Path) -> bool { + if !path.is_dir() { + return false; + } + std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_some()) +} diff --git a/crates/core/src/platform/error.rs b/crates/core/src/platform/error.rs new file mode 100644 index 0000000..95e392e --- /dev/null +++ b/crates/core/src/platform/error.rs @@ -0,0 +1,124 @@ +//! platform 层错误类型与结果别名。 +//! +//! 设计出处:`DevDocs/PlanDoc/04-data-and-config.md` §1(三模式 + 迁移收据)、 +//! `DevDocs/analyse/07-settings-packaging-update.md` §1.6(保存通道)、§1.8(遗留迁移收据)。 + +use std::fmt; +use std::io; +use std::path::PathBuf; + +/// platform 层的结果别名。 +pub type PlatformResult = Result; + +/// 数据目录解析、文件系统原语、目录迁移可能出的问题。 +#[derive(Debug)] +pub enum PlatformError { + /// 文件系统操作失败(带出错路径)。 + Io { + /// 出错的文件或目录。 + path: PathBuf, + /// 底层 IO 错误。 + source: io::Error, + }, + /// Portable 模式拿不到可执行文件所在目录。 + NoExecutableDir, + /// Standard 模式拿不到宿主标准数据目录(环境变量缺失)。 + NoStandardDataDir { + /// 平台名(`windows` / `macos` / `linux` / `other`)。 + platform: &'static str, + }, + /// Custom 模式没给目录,或给的不是绝对路径。 + InvalidCustomRoot { + /// 用户给的目录。 + root: PathBuf, + }, + /// 实例 id 非法(只允许 `[A-Za-z0-9_-]`,挡掉 `../` 路径穿越)。 + InvalidInstanceId { + /// 用户/档案里给的 id。 + id: String, + }, + /// `redirect.json` 链超过跳数上限或成环。 + RedirectCycle { + /// 起始目录。 + dir: PathBuf, + /// 实际跳数。 + hops: usize, + }, + /// `redirect.json` 内容损坏。 + RedirectCorrupt { + /// 指针文件路径。 + path: PathBuf, + /// 原因。 + reason: String, + }, + /// 拒绝迁移(目标已有数据、源不存在等),原因写在消息里。 + MigrationRefused { + /// 拒绝原因。 + reason: String, + }, + /// 迁移失败且回滚也失败——需要人工介入,不能当成普通失败吞掉。 + RollbackFailed { + /// 原始失败 + 回滚失败串起来的原因。 + reason: String, + }, + /// 迁移收据损坏。 + ReceiptCorrupt { + /// 收据文件路径。 + path: PathBuf, + /// 原因。 + reason: String, + }, +} + +impl fmt::Display for PlatformError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, source } => write!(f, "IO 失败 @ {}: {source}", path.display()), + Self::NoExecutableDir => write!(f, "Portable 模式无法定位可执行文件所在目录"), + Self::NoStandardDataDir { platform } => { + write!( + f, + "Standard 模式在 {platform} 上拿不到标准数据目录(环境变量缺失)" + ) + } + Self::InvalidCustomRoot { root } => { + write!( + f, + "Custom 数据目录不合法(必须是绝对路径): {}", + root.display() + ) + } + Self::InvalidInstanceId { id } => { + write!(f, "实例 id 非法(只允许 [A-Za-z0-9_-]): {id:?}") + } + Self::RedirectCycle { dir, hops } => write!( + f, + "redirect.json 跳转成环或超限({hops} 跳)@ {}", + dir.display() + ), + Self::RedirectCorrupt { path, reason } => { + write!(f, "redirect.json 损坏 @ {}: {reason}", path.display()) + } + Self::MigrationRefused { reason } => write!(f, "拒绝迁移: {reason}"), + Self::RollbackFailed { reason } => write!(f, "迁移回滚失败,需人工介入: {reason}"), + Self::ReceiptCorrupt { path, reason } => { + write!(f, "迁移收据损坏 @ {}: {reason}", path.display()) + } + } + } +} + +impl std::error::Error for PlatformError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + _ => None, + } + } +} + +/// 把 `io::Error` 绑上路径,给 `map_err` 用。 +pub(crate) fn io_at>(path: P) -> impl FnOnce(io::Error) -> PlatformError { + let path = path.into(); + move |source| PlatformError::Io { path, source } +} diff --git a/crates/core/src/platform/hash.rs b/crates/core/src/platform/hash.rs new file mode 100644 index 0000000..9b31944 --- /dev/null +++ b/crates/core/src/platform/hash.rs @@ -0,0 +1,95 @@ +//! 迁移收据的源文件指纹(SHA-256),委托给 `sha2`。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.8——收据里存源文件 `sourceSHA256`, +//! 这样「删掉新文件但旧文件没变」时不会重复迁移,旧文件变了才重放。 +//! +//! 阶段A 在「不许动 Cargo.toml」的约束下自带过一版 FIPS 180-4 实现;阶段B 按 tower 批复 +//! 改用 `sha2`(安全哈希不自维护),**公开签名不变**,单测从「自带实现的标准向量」 +//! 变为「wrapper 与 `sha2` 一致 + 标准向量兜底」。 + +use std::io; +use std::path::Path; + +use sha2::{Digest, Sha256}; + +/// 计算 32 字节摘要。 +#[must_use] +pub fn sha256(data: &[u8]) -> [u8; 32] { + let digest = Sha256::digest(data); + let mut out = [0u8; 32]; + out.copy_from_slice(digest.as_slice()); + out +} + +/// 计算小写十六进制摘要。 +#[must_use] +pub fn sha256_hex(data: &[u8]) -> String { + to_hex(&sha256(data)) +} + +/// 读文件算十六进制摘要(配置文件很小,整读即可)。 +/// +/// # Errors +/// 文件读失败时返回原始 `io::Error`。 +pub fn sha256_hex_file(path: &Path) -> io::Result { + let bytes = std::fs::read(path)?; + Ok(sha256_hex(&bytes)) +} + +fn to_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(char::from(HEX[usize::from(byte >> 4)])); + out.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + out +} + +#[cfg(test)] +mod tests { + use sha2::Digest; + + use super::{sha256, sha256_hex, sha256_hex_file}; + + #[test] + fn matches_fips_vectors() { + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + sha256_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" + ); + } + + #[test] + fn agrees_with_sha2_at_block_boundaries() { + // 55 / 56 / 63 / 64 / 65 是补齐逻辑最容易出错的长度,wrapper 必须与 sha2 完全一致。 + for len in [55usize, 56, 63, 64, 65, 128, 1000] { + let data = vec![b'a'; len]; + let expected = sha2::Sha256::digest(&data); + assert_eq!(&sha256(&data)[..], expected.as_slice(), "length {len}"); + assert_eq!(sha256_hex(&data).len(), 64); + } + } + + #[test] + fn hashes_file_contents() { + let dir = std::env::temp_dir().join(format!("ull-hash-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let path = dir.join("a.txt"); + std::fs::write(&path, b"abc").expect("write"); + let hex = sha256_hex_file(&path).expect("hash file"); + std::fs::remove_dir_all(&dir).ok(); + assert_eq!( + hex, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } +} diff --git a/crates/core/src/platform/layout.rs b/crates/core/src/platform/layout.rs new file mode 100644 index 0000000..146a011 --- /dev/null +++ b/crates/core/src/platform/layout.rs @@ -0,0 +1,223 @@ +//! `.ULL/` 四分区布局——所有模块取路径的唯一入口,禁止各自手拼路径。 +//! +//! 布局依据 `DevDocs/PlanDoc/04-data-and-config.md` §2(承 HMCL 四分区): +//! `config/`(schema 化 JSON)、`data/`(可重建但优先保留)、`cache/`(随时可删)、`logs/`。 + +use std::path::{Path, PathBuf}; + +use super::error::{io_at, PlatformError, PlatformResult}; + +/// Portable 模式下启动器数据目录名(exe 同目录)。 +pub const LAUNCHER_HOME_DIR: &str = ".ULL"; +/// 游戏数据目录名。 +pub const GAME_HOME_DIR: &str = ".minecraft"; +/// 分区:配置。 +pub const CONFIG_DIR: &str = "config"; +/// 分区:状态数据。 +pub const DATA_DIR: &str = "data"; +/// 分区:纯缓存。 +pub const CACHE_DIR: &str = "cache"; +/// 分区:启动器日志。 +pub const LOGS_DIR: &str = "logs"; + +/// 全局设置文件名。 +pub const LAUNCHER_CONFIG_FILE: &str = "launcher.json"; +/// 实例档案列表文件名。 +pub const PROFILES_CONFIG_FILE: &str = "profiles.json"; +/// 每实例设置所在子目录。 +pub const INSTANCES_DIR: &str = "instances"; +/// 迁移后留在旧目录的指针文件名。 +pub const REDIRECT_FILE: &str = "redirect.json"; +/// 目录迁移收据文件名(写在目标 `config/`)。 +pub const MIGRATION_RECEIPT_FILE: &str = "migration-receipt.json"; + +/// `.ULL/` 下的四个分区。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Partition { + /// `config/`:配置(迁移时优先搬)。 + Config, + /// `data/`:状态数据(不可丢)。 + Data, + /// `cache/`:纯缓存(可丢,迁移可选)。 + Cache, + /// `logs/`:启动器日志。 + Logs, +} + +impl Partition { + /// 全部四个分区。 + pub const ALL: [Self; 4] = [Self::Config, Self::Data, Self::Cache, Self::Logs]; + + /// 分区目录名。 + #[must_use] + pub const fn dir_name(&self) -> &'static str { + match self { + Self::Config => CONFIG_DIR, + Self::Data => DATA_DIR, + Self::Cache => CACHE_DIR, + Self::Logs => LOGS_DIR, + } + } + + /// 稳定名(日志 / 收据用)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + self.dir_name() + } + + /// 是否为可丢弃分区(迁移时可跳过)。 + #[must_use] + pub const fn is_disposable(&self) -> bool { + matches!(self, Self::Cache) + } +} + +/// 一个 launcher home(`.ULL` 的等价物)的路径视图。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LauncherLayout { + home: PathBuf, +} + +impl LauncherLayout { + /// 绑定到某个 home 目录(不创建任何东西,创建用 [`Self::ensure`])。 + #[must_use] + pub fn new(home: impl Into) -> Self { + Self { home: home.into() } + } + + /// home 根目录。 + #[must_use] + pub fn home(&self) -> &Path { + &self.home + } + + /// 任一分区目录。 + #[must_use] + pub fn partition(&self, partition: Partition) -> PathBuf { + self.home.join(partition.dir_name()) + } + + /// `config/`。 + #[must_use] + pub fn config(&self) -> PathBuf { + self.partition(Partition::Config) + } + + /// `data/`。 + #[must_use] + pub fn data(&self) -> PathBuf { + self.partition(Partition::Data) + } + + /// `cache/`。 + #[must_use] + pub fn cache(&self) -> PathBuf { + self.partition(Partition::Cache) + } + + /// `logs/`。 + #[must_use] + pub fn logs(&self) -> PathBuf { + self.partition(Partition::Logs) + } + + /// `config/`。 + #[must_use] + pub fn config_file(&self, name: &str) -> PathBuf { + self.config().join(name) + } + + /// `config/launcher.json`。 + #[must_use] + pub fn launcher_config(&self) -> PathBuf { + self.config_file(LAUNCHER_CONFIG_FILE) + } + + /// `config/profiles.json`。 + #[must_use] + pub fn profiles_config(&self) -> PathBuf { + self.config_file(PROFILES_CONFIG_FILE) + } + + /// `config/instances/.json`。 + /// + /// # Errors + /// 实例 id 只允许 `[A-Za-z0-9_-]`(正常是稳定 UUID),否则返回 + /// [`super::error::PlatformError::InvalidInstanceId`],挡掉 `../` 穿越。 + pub fn instance_config(&self, id: &str) -> PlatformResult { + if !is_valid_instance_id(id) { + return Err(PlatformError::InvalidInstanceId { id: id.to_owned() }); + } + Ok(self.config().join(INSTANCES_DIR).join(format!("{id}.json"))) + } + + /// `cache/sha1/

`——内容寻址缓存的两级分片(避开 NTFS 单目录塌陷)。 + #[must_use] + pub fn sha1_cache(&self, shard: &str) -> PathBuf { + self.cache().join("sha1").join(shard) + } + + /// `cache/etag.json`——共享 ETag 表。 + #[must_use] + pub fn etag_cache(&self) -> PathBuf { + self.cache().join("etag.json") + } + + /// `cache/meta/`——列表 API 响应缓存(带 TTL)。 + #[must_use] + pub fn meta_cache(&self) -> PathBuf { + self.cache().join("meta") + } + + /// `data/java-managed/`——自动下载的 JDK。 + #[must_use] + pub fn java_managed(&self) -> PathBuf { + self.data().join("java-managed") + } + + /// `data/auth/`——账户元数据与凭据。 + #[must_use] + pub fn auth_data(&self) -> PathBuf { + self.data().join("auth") + } + + /// `data/agent/sessions/`——AI 会话 jsonl 与审计日志。 + #[must_use] + pub fn agent_sessions(&self) -> PathBuf { + self.data().join("agent").join("sessions") + } + + /// `/redirect.json`——「我已迁走」指针。 + #[must_use] + pub fn redirect_path(&self) -> PathBuf { + self.home.join(REDIRECT_FILE) + } + + /// `config/migration-receipt.json`——迁移收据。 + #[must_use] + pub fn migration_receipt(&self) -> PathBuf { + self.config_file(MIGRATION_RECEIPT_FILE) + } + + /// 一次性建齐四分区。 + /// + /// # Errors + /// 目录创建失败时返回带路径的 [`super::error::PlatformError::Io`]。 + pub fn ensure(&self) -> PlatformResult<()> { + for partition in Partition::ALL { + let dir = self.partition(partition); + std::fs::create_dir_all(&dir).map_err(io_at(&dir))?; + } + Ok(()) + } +} + +/// 实例 id 白名单:字母数字与 `_`/`-`。 +#[must_use] +pub fn is_valid_instance_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 64 + && id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') +} diff --git a/crates/core/src/platform/linux_caps.rs b/crates/core/src/platform/linux_caps.rs new file mode 100644 index 0000000..3397e9d --- /dev/null +++ b/crates/core/src/platform/linux_caps.rs @@ -0,0 +1,225 @@ +//! Linux 探测:**能力**而非版本号(D19 · `PlanDoc/11-os-detection.md` §2)。 +//! +//! 发行版版本对能不能跑没有意义,所以这里分三类事实: +//! - `/etc/os-release` 的 `PRETTY_NAME` —— **仅供显示/日志**; +//! - 内核 release(`/proc/sys/kernel/osrelease`,退化 `uname -r`)—— 喂 MC 的 +//! `rules.os.version`,同时给出最低内核判定用的 `major.minor`; +//! - 能力探测:图形会话类型(Wayland / X11 / 无显示)与 GL 能力钩子(见 [`probe_gl`])。 +//! +//! glibc 版本用 `ldd --version` 探测(musl / Termux 上取不到,此时不阻塞)。 +//! fontconfig 可用性与 `xdg-desktop-portal` 同理留到 app 层(字体回退、主题跟随、文件选择器), +//! core 只报环境事实,不做渲染后端决策。 +//! 解析函数是纯函数且跨平台编译,单测用注入样本覆盖(见 `tests/platform/`)。 + +/// 图形会话类型(`Unknown` 是派生默认值:未探测)。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum SessionKind { + /// Wayland 会话(`WAYLAND_DISPLAY` 或 `XDG_SESSION_TYPE=wayland`)。 + Wayland, + /// X11 会话(`DISPLAY` 或 `XDG_SESSION_TYPE=x11`)。 + X11, + /// 没有图形会话(服务器 / 容器 / SSH)。GUI 用不了,CLI 照常。 + Headless, + /// 未探测(非 Linux 平台,或探测未执行)。 + #[default] + Unknown, +} + +impl SessionKind { + /// 简短名(日志用)。 + pub fn as_str(self) -> &'static str { + match self { + Self::Wayland => "wayland", + Self::X11 => "x11", + Self::Headless => "headless", + Self::Unknown => "unknown", + } + } + + /// 是否存在可用的显示服务器。 + pub fn has_display(self) -> bool { + matches!(self, Self::Wayland | Self::X11) + } +} + +/// GL/EGL 能力探测结果。 +/// +/// P1 只留钩子:真探测要 `dlopen("libEGL.so")` + `eglQueryString`,那是 FFI, +/// 而 core 是 `#![forbid(unsafe_code)]`;因此真实探测放在 app 层「渲染后端选择」里做, +/// core 只负责把 `Unknown` 如实报出来,不阻塞任何流程。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum GlProbe { + /// 未探测(P1 常态)。 + #[default] + Unknown, + /// 有可用的 GL/EGL。 + Available, + /// 没有可用 GL(调用方应降级到软渲染)。 + Missing, +} + +impl GlProbe { + /// 简短名(日志用)。 + pub fn as_str(self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::Available => "available", + Self::Missing => "missing", + } + } +} + +/// Linux 探测结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinuxCaps { + /// 发行版展示名(`PRETTY_NAME`,仅显示)。 + pub distro: Option, + /// 内核 release 原始串(`6.8.0-45-generic`),喂 `rules.os.version`。 + pub kernel: Option, + /// 内核 `major.minor`(最低内核判定用)。 + pub kernel_version: Option<(u32, u32)>, + /// glibc `major.minor`;musl / 取不到时为 `None`。 + pub glibc: Option<(u32, u32)>, + /// 图形会话类型。 + pub session: SessionKind, + /// GL 能力(P1 恒为 [`GlProbe::Unknown`])。 + pub gl: GlProbe, +} + +/// 探测当前 Linux 环境;非 Linux 平台返回全空的默认值。 +#[cfg(target_os = "linux")] +pub fn probe() -> LinuxCaps { + let distro = std::fs::read_to_string("/etc/os-release") + .ok() + .and_then(|text| parse_os_release(&text)); + let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease") + .ok() + .map(|text| text.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| run("uname", &["-r"])); + let glibc = run("ldd", &["--version"]).and_then(|text| parse_glibc_version(&text)); + LinuxCaps { + distro, + kernel_version: kernel.as_deref().and_then(parse_kernel_version), + kernel, + glibc, + session: current_session(), + gl: probe_gl(), + } +} + +/// 探测当前 Linux 环境;非 Linux 平台返回全空的默认值。 +#[cfg(not(target_os = "linux"))] +pub fn probe() -> LinuxCaps { + LinuxCaps { + distro: None, + kernel: None, + kernel_version: None, + glibc: None, + session: SessionKind::Unknown, + gl: GlProbe::Unknown, + } +} + +/// GL/EGL 探测钩子(P1 返回 [`GlProbe::Unknown`],见类型文档)。 +pub fn probe_gl() -> GlProbe { + GlProbe::Unknown +} + +/// 按当前环境变量判定会话类型。 +pub fn current_session() -> SessionKind { + session_from_env(|key| std::env::var(key).ok()) +} + +/// 按注入的环境变量读取函数判定会话类型(便于单测)。 +/// +/// 判定顺序:`WAYLAND_DISPLAY` → `XDG_SESSION_TYPE` → `DISPLAY` → 无显示。 +pub fn session_from_env(get: impl Fn(&str) -> Option) -> SessionKind { + let has = |key: &str| get(key).map(|v| !v.trim().is_empty()).unwrap_or(false); + if has("WAYLAND_DISPLAY") { + return SessionKind::Wayland; + } + match get("XDG_SESSION_TYPE").as_deref().map(str::trim) { + Some(value) if value.eq_ignore_ascii_case("wayland") => return SessionKind::Wayland, + Some(value) if value.eq_ignore_ascii_case("x11") => return SessionKind::X11, + _ => {} + } + if has("DISPLAY") { + return SessionKind::X11; + } + SessionKind::Headless +} + +/// 从 `/etc/os-release` 取 `PRETTY_NAME`(去引号)。 +pub fn parse_os_release(text: &str) -> Option { + let mut fallback = None; + for line in text.lines() { + let line = line.trim(); + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim().trim_matches('"').trim_matches('\'').trim(); + if value.is_empty() { + continue; + } + match key.trim() { + "PRETTY_NAME" => return Some(value.to_string()), + // 没有 PRETTY_NAME 时用 NAME + VERSION_ID 拼一个(某些裁剪发行版只给这两个)。 + "NAME" => { + let name = value.to_string(); + fallback = Some(match fallback.take() { + Some(prev) => format!("{prev} {name}"), + None => name, + }); + } + "VERSION_ID" => { + let version = value.to_string(); + fallback = Some(match fallback.take() { + Some(prev) => format!("{prev} {version}"), + None => version, + }); + } + _ => {} + } + } + fallback +} + +/// 从内核 release 串取 `major.minor`(`6.8.0-45-generic` → `(6, 8)`)。 +pub fn parse_kernel_version(release: &str) -> Option<(u32, u32)> { + let trimmed = release.trim(); + let mut parts = trimmed.split(|c: char| !c.is_ascii_digit()); + let major: u32 = parts.next()?.parse().ok()?; + let minor: u32 = parts.next().unwrap_or("0").parse().unwrap_or(0); + Some((major, minor)) +} + +/// 从 `ldd --version` 输出取 glibc `major.minor` +/// (`ldd (Ubuntu GLIBC 2.39-0ubuntu8.3) 2.39` → `(2, 39)`)。 +pub fn parse_glibc_version(text: &str) -> Option<(u32, u32)> { + // 只看第一行:后面是版权声明,可能含有别的数字。 + let first = text.lines().next().unwrap_or_default(); + first.split_whitespace().rev().find_map(|token| { + let (major, minor) = token.split_once('.')?; + let minor = minor + .split(|c: char| !c.is_ascii_digit()) + .next() + .unwrap_or_default(); + Some((major.parse().ok()?, minor.parse().ok()?)) + }) +} + +/// `(2, 31)` → `"2.31"`(展示用)。 +pub fn format_version(version: (u32, u32)) -> String { + format!("{}.{}", version.0, version.1) +} + +#[cfg(target_os = "linux")] +fn run(program: &str, args: &[&str]) -> Option { + use std::process::Command; + let output = Command::new(program).args(args).output().ok()?; + if output.stdout.is_empty() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} diff --git a/crates/core/src/platform/macos_version.rs b/crates/core/src/platform/macos_version.rs new file mode 100644 index 0000000..bc18447 --- /dev/null +++ b/crates/core/src/platform/macos_version.rs @@ -0,0 +1,100 @@ +//! macOS 探测:产品版本 + 运行架构(D19 · `PlanDoc/11-os-detection.md` §2)。 +//! +//! - 产品版本:`sw_vers -productVersion` → `12.3.1`(最低部署目标判定 + MC `os.version` 规则); +//! - 运行架构:`sysctl -n hw.optional.arm64` 输出 `1` 表示**当前运行在 Apple Silicon 上** +//! (关键:Rosetta 2 下 `cfg!(target_arch)` 仍是 `x86_64`,但原生渲染路径与 MC 原生库 +//! 要的是真实架构,所以必须运行期探); +//! - 内核版本:`uname -r`(`22.4.0`)只进日志,不参与判定。 +//! +//! 解析函数是纯函数且跨平台编译,单测用注入样本覆盖(见 `tests/platform/`)。 + +/// macOS 版本 + 运行架构。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MacOsVersion { + /// 主版本号(macOS 12 Monterey = 12)。 + pub major: u32, + /// 次版本号。 + pub minor: u32, + /// 补丁号。 + pub patch: u32, + /// 是否运行在 Apple Silicon(含 Rosetta 2 场景)。 + pub arm64: bool, +} + +impl MacOsVersion { + /// 构造。 + pub fn new(major: u32, minor: u32, patch: u32, arm64: bool) -> Self { + Self { + major, + minor, + patch, + arm64, + } + } + + /// 产品版本串(`rules.os.version` 用,例如 `12.3.1`)。 + pub fn version_string(&self) -> String { + format!("{}.{}.{}", self.major, self.minor, self.patch) + } + + /// 展示名(`macOS 12.3.1 (arm64)`)。 + pub fn display_name(&self) -> String { + let arch = if self.arm64 { "arm64" } else { "x86_64" }; + format!("macOS {} ({arch})", self.version_string()) + } + + /// `rules.os.arch` 取值:Apple Silicon 上必须是 `arm64`。 + pub fn runtime_arch(&self) -> &'static str { + if self.arm64 { + "arm64" + } else { + "x86_64" + } + } +} + +/// 真实探测;非 macOS 平台恒为 `None`。 +#[cfg(target_os = "macos")] +pub fn probe() -> Option { + let (major, minor, patch) = + run("sw_vers", &["-productVersion"]).and_then(|text| parse_product_version(&text))?; + // sysctl 在 Intel 机器上以非零码退出(键不存在)→ stdout 为空 → false。 + let arm64 = run("sysctl", &["-n", "hw.optional.arm64"]) + .map(|text| parse_sysctl_bool(&text)) + .unwrap_or(false); + Some(MacOsVersion::new(major, minor, patch, arm64)) +} + +/// 真实探测;非 macOS 平台恒为 `None`。 +#[cfg(not(target_os = "macos"))] +pub fn probe() -> Option { + None +} + +/// 解析 `12.3.1` / `12.3` / `12`(`sw_vers -productVersion` 的输出)。 +pub fn parse_product_version(text: &str) -> Option<(u32, u32, u32)> { + let trimmed = text.trim(); + let token = trimmed + .split_whitespace() + .find(|token| token.starts_with(|c: char| c.is_ascii_digit()))?; + let mut parts = token.split('.').map(str::parse::); + let major = parts.next()?.ok()?; + let minor = parts.next().map(|value| value.unwrap_or(0)).unwrap_or(0); + let patch = parts.next().map(|value| value.unwrap_or(0)).unwrap_or(0); + Some((major, minor, patch)) +} + +/// 解析 `sysctl -n hw.optional.arm64` 的输出(`1` 为真)。 +pub fn parse_sysctl_bool(text: &str) -> bool { + text.trim() == "1" +} + +#[cfg(target_os = "macos")] +fn run(program: &str, args: &[&str]) -> Option { + use std::process::Command; + let output = Command::new(program).args(args).output().ok()?; + if !output.status.success() || output.stdout.is_empty() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} diff --git a/crates/core/src/platform/mod.rs b/crates/core/src/platform/mod.rs new file mode 100644 index 0000000..d8ee16f --- /dev/null +++ b/crates/core/src/platform/mod.rs @@ -0,0 +1,42 @@ +//! OS 抽象层:路径规则、平台目录差异、进程与文件锁、软渲染/显示能力探测辅助。 +//! +//! 设计定稿:`DevDocs/PlanDoc/04-data-and-config.md` §1–§2(三模式路径与 `.ULL` 四分区)。 +//! Win7 单独适配(决策 D11)在此收敛平台差异判断,避免散落各模块打补丁。 +//! +//! 公共面(M12 已落地): +//! - [`paths`]:宿主平台族与标准目录探测(环境变量可注入),可搬运路径字符串; +//! - [`layout`]:`.ULL/` 四分区(config/data/cache/logs)与各已知路径的唯一入口; +//! - [`data_dir`]:三模式数据目录解析(Portable 默认 / Standard / Custom + 高级覆盖); +//! - [`redirect`]:迁移后留在旧目录的 `redirect.json` 指针(防便携盘旧目录被再次初始化); +//! - [`receipt`]:迁移收据(源指纹防重放); +//! - [`dir_migration`]:数据目录迁移(分区逐个搬 + 失败逆序回滚); +//! - [`atomic`]:原子写(tmp → fsync → rename);[`hash`]:迁移收据用的 SHA-256。 +//! +//! 运行期系统探测(M20 / D19 · `11-os-detection.md`): +//! - [`os_info`]:`OsInfo` 快照 + 最低支持判定(One / Unsupported,基线 **Windows 10+ / +//! macOS 12+ / Linux 达标**)+ 启动自检; +//! - [`windows_version`]:真实 `major.minor.build`(注册表 `CurrentBuildNumber` 快路径 → +//! 整键 `CurrentVersion` 家族 → `cmd /c ver` 兜底); +//! - [`macos_version`]:`sw_vers` 产品版本 + `sysctl hw.optional.arm64` 运行架构; +//! - [`linux_caps`]:发行版(仅显示)+ 内核 release(喂 `os.version`)+ 会话/GL 能力探测; +//! - [`vk_probe`]:Vulkan 能力的安全层探测(loader 文件 / ICD 注册 / 环境变量,无 `unsafe`); +//! - [`theme_probe`]:系统主题(浅色/深色)探测,供主题三态的 `auto` 初始化对齐与兜底跟随。 +//! +//! 尚未落地(后续 mission):文件锁、进程工具、真实 GL/Vulkan 能力探测(钩子见 +//! [`linux_caps::probe_gl`] 与 [`vk_probe::probe_vk`],FFI 受限,实际探测归 app 层渲染后端选择)。 + +pub mod atomic; +pub mod data_dir; +pub mod dir_migration; +pub mod error; +pub mod hash; +pub mod layout; +pub mod linux_caps; +pub mod macos_version; +pub mod os_info; +pub mod paths; +pub mod receipt; +pub mod redirect; +pub mod theme_probe; +pub mod vk_probe; +pub mod windows_version; diff --git a/crates/core/src/platform/os_info.rs b/crates/core/src/platform/os_info.rs new file mode 100644 index 0000000..70a9eb9 --- /dev/null +++ b/crates/core/src/platform/os_info.rs @@ -0,0 +1,388 @@ +//! 运行期系统探测与最低支持判定(D19 · `DevDocs/PlanDoc/11-os-detection.md`)。 +//! +//! 事实通道按平台分流(各文件里的 `probe()`):[`windows_version`] 注册表 `CurrentBuildNumber` +//! 快路径 → 整键 `CurrentVersion` 家族 → `cmd /c ver` 兜底;[`macos_version`] `sw_vers` + +//! `sysctl`;[`linux_caps`] `/etc/os-release`(仅显示)+ 内核 release + 会话/GL 能力探测。 +//! 不用 `RtlGetVersion` FFI 是因为 core 是 `#![forbid(unsafe_code)]`,而这些路径读的是同一份 +//! 真实事实,代价只是一次性几十毫秒。 +//! +//! 支持基线(用户拍板):**Windows 10 起、macOS 12 起、Linux(内核/glibc 达标)**。 +//! 因此等级只有两档:[`SupportLevel::One`] 与 [`SupportLevel::Unsupported`]——Win8.1/8/7 +//! 不承诺 GUI,也不承诺 CLI(识别出旧版本正是为了报「不支持」)。 +//! +//! 入口:[`detect`](真探)· [`current`](进程内缓存)· [`check`](CLI 启动自检); +//! 等级映射是纯函数([`support_level_for`] 及三平台分解),单测用注入式假数据覆盖。 + +use std::sync::OnceLock; + +use crate::platform::linux_caps::{self, GlProbe, SessionKind}; +use crate::platform::macos_version; +use crate::platform::vk_probe::{self, VkProbe}; +use crate::platform::windows_version; + +/// 最低支持的 Windows 版本(10.0 = Windows 10,build 10240 起)。 +pub const MIN_WINDOWS: (u32, u32) = (10, 0); +/// 最低支持的 macOS 主版本(Slint / winit 的现实部署下限,见 D19 §4)。 +pub const MIN_MACOS_MAJOR: u32 = 12; +/// 最低 Linux 内核(Rust std 的 Linux 下限)。 +pub const MIN_LINUX_KERNEL: (u32, u32) = (3, 2); +/// 最低 glibc(`x86_64-unknown-linux-gnu` 构建产物能跑的下限)。 +pub const MIN_GLIBC: (u32, u32) = (2, 17); + +/// 操作系统家族。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OsFamily { + /// Windows。 + Windows, + /// macOS。 + MacOs, + /// Linux 及其它 POSIX 系统。 + Linux, +} + +impl OsFamily { + /// 当前编译目标的系统家族。 + pub fn current() -> Self { + if cfg!(target_os = "windows") { + Self::Windows + } else if cfg!(target_os = "macos") { + Self::MacOs + } else { + Self::Linux + } + } + + /// Mojang 清单里 `rules.os.name` 的取值。 + pub fn manifest_name(self) -> &'static str { + match self { + Self::Windows => "windows", + Self::MacOs => "osx", + Self::Linux => "linux", + } + } + + /// `rules.os.arch` 的取值(按编译目标;Apple Silicon 的真实架构另见 + /// [`MacOsVersion::runtime_arch`](crate::platform::macos_version::MacOsVersion::runtime_arch))。 + pub fn manifest_arch(self) -> &'static str { + if cfg!(target_arch = "x86_64") { + "x86_64" + } else if cfg!(target_arch = "x86") { + "x86" + } else if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "unknown" + } + } + + /// 是否为 POSIX 系统(决定 `nice`、进程组 kill 等分支)。 + pub fn is_posix(self) -> bool { + !matches!(self, Self::Windows) + } +} + +/// 支持等级(两档:满足基线 / 不满足)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SupportLevel { + /// 一等:基线以上(Windows 10+ / macOS 12+ / Linux 达标),GUI + CLI。 + One, + /// 不支持:低于基线,启动时拒绝并给升级指引。 + Unsupported, +} + +impl SupportLevel { + /// 简短名(日志/事件用)。 + pub fn as_str(self) -> &'static str { + match self { + Self::One => "one", + Self::Unsupported => "unsupported", + } + } + + /// 是否允许运行。 + pub fn is_usable(self) -> bool { + matches!(self, Self::One) + } +} + +/// 运行期系统信息(不可变快照,可跨线程共享;字段全是探测出来的事实)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OsInfo { + /// 系统家族。 + pub family: OsFamily, + /// `rules.os.arch` 用的架构名。 + pub arch: String, + /// 版本 `major.minor`(探测失败为 `None`)。 + pub major_minor: Option<(u32, u32)>, + /// 构建号(Windows build,其它平台 `None`)。 + pub build: Option, + /// 展示名(`Windows 11` / `macOS 12.3.1 (arm64)` / `Ubuntu 24.04.1 LTS`)。 + pub product: String, + /// 版本串(`rules.os.version` 用;Linux 上是内核 release)。 + pub version_string: String, + /// 图形会话类型(Linux 有意义,其它平台 `Unknown`)。 + pub session: SessionKind, + /// GL 能力(P1 恒为 `Unknown`,见 [`GlProbe`])。 + pub gl: GlProbe, + /// Vulkan 能力(文件/注册表层,无 `unsafe`;真探测归 app 层,见 [`VkProbe`])。 + pub vk: VkProbe, + /// Linux 内核 `major.minor`。 + pub kernel: Option<(u32, u32)>, + /// glibc `major.minor`(musl / 取不到为 `None`)。 + pub glibc: Option<(u32, u32)>, + /// 支持等级(由 [`support_level_for`] 从上面的事实算出)。 + pub support_level: SupportLevel, +} + +impl OsInfo { + /// `rules.os.version` 的取值(探测失败时为空串 —— 规则求值会走保守分支)。 + pub fn os_version(&self) -> &str { + &self.version_string + } + + /// 一行日志摘要(`§5.3` 每次启动写日志 / 诊断包环境快照)。 + pub fn summary_line(&self) -> String { + let version = if self.version_string.is_empty() { + "unknown" + } else { + self.version_string.as_str() + }; + format!( + "os={} version={version} arch={} level={} product=\"{}\" session={} gl={} vk={}", + self.family.manifest_name(), + self.arch, + self.support_level.as_str(), + self.product, + self.session.as_str(), + self.gl.as_str(), + self.vk.as_str(), + ) + } + + /// 用户可读文案(中英双语)。 + pub fn describe(&self) -> SupportText { + match self.support_level { + SupportLevel::One => self.text_supported(), + SupportLevel::Unsupported => self.text_unsupported(), + } + } + + fn text_supported(&self) -> SupportText { + let mut zh = format!("系统满足最低要求:{}({})。", self.product, self.arch); + let mut en = format!("Meets minimum requirements: {}.", self.product); + if self.family == OsFamily::Linux && !self.session.has_display() { + zh.push_str("\n注意:未检测到 Wayland/X11 图形会话,GUI 需要图形会话;CLI 不受影响。"); + en.push_str( + "\nNote: no Wayland/X11 graphical session; the GUI needs one. CLI is fine.", + ); + } + SupportText { zh, en } + } + + fn text_unsupported(&self) -> SupportText { + let product = if self.product.is_empty() { + "未知系统" + } else { + self.product.as_str() + }; + let (need_zh, need_en) = match self.family { + OsFamily::Windows => ( + " Windows 10 或更高版本".to_string(), + "Windows 10 or newer".to_string(), + ), + OsFamily::MacOs => ( + format!(" macOS {MIN_MACOS_MAJOR} 或更高版本"), + format!("macOS {MIN_MACOS_MAJOR} or newer"), + ), + OsFamily::Linux => { + let kernel = format!("{}.{}", MIN_LINUX_KERNEL.0, MIN_LINUX_KERNEL.1); + let glibc = MIN_GLIBC.0; + ( + format!(" Linux 内核 ≥ {kernel}(glibc ≥ {glibc})"), + format!("Linux kernel >= {kernel} (glibc >= {glibc})"), + ) + } + }; + // Windows 上版本探测失败时要说清楚是「探不到」而不是「探到旧版本」。 + let hint = if self.family == OsFamily::Windows && self.major_minor.is_none() { + "(未能探测到系统版本)" + } else { + "" + }; + SupportText { + zh: format!("不支持的系统:{product}{hint}。本启动器要求{need_zh}。"), + en: format!("Unsupported system: {product}. This launcher requires {need_en}."), + } + } +} + +/// 双语用户文案。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SupportText { + /// 中文文案。 + pub zh: String, + /// 英文文案。 + pub en: String, +} + +impl SupportText { + /// 中文 + 换行 + 英文(CLI 直接打印的形态)。 + pub fn combined(&self) -> String { + format!("{}\n{}", self.zh, self.en) + } +} + +/// 启动自检结果(CLI 打印 + 写日志用)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SupportCheck { + /// 支持等级。 + pub level: SupportLevel, + /// 用户可读文案(中英双语,多行)。 + pub message: String, + /// 探测到的系统信息(写日志 / 诊断包用)。 + pub os: OsInfo, +} + +impl SupportCheck { + /// 是否允许继续启动。 + pub fn is_usable(&self) -> bool { + self.level.is_usable() + } +} + +/// 按家族选对应的等级判定(探测与单测走同一条路径)。 +pub fn support_level_for( + family: OsFamily, + major_minor: Option<(u32, u32)>, + kernel: Option<(u32, u32)>, + glibc: Option<(u32, u32)>, +) -> SupportLevel { + match family { + OsFamily::Windows => windows_level(major_minor), + OsFamily::MacOs => macos_level(major_minor.map(|(major, _)| major)), + OsFamily::Linux => linux_level(kernel, glibc), + } +} + +/// Windows 等级:≥ Windows 10 → 一等;Win8.1/8/7 及更旧 → 不支持;探测不到 → 不支持(保守)。 +pub fn windows_level(major_minor: Option<(u32, u32)>) -> SupportLevel { + match major_minor { + Some(version) if version >= MIN_WINDOWS => SupportLevel::One, + _ => SupportLevel::Unsupported, + } +} + +/// macOS 等级:≥ 12 → 一等;更低或不认识 → 不支持。 +pub fn macos_level(major: Option) -> SupportLevel { + match major { + Some(major) if major >= MIN_MACOS_MAJOR => SupportLevel::One, + _ => SupportLevel::Unsupported, + } +} + +/// Linux 等级:内核/glibc 探得到但过低 → 不支持;取不到(容器、musl、Termux)不阻塞 → 一等。 +pub fn linux_level(kernel: Option<(u32, u32)>, glibc: Option<(u32, u32)>) -> SupportLevel { + if kernel.is_some_and(|version| version < MIN_LINUX_KERNEL) { + return SupportLevel::Unsupported; + } + if glibc.is_some_and(|version| version < MIN_GLIBC) { + return SupportLevel::Unsupported; + } + SupportLevel::One +} + +/// 真探一次当前系统(会起子进程,别在热路径反复调用;常规路径用 [`current`])。 +pub fn detect() -> OsInfo { + let family = OsFamily::current(); + let facts = probe(family); + let arch = if facts.arm64 { + "arm64".to_string() + } else { + family.manifest_arch().to_string() + }; + let product = facts + .product + .unwrap_or_else(|| family.manifest_name().to_string()); + OsInfo { + family, + arch, + major_minor: facts.major_minor, + build: facts.build, + product, + version_string: facts.version_string.unwrap_or_default(), + session: facts.session, + gl: facts.gl, + vk: vk_probe::probe_vk(), + kernel: facts.kernel, + glibc: facts.glibc, + support_level: support_level_for(family, facts.major_minor, facts.kernel, facts.glibc), + } +} + +/// 三平台探测结果统一成同一形状(内部用;`None` 表示该平台没有这项事实)。 +#[derive(Default)] +struct Facts { + major_minor: Option<(u32, u32)>, + build: Option, + product: Option, + version_string: Option, + kernel: Option<(u32, u32)>, + glibc: Option<(u32, u32)>, + session: SessionKind, + gl: GlProbe, + arm64: bool, +} + +fn probe(family: OsFamily) -> Facts { + match family { + OsFamily::Windows => { + let version = windows_version::probe(); + Facts { + major_minor: version.map(|v| v.major_minor()), + build: version.map(|v| v.build), + product: version.map(|v| v.display_name().to_string()), + version_string: version.map(|v| v.version_string()), + ..Facts::default() + } + } + OsFamily::MacOs => { + let version = macos_version::probe(); + Facts { + major_minor: version.map(|v| (v.major, v.minor)), + product: version.map(|v| v.display_name()), + version_string: version.map(|v| v.version_string()), + arm64: version.is_some_and(|v| v.arm64), + ..Facts::default() + } + } + OsFamily::Linux => { + let caps = linux_caps::probe(); + Facts { + major_minor: caps.kernel_version, + product: caps.distro, + version_string: caps.kernel, + kernel: caps.kernel_version, + glibc: caps.glibc, + session: caps.session, + gl: caps.gl, + ..Facts::default() + } + } + } +} + +/// 进程内缓存的一次探测结果(启动时多处以它为准,避免重复起子进程)。 +pub fn current() -> &'static OsInfo { + static CACHED: OnceLock = OnceLock::new(); + CACHED.get_or_init(detect) +} + +/// CLI 启动自检:等级 + 双语文案 + 系统信息。 +pub fn check() -> SupportCheck { + let os = current().clone(); + SupportCheck { + level: os.support_level, + message: os.describe().combined(), + os, + } +} diff --git a/crates/core/src/platform/paths.rs b/crates/core/src/platform/paths.rs new file mode 100644 index 0000000..afb2a8e --- /dev/null +++ b/crates/core/src/platform/paths.rs @@ -0,0 +1,186 @@ +//! 宿主平台探测与标准目录解析。 +//! +//! 环境变量读取走 [`EnvSource`] 注入,因此三模式落点可以在单测里确定性地覆盖各 OS。 +//! 设计出处:`DevDocs/PlanDoc/04-data-and-config.md` §1(三模式落点)。 + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// 需要分支处理的宿主平台族。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + /// Windows。 + Windows, + /// macOS。 + MacOs, + /// Linux 及其它 Unix。 + Linux, + /// 其它(按 Linux 的 XDG 习惯兜底)。 + Other, +} + +impl Platform { + /// 编译目标决定的当前平台。 + #[must_use] + pub const fn current() -> Self { + if cfg!(windows) { + Self::Windows + } else if cfg!(target_os = "macos") { + Self::MacOs + } else if cfg!(unix) { + Self::Linux + } else { + Self::Other + } + } + + /// 稳定名(写日志 / 序列化用)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Windows => "windows", + Self::MacOs => "macos", + Self::Linux => "linux", + Self::Other => "other", + } + } +} + +/// 环境变量来源(生产用 [`ProcessEnv`],测试用 [`MapEnv`])。 +pub trait EnvSource { + /// 读一个环境变量;空串按不存在处理。 + fn var(&self, name: &str) -> Option; +} + +/// 真实进程环境。 +#[derive(Debug, Clone, Copy, Default)] +pub struct ProcessEnv; + +impl ProcessEnv { + /// 构造。 + #[must_use] + pub const fn new() -> Self { + Self + } +} + +impl EnvSource for ProcessEnv { + fn var(&self, name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + } +} + +/// 固定环境(测试 / 便携化封装用)。 +#[derive(Debug, Clone, Default)] +pub struct MapEnv { + vars: BTreeMap, +} + +impl MapEnv { + /// 空环境。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 链式塞一条变量:`MapEnv::new().with("HOME", "/home/u")`。 + #[must_use] + pub fn with(mut self, name: &str, value: &str) -> Self { + self.vars.insert(name.to_owned(), value.to_owned()); + self + } +} + +impl EnvSource for MapEnv { + fn var(&self, name: &str) -> Option { + self.vars.get(name).cloned() + } +} + +/// 用户主目录。 +#[must_use] +pub fn home_dir(env: &dyn EnvSource) -> Option { + if cfg!(windows) { + env.var("USERPROFILE") + .or_else(|| env.var("HOME")) + .map(PathBuf::from) + } else { + env.var("HOME").map(PathBuf::from) + } +} + +/// 可执行文件所在目录(Portable 模式的根)。 +/// +/// Linux 上 `/proc/self/exe` 由 `std` 处理;AppImage 场景下指向挂载点内的 exe, +/// 因此 AppImage 想要「便携到 exe 旁边」需由打包层设置环境变量接管(见 §1 细节)。 +#[must_use] +pub fn exe_dir() -> Option { + std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(Path::to_path_buf)) +} + +/// Standard 模式下的启动器数据目录(`.ULL` 的等价物)。 +/// +/// - Windows:`%APPDATA%\ULL` +/// - macOS:`~/Library/Application Support/ULL` +/// - Linux / 其它:`$XDG_DATA_HOME/ull`,回落 `~/.local/share/ull` +#[must_use] +pub fn standard_launcher_home(platform: Platform, env: &dyn EnvSource) -> Option { + match platform { + Platform::Windows => roaming_app_data(env).map(|dir| dir.join("ULL")), + Platform::MacOs => home_dir(env).map(|home| home.join("Library/Application Support/ULL")), + Platform::Linux | Platform::Other => xdg_data_home(env).map(|dir| dir.join("ull")), + } +} + +/// Standard 模式下的游戏目录(Mojang 约定,与 HMCL 的 HOME 目录一致)。 +/// +/// - Windows:`%APPDATA%\.minecraft` +/// - macOS:`~/Library/Application Support/minecraft` +/// - Linux / 其它:`~/.minecraft` +#[must_use] +pub fn standard_game_home(platform: Platform, env: &dyn EnvSource) -> Option { + match platform { + Platform::Windows => roaming_app_data(env).map(|dir| dir.join(".minecraft")), + Platform::MacOs => { + home_dir(env).map(|home| home.join("Library/Application Support/minecraft")) + } + Platform::Linux | Platform::Other => home_dir(env).map(|home| home.join(".minecraft")), + } +} + +/// `%APPDATA%`,回落 `%USERPROFILE%\AppData\Roaming`。 +fn roaming_app_data(env: &dyn EnvSource) -> Option { + env.var("APPDATA").map(PathBuf::from).or_else(|| { + env.var("USERPROFILE") + .map(|profile| PathBuf::from(profile).join("AppData/Roaming")) + }) +} + +/// `$XDG_DATA_HOME`,回落 `~/.local/share`。 +fn xdg_data_home(env: &dyn EnvSource) -> Option { + env.var("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|dir| dir.is_absolute()) + .or_else(|| home_dir(env).map(|home| home.join(".local/share"))) +} + +/// 写成可搬运字符串:相对路径统一用 `/`(HMCL `PortablePath` 做法),绝对路径原样保留。 +#[must_use] +pub fn to_portable_string(path: &Path) -> String { + let text = path.to_string_lossy(); + if path.is_absolute() { + text.into_owned() + } else { + text.replace('\\', "/") + } +} + +/// 解析可搬运字符串。 +#[must_use] +pub fn from_portable_string(text: &str) -> PathBuf { + PathBuf::from(text) +} diff --git a/crates/core/src/platform/receipt.rs b/crates/core/src/platform/receipt.rs new file mode 100644 index 0000000..5ea0a02 --- /dev/null +++ b/crates/core/src/platform/receipt.rs @@ -0,0 +1,202 @@ +//! 迁移收据:指纹 + 幂等,防重复迁移 / 半迁移(HMCL `MigrationReceipt` 思想)。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.8—— +//! 收据里存源文件 `sourceSHA256`,这样「用户删掉新文件、但旧文件没变」时不会重复迁移,旧文件变了才重放。 + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::atomic; +use super::error::PlatformError; +use super::error::PlatformResult; +use super::hash; + +/// 本实现能读懂的收据版本。 +pub const RECEIPT_SCHEMA_VERSION: u32 = 1; + +/// 收据类型。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReceiptKind { + /// 数据目录迁移。 + DataDirMove, + /// 遗留配置格式迁移。 + LegacyConfig, +} + +impl ReceiptKind { + /// 稳定名。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::DataDirMove => "data_dir_move", + Self::LegacyConfig => "legacy_config", + } + } +} + +/// 一次已完成的迁移记录。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MigrationReceipt { + /// 格式版本。 + pub schema_version: u32, + /// 收据类型。 + pub kind: ReceiptKind, + /// 源指纹(普通配置迁移 = 源文件内容 SHA-256;目录迁移 = 源/目标路径签名)。 + pub source_fingerprint: String, + /// 源路径。 + pub source: PathBuf, + /// 目标路径。 + pub target: PathBuf, + /// 完成时间(毫秒 since UNIX_EPOCH)。 + pub applied_at_ms: u64, +} + +impl MigrationReceipt { + /// 构造数据目录迁移收据。 + #[must_use] + pub fn data_dir_move(source: PathBuf, target: PathBuf, applied_at_ms: u64) -> Self { + let fingerprint = fingerprint_data_dir_move(&source, &target); + Self { + schema_version: RECEIPT_SCHEMA_VERSION, + kind: ReceiptKind::DataDirMove, + source_fingerprint: fingerprint, + source, + target, + applied_at_ms, + } + } + + /// 构造遗留配置迁移收据。 + #[must_use] + pub fn legacy_config( + source: PathBuf, + target: PathBuf, + source_fingerprint: impl Into, + applied_at_ms: u64, + ) -> Self { + Self { + schema_version: RECEIPT_SCHEMA_VERSION, + kind: ReceiptKind::LegacyConfig, + source_fingerprint: source_fingerprint.into(), + source, + target, + applied_at_ms, + } + } +} + +/// 是否该重放这次迁移。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplayDecision { + /// 收据在且源指纹相同 → 已经迁过,跳过(防重放)。 + SkipSameSource, + /// 收据在但源指纹变了 → 允许重放。 + ReplayChangedSource, + /// 没有收据 → 正常迁移。 + ReplayNoReceipt, +} + +impl ReplayDecision { + /// 是否需要执行迁移。 + #[must_use] + pub const fn should_replay(&self) -> bool { + !matches!(self, Self::SkipSameSource) + } + + /// 稳定名(日志 / 审计)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::SkipSameSource => "skip_same_source", + Self::ReplayChangedSource => "replay_changed_source", + Self::ReplayNoReceipt => "replay_no_receipt", + } + } +} + +/// 根据已有收据与当前源指纹决定是否重放。 +#[must_use] +pub fn decide(existing: Option<&MigrationReceipt>, fingerprint: &str) -> ReplayDecision { + match existing { + None => ReplayDecision::ReplayNoReceipt, + Some(receipt) if receipt.source_fingerprint == fingerprint => { + ReplayDecision::SkipSameSource + } + Some(_) => ReplayDecision::ReplayChangedSource, + } +} + +/// 读收据;不存在返回 `None`。 +/// +/// # Errors +/// 读失败或 JSON 损坏、版本不认识时返回 [`PlatformError::ReceiptCorrupt`] / [`PlatformError::Io`]。 +pub fn read(path: &Path) -> PlatformResult> { + let Some(bytes) = atomic::read_optional(path)? else { + return Ok(None); + }; + let receipt: MigrationReceipt = + serde_json::from_slice(&bytes).map_err(|err| PlatformError::ReceiptCorrupt { + path: path.to_path_buf(), + reason: err.to_string(), + })?; + if receipt.schema_version > RECEIPT_SCHEMA_VERSION { + return Err(PlatformError::ReceiptCorrupt { + path: path.to_path_buf(), + reason: format!( + "收据版本 {} 高于本实现支持的 {RECEIPT_SCHEMA_VERSION}", + receipt.schema_version + ), + }); + } + Ok(Some(receipt)) +} + +/// 原子写收据。 +/// +/// # Errors +/// 序列化或写盘失败时返回 [`PlatformError`]。 +pub fn write(path: &Path, receipt: &MigrationReceipt) -> PlatformResult<()> { + let bytes = + serde_json::to_vec_pretty(receipt).map_err(|err| PlatformError::ReceiptCorrupt { + path: path.to_path_buf(), + reason: format!("无法序列化收据: {err}"), + })?; + atomic::write_atomic(path, &bytes) +} + +/// 内容指纹(SHA-256 十六进制)。 +#[must_use] +pub fn fingerprint_bytes(bytes: &[u8]) -> String { + hash::sha256_hex(bytes) +} + +/// 文件内容指纹;文件不存在返回 `None`。 +/// +/// # Errors +/// 读失败时返回 [`PlatformError::Io`]。 +pub fn fingerprint_file(path: &Path) -> PlatformResult> { + match hash::sha256_hex_file(path) { + Ok(hex) => Ok(Some(hex)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(PlatformError::Io { + path: path.to_path_buf(), + source: err, + }), + } +} + +/// 目录迁移的源指纹:源/目标路径签名。 +/// +/// 目录内容的哈希代价太高且天然易变(缓存/日志一直在动),因此目录迁移的抗重放主力是 +/// [`super::redirect`] 指针 + 「目标非空则拒绝」,收据负责留痕与「同源同目标不重做」。 +#[must_use] +pub fn fingerprint_data_dir_move(source: &Path, target: &Path) -> String { + let signature = format!( + "{}|{}", + super::paths::to_portable_string(source), + super::paths::to_portable_string(target) + ); + fingerprint_bytes(signature.as_bytes()) +} diff --git a/crates/core/src/platform/redirect.rs b/crates/core/src/platform/redirect.rs new file mode 100644 index 0000000..aa16a2f --- /dev/null +++ b/crates/core/src/platform/redirect.rs @@ -0,0 +1,137 @@ +//! `redirect.json` 指针文件——迁移后留在旧目录,防止便携盘被搬走后旧目录重新长出一个新实例。 +//! +//! 出处:`DevDocs/PlanDoc/04-data-and-config.md` §1 细节(「迁移 = 移动 + 原地留 redirect.json 指针」)。 + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::atomic; +use super::data_dir::DataMode; +use super::error::{io_at, PlatformError, PlatformResult}; +use super::layout::REDIRECT_FILE; + +/// 本实现能读懂的指针文件版本;更高版本视为不认识(避免瞎读新格式)。 +pub const REDIRECT_SCHEMA_VERSION: u32 = 1; + +/// 指针内容:旧目录 → 新目录。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RedirectPointer { + /// 格式版本。 + pub schema_version: u32, + /// 迁移时采用的模式。 + pub mode: DataMode, + /// 新的启动器数据目录。 + pub launcher_home: PathBuf, + /// 新的游戏目录。 + pub game_home: PathBuf, + /// 迁移时间(毫秒 since UNIX_EPOCH)。 + pub migrated_at_ms: u64, +} + +/// 跟随指针的结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Followed { + /// 最终命中的指针。 + pub pointer: RedirectPointer, + /// 实际跟随跳数(1 = 只跟了一跳)。 + pub hops: usize, +} + +/// `/redirect.json`。 +#[must_use] +pub fn redirect_path(dir: &Path) -> PathBuf { + dir.join(REDIRECT_FILE) +} + +/// 读指针;没有指针文件返回 `None`。 +/// +/// # Errors +/// 读失败或 JSON 损坏、版本不认识时返回 [`PlatformError`]。 +pub fn read(dir: &Path) -> PlatformResult> { + let path = redirect_path(dir); + let Some(bytes) = atomic::read_optional(&path)? else { + return Ok(None); + }; + let pointer: RedirectPointer = + serde_json::from_slice(&bytes).map_err(|err| PlatformError::RedirectCorrupt { + path: path.clone(), + reason: err.to_string(), + })?; + if pointer.schema_version > REDIRECT_SCHEMA_VERSION { + return Err(PlatformError::RedirectCorrupt { + path, + reason: format!( + "指针版本 {} 高于本实现支持的 {REDIRECT_SCHEMA_VERSION}", + pointer.schema_version + ), + }); + } + Ok(Some(pointer)) +} + +/// 原子写指针(父目录自动创建)。 +/// +/// # Errors +/// 序列化或写盘失败时返回 [`PlatformError`]。 +pub fn write(dir: &Path, pointer: &RedirectPointer) -> PlatformResult { + let path = redirect_path(dir); + let bytes = + serde_json::to_vec_pretty(pointer).map_err(|err| PlatformError::RedirectCorrupt { + path: path.clone(), + reason: format!("无法序列化指针: {err}"), + })?; + std::fs::create_dir_all(dir).map_err(io_at(dir))?; + atomic::write_atomic(&path, &bytes)?; + Ok(path) +} + +/// 删除指针(`None` = 本来就没有)。 +/// +/// # Errors +/// 删除失败时返回 [`PlatformError::Io`]。 +pub fn remove(dir: &Path) -> PlatformResult { + let path = redirect_path(dir); + match std::fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(PlatformError::Io { path, source: err }), + } +} + +/// 跟随指针链(最多 `max_hops` 跳,成环或超限报错)。 +/// +/// # Errors +/// 指针损坏或跳转成环/超限时返回 [`PlatformError`]。 +pub fn follow(dir: &Path, max_hops: usize) -> PlatformResult> { + let mut current = dir.to_path_buf(); + let mut visited: BTreeSet = BTreeSet::new(); + visited.insert(current.clone()); + let mut hops = 0usize; + + loop { + let Some(pointer) = read(¤t)? else { + return Ok(None); + }; + hops += 1; + if hops > max_hops { + return Err(PlatformError::RedirectCycle { + dir: dir.to_path_buf(), + hops, + }); + } + let next = pointer.launcher_home.clone(); + if !visited.insert(next.clone()) { + return Err(PlatformError::RedirectCycle { + dir: dir.to_path_buf(), + hops, + }); + } + // 还有下一跳就继续跟,否则这就是终点。 + if !redirect_path(&next).exists() { + return Ok(Some(Followed { pointer, hops })); + } + current = next; + } +} diff --git a/crates/core/src/platform/theme_probe.rs b/crates/core/src/platform/theme_probe.rs new file mode 100644 index 0000000..e1216de --- /dev/null +++ b/crates/core/src/platform/theme_probe.rs @@ -0,0 +1,182 @@ +//! 系统主题(浅色/深色)探测:给主题三态的 `auto` 模式做**初始化对齐**与后端不支持时的兜底跟随。 +//! +//! 出处:`DevDocs/PlanDoc/09-ui-layout.md` §2「主题模式三态」(D17 用户定案:Win/mac 用 Slint +//! `Palette.color-scheme` 原生跟随 OS 热切;Linux 依 DE 的 xdg-desktop-portal,缺省回落浅色并 +//! 允许手动钉死)。Slint 后端会在运行期自己监听 OS 主题变化,所以这里**只补两件事**: +//! +//! 1. 启动时先读一次真实系统主题,避免「auto 模式下首帧按浅色画、几百毫秒后突然翻黑」的闪变; +//! 2. 后端把 `Palette.color-scheme` 报成 `unknown`(无 portal 的 Linux / 软渲染后端)时, +//! 由 app 轮询本模块把系统主题补上,`auto` 仍然可用。 +//! +//! 与 platform 层其他探测一致(见 [`super::windows_version`]):core 是 `#![forbid(unsafe_code)]`, +//! 一律走外部命令 + 纯函数解析。解析函数跨平台编译,任意宿主都能单测(`tests/platform/theme_cases.rs`)。 + +/// 系统主题三态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SystemTheme { + /// 浅色。 + Light, + /// 深色。 + Dark, + /// 读不到(无桌面环境 / 命令缺失 / 输出无法解析)。 + Unknown, +} + +impl SystemTheme { + /// 稳定名(日志用)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Light => "light", + Self::Dark => "dark", + Self::Unknown => "unknown", + } + } + + /// 是否深色(`Unknown` 为 `None`,调用方决定回落)。 + #[must_use] + pub const fn is_dark(&self) -> Option { + match self { + Self::Light => Some(false), + Self::Dark => Some(true), + Self::Unknown => None, + } + } +} + +/// Windows 个性化键(`AppsUseLightTheme` = 应用/任务栏主题,与 `SystemUsesLightTheme` 区分)。 +pub const WINDOWS_PERSONALIZE_KEY: &str = + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; + +/// 真实探测系统主题。 +/// +/// - Windows:注册表 `AppsUseLightTheme`(DWORD,1 = 浅色 / 0 = 深色); +/// - macOS:`defaults read -g AppleInterfaceStyle`(深色时输出 `Dark`,浅色时该键不存在); +/// - Linux:`gsettings get org.gnome.desktop.interface color-scheme`,退到 `gtk-theme` 名字。 +#[must_use] +pub fn probe() -> SystemTheme { + #[cfg(target_os = "windows")] + { + run( + "reg", + &["query", WINDOWS_PERSONALIZE_KEY, "/v", "AppsUseLightTheme"], + ) + .and_then(|text| parse_apps_use_light_theme(&text)) + .unwrap_or(SystemTheme::Unknown) + } + #[cfg(target_os = "macos")] + { + // 键不存在 = 浅色(defaults 会非零退出,stdout 为空 → 回落 Light)。 + match run("defaults", &["read", "-g", "AppleInterfaceStyle"]) { + Some(text) => parse_macos_interface_style(&text).unwrap_or(SystemTheme::Unknown), + None => SystemTheme::Light, + } + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + probe_linux() + } +} + +/// Linux:先问 GNOME/portal 的 `color-scheme`,再退到 `gtk-theme` 名字里是否带 `-dark`。 +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn probe_linux() -> SystemTheme { + if let Some(text) = run( + "gsettings", + &["get", "org.gnome.desktop.interface", "color-scheme"], + ) { + if let Some(theme) = parse_gsettings_color_scheme(&text) { + return theme; + } + } + if let Some(text) = run( + "gsettings", + &["get", "org.gnome.desktop.interface", "gtk-theme"], + ) { + if let Some(theme) = parse_gtk_theme(&text) { + return theme; + } + } + SystemTheme::Unknown +} + +/// 解析 `reg query ... /v AppsUseLightTheme` 的输出。 +/// +/// 形如 ` AppsUseLightTheme REG_DWORD 0x1`;`0x0` = 深色。 +#[must_use] +pub fn parse_apps_use_light_theme(text: &str) -> Option { + text.lines().find_map(|line| { + let mut fields = line.split_whitespace(); + let name = fields.next()?; + if !name.eq_ignore_ascii_case("AppsUseLightTheme") { + return None; + } + let _kind = fields.next()?; + let raw = fields.next().unwrap_or_default(); + parse_dword(raw).map(|value| { + if value == 0 { + SystemTheme::Dark + } else { + SystemTheme::Light + } + }) + }) +} + +/// 解析 `defaults read -g AppleInterfaceStyle` 的输出(深色 = `Dark`;浅色时该键不存在)。 +#[must_use] +pub fn parse_macos_interface_style(text: &str) -> Option { + match text.trim() { + "" => None, + value if value.eq_ignore_ascii_case("dark") => Some(SystemTheme::Dark), + _ => Some(SystemTheme::Light), + } +} + +/// 解析 `gsettings get ... color-scheme`:`'prefer-dark'` / `'prefer-light'` / `'default'`。 +/// +/// `default` 表示「未指定」,要继续看 `gtk-theme`,故返回 `None`。 +#[must_use] +pub fn parse_gsettings_color_scheme(text: &str) -> Option { + let value = text.trim().trim_matches('\'').trim_matches('"'); + if value.eq_ignore_ascii_case("prefer-dark") { + Some(SystemTheme::Dark) + } else if value.eq_ignore_ascii_case("prefer-light") { + Some(SystemTheme::Light) + } else { + None + } +} + +/// 解析 `gsettings get ... gtk-theme`:主题名带 `dark` 即深色(`Yaru-dark`、`Adwaita-dark`)。 +#[must_use] +pub fn parse_gtk_theme(text: &str) -> Option { + let value = text.trim().trim_matches('\'').trim_matches('"'); + if value.is_empty() { + return None; + } + if value.to_ascii_lowercase().contains("dark") { + Some(SystemTheme::Dark) + } else { + Some(SystemTheme::Light) + } +} + +fn parse_dword(raw: &str) -> Option { + let raw = raw.trim(); + if let Some(hex) = raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) { + u32::from_str_radix(hex, 16).ok() + } else { + raw.parse().ok() + } +} + +/// 跑一个外部命令,取 stdout(命令缺失 / 非零退出 / 空输出都返回 `None`)。 +fn run(program: &str, args: &[&str]) -> Option { + use std::process::Command; + let output = Command::new(program).args(args).output().ok()?; + if output.stdout.is_empty() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} diff --git a/crates/core/src/platform/vk_probe.rs b/crates/core/src/platform/vk_probe.rs new file mode 100644 index 0000000..25a817e --- /dev/null +++ b/crates/core/src/platform/vk_probe.rs @@ -0,0 +1,305 @@ +//! Vulkan 能力探测的**安全层**(无 `unsafe`,无 FFI):只报可观测事实,不下「能不能玩」的结论。 +//! +//! 依据 `DevDocs/PlanDoc/research/05-mc-vulkan.md`(M26)§2.3 与 §4.3: +//! MC 的 Vulkan 后端在 Windows/Linux 上**不打包** `vulkan-1.dll` / `libvulkan.so.1`, +//! 而是运行期去加载系统的 loader(natives 只给 macOS 发 MoltenVK)。所以「版本支持 Vulkan」 +//! 与「这台机器能跑 Vulkan」是两件事,前置探测不可省。 +//! +//! 分工照抄 [`super::linux_caps::probe_gl`]:core 只做「文件存在性 / 注册表 / 环境变量 / +//! 标准目录」这一层,真探测(建 instance、枚举物理设备、查 `VK 1.2` + +//! `VK_KHR_dynamic_rendering` + `VK_KHR_push_descriptor` 三项硬门槛)归 app 层。 +//! +//! # ⚠️ 注册表键缺失 ≠ 没有 Vulkan(真机证据) +//! +//! 实测机(Windows 11 + AMD 独显,Vulkan 1.3.217 可用)上 +//! `HKLM\SOFTWARE\Khronos\Vulkan\Drivers` **压根不存在**——ICD 由 loader 自己按 +//! `%SystemRoot%\System32` 与驱动安装目录自寻。因此该键只作**加分项**([`VkProbe::Available`]), +//! 缺失时降级为 [`VkProbe::LoaderOnly`]、**绝不**判 [`VkProbe::Missing`]。 +//! +//! 解析函数是纯函数且跨平台编译,单测用注入样本覆盖三平台(见 `tests/platform/vk_cases.rs`)。 + +use std::path::{Path, PathBuf}; + +use crate::platform::os_info::OsFamily; + +/// Windows 上的 Vulkan loader 文件名(系统目录内)。 +pub const WINDOWS_LOADER: &str = "vulkan-1.dll"; + +/// `HKLM\SOFTWARE\Khronos\Vulkan\Drivers`:显式 ICD 注册键(**只作加分**,见模块文档)。 +/// +/// ICD 注册为这个键下的**值**(值名 = manifest 绝对路径,数据是 `dword:00000000`), +/// 不是子键(Vulkan-Loader `LoaderDriverInterface.md` 的 Windows 驱动发现一节)。 +pub const VULKAN_DRIVERS_KEY: &str = r"HKLM\SOFTWARE\Khronos\Vulkan\Drivers"; + +/// 同一个键在 `reg query` 输出里的**键头行**形态。 +/// +/// `reg query` 即使传入缩写 hive 名(`HKLM`),输出也用完整 hive 名 +/// (`HKEY_LOCAL_MACHINE`)且不带尾反斜杠——解析时必须按这个形态比对。 +pub const VULKAN_DRIVERS_KEY_HEADER: &str = r"HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\Drivers"; + +/// Khronos loader 认的 ICD 路径环境变量(旧名在前,新名在后)。 +pub const ICD_ENV_VARS: &[&str] = &["VK_ICD_FILENAMES", "VK_DRIVER_FILES"]; + +/// Linux 的 ICD 清单目录(`*.json`,loader 启动时按此枚举驱动)。 +pub const ICD_DIRS: &[&str] = &[ + "/usr/share/vulkan/icd.d", + "/usr/local/share/vulkan/icd.d", + "/etc/vulkan/icd.d", +]; + +/// Vulkan 能力(四档;`Unknown` 是派生默认值,「没探过」不等于「没有」)。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum VkProbe { + /// 未探测(非探测路径构造的快照)。 + #[default] + Unknown, + /// loader 在,但没有看到任何 ICD 注册信号。 + /// + /// **不是**「没有 Vulkan」:Windows 上注册表键缺失是常态(模块文档的真机证据), + /// 设备是否存在要靠 app 层的真探测确认。 + LoaderOnly, + /// loader + 驱动信号都在(或 macOS 上 MoltenVK 在场)——大概率可用。 + Available, + /// 连 loader 都找不到:Vulkan 后端一定起不来。 + Missing, +} + +impl VkProbe { + /// 简短名(日志/摘要行用)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::LoaderOnly => "loader_only", + Self::Available => "available", + Self::Missing => "missing", + } + } + + /// loader 是否在场(`Available` 与 `LoaderOnly` 都为真)。 + #[must_use] + pub const fn has_loader(self) -> bool { + matches!(self, Self::Available | Self::LoaderOnly) + } + + /// 是否已能确定「这台机器跑不了 Vulkan」(只有 [`Self::Missing`])。 + #[must_use] + pub const fn is_missing(self) -> bool { + matches!(self, Self::Missing) + } + + /// 是否足够确定「可以用 Vulkan」(只有 [`Self::Available`])。 + /// + /// UI/建议路径用它可以保守一点:`LoaderOnly` 应当提示「先试一次再说」而不是承诺可用。 + #[must_use] + pub const fn is_available(self) -> bool { + matches!(self, Self::Available) + } +} + +/// 平台无关的探测证据(单测直接注入,不读真机)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VkEvidence { + /// 系统家族(决定判定口径)。 + pub family: OsFamily, + /// loader 文件是否找到(`vulkan-1.dll` / `libvulkan.so.1` / `libvulkan.1.dylib` / MoltenVK)。 + pub loader_present: bool, + /// 是否看到驱动信号:Windows 注册表键、Linux 的 ICD 目录/环境变量、macOS 无此项。 + pub icd_signal: bool, +} + +impl VkEvidence { + /// 构造。 + #[must_use] + pub const fn new(family: OsFamily, loader_present: bool, icd_signal: bool) -> Self { + Self { + family, + loader_present, + icd_signal, + } + } +} + +/// 证据 → 结论(纯函数,三平台共用;macOS 单独口径见函数体注释)。 +#[must_use] +pub fn classify(evidence: VkEvidence) -> VkProbe { + if evidence.family == OsFamily::MacOs { + // macOS 没有系统 Vulkan:LWJGL 自带的 MoltenVK 既是 loader 又是 ICD(Vulkan→Metal 翻译), + // 所以「MoltenVK/libvulkan.1.dylib 在」本身就是可用证据,没有 LoaderOnly 这一档。 + return if evidence.loader_present { + VkProbe::Available + } else { + VkProbe::Missing + }; + } + if !evidence.loader_present { + return VkProbe::Missing; + } + if evidence.icd_signal { + VkProbe::Available + } else { + // 注册表键缺失是常态,只降级不否定(模块文档的真机证据)。 + VkProbe::LoaderOnly + } +} + +/// 探测当前机器的 Vulkan 能力(一次性,结果由 [`crate::platform::os_info::current`] 缓存)。 +#[must_use] +pub fn probe_vk() -> VkProbe { + classify(probe_evidence()) +} + +/// 采集当前机器的探测证据(真读盘/注册表;单测请直接用 [`classify`])。 +#[must_use] +pub fn probe_evidence() -> VkEvidence { + let family = OsFamily::current(); + VkEvidence::new(family, loader_present(family), icd_signal(family)) +} + +/// loader 候选绝对路径(按平台;`SystemRoot` 缺失时回落到 `C:\Windows`)。 +#[must_use] +pub fn loader_candidates(family: OsFamily) -> Vec { + match family { + OsFamily::Windows => { + let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_owned()); + vec![Path::new(&root).join("System32").join(WINDOWS_LOADER)] + } + OsFamily::MacOs => [ + "/usr/local/lib/libvulkan.1.dylib", + "/opt/homebrew/lib/libvulkan.1.dylib", + "/usr/local/lib/libvulkan.dylib", + "/opt/homebrew/lib/libvulkan.dylib", + // MC 的 lwjgl-vulkan natives 里带的就是这个。 + "/usr/local/lib/libMoltenVK.dylib", + "/opt/homebrew/lib/libMoltenVK.dylib", + ] + .iter() + .map(PathBuf::from) + .collect(), + OsFamily::Linux => [ + "/usr/lib/x86_64-linux-gnu/libvulkan.so.1", + "/usr/lib/aarch64-linux-gnu/libvulkan.so.1", + "/usr/lib64/libvulkan.so.1", + "/usr/lib/libvulkan.so.1", + "/usr/local/lib/libvulkan.so.1", + // 开发包只给不带版本号的名字(`libvulkan.so`)。 + "/usr/lib/x86_64-linux-gnu/libvulkan.so", + "/usr/lib64/libvulkan.so", + "/usr/local/lib/libvulkan.so", + ] + .iter() + .map(PathBuf::from) + .collect(), + } +} + +fn loader_present(family: OsFamily) -> bool { + if loader_candidates(family).iter().any(|path| path.is_file()) { + return true; + } + // 标准路径没命中时,Linux 用 `ldconfig -p` 兜底(发行版把 loader 放在别处时更权威)。 + #[cfg(target_os = "linux")] + { + ldconfig_has_libvulkan(&run("ldconfig", &["-p"]).unwrap_or_default()) + } + #[cfg(not(target_os = "linux"))] + { + false + } +} + +fn icd_signal(family: OsFamily) -> bool { + match family { + OsFamily::Windows => { + let text = run("reg", &["query", VULKAN_DRIVERS_KEY]).unwrap_or_default(); + registry_has_driver_entry(&text) + } + OsFamily::Linux => { + env_icd_path(|key| std::env::var(key).ok()).is_some() + || ICD_DIRS + .iter() + .any(|dir| count_icd_manifests(Path::new(dir)) > 0) + } + // macOS 的 ICD 就是 MoltenVK 本身,已在 loader 判据里。 + OsFamily::MacOs => false, + } +} + +/// 从环境变量取 ICD 清单路径(`VK_ICD_FILENAMES` 优先,其次 `VK_DRIVER_FILES`)。 +#[must_use] +pub fn env_icd_path(get: impl Fn(&str) -> Option) -> Option { + ICD_ENV_VARS.iter().find_map(|key| { + get(key) + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + }) +} + +/// 目录下 `*.json` ICD 清单的条数(目录不存在返回 0)。 +#[must_use] +pub fn count_icd_manifests(dir: &Path) -> usize { + let Ok(entries) = std::fs::read_dir(dir) else { + return 0; + }; + entries + .flatten() + .filter(|entry| { + entry + .path() + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("json")) + }) + .count() +} + +/// `reg query <键>`(**不带** `/s`)的输出里,该键是否存在**值项**。 +/// +/// 真实格式(本机实测,Win11 22H2):一个空行 → 键头行(顶格、用完整 hive 名、无尾反斜杠) +/// → 缩进的值行 ` <值名> REG_DWORD 0x0`。Vulkan 的 ICD 注册正是「**值名 = manifest +/// 绝对路径**」(`"C:\vendor\vk.json"=dword:00000000`,见 Vulkan-Loader +/// `LoaderDriverInterface.md` 的 Windows 驱动发现一节),不是子键。 +/// +/// 所以判据是「键头行 + 其后至少一条带 `REG_` 类型标记的缩进行」:键存在但没有任何值时 +/// 只有键头行;子键在没有 `/s` 时也缩进打印,但**不带** `REG_` 标记,据此区分。键不存在时 +/// 输出只有本地化错误行(`ERROR: The system was unable to find…` / +/// `错误: 系统找不到指定的注册表项或值。`),既无键头也无数值行。 +/// +/// 注:值名可能含空格(manifest 绝对路径),所以不能按列切分,只能按 token 找类型标记。 +#[must_use] +pub fn registry_has_driver_entry(text: &str) -> bool { + let mut in_key = false; + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if !line.starts_with([' ', '\t']) { + // 顶格行 = 键头行(或错误行);遇到别的键就重置状态。 + in_key = trimmed + .trim_end_matches('\\') + .eq_ignore_ascii_case(VULKAN_DRIVERS_KEY_HEADER); + continue; + } + if in_key && trimmed.split_whitespace().any(|t| t.starts_with("REG_")) { + return true; + } + } + false +} + +/// `ldconfig -p` 输出里是否列了 `libvulkan.so.1`。 +#[must_use] +pub fn ldconfig_has_libvulkan(text: &str) -> bool { + text.lines() + .any(|line| line.contains("libvulkan.so.1") || line.contains("libvulkan.so ")) +} + +/// 跑一个只读命令取 stdout(`reg query` / `ldconfig -p`;失败返回 `None`)。 +fn run(program: &str, args: &[&str]) -> Option { + use std::process::Command; + let output = Command::new(program).args(args).output().ok()?; + if output.stdout.is_empty() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} diff --git a/crates/core/src/platform/windows_version.rs b/crates/core/src/platform/windows_version.rs new file mode 100644 index 0000000..bc9fd9b --- /dev/null +++ b/crates/core/src/platform/windows_version.rs @@ -0,0 +1,215 @@ +//! Windows 版本探测:真实的 `major.minor.build`(D19 · `PlanDoc/11-os-detection.md` §2)。 +//! +//! 数据源(从快到慢,探测发生在启动路径上): +//! +//! 1. `reg query ... /v CurrentBuildNumber` 取 build 号,由 [`from_build`] 反推 `major.minor` +//! (build 号在版本族内单调;实测单值查询约 50ms,整键查询约 150ms); +//! 2. 整键 `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion`:Win10+ 有 +//! `CurrentMajorVersionNumber` / `CurrentMinorVersionNumber`(REG_DWORD,`reg query` 以 +//! `0x..` 十六进制输出),Win7 / 8 / 8.1 只有 `CurrentVersion`(`6.1` / `6.2` / `6.3`, +//! Win10+ 上它是过期值); +//! 3. 兜底 `cmd /c ver`:输出 `Microsoft Windows [Version 10.0.19045.4046]`(中文系统为 +//! `[版本 …]`)。只取方括号内的数字段,因此与系统语言无关。 +//! +//! # 为什么不用 `RtlGetVersion` +//! +//! D19 原文方案是 `RtlGetVersion`(ntdll),但 core 是 `#![forbid(unsafe_code)]` +//! (`lib.rs`,D3 架构纪律 + workspace lint `unsafe_code = deny`),FFI 一律不可用。 +//! 上面两条路径读的是同一份真实事实(`cmd.exe` 自带 manifest,报真实版本,不受 +//! `GetVersionEx` 兼容层欺骗),差别只是启动时一次性几毫秒 —— 结果由 +//! [`crate::platform::os_info::current`] 缓存,不会重复付这个代价。 +//! +//! 解析函数是纯函数且**跨平台编译**,单测用注入样本覆盖各版本输出(见 `tests/platform/`)。 + +/// Windows 11 的起始 build 号(22H2 之前就有 22000)。 +pub const WIN11_MIN_BUILD: u32 = 22_000; + +/// 存放版本值的注册表键。 +pub const VERSION_KEY: &str = r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion"; + +/// Windows 真实版本。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowsVersion { + /// 主版本号(Win7 = 6,Win10/11 = 10)。 + pub major: u32, + /// 次版本号(Win7 = 1,Win10/11 = 0)。 + pub minor: u32, + /// 构建号(Win7 SP1 = 7601,Win10 22H2 = 19045,Win11 = 22000+)。 + pub build: u32, +} + +impl WindowsVersion { + /// 构造。 + pub fn new(major: u32, minor: u32, build: u32) -> Self { + Self { + major, + minor, + build, + } + } + + /// `major.minor` 元组(最低版本判定用)。 + pub fn major_minor(&self) -> (u32, u32) { + (self.major, self.minor) + } + + /// 版本串(`rules.os.version` 用,例如 `10.0.19045`)。 + pub fn version_string(&self) -> String { + format!("{}.{}.{}", self.major, self.minor, self.build) + } + + /// 产品名(由版本号推导,不用本地化的注册表 `ProductName`)。 + pub fn display_name(&self) -> &'static str { + match self.major_minor() { + (10, _) if self.build >= WIN11_MIN_BUILD => "Windows 11", + (10, _) => "Windows 10", + (6, 3) => "Windows 8.1", + (6, 2) => "Windows 8", + (6, 1) => "Windows 7", + (6, 0) => "Windows Vista", + (5, _) => "Windows XP", + _ => "Windows", + } + } +} + +/// 真实探测;非 Windows 平台恒为 `None`。 +/// +/// 三档,从快到慢(探测在启动路径上,实测整键 `reg query` 会枚举上百个子键,约 150ms: +/// 单值查询约 50ms): +/// 1. `reg query ... /v CurrentBuildNumber` 取 build,再由 [`from_build`] 反推 `major.minor`; +/// 2. 整键查询(Win7 的 `CurrentVersion`、Win10+ 的数字键都在这里解析,见 [`parse_reg_query`]); +/// 3. `cmd /c ver`(注册表被策略禁用时的兜底)。 +#[cfg(target_os = "windows")] +pub fn probe() -> Option { + let fast = run("reg", &["query", VERSION_KEY, "/v", "CurrentBuildNumber"]) + .and_then(|text| parse_build_number(&text)) + .and_then(from_build); + if fast.is_some() { + return fast; + } + if let Some(version) = + run("reg", &["query", VERSION_KEY]).and_then(|text| parse_reg_query(&text)) + { + return Some(version); + } + run("cmd", &["/c", "ver"]).and_then(|text| parse_ver_output(&text)) +} + +/// 真实探测;非 Windows 平台恒为 `None`。 +#[cfg(not(target_os = "windows"))] +pub fn probe() -> Option { + None +} + +/// 由 build 号反推 `major.minor`(build 号在版本族内单调,是唯一一条免整键查询的快路径)。 +/// +/// 7600/7601 = 6.1(Win7 / 2008 R2)、9200 = 6.2(Win8 / 2012)、9600 = 6.3(Win8.1 / 2012 R2)、 +/// 10240+ = 10.0(Win10 / Win11 / Server 2016+)。认不出的 build 返回 `None`, +/// 由 [`parse_reg_query`] / [`parse_ver_output`] 兜底。 +pub fn from_build(build: u32) -> Option { + let (major, minor) = match build { + 6000..=7599 => (6, 0), + 7600..=9199 => (6, 1), + 9200..=9599 => (6, 2), + 9600..=10239 => (6, 3), + 10240.. => (10, 0), + // Vista 之前的 build 号太小,不猜(交给整键/`ver` 解析)。 + _ => return None, + }; + Some(WindowsVersion::new(major, minor, build)) +} + +/// 从 `reg query ... /v CurrentBuildNumber` 的输出取 build 号。 +pub fn parse_build_number(text: &str) -> Option { + text.lines().find_map(|line| { + let mut fields = line.split_whitespace(); + let name = fields.next()?; + if !name.eq_ignore_ascii_case("currentbuildnumber") { + return None; + } + let _kind = fields.next(); + parse_number(fields.next().unwrap_or_default()) + }) +} + +/// 解析 `reg query ` 的输出。 +/// +/// 形如 ` CurrentBuildNumber REG_SZ 19045`;DWORD 值以 `0x..` 输出。 +pub fn parse_reg_query(text: &str) -> Option { + let mut major = None; + let mut minor = None; + let mut build = None; + let mut legacy = None; + for line in text.lines() { + let mut fields = line.split_whitespace(); + let Some(name) = fields.next() else { + continue; + }; + let _kind = fields.next(); + let value = fields.next().unwrap_or_default(); + match name.to_ascii_lowercase().as_str() { + "currentmajorversionnumber" => major = parse_number(value), + "currentminorversionnumber" => minor = parse_number(value), + "currentbuildnumber" => build = parse_number(value), + "currentversion" => legacy = parse_version_pair(value), + _ => {} + } + } + let build = build?; + let (major, minor) = match major { + Some(major) => (major, minor.unwrap_or(0)), + // Win7 / 8 / 8.1:没有 CurrentMajorVersionNumber,只能读 CurrentVersion。 + None => legacy?, + }; + Some(WindowsVersion::new(major, minor, build)) +} + +/// 解析 `cmd /c ver` 的输出(`Microsoft Windows [Version 10.0.19045.4046]`, +/// 中文系统 `Microsoft Windows [版本 10.0.19045.4046]`)。 +pub fn parse_ver_output(text: &str) -> Option { + let start = text.find('[')? + 1; + let rest = &text[start..]; + let inner = &rest[..rest.find(']')?]; + // 只保留数字与点,语言相关的字样(`Version` / `版本`)会被切成空段。 + let digits = inner + .split(|c: char| !(c.is_ascii_digit() || c == '.')) + .find(|segment| segment.contains('.'))?; + let parts: Vec = digits + .split('.') + .take(3) + .map(str::parse) + .collect::>() + .ok()?; + match parts.as_slice() { + [major, minor, build] => Some(WindowsVersion::new(*major, *minor, *build)), + _ => None, + } +} + +fn parse_number(value: &str) -> Option { + let trimmed = value.trim(); + if let Some(hex) = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + { + u32::from_str_radix(hex, 16).ok() + } else { + trimmed.parse().ok() + } +} + +fn parse_version_pair(value: &str) -> Option<(u32, u32)> { + let (major, minor) = value.trim().split_once('.')?; + Some((major.parse().ok()?, minor.parse().ok()?)) +} + +#[cfg(target_os = "windows")] +fn run(program: &str, args: &[&str]) -> Option { + use std::process::Command; + let output = Command::new(program).args(args).output().ok()?; + if output.stdout.is_empty() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} diff --git a/crates/core/src/setting/appearance.rs b/crates/core/src/setting/appearance.rs new file mode 100644 index 0000000..704c3ac --- /dev/null +++ b/crates/core/src/setting/appearance.rs @@ -0,0 +1,226 @@ +//! 「外观(高级)」配置簇的读写(D17 · `09-ui-layout.md` §2 G6 + §111 约束)。 +//! +//! 为什么放在 core:主题令牌的**取值与校验**是配置事实,视觉实现(Slint 侧)只消费结果。 +//! 分层纪律要求 core 零 UI 依赖——本文件只认 `appearance.*` 这些键与它们的取值形状, +//! 不认任何像素/控件([`super::keys`] 登记同一份键表,两边由 `tests/setting/appearance_keys.rs` 固定)。 +//! +//! 设计约束(09 §111):一切高级特性**默认关闭**(出厂 Metro 纯色);全部走 D6 schema +//! (AI 可写白名单内,可被 agent 调节)。因此默认值必须等于「什么都没开」的出厂态: +//! `panel_alpha = 1.0`、`glass = false`、`bg_image_enabled = false`、`accent` 空串(= 出厂 accent)。 + +use serde_json::Value; + +use super::document::SettingDocument; +use super::store::SettingStore; + +/// `appearance.bg_fill` 的允许值(09 §101:cover / contain / tile)。**顺序即 UI 分段控件的顺序**。 +pub const BG_FILLS: &[&str] = &["cover", "contain", "tile"]; + +/// 出厂强调色(Metro 蓝,D14);`appearance.accent_color` 为空串时用它。 +pub const DEFAULT_ACCENT: &str = "#0078D4"; + +/// `appearance.panel_alpha` 下限:再低前景就压不住背景图(09 §111「对比度检查提示」的硬底)。 +pub const PANEL_ALPHA_MIN: f64 = 0.4; +/// `appearance.bg_dim` 下限(0 = 不暗化,允许但设置页会提示对比度风险)。 +pub const BG_DIM_MIN: f64 = 0.0; +/// `appearance.bg_dim` 上限:再高背景图基本看不见,等于关掉。 +pub const BG_DIM_MAX: f64 = 0.95; + +/// 背景图填充模式。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BgFill { + /// 等比铺满、裁切溢出(默认:不露底色) + #[default] + Cover, + /// 等比完整显示、留底色 + Contain, + /// 原始尺寸平铺 + Tile, +} + +impl BgFill { + /// 落盘取值。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Cover => "cover", + Self::Contain => "contain", + Self::Tile => "tile", + } + } + + /// 从落盘取值解析;未知值按 `cover` 兜底(手改坏配置也不崩)。 + #[must_use] + pub fn from_key_value(value: &str) -> Self { + match value { + "contain" => Self::Contain, + "tile" => Self::Tile, + _ => Self::Cover, + } + } +} + +/// 外观(高级)配置簇:**只是取值快照**,读写都走 [`super::store::SettingStore`]。 +#[derive(Debug, Clone, PartialEq)] +pub struct Appearance { + /// 主题三态:`auto` / `light` / `dark`(与 `prefs::ThemeMode` 同名同值)。 + pub theme_mode: String, + /// 自定义窗口背景图开关(默认关)。 + pub bg_image_enabled: bool, + /// 背景图路径(PNG;空 = 未设置)。 + pub bg_image: String, + /// 背景图填充模式。 + pub bg_fill: BgFill, + /// 叠加暗化层强度 0.0~0.95(越大前景越清晰)。 + pub bg_dim: f64, + /// 全局面板半透明度 0.4~1.0(1.0 = 出厂不透明)。 + pub panel_alpha: f64, + /// 玻璃拟态卡片(预生成模糊背景层)。 + pub glass: bool, + /// 自定义强调色 `#RRGGBB`(空串 = 出厂 [`DEFAULT_ACCENT`])。 + pub accent_color: String, +} + +impl Default for Appearance { + fn default() -> Self { + Self { + theme_mode: "auto".to_owned(), + bg_image_enabled: false, + bg_image: String::new(), + bg_fill: BgFill::Cover, + bg_dim: 0.35, + panel_alpha: 1.0, + glass: false, + accent_color: String::new(), + } + } +} + +impl Appearance { + /// 从配置文档读一份快照(文件值 → 默认值;坏值按默认值兜底,绝不 panic)。 + #[must_use] + pub fn read(doc: &SettingDocument) -> Self { + let default = Self::default(); + let text = |key: &str| doc.effective_str(key).map(str::to_owned); + let boolean = |key: &str| doc.effective(key).and_then(Value::as_bool); + let number = |key: &str| doc.effective(key).and_then(Value::as_f64); + + Self { + theme_mode: text("appearance.theme_mode").unwrap_or(default.theme_mode), + bg_image_enabled: boolean("appearance.bg_image_enabled").unwrap_or(false), + bg_image: text("appearance.bg_image").unwrap_or_default(), + bg_fill: text("appearance.bg_fill") + .map_or(default.bg_fill, |value| BgFill::from_key_value(&value)), + bg_dim: number("appearance.bg_dim") + .map_or(default.bg_dim, |value| clamp(value, BG_DIM_MIN, BG_DIM_MAX)), + panel_alpha: number("appearance.panel_alpha").map_or(default.panel_alpha, |value| { + clamp(value, PANEL_ALPHA_MIN, 1.0) + }), + glass: boolean("appearance.glass").unwrap_or(false), + accent_color: text("appearance.accent_color") + .map(|value| normalize_hex(&value).unwrap_or_default()) + .unwrap_or_default(), + } + } + + /// 从 store 读(配置读写的统一入口)。 + #[must_use] + pub fn from_store(store: &SettingStore) -> Self { + Self::read(store.doc()) + } + + /// 实际生效的强调色:未自定义时回落到出厂 accent。 + #[must_use] + pub fn accent_or_default(&self) -> &str { + if self.accent_color.is_empty() { + DEFAULT_ACCENT + } else { + &self.accent_color + } + } + + /// 背景图是否真的会用上(开关打开 **且** 路径非空)。 + #[must_use] + pub fn bg_image_active(&self) -> bool { + self.bg_image_enabled && !self.bg_image.trim().is_empty() + } + + /// 写回一组键(调用方负责 `save()`;默认值不落盘由 `SettingDocument` 决定)。 + /// + /// 值先过一遍规范化:`bg_fill` 认枚举、`bg_dim`/`panel_alpha` 夹到范围内、 + /// `accent_color` 只接受 `#RRGGBB`(空串表示清掉自定义)。 + /// + /// # Errors + /// 键值被 schema 拒绝(只读文件 / 未知键 / 越界)时返回 [`super::error::SettingError`]。 + pub fn write(&self, store: &mut SettingStore) -> super::error::SettingResult<()> { + store.set( + "appearance.theme_mode", + Value::from(self.theme_mode.as_str()), + )?; + store.set( + "appearance.bg_image_enabled", + Value::from(self.bg_image_enabled), + )?; + store.set("appearance.bg_image", Value::from(self.bg_image.as_str()))?; + store.set("appearance.bg_fill", Value::from(self.bg_fill.as_str()))?; + store.set( + "appearance.bg_dim", + Value::from(clamp(self.bg_dim, BG_DIM_MIN, BG_DIM_MAX)), + )?; + store.set( + "appearance.panel_alpha", + Value::from(clamp(self.panel_alpha, PANEL_ALPHA_MIN, 1.0)), + )?; + store.set("appearance.glass", Value::from(self.glass))?; + store.set( + "appearance.accent_color", + Value::from(self.accent_color.as_str()), + )?; + Ok(()) + } +} + +/// `#RRGGBB` 规范化(大写、去空格);不合法返回 `None`。 +#[must_use] +pub fn normalize_hex(text: &str) -> Option { + let trimmed = text.trim(); + let body = trimmed.strip_prefix('#').unwrap_or(trimmed); + if body.len() != 6 || !body.chars().all(|ch| ch.is_ascii_hexdigit()) { + return None; + } + Some(format!("#{}", body.to_ascii_uppercase())) +} + +/// `#RRGGBB` → `(r, g, b)`(UI 侧构造颜色用)。 +#[must_use] +pub fn hex_to_rgb(text: &str) -> Option<(u8, u8, u8)> { + let hex = normalize_hex(text)?; + let byte = |range: std::ops::Range| u8::from_str_radix(&hex[range], 16).ok(); + Some((byte(1..3)?, byte(3..5)?, byte(5..7)?)) +} + +/// 相对亮度(WCAG 2.x,`0.0`~`1.0`):设置页「对比度检查提示」用它判断前景是否够读。 +#[must_use] +pub fn relative_luminance(text: &str) -> Option { + let (r, g, b) = hex_to_rgb(text)?; + let channel = |value: u8| { + let value = f64::from(value) / 255.0; + if value <= 0.039_28 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }; + Some(0.2126f64.mul_add( + channel(r), + 0.7152f64.mul_add(channel(g), 0.0722 * channel(b)), + )) +} + +fn clamp(value: f64, min: f64, max: f64) -> f64 { + if value.is_nan() { + min + } else { + value.clamp(min, max) + } +} diff --git a/crates/core/src/setting/catalog.rs b/crates/core/src/setting/catalog.rs new file mode 100644 index 0000000..2535618 --- /dev/null +++ b/crates/core/src/setting/catalog.rs @@ -0,0 +1,195 @@ +//! 一份配置文件的键目录:[`SettingSchema`] 把 key → 元数据查表能力收拢在一处。 +//! +//! 与 [`super::spec`] 的分工:`spec` 定义「一个键长什么样」,这里定义「一份文件的键集合」, +//! 并对外提供 AI 白名单 / 校验 / 工具 JSON Schema 投影(M16 `ToolSpec` 直接吃这份投影)。 + +use std::collections::BTreeSet; + +use serde_json::{json, Map, Value}; + +use super::error::{SettingError, SettingResult}; +use super::schema::{SchemaId, SchemaRef, SchemaVersion}; +use super::spec::{Sensitivity, SettingSpec}; + +/// 一份配置文件里所有键的集合。 +#[derive(Debug, Clone, PartialEq)] +pub struct SettingSchema { + id: SchemaId, + version: SchemaVersion, + specs: Vec, +} + +impl SettingSchema { + /// 空 schema。 + #[must_use] + pub fn new(id: SchemaId, version: SchemaVersion) -> Self { + Self { + id, + version, + specs: Vec::new(), + } + } + + /// 链式加一个键(重复键直接 panic —— 这是编译期就该发现的表写错)。 + #[must_use] + pub fn with(mut self, spec: SettingSpec) -> Self { + assert!( + !self.specs.iter().any(|existing| existing.key == spec.key), + "重复的配置键: {}", + spec.key + ); + self.specs.push(spec); + self + } + + /// 种类。 + #[must_use] + pub const fn id(&self) -> SchemaId { + self.id + } + + /// 版本。 + #[must_use] + pub const fn version(&self) -> SchemaVersion { + self.version + } + + /// `$schema` 标识。 + #[must_use] + pub const fn schema_ref(&self) -> SchemaRef { + SchemaRef::new(self.id, self.version) + } + + /// 全部键元数据。 + #[must_use] + pub fn specs(&self) -> &[SettingSpec] { + &self.specs + } + + /// 按键名取元数据。 + #[must_use] + pub fn spec(&self, key: &str) -> Option<&SettingSpec> { + self.specs.iter().find(|spec| spec.key == key) + } + + /// 是否定义了该键。 + #[must_use] + pub fn is_known(&self, key: &str) -> bool { + self.spec(key).is_some() + } + + /// 是否是某个已知键的**前缀路径**(用于区分「嵌套对象」与「陌生整棵子树」)。 + #[must_use] + pub fn has_prefix(&self, segments: &[&str]) -> bool { + let mut prefix = segments.join("."); + prefix.push('.'); + self.specs.iter().any(|spec| spec.key.starts_with(&prefix)) + } + + /// 默认值。 + #[must_use] + pub fn default_value(&self, key: &str) -> Option<&Value> { + self.spec(key).map(|spec| &spec.default) + } + + /// 校验一个键值对。 + /// + /// # Errors + /// 未定义的键 → [`SettingError::UnknownKey`];值不合法 → [`SettingError::InvalidValue`]。 + pub fn validate(&self, key: &str, value: &Value) -> SettingResult<()> { + let spec = self.spec(key).ok_or_else(|| SettingError::UnknownKey { + key: key.to_owned(), + })?; + spec.kind + .check(value) + .map_err(|reason| SettingError::InvalidValue { + key: key.to_owned(), + reason, + }) + } + + /// AI 可写键白名单(供 M16 `set_config` 前置检查)。 + #[must_use] + pub fn ai_writable_keys(&self) -> Vec<&'static str> { + self.specs + .iter() + .filter(|spec| spec.ai.writable && !spec.is_deprecated()) + .map(|spec| spec.key) + .collect() + } + + /// AI 是否允许写该键(未定义的键 = 不允许)。 + #[must_use] + pub fn accepts_ai_write(&self, key: &str) -> bool { + self.spec(key) + .is_some_and(|spec| spec.ai.writable && !spec.is_deprecated()) + } + + /// 已废弃键清单(只读兼容,不再落盘)。 + #[must_use] + pub fn deprecated_keys(&self) -> Vec<&'static str> { + self.specs + .iter() + .filter(|spec| spec.is_deprecated()) + .map(|spec| spec.key) + .collect() + } + + /// 某键的替代键(不是废弃键则 `None`)。 + #[must_use] + pub fn replacement_of(&self, key: &str) -> Option<&'static str> { + self.spec(key)?.deprecated.map(|info| info.replaced_by) + } + + /// 敏感分级。 + #[must_use] + pub fn sensitivity(&self, key: &str) -> Option { + self.spec(key).map(|spec| spec.ai.sensitivity) + } + + /// 顶层键名集合(`download.concurrency` → `download`)。 + #[must_use] + pub fn top_level_keys(&self) -> BTreeSet<&'static str> { + self.specs + .iter() + .filter_map(|spec| spec.key.split('.').next()) + .collect() + } + + /// 生成给 AI 工具用的 JSON Schema(只含 AI 可写键)。 + #[must_use] + pub fn to_json_schema(&self) -> Value { + let mut properties = Map::new(); + for spec in self + .specs + .iter() + .filter(|spec| spec.ai.writable && !spec.is_deprecated()) + { + let mut property = match spec.kind.to_json_schema() { + Value::Object(map) => map, + other => { + let mut map = Map::new(); + map.insert("schema".to_owned(), other); + map + } + }; + property.insert("description".to_owned(), Value::from(spec.doc)); + property.insert( + "x-ai-sensitivity".to_owned(), + Value::from(spec.ai.sensitivity.as_str()), + ); + if !spec.default.is_null() { + property.insert("default".to_owned(), spec.default.clone()); + } + properties.insert(spec.key.to_owned(), Value::Object(property)); + } + + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": format!("ull.{}", self.id.as_str()), + "type": "object", + "additionalProperties": false, + "properties": properties, + }) + } +} diff --git a/crates/core/src/setting/document.rs b/crates/core/src/setting/document.rs new file mode 100644 index 0000000..f59d050 --- /dev/null +++ b/crates/core/src/setting/document.rs @@ -0,0 +1,399 @@ +//! 配置文档模型:嵌套点路径取值、脏字段追踪、未知字段保留、`$schema` 保留。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.2-§1.3(HMCL `DirtyTracker` + +//! `unknownFields` + `RawPreservingProperty`)与 `DevDocs/PlanDoc/04-data-and-config.md` §3。 +//! +//! 三个关键取舍: +//! 1. **默认值不进文件**——只有「文件里读到的键」和「本次显式设过的键」会出现在落盘视图里, +//! 因此改默认值不需要写迁移代码; +//! 2. **未知字段整棵保留**——旧版本打开新版本写的文件不会丢配置; +//! 3. **坏值原样保留**——单个键解析/校验失败只记 `LoadProblem`,不丢数据、不整文件报废。 + +use std::collections::BTreeSet; +use std::fmt; + +use serde_json::{Map, Value}; + +use super::catalog::SettingSchema; +use super::error::{SettingError, SettingResult}; +use super::json_path::{collect_unknown, flatten, insert_at, lookup, merge_maps, segments}; +use super::schema::{ + SchemaCompat, SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_URL_PREFIX, SCHEMA_VERSION_KEY, +}; + +/// 落盘视图模式。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenderMode { + /// 完整视图:`$schema` + `schemaVersion` + 已读/已设键 + 未知键(保存用,无损)。 + Full, + /// 只含本次脏字段(AI 增量 / IPC 同步 / 审计用)。 + DirtyOnly, +} + +/// 加载时发现的问题(都不致命,配置照常用)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoadProblem { + /// 文件里有、schema 不认识的键(整棵子树原样保留)。 + UnknownKey { + /// 点分键;陌生子树按**最上层**节点报一次(不做逐叶展开)。 + key: String, + }, + /// 已知键但值不合法(原样保留,等用户/AI 修)。 + InvalidValue { + /// 点分键。 + key: String, + /// 原因。 + reason: String, + }, + /// 已知键的父路径类型不符(如 `download` 本该是对象却是字符串)。 + InvalidShape { + /// 点分键。 + key: String, + /// 原因。 + reason: String, + }, + /// `schemaVersion` 镜像与 `$schema` 尾部不一致(以 `$schema` 为准)。 + SchemaVersionMismatch { + /// 文件里写的整数。 + declared: u64, + /// `$schema` 推出的 major。 + expected: u32, + }, + /// 文件用的是已废弃键:值已按映射带到替代键,下次保存只写替代键。 + DeprecatedKey { + /// 已废弃的键。 + key: String, + /// 替代键。 + replaced_by: &'static str, + }, + /// `$schema` 本身的状态(缺失 / 非法 / 更高版本等)。 + Schema(SchemaCompat), + /// 有 `$schema` 字符串但认不出前缀(M40:值照读、文件按只读,不再静默回落默认值)。 + UnknownSchema { + /// 文件里写的原串。 + url: String, + }, +} + +impl fmt::Display for LoadProblem { + /// 人读一行(CLI 的 `problems` 通道、日志与设置页共用一份文案)。 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownKey { key } => write!(f, "{key}:schema 不认识的键(已原样保留)"), + Self::InvalidValue { key, reason } => write!(f, "{key}:值不合法({reason}),已保留"), + Self::InvalidShape { key, reason } => write!(f, "{key}:结构不符({reason})"), + Self::SchemaVersionMismatch { declared, expected } => { + write!( + f, + "schemaVersion={declared} 不是 $schema 的 major {expected}" + ) + } + Self::DeprecatedKey { key, replaced_by } => write!(f, "{key}:改用 {replaced_by}"), + Self::Schema(compat) => write!(f, "$schema 状态:{}", compat.as_str()), + Self::UnknownSchema { url } => { + write!(f, "$schema 认不出「{url}」,前缀需为 {SCHEMA_URL_PREFIX}") + } + } + } +} + +/// 一份配置文件的内存视图。 +#[derive(Debug, Clone)] +pub struct SettingDocument { + schema: SettingSchema, + version: SchemaVersion, + values: Map, + /// 已废弃键的原始值:只供诊断/UI 提示,**永远不落盘**。 + legacy: Map, + /// 废弃键(点分)清单。 + legacy_keys: BTreeSet, + dirty: BTreeSet, + unknown: Map, + unknown_keys: BTreeSet, + preserve_schema: Option, +} + +impl SettingDocument { + /// 空文档(全部走默认值),版本取 schema 版本。 + #[must_use] + pub fn new(schema: SettingSchema) -> Self { + let version = schema.version(); + Self { + schema, + version, + values: Map::new(), + legacy: Map::new(), + legacy_keys: BTreeSet::new(), + dirty: BTreeSet::new(), + unknown: Map::new(), + unknown_keys: BTreeSet::new(), + preserve_schema: None, + } + } + + /// 从文件内容加载;返回文档与问题清单(永不失败)。 + #[must_use] + pub fn load(schema: &SettingSchema, raw: &Value) -> (Self, Vec) { + let mut problems = Vec::new(); + let mut document = Self::new(schema.clone()); + + let original_schema = raw + .get(SCHEMA_KEY) + .and_then(Value::as_str) + .map(str::to_owned); + let compat = match &original_schema { + Some(url) => SchemaCompat::check_url(url, schema.schema_ref()), + None => SchemaCompat::Missing, + }; + if compat.preserve_schema() { + document.preserve_schema = original_schema.clone(); + } + // 认得 `$schema` 键、但前缀解析不出来:别静默按「没有声明」装默认值。 + if let (SchemaCompat::Invalid, Some(url)) = (compat, original_schema.clone()) { + problems.push(LoadProblem::UnknownSchema { url }); + } + if let Some(parsed) = original_schema.as_deref().and_then(SchemaRef::parse_url) { + document.version = parsed.version; + } + if let Some(declared) = raw.get(SCHEMA_VERSION_KEY).and_then(Value::as_u64) { + let expected = document.version.major; + if declared != u64::from(expected) { + problems.push(LoadProblem::SchemaVersionMismatch { declared, expected }); + } + } + + let Some(object) = raw.as_object() else { + if !raw.is_null() { + problems.push(LoadProblem::Schema(compat)); + } + return (document, problems); + }; + + for spec in schema.specs() { + let path = segments(spec.key); + let Some(value) = lookup(object, &path) else { + continue; + }; + + if let Some(deprecation) = spec.deprecated { + // 废弃键:原文进 legacy(不落盘),值按映射带到替代键(新键已在则新键优先)。 + insert_at(&mut document.legacy, &path, value.clone()); + problems.push(LoadProblem::DeprecatedKey { + key: spec.key.to_owned(), + replaced_by: deprecation.replaced_by, + }); + let replacement = segments(deprecation.replaced_by); + if lookup(object, &replacement).is_none() { + let translated = deprecation.translate(value); + if schema + .validate(deprecation.replaced_by, &translated) + .is_err() + { + problems.push(LoadProblem::InvalidValue { + key: deprecation.replaced_by.to_owned(), + reason: format!("旧键 {} 的值 {value} 无法映射到新键", spec.key), + }); + } + insert_at(&mut document.values, &replacement, translated); + } + continue; + } + + if let Err(err) = schema.validate(spec.key, value) { + problems.push(LoadProblem::InvalidValue { + key: spec.key.to_owned(), + reason: err.to_string(), + }); + } + insert_at(&mut document.values, &path, value.clone()); + } + + document.legacy_keys = flatten(&document.legacy); + + let unknown = collect_unknown(&[], object, schema, &mut problems); + document.unknown_keys = flatten(&unknown); + document.unknown = unknown; + + (document, problems) + } + + /// 键表。 + #[must_use] + pub const fn schema(&self) -> &SettingSchema { + &self.schema + } + + /// 文档版本(来自文件 `$schema`;迁移后用 [`Self::set_version`] 推进)。 + #[must_use] + pub const fn version(&self) -> SchemaVersion { + self.version + } + + /// 设置文档版本(迁移链推进后调用)。 + pub const fn set_version(&mut self, version: SchemaVersion) { + self.version = version; + } + + /// 文件里显式出现过的值(不含默认值)。 + #[must_use] + pub fn get(&self, key: &str) -> Option<&Value> { + let path = segments(key); + lookup(&self.values, &path).or_else(|| lookup(&self.legacy, &path)) + } + + /// 已废弃键在文件里的原文(仅供诊断/UI 提示;不参与落盘)。 + #[must_use] + pub fn legacy_value(&self, key: &str) -> Option<&Value> { + lookup(&self.legacy, &segments(key)) + } + + /// 废弃键清单(文件里真的出现过的)。 + pub fn legacy_keys(&self) -> impl Iterator { + self.legacy_keys.iter().map(String::as_str) + } + + /// 生效值:文件值 → schema 默认值(默认值为 `null` 视为未设置)。 + #[must_use] + pub fn effective(&self, key: &str) -> Option<&Value> { + self.get(key) + .or_else(|| self.schema.default_value(key)) + .filter(|value| !value.is_null()) + } + + /// 生效布尔值。 + #[must_use] + pub fn effective_bool(&self, key: &str) -> Option { + self.effective(key).and_then(Value::as_bool) + } + + /// 生效整数。 + #[must_use] + pub fn effective_i64(&self, key: &str) -> Option { + self.effective(key).and_then(Value::as_i64) + } + + /// 生效浮点。 + #[must_use] + pub fn effective_f64(&self, key: &str) -> Option { + self.effective(key).and_then(Value::as_f64) + } + + /// 生效文本。 + #[must_use] + pub fn effective_str(&self, key: &str) -> Option<&str> { + self.effective(key).and_then(Value::as_str) + } + + /// 写一个键(走 schema 校验,并标脏)。 + /// + /// # Errors + /// 未定义的键或值不合法时返回 [`SettingError`]。 + pub fn set(&mut self, key: &str, value: Value) -> SettingResult<()> { + if let Some(replaced_by) = self.schema.replacement_of(key) { + return Err(SettingError::DeprecatedKey { + key: key.to_owned(), + replaced_by, + }); + } + self.schema.validate(key, &value)?; + insert_at(&mut self.values, &segments(key), value); + self.dirty.insert(key.to_owned()); + Ok(()) + } + + /// 是否有未落盘的改动。 + #[must_use] + pub fn is_dirty(&self) -> bool { + !self.dirty.is_empty() + } + + /// 脏键清单。 + pub fn dirty_keys(&self) -> impl Iterator { + self.dirty.iter().map(String::as_str) + } + + /// 把当前所有已读/已设键全部标脏(迁移后强制回写用)。 + /// + /// 废弃键跳过:它们只做读兼容、不落盘,不需要回写。 + pub fn mark_all_dirty(&mut self) { + for spec in self.schema.specs() { + if spec.is_deprecated() { + continue; + } + if self.get(spec.key).is_some() { + self.dirty.insert(spec.key.to_owned()); + } + } + } + + /// 清脏(保存成功后调用)。 + pub fn mark_clean(&mut self) { + self.dirty.clear(); + } + + /// 未知键(点分)清单。 + pub fn unknown_keys(&self) -> impl Iterator { + self.unknown_keys.iter().map(String::as_str) + } + + /// 文件里原有的 `$schema`(仅 `preserve_schema` 状态下有值)。 + #[must_use] + pub fn preserve_schema(&self) -> Option<&str> { + self.preserve_schema.as_deref() + } + + /// 显式指定保存时沿用的 `$schema` 字符串。 + pub fn set_preserve_schema(&mut self, url: Option) { + self.preserve_schema = url; + } + + /// 落盘视图。 + #[must_use] + pub fn render(&self, mode: RenderMode) -> Value { + let mut root = Map::new(); + root.insert(SCHEMA_KEY.to_owned(), Value::String(self.schema_url())); + root.insert( + SCHEMA_VERSION_KEY.to_owned(), + Value::from(self.version.major), + ); + match mode { + RenderMode::Full => { + merge_maps(&mut root, &self.values); + merge_maps(&mut root, &self.unknown); + } + RenderMode::DirtyOnly => { + let patch = self.dirty_patch_map(); + merge_maps(&mut root, &patch); + } + } + Value::Object(root) + } + + /// 完整落盘视图(等价 `render(RenderMode::Full)`)。 + #[must_use] + pub fn to_value(&self) -> Value { + self.render(RenderMode::Full) + } + + /// 只含脏字段的嵌套对象。 + #[must_use] + pub fn dirty_patch(&self) -> Value { + Value::Object(self.dirty_patch_map()) + } + + fn dirty_patch_map(&self) -> Map { + let mut patch = Map::new(); + for key in &self.dirty { + if let Some(value) = lookup(&self.values, &segments(key)) { + insert_at(&mut patch, &segments(key), value.clone()); + } + } + patch + } + + fn schema_url(&self) -> String { + self.preserve_schema + .clone() + .unwrap_or_else(|| self.schema.schema_ref().url()) + } +} diff --git a/crates/core/src/setting/error.rs b/crates/core/src/setting/error.rs new file mode 100644 index 0000000..186a29b --- /dev/null +++ b/crates/core/src/setting/error.rs @@ -0,0 +1,121 @@ +//! setting 层错误类型与结果别名。 + +use std::fmt; +use std::io; +use std::path::PathBuf; + +use crate::platform::error::PlatformError; + +use super::schema::{SchemaCompat, SchemaVersion}; + +/// setting 层的结果别名。 +pub type SettingResult = Result; + +/// 配置读写与迁移可能出的问题。 +#[derive(Debug)] +pub enum SettingError { + /// 文件系统操作失败。 + Io { + /// 出错路径。 + path: PathBuf, + /// 底层错误。 + source: io::Error, + }, + /// JSON 解析/序列化失败。 + Json { + /// 出错路径。 + path: PathBuf, + /// 底层错误。 + source: serde_json::Error, + }, + /// 文件是只读档(`READ_ONLY`),拒绝保存。 + ReadOnly { + /// 文件路径。 + path: PathBuf, + }, + /// 文件不可读档(`UNREADABLE`),全程不写。 + Unreadable { + /// 文件路径。 + path: PathBuf, + /// 判定依据。 + compat: SchemaCompat, + }, + /// schema 里没有这个键(AI 白名单外的键也走这里)。 + UnknownKey { + /// 键。 + key: String, + }, + /// 值不合法(类型/范围/枚举)。 + InvalidValue { + /// 键。 + key: String, + /// 原因。 + reason: String, + }, + /// 键已废弃,写必须走替代键。 + DeprecatedKey { + /// 用户/调用方给的旧键。 + key: String, + /// 替代键。 + replaced_by: &'static str, + }, + /// 迁移链断链/缺环/顺序错。 + ChainBroken { + /// 原因。 + reason: String, + }, + /// 单步迁移失败。 + Migration { + /// 起始版本。 + from: SchemaVersion, + /// 目标版本。 + to: SchemaVersion, + /// 原因。 + reason: String, + }, + /// platform 层错误透传。 + Platform(PlatformError), +} + +impl fmt::Display for SettingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io { path, source } => write!(f, "IO 失败 @ {}: {source}", path.display()), + Self::Json { path, source } => write!(f, "JSON 失败 @ {}: {source}", path.display()), + Self::ReadOnly { path } => write!(f, "文件只读,拒绝保存: {}", path.display()), + Self::Unreadable { path, compat } => write!( + f, + "文件不可读({}),全程不写: {}", + compat.as_str(), + path.display() + ), + Self::UnknownKey { key } => write!(f, "schema 未定义该键: {key}"), + Self::InvalidValue { key, reason } => write!(f, "值不合法 [{key}]: {reason}"), + Self::DeprecatedKey { key, replaced_by } => { + write!(f, "键 {key} 已废弃,请改用 {replaced_by}") + } + Self::ChainBroken { reason } => write!(f, "迁移链不完整: {reason}"), + Self::Migration { from, to, reason } => { + write!(f, "迁移 {from} → {to} 失败: {reason}") + } + Self::Platform(err) => write!(f, "{err}"), + } + } +} + +impl std::error::Error for SettingError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io { source, .. } => Some(source), + Self::Json { source, .. } => Some(source), + Self::Platform(err) => Some(err), + _ => None, + } + } +} + +impl From for SettingError { + fn from(err: PlatformError) -> Self { + Self::Platform(err) + } +} diff --git a/crates/core/src/setting/instance_keys.rs b/crates/core/src/setting/instance_keys.rs new file mode 100644 index 0000000..aaa7948 --- /dev/null +++ b/crates/core/src/setting/instance_keys.rs @@ -0,0 +1,155 @@ +//! `config/profiles.json` 与 `config/instances/.json` 的键表。 +//! +//! 实例模型预告见 `DevDocs/PlanDoc/04-data-and-config.md` §4: +//! 每实例 = 一个 `.minecraft` 目录绑定 + 独立设置覆盖链(全局 → 实例 → 临时启动参数), +//! 实例 id 用稳定 UUID(不用路径——路径会变)。 +//! +//! # 实例文件的形状(D20 定案,research/04 §5.1) +//! +//! 实例文件是**稀疏 camelCase 扁平结构**,与 `game::install::instance::InstanceConfig` +//! 的 serde 形状逐键一致(那份结构才是写入者,本文件是它的 schema 白名单视图): +//! +//! ```jsonc +//! { +//! "schemaVersion": 1, +//! "id": "", +//! "versionId": "1.20.1", +//! "isolation": "custom", // 缺键 = inherit(跟随全局,不落盘) +//! "gameDir": "D:/mcs/farm", // 仅 custom 时有值 +//! "overrides": ["maxMemory"], +//! "lastModeChange": { "at": "2026-10-30T12:00:00Z", "from": "shared", "to": "isolated", +//! "probe": { "isolatedSide": 0, "sharedSide": 12 }, "warned": true } +//! } +//! ``` +//! +//! 三条不变量: +//! 1. `isolation` 默认 `inherit` 且**不落盘**——改全局模式对存量实例立即生效 +//! (`document` 的「默认值不进文件」保证这一点,直接避开 PCL2 的默认值固化缺陷); +//! 显式写 `"isolation": "inherit"` 也合法:`InstanceConfig` 的反序列化把它归一成 +//! 「键不存在」(等价语义,不会出现两层读写回环不一致); +//! 2. `isolation`/`lastModeChange` 由用户(前者)与启动器(后者)维护,**AI 不可写**; +//! 3. 派生的绝对路径不入档:`isolated` 只是「运行目录 = `versions/`」的语法糖, +//! 派生实现归 `game::isolation`。 + +use super::catalog::SettingSchema; +use super::schema::{SchemaId, SchemaVersion}; +use super::spec::{AiPolicy, SettingSpec}; + +/// 实例级 `isolation` 允许值(`inherit` = 键缺失,跟随全局 `instances.mode`)。 +pub const ISOLATION_OVERRIDES: &[&str] = &["inherit", "isolated", "shared", "custom"]; +/// 切换检测记录里 `from`/`to` 的允许值(`inherit` 不是生效档位,故不在表里)。 +pub const ISOLATION_RESOLVED: &[&str] = &["isolated", "shared", "custom"]; +/// 实例可覆盖的设置键(enum 白名单,拼错即校验失败而不是静默不生效)。 +pub const OVERRIDE_KEYS: &[&str] = &["maxMemory", "javaType", "runningDirectory"]; + +/// profiles.json 当前 schema 版本。 +#[must_use] +pub fn profiles_version() -> SchemaVersion { + SchemaVersion::new(1, 0, 0) +} + +/// instances/.json 当前 schema 版本。 +/// +/// `1.1.0` 登记 D20 的 `isolation` 档位(`inherit`/`isolated`/`shared`/`custom`)与 +/// `lastModeChange` 切换检测键;老文件缺键即默认值,不需要迁移步骤(major 未变)。 +#[must_use] +pub fn instance_version() -> SchemaVersion { + SchemaVersion::new(1, 1, 0) +} + +/// `config/profiles.json` 的 schema。 +#[must_use] +pub fn profiles_schema() -> SettingSchema { + SettingSchema::new(SchemaId::PROFILES, profiles_version()) + .with(SettingSpec::text( + "selected_profile_id", + "", + 64, + AiPolicy::audited(), + "上次选中的实例 id", + )) + .with(SettingSpec::any( + "profiles", + AiPolicy::locked(), + "实例档案列表(数据而非配置,由 game 模块维护;AI 不得整体改写)", + )) +} + +/// `config/instances/.json` 的 schema。 +#[must_use] +pub fn instance_schema() -> SettingSchema { + SettingSchema::new(SchemaId::INSTANCE, instance_version()) + .with(SettingSpec::text( + "id", + "", + 64, + AiPolicy::locked(), + "实例稳定 id(UUID,与文件名一致;不允许改写身份)", + )) + .with(SettingSpec::text( + "versionId", + "", + 128, + AiPolicy::audited(), + "绑定的版本 id(versions/)", + )) + .with(SettingSpec::enumeration( + "isolation", + "inherit", + ISOLATION_OVERRIDES, + AiPolicy::sensitive(), + "实例隔离档位:inherit(跟随全局 instances.mode,不落盘)/ isolated(运行目录派生 versions/)/ shared(共用 .minecraft 根)/ custom(用 gameDir)", + )) + .with(SettingSpec::path( + "gameDir", + AiPolicy::sensitive(), + "自定义运行目录(仅 isolation=custom 生效)", + )) + .with(SettingSpec::enum_list( + "overrides", + OVERRIDE_KEYS, + AiPolicy::locked(), + "该实例覆盖了全局哪些设置键(enum 白名单,拼错即校验失败)", + )) + .with(SettingSpec::text( + "lastModeChange.at", + "", + 40, + AiPolicy::locked(), + "上次切换隔离档位的时间(RFC-3339,启动器写入)", + )) + .with(SettingSpec::enumeration( + "lastModeChange.from", + "isolated", + ISOLATION_RESOLVED, + AiPolicy::locked(), + "切换前的生效档位(启动器写入)", + )) + .with(SettingSpec::enumeration( + "lastModeChange.to", + "isolated", + ISOLATION_RESOLVED, + AiPolicy::locked(), + "切换后的生效档位(启动器写入)", + )) + .with(SettingSpec::int( + "lastModeChange.probe.isolatedSide", + 0, + (0, 1_000_000_000), + AiPolicy::locked(), + "切换时隔离侧(versions/)的内容条目数(只探测,不迁移)", + )) + .with(SettingSpec::int( + "lastModeChange.probe.sharedSide", + 0, + (0, 1_000_000_000), + AiPolicy::locked(), + "切换时共享侧(.minecraft 根)的内容条目数(只探测,不迁移)", + )) + .with(SettingSpec::bool( + "lastModeChange.warned", + false, + AiPolicy::locked(), + "该次切换的提示是否已展示过(保证切换后首次启动只提示一次)", + )) +} diff --git a/crates/core/src/setting/json_path.rs b/crates/core/src/setting/json_path.rs new file mode 100644 index 0000000..3cfaf93 --- /dev/null +++ b/crates/core/src/setting/json_path.rs @@ -0,0 +1,124 @@ +//! 点分键(`download.concurrency`)与嵌套 JSON 之间的最小工具集。 +//! +//! 单独成文件:这些函数是 [`super::document`] 的机械部件,和「配置语义」无关, +//! 也是容易出边界 bug(空路径、中间层不是对象)的地方。 + +use std::collections::BTreeSet; + +use serde_json::{Map, Value}; + +use super::catalog::SettingSchema; +use super::document::LoadProblem; +use super::schema::{SCHEMA_KEY, SCHEMA_VERSION_KEY}; + +/// 点分键 → 路径段。 +pub(crate) fn segments(key: &str) -> Vec<&str> { + key.split('.').filter(|part| !part.is_empty()).collect() +} + +/// 沿路径取嵌套值。 +pub(crate) fn lookup<'a>(root: &'a Map, path: &[&str]) -> Option<&'a Value> { + let (head, tail) = path.split_first()?; + let value = root.get(*head)?; + if tail.is_empty() { + Some(value) + } else { + lookup(value.as_object()?, tail) + } +} + +/// 沿路径写嵌套值(中间层不是对象则重建)。 +pub(crate) fn insert_at(root: &mut Map, path: &[&str], value: Value) { + let Some((head, tail)) = path.split_first() else { + return; + }; + if tail.is_empty() { + root.insert((*head).to_owned(), value); + return; + } + let entry = root + .entry((*head).to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + if !entry.is_object() { + *entry = Value::Object(Map::new()); + } + if let Some(child) = entry.as_object_mut() { + insert_at(child, tail, value); + } +} + +/// 深合并(`extra` 覆盖 `target` 的同名叶子)。 +pub(crate) fn merge_maps(target: &mut Map, extra: &Map) { + for (key, value) in extra { + if let Some(Value::Object(existing)) = target.get_mut(key) { + if let Some(incoming) = value.as_object() { + merge_maps(existing, incoming); + continue; + } + } + target.insert(key.clone(), value.clone()); + } +} + +/// 收集 schema 不认识的键(整棵保留),并把形状问题记进 `problems`。 +pub(crate) fn collect_unknown( + prefix: &[&str], + node: &Map, + schema: &SettingSchema, + problems: &mut Vec, +) -> Map { + let mut out = Map::new(); + for (key, value) in node { + if prefix.is_empty() && (key == SCHEMA_KEY || key == SCHEMA_VERSION_KEY) { + continue; + } + let mut path = prefix.to_vec(); + path.push(key.as_str()); + let dotted = path.join("."); + if schema.is_known(&dotted) { + continue; + } + if schema.has_prefix(&path) { + match value.as_object() { + Some(child) => { + let nested = collect_unknown(&path, child, schema, problems); + if !nested.is_empty() { + out.insert(key.clone(), Value::Object(nested)); + } + } + None => problems.push(LoadProblem::InvalidShape { + key: path.join("."), + reason: "期望对象(其下还有已知键)".to_owned(), + }), + } + continue; + } + problems.push(LoadProblem::UnknownKey { + key: path.join("."), + }); + out.insert(key.clone(), value.clone()); + } + out +} + +/// 把嵌套对象摊平成点分叶子集合。 +pub(crate) fn flatten(map: &Map) -> BTreeSet { + fn walk(prefix: &str, node: &Map, out: &mut BTreeSet) { + for (key, value) in node { + let dotted = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + match value.as_object() { + Some(child) if !child.is_empty() => walk(&dotted, child, out), + _ => { + out.insert(dotted); + } + } + } + } + let mut out = BTreeSet::new(); + walk("", map, &mut out); + out +} diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs new file mode 100644 index 0000000..0a4b0ee --- /dev/null +++ b/crates/core/src/setting/keys.rs @@ -0,0 +1,352 @@ +//! `config/launcher.json` 的键表(全局设置)。 +//! +//! 键名与默认值对齐 `DevDocs/PlanDoc/04-data-and-config.md` §2-§3 与 `05/06/07/08` 各专题; +//! AI 可写 + 敏感分级按 `02-ai-agent-design.md` §3.1 的边界落:**密钥类一律 locked**。 + +use super::catalog::SettingSchema; +use super::schema::{SchemaId, SchemaVersion}; +use super::spec::{AiPolicy, SettingSpec}; + +/// `core.data_mode` 允许值。 +pub const DATA_MODES: &[&str] = &["portable", "standard", "custom"]; +/// `download.region` 允许值。 +pub const REGIONS: &[&str] = &["auto", "cn", "global"]; +/// `download.mirror_preference` 允许值。 +pub const MIRROR_PREFERENCES: &[&str] = &["auto", "official", "mirror"]; +/// `download.proxy_mode` 允许值。 +pub const PROXY_MODES: &[&str] = &["none", "system", "manual"]; +/// `ai.provider` 允许值。 +pub const AI_PROVIDERS: &[&str] = &["openai_compatible", "anthropic", "ollama", "disabled"]; +/// `ui.language` 允许值。 +pub const LANGUAGES: &[&str] = &["zh_cn", "en_us"]; +/// `appearance.theme_mode` 允许值(D17 定案:auto/light/dark)。 +pub const THEME_MODES: &[&str] = &["auto", "light", "dark"]; +/// `ui.theme` 的旧允许值(该键已废弃,仅读兼容,不再落盘)。 +pub const LEGACY_THEMES: &[&str] = &["system", "metro_light", "metro_dark"]; +/// 旧 `ui.theme` 值 → 新 `appearance.theme_mode` 值(tower 键冲突裁决的读兼容映射)。 +pub const THEME_ALIASES: &[(&str, &str)] = &[ + ("system", "auto"), + ("metro_light", "light"), + ("metro_dark", "dark"), +]; +/// `log.level` 允许值。 +pub const LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace"]; +/// `instances.mode` 允许值(D20 定案:默认隔离,用户可手动切共享)。 +pub const INSTANCE_MODES: &[&str] = &["isolated", "shared"]; +/// `java.installRoot` 未设置时的哨兵(空串 = 按数据目录模式派生落点)。 +pub const JAVA_INSTALL_ROOT_UNSET: &str = ""; +/// `java.vendorPriority` 的允许值(D23:四家发行版,顺序即优先级)。 +/// +/// 与 `crate::game::java::vendor::VENDOR_IDS` 必须是同一份集合——setting 层不依赖 game 层 +/// (分层纪律),一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。 +pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"]; + +/// launcher.json 当前 schema 版本。 +/// +/// `1.1.0` 登记 D20 实例隔离键(`instances.mode` 等)。 +/// `1.2.0` 登记 D23 的 JDK 落点与发行版优先级键(`java.installRoot` / `java.vendorPriority`): +/// 同样只推 minor、不强制迁移——[`super::document`] 的「默认值不落盘」让老文件缺键即取默认, +/// 迁移链(按 major 走)不需要新步骤;`1.1.0 → 1.2.0` 的差异只体现在文件里出现新键之后。 +/// `1.3.0` 登记 D17 的「外观(高级)」P1 键(`appearance.bg_image*` / `panel_alpha` / `glass` / +/// `accent_color`):同一套纯增量语义——出厂全默认(高级特性全关)即等于老文件缺键。 +#[must_use] +pub fn launcher_version() -> SchemaVersion { + SchemaVersion::new(1, 3, 0) +} + +/// `config/launcher.json` 的 schema。 +#[must_use] +pub fn launcher_schema() -> SettingSchema { + SettingSchema::new(SchemaId::LAUNCHER, launcher_version()) + .with(SettingSpec::enumeration( + "core.data_mode", + "portable", + DATA_MODES, + AiPolicy::sensitive(), + "数据目录模式:portable(exe 旁)/ standard(系统标准目录)/ custom(自定义)", + )) + .with(SettingSpec::path( + "core.data_custom_root", + AiPolicy::sensitive(), + "custom 模式的根目录(必须是绝对路径)", + )) + .with(SettingSpec::path( + "core.launcher_home", + AiPolicy::sensitive(), + "显式覆盖启动器数据目录(.ULL 的等价物)", + )) + .with(SettingSpec::path( + "core.game_home", + AiPolicy::sensitive(), + "游戏目录(.minecraft),与数据目录分离指定", + )) + // D20(research/04 §5.1):只留一个全局默认档,实例级 override 在 + // `instance_keys::instance_schema`(`isolation`),派生逻辑在 `game::isolation`。 + .with(SettingSpec::enumeration( + "instances.mode", + "isolated", + INSTANCE_MODES, + AiPolicy::sensitive(), + "实例默认隔离模式:isolated(运行目录派生 versions/<版本 id>,D20 默认)/ shared(所有实例共用 .minecraft 根)", + )) + .with(SettingSpec::int( + "download.concurrency", + 8, + (1, 256), + AiPolicy::silent(), + "下载并发数", + )) + .with(SettingSpec::int( + "download.retry_limit", + 3, + (0, 10), + AiPolicy::silent(), + "单个文件重试次数", + )) + .with(SettingSpec::enumeration( + "download.region", + "auto", + REGIONS, + AiPolicy::audited(), + "下载地区判定:auto(时区+语言+探测)/ cn / global", + )) + .with(SettingSpec::enumeration( + "download.mirror_preference", + "auto", + MIRROR_PREFERENCES, + AiPolicy::audited(), + "镜像偏好:auto(健康度评分)/ official(官方源优先)/ mirror(镜像优先)", + )) + .with(SettingSpec::enumeration( + "download.proxy_mode", + "none", + PROXY_MODES, + AiPolicy::sensitive(), + "代理模式:none / system / manual", + )) + .with(SettingSpec::text( + "download.proxy_url", + "", + 512, + AiPolicy::sensitive(), + "manual 代理地址(如 http://127.0.0.1:7890)", + )) + .with(SettingSpec::path( + "java.selected_path", + AiPolicy::sensitive(), + "用户选定的 Java 可执行文件路径", + )) + .with(SettingSpec::bool( + "java.auto_install", + true, + AiPolicy::audited(), + "缺 Java 时自动下载(D13 基准 JDK17)", + )) + // D23:托管 JDK 的落点三模式。语义在 `game::java::location`: + // 未设置(空串)→ 按 `core.data_mode` 派生(Portable 数据根 / Standard 系统目录); + // 设置了 → 覆盖前两者,按 `/` 分层。 + .with(SettingSpec::text( + "java.installRoot", + JAVA_INSTALL_ROOT_UNSET, + 512, + AiPolicy::sensitive(), + "托管 JDK 的自定义安装根(留空 = 按数据目录模式:portable 进数据根、standard 进 %APPDATA%\\Java);设置后覆盖两者,JDK 落在 /<发行方>/<版本>/", + )) + // D23:发行版回退链的用户优先级。空数组 = 按地区默认序 + // (国内 Temurin→Oracle→Microsoft→Zulu;国外 Temurin→Microsoft→Oracle→Zulu)。 + .with(SettingSpec::enum_list( + "java.vendorPriority", + JDK_VENDORS, + AiPolicy::audited(), + "JDK 发行版优先级(temurin/oracle/microsoft/zulu);留空按地区默认序,只写几家则其余按默认序补齐", + )) + .with(SettingSpec::int( + "java.default_memory_mb", + 2048, + (512, 65536), + AiPolicy::silent(), + "默认分配给游戏的最大堆内存(MB)", + )) + .with(SettingSpec::text( + "java.extra_args", + "", + 1024, + AiPolicy::sensitive(), + "附加 JVM 参数(原样拼到命令行)", + )) + .with(SettingSpec::bool( + "ai.enabled", + true, + AiPolicy::silent(), + "启用内置 AI agent(关闭后规则引擎诊断仍可用)", + )) + .with(SettingSpec::enumeration( + "ai.provider", + "openai_compatible", + AI_PROVIDERS, + AiPolicy::sensitive(), + "LLM 提供方:openai_compatible(含本地 llama.cpp/DeepSeek/Qwen)/ anthropic / ollama / disabled", + )) + .with(SettingSpec::text( + "ai.base_url", + "", + 512, + AiPolicy::sensitive(), + "LLM 端点 base_url(OpenAI 兼容形态)", + )) + .with(SettingSpec::text( + "ai.model", + "", + 128, + AiPolicy::audited(), + "模型名(如 gpt-4o-mini / deepseek-chat)", + )) + .with(SettingSpec::bool( + "ai.yolo", + false, + AiPolicy::sensitive(), + "自动批准全部工具调用(审计照记,白名单不变)", + )) + .with(SettingSpec::new( + "ai.api_key", + super::spec::ValueKind::Text { max_len: 4096 }, + serde_json::Value::Null, + AiPolicy::locked(), + "LLM API 密钥——AI 永不读写的凭据(D9)", + )) + // M39(P3 MCP server,02 §0「传输定论」):对外 agent 工具服务。 + // 三个开关默认「关 / 只读」——外部客户端拿到的是与内置客户端**同一注册表**的工具, + // 但写权限必须用户显式打开(02 §1「同一注册表、同一安全策略、同一审计日志」)。 + .with(SettingSpec::bool( + "agent.mcp.enabled", + false, + AiPolicy::sensitive(), + "对外 MCP server 总开关(127.0.0.1 上的 Streamable HTTP;stdio 形态按需显式启动)", + )) + .with(SettingSpec::int( + "agent.mcp.port", + 47823, + (1024, 65535), + AiPolicy::sensitive(), + "MCP server 的 Streamable HTTP 端口(只绑 127.0.0.1)", + )) + .with(SettingSpec::bool( + "agent.mcp.allow_write", + false, + AiPolicy::sensitive(), + "允许外部 MCP 客户端调用写工具(默认只读;敏感键仍受分级确认与 YOLO 规则约束)", + )) + .with(SettingSpec::text( + "agent.mcp.token", + "", + 256, + AiPolicy::locked(), + "MCP server 本地访问令牌(Authorization: Bearer;空 = 不校验,仅绑 127.0.0.1)", + )) + .with(SettingSpec::enumeration( + "ui.language", + "zh_cn", + LANGUAGES, + AiPolicy::audited(), + "界面语言", + )) + .with(SettingSpec::enumeration( + "appearance.theme_mode", + "auto", + THEME_MODES, + AiPolicy::silent(), + "主题模式:auto(跟随系统)/ light(Metro 浅色)/ dark(Metro 深色)", + )) + // D17「外观(高级)」P1 四件(09 §101-104 / §111):**默认全部关闭**(出厂 Metro 纯色)。 + // 分级一律 audited(Medium):全部是纯视觉、可一键复原的键,没有安全面(不碰凭据/网络/ + // 数据根),谈不上 High 级确认;但 AI 改过什么要留审计痕迹,故不用 silent。 + .with(SettingSpec::bool( + "appearance.bg_image_enabled", + false, + AiPolicy::audited(), + "自定义窗口背景图开关(默认关;开启时设置页给出对比度提示)", + )) + .with(SettingSpec::text( + "appearance.bg_image", + "", + 512, + AiPolicy::audited(), + "背景图路径(PNG;空 = 未设置。Slint 默认解码器只带 PNG)", + )) + .with(SettingSpec::enumeration( + "appearance.bg_fill", + "cover", + super::appearance::BG_FILLS, + AiPolicy::audited(), + "背景图填充模式:cover(铺满裁切)/ contain(完整留底色)/ tile(平铺)", + )) + .with(SettingSpec::float( + "appearance.bg_dim", + 0.35, + ( + super::appearance::BG_DIM_MIN, + super::appearance::BG_DIM_MAX, + ), + AiPolicy::audited(), + "背景图叠加暗化层强度(越大前景越清晰;09 §101 的对比度保底手法)", + )) + .with(SettingSpec::float( + "appearance.panel_alpha", + 1.0, + (super::appearance::PANEL_ALPHA_MIN, 1.0), + AiPolicy::audited(), + "全局面板半透明度(卡片/导航/AI 栏统一透明度;1.0 = 出厂不透明)", + )) + .with(SettingSpec::bool( + "appearance.glass", + false, + AiPolicy::audited(), + "玻璃拟态卡片(背景图预生成模糊层作卡片底;Slint 无 backdrop-filter 的替代)", + )) + .with(SettingSpec::text( + "appearance.accent_color", + "", + 9, + AiPolicy::audited(), + "自定义强调色 #RRGGBB(空 = 出厂 #0078D4;accent 令牌全域生效)", + )) + // 已废弃:D17 定案 appearance.theme_mode 为准(旧值读兼容映射,写永远走新键)。 + .with( + SettingSpec::new( + "ui.theme", + super::spec::ValueKind::Enum(LEGACY_THEMES), + serde_json::Value::Null, + AiPolicy::retired(), + "【已废弃】改用 appearance.theme_mode;旧值读兼容:system→auto / metro_light→light / metro_dark→dark", + ) + .deprecated("appearance.theme_mode", THEME_ALIASES), + ) + .with(SettingSpec::float( + "ui.scale", + 1.0, + (0.75, 2.0), + AiPolicy::silent(), + "界面缩放系数", + )) + .with(SettingSpec::int( + "ui.window.width", + 1280, + (320, 16384), + AiPolicy::silent(), + "主窗口宽度(逻辑像素)", + )) + .with(SettingSpec::int( + "ui.window.height", + 800, + (200, 16384), + AiPolicy::silent(), + "主窗口高度(逻辑像素)", + )) + .with(SettingSpec::enumeration( + "log.level", + "info", + LOG_LEVELS, + AiPolicy::audited(), + "启动器日志级别", + )) +} diff --git a/crates/core/src/setting/migrate.rs b/crates/core/src/setting/migrate.rs new file mode 100644 index 0000000..03bd13a --- /dev/null +++ b/crates/core/src/setting/migrate.rs @@ -0,0 +1,222 @@ +//! 迁移链:`schemaVersion N → N+1` 顺序迁移框架 + 收据闸门(防重放)。 +//! +//! 出处:`DevDocs/PlanDoc/04-data-and-config.md` §3(迁移链 + 迁移收据)、 +//! `DevDocs/analyse/07-settings-packaging-update.md` §1.8(收据存源文件 `sourceSHA256`)。 +//! +//! 与 HMCL 的差别:HMCL 完全靠 `$schema` 字段做兼容、迁移只处理遗留格式;ULL 在这之上补一条 +//! 「major 逐级迁移链」——每一步只跨一个 major,缺环/重复/逆序在 `push` 时就报错, +//! 避免出现「跳版迁移脚本」这种组合爆炸。 + +use std::path::Path; + +use serde_json::{Map, Value}; + +use crate::platform::error::PlatformResult; +use crate::platform::receipt::{self, MigrationReceipt, ReplayDecision}; + +use super::error::{SettingError, SettingResult}; +use super::schema::{SchemaVersion, SCHEMA_VERSION_KEY}; + +/// 一步迁移。实现者只管把 `root` 从 `from` 改造成 `to`。 +pub trait SettingMigration: Send + Sync { + /// 起始版本。 + fn from(&self) -> SchemaVersion; + /// 目标版本(必须等于 `from.next_major()`)。 + fn to(&self) -> SchemaVersion; + /// 一句话说明(进日志与迁移报告)。 + fn describe(&self) -> &'static str; + /// 就地改 JSON 对象;`Err(String)` 会被包成 [`SettingError::Migration`]。 + /// + /// # Errors + /// 结构不符合预期(该有的键没有/类型不对)时返回原因。 + fn apply(&self, root: &mut Map) -> Result<(), String>; +} + +/// 迁移报告(进日志 / 遥测 / 收据)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MigrationReport { + /// 起始版本。 + pub from: SchemaVersion, + /// 结束版本。 + pub to: SchemaVersion, + /// 依次执行的步骤说明。 + pub applied: Vec<&'static str>, +} + +impl MigrationReport { + /// 是否真的做了迁移。 + #[must_use] + pub fn is_noop(&self) -> bool { + self.applied.is_empty() + } +} + +/// 有序迁移链。 +#[derive(Default)] +pub struct MigrationChain { + steps: Vec>, +} + +impl std::fmt::Debug for MigrationChain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_list() + .entries(self.steps.iter().map(|step| step.describe())) + .finish() + } +} + +impl MigrationChain { + /// 空链。 + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// 追加一步;必须与上一环首尾相接,且只跨一个 major。 + /// + /// # Errors + /// 跨多个 major、起始版本重复、顺序错乱时返回 [`SettingError::ChainBroken`]。 + pub fn push(&mut self, step: Box) -> SettingResult<()> { + let from = step.from(); + let to = step.to(); + if to != from.next_major() { + return Err(SettingError::ChainBroken { + reason: format!("一步迁移只能跨一个 major:{from} → {to}"), + }); + } + if self.steps.iter().any(|existing| existing.from() == from) { + return Err(SettingError::ChainBroken { + reason: format!("{from} 已有迁移步骤"), + }); + } + if let Some(last) = self.steps.last() { + if last.to() != from { + return Err(SettingError::ChainBroken { + reason: format!("顺序错误:上一环到 {},本环从 {from}", last.to()), + }); + } + } + self.steps.push(step); + Ok(()) + } + + /// 步骤数。 + #[must_use] + pub fn len(&self) -> usize { + self.steps.len() + } + + /// 是否没有步骤。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.steps.is_empty() + } + + /// 步骤说明清单(日志用)。 + #[must_use] + pub fn describe(&self) -> Vec<&'static str> { + self.steps.iter().map(|step| step.describe()).collect() + } + + /// 链能达到的最新版本。 + #[must_use] + pub fn latest(&self) -> Option { + self.steps.last().map(|step| step.to()) + } + + /// 从 `from` 起要执行的步骤(含 `from` 那一步)。 + /// + /// # Errors + /// 链里没有以 `from` 开头的步骤(版本不认识)时返回 [`SettingError::ChainBroken`]。 + pub fn plan_from(&self, from: SchemaVersion) -> SettingResult> { + let start = self + .steps + .iter() + .position(|step| step.from() == from) + .ok_or_else(|| SettingError::ChainBroken { + reason: format!("链里没有以 {from} 起始的迁移步骤"), + })?; + Ok(self.steps[start..] + .iter() + .map(|step| step.as_ref()) + .collect()) + } + + /// 顺序执行迁移,并把 `schemaVersion` 镜像更新成最终 major。 + /// + /// # Errors + /// 缺环 → [`SettingError::ChainBroken`];某步失败 → [`SettingError::Migration`]。 + pub fn migrate( + &self, + from: SchemaVersion, + root: &mut Map, + ) -> SettingResult { + let plan = self.plan_from(from)?; + let mut current = from; + let mut applied = Vec::with_capacity(plan.len()); + for step in plan { + step.apply(root).map_err(|reason| SettingError::Migration { + from: current, + to: step.to(), + reason, + })?; + current = step.to(); + applied.push(step.describe()); + } + root.insert(SCHEMA_VERSION_KEY.to_owned(), Value::from(current.major)); + Ok(MigrationReport { + from, + to: current, + applied, + }) + } +} + +/// 收据闸门:迁移前问一句「这次该不该跑」。 +#[derive(Debug, Clone, Copy)] +pub struct MigrationGate<'a> { + receipt_path: &'a Path, + now_ms: u64, +} + +impl<'a> MigrationGate<'a> { + /// 绑定收据路径与当前时间(毫秒 since UNIX_EPOCH,注入便于测试)。 + #[must_use] + pub const fn new(receipt_path: &'a Path, now_ms: u64) -> Self { + Self { + receipt_path, + now_ms, + } + } + + /// 当前时间。 + #[must_use] + pub const fn now_ms(&self) -> u64 { + self.now_ms + } + + /// 读收据并判断是否重放。 + /// + /// # Errors + /// 收据损坏或读失败时返回 [`crate::platform::error::PlatformError`]。 + pub fn evaluate(&self, source_fingerprint: &str) -> PlatformResult { + let existing = receipt::read(self.receipt_path)?; + Ok(receipt::decide(existing.as_ref(), source_fingerprint)) + } + + /// 是否该执行迁移(收据在且源指纹相同 → `false`)。 + /// + /// # Errors + /// 同 [`Self::evaluate`]。 + pub fn should_run(&self, source_fingerprint: &str) -> PlatformResult { + Ok(self.evaluate(source_fingerprint)?.should_replay()) + } + + /// 记录一次迁移收据。 + /// + /// # Errors + /// 写盘失败时返回 [`crate::platform::error::PlatformError`]。 + pub fn record(&self, receipt: &MigrationReceipt) -> PlatformResult<()> { + receipt::write(self.receipt_path, receipt) + } +} diff --git a/crates/core/src/setting/mod.rs b/crates/core/src/setting/mod.rs new file mode 100644 index 0000000..f968aae --- /dev/null +++ b/crates/core/src/setting/mod.rs @@ -0,0 +1,36 @@ +//! 配置与数据目录:三模式数据根(Portable 默认 / Standard / Custom)、schema 化配置、原子写、迁移收据。 +//! +//! 设计定稿:`DevDocs/PlanDoc/04-data-and-config.md`(决策 D6)。 +//! 刻意把 setting 放在 core 而不是 UI 层——这是 HMCL 的架构债 +//! (`DevDocs/analyse/01-architecture-overview.md` §3.5),我们反过来做。 +//! +//! 与其它模块的契约:所有模块的配置读写统一走本模块的 schema API, +//! 模块自有配置键需先在 `DevDocs/SubAgentsTalk/` 报备(见该目录 README 的对齐点)。 +//! 暴露给各模块的签名清单见 `DevDocs/SubAgentsTalk/setting-接口约定.md`。 +//! +//! 公共面(M12 已落地): +//! - [`schema`]:`$schema` / `schemaVersion` 标识、8 态兼容判定 → 三档访问权; +//! - [`spec`] + [`catalog`]:键元数据(类型/范围、默认值)与键目录; +//! - [`policy`]:AI 可写白名单与敏感分级(`setting::spec::AiPolicy` 仍是兼容别名); +//! - [`keys`] / [`instance_keys`]:`launcher.json`、`profiles.json`、`instances/.json` 的键表; +//! - [`appearance`]:D17「外观(高级)」键簇的取值快照与校验(主题三态 / 背景图 / 面板透明度 / +//! 玻璃拟态 / 强调色); +//! - [`document`]:内存文档(点分键、脏字段、未知字段保留、默认值不落盘); +//! - [`store`]:单文件加载降级 + 原子保存 + 坏文件备份; +//! - [`save`] / [`save_worker`]:200ms 去抖攒批与后台保存线程; +//! - [`migrate`]:`schemaVersion N → N+1` 迁移链 + 收据闸门。 + +pub mod appearance; +pub mod catalog; +pub mod document; +pub mod error; +pub mod instance_keys; +pub mod json_path; +pub mod keys; +pub mod migrate; +pub mod policy; +pub mod save; +pub mod save_worker; +pub mod schema; +pub mod spec; +pub mod store; diff --git a/crates/core/src/setting/policy.rs b/crates/core/src/setting/policy.rs new file mode 100644 index 0000000..f071ace --- /dev/null +++ b/crates/core/src/setting/policy.rs @@ -0,0 +1,92 @@ +//! 键的 AI 边界:可写白名单与敏感分级(弹窗确认)。 +//! +//! 出处:`DevDocs/PlanDoc/02-ai-agent-design.md` §3.1(AI 安全边界:白名单 + 类型/范围校验 + +//! 分级确认)、`DevDocs/PlanDoc/04-data-and-config.md` §3(schema 化)。 +//! +//! 从 [`super::spec`] 拆出来的原因很实际:`spec.rs` 摸到 400 行硬上限(`guards.yml` 的 +//! file-size 门禁),而「策略」与「键的形状」本来就是两件事。 + +/// 敏感分级:决定 AI 改这个键时要不要弹窗。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Sensitivity { + /// 普通键:静默生效。 + Low, + /// 静默生效但审计标记(便于回看「AI 动过什么」)。 + Medium, + /// 敏感键:必须 UI 弹窗确认。 + High, +} + +impl Sensitivity { + /// 稳定名。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + } + } + + /// 是否必须人工确认。 + #[must_use] + pub const fn requires_confirmation(&self) -> bool { + matches!(self, Self::High) + } +} + +/// 一个键的 AI 可写策略。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AiPolicy { + /// AI 是否允许写这个键(白名单)。 + pub writable: bool, + /// 敏感分级。 + pub sensitivity: Sensitivity, +} + +impl AiPolicy { + /// 普通键:AI 可写,静默生效。 + #[must_use] + pub const fn silent() -> Self { + Self { + writable: true, + sensitivity: Sensitivity::Low, + } + } + + /// 可写但要审计标记。 + #[must_use] + pub const fn audited() -> Self { + Self { + writable: true, + sensitivity: Sensitivity::Medium, + } + } + + /// 可写但必须弹窗确认(java 路径 / 数据目录 / 代理 / AI 端点 / 隔离档位)。 + #[must_use] + pub const fn sensitive() -> Self { + Self { + writable: true, + sensitivity: Sensitivity::High, + } + } + + /// 已废弃键:AI 不可写,读兼容映射到替代键后由替代键承担策略。 + #[must_use] + pub const fn retired() -> Self { + Self { + writable: false, + sensitivity: Sensitivity::Low, + } + } + + /// 锁死:AI 永远不能写(凭据/密钥,D9「AI 永不触凭据」)。 + #[must_use] + pub const fn locked() -> Self { + Self { + writable: false, + sensitivity: Sensitivity::High, + } + } +} diff --git a/crates/core/src/setting/save.rs b/crates/core/src/setting/save.rs new file mode 100644 index 0000000..679469d --- /dev/null +++ b/crates/core/src/setting/save.rs @@ -0,0 +1,217 @@ +//! 保存通道的**去抖攒批**部分(纯逻辑,可注入时钟,确定性单测)。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.6(HMCL `FileSaver`): +//! 入队 → 消费线程先 `sleep(200ms)` 等更多改动 → 全量 drain 去重 → 逐条 `saveSync`(tmp + move)。 +//! 效果:一次窗口拖拽只产生一次写盘,而不是 60 次。 +//! +//! 分层:本文件只管「什么时候写、写什么」;真正落盘由 [`JobSink`] 决定 +//! (生产用 [`AtomicSink`],测试用记录型 sink)。 + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use super::error::SettingResult; + +/// 默认去抖窗口(analyse/07 §1.6 的 200ms)。 +pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(200); + +/// 一条待写任务。 +#[derive(Debug, Clone, PartialEq, Eq)] +struct Pending { + payload: Vec, + queued_at: Instant, +} + +/// 按 key 去重的去抖队列:同一 key 的新内容覆盖旧内容,但**保留首次入队时间** +/// (窗口从第一次改动开始算,避免连续改动把写盘无限推迟)。 +#[derive(Debug, Clone)] +pub struct DebounceQueue { + debounce: Duration, + pending: BTreeMap, +} + +impl DebounceQueue { + /// 新建一个去抖队列。 + #[must_use] + pub fn new(debounce: Duration) -> Self { + Self { + debounce, + pending: BTreeMap::new(), + } + } + + /// 去抖窗口。 + #[must_use] + pub const fn debounce(&self) -> Duration { + self.debounce + } + + /// 入队(同 key 覆盖内容)。返回 `true` 表示是新增条目。 + pub fn enqueue(&mut self, key: K, payload: impl Into>, now: Instant) -> bool { + match self.pending.get_mut(&key) { + Some(existing) => { + existing.payload = payload.into(); + false + } + None => { + self.pending.insert( + key, + Pending { + payload: payload.into(), + queued_at: now, + }, + ); + true + } + } + } + + /// 待写条目数。 + #[must_use] + pub fn len(&self) -> usize { + self.pending.len() + } + + /// 是否没有待写任务。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.pending.is_empty() + } + + /// 某 key 是否在队列里。 + #[must_use] + pub fn contains(&self, key: &K) -> bool { + self.pending.contains_key(key) + } + + /// 最早该写盘的时刻。 + #[must_use] + pub fn next_due(&self) -> Option { + self.pending + .values() + .map(|pending| pending.queued_at + self.debounce) + .min() + } + + /// 取出所有已过窗口的任务(按键排序,保证写盘顺序稳定)。 + pub fn drain_due(&mut self, now: Instant) -> Vec<(K, Vec)> { + let due: Vec = self + .pending + .iter() + .filter(|(_, pending)| pending.queued_at + self.debounce <= now) + .map(|(key, _)| key.clone()) + .collect(); + self.take(due) + } + + /// 取出全部任务(关停兜底)。 + pub fn drain_all(&mut self) -> Vec<(K, Vec)> { + let all: Vec = self.pending.keys().cloned().collect(); + self.take(all) + } + + fn take(&mut self, keys: Vec) -> Vec<(K, Vec)> { + keys.into_iter() + .filter_map(|key| { + self.pending + .remove(&key) + .map(|pending| (key, pending.payload)) + }) + .collect() + } +} + +impl Default for DebounceQueue { + fn default() -> Self { + Self::new(DEFAULT_DEBOUNCE) + } +} + +/// 真正落盘的执行者(测试可注入记录型实现)。 +pub trait JobSink { + /// 写一个文件。 + /// + /// # Errors + /// 落盘失败时返回 [`super::error::SettingError`]。 + fn write(&self, path: &Path, payload: &[u8]) -> SettingResult<()>; +} + +/// 生产用 sink:tmp → fsync → rename。 +#[derive(Debug, Clone, Copy, Default)] +pub struct AtomicSink; + +impl JobSink for AtomicSink { + fn write(&self, path: &Path, payload: &[u8]) -> SettingResult<()> { + crate::platform::atomic::write_atomic(path, payload)?; + Ok(()) + } +} + +/// 去抖队列 + sink 的组合体(app 层持有它,或交给 [`super::save_worker::SaveWorker`] 托管)。 +#[derive(Debug, Clone)] +pub struct CoalescingWriter { + queue: DebounceQueue, + sink: S, +} + +impl CoalescingWriter { + /// 组合去抖队列与 sink。 + #[must_use] + pub fn new(sink: S, debounce: Duration) -> Self { + Self { + queue: DebounceQueue::new(debounce), + sink, + } + } + + /// 入队一次写盘。 + pub fn queue(&mut self, path: impl Into, payload: impl Into>, now: Instant) { + self.queue.enqueue(path.into(), payload, now); + } + + /// 待写条目数。 + #[must_use] + pub fn pending_len(&self) -> usize { + self.queue.len() + } + + /// 是否没有待写任务。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.queue.is_empty() + } + + /// 最早该写盘的时刻。 + #[must_use] + pub fn next_due(&self) -> Option { + self.queue.next_due() + } + + /// 写掉已过窗口的任务,返回写了几个文件。 + /// + /// # Errors + /// sink 落盘失败时返回 [`super::error::SettingError`]。 + pub fn flush_due(&mut self, now: Instant) -> SettingResult { + let jobs = self.queue.drain_due(now); + self.run(jobs) + } + + /// 写掉全部任务(关停兜底),返回写了几个文件。 + /// + /// # Errors + /// sink 落盘失败时返回 [`super::error::SettingError`]。 + pub fn flush_all(&mut self) -> SettingResult { + let jobs = self.queue.drain_all(); + self.run(jobs) + } + + fn run(&mut self, jobs: Vec<(PathBuf, Vec)>) -> SettingResult { + let mut written = 0usize; + for (path, payload) in jobs { + self.sink.write(&path, &payload)?; + written += 1; + } + Ok(written) + } +} diff --git a/crates/core/src/setting/save_worker.rs b/crates/core/src/setting/save_worker.rs new file mode 100644 index 0000000..78adf63 --- /dev/null +++ b/crates/core/src/setting/save_worker.rs @@ -0,0 +1,181 @@ +//! 去抖保存的线程封装:app 层持有它,界面改配置只入队,后台线程负责攒批落盘。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.6(HMCL `FileSaver` 消费线程 + +//! 关停钩子 `waitForAllSaves()`)。本实现的「攒批/去抖/落盘」逻辑在 [`super::save`], +//! 这里只做线程与命令通道,方便对时序逻辑保持确定性单测。 + +use std::io; +use std::path::PathBuf; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use super::error::{SettingError, SettingResult}; +use super::save::{AtomicSink, CoalescingWriter, DEFAULT_DEBOUNCE}; + +/// 线程没起时的兜底路径标记(只为让 [`SettingError::Io`] 有出处)。 +const WORKER_PATH: &str = ""; + +enum Command { + Queue { path: PathBuf, payload: Vec }, + Flush(Sender>), + Shutdown(Sender>), +} + +/// 后台保存线程句柄。 +#[derive(Debug)] +pub struct SaveWorker { + tx: Option>, + handle: Option>, +} + +impl SaveWorker { + /// 用默认 200ms 去抖窗口起线程。 + /// + /// # Errors + /// 线程创建失败时返回 [`SettingError::Io`]。 + pub fn spawn_default() -> SettingResult { + Self::spawn(DEFAULT_DEBOUNCE) + } + + /// 用指定去抖窗口起线程。 + /// + /// # Errors + /// 线程创建失败时返回 [`SettingError::Io`]。 + pub fn spawn(debounce: Duration) -> SettingResult { + let (tx, rx) = mpsc::channel(); + let handle = thread::Builder::new() + .name("ull-save".to_owned()) + .spawn(move || run_loop(&rx, debounce)) + .map_err(io_error)?; + Ok(Self { + tx: Some(tx), + handle: Some(handle), + }) + } + + /// 入队一次写盘(不阻塞)。 + /// + /// # Errors + /// 保存线程已停止时返回 [`SettingError::Io`]。 + pub fn queue( + &self, + path: impl Into, + payload: impl Into>, + ) -> SettingResult<()> { + let command = Command::Queue { + path: path.into(), + payload: payload.into(), + }; + self.send(command) + } + + /// 同步落盘(返回时队列已空,且错误已带回当前线程)。 + /// + /// # Errors + /// 保存线程已停止,或后台落盘失败时返回 [`SettingError`]。 + pub fn flush(&self) -> SettingResult<()> { + let (ack_tx, ack_rx) = mpsc::channel(); + self.send(Command::Flush(ack_tx))?; + ack_rx.recv().map_err(|_| { + io_error(io::Error::new( + io::ErrorKind::BrokenPipe, + "保存线程未回传结果", + )) + })? + } + + /// drain 全部任务后停止线程。 + /// + /// # Errors + /// 落盘失败时返回 [`SettingError`]。 + pub fn shutdown(&mut self) -> SettingResult<()> { + let Some(tx) = self.tx.take() else { + return Ok(()); + }; + let (ack_tx, ack_rx) = mpsc::channel(); + let sent = tx.send(Command::Shutdown(ack_tx)); + let result = match sent { + Ok(()) => ack_rx.recv().unwrap_or_else(|_| { + Err(io_error(io::Error::new( + io::ErrorKind::BrokenPipe, + "保存线程未回传结果", + ))) + }), + Err(_) => Ok(()), + }; + if let Some(handle) = self.handle.take() { + handle.join().ok(); + } + result + } + + fn send(&self, command: Command) -> SettingResult<()> { + let Some(tx) = self.tx.as_ref() else { + return Err(io_error(io::Error::new( + io::ErrorKind::BrokenPipe, + "保存线程已停止", + ))); + }; + tx.send(command) + .map_err(|_| io_error(io::Error::new(io::ErrorKind::BrokenPipe, "保存线程已停止"))) + } +} + +impl Drop for SaveWorker { + fn drop(&mut self) { + self.shutdown().ok(); + } +} + +fn run_loop(rx: &Receiver, debounce: Duration) { + let mut writer = CoalescingWriter::new(AtomicSink, debounce); + let mut last_error: Option = None; + + loop { + let wait = writer + .next_due() + .map(|due| due.saturating_duration_since(Instant::now())) + .unwrap_or(Duration::from_secs(3600)) + .max(Duration::from_millis(1)); + + match rx.recv_timeout(wait) { + Ok(Command::Queue { path, payload }) => { + writer.queue(path, payload, Instant::now()); + } + Ok(Command::Flush(ack)) => { + let result = combine(last_error.take(), writer.flush_all()); + ack.send(result).ok(); + } + Ok(Command::Shutdown(ack)) => { + let result = combine(last_error.take(), writer.flush_all()); + ack.send(result).ok(); + break; + } + Err(RecvTimeoutError::Timeout) => { + if let Err(err) = writer.flush_due(Instant::now()) { + last_error = Some(err); + } + } + Err(RecvTimeoutError::Disconnected) => { + writer.flush_all().ok(); + break; + } + } + } +} + +/// 后台静默失败过的错误优先上报(不吞掉)。 +fn combine(previous: Option, result: SettingResult) -> SettingResult<()> { + match previous { + Some(err) => Err(err), + None => result.map(|_| ()), + } +} + +fn io_error(source: io::Error) -> SettingError { + SettingError::Io { + path: PathBuf::from(WORKER_PATH), + source, + } +} diff --git a/crates/core/src/setting/schema.rs b/crates/core/src/setting/schema.rs new file mode 100644 index 0000000..5849f00 --- /dev/null +++ b/crates/core/src/setting/schema.rs @@ -0,0 +1,292 @@ +//! `$schema` 与 `schemaVersion`:版本标识、8 态兼容判定、三档访问权。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.5(`JsonSchema.check` 8 态 + +//! `SettingFileAccess` 三档),`DevDocs/PlanDoc/04-data-and-config.md` §3。 +//! +//! 我们抄的是「一个 `$schema` 字段决定可读/可写/是否保留原 schema」,不抄 HMCL 的 +//! `major.minor.patch` 三级分支——ULL 的迁移链按 **major** 走:`1.0.0 → 2.0.0 → 3.0.0`。 + +use std::fmt; + +use serde_json::Value; + +/// `$schema` 键名。 +pub const SCHEMA_KEY: &str = "$schema"; +/// `schemaVersion` 键名(整数镜像 = major)。 +pub const SCHEMA_VERSION_KEY: &str = "schemaVersion"; +/// schema URL 前缀(逻辑标识,暂不对外发布)。 +pub const SCHEMA_URL_PREFIX: &str = "https://schemas.unlucky-launcher.dev/ull/"; + +/// 配置文件的语义版本。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +pub struct SchemaVersion { + /// 主版本(迁移链单位)。 + pub major: u32, + /// 次版本(新版本文件只读)。 + pub minor: u32, + /// 修订号。 + pub patch: u32, +} + +impl SchemaVersion { + /// 构造。 + #[must_use] + pub const fn new(major: u32, minor: u32, patch: u32) -> Self { + Self { + major, + minor, + patch, + } + } + + /// 解析 `1.0.0` 形式。 + #[must_use] + pub fn parse(text: &str) -> Option { + let mut parts = text.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + Some(Self::new(major, minor, patch)) + } + + /// 下一个主版本(迁移链的一步)。 + #[must_use] + pub const fn next_major(&self) -> Self { + Self::new(self.major + 1, 0, 0) + } +} + +impl fmt::Display for SchemaVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +/// 配置文件种类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SchemaId(&'static str); + +impl SchemaId { + /// `config/launcher.json`。 + pub const LAUNCHER: Self = Self("launcher"); + /// `config/profiles.json`。 + pub const PROFILES: Self = Self("profiles"); + /// `config/instances/.json`。 + pub const INSTANCE: Self = Self("instance"); + + /// 全部已知 id。 + pub const ALL: [Self; 3] = [Self::LAUNCHER, Self::PROFILES, Self::INSTANCE]; + + /// 稳定名。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + self.0 + } + + /// 从字符串解析(只认已知 id)。 + #[must_use] + pub fn parse(text: &str) -> Option { + Self::ALL.into_iter().find(|id| id.0 == text) + } +} + +/// `$schema` 的完整标识:种类 + 版本。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SchemaRef { + /// 种类。 + pub id: SchemaId, + /// 版本。 + pub version: SchemaVersion, +} + +impl SchemaRef { + /// 构造。 + #[must_use] + pub const fn new(id: SchemaId, version: SchemaVersion) -> Self { + Self { id, version } + } + + /// 写进文件的 `$schema` 字符串。 + #[must_use] + pub fn url(&self) -> String { + format!( + "{SCHEMA_URL_PREFIX}{}/{}.{}.{}", + self.id.as_str(), + self.version.major, + self.version.minor, + self.version.patch + ) + } + + /// 反解 `$schema` 字符串。 + #[must_use] + pub fn parse_url(text: &str) -> Option { + let tail = text.strip_prefix(SCHEMA_URL_PREFIX)?; + let (id, version) = tail.split_once('/')?; + Some(Self::new( + SchemaId::parse(id)?, + SchemaVersion::parse(version)?, + )) + } +} + +/// 文件与本实现期望 schema 的兼容状态(8 态)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SchemaCompat { + /// 同版本或更旧的版本:可读可写,正常升级。 + ReadWrite, + /// 同 minor、更高 patch:可读可写,但保留原 `$schema`。 + ReadWritePreserveSchema, + /// 同 major、更高 minor:可读不可写(新版写的文件),保留原 `$schema`。 + ReadOnlyPreserveSchema, + /// 文件没有 `$schema`(首建 / 遗留格式)。 + Missing, + /// `$schema` 不是我们能解析的 URL。 + /// + /// 手写配置最容易踩的一格:前缀写错(`https://unlucky-launcher.local/schema/...`)后整份 + /// 文件的价值都不该被丢掉,所以 M40 起按**只读**处理——值照读、不静默回落默认值, + /// 同时由 [`super::document::LoadProblem::UnknownSchema`] 明确告警。 + Invalid, + /// 文件连 JSON 都解析不了(由加载方给出)。 + Unparseable, + /// `$schema` 指向别的配置文件种类。 + UnexpectedId, + /// 文件 major 高于本实现——不认识,绝不能覆盖。 + UnsupportedMajor, +} + +impl SchemaCompat { + /// 判定文件里的 `$schema`(缺失时由调用方给 [`Self::Missing`])。 + #[must_use] + pub fn check(value: &Value, expected: SchemaRef) -> Self { + match value.get(SCHEMA_KEY).and_then(Value::as_str) { + None => Self::Missing, + Some(url) => Self::check_url(url, expected), + } + } + + /// 判定 `$schema` 字符串。 + #[must_use] + pub fn check_url(url: &str, expected: SchemaRef) -> Self { + let Some(found) = SchemaRef::parse_url(url) else { + return Self::Invalid; + }; + if found.id != expected.id { + return Self::UnexpectedId; + } + if found.version.major > expected.version.major { + return Self::UnsupportedMajor; + } + if found.version.major < expected.version.major { + // 旧文件:正常升级(迁移链负责搬运语义)。 + return Self::ReadWrite; + } + if found.version.minor > expected.version.minor { + return Self::ReadOnlyPreserveSchema; + } + if found.version.minor == expected.version.minor + && found.version.patch > expected.version.patch + { + return Self::ReadWritePreserveSchema; + } + Self::ReadWrite + } + + /// 稳定名(日志 / 收据)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::ReadWrite => "read_write", + Self::ReadWritePreserveSchema => "read_write_preserve_schema", + Self::ReadOnlyPreserveSchema => "read_only_preserve_schema", + Self::Missing => "missing", + Self::Invalid => "invalid", + Self::Unparseable => "unparseable", + Self::UnexpectedId => "unexpected_id", + Self::UnsupportedMajor => "unsupported_major", + } + } + + /// 是否可读。 + #[must_use] + pub const fn readable(&self) -> bool { + matches!( + self, + Self::ReadWrite + | Self::ReadWritePreserveSchema + | Self::ReadOnlyPreserveSchema + | Self::Missing + | Self::Invalid + ) + } + + /// 保存时是否沿用文件里原有的 `$schema` 字符串(不降级覆盖新版本标记)。 + #[must_use] + pub const fn preserve_schema(&self) -> bool { + matches!( + self, + Self::ReadWritePreserveSchema | Self::ReadOnlyPreserveSchema + ) + } + + /// 映射到运行时三档访问权。 + /// + /// [`Self::Invalid`] 走**只读**:认不出的 `$schema` 前缀不该让整份配置失效(值照读, + /// 由 [`super::document::LoadProblem::UnknownSchema`] 告警),但也不该被我们悄悄改写。 + #[must_use] + pub const fn access(&self) -> FileAccess { + match self { + Self::ReadWrite | Self::ReadWritePreserveSchema | Self::Missing => { + FileAccess::ReadWrite + } + Self::ReadOnlyPreserveSchema | Self::Invalid => FileAccess::ReadOnly, + Self::Unparseable | Self::UnexpectedId | Self::UnsupportedMajor => { + FileAccess::Unreadable + } + } + } +} + +/// 运行时访问权三档(锁文件/权限异常时降级运行而非崩溃)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileAccess { + /// 可读可写。 + ReadWrite, + /// 可读不可写(新版文件 / 权限受限)。 + ReadOnly, + /// 读不了:值不可信,全程不写(防止覆盖新版本配置)。 + Unreadable, +} + +impl FileAccess { + /// 能否使用文件里的值。 + #[must_use] + pub const fn can_read(&self) -> bool { + !matches!(self, Self::Unreadable) + } + + /// 能否保存。 + #[must_use] + pub const fn can_save(&self) -> bool { + matches!(self, Self::ReadWrite) + } + + /// UI 是否该禁用编辑。 + #[must_use] + pub const fn blocks_editing(&self) -> bool { + !self.can_save() + } + + /// 稳定名。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::ReadWrite => "read_write", + Self::ReadOnly => "read_only", + Self::Unreadable => "unreadable", + } + } +} diff --git a/crates/core/src/setting/spec.rs b/crates/core/src/setting/spec.rs new file mode 100644 index 0000000..90a751f --- /dev/null +++ b/crates/core/src/setting/spec.rs @@ -0,0 +1,331 @@ +//! 配置键元数据:类型/范围、默认值、AI 可写白名单、敏感分级。 +//! +//! 出处:`DevDocs/PlanDoc/04-data-and-config.md` §3(schema 化 + AI 可写键 = schema 子集)、 +//! `DevDocs/PlanDoc/02-ai-agent-design.md` §3.1(AI 安全边界:白名单 + 类型/范围校验 + 分级确认)。 +//! +//! 这份元数据是「唯一真相源」:UI 设置页、AI 的 `get_config`/`set_config`、审计日志都读它。 + +use serde_json::{json, Value}; + +/// 键的值类型与约束。 +#[derive(Debug, Clone, PartialEq)] +pub enum ValueKind { + /// 布尔。 + Bool, + /// 整数(含闭区间范围)。 + Int { + /// 下界(含)。 + min: i64, + /// 上界(含)。 + max: i64, + }, + /// 浮点(含闭区间范围)。 + Float { + /// 下界(含)。 + min: f64, + /// 上界(含)。 + max: f64, + }, + /// 文本(长度上限,按字符数)。 + Text { + /// 最大字符数。 + max_len: usize, + }, + /// 枚举(只允许列出的字面量)。 + Enum(&'static [&'static str]), + /// 路径(非空字符串;绝对/相对都由业务层决定)。 + Path, + /// 字符串列表。 + TextList, + /// 枚举字面量数组:每个元素都必须落在白名单里(`overrides` 这类键用)。 + EnumList(&'static [&'static str]), + /// 任意 JSON(数据结构本身不参与校验,用于档案列表这类「数据而非配置」的键)。 + Any, +} + +impl ValueKind { + /// 校验;返回中文原因便于直接给用户看。 + pub fn check(&self, value: &Value) -> Result<(), String> { + match self { + Self::Bool => value + .is_boolean() + .then_some(()) + .ok_or_else(|| format!("期望布尔,实际是 {value}")), + Self::Int { min, max } => { + let Some(number) = value.as_i64() else { + return Err(format!("期望整数,实际是 {value}")); + }; + if number < *min || number > *max { + return Err(format!("{number} 超出范围 [{min}, {max}]")); + } + Ok(()) + } + Self::Float { min, max } => { + let Some(number) = value.as_f64() else { + return Err(format!("期望数字,实际是 {value}")); + }; + if number < *min || number > *max { + return Err(format!("{number} 超出范围 [{min}, {max}]")); + } + Ok(()) + } + Self::Text { max_len } => { + let Some(text) = value.as_str() else { + return Err(format!("期望文本,实际是 {value}")); + }; + let len = text.chars().count(); + if len > *max_len { + return Err(format!("文本 {len} 字符,超过上限 {max_len}")); + } + Ok(()) + } + Self::Enum(allowed) => { + let Some(text) = value.as_str() else { + return Err(format!("期望枚举字面量,实际是 {value}")); + }; + if allowed.contains(&text) { + Ok(()) + } else { + Err(format!("{text:?} 不在 {allowed:?} 之中")) + } + } + Self::Path => match value.as_str() { + Some(text) if !text.trim().is_empty() => Ok(()), + _ => Err(format!("期望非空路径字符串,实际是 {value}")), + }, + Self::TextList => { + let Some(items) = value.as_array() else { + return Err(format!("期望字符串数组,实际是 {value}")); + }; + if items.iter().all(Value::is_string) { + Ok(()) + } else { + Err("数组里含非字符串元素".to_owned()) + } + } + Self::EnumList(allowed) => { + let Some(items) = value.as_array() else { + return Err(format!("期望枚举字面量数组,实际是 {value}")); + }; + for item in items { + let Some(text) = item.as_str() else { + return Err(format!("数组里含非字符串元素: {item}")); + }; + if !allowed.contains(&text) { + return Err(format!("{text:?} 不在 {allowed:?} 之中")); + } + } + Ok(()) + } + Self::Any => Ok(()), + } + } + + /// 转成 JSON Schema 片段(喂给 M16 的 `ToolSpec`)。 + #[must_use] + pub(crate) fn to_json_schema(&self) -> Value { + match self { + Self::Bool => json!({ "type": "boolean" }), + Self::Int { min, max } => json!({ "type": "integer", "minimum": min, "maximum": max }), + Self::Float { min, max } => json!({ "type": "number", "minimum": min, "maximum": max }), + Self::Text { max_len } => json!({ "type": "string", "maxLength": max_len }), + Self::Enum(allowed) => json!({ "type": "string", "enum": allowed }), + Self::Path => json!({ "type": "string", "minLength": 1 }), + Self::TextList => json!({ "type": "array", "items": { "type": "string" } }), + Self::EnumList(allowed) => { + json!({ "type": "array", "items": { "type": "string", "enum": allowed } }) + } + Self::Any => json!({}), + } + } +} + +// AI 边界类型从 [`super::policy`] 拆出去(`spec.rs` 摸到 400 行硬上限), +// 路径保持不变:`setting::spec::AiPolicy` 仍是可用名。 +pub use super::policy::{AiPolicy, Sensitivity}; + +/// 键废弃信息:替代键 + 旧值映射表。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Deprecation { + /// 替代键(写永远走这里)。 + pub replaced_by: &'static str, + /// 旧值 → 新值映射(读兼容);表里没有的值原样带过去。 + pub aliases: &'static [(&'static str, &'static str)], +} + +impl Deprecation { + /// 把旧值翻译成新键能接受的值(非字符串或表里没有的值原样返回)。 + #[must_use] + pub fn translate(&self, legacy: &Value) -> Value { + match legacy.as_str() { + Some(text) => self + .aliases + .iter() + .find(|(old, _)| *old == text) + .map_or_else(|| legacy.clone(), |(_, new)| Value::from(*new)), + None => legacy.clone(), + } + } +} + +/// 一个配置键的完整元数据。 +#[derive(Debug, Clone, PartialEq)] +pub struct SettingSpec { + /// 点分键名(如 `download.concurrency`)。 + pub key: &'static str, + /// 类型与约束。 + pub kind: ValueKind, + /// 默认值;`Value::Null` 表示「无默认」(如用户必须先选的路径)。 + pub default: Value, + /// AI 策略。 + pub ai: AiPolicy, + /// 一句话说明(UI 提示 + AI 工具描述)。 + pub doc: &'static str, + /// 废弃信息:有值表示这个键只做**读兼容**,写必须走替代键。 + pub deprecated: Option, +} + +impl SettingSpec { + /// 完全手工构造。 + #[must_use] + pub const fn new( + key: &'static str, + kind: ValueKind, + default: Value, + ai: AiPolicy, + doc: &'static str, + ) -> Self { + Self { + key, + kind, + default, + ai, + doc, + deprecated: None, + } + } + + /// 标记为「已废弃,读兼容到 `replaced_by`」,并按 `aliases` 翻译旧值。 + /// + /// 用于键改名/语义变更(如 `ui.theme` → `appearance.theme_mode`,tower 键冲突裁决): + /// 旧文件照读、下次保存只写新键,AI 白名单与工具 JSON Schema 自动排除本键。 + #[must_use] + pub const fn deprecated( + mut self, + replaced_by: &'static str, + aliases: &'static [(&'static str, &'static str)], + ) -> Self { + self.deprecated = Some(Deprecation { + replaced_by, + aliases, + }); + self + } + + /// 该键是否已废弃。 + #[must_use] + pub const fn is_deprecated(&self) -> bool { + self.deprecated.is_some() + } + + /// 布尔键。 + #[must_use] + pub fn bool(key: &'static str, default: bool, ai: AiPolicy, doc: &'static str) -> Self { + Self::new(key, ValueKind::Bool, Value::Bool(default), ai, doc) + } + + /// 整数键(闭区间)。 + #[must_use] + pub fn int( + key: &'static str, + default: i64, + (min, max): (i64, i64), + ai: AiPolicy, + doc: &'static str, + ) -> Self { + Self::new( + key, + ValueKind::Int { min, max }, + Value::from(default), + ai, + doc, + ) + } + + /// 浮点键(闭区间)。 + #[must_use] + pub fn float( + key: &'static str, + default: f64, + (min, max): (f64, f64), + ai: AiPolicy, + doc: &'static str, + ) -> Self { + Self::new( + key, + ValueKind::Float { min, max }, + Value::from(default), + ai, + doc, + ) + } + + /// 文本键。 + #[must_use] + pub fn text( + key: &'static str, + default: &str, + max_len: usize, + ai: AiPolicy, + doc: &'static str, + ) -> Self { + Self::new( + key, + ValueKind::Text { max_len }, + Value::from(default), + ai, + doc, + ) + } + + /// 枚举键。 + #[must_use] + pub fn enumeration( + key: &'static str, + default: &'static str, + allowed: &'static [&'static str], + ai: AiPolicy, + doc: &'static str, + ) -> Self { + Self::new(key, ValueKind::Enum(allowed), Value::from(default), ai, doc) + } + + /// 路径键(无默认值,未设置时为 `null`)。 + #[must_use] + pub fn path(key: &'static str, ai: AiPolicy, doc: &'static str) -> Self { + Self::new(key, ValueKind::Path, Value::Null, ai, doc) + } + + /// 枚举字面量数组键(默认空数组;元素逐个校验)。 + #[must_use] + pub fn enum_list( + key: &'static str, + allowed: &'static [&'static str], + ai: AiPolicy, + doc: &'static str, + ) -> Self { + Self::new( + key, + ValueKind::EnumList(allowed), + Value::Array(Vec::new()), + ai, + doc, + ) + } + + /// 任意 JSON 键(数据结构不参与校验)。 + #[must_use] + pub fn any(key: &'static str, ai: AiPolicy, doc: &'static str) -> Self { + Self::new(key, ValueKind::Any, Value::Null, ai, doc) + } +} diff --git a/crates/core/src/setting/store.rs b/crates/core/src/setting/store.rs new file mode 100644 index 0000000..737bc04 --- /dev/null +++ b/crates/core/src/setting/store.rs @@ -0,0 +1,301 @@ +//! 一个配置文件 = 一个 [`SettingStore`]:加载降级 → 编辑 → 原子保存 → 坏文件备份。 +//! +//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.5-§1.6(8 态 schema / 三档访问权 / +//! 「文件损坏 → 默认值 + 下次保存前备份 `xxx.json.1`」),`DevDocs/PlanDoc/04-data-and-config.md` §3。 +//! +//! 加载永不 panic:读不了/版本不认识就降级成只读或不可读,让启动器带着默认值继续跑 +//! (analyse/07 §1「锁文件/权限异常时降级运行而非崩溃」)。 + +use std::io; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::platform::atomic; + +use super::catalog::SettingSchema; +use super::document::{LoadProblem, SettingDocument}; +use super::error::{SettingError, SettingResult}; +use super::migrate::{MigrationChain, MigrationReport}; +use super::schema::{FileAccess, SchemaCompat, SCHEMA_KEY, SCHEMA_VERSION_KEY}; + +/// 保存结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SaveOutcome { + /// 已原子落盘。 + Saved(PathBuf), + /// 没有写盘,原因见 [`SkipReason`]。 + Skipped(SkipReason), +} + +impl SaveOutcome { + /// 是否真的写了盘。 + #[must_use] + pub fn wrote(&self) -> bool { + matches!(self, Self::Saved(_)) + } +} + +/// 跳过保存的原因。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkipReason { + /// 没有脏字段。 + NotDirty, + /// 文件只读(`READ_ONLY`)。 + ReadOnly, + /// 文件不可信(`UNREADABLE`),全程不写。 + Unreadable, +} + +impl SkipReason { + /// 稳定名。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::NotDirty => "not_dirty", + Self::ReadOnly => "read_only", + Self::Unreadable => "unreadable", + } + } +} + +/// 单个配置文件的内存 + 磁盘视图。 +#[derive(Debug, Clone)] +pub struct SettingStore { + path: PathBuf, + doc: SettingDocument, + access: FileAccess, + compat: SchemaCompat, + problems: Vec, + backup_on_next_save: bool, +} + +impl SettingStore { + /// 加载(或首建)一个配置文件;永不失败,状态从 [`Self::access`] 读。 + #[must_use] + pub fn load(path: impl Into, schema: SettingSchema) -> Self { + let mut store = Self { + path: path.into(), + doc: SettingDocument::new(schema.clone()), + access: FileAccess::ReadWrite, + compat: SchemaCompat::Missing, + problems: Vec::new(), + backup_on_next_save: false, + }; + store.reload_inner(); + store + } + + /// 文件路径。 + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + /// 访问权三档。 + #[must_use] + pub const fn access(&self) -> FileAccess { + self.access + } + + /// `$schema` 兼容状态。 + #[must_use] + pub const fn compat(&self) -> SchemaCompat { + self.compat + } + + /// 加载时的问题清单(未知键 / 坏值 / 版本镜像不一致…)。 + #[must_use] + pub fn problems(&self) -> &[LoadProblem] { + &self.problems + } + + /// 只读视图。 + #[must_use] + pub const fn doc(&self) -> &SettingDocument { + &self.doc + } + + /// 可变视图(迁移 / 导入用;正常改配置请走 [`Self::set`],那里带访问权检查)。 + pub const fn doc_mut(&mut self) -> &mut SettingDocument { + &mut self.doc + } + + /// 生效值:文件值 → 默认值。 + #[must_use] + pub fn effective(&self, key: &str) -> Option<&Value> { + self.doc.effective(key) + } + + /// 是否有未落盘改动。 + #[must_use] + pub fn is_dirty(&self) -> bool { + self.doc.is_dirty() + } + + /// 改一个键(只读/不可读时直接拒绝,不静默丢改动)。 + /// + /// # Errors + /// 文件只读 → [`SettingError::ReadOnly`];不可读 → [`SettingError::Unreadable`]; + /// 键值不合法 → [`SettingError::InvalidValue`] / [`SettingError::UnknownKey`]。 + pub fn set(&mut self, key: &str, value: Value) -> SettingResult<()> { + match self.access { + FileAccess::ReadWrite => self.doc.set(key, value), + FileAccess::ReadOnly => Err(SettingError::ReadOnly { + path: self.path.clone(), + }), + FileAccess::Unreadable => Err(SettingError::Unreadable { + path: self.path.clone(), + compat: self.compat, + }), + } + } + + /// 保存(只写脏字段、原子落盘;不满足条件时返回 [`SaveOutcome::Skipped`])。 + /// + /// # Errors + /// 序列化 / 备份 / 写盘失败时返回 [`SettingError`]。 + pub fn save(&mut self) -> SettingResult { + if !self.access.can_save() { + let reason = if matches!(self.access, FileAccess::ReadOnly) { + SkipReason::ReadOnly + } else { + SkipReason::Unreadable + }; + return Ok(SaveOutcome::Skipped(reason)); + } + if !self.doc.is_dirty() { + return Ok(SaveOutcome::Skipped(SkipReason::NotDirty)); + } + if self.backup_on_next_save { + self.backup_existing()?; + } + self.write_document()?; + Ok(SaveOutcome::Saved(self.path.clone())) + } + + /// 显式强写(用户在 UI 上确认「覆盖只读/损坏文件」后调用):先备份,再覆盖,恢复可写。 + /// + /// # Errors + /// 备份或写盘失败时返回 [`SettingError`]。 + pub fn save_forced(&mut self) -> SettingResult { + self.backup_existing()?; + self.write_document()?; + self.access = FileAccess::ReadWrite; + self.backup_on_next_save = false; + Ok(SaveOutcome::Saved(self.path.clone())) + } + + /// 重新从磁盘加载(丢弃内存改动)。 + pub fn reload(&mut self) { + self.reload_inner(); + } + + /// 按迁移链把文件从旧版本推进到 schema 版本(补丁后再落盘由调用方决定)。 + /// + /// # Errors + /// 链不完整或某步迁移失败时返回 [`SettingError`]。 + pub fn apply_chain( + &mut self, + chain: &MigrationChain, + ) -> SettingResult> { + let schema = self.doc.schema().clone(); + if !self.access.can_read() || self.doc.version() == schema.version() { + return Ok(None); + } + let Value::Object(mut root) = self.doc.to_value() else { + return Ok(None); + }; + let report = chain.migrate(self.doc.version(), &mut root)?; + root.insert( + SCHEMA_KEY.to_owned(), + Value::String(schema.schema_ref().url()), + ); + root.insert( + SCHEMA_VERSION_KEY.to_owned(), + Value::from(schema.version().major), + ); + + let (mut document, problems) = SettingDocument::load(&schema, &Value::Object(root)); + document.set_version(schema.version()); + document.mark_all_dirty(); + self.doc = document; + self.problems = problems; + Ok(Some(report)) + } + + /// 下一个空位的备份路径(`launcher.json.1`、`.2`…)。 + #[must_use] + pub fn next_backup_path(&self) -> PathBuf { + for index in 1..=999u32 { + let candidate = PathBuf::from(format!("{}.{index}", self.path.display())); + if !candidate.exists() { + return candidate; + } + } + PathBuf::from(format!("{}.1", self.path.display())) + } + + fn reload_inner(&mut self) { + let schema = self.doc.schema().clone(); + self.doc = SettingDocument::new(schema.clone()); + self.problems.clear(); + self.access = FileAccess::ReadWrite; + self.compat = SchemaCompat::Missing; + self.backup_on_next_save = false; + + let bytes = match std::fs::read(&self.path) { + Ok(bytes) => bytes, + // 首建:可写 + 全默认值。 + Err(err) if err.kind() == io::ErrorKind::NotFound => return, + // 读不了(权限/被占用):值不可信,全程不写。 + Err(_) => { + self.access = FileAccess::Unreadable; + self.compat = SchemaCompat::Unparseable; + return; + } + }; + + match serde_json::from_slice::(&bytes) { + // 文件损坏:默认值 + 下次保存前先备份(不把坏文件当未知字段回写)。 + Err(_) => { + self.compat = SchemaCompat::Unparseable; + self.backup_on_next_save = true; + } + Ok(value) => { + let compat = SchemaCompat::check(&value, schema.schema_ref()); + self.compat = compat; + self.access = compat.access(); + if !self.access.can_read() { + return; + } + let (document, problems) = SettingDocument::load(&schema, &value); + self.doc = document; + self.problems = problems; + } + } + } + + fn write_document(&mut self) -> SettingResult<()> { + let value = self.doc.to_value(); + let bytes = serde_json::to_vec_pretty(&value).map_err(|source| SettingError::Json { + path: self.path.clone(), + source, + })?; + atomic::write_atomic(&self.path, &bytes)?; + self.doc.mark_clean(); + Ok(()) + } + + fn backup_existing(&mut self) -> SettingResult> { + if !self.path.exists() { + return Ok(None); + } + let backup = self.next_backup_path(); + std::fs::rename(&self.path, &backup).map_err(|source| SettingError::Io { + path: self.path.clone(), + source, + })?; + Ok(Some(backup)) + } +} diff --git a/crates/core/tests/auth/auth_failure_journal.rs b/crates/core/tests/auth/auth_failure_journal.rs new file mode 100644 index 0000000..a7e6755 --- /dev/null +++ b/crates/core/tests/auth/auth_failure_journal.rs @@ -0,0 +1,151 @@ +//! M38:认证失败事件落盘(`.ULL/logs/auth.jsonl`)与凭据红线。 +//! +//! 验收场景:`ull accounts add msa` 失败后,终端文案会滚走;日志区里要留下 +//! 一行结构化记录(`code` / `detail_code` / `status` / `content_type` / `body_kind` / +//! 已脱敏摘要),且**不含任何 token**。便携数据根还没初始化时先补建 `logs/`。 + +use serde_json::json; +use unlucky_core::auth::{AuthError, AuthFailureEvent, AuthJournal, BodyKind}; + +use crate::support::{ + assert_no_credentials, auth_for, expired_session, full_routes, routes_until_login, temp_dir, + AuthMock, MockReply, HTML_BLOCK_PAGE, +}; + +const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g"; + +#[tokio::test] +async fn body_digest_never_carries_credentials() { + let mut routes = routes_until_login(); + routes.push(( + "/login_with_xbox", + vec![MockReply::json_status( + 403, + json!({ "error": "DENIED", "echo": "xsts-token-value", "jwt": JWT }), + )], + )); + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + + let message = error.message(); + assert_no_credentials(&message); + // 长不透明串(JWT)必须打码,别把 token 回显抄进摘要。 + assert!(!message.contains("eyJhbGci"), "JWT 必须脱敏:{message}"); + assert!(message.contains("redacted"), "{message}"); + + // 事件层同样不落凭据。 + let event = AuthFailureEvent::of("device_code_login", &error); + let json = event.to_json().to_string(); + assert_no_credentials(&json); + assert!(!json.contains("eyJhbGci"), "{json}"); + assert!(json.contains("login_with_xbox_403"), "{json}"); + mock.stop(); +} + +#[tokio::test] +async fn failure_journal_initializes_logs_and_records_the_403() { + let root = temp_dir("diag-journal"); + let logs = root.join(".ULL").join("logs"); + let journal = AuthJournal::in_logs_dir(&logs); + assert!(!logs.exists(), "模拟未初始化的便携数据根"); + + let mut routes = routes_until_login(); + routes.push(( + "/login_with_xbox", + vec![MockReply::html(403, HTML_BLOCK_PAGE)], + )); + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .with_journal(Some(journal.clone())) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + + let text = std::fs::read_to_string(journal.path()).expect("失败日志必须落盘"); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 1, "一次失败一条记录:{text}"); + let record: serde_json::Value = serde_json::from_str(lines[0]).expect("合法 JSONL"); + assert_eq!(record["event"], "auth_failure"); + assert_eq!(record["level"], "error"); + assert_eq!(record["scope"], "device_code_login"); + assert_eq!(record["step"], "login_with_xbox"); + assert_eq!(record["detail_code"], "login_with_xbox_non_json"); + assert_eq!(record["status"], 403); + assert_eq!(record["body_kind"], "html"); + assert_eq!(record["transient"], true); + assert_eq!( + record["message"], + error.message(), + "事件里的文案要与返回的错误一致" + ); + assert_no_credentials(&text); + mock.stop(); + let _ = std::fs::remove_dir_all(&root); +} + +#[tokio::test] +async fn journal_event_shape_covers_non_chain_errors() { + let event = AuthFailureEvent::of("refresh", &AuthError::Canceled); + assert_eq!(event.detail_code, "canceled"); + assert_eq!(event.step, None); + assert_eq!(event.status, None); + assert_eq!(event.body_kind, None); + assert!(!event.transient); + assert_eq!(event.to_json()["scope"], "refresh"); + + let detail = unlucky_core::auth::ResponseDetail { + step: "profile".to_string(), + what: "拉取 Minecraft 档案被拒".to_string(), + status: 401, + content_type: Some("application/json".to_string()), + kind: BodyKind::Json, + server_message: Some("Unauthorized".to_string()), + digest: "{\"error\":\"Unauthorized\"}".to_string(), + }; + let chain = AuthFailureEvent::of("device_code_login", &AuthError::Response(Box::new(detail))); + assert_eq!(chain.step.as_deref(), Some("profile")); + assert_eq!(chain.detail_code, "profile_401"); + assert_eq!(chain.status, Some(401)); + assert!(!chain.transient, "JSON 拒绝不是可重试的网络问题"); +} + +#[tokio::test] +async fn successful_login_writes_no_journal_and_refresh_failures_do() { + let root = temp_dir("diag-journal-ok"); + let journal = AuthJournal::in_logs_dir(root.join("logs")); + + // 成功:不落盘。 + let mock = AuthMock::start(full_routes()).await; + auth_for(&mock.base()) + .with_journal(Some(journal.clone())) + .device_code_login(|_| {}) + .await + .expect("应登录成功"); + assert!(!journal.path().exists(), "成功路径不写失败日志"); + mock.stop(); + + // 刷新失败:落一条(凭据被撤销 = 要人重新登录)。 + let failing = AuthMock::start(vec![( + "/token", + vec![MockReply::json_status( + 400, + json!({ "error": "invalid_grant", "error_description": "AADSTS70000" }), + )], + )]) + .await; + let error = auth_for(&failing.base()) + .with_journal(Some(journal.clone())) + .refresh(&expired_session()) + .await + .expect_err("应失败"); + assert!(error.is_credential_expired()); + let text = std::fs::read_to_string(journal.path()).expect("刷新失败也要落盘"); + assert_eq!(text.lines().count(), 1, "{text}"); + assert!(text.contains("refresh"), "{text}"); + assert!(text.contains("credential_expired"), "{text}"); + failing.stop(); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/core/tests/auth/authlib_api.rs b/crates/core/tests/auth/authlib_api.rs new file mode 100644 index 0000000..dcdb8d7 --- /dev/null +++ b/crates/core/tests/auth/authlib_api.rs @@ -0,0 +1,307 @@ +//! Yggdrasil(authlib-injector)四动作的报文级对接:登录 / 刷新 / 校验 / 注销。 + +use serde_json::json; +use unlucky_core::auth::authlib::{ + AuthlibAuth, AuthlibEndpoints, AuthlibProfile, AuthlibServerMeta, AuthlibSession, +}; +use unlucky_core::auth::{AuthError, Secret}; + +use crate::support::{AuthMock, MockReply}; + +const API_ROOT: &str = "https://example.invalid/api/yggdrasil/"; +const PROFILE_ID: &str = "0123456789abcdef0123456789abcdef"; + +fn paths() -> (&'static str, &'static str, &'static str, &'static str) { + ( + "/api/yggdrasil/authserver/authenticate", + "/api/yggdrasil/authserver/refresh", + "/api/yggdrasil/authserver/validate", + "/api/yggdrasil/authserver/invalidate", + ) +} + +/// 把 API 根改指到 mock(`base` 代替 https://example.invalid)。 +fn mock_root(mock: &AuthMock) -> String { + format!("{}/api/yggdrasil", mock.base()) +} + +fn session() -> AuthlibSession { + AuthlibSession { + access_token: Secret::new("access-token-value"), + client_token: Secret::new("client-token-value"), + selected_profile: Some(AuthlibProfile { + id: PROFILE_ID.to_string(), + name: "Steve".to_string(), + }), + } +} + +#[tokio::test] +async fn authenticate_sends_the_canonical_body() { + let (authenticate, _, _, _) = paths(); + let mock = AuthMock::start(vec![( + authenticate, + vec![MockReply::json(json!({ + "accessToken": "access-token-value", + "clientToken": "client-token-value", + "selectedProfile": { "id": PROFILE_ID, "name": "Steve" } + }))], + )]) + .await; + let auth = AuthlibAuth::new().expect("构造"); + let login = auth + .authenticate( + &mock_root(&mock), + "steve@example.com", + &Secret::new("hunter2-password"), + &Secret::new("client-token-value"), + ) + .await + .expect("登录成功"); + + assert_eq!(login.access_token.expose(), "access-token-value"); + assert_eq!(login.client_token.expose(), "client-token-value"); + assert!(login.has_profile()); + assert_eq!( + login.selected_profile.as_ref().map(|p| p.name.as_str()), + Some("Steve") + ); + + let body = &mock.bodies_on(authenticate)[0]; + assert!( + body.contains("\"agent\":{\"name\":\"Minecraft\",\"version\":1}"), + "{body}" + ); + assert!(body.contains("\"requestUser\":true"), "{body}"); + assert!( + body.contains("\"username\":\"steve@example.com\""), + "{body}" + ); + assert!( + body.contains("\"clientToken\":\"client-token-value\""), + "{body}" + ); + // 密码确实发了(否则登录不了),但会话 Debug 里看不到它。 + assert!(body.contains("hunter2-password")); + assert!(!format!("{login:?}").contains("hunter2-password")); + mock.stop(); +} + +#[tokio::test] +async fn wrong_password_is_denied_with_server_message() { + let (authenticate, _, _, _) = paths(); + let mock = AuthMock::start(vec![( + authenticate, + vec![MockReply::json_status( + 403, + json!({ + "error": "ForbiddenOperationException", + "errorMessage": "Invalid credentials. Invalid username or password." + }), + )], + )]) + .await; + let auth = AuthlibAuth::new().expect("构造"); + let error = auth + .authenticate( + &mock_root(&mock), + "steve", + &Secret::new("wrong"), + &Secret::new("client-token-value"), + ) + .await + .expect_err("应失败"); + match error { + AuthError::Denied { code, description } => { + assert_eq!(code, "ForbiddenOperationException"); + assert!(description.contains("Invalid credentials")); + } + other => panic!("应是 Denied,实际 {other:?}"), + } + mock.stop(); +} + +#[tokio::test] +async fn server_must_echo_the_client_token() { + let (authenticate, _, _, _) = paths(); + let mock = AuthMock::start(vec![( + authenticate, + vec![MockReply::json(json!({ + "accessToken": "access-token-value", + "clientToken": "someone-elses-token", + "selectedProfile": { "id": PROFILE_ID, "name": "Steve" } + }))], + )]) + .await; + let auth = AuthlibAuth::new().expect("构造"); + let error = auth + .authenticate( + &mock_root(&mock), + "steve", + &Secret::new("pw"), + &Secret::new("client-token-value"), + ) + .await + .expect_err("应拒绝"); + assert_eq!(error.code(), "malformed"); + assert!( + error.message().contains("clientToken"), + "{}", + error.message() + ); + mock.stop(); +} + +#[tokio::test] +async fn refresh_can_select_a_character() { + let (_, refresh, _, _) = paths(); + let mock = AuthMock::start(vec![( + refresh, + vec![MockReply::json(json!({ + "accessToken": "access-token-2", + "clientToken": "client-token-value", + "selectedProfile": { "id": PROFILE_ID, "name": "Alex" } + }))], + )]) + .await; + let auth = AuthlibAuth::new().expect("构造"); + let wanted = AuthlibProfile { + id: PROFILE_ID.to_string(), + name: "Alex".to_string(), + }; + let refreshed = auth + .refresh(&mock_root(&mock), &session(), Some(&wanted)) + .await + .expect("刷新成功"); + assert_eq!(refreshed.access_token.expose(), "access-token-2"); + assert_eq!(refreshed.selected_profile.expect("角色").name, "Alex"); + // 新的 refresh_token 不存在于 Yggdrasil:clientToken 保持不变。 + assert_eq!(refreshed.client_token.expose(), "client-token-value"); + + let body = &mock.bodies_on(refresh)[0]; + assert!(body.contains("\"selectedProfile\":{\"id\":\"0123456789abcdef0123456789abcdef\"")); + assert!(body.contains("\"accessToken\":\"access-token-value\"")); + mock.stop(); +} + +#[tokio::test] +async fn refresh_that_did_not_switch_character_is_malformed() { + let (_, refresh, _, _) = paths(); + let mock = AuthMock::start(vec![( + refresh, + vec![MockReply::json(json!({ + "accessToken": "access-token-2", + "clientToken": "client-token-value", + "selectedProfile": { "id": PROFILE_ID, "name": "Steve" } + }))], + )]) + .await; + let auth = AuthlibAuth::new().expect("构造"); + let wanted = AuthlibProfile { + id: "ffffffffffffffffffffffffffffffff".to_string(), + name: "Ghost".to_string(), + }; + let error = auth + .refresh(&mock_root(&mock), &session(), Some(&wanted)) + .await + .expect_err("应失败"); + assert_eq!(error.code(), "malformed"); + mock.stop(); +} + +#[tokio::test] +async fn forbidden_on_refresh_means_credential_expired() { + let (_, refresh, _, _) = paths(); + let mock = AuthMock::start(vec![( + refresh, + vec![MockReply::json_status( + 403, + json!({ "error": "ForbiddenOperationException", "errorMessage": "Invalid token." }), + )], + )]) + .await; + let auth = AuthlibAuth::new().expect("构造"); + let error = auth + .refresh(&mock_root(&mock), &session(), None) + .await + .expect_err("应失败"); + assert!(error.is_credential_expired(), "{error:?}"); + mock.stop(); +} + +#[tokio::test] +async fn validate_distinguishes_invalid_token_from_server_trouble() { + let (_, _, validate, _) = paths(); + // 空体 200 → 有效 + let ok = AuthMock::start(vec![(validate, vec![MockReply::status(200)])]).await; + let auth = AuthlibAuth::new().expect("构造"); + assert!(auth + .validate(&mock_root(&ok), &session()) + .await + .expect("有效")); + ok.stop(); + + // 403 ForbiddenOperationException → 无效(返回值 false,不抛错) + let invalid = AuthMock::start(vec![( + validate, + vec![MockReply::json_status( + 403, + json!({ "error": "ForbiddenOperationException", "errorMessage": "Invalid token." }), + )], + )]) + .await; + assert!(!auth + .validate(&mock_root(&invalid), &session()) + .await + .expect("无效")); + invalid.stop(); + + // 500 → 服务器问题,必须抛错(调用方才会重试而不是刷新) + let broken = AuthMock::start(vec![(validate, vec![MockReply::status(500)])]).await; + let error = auth + .validate(&mock_root(&broken), &session()) + .await + .expect_err("应失败"); + assert_eq!(error.code(), "network"); + broken.stop(); +} + +#[tokio::test] +async fn invalidate_tolerates_already_invalid_tokens() { + let (_, _, _, invalidate) = paths(); + let auth = AuthlibAuth::new().expect("构造"); + let ok = AuthMock::start(vec![(invalidate, vec![MockReply::status(200)])]).await; + auth.invalidate(&mock_root(&ok), &session()) + .await + .expect("注销成功"); + ok.stop(); + + let gone = AuthMock::start(vec![( + invalidate, + vec![MockReply::json_status( + 403, + json!({ "error": "ForbiddenOperationException" }), + )], + )]) + .await; + auth.invalidate(&mock_root(&gone), &session()) + .await + .expect("已失效也算成功"); + gone.stop(); +} + +#[test] +fn endpoints_and_metadata_stay_in_sync_with_the_protocol() { + let endpoints = AuthlibEndpoints::from_api_root(API_ROOT).expect("端点"); + assert_eq!( + endpoints.authenticate, + format!("{API_ROOT}authserver/authenticate") + ); + let meta = AuthlibServerMeta::from_value(&json!({ + "meta": { "serverName": "Example", "feature.non_email_login": true }, + "skinDomains": ["example.invalid"] + })); + assert_eq!(meta.name.as_deref(), Some("Example")); + assert!(meta.non_email_login); + assert_eq!(meta.skin_domains, vec!["example.invalid".to_string()]); +} diff --git a/crates/core/tests/auth/main.rs b/crates/core/tests/auth/main.rs new file mode 100644 index 0000000..8f79b50 --- /dev/null +++ b/crates/core/tests/auth/main.rs @@ -0,0 +1,19 @@ +//! `core::auth` 的集成测试入口。 +//! +//! 每个子模块对应 mission 的一项验收: +//! - `offline_uuid`:离线 UUID 与 JDK(= HMCL 算法)**逐字节对拍**的已知向量; +//! - `store_separation`:元数据/凭据分文件、原子写、写序、容错与孤儿清理; +//! - `microsoft_flow`:device-code 全链(含 pending / slow_down / 取消 / 超时 / XErr); +//! - `microsoft_diagnostics`:非 2xx 正文诊断(JSON/HTML/空体分流)+ 失败日志落盘(M38); +//! - `microsoft_refresh`:refresh 轮换、profile 变脸防线、validate 语义、404 二义; +//! - `authlib_api`:Yggdrasil 四动作的报文级对接。 + +mod support; + +mod auth_failure_journal; +mod authlib_api; +mod microsoft_diagnostics; +mod microsoft_flow; +mod microsoft_refresh; +mod offline_uuid; +mod store_separation; diff --git a/crates/core/tests/auth/microsoft_diagnostics.rs b/crates/core/tests/auth/microsoft_diagnostics.rs new file mode 100644 index 0000000..1a90e11 --- /dev/null +++ b/crates/core/tests/auth/microsoft_diagnostics.rs @@ -0,0 +1,231 @@ +//! M38:非 2xx 应答正文诊断(`AuthError::Response`)。 +//! +//! 覆盖 MSA 真机验收里分不开的那几类: +//! - **JSON 403**:真实服务拒绝(服务端文案直出,引导查许可/区域); +//! - **HTML 403**:网关/风控拦截页(引导查网络/代理,独立细分码 `*_non_json`); +//! - **空体 403**:同样按「不确定」分流(`*_empty_body`); +//! - 4xx / 5xx 各一,外加 XErr 优先级与 JSON 码的向后兼容。 +//! +//! 失败日志(JSONL)与凭据红线在同一目录的 `auth_failure_journal.rs`。 + +use serde_json::json; +use unlucky_core::auth::{AuthError, BodyKind}; + +use crate::support::{ + assert_no_credentials, auth_for, full_routes, response_of, routes_until_login, AuthMock, + MockReply, HTML_BLOCK_PAGE, +}; + +#[tokio::test] +async fn json_403_surfaces_server_message_and_points_at_license() { + let mut routes = routes_until_login(); + routes.push(( + "/login_with_xbox", + vec![MockReply::json_status( + 403, + json!({ + "path": "/authentication/login_with_xbox", + "error": "UNAUTHORIZED", + "errorMessage": "You do not own Minecraft Java Edition" + }), + )], + )); + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + + assert_eq!(error.code(), "denied", "粗分流层保持 M31 的 denied"); + assert_eq!(error.detail_code(), "login_with_xbox_403"); + assert!(!error.is_transient(), "服务端明确拒绝,重试无意义"); + let detail = response_of(&error); + assert_eq!(detail.status, 403); + assert_eq!(detail.kind, BodyKind::Json); + assert_eq!(detail.content_type.as_deref(), Some("application/json")); + let message = error.message(); + // 服务端文案直出(验收要看到真实原因)。 + assert!(message.contains("You do not own"), "{message}"); + assert!(message.contains("UNAUTHORIZED"), "{message}"); + assert!(message.contains("许可"), "应引导查许可:{message}"); + assert!( + !message.contains("代理"), + "JSON 拒绝不该往网络引:{message}" + ); + assert!(message.contains("响应摘要"), "{message}"); + assert_no_credentials(&message); + mock.stop(); +} + +#[tokio::test] +async fn html_403_is_routed_to_network_and_proxy() { + let mut routes = routes_until_login(); + routes.push(( + "/login_with_xbox", + vec![MockReply::html(403, HTML_BLOCK_PAGE)], + )); + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + + assert_eq!(error.code(), "denied"); + assert_eq!( + error.detail_code(), + "login_with_xbox_non_json", + "拦截页要有独立细分码" + ); + assert!(error.is_transient(), "拦截页可以换代理重试"); + let detail = response_of(&error); + assert_eq!(detail.kind, BodyKind::Html); + assert!(detail.is_interception()); + assert_eq!(detail.status, 403); + assert!( + detail + .content_type + .as_deref() + .is_some_and(|value| value.contains("text/html")), + "{:?}", + detail.content_type + ); + let message = error.message(); + assert!(message.contains("响应非 JSON"), "{message}"); + assert!(message.contains("网络"), "{message}"); + assert!(message.contains("代理"), "{message}"); + assert!( + message.contains("blocked by gateway"), + "摘要要留下证据:{message}" + ); + assert!( + !message.contains("许可"), + "拦截页不该把用户引向许可:{message}" + ); + assert_no_credentials(&message); + mock.stop(); +} + +#[tokio::test] +async fn empty_403_reports_the_empty_body() { + let mut routes = routes_until_login(); + routes.push(("/login_with_xbox", vec![MockReply::status(403)])); + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + + assert_eq!(error.detail_code(), "login_with_xbox_empty_body"); + let detail = response_of(&error); + assert_eq!(detail.kind, BodyKind::Empty); + assert!(detail.digest.is_empty()); + assert!(error.message().contains("为空"), "{}", error.message()); + mock.stop(); +} + +#[tokio::test] +async fn profile_4xx_keeps_the_step_status_code() { + let mut routes = full_routes(); + routes[6].1 = vec![MockReply::json_status( + 401, + json!({ "path": "/minecraft/profile", "error": "Unauthorized" }), + )]; + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + assert_eq!(error.detail_code(), "profile_401"); + assert!(error.message().contains("档案"), "{}", error.message()); + assert_no_credentials(&error.message()); + mock.stop(); +} + +#[tokio::test] +async fn entitlements_5xx_text_is_an_interception_not_a_license_verdict() { + let mut routes = full_routes(); + routes[5].1 = vec![MockReply::text(503, "Service Unavailable")]; + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + // 关键:网关页不能报成「你没买游戏」(M31 的 no_entitlement 只留给 JSON/空体)。 + assert_eq!(error.detail_code(), "entitlements_non_json"); + let detail = response_of(&error); + assert_eq!(detail.kind, BodyKind::Text); + assert_eq!(detail.status, 503); + assert!( + error.message().contains("Service Unavailable"), + "{}", + error.message() + ); + mock.stop(); +} + +#[tokio::test] +async fn entitlements_404_with_empty_body_still_reports_no_entitlement() { + let mut routes = full_routes(); + routes[5].1 = vec![MockReply::status(404)]; + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + // 向后兼容:M31 的稳定码 `no_entitlement` 不动。 + match error { + AuthError::Denied { code, description } => { + assert_eq!(code, "no_entitlement"); + assert!(description.contains("404"), "{description}"); + } + other => panic!("应是 Denied,实际 {other:?}"), + } + mock.stop(); +} + +#[tokio::test] +async fn entitlements_403_with_empty_body_is_not_a_license_verdict() { + let mut routes = full_routes(); + routes[5].1 = vec![MockReply::status(403)]; + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + // 403 且没正文:说不清是许可还是拦截,按「不确定」报,不下「没买游戏」的结论。 + assert_eq!(error.detail_code(), "entitlements_empty_body"); + mock.stop(); +} + +#[tokio::test] +async fn xsts_401_without_xerr_still_reports_body_context() { + let mut routes = routes_until_login(); + routes[3].1 = vec![MockReply::html(401, HTML_BLOCK_PAGE)]; + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + assert_eq!(error.detail_code(), "xsts_non_json"); + assert!(error.message().contains("XSTS"), "{}", error.message()); + mock.stop(); +} + +#[tokio::test] +async fn xerr_still_wins_over_the_body_diagnostic() { + let mut routes = routes_until_login(); + routes[3].1 = vec![MockReply::json_status( + 401, + json!({ "XErr": 2_148_916_235_u32, "Message": "country unavailable" }), + )]; + let mock = AuthMock::start(routes).await; + let error = auth_for(&mock.base()) + .device_code_login(|_| {}) + .await + .expect_err("应失败"); + match error { + AuthError::Xbox { code } => assert_eq!(code, 2_148_916_235), + other => panic!("XErr 判定必须优先,实际 {other:?}"), + } + mock.stop(); +} diff --git a/crates/core/tests/auth/microsoft_flow.rs b/crates/core/tests/auth/microsoft_flow.rs new file mode 100644 index 0000000..3cc3cb3 --- /dev/null +++ b/crates/core/tests/auth/microsoft_flow.rs @@ -0,0 +1,274 @@ +//! device-code 全链(打到进程内 mock 服务器):报文形状 + 轮询状态机 + 错误分流。 + +use std::time::Duration; + +use serde_json::json; +use unlucky_core::auth::microsoft::{ + DeviceCodePrompt, MicrosoftAuth, MicrosoftEndpoints, PollPolicy, +}; +use unlucky_core::auth::AuthError; + +use crate::support::{AuthMock, MockReply}; + +const PROFILE_ID: &str = "0f8fad5bd9cb469fa16570867728950e"; +const CLIENT_ID: &str = "test-client-id"; + +fn fast_poll() -> PollPolicy { + PollPolicy { + min_interval: Duration::from_millis(1), + slow_down_step: Duration::from_millis(1), + max_wait: Duration::from_secs(5), + } +} + +fn device_code_reply() -> MockReply { + MockReply::json(json!({ + "user_code": "ABCD-1234", + "device_code": "device-code-secret-value", + "verification_uri": "https://www.microsoft.com/link", + "expires_in": 900, + "interval": 0, + "message": "go to the url" + })) +} + +fn chain_replies() -> Vec<(&'static str, Vec)> { + vec![ + ("/devicecode", vec![device_code_reply()]), + ( + "/token", + vec![ + MockReply::json(json!({ "error": "authorization_pending" })), + MockReply::json(json!({ + "access_token": "live-access-token-value", + "refresh_token": "live-refresh-token-value" + })), + ], + ), + ( + "/xbl", + vec![MockReply::json(json!({ + "Token": "xbl-token-value", + "DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] } + }))], + ), + ( + "/xsts", + vec![MockReply::json(json!({ + "Token": "xsts-token-value", + "DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] } + }))], + ), + ( + "/login_with_xbox", + vec![MockReply::json(json!({ + "username": "Steve", + "access_token": "mc-access-token-value", + "token_type": "Bearer", + "expires_in": 86_400 + }))], + ), + ( + "/entitlements/mcstore", + vec![MockReply::json(json!({ "items": [] }))], + ), + ( + "/profile", + vec![MockReply::json( + json!({ "id": PROFILE_ID, "name": "Steve" }), + )], + ), + ] +} + +#[tokio::test] +async fn device_code_login_runs_the_whole_chain() { + let mock = AuthMock::start(chain_replies()).await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_poll_policy(fast_poll()); + + let mut prompted: Option = None; + let session = auth + .device_code_login(|prompt| prompted = Some(prompt.clone())) + .await + .expect("登录成功"); + + // 1. 用户码/URL 在轮询前就交给宿主了(CLI 才能先打印再等)。 + let prompt = prompted.expect("必须回调展示用户码"); + assert_eq!(prompt.user_code, "ABCD-1234"); + assert_eq!(prompt.verification_uri, "https://www.microsoft.com/link"); + assert_eq!(prompt.expires_in, Duration::from_secs(900)); + assert_eq!(prompt.interval, Duration::ZERO, "服务端 interval 原样收下"); + let (line1, line2) = prompt.lines(); + assert!(line1.contains("https://www.microsoft.com/link")); + assert!(line2.contains("ABCD-1234")); + + // 2. 会话字段:MC token 落 access_token,Live refresh 落 refresh_token。 + assert_eq!(session.profile_id, PROFILE_ID); + assert_eq!(session.profile_name, "Steve"); + assert_eq!(session.access_token.expose(), "mc-access-token-value"); + assert_eq!(session.refresh_token.expose(), "live-refresh-token-value"); + assert!(session.not_after_ms > 1_577_836_800_000); + assert!(session.has_profile_name()); + // Debug 里不能出现任何 token。 + let debug = format!("{session:?}"); + assert!(!debug.contains("mc-access-token-value"), "{debug}"); + assert!(!debug.contains("live-refresh-token-value"), "{debug}"); + + // 3. 报文形状(这几处抄错就会 400,必须逐条盯住)。 + let device_bodies = mock.bodies_on("/devicecode"); + assert_eq!(device_bodies.len(), 1); + assert!(device_bodies[0].contains("client_id=test-client-id")); + assert!( + device_bodies[0].contains("scope=XboxLive.signin+offline_access") + || device_bodies[0].contains("scope=XboxLive.signin%20offline_access") + ); + + let token_bodies = mock.bodies_on("/token"); + assert_eq!(token_bodies.len(), 2, "pending 一次后成功"); + for body in &token_bodies { + assert!(body.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code")); + assert!(body.contains("code=device-code-secret-value")); + assert!(!body.contains("{"), "表单体不该是 JSON:{body}"); + } + + let xbl = &mock.bodies_on("/xbl")[0]; + // `d=` 前缀是 MSA 消费版票据标记(漏了就 400)。 + assert!( + xbl.contains("\"RpsTicket\":\"d=live-access-token-value\""), + "{xbl}" + ); + assert!(xbl.contains("user.auth.xboxlive.com")); + + let xsts = &mock.bodies_on("/xsts")[0]; + assert!(xsts.contains("rp://api.minecraftservices.com/"), "{xsts}"); + assert!(xsts.contains("\"SandboxId\":\"RETAIL\""), "{xsts}"); + assert!(xsts.contains("xbl-token-value"), "{xsts}"); + + let login = &mock.bodies_on("/login_with_xbox")[0]; + assert!( + login.contains("XBL3.0 x=uhs-1;xsts-token-value"), + "identityToken 拼接错:{login}" + ); + + mock.stop(); +} + +#[tokio::test] +async fn slow_down_backs_off_instead_of_failing() { + let mut routes = chain_replies(); + // 把 /token 换成 pending → slow_down → 成功。 + routes[1].1 = vec![ + MockReply::json(json!({ "error": "authorization_pending" })), + MockReply::json(json!({ "error": "slow_down" })), + MockReply::json(json!({ + "access_token": "live-access-token-value", + "refresh_token": "live-refresh-token-value" + })), + ]; + let mock = AuthMock::start(routes).await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_poll_policy(fast_poll()); + let session = auth.device_code_login(|_| {}).await.expect("登录成功"); + assert_eq!(session.profile_name, "Steve"); + assert_eq!(mock.bodies_on("/token").len(), 3, "三次轮询"); + mock.stop(); +} + +#[tokio::test] +async fn access_denied_is_reported_as_canceled() { + let mut routes = chain_replies(); + routes[1].1 = vec![MockReply::json(json!({ "error": "access_denied" }))]; + let mock = AuthMock::start(routes).await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_poll_policy(fast_poll()); + let error = auth.device_code_login(|_| {}).await.expect_err("应取消"); + assert_eq!(error, AuthError::Canceled); + assert_eq!(error.code(), "canceled"); + mock.stop(); +} + +#[tokio::test] +async fn expired_device_code_is_a_timeout() { + let mut routes = chain_replies(); + routes[1].1 = vec![MockReply::json(json!({ "error": "expired_token" }))]; + let mock = AuthMock::start(routes).await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_poll_policy(fast_poll()); + let error = auth.device_code_login(|_| {}).await.expect_err("应超时"); + assert_eq!(error.code(), "timeout"); + mock.stop(); +} + +#[tokio::test] +async fn polling_gives_up_after_the_wait_budget() { + let mut routes = chain_replies(); + let pending = MockReply::json(json!({ "error": "authorization_pending" })); + routes[1].1 = vec![pending; 64]; + let mock = AuthMock::start(routes).await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_poll_policy(PollPolicy { + min_interval: Duration::from_millis(1), + slow_down_step: Duration::from_millis(1), + max_wait: Duration::from_millis(20), + }); + let error = auth.device_code_login(|_| {}).await.expect_err("应超时"); + assert_eq!(error.code(), "timeout"); + assert!(mock.bodies_on("/token").len() < 64, "超时后必须停止轮询"); + mock.stop(); +} + +#[tokio::test] +async fn xsts_401_with_xerr_is_mapped() { + let mut routes = chain_replies(); + routes[2].1 = vec![MockReply::json_status( + 401, + json!({ "Identity": "0", "XErr": 2_148_916_233_u32, "Message": "no xbox account" }), + )]; + let mock = AuthMock::start(routes).await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_poll_policy(fast_poll()); + let error = auth.device_code_login(|_| {}).await.expect_err("应失败"); + match error { + AuthError::Xbox { code } => { + assert_eq!(code, 2_148_916_233); + assert!(unlucky_core::auth::error::xbox_hint(code).contains("Xbox 档案")); + } + other => panic!("应是 Xbox 错误,实际 {other:?}"), + } + mock.stop(); +} + +#[tokio::test] +async fn device_code_endpoint_failure_is_denied() { + let mock = AuthMock::start(vec![( + "/devicecode", + vec![MockReply::json_status( + 400, + json!({ "error": "invalid_client", "error_description": "bad client id" }), + )], + )]) + .await; + let auth = MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(&mock.base())) + .with_poll_policy(fast_poll()); + let error = auth.device_code_login(|_| {}).await.expect_err("应失败"); + match error { + AuthError::Denied { code, .. } => assert_eq!(code, "invalid_client"), + other => panic!("应是 Denied,实际 {other:?}"), + } + mock.stop(); +} diff --git a/crates/core/tests/auth/microsoft_refresh.rs b/crates/core/tests/auth/microsoft_refresh.rs new file mode 100644 index 0000000..1b475ba --- /dev/null +++ b/crates/core/tests/auth/microsoft_refresh.rs @@ -0,0 +1,283 @@ +//! refresh 语义与 validate 分支:轮换、换号防线、404 二义、拥有权门禁。 + +use std::time::Duration; + +use serde_json::json; +use unlucky_core::auth::microsoft::{ + MicrosoftAuth, MicrosoftEndpoints, MicrosoftSession, PollPolicy, RefreshDecision, RefreshReason, +}; +use unlucky_core::auth::Secret; + +use crate::support::{AuthMock, MockReply}; + +const PROFILE_ID: &str = "0f8fad5bd9cb469fa16570867728950e"; +const OTHER_PROFILE_ID: &str = "11111111111111111111111111111111"; +const CLIENT_ID: &str = "test-client-id"; + +fn auth_for(base: &str) -> MicrosoftAuth { + MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(base)) + .with_poll_policy(PollPolicy { + min_interval: Duration::from_millis(1), + slow_down_step: Duration::from_millis(1), + max_wait: Duration::from_secs(5), + }) +} + +/// 一个「已过期」的旧会话(`not_after` 早于当前时刻)。 +fn expired_session() -> MicrosoftSession { + MicrosoftSession::new( + PROFILE_ID, + "Steve", + Secret::new("mc-access-token-1"), + Secret::new("live-refresh-token-1"), + 3_600, + 0, + None, + ) +} + +/// 一个「新鲜」的会话。 +fn fresh_session() -> MicrosoftSession { + MicrosoftSession::new( + PROFILE_ID, + "Steve", + Secret::new("mc-access-token-1"), + Secret::new("live-refresh-token-1"), + 86_400, + unlucky_core::auth::microsoft::now_ms(), + None, + ) +} + +/// 刷新链(Live refresh + 五步)。 +fn refresh_routes(profile_id: &str, license: bool) -> Vec<(&'static str, Vec)> { + vec![ + ( + "/token", + vec![MockReply::json(json!({ + "access_token": "live-access-token-2", + "refresh_token": "live-refresh-token-2" + }))], + ), + ( + "/xbl", + vec![MockReply::json(json!({ + "Token": "xbl-token-2", + "DisplayClaims": { "xui": [{ "uhs": "uhs-2" }] } + }))], + ), + ( + "/xsts", + vec![MockReply::json(json!({ + "Token": "xsts-token-2", + "DisplayClaims": { "xui": [{ "uhs": "uhs-2" }] } + }))], + ), + ( + "/login_with_xbox", + vec![MockReply::json(json!({ + "username": "Steve", + "access_token": "mc-access-token-2", + "expires_in": 86_400 + }))], + ), + ( + "/entitlements/mcstore", + vec![MockReply::json(json!({ "items": [] }))], + ), + ( + "/entitlements/license", + vec![MockReply::json(if license { + json!({ "items": [{ "name": "game_minecraft" }] }) + } else { + json!({ "items": [{ "name": "product_minecraft" }] }) + })], + ), + ( + "/profile", + vec![MockReply::json( + json!({ "id": profile_id, "name": "Steve" }), + )], + ), + ] +} + +#[test] +fn refresh_decision_table() { + let expired = expired_session(); + assert_eq!( + expired.refresh_decision(unlucky_core::auth::microsoft::now_ms()), + RefreshDecision::Refresh(RefreshReason::Expired) + ); + let fresh = fresh_session(); + assert_eq!( + fresh.refresh_decision(unlucky_core::auth::microsoft::now_ms()), + RefreshDecision::Reuse + ); +} + +#[tokio::test] +async fn refresh_rotates_tokens_and_keeps_profile() { + let mock = AuthMock::start(refresh_routes(PROFILE_ID, true)).await; + let auth = auth_for(&mock.base()); + let old = expired_session(); + let (refreshed, reason) = auth.ensure_fresh(&old).await.expect("刷新成功"); + + assert_eq!(reason, Some(RefreshReason::Expired)); + assert_eq!(refreshed.profile_id, PROFILE_ID); + // 轮换:access 与 refresh **都**换新(refresh 返回新 refresh_token)。 + assert_eq!(refreshed.access_token.expose(), "mc-access-token-2"); + assert_eq!(refreshed.refresh_token.expose(), "live-refresh-token-2"); + assert_ne!(refreshed.access_token, old.access_token); + assert_ne!(refreshed.refresh_token, old.refresh_token); + assert!(refreshed.not_after_ms > old.not_after_ms); + + // 刷新报文:拿旧 refresh_token 走 refresh_token grant。 + let body = &mock.bodies_on("/token")[0]; + assert!(body.contains("grant_type=refresh_token"), "{body}"); + assert!( + body.contains("refresh_token=live-refresh-token-1"), + "{body}" + ); + // 整链重跑:XBL 用的是**新的** Live access token。 + let xbl = &mock.bodies_on("/xbl")[0]; + assert!(xbl.contains("d=live-access-token-2"), "{xbl}"); + mock.stop(); +} + +#[tokio::test] +async fn fresh_session_needs_no_network() { + let mock = AuthMock::start(vec![("/token", vec![MockReply::status(500)])]).await; + let auth = auth_for(&mock.base()); + let (session, reason) = auth.ensure_fresh(&fresh_session()).await.expect("直接复用"); + assert!(reason.is_none()); + assert_eq!(session.access_token.expose(), "mc-access-token-1"); + assert!(mock.requests().is_empty(), "不该打网络"); + mock.stop(); +} + +#[tokio::test] +async fn refresh_that_changes_profile_is_rejected() { + let mock = AuthMock::start(refresh_routes(OTHER_PROFILE_ID, true)).await; + let auth = auth_for(&mock.base()); + let error = auth.refresh(&expired_session()).await.expect_err("应拒绝"); + assert_eq!(error.code(), "malformed"); + assert!(error.message().contains("档案变了"), "{}", error.message()); + mock.stop(); +} + +#[tokio::test] +async fn revoked_refresh_token_requires_relogin() { + let mock = AuthMock::start(vec![( + "/token", + vec![MockReply::json_status( + 400, + json!({ + "error": "invalid_grant", + "error_description": "AADSTS70000: The grant is expired or revoked" + }), + )], + )]) + .await; + let auth = auth_for(&mock.base()); + let error = auth.refresh(&expired_session()).await.expect_err("应失败"); + assert!(error.is_credential_expired(), "{error:?}"); + mock.stop(); +} + +#[tokio::test] +async fn validate_reports_token_state_without_swallowing_profile_errors() { + // 200 → 有效 + let ok = AuthMock::start(vec![( + "/profile", + vec![MockReply::json( + json!({ "id": PROFILE_ID, "name": "Steve" }), + )], + )]) + .await; + assert!(auth_for(&ok.base()) + .validate(&fresh_session()) + .await + .expect("有效")); + ok.stop(); + + // 401 → token 失效(返回值,不是错误) + let unauthorized = AuthMock::start(vec![("/profile", vec![MockReply::status(401)])]).await; + assert!(!auth_for(&unauthorized.base()) + .validate(&fresh_session()) + .await + .expect("失效")); + unauthorized.stop(); + + // 404 + 没有 game_minecraft → 「没买游戏」是真实错误,不当 token 失效 + let no_license = AuthMock::start(vec![ + ("/profile", vec![MockReply::status(404)]), + ( + "/entitlements/license", + vec![MockReply::json( + json!({ "items": [{ "name": "product_minecraft" }] }), + )], + ), + ]) + .await; + let error = auth_for(&no_license.base()) + .validate(&fresh_session()) + .await + .expect_err("应上抛"); + assert_eq!(error.code(), "denied"); + assert!(error.message().contains("没有购买"), "{}", error.message()); + no_license.stop(); + + // 404 + 有 game_minecraft → 「买了没建档案」 + let no_profile = AuthMock::start(vec![ + ("/profile", vec![MockReply::status(404)]), + ( + "/entitlements/license", + vec![MockReply::json( + json!({ "items": [{ "name": "game_minecraft" }] }), + )], + ), + ]) + .await; + let error = auth_for(&no_profile.base()) + .validate(&fresh_session()) + .await + .expect_err("应上抛"); + assert!( + error.message().contains("还没有创建档案"), + "{}", + error.message() + ); + no_profile.stop(); +} + +#[tokio::test] +async fn missing_entitlement_fails_the_chain_early() { + let mut routes = refresh_routes(PROFILE_ID, true); + routes[4].1 = vec![MockReply::status(404)]; + let mock = AuthMock::start(routes).await; + let auth = auth_for(&mock.base()); + let error = auth.refresh(&expired_session()).await.expect_err("应失败"); + match error { + unlucky_core::auth::AuthError::Denied { code, .. } => assert_eq!(code, "no_entitlement"), + other => panic!("应是 Denied,实际 {other:?}"), + } + mock.stop(); +} + +#[tokio::test] +async fn uhs_mismatch_between_xbl_and_xsts_is_malformed() { + let mut routes = refresh_routes(PROFILE_ID, true); + routes[2].1 = vec![MockReply::json(json!({ + "Token": "xsts-token-2", + "DisplayClaims": { "xui": [{ "uhs": "uhs-diFFERENT" }] } + }))]; + let mock = AuthMock::start(routes).await; + let auth = auth_for(&mock.base()); + let error = auth.refresh(&expired_session()).await.expect_err("应失败"); + assert_eq!(error.code(), "malformed"); + assert!(error.message().contains("userhash"), "{}", error.message()); + mock.stop(); +} diff --git a/crates/core/tests/auth/offline_uuid.rs b/crates/core/tests/auth/offline_uuid.rs new file mode 100644 index 0000000..eda7f7e --- /dev/null +++ b/crates/core/tests/auth/offline_uuid.rs @@ -0,0 +1,105 @@ +//! 离线 UUID 与 **JDK 对拍**(这就是「HMCL 兼容」的定义)。 +//! +//! 期望值不是我们算出来再抄回来的,而是用 JDK 25 跑 +//! `UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(UTF_8))` +//! 生成的(驱动源码见下方注释里的等价 Java)。HMCL 的 +//! `OfflineAccountFactory.getUUIDFromUserName` 就是这一行,因此对拍 JDK 即对拍 HMCL。 +//! +//! ```java +//! UUID id = UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8)); +//! System.out.println(id.toString().replace("-", "")); +//! ``` + +use unlucky_core::auth::offline::{offline_login, offline_meta, offline_uuid, offline_uuid_dashed}; +use unlucky_core::auth::AccountKind; + +/// `(名字, JDK 输出 32 位无连字符)`。 +const JDK_VECTORS: [(&str, &str); 7] = [ + ("Notch", "b50ad385829d3141a2167e7d7539ba7f"), + ("jeb_", "a762f5604fce3236812ab80efff0b62b"), + ("Player", "a01e3843e5213998958af459800e4d11"), + ("Steve", "5627dd98e6be3c21b8a8e92344183641"), + // 非 ASCII:UTF-8 字节参与 MD5(中文名离线服常见)。 + ("小明", "e1fa999fd9e93419bc012f51157a00e5"), + ("name with space", "46c02d33ff53313b813aa95508518305"), + ("AskaEth", "5c5918fb61303d6eb8d8eb2d1da6e439"), +]; + +#[test] +fn matches_jdk_name_uuid_from_bytes() { + for (name, expected) in JDK_VECTORS { + assert_eq!( + offline_uuid(name), + expected, + "离线 UUID 与 JDK/HMCL 不一致:{name}" + ); + } +} + +#[test] +fn dashed_form_is_the_same_uuid() { + for (name, expected) in JDK_VECTORS { + let dashed = offline_uuid_dashed(name); + assert_eq!(dashed.replace('-', ""), expected, "{name} 的连字符形式"); + assert_eq!(dashed.len(), 36); + assert_eq!(dashed.matches('-').count(), 4); + } +} + +#[test] +fn version_is_3_and_variant_is_ietf() { + for (name, raw) in JDK_VECTORS { + let version_nibble = &raw[12..13]; + assert_eq!(version_nibble, "3", "{name} 必须是 v3"); + let variant = u8::from_str_radix(&raw[16..17], 16).expect("变体位"); + assert!((0x8..=0xb).contains(&variant), "{name} 变体必须是 10xx"); + } +} + +#[test] +fn uuid_depends_on_exact_bytes_not_case() { + // 大小写不同 → 不同身份(服务端就是按字节算的,别做「友好」归一化)。 + assert_ne!(offline_uuid("Steve"), offline_uuid("steve")); + // 前缀也参与哈希:直接哈希名字会得到另一个 UUID。 + assert_ne!(offline_uuid("Notch"), offline_uuid("OfflinePlayer:Notch")); +} + +#[test] +fn offline_login_matches_hmcl_behaviour() { + let meta = offline_meta("小明", 1).expect("建元数据"); + assert_eq!(meta.kind, AccountKind::Offline); + assert_eq!(meta.profile_id, "e1fa999fd9e93419bc012f51157a00e5"); + assert_eq!(meta.profile_name, "小明"); + + let placeholders = offline_login(&meta).expect("占位符"); + // uuid 必须是**无连字符** 32 位(HMCL 的 UUIDs.toCompactString)。 + assert_eq!(placeholders.uuid, meta.profile_id); + assert_eq!(placeholders.uuid.len(), 32); + assert!(!placeholders.uuid.contains('-')); + // accessToken = 随机 32 位十六进制(每次不同、非空:游戏不接受空 token)。 + assert_eq!(placeholders.access_token.len(), 32); + assert!(placeholders + .access_token + .chars() + .all(|ch| ch.is_ascii_hexdigit())); + assert_ne!( + placeholders.access_token, + offline_login(&meta).expect("再来一次").access_token + ); + // userType=msa、userProperties={}(承 OfflineAccount.logInWithoutSkin)。 + assert_eq!(placeholders.user_type, "msa"); + assert_eq!(placeholders.user_properties, "{}"); +} + +#[test] +fn names_are_validated_before_hashing() { + assert!(offline_meta(" ", 1).is_err()); + assert!(offline_meta("0123456789abcdefg", 1).is_err()); + // 16 字符刚好合法。 + assert!(offline_meta("0123456789abcdef", 1).is_ok()); + // 首尾空白被去掉后哈希(避免「名字带空格」算出两套 UUID)。 + assert_eq!( + offline_meta(" Steve ", 1).expect("合法").profile_id, + offline_uuid("Steve") + ); +} diff --git a/crates/core/tests/auth/store_separation.rs b/crates/core/tests/auth/store_separation.rs new file mode 100644 index 0000000..cef9a06 --- /dev/null +++ b/crates/core/tests/auth/store_separation.rs @@ -0,0 +1,240 @@ +//! 存储契约:**分文件 / 原子写 / 写序 / 容错 / 孤儿清理**。 +//! +//! 这些是「凭据红线」的落盘侧证据:元数据文件里永远搜不到 token(可安全备份/分享), +//! 凭据文件只在需要发请求时才读,且写坏了也不会让账户列表整体打不开。 + +use std::path::Path; + +use unlucky_core::auth::account::{AccountKind, AccountMeta, Credentials}; +use unlucky_core::auth::microsoft::MicrosoftSession; +use unlucky_core::auth::offline::offline_meta; +use unlucky_core::auth::{AccountStore, Secret}; + +use crate::support::temp_dir; + +const MC_TOKEN: &str = "mc-access-token-value"; +const LIVE_REFRESH: &str = "live-refresh-token-value"; + +fn microsoft_entry(created_at_ms: u64) -> (AccountMeta, Credentials) { + let meta = AccountMeta::new( + AccountKind::Microsoft, + "0f8fad5bd9cb469fa16570867728950e", + "Steve", + created_at_ms, + ); + let session = MicrosoftSession::new( + "0f8fad5bd9cb469fa16570867728950e", + "Steve", + Secret::new(MC_TOKEN), + Secret::new(LIVE_REFRESH), + 86_400, + 1_000, + None, + ); + (meta, Credentials::Microsoft(session)) +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("读文件") +} + +fn tmp_leftovers(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .expect("列目录") + .filter_map(|entry| entry.ok()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.contains(".tmp")) + .collect() +} + +#[test] +fn metadata_and_credentials_live_in_separate_files() { + let dir = temp_dir("sep"); + let mut store = AccountStore::open(&dir); + let (meta, credentials) = microsoft_entry(1); + store.insert(meta, credentials).expect("插入"); + store.save().expect("落盘"); + + let accounts_text = read(&store.paths().accounts_file()); + let credentials_text = read(&store.paths().credentials_file()); + + // 元数据:有身份、有轨道,但**没有任何 token**。 + assert!(accounts_text.contains("0f8fad5bd9cb469fa16570867728950e")); + assert!(accounts_text.contains("\"kind\": \"microsoft\"")); + assert!(!accounts_text.contains(MC_TOKEN), "{accounts_text}"); + assert!(!accounts_text.contains(LIVE_REFRESH), "{accounts_text}"); + // 凭据:有 token、有保护级别标记,但**没有**可公开的展示字段。 + assert!(credentials_text.contains(MC_TOKEN)); + assert!(credentials_text.contains(LIVE_REFRESH)); + assert!(credentials_text.contains("\"protection\": \"plain-v1\"")); + // 凭据文件里没有元数据那份「账户列表」结构(两个文件的 schema 各管各的)。 + assert!( + !credentials_text.contains("\"accounts\""), + "{credentials_text}" + ); + + // 原子写不留 tmp 垃圾。 + assert!(tmp_leftovers(&dir).is_empty(), "残留临时文件"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn roundtrip_keeps_credentials_intact() { + let dir = temp_dir("roundtrip"); + let mut store = AccountStore::open(&dir); + let (meta, credentials) = microsoft_entry(1); + let id = meta.id.clone(); + store.insert(meta, credentials.clone()).expect("插入"); + store.save().expect("落盘"); + + let reopened = AccountStore::open(&dir); + assert_eq!(reopened.accounts().len(), 1); + assert!(reopened.warnings().is_empty(), "{:?}", reopened.warnings()); + assert_eq!(reopened.credentials(&id), Some(&credentials)); + let placeholders = match reopened.credentials(&id).expect("凭据") { + Credentials::Microsoft(session) => session.to_placeholders().expect("占位符"), + other => panic!("应是微软凭据,实际 {other:?}"), + }; + assert_eq!(placeholders.player_name, "Steve"); + assert_eq!(placeholders.access_token, MC_TOKEN); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn credentials_are_written_before_metadata() { + let dir = temp_dir("order"); + let mut store = AccountStore::open(&dir); + store + .insert(offline_meta("First", 1).expect("甲"), Credentials::Offline) + .expect("插入甲"); + store.save().expect("首次落盘"); + + // 把凭据文件的位置换成目录 → 凭据写必定失败。 + let credentials_path = store.paths().credentials_file(); + std::fs::remove_file(&credentials_path).expect("删凭据文件"); + std::fs::create_dir_all(&credentials_path).expect("建同名目录"); + + store + .insert(offline_meta("Second", 2).expect("乙"), Credentials::Offline) + .expect("插入乙"); + let error = store.save().expect_err("凭据写不进去时 save 必须失败"); + assert_eq!(error.code(), "store"); + + // 关键断言:元数据没有被先写(写序正确才有的结果)。 + let accounts_text = read(&store.paths().accounts_file()); + assert!(!accounts_text.contains("Second"), "{accounts_text}"); + assert!(tmp_leftovers(&dir).is_empty(), "残留临时文件"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn corrupt_record_is_skipped_not_fatal() { + let dir = temp_dir("corrupt"); + let store = AccountStore::open(&dir); + let good = offline_meta("Good", 1).expect("合法元数据"); + let payload = serde_json::json!({ + "schemaVersion": 1, + "accounts": [ + { "id": good.id.as_str(), "kind": "offline", + "profileId": good.profile_id, "profileName": "Good", "createdAtMs": 1 }, + { "id": "account:zzzz", "kind": "offline" }, + { "id": "account:0123456789abcdef0123456789abcdef", "kind": "unknown-track" } + ] + }); + std::fs::write( + store.paths().accounts_file(), + serde_json::to_string_pretty(&payload).expect("序列化"), + ) + .expect("写元数据"); + + let reopened = AccountStore::open(&dir); + assert_eq!(reopened.accounts().len(), 1, "坏记录必须被跳过"); + assert_eq!(reopened.accounts()[0].display_name(), "Good"); + assert_eq!(reopened.warnings().len(), 2, "{:?}", reopened.warnings()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn broken_metadata_file_still_allows_empty_store() { + let dir = temp_dir("broken"); + let store = AccountStore::open(&dir); + std::fs::write(store.paths().accounts_file(), "{ this is not json").expect("写坏文件"); + let reopened = AccountStore::open(&dir); + assert!(reopened.is_empty()); + assert_eq!(reopened.warnings().len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn orphan_credentials_are_ignored_and_cleaned_on_save() { + let dir = temp_dir("orphan"); + let store = AccountStore::open(&dir); + let orphan_id = "account:00000000000000000000000000000000"; + let payload = serde_json::json!({ + "schemaVersion": 1, + "protection": "plain-v1", + "entries": [{ "accountId": orphan_id, "data": { "kind": "offline" } }] + }); + std::fs::write( + store.paths().credentials_file(), + serde_json::to_string_pretty(&payload).expect("序列化"), + ) + .expect("写凭据"); + + let reopened = AccountStore::open(&dir); + assert!(reopened + .warnings() + .iter() + .any(|line| line.contains("没有对应账户"))); + let unknown = unlucky_core::auth::AccountId::parse(orphan_id).expect("id"); + assert!(reopened.credentials(&unknown).is_none()); + reopened.save().expect("落盘"); + let text = read(&reopened.paths().credentials_file()); + assert!(!text.contains(orphan_id), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn unknown_protection_level_is_reported() { + let dir = temp_dir("protection"); + let store = AccountStore::open(&dir); + std::fs::write( + store.paths().credentials_file(), + serde_json::to_string_pretty(&serde_json::json!({ + "schemaVersion": 1, + "protection": "hmcl-obfuscated-v1", + "entries": [] + })) + .expect("序列化"), + ) + .expect("写凭据"); + let reopened = AccountStore::open(&dir); + assert!(reopened + .warnings() + .iter() + .any(|line| line.contains("保护级别"))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn remove_drops_credential_entry_on_disk() { + let dir = temp_dir("remove"); + let mut store = AccountStore::open(&dir); + store + .insert(offline_meta("Keep", 1).expect("甲"), Credentials::Offline) + .expect("甲"); + let (meta, credentials) = microsoft_entry(2); + let id = meta.id.clone(); + store.insert(meta, credentials).expect("乙"); + store.save().expect("落盘"); + + store.remove("Steve").expect("删乙"); + store.save().expect("再落盘"); + let text = read(&store.paths().credentials_file()); + assert!(!text.contains(id.as_str()), "{text}"); + + let reopened = AccountStore::open(&dir); + assert_eq!(reopened.accounts().len(), 1); + assert_eq!(reopened.accounts()[0].display_name(), "Keep"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/core/tests/auth/support/microsoft.rs b/crates/core/tests/auth/support/microsoft.rs new file mode 100644 index 0000000..c57190a --- /dev/null +++ b/crates/core/tests/auth/support/microsoft.rs @@ -0,0 +1,136 @@ +//! 微软轨的共用夹具:`MicrosoftAuth` 构造、五步链路由表、诊断断言助手。 +//! +//! 从 `support/mod.rs` 拆出来(M38):mock 服务器本体与「业务路由/断言」各自长大, +//! 分文件后两边都不顶 400 行。路由表默认用 `[0]/[3]/[5]/[6]` 下标对应 +//! `xsts` / `login_with_xbox` / `entitlements/mcstore` / `profile`,用例按需替换。 + +use serde_json::json; +use unlucky_core::auth::microsoft::{ + MicrosoftAuth, MicrosoftEndpoints, MicrosoftSession, PollPolicy, +}; +use unlucky_core::auth::{AuthError, ResponseDetail, Secret}; + +use super::MockReply; + +/// 测试用档案 UUID(32 位无连字符)。 +pub const PROFILE_ID: &str = "0f8fad5bd9cb469fa16570867728950e"; +/// 测试用 client id。 +pub const CLIENT_ID: &str = "test-client-id"; +/// 网关/风控拦截页(HTML 403 的典型正文)。 +pub const HTML_BLOCK_PAGE: &str = "Service unavailableblocked by gateway"; + +/// 小间隔轮询策略(设备码轮询在测试里要秒级跑完)。 +pub fn fast_poll() -> PollPolicy { + PollPolicy { + min_interval: std::time::Duration::from_millis(1), + slow_down_step: std::time::Duration::from_millis(1), + max_wait: std::time::Duration::from_secs(5), + } +} + +/// 一台整表指向 mock 的 `MicrosoftAuth`。 +pub fn auth_for(base: &str) -> MicrosoftAuth { + MicrosoftAuth::new(CLIENT_ID) + .expect("构造") + .with_endpoints(MicrosoftEndpoints::all_on(base)) + .with_poll_policy(fast_poll()) +} + +/// 一路通到 `login_with_xbox` 的路由表(后续步骤留给用例替换)。 +pub fn routes_until_login() -> Vec<(&'static str, Vec)> { + vec![ + ( + "/devicecode", + vec![MockReply::json(json!({ + "user_code": "ABCD-1234", + "device_code": "device-code-secret-value", + "verification_uri": "https://www.microsoft.com/link", + "expires_in": 900, + "interval": 0 + }))], + ), + ( + "/token", + vec![MockReply::json(json!({ + "access_token": "live-access-token-value", + "refresh_token": "live-refresh-token-value" + }))], + ), + ( + "/xbl", + vec![MockReply::json(json!({ + "Token": "xbl-token-value", + "DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] } + }))], + ), + ( + "/xsts", + vec![MockReply::json(json!({ + "Token": "xsts-token-value", + "DisplayClaims": { "xui": [{ "uhs": "uhs-1" }] } + }))], + ), + ] +} + +/// 整链(含 entitlements 与 profile),便于替换中间任意一步。 +pub fn full_routes() -> Vec<(&'static str, Vec)> { + let mut routes = routes_until_login(); + routes.push(( + "/login_with_xbox", + vec![MockReply::json(json!({ + "username": "Steve", + "access_token": "mc-access-token-value", + "expires_in": 86_400 + }))], + )); + routes.push(( + "/entitlements/mcstore", + vec![MockReply::json(json!({ "items": [] }))], + )); + routes.push(( + "/profile", + vec![MockReply::json( + json!({ "id": PROFILE_ID, "name": "Steve" }), + )], + )); + routes +} + +/// 一个已过期的旧会话(刷新/校验用例用)。 +pub fn expired_session() -> MicrosoftSession { + MicrosoftSession::new( + PROFILE_ID, + "Steve", + Secret::new("mc-access-token-1"), + Secret::new("live-refresh-token-1"), + 3_600, + 0, + None, + ) +} + +/// 取 `AuthError::Response` 的诊断(不是这个变体就让用例炸掉,报出真实值)。 +pub fn response_of(error: &AuthError) -> &ResponseDetail { + match error { + AuthError::Response(detail) => detail, + other => panic!("应是 Response,实际 {other:?}"), + } +} + +/// 断言一段文案/事件里没有任何测试 token。 +pub fn assert_no_credentials(text: &str) { + for forbidden in [ + "xsts-token-value", + "xbl-token-value", + "live-access-token-value", + "live-refresh-token-value", + "device-code-secret-value", + "mc-access-token-value", + ] { + assert!( + !text.contains(forbidden), + "文案泄漏了凭据 {forbidden}:{text}" + ); + } +} diff --git a/crates/core/tests/auth/support/mod.rs b/crates/core/tests/auth/support/mod.rs new file mode 100644 index 0000000..7a32eb4 --- /dev/null +++ b/crates/core/tests/auth/support/mod.rs @@ -0,0 +1,279 @@ +//! `core::auth` 集成测试夹具:临时目录 + 进程内 HTTP 服务器(零额外依赖)。 +//! +//! 为什么要 mock 服务器:微软/authlib 两条轨的正确性全在**报文级**(`XBL3.0 x=;` +//! 的拼接、XSTS 401 带 `XErr`、`/minecraft/profile` 404 的二义复判、Yggdrasil 的 +//! `clientToken` 回传校验)——只有把整条链打到一个真实 socket 上,这些分支才被真的跑到。 +//! +//! 服务器按 **path + 顺序队列**回应答:同一 path 的第 N 次请求用第 N 个应答 +//! (设备码轮询要靠这个:`authorization_pending` → `slow_down` → 成功)。 +//! 微软轨的 `MicrosoftAuth` / 路由表 / 断言助手拆在 [`microsoft`] 子模块里(M38)。 + +#![allow(dead_code)] + +use std::collections::{HashMap, VecDeque}; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +pub mod microsoft; +pub use microsoft::{ + assert_no_credentials, auth_for, expired_session, full_routes, response_of, routes_until_login, + HTML_BLOCK_PAGE, +}; + +/// 唯一临时目录(进程内计数 + 时间戳,避免并行测试互踩)。 +pub fn temp_dir(tag: &str) -> std::path::PathBuf { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_nanos()); + let dir = std::env::temp_dir().join(format!("ull-auth-test-{tag}-{stamp}-{unique}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("创建临时目录失败"); + dir +} + +/// 一次 mock 应答。 +#[derive(Debug, Clone)] +pub struct MockReply { + /// HTTP 状态码。 + pub status: u16, + /// 响应体(JSON 文本或空)。 + pub body: String, + /// `Content-Type`(默认 `application/json`;拦截页场景可改成 `text/html`)。 + pub content_type: &'static str, +} + +impl MockReply { + /// 200 + JSON。 + pub fn json(value: serde_json::Value) -> Self { + Self { + status: 200, + body: value.to_string(), + content_type: "application/json", + } + } + + /// 指定状态码 + JSON。 + pub fn json_status(status: u16, value: serde_json::Value) -> Self { + Self { + status, + body: value.to_string(), + content_type: "application/json", + } + } + + /// 指定状态码 + 空体。 + pub fn status(status: u16) -> Self { + Self { + status, + body: String::new(), + content_type: "application/json", + } + } + + /// 指定状态码 + HTML 拦截页(`Content-Type: text/html`)——M38 的网络分流用。 + pub fn html(status: u16, body: &str) -> Self { + Self { + status, + body: body.to_string(), + content_type: "text/html; charset=utf-8", + } + } + + /// 指定状态码 + 纯文本(网关页的另一种常见形状)。 + pub fn text(status: u16, body: &str) -> Self { + Self { + status, + body: body.to_string(), + content_type: "text/plain", + } + } +} + +/// 收到的一次请求(断言报文形状用)。 +#[derive(Debug, Clone)] +pub struct Recorded { + /// 方法(大写)。 + pub method: String, + /// 路径(不含 query)。 + pub path: String, + /// 请求体(表单或 JSON 原文)。 + pub body: String, +} + +/// 进程内 HTTP/1.1 服务器(按 path 排队回应答)。 +pub struct AuthMock { + addr: SocketAddr, + requests: Arc>>, + handle: tokio::task::JoinHandle<()>, +} + +impl AuthMock { + /// 启动服务器。`routes` 是 `(path, 该 path 依次使用的应答队列)`。 + pub async fn start(routes: Vec<(&str, Vec)>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("绑定端口"); + let addr = listener.local_addr().expect("取地址"); + let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let queues: Arc>>> = Arc::new(Mutex::new( + routes + .into_iter() + .map(|(path, replies)| (path.to_string(), replies.into())) + .collect(), + )); + + let recorded = Arc::clone(&requests); + let queues_for_task = Arc::clone(&queues); + let handle = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let recorded = Arc::clone(&recorded); + let queues = Arc::clone(&queues_for_task); + tokio::spawn(async move { + let _ = serve(stream, recorded, queues).await; + }); + } + }); + + Self { + addr, + requests, + handle, + } + } + + /// 基址(喂给 `MicrosoftEndpoints::all_on` / `AuthlibEndpoints` 的根)。 + pub fn base(&self) -> String { + format!("http://{}", self.addr) + } + + /// 端口。 + pub fn port(&self) -> u16 { + self.addr.port() + } + + /// 已收到的请求(按到达顺序)。 + pub fn requests(&self) -> Vec { + self.requests + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + } + + /// 找某 path 的请求体列表。 + pub fn bodies_on(&self, path: &str) -> Vec { + self.requests() + .into_iter() + .filter(|request| request.path == path) + .map(|request| request.body) + .collect() + } + + /// 关停。 + pub fn stop(self) { + self.handle.abort(); + } +} + +async fn serve( + mut stream: TcpStream, + recorded: Arc>>, + queues: Arc>>>, +) -> std::io::Result<()> { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 2048]; + // 读头 + let header_end = loop { + let read = stream.read(&mut chunk).await?; + if read == 0 { + return Ok(()); + } + buffer.extend_from_slice(&chunk[..read]); + if let Some(position) = find_headers_end(&buffer) { + break position; + } + }; + let head = String::from_utf8_lossy(&buffer[..header_end]).to_string(); + let content_length = head + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.trim().eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + }) + .unwrap_or(0); + while buffer.len() < header_end + content_length { + let read = stream.read(&mut chunk).await?; + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + } + let body = String::from_utf8_lossy( + &buffer[header_end..(header_end + content_length).min(buffer.len())], + ) + .to_string(); + + let mut first_line = head.lines().next().unwrap_or_default().split(' '); + let method = first_line.next().unwrap_or_default().to_ascii_uppercase(); + let raw_path = first_line.next().unwrap_or("/"); + let path = raw_path.split('?').next().unwrap_or("/").to_string(); + + recorded + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push(Recorded { + method, + path: path.clone(), + body, + }); + + let reply = queues + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get_mut(&path) + .and_then(VecDeque::pop_front) + .unwrap_or_else(|| MockReply::status(500)); + + let mut response = format!( + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + reply.status, + reason(reply.status), + reply.content_type, + reply.body.len() + ) + .into_bytes(); + response.extend_from_slice(reply.body.as_bytes()); + stream.write_all(&response).await?; + stream.flush().await?; + Ok(()) +} + +fn find_headers_end(buffer: &[u8]) -> Option { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| position + 4) +} + +fn reason(status: u16) -> &'static str { + match status { + 200 => "OK", + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "Status", + } +} diff --git a/crates/core/tests/diagnose/collectors_probe.rs b/crates/core/tests/diagnose/collectors_probe.rs new file mode 100644 index 0000000..1c81c7e --- /dev/null +++ b/crates/core/tests/diagnose/collectors_probe.rs @@ -0,0 +1,233 @@ +//! 五类信号采集:环形缓冲语义 / 退出码分类 / 磁盘采集器 / 引擎输入拼装。 + +use std::fs; +use std::time::{Duration, SystemTime}; + +use crate::prelude::*; + +#[test] +fn log_buffer_keeps_key_lines_when_evicted() { + let mut buffer = LogBuffer::new(2); + buffer.push(SignalOrigin::Stdout, "java.lang.Exception: boom"); + buffer.push(SignalOrigin::Stdout, "line 2"); + buffer.push(SignalOrigin::Stdout, "line 3"); + buffer.push(SignalOrigin::Stderr, "line 4"); + + assert_eq!(buffer.pushed(), 4); + assert_eq!(buffer.dropped(), 2); + assert_eq!(buffer.kept_key_lines(), 1, "关键行被淘汰后仍应保留"); + assert_eq!(buffer.len(), 3, "保留 1 行关键历史 + 环形区 2 行"); + let text = buffer.text(); + assert!(text.contains("boom"), "根因行不该被挤出缓冲区"); + assert!(text.contains("line 4")); + assert!(!text.contains("line 2")); + assert!(!buffer.is_empty()); +} + +#[test] +fn exit_types_match_launch_contract() { + // 字符串值与 launch-接口约定.md §3 exit.exit_type 完全一致 + let kill = ExitSignal::classify(Some(137), Some(60_000), 0, ""); + assert_eq!(kill.exit_type, ExitType::Sigkill); + assert_eq!(kill.exit_type.as_str(), "sigkill"); + assert_eq!(ExitType::from_name("sigkill"), ExitType::Sigkill); + + let term = ExitSignal::classify(Some(143), Some(60_000), 1, ""); + assert_eq!(term.exit_type, ExitType::Sigterm); + + let jvm = ExitSignal::classify( + Some(1), + Some(800), + 2, + "Error occurred during initialization of VM\nCould not create the Java Virtual Machine", + ); + assert_eq!(jvm.exit_type, ExitType::JvmError); + assert!(!jvm.silent_failure); + + let normal = ExitSignal::classify(Some(0), Some(600_000), 0, "Game started fine"); + assert_eq!(normal.exit_type, ExitType::Normal); + assert!(!normal.silent_failure); + assert!(normal.status_hex().is_none()); + + // exit 0 + 短命 + 有错误行 → 静默失败(HMCL 会判 NORMAL 放过) + let silent = ExitSignal::classify(Some(0), Some(5_000), 4, "java.lang.Exception: boom"); + assert_eq!(silent.exit_type, ExitType::Normal); + assert!(silent.silent_failure); + + // Windows 访问违例:负的 i32 → 十六进制状态码 + let access = ExitSignal::classify(Some(-1_073_741_819), Some(90_000), 0, ""); + assert!(access.is_access_violation()); + assert_eq!(access.status_hex().as_deref(), Some("0xc0000005")); + + for exit_type in [ + ExitType::Normal, + ExitType::ApplicationError, + ExitType::JvmError, + ExitType::Sigkill, + ExitType::Sigterm, + ExitType::Killed, + ExitType::Interrupted, + ] { + assert_eq!(ExitType::from_name(exit_type.as_str()), exit_type); + } +} + +#[test] +fn crash_report_collector_reads_newest_and_honours_started_at() { + let run_dir = temp_dir("collect-crash"); + let reports = run_dir.join("crash-reports"); + fs::create_dir_all(&reports).expect("建目录"); + fs::write( + reports.join("crash-2026-10-29_12.00.00-client.txt"), + "---- Minecraft Crash Report ----\nDescription: Example\n", + ) + .expect("写崩溃报告"); + + let collector = CrashReportCollector::new(); + let collectors: [&dyn SignalCollector; 1] = [&collector]; + let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir)); + assert!(errors.is_empty()); + let captured = bundle.crash_report.expect("应采到崩溃报告"); + assert!(captured.text.contains("Description: Example")); + assert_eq!(bundle.sources.len(), 1); + + // 只要「本次会话之后」的文件:把下界设到未来 → 不采 + let future = + CollectionContext::new(&run_dir).since(SystemTime::now() + Duration::from_secs(3_600)); + let (bundle, errors) = collect_all(&collectors, &future); + assert!(errors.is_empty()); + assert!(bundle.crash_report.is_none(), "历史崩溃不该算进本次"); +} + +#[test] +fn hs_err_collector_parses_newest_file() { + let run_dir = temp_dir("collect-hs-err"); + fs::write( + run_dir.join("hs_err_pid4242.log"), + "#\n# A fatal error has been detected by the Java Runtime Environment:\n#\n# EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x1, pid=4242, tid=0x2\n# Problematic frame:\n# C [nvoglv64.dll+0x10]\n", + ) + .expect("写 hs_err"); + + let collector = HsErrCollector::new(); + let collectors: [&dyn SignalCollector; 1] = [&collector]; + let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir)); + assert!(errors.is_empty()); + let report = bundle.hs_err.expect("应解析 hs_err"); + assert_eq!(report.kind, HsErrKind::AccessViolation); + assert_eq!(report.pid, Some(4242)); + assert_eq!(bundle.sources.len(), 1); +} + +#[test] +fn latest_log_collector_tails_n_lines() { + let run_dir = temp_dir("collect-latest"); + let logs = run_dir.join("logs"); + fs::create_dir_all(&logs).expect("建目录"); + let mut text = String::new(); + for index in 0..10 { + text.push_str(&format!("line {index}\n")); + } + fs::write(logs.join("latest.log"), text).expect("写日志"); + + let collector = LatestLogCollector::new().with_max_lines(4); + let collectors: [&dyn SignalCollector; 1] = [&collector]; + let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir)); + assert!(errors.is_empty()); + assert_eq!(bundle.log.pushed(), 4); + assert!(bundle.log.text().contains("line 9")); + assert!(!bundle.log.text().contains("line 5")); +} + +#[test] +fn collect_all_aggregates_pushed_and_disk_signals() { + let run_dir = temp_dir("collect-all"); + let reports = run_dir.join("crash-reports"); + fs::create_dir_all(&reports).expect("建目录"); + fs::write( + reports.join("crash-a.txt"), + "---- Minecraft Crash Report ----\n", + ) + .expect("写报告"); + + let snapshot = EnvironmentSnapshot { + java_major: Some(17), + java_bits: Some(64), + xmx_mb: Some(4096), + physical_memory_mb: Some(16_384), + mods: vec![ModRecord::from_file_name("sodium-fabric-0.5.8.jar")], + components: vec![unlucky_core::diagnose::collectors::ComponentVersion::new( + "fabric", "0.15.0", + )], + ..EnvironmentSnapshot::default() + }; + + let exit = ExitCollector::new(ExitSignal::classify(Some(1), Some(9_000), 1, "boom")); + let environment = EnvironmentCollector::new(snapshot); + let crash = CrashReportCollector::new(); + let hs_err = HsErrCollector::new(); + let collectors: [&dyn SignalCollector; 4] = [&exit, &environment, &crash, &hs_err]; + + let (bundle, errors) = collect_all(&collectors, &CollectionContext::new(&run_dir)); + assert!(errors.is_empty(), "单源缺失不应报错:{errors:?}"); + assert!(bundle.exit.is_some()); + assert!(bundle.crash_report.is_some()); + assert!( + bundle.hs_err.is_none(), + "没有 hs_err 文件时是 None(不是错误)" + ); + let snapshot = bundle.environment.as_ref().expect("环境快照"); + assert_eq!(snapshot.java_major, Some(17)); + assert!(snapshot.summary().contains("Java 17")); + assert_eq!(bundle.mods.len(), 1); + assert!(bundle.mods[0].enabled); + assert!(!bundle.is_empty()); +} + +#[test] +fn mod_record_parses_file_name() { + let record = ModRecord::from_file_name("jei-1.20.1-15.2.0.27.jar"); + assert_eq!(record.id, "jei-1.20.1"); + assert_eq!(record.version.as_deref(), Some("15.2.0.27")); + assert!(record.enabled); + + let disabled = ModRecord::from_file_name("optifine.jar.disabled"); + assert!(!disabled.enabled); + assert_eq!(disabled.id, "optifine"); +} + +#[test] +fn bundle_input_groups_log_by_origin_and_carries_hs_err() { + let mut bundle = SignalBundle::new(); + bundle.log.push(SignalOrigin::Stdout, "stdout line"); + bundle.log.push(SignalOrigin::Stderr, "stderr line"); + bundle.exit = Some(ExitSignal::classify(Some(1), Some(1_000), 1, "boom")); + + let input = bundle.diagnosis_input(); + assert!(input.exit.is_some()); + let origins = input + .haystacks + .iter() + .map(|haystack| haystack.origin) + .collect::>(); + assert!(origins.contains(&SignalOrigin::Stdout)); + assert!(origins.contains(&SignalOrigin::Stderr)); + assert!(!input.is_empty()); + + let mut with_hs_err = SignalBundle::new(); + with_hs_err.hs_err = Some(hs_err::parse(&read_sample( + "samples/hs_err/access_violation.log", + ))); + let input = with_hs_err.diagnosis_input(); + assert!(input.hs_err.is_some()); + assert!(input + .haystacks + .iter() + .any(|haystack| haystack.origin == SignalOrigin::HsErr)); +} + +#[test] +fn empty_bundle_produces_empty_input() { + let bundle = SignalBundle::new(); + assert!(bundle.is_empty()); + assert!(bundle.diagnosis_input().is_empty()); +} diff --git a/crates/core/tests/diagnose/engine_rank.rs b/crates/core/tests/diagnose/engine_rank.rs new file mode 100644 index 0000000..636e63f --- /dev/null +++ b/crates/core/tests/diagnose/engine_rank.rs @@ -0,0 +1,233 @@ +//! 引擎行为:主因排序 / 抑制关系 / 去重 / 内置结论 / 动作物化。 + +use crate::prelude::*; + +#[test] +fn root_cause_outranks_secondary_error() { + // 一条日志里既 OOM 又 NoClassDefFoundError:OOM 是根因(priority 80 vs 40)。 + let text = "\ +java.lang.OutOfMemoryError: Java heap space +java.lang.NoClassDefFoundError: net/minecraftforge/fml/loading/FMLEnvironment +"; + let report = analyze_text(SignalOrigin::Stdout, text); + assert_eq!( + report.primary().expect("命中").rule_id, + "memory.out_of_memory" + ); + assert!( + ids(&report).contains(&"integrity.no_class_def_found"), + "低级错误也应给出(哪怕是次生):{:?}", + ids(&report) + ); +} + +#[test] +fn suppress_moves_rule_into_secondary() { + // 32 位 Java 分配失败 + 次生 OutOfMemoryError + let text = "\ +Could not reserve enough space for 4194304KB object heap +java.lang.OutOfMemoryError: Java heap space +"; + let report = analyze_text(SignalOrigin::Stdout, text); + assert_eq!(report.primary().expect("命中").rule_id, "jvm.32bit"); + let secondary = report + .secondary + .iter() + .find(|finding| finding.rule_id == "memory.out_of_memory") + .expect("OOM 应被降级为次生结论而不是丢弃"); + assert_eq!(secondary.suppressed_by.as_deref(), Some("jvm.32bit")); + assert!( + !ids(&report).contains(&"memory.out_of_memory"), + "被抑制的结论不应出现在主因列表" + ); +} + +#[test] +fn same_rule_keeps_best_evidence_source() { + // 同一条文本在两个来源出现:只留一条结论,且优先崩溃报告。 + let mut input = DiagnosisInput::new(); + input.push_text( + SignalOrigin::Stdout, + "java.lang.OutOfMemoryError: Java heap space", + ); + input.push_text( + SignalOrigin::CrashReport, + "---- Minecraft Crash Report ----\njava.lang.OutOfMemoryError: Java heap space", + ); + let report = diagnoser().analyze(&input); + let hits = report + .findings + .iter() + .filter(|finding| finding.rule_id == "memory.out_of_memory") + .count(); + assert_eq!(hits, 1, "同一规则只应报一次"); + let finding = &report.findings[0]; + assert_eq!(finding.evidence[0].origin, SignalOrigin::CrashReport); +} + +#[test] +fn access_violation_exit_is_diagnosed() { + let exit = ExitSignal::classify(Some(-1_073_741_819), Some(90_000), 0, ""); + let mut input = DiagnosisInput::new(); + input.exit = Some(exit); + let report = diagnoser().analyze(&input); + let finding = report + .findings + .iter() + .find(|finding| finding.rule_id == "signal.access_violation") + .expect("0xC0000005 应产出内置结论"); + assert_eq!(finding.category, RuleCategory::Graphics); + let action = &finding.materialized_actions()[0]; + assert_eq!(action.kind, FixActionKind::ChangeGraphics); + assert_eq!(action.tool, "graphics.set"); + assert_eq!( + action.args.get("update_driver").map(String::as_str), + Some("true") + ); +} + +#[test] +fn silent_failure_is_marked_on_exit_zero() { + // analyse/02 §7.10-10:exit 0 + 存活 <30s + 有错误行 → 静默失败,HMCL 会判 NORMAL 放过。 + let exit = ExitSignal::classify(Some(0), Some(4_200), 3, "java.lang.Exception: boom"); + assert_eq!(exit.exit_type, ExitType::Normal); + assert!(exit.silent_failure); + let mut input = DiagnosisInput::new(); + input.exit = Some(exit); + let report = diagnoser().analyze(&input); + assert!( + ids(&report).contains(&"signal.silent_failure"), + "静默失败应单独打标:{:?}", + ids(&report) + ); + // 低置信 → 交 AI 深化(PlanDoc/06 §2.3) + assert!(report.needs_more_info()); +} + +#[test] +fn empty_input_asks_for_more_information() { + let report = diagnoser().analyze(&DiagnosisInput::new()); + assert!(report.findings.is_empty()); + assert!(report.needs_more_info()); +} + +#[test] +fn conflict_rule_materializes_capture_groups() { + let text = read_sample("samples/fabric/mod_resolution_conflict.log"); + let report = analyze_text(SignalOrigin::Stdout, &text); + let primary = report.primary().expect("命中"); + assert_eq!(primary.rule_id, "fabric.mod_resolution_conflict"); + let action = &primary.materialized_actions()[0]; + assert_eq!(action.kind, FixActionKind::RemoveMod); + assert_eq!(action.tool, "mod.remove"); + assert_eq!( + action.args.get("mod_ids").map(String::as_str), + Some("sodium,sodium-extra"), + "mod_ids 应已代入命名捕获组" + ); +} + +/// 回归(review P2-1):OptiFine/Forge 兼容规则最后一个 alternative 必须是**前缀语义**, +/// 既收裸 `(Reflector.java)` 也收真实的带行号形态 `(Reflector.java:455)`。 +#[test] +fn optifine_forge_matches_both_frame_forms() { + for frame in [ + "\tTRANSFORMER/net.optifine/net.optifine.reflect.Reflector.(Reflector.java:455)", + "\tTRANSFORMER/net.optifine/net.optifine.reflect.Reflector.(Reflector.java)", + ] { + let report = analyze_text(SignalOrigin::Stdout, frame); + assert!( + ids(&report).contains(&"conflict.optifine_forge"), + "真实栈帧形态应命中:{frame}(实际命中:{:?})", + ids(&report) + ); + } +} + +#[test] +fn too_old_java_converts_class_file_version() { + let text = read_sample("samples/jvm/too_old_java.log"); + let report = analyze_text(SignalOrigin::Stdout, &text); + let primary = report.primary().expect("命中"); + assert_eq!(primary.rule_id, "jvm.too_old_java"); + let action = &primary.materialized_actions()[0]; + assert_eq!( + action.args.get("required_major").map(String::as_str), + Some("17"), + "class file version 61 应换算成 Java 17" + ); + assert_eq!(class_file_to_java_major(61), Some(17)); + assert_eq!(class_file_to_java_major(52), Some(8)); + assert_eq!(class_file_to_java_major(40), None); +} + +#[test] +fn fix_actions_are_deduplicated_across_findings() { + // 一条日志同时命中 OOM(adjust_memory)与 32bit(adjust_memory + change_java) + let text = "\ +Could not reserve enough space for 4194304KB object heap +java.lang.OutOfMemoryError: Java heap space +"; + let report = analyze_text(SignalOrigin::Stdout, text); + let actions = report.fix_actions(); + assert!(!actions.is_empty()); + let mut unique = actions.clone(); + unique.sort_by(|a, b| a.tool.cmp(b.tool).then(a.args.len().cmp(&b.args.len()))); + unique.dedup(); + assert_eq!(unique.len(), actions.len(), "fix_actions 应当已去重"); +} + +#[test] +fn record_matches_launch_diagnosis_payload() { + let text = read_sample("samples/memory/out_of_memory.log"); + let report = analyze_text(SignalOrigin::Stdout, &text); + let record = report.to_record(); + assert_eq!(record.source, "rules"); + assert!(!record.candidates.is_empty()); + let candidate = &record.candidates[0]; + assert_eq!(candidate.id, "memory.out_of_memory"); + assert_eq!(candidate.severity, "critical"); + assert_eq!(candidate.category, "memory"); + assert_eq!(candidate.message_key, "diagnose.cause.memory.out_of_memory"); + assert!(!candidate.message.is_empty(), "M16 需要渲染后的中文结论"); + assert!(!candidate.evidence.is_empty()); + assert_eq!(candidate.fix_actions[0].tool, "memory.set"); +} + +#[test] +fn hs_err_report_feeds_builtin_findings() { + let report = hs_err::parse(&read_sample("samples/hs_err/access_violation.log")); + let mut input = DiagnosisInput::new(); + input.push_hs_err(report); + let analysis = diagnoser().analyze(&input); + assert_eq!( + analysis.primary().expect("命中").rule_id, + "hs_err.access_violation" + ); + // graphics.graphics_driver 会被内置结论 suppress 成次生(同一条证据、不同粒度) + assert!( + analysis + .secondary + .iter() + .any(|finding| finding.rule_id == "graphics.graphics_driver"), + "hs_err 原文也应命中图形规则,但被降级:{:?}", + analysis + .secondary + .iter() + .map(|finding| finding.rule_id.as_str()) + .collect::>() + ); +} + +#[test] +fn file_scope_rules_survive_very_long_logs() { + // >1 MiB:首尾窗口扫描,尾部窗口里的签名仍应命中。 + let mut text = String::from("noise line\n").repeat(120_000); + text.push_str("java.lang.OutOfMemoryError: Java heap space\n"); + assert!(text.len() > 1024 * 1024); + let report = analyze_text(SignalOrigin::LatestLog, &text); + assert!( + ids(&report).contains(&"memory.out_of_memory"), + "超长日志的尾部窗口应被扫描" + ); +} diff --git a/crates/core/tests/diagnose/hs_err_parse.rs b/crates/core/tests/diagnose/hs_err_parse.rs new file mode 100644 index 0000000..a6df686 --- /dev/null +++ b/crates/core/tests/diagnose/hs_err_parse.rs @@ -0,0 +1,143 @@ +//! `hs_err_pid*.log` 解析器:标题行 / 线程段 / 内存段 / 失败帧。 + +use crate::prelude::*; + +#[test] +fn parses_access_violation_report() { + let text = read_sample("samples/hs_err/access_violation.log"); + let report = hs_err::parse(&text); + + assert_eq!(report.kind, HsErrKind::AccessViolation); + assert_eq!( + report.header.as_deref(), + Some("A fatal error has been detected by the Java Runtime Environment:") + ); + assert_eq!( + report.exception.as_deref(), + Some("EXCEPTION_ACCESS_VIOLATION (0xc0000005)") + ); + assert_eq!(report.pc.as_deref(), Some("0x00007ffb1a2b3c4d")); + assert_eq!(report.pid, Some(12344)); + assert_eq!(report.tid.as_deref(), Some("0x0000000000002a1c")); + + let frame = report.failed_frame.as_ref().expect("失败帧"); + assert_eq!(frame.kind, FrameKind::Native); + assert_eq!(frame.module.as_deref(), Some("ig8d64.dll")); + assert_eq!(frame.offset.as_deref(), Some("0x9b3c4d")); + + let thread = report.current_thread.as_deref().expect("当前线程"); + assert!(thread.contains("Render thread"), "{thread}"); + assert!(report + .jre_version + .as_deref() + .is_some_and(|line| line.contains("8.0_51"))); + assert!(report + .java_vm + .as_deref() + .is_some_and(|line| line.contains("HotSpot"))); + + let memory = report.memory.as_ref().expect("内存段"); + assert_eq!(memory.page_size.as_deref(), Some("4k")); + assert_eq!(memory.physical_kb, Some(16_741_480)); + assert_eq!(memory.physical_free_kb, Some(4_716_856)); + assert_eq!(memory.swap_kb, Some(18_672_384)); + assert_eq!(memory.swap_free_kb, Some(9_433_608)); + assert!(memory.summary().contains("GB")); + + assert!(!report.heap.is_empty(), "Heap 段应被抽取"); + assert!(report + .vm_arguments + .iter() + .any(|arg| arg.contains("-Xmx4096m"))); + assert!(report + .command_line + .as_deref() + .is_some_and(|line| line.contains("net.minecraft.launchwrapper.Launch"))); +} + +#[test] +fn digest_keeps_header_block_for_cross_line_rules() { + let report = hs_err::parse(&read_sample("samples/hs_err/access_violation.log")); + let digest = report.digest(); + // 跨行图形规则依赖「EXCEPTION_ACCESS_VIOLATION … # C [ig…」的相对位置 + assert!(digest.contains("EXCEPTION_ACCESS_VIOLATION")); + assert!(digest.contains("# C [ig8d64.dll")); + assert!(digest.contains("Current thread")); + assert!(digest.contains("Memory:")); +} + +#[test] +fn parses_out_of_memory_report() { + let report = hs_err::parse(&read_sample("samples/hs_err/out_of_memory.log")); + assert_eq!(report.kind, HsErrKind::OutOfMemory); + assert_eq!(report.pid, Some(9876)); + assert_eq!( + report.exception, None, + "OOM 型 hs_err 没有 EXCEPTION_* 行,不该瞎猜" + ); + assert!(report + .header + .as_deref() + .is_some_and(|header| header.contains("insufficient memory"))); + let memory = report.memory.as_ref().expect("内存段"); + assert_eq!(memory.physical_kb, Some(8_134_564)); + assert!(report.summary().contains("out_of_memory")); +} + +#[test] +fn parse_never_fails_on_garbage() { + for text in ["", "\n\n", "随便一段中文", "# EXCEPTION_ACCESS_VIOLATION"] { + let report = hs_err::parse(text); + // 不 panic 即可;字段可以全空 + let _ = report.digest(); + let _ = report.summary(); + } + assert_eq!(hs_err::parse("").kind, HsErrKind::Other); +} + +#[test] +fn parse_file_attaches_path() { + let path = rules_dir().join("samples/hs_err/access_violation.log"); + let report = hs_err::parse_file(&path).expect("读文件"); + assert_eq!(report.path.as_deref(), Some(path.as_path())); + assert_eq!(report.kind, HsErrKind::AccessViolation); +} + +#[test] +fn missing_file_is_an_io_error() { + let path = temp_dir("hs-err-missing").join("hs_err_pid1.log"); + assert!(hs_err::parse_file(&path).is_err()); +} + +#[test] +fn java_frame_is_classified() { + let text = "\ +# EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x0000000000000000, pid=1, tid=0x2 +# Problematic frame: +# J 123 c1 com.example.mod.Foo.bar(Ljava/lang/String;)V (12 bytes) @ 0x00007ffb +"; + let report = hs_err::parse(text); + let frame = report.failed_frame.as_ref().expect("失败帧"); + assert_eq!(frame.kind, FrameKind::Java); + assert_eq!(frame.module, None); +} + +#[test] +fn engine_reports_hs_err_out_of_memory() { + let report = hs_err::parse(&read_sample("samples/hs_err/out_of_memory.log")); + let mut input = DiagnosisInput::new(); + input.push_hs_err(report); + let analysis = diagnoser().analyze(&input); + let finding = analysis + .findings + .iter() + .find(|finding| finding.rule_id == "hs_err.out_of_memory") + .expect("hs_err OOM 应产出内置结论"); + assert_eq!(finding.category, RuleCategory::Memory); + assert!(finding.message.contains("内存不足")); + assert_eq!( + finding.materialized_actions()[0].tool, + "memory.set", + "M16 需要能直接执行的工具 id" + ); +} diff --git a/crates/core/tests/diagnose/loader_reload.rs b/crates/core/tests/diagnose/loader_reload.rs new file mode 100644 index 0000000..f402a34 --- /dev/null +++ b/crates/core/tests/diagnose/loader_reload.rs @@ -0,0 +1,171 @@ +//! 规则库加载 / 热重载 / 坏文件的降级行为。 + +use std::fs; + +use crate::prelude::*; + +const CUSTOM_MEMORY: &str = "\ +version: 1 +category: memory +rules: + - id: memory.custom_only + severity: warning + pattern: 'CUSTOM TEST SIGNATURE' + confidence: 0.9 + priority: 70 + cause: + key: diagnose.cause.memory.custom_only + summary: '测试用自定义规则' + fix_action: + - kind: seek_help + sample: samples/memory/out_of_memory.log +"; + +#[test] +fn embedded_library_is_complete() { + let loader = RuleLoader::embedded(); + assert_eq!(EMBEDDED_RULES.len(), 10, "10 个根因分组各一个 YAML"); + // 64 = HMCL 59 条全量平移 + 5 条 Vulkan 组(graphics.yaml 末尾的 4 个原因码 + // + no_graphics_backend)。加规则要同时加样本并同步这个数。 + assert_eq!(loader.rules().len(), 64); + assert!(loader.last_errors().is_empty()); + assert!(loader.dir().is_none()); +} + +#[test] +fn missing_directory_falls_back_to_embedded() { + let dir = temp_dir("loader-missing").join("nope"); + let loader = RuleLoader::from_dir(&dir); + assert_eq!(loader.rules().len(), 64); + assert!(loader.last_errors().is_empty()); +} + +#[test] +fn disk_file_replaces_embedded_group() { + let dir = temp_dir("loader-override"); + fs::write(dir.join("memory.yaml"), CUSTOM_MEMORY).expect("写规则文件"); + let loader = RuleLoader::from_dir(&dir); + + assert_eq!(loader.rules().len(), 63, "memory.yaml 的 2 条被 1 条替换"); + assert!(loader.last_errors().is_empty()); + assert!(loader.rule_set().get("memory.custom_only").is_some()); + assert!( + loader.rule_set().get("memory.out_of_memory").is_none(), + "同组文件被整份替换" + ); +} + +#[test] +fn refresh_picks_up_edits() { + let dir = temp_dir("loader-refresh"); + let mut loader = RuleLoader::from_dir(&dir); + assert_eq!( + loader.refresh(), + ReloadOutcome::Unchanged, + "没变就不该重解析" + ); + + fs::write(dir.join("memory.yaml"), CUSTOM_MEMORY).expect("写规则文件"); + assert_eq!( + loader.refresh(), + ReloadOutcome::Reloaded { rules: 63 }, + "指纹变了应重新加载" + ); + assert!(loader.rule_set().get("memory.custom_only").is_some()); +} + +#[test] +fn broken_yaml_keeps_last_good_rules() { + let dir = temp_dir("loader-broken"); + fs::write(dir.join("memory.yaml"), CUSTOM_MEMORY).expect("写规则文件"); + let mut loader = RuleLoader::from_dir(&dir); + assert_eq!(loader.rules().len(), 63); + + // 用户手改坏了 YAML:保留上一份好规则 + 报错,而不是让诊断能力归零 + fs::write( + dir.join("memory.yaml"), + "version: 1\ncategory: memory\nrules: [ oops\n", + ) + .expect("写坏文件"); + match loader.refresh() { + ReloadOutcome::Rejected { errors } => { + assert!(!errors.is_empty()); + assert_eq!( + errors[0].kind, + unlucky_core::diagnose::loader::LoadErrorKind::Yaml + ); + } + other => panic!("应被拒绝:{other:?}"), + } + assert_eq!(loader.rules().len(), 63, "坏文件不该清空规则库"); + assert!(loader.rule_set().get("memory.custom_only").is_some()); + assert!(!loader.last_errors().is_empty()); +} + +#[test] +fn duplicate_rule_id_across_files_is_rejected() { + let dir = temp_dir("loader-duplicate"); + let duplicated = "\ +version: 1 +category: other +rules: + - id: memory.out_of_memory + pattern: 'dup' + cause: + key: diagnose.cause.dup + summary: '重复 id' +"; + fs::write(dir.join("other.yaml"), duplicated).expect("写规则文件"); + let loader = RuleLoader::from_dir(&dir); + + assert_eq!(loader.rules().len(), 64, "整体失败后回退内嵌规则集"); + assert!(loader + .last_errors() + .iter() + .any(|error| error.kind == unlucky_core::diagnose::loader::LoadErrorKind::DuplicateId)); +} + +#[test] +fn unknown_field_is_rejected() { + let dir = temp_dir("loader-typo"); + let typo = "\ +version: 1 +category: other +rules: + - id: other.typo + patternn: 'typo in field name' + cause: + key: diagnose.cause.typo + summary: '字段拼错' +"; + fs::write(dir.join("other.yaml"), typo).expect("写规则文件"); + let loader = RuleLoader::from_dir(&dir); + assert_eq!(loader.rules().len(), 64); + assert!( + !loader.last_errors().is_empty(), + "deny_unknown_fields 应把拼错的字段报出来" + ); +} + +#[test] +fn rule_set_rejects_invalid_regex() { + let sources = vec![( + "bad.yaml".to_owned(), + "\ +version: 1 +category: other +rules: + - id: other.bad_regex + pattern: '(unclosed' + cause: + key: diagnose.cause.bad + summary: '坏正则' +" + .to_owned(), + )]; + let errors = RuleSet::from_sources(&sources).expect_err("坏正则应失败"); + assert_eq!( + errors[0].kind, + unlucky_core::diagnose::loader::LoadErrorKind::Regex + ); +} diff --git a/crates/core/tests/diagnose/main.rs b/crates/core/tests/diagnose/main.rs new file mode 100644 index 0000000..8f423c1 --- /dev/null +++ b/crates/core/tests/diagnose/main.rs @@ -0,0 +1,83 @@ +//! `core::diagnose` 集成测试。 +//! +//! 结构对齐 HMCL 的 `CrashReportAnalyzerTest` + `resources/logs`(analyse/02 §7.9): +//! **每条规则一个样本**(`src/diagnose/rules.d/samples/`),断言「样本命中它自己」。 +//! 规则库是数据不是代码,样本就是规则的验收标准——新增规则必须同时提交样本。 +//! +//! 跑法:`cargo test -p unlucky-core --test diagnose` + +mod collectors_probe; +mod engine_rank; +mod hs_err_parse; +mod loader_reload; +mod rules_samples; + +/// 测试共用导入与辅助函数。 +pub mod prelude { + use std::path::{Path, PathBuf}; + + pub use unlucky_core::diagnose::collectors::{ + collect_all, CollectionContext, CrashReportCollector, EnvironmentCollector, + EnvironmentSnapshot, ExitCollector, ExitSignal, ExitType, HsErrCollector, + LatestLogCollector, LogBuffer, ModRecord, SignalBundle, SignalCollector, SignalKind, + SignalOrigin, + }; + pub use unlucky_core::diagnose::engine::{ + Diagnoser, DiagnosisInput, DiagnosisReport, Evidence, Finding, CONCLUSIVE_CONFIDENCE, + }; + pub use unlucky_core::diagnose::hs_err::{self, FrameKind, HsErrKind, HsErrReport}; + pub use unlucky_core::diagnose::loader::{ReloadOutcome, RuleLoader, RuleSet, EMBEDDED_RULES}; + pub use unlucky_core::diagnose::rules::{ + class_file_to_java_major, ComponentKind, FixAction, FixActionKind, GraphicsSetting, + JavaVendor, ModAction, RuleCategory, Severity, + }; + + /// 规则目录(`crates/core/src/diagnose/rules.d`)。 + #[must_use] + pub fn rules_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("src/diagnose/rules.d") + } + + /// 读样本(路径相对 `rules.d/`)。 + pub fn read_sample(relative: &str) -> String { + let path = rules_dir().join(relative); + std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("样本 {} 读不到:{error}", path.display())) + } + + /// 用内嵌规则库跑一段文本。 + pub fn analyze_text(origin: SignalOrigin, text: &str) -> DiagnosisReport { + let mut input = DiagnosisInput::new(); + input.push_text(origin, text); + diagnoser().analyze(&input) + } + + /// 内嵌规则库构造的引擎。 + #[must_use] + pub fn diagnoser() -> Diagnoser { + Diagnoser::new(RuleLoader::embedded().rules().to_vec()) + } + + /// 结论 id 列表(断言失败时打印用)。 + #[must_use] + pub fn ids(report: &DiagnosisReport) -> Vec<&str> { + report + .findings + .iter() + .map(|finding| finding.rule_id.as_str()) + .collect() + } + + /// 临时目录(进程 id + 计数器,不引 tempfile 依赖)。 + #[must_use] + pub fn temp_dir(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let index = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = + std::env::temp_dir().join(format!("ull-diagnose-{}-{tag}-{index}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("创建临时目录"); + dir + } +} diff --git a/crates/core/tests/diagnose/rules_samples.rs b/crates/core/tests/diagnose/rules_samples.rs new file mode 100644 index 0000000..7573868 --- /dev/null +++ b/crates/core/tests/diagnose/rules_samples.rs @@ -0,0 +1,267 @@ +//! 规则库完整性 + 每条规则的样本回归(HMCL `CrashReportAnalyzerTest` 同构)。 + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use crate::prelude::*; + +#[test] +fn every_rule_has_a_sample_that_hits_itself() { + let loader = RuleLoader::embedded(); + let rules = loader.rules(); + // 64 = HMCL 59 条全量平移 + 5 条 Vulkan 组(rules.d/graphics.yaml 末尾: + // vulkan_loader_missing / vulkan_device_version_too_low / vulkan_missing_extension / + // vulkan_missing_feature / no_graphics_backend)。加规则记得同时加样本并改这个数。 + assert_eq!(rules.len(), 64, "HMCL 59 条 + Vulkan 5 条规则应全量在位"); + for rule in rules { + let sample = rule + .rule() + .sample + .as_deref() + .unwrap_or_else(|| panic!("规则 {} 没有 sample 字段", rule.id())); + let text = read_sample(sample); + let report = analyze_text(SignalOrigin::Stdout, &text); + let hit = report + .findings + .iter() + .chain(report.secondary.iter()) + .any(|finding| finding.rule_id == rule.id()); + assert!( + hit, + "规则 {} 的样本 {} 没有命中它自己(实际命中:{:?})", + rule.id(), + sample, + ids(&report) + ); + } +} + +#[test] +fn rule_ids_are_unique_and_namespaced() { + let loader = RuleLoader::embedded(); + let mut seen = BTreeSet::new(); + for rule in loader.rules() { + assert!(seen.insert(rule.id()), "规则 id 重复:{}", rule.id()); + let (namespace, name) = rule + .id() + .split_once('.') + .unwrap_or_else(|| panic!("规则 id 必须带命名空间:{}", rule.id())); + assert!( + !namespace.is_empty() && !name.is_empty(), + "规则 id 不合法:{}", + rule.id() + ); + } +} + +#[test] +fn rules_carry_cause_actions_and_provenance() { + for rule in RuleLoader::embedded().rules() { + let rule = rule.rule(); + assert!( + rule.cause.key.starts_with("diagnose.cause."), + "规则 {} 的 cause.key 应以 diagnose.cause. 开头", + rule.id + ); + assert!( + !rule.cause.summary.is_empty(), + "规则 {} 缺结论文案", + rule.id + ); + assert!( + !rule.fix_action.is_empty(), + "规则 {} 没有绑定六维修复动作(M16 一键修复要靠它)", + rule.id + ); + assert!( + rule.source.is_some(), + "规则 {} 缺 source(HMCL 出处)", + rule.id + ); + assert!( + (0.0..=1.0).contains(&rule.confidence), + "规则 {} 的 confidence 超出 0~1", + rule.id + ); + } +} + +#[test] +fn every_reported_category_has_rules() { + let mut categories = BTreeSet::new(); + for rule in RuleLoader::embedded().rules() { + categories.insert(rule.category()); + } + for category in RuleCategory::all() { + assert!( + categories.contains(&category), + "分类 {category} 一条规则都没有(rules.d 里缺这个文件或为空)" + ); + } +} + +#[test] +fn no_orphan_samples() { + let samples_root = rules_dir().join("samples"); + let declared = RuleLoader::embedded() + .rules() + .iter() + .filter_map(|rule| rule.rule().sample.clone()) + .collect::>(); + let mut on_disk = BTreeSet::new(); + for group in std::fs::read_dir(&samples_root).expect("samples 目录") { + let group = group.expect("目录项").path(); + if !group.is_dir() { + continue; + } + collect(&samples_root, &group, &mut on_disk); + } + for path in &on_disk { + assert!( + declared.contains(path), + "样本 {path} 没有规则引用它(孤儿样本,或 sample: 路径写错)" + ); + } +} + +/// 递归收集 `.log` 样本,返回相对 `rules.d/` 的路径。 +fn collect(root: &Path, dir: &Path, out: &mut BTreeSet) { + for entry in std::fs::read_dir(dir).expect("目录项") { + let path = entry.expect("目录项").path(); + if path.is_dir() { + collect(root, &path, out); + } else if path.extension().is_some_and(|ext| ext == "log") { + let relative = path + .strip_prefix(root) + .expect("相对路径") + .to_string_lossy() + .replace('\\', "/"); + // hs_err 样本是解析器测试素材,不对应规则。 + if !relative.starts_with("hs_err/") { + out.insert(format!("samples/{relative}")); + } + } + } +} + +#[test] +fn decisive_rule_gives_evidence_message_and_actions() { + let text = read_sample("samples/integrity/mod_files_decompressed.log"); + let report = analyze_text(SignalOrigin::Stdout, &text); + let primary = report.primary().expect("应当命中"); + assert_eq!(primary.rule_id, "integrity.mod_files_decompressed"); + assert!(!primary.message.is_empty()); + assert_eq!(primary.severity, Severity::Critical); + let evidence = primary.evidence.first().expect("证据"); + assert_eq!(evidence.origin, SignalOrigin::Stdout); + assert!(evidence.render().starts_with("stdout:")); + assert!(!primary.fix_actions.is_empty()); + + // 高置信结论不该再要求 AI 深化 + assert!( + !report.needs_more_info(), + "0.95 置信度的结论被判成「需要更多信息」" + ); + assert_eq!(report.rules_evaluated, 64); +} + +/// Vulkan 组:语义是「Vulkan 后端起不来」,所以**成功的** Vulkan 日志不该命中任何一条。 +#[test] +fn healthy_vulkan_startup_hits_no_vulkan_rule() { + let text = "\ +[12:00:21] [Render thread/INFO]: Backend library: LWJGL version 3.4.3+13 +[12:00:21] [Render thread/INFO]: Using graphics backend VULKAN, using drivers: AMD proprietary driver +[12:00:21] [Render thread/INFO]: OpenGL debug message callback registered +"; + let report = analyze_text(SignalOrigin::Stdout, text); + let hit = report + .findings + .iter() + .chain(report.secondary.iter()) + .map(|finding| finding.rule_id.as_str()) + .filter(|id| id.starts_with("graphics.vulkan") || *id == "graphics.no_graphics_backend") + .collect::>(); + assert!(hit.is_empty(), "正常的 Vulkan 启动日志被判成故障:{hit:?}"); +} + +/// Vulkan 规则必须落在 `graphics_api` 这个新维度上(否则 agent 侧不知道该改哪一项)。 +#[test] +fn vulkan_rules_point_at_the_graphics_api_setting() { + let loader = RuleLoader::embedded(); + for id in [ + "graphics.vulkan_loader_missing", + "graphics.vulkan_device_version_too_low", + "graphics.vulkan_missing_extension", + "graphics.vulkan_missing_feature", + "graphics.no_graphics_backend", + ] { + let compiled = loader + .rule_set() + .get(id) + .unwrap_or_else(|| panic!("规则 {id} 不存在")); + let rule = compiled.rule(); + let settings = rule + .fix_action + .iter() + .map(|action| action.materialize(&BTreeMap::new())) + .filter(|action| { + action + .args + .get("setting") + .is_some_and(|setting| setting == "graphics_api") + }) + .count(); + assert!( + settings > 0, + "规则 {id} 没有 change_graphics/graphics_api 动作" + ); + assert!( + rule.fix_action + .iter() + .any(|action| action.kind().as_str() == "change_graphics"), + "规则 {id} 的修复动作应属于 change_graphics 维度" + ); + } +} + +/// 后端全挂(两个都失败)应当压过单条 vulkan_* 原因,避免同一个故障报五遍。 +#[test] +fn no_graphics_backend_suppresses_single_reason_rules() { + let text = read_sample("samples/graphics/no_graphics_backend.log"); + let report = analyze_text(SignalOrigin::Stdout, &text); + assert_eq!( + report.primary().expect("命中").rule_id, + "graphics.no_graphics_backend" + ); +} + +/// SDL 变体必须保持 macOS 特异性:`cocoa` 是 SDL 在 macOS 的视频驱动名,两者同现才算 +/// 本规则命中。否则 Windows/Linux 的 SDL_Init 失败会被误归因成「macOS 显示器问题」。 +#[test] +fn sdl_display_failure_is_attributed_to_macos_only_with_cocoa() { + let hit = |text: &str| { + let report = analyze_text(SignalOrigin::Stdout, text); + report + .findings + .iter() + .chain(report.secondary.iter()) + .any(|finding| finding.rule_id == "graphics.macos_display_port") + }; + + let macos = "\ +[12:00:21] [Render thread/ERROR]: java.lang.IllegalStateException: SDL_Init(SDL_INIT_VIDEO) failed: cocoa: Failed to create window +"; + assert!(hit(macos), "macOS(cocoa)的 SDL 初始化失败应命中原规则"); + + for other in [ + // Windows:SDL 的 windows 驱动,文本里没有 cocoa。 + "[12:00:21] [Render thread/ERROR]: SDL_Init(SDL_INIT_VIDEO) failed: No available video device\n", + // Linux:x11/wayland 驱动。 + "[12:00:21] [Render thread/ERROR]: SDL_Init(SDL_INIT_VIDEO) failed: x11: Could not connect to display\n", + ] { + assert!( + !hit(other), + "非 macOS 的 SDL 初始化失败不该归因到 macOS 显示器规则:{other}" + ); + } +} diff --git a/crates/core/tests/download/backoff_test.rs b/crates/core/tests/download/backoff_test.rs new file mode 100644 index 0000000..fb4b5b0 --- /dev/null +++ b/crates/core/tests/download/backoff_test.rs @@ -0,0 +1,68 @@ +//! 指数退避与抖动的单测(纯函数,无网络)。 + +use std::time::Duration; + +use unlucky_core::download::backoff::{BackoffPolicy, Jitter}; + +#[test] +fn exponential_growth_doubles_and_caps() { + let policy = BackoffPolicy::default(); + assert_eq!(policy.delay(0), Duration::from_millis(1_000)); + assert_eq!(policy.delay(1), Duration::from_millis(2_000)); + assert_eq!(policy.delay(2), Duration::from_millis(4_000)); + assert_eq!(policy.delay(3), Duration::from_millis(8_000)); + // 上限封顶,不会涨到分钟级。 + assert_eq!(policy.delay(10), Duration::from_millis(30_000)); + assert_eq!(policy.delay(u32::MAX), Duration::from_millis(30_000)); +} + +#[test] +fn no_retry_policy_waits_nothing() { + let policy = BackoffPolicy::none(); + assert_eq!(policy.delay(0), Duration::ZERO); + assert_eq!(policy.max_attempts, 1); + assert_eq!(policy.delay_jittered(0, 0.5), Duration::ZERO); +} + +#[test] +fn jitter_stays_within_ratio_bounds() { + let policy = BackoffPolicy::default(); + // base 1000ms ± 30% → [700, 1300] + let low = policy.delay_jittered(0, 0.0); + let high = policy.delay_jittered(0, 1.0); + assert_eq!(low, Duration::from_millis(700)); + assert_eq!(high, Duration::from_millis(1_300)); + // 抖动永不超过 max_ms。 + for rng in [0.0, 0.25, 0.5, 0.75, 1.0] { + assert!(policy.delay_jittered(9, rng) <= Duration::from_millis(30_000)); + } +} + +#[test] +fn jitter_without_ratio_is_deterministic() { + let policy = BackoffPolicy { + jitter_ratio: 0.0, + ..BackoffPolicy::default() + }; + assert_eq!( + policy.delay_jittered(2, 0.0), + policy.delay_jittered(2, 0.99) + ); +} + +#[test] +fn jitter_source_is_reproducible_and_in_range() { + let mut first = Jitter::new(42); + let mut second = Jitter::new(42); + for _ in 0..32 { + let a = first.next_fraction(); + let b = second.next_fraction(); + assert_eq!(a, b, "同种子的抖动序列必须一致"); + assert!((0.0..1.0).contains(&a)); + } + + let mut other = Jitter::new(7); + assert_ne!(Jitter::new(1).next_u64(), other.next_u64()); + // 种子 0 会被替换成固定非零种子,但仍然可用。 + assert!((0.0..1.0).contains(&Jitter::new(0).next_fraction())); +} diff --git a/crates/core/tests/download/cache_test.rs b/crates/core/tests/download/cache_test.rs new file mode 100644 index 0000000..5420372 --- /dev/null +++ b/crates/core/tests/download/cache_test.rs @@ -0,0 +1,149 @@ +//! 内容寻址缓存的单测:两级分片、记录信任(命中不重算)、损坏检测、索引持久化。 + +mod support; + +use std::time::Duration; + +use unlucky_core::download::cache::ContentCache; +use unlucky_core::download::types::{HashAlgo, Integrity}; + +const NOW: u64 = 5_000_000; + +fn cache_dir(tag: &str) -> std::path::PathBuf { + support::temp_dir(tag) +} + +#[test] +fn sharded_path_follows_layout() { + let dir = cache_dir("cache-path"); + let cache = ContentCache::open(&dir); + let integrity = Integrity::sha1("abcdef0123456789abcdef0123456789abcdef01"); + let path = cache.path_for(integrity.algo, &integrity.hex); + assert!(path.ends_with("sha1/ab/abcdef0123456789abcdef0123456789abcdef01")); + assert!(path.starts_with(cache.root())); + // SHA-256 单独一棵子树。 + let sha256_path = cache.path_for(HashAlgo::Sha256, "00112233445566778899aabbccddeeff"); + assert!(sha256_path.ends_with("sha256/00/00112233445566778899aabbccddeeff")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn insert_then_lookup_hits_without_rehashing() { + let dir = cache_dir("cache-hit"); + let cache = ContentCache::open(&dir); + + let payload = b"hello unlucky launcher".to_vec(); + let integrity = Integrity::sha1(support::sha1_hex(&payload)); + let source = dir.join("download.bin"); + std::fs::write(&source, &payload).unwrap(); + + let cached = cache + .insert(&source, &integrity, NOW) + .expect("写入缓存失败"); + assert!(cached.is_file()); + assert_eq!(cache.len(), 1); + + // 第二次命中:size/mtime 未变 → 记录信任,直接返回(不重算摘要)。 + let hit = cache.lookup(&integrity, NOW + 10).expect("应命中缓存"); + assert_eq!(hit, cached); + + // 文件被删 → 记录作废。 + std::fs::remove_file(&cached).unwrap(); + assert!(cache.lookup(&integrity, NOW + 20).is_none()); + assert_eq!(cache.len(), 0); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn truncated_cache_file_is_a_miss() { + let dir = cache_dir("cache-truncate"); + let cache = ContentCache::open(&dir); + + let payload = vec![7_u8; 256]; + let integrity = Integrity::sha1(support::sha1_hex(&payload)); + let source = dir.join("payload.bin"); + std::fs::write(&source, &payload).unwrap(); + let cached = cache.insert(&source, &integrity, NOW).unwrap(); + + // 半损坏(长度变了)必须立刻视为未命中。 + std::fs::write(&cached, &payload[..128]).unwrap(); + assert!(cache.lookup(&integrity, NOW + 1).is_none()); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn metadata_change_forces_a_rehash() { + let dir = cache_dir("cache-rehash"); + let cache = ContentCache::open(&dir); + + let payload = vec![3_u8; 128]; + let integrity = Integrity::sha1(support::sha1_hex(&payload)); + let source = dir.join("data.bin"); + std::fs::write(&source, &payload).unwrap(); + let cached = cache.insert(&source, &integrity, NOW).unwrap(); + // 硬链接/复制都可能让源文件与缓存同 inode,先断开再改缓存内容。 + let _ = std::fs::remove_file(&source); + + // 等 mtime 变化(部分文件系统时间戳精度 10ms)。 + std::thread::sleep(Duration::from_millis(30)); + let mut tampered = payload.clone(); + tampered[0] = 9; + std::fs::write(&cached, &tampered).unwrap(); + + // 内容变了 → 摘要不符 → 记录作废。 + assert!(cache.lookup(&integrity, NOW + 100).is_none()); + assert_eq!(cache.len(), 0); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn lookup_verified_rejects_wrong_content_with_same_size() { + let dir = cache_dir("cache-verified"); + let cache = ContentCache::open(&dir); + + let payload = vec![1_u8; 64]; + let integrity = Integrity::sha1(support::sha1_hex(&payload)); + // 直接放到缓存位但内容是错的(模拟外部篡改 / 镜像写坏)。 + let path = cache.path_for(integrity.algo, &integrity.hex); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, vec![2_u8; 64]).unwrap(); + cache.register(&path, &integrity, NOW).unwrap(); + + assert!( + cache.lookup_verified(&integrity, NOW + 1).is_none(), + "强制校验必须发现内容不符" + ); + + // 内容对了就能通过。 + std::fs::write(&path, &payload).unwrap(); + cache.register(&path, &integrity, NOW).unwrap(); + assert!(cache.lookup_verified(&integrity, NOW + 2).is_some()); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn index_persists_across_reopen() { + let dir = cache_dir("cache-persist"); + let payload = vec![5_u8; 32]; + let integrity = Integrity::sha1(support::sha1_hex(&payload)); + + { + let cache = ContentCache::open(&dir); + let source = dir.join("src.bin"); + std::fs::write(&source, &payload).unwrap(); + cache.insert(&source, &integrity, NOW).unwrap(); + cache.persist().expect("持久化索引失败"); + assert_eq!(cache.stats().entries, 1); + assert_eq!(cache.stats().total_bytes, 32); + } + + let reopened = ContentCache::open(&dir); + assert_eq!(reopened.len(), 1); + assert!(reopened.lookup(&integrity, NOW + 1).is_some()); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/core/tests/download/chunked_test.rs b/crates/core/tests/download/chunked_test.rs new file mode 100644 index 0000000..906baf3 --- /dev/null +++ b/crates/core/tests/download/chunked_test.rs @@ -0,0 +1,375 @@ +//! 分块规划与 Range 语义的单测(纯函数)+ 真跑 4 连接的端到端用例(mock 源)。 + +mod support; + +use std::sync::Arc; +use std::time::Duration; + +use unlucky_core::download::backoff::BackoffPolicy; +use unlucky_core::download::cache::ContentCache; +use unlucky_core::download::chunked::{ + content_range_matches, parse_content_range, plan, range_support, ByteRange, ChunkedConfig, + ContentRange, RangeSupport, MAX_CONNECTIONS, +}; +use unlucky_core::download::config::EngineConfig; +use unlucky_core::download::engine::DownloadEngine; +use unlucky_core::download::health::HealthRegistry; +use unlucky_core::download::mirrors::MirrorTable; +use unlucky_core::download::region::RegionProfile; +use unlucky_core::download::types::{DownloadRequest, ResourceKind}; + +use support::{sha1_hex, temp_dir, MockResponse, MockServer}; + +#[test] +fn plan_partitions_whole_file_without_gaps() { + let cases = [ + (1_u64, 4_usize), + (3, 4), + (100, 4), + (1000, 7), + (123_456_789, 4), + ]; + for (total, connections) in cases { + let ranges = plan(total, connections); + assert!(!ranges.is_empty(), "total={total} 不能切出空集"); + assert_eq!(ranges[0].start, 0, "必须从 0 开始"); + assert_eq!(ranges.last().unwrap().end, total - 1, "必须覆盖到末尾"); + let mut next = 0_u64; + for range in &ranges { + assert_eq!(range.start, next, "段之间不能有空洞或重叠"); + assert!(!range.is_empty()); + next = range.end + 1; + } + assert_eq!(next, total); + assert_eq!(ranges.iter().map(ByteRange::len).sum::(), total); + } +} + +#[test] +fn plan_caps_connections_and_handles_edge_cases() { + assert!(plan(0, 4).is_empty()); + // 文件比连接数还小:每段 1 字节。 + let tiny = plan(3, 4); + assert_eq!(tiny.len(), 3); + assert!(tiny.iter().all(|range| range.len() == 1)); + // 连接数被夹到上限。 + let many = plan(1_000_000, 100); + assert_eq!(many.len(), MAX_CONNECTIONS); + // 单连接。 + assert_eq!(plan(120, 1), vec![ByteRange::new(0, 119)]); +} + +#[test] +fn byte_range_headers_are_closed_intervals() { + let range = ByteRange::new(100, 199); + assert_eq!(range.len(), 100); + assert_eq!(range.header_value(), "bytes=100-199"); + assert!(ByteRange::new(10, 9).is_empty()); + assert_eq!(ByteRange::new(10, 9).len(), 0); +} + +#[test] +fn parse_content_range_accepts_servers_forms() { + assert_eq!( + parse_content_range("bytes 0-99/1000"), + Some(ContentRange { + start: 0, + end: 99, + total: Some(1000) + }) + ); + assert_eq!( + parse_content_range(" bytes 100-199/* "), + Some(ContentRange { + start: 100, + end: 199, + total: None + }) + ); + // 畸形一律 None:调用方据此退化。 + assert_eq!(parse_content_range("bytes 100-99/1000"), None); + assert_eq!(parse_content_range("items 0-9/10"), None); + assert_eq!(parse_content_range("bytes 0-9"), None); + assert_eq!(parse_content_range(""), None); +} + +#[test] +fn range_support_matrix() { + assert_eq!(range_support(Some("bytes"), 200), RangeSupport::Supported); + assert_eq!(range_support(None, 206), RangeSupport::Supported); + assert_eq!(range_support(Some("none"), 200), RangeSupport::Unsupported); + assert_eq!( + range_support(Some("crumbs"), 200), + RangeSupport::Unsupported + ); + assert_eq!(range_support(None, 200), RangeSupport::Unknown); +} + +#[test] +fn chunked_config_gate() { + let config = ChunkedConfig::default(); + assert!(config.enabled); + assert_eq!(config.connections, 4); + // 只有大文件 + 大资源类型才分块。 + assert!(config.applies_to(ResourceKind::JavaRuntime, Some(200 * 1024 * 1024))); + assert!(!config.applies_to(ResourceKind::JavaRuntime, Some(1024))); + assert!(!config.applies_to(ResourceKind::ClientJar, Some(200 * 1024 * 1024))); + assert!(!config.applies_to(ResourceKind::JavaRuntime, None)); + // 设置项关掉后一律不分块。 + assert!(!ChunkedConfig::disabled().applies_to(ResourceKind::JavaRuntime, Some(u64::MAX))); + assert_eq!( + ChunkedConfig { + connections: 999, + ..ChunkedConfig::default() + } + .effective_connections(), + MAX_CONNECTIONS + ); +} + +/// 端到端:`min_size` 调到 1KiB 后,20KiB 的 JDK 类资源真的被切成 4 段并行取。 +/// +/// 断言三件事:①mock 源收到 4 次带 `Range` 的请求(不是 1 次)②拼装后的文件逐字节正确 +/// ③流式 sha1 校验通过(证明分块乱序写没有错位)。 +#[tokio::test] +async fn downloads_large_file_over_four_parallel_ranges() { + let payload: Vec = (0..20 * 1024).map(|index| (index % 251) as u8).collect(); + let server = MockServer::start(vec![ + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + ]) + .await; + let url = server.url("/jdk.zip"); + let dir = temp_dir("chunked-e2e"); + let engine = DownloadEngine::new( + EngineConfig { + max_attempts_per_source: 1, + backoff: BackoffPolicy { + base_ms: 1, + max_ms: 2, + jitter_ratio: 0.0, + max_attempts: 1, + }, + read_timeout: Duration::from_secs(5), + connect_timeout: Duration::from_secs(5), + // 真开分块:4 条连接,1KiB 以上就切。 + chunked: ChunkedConfig { + enabled: true, + connections: 4, + min_size: 1024, + }, + ..EngineConfig::default() + }, + Arc::new(MirrorTable::builtin()), + Arc::new(RegionProfile::direct(0)), + Arc::new(HealthRegistry::in_memory()), + Arc::new(ContentCache::open(dir.join("cache"))), + ); + + let dest = dir.join("jdk.zip"); + let outcome = engine + .download( + DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64), + ) + .await + .expect("分块下载失败"); + + assert_eq!(outcome.bytes, payload.len() as u64); + assert_eq!(outcome.source, "official"); + assert_eq!( + server.hits(), + 4, + "4 条连接应各发一次带 Range 的请求(退化单连接会只有 1 次)" + ); + assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload); + assert!( + support::part_files(&dir).is_empty(), + "中间文件必须在提交后消失:{:?}", + support::part_files(&dir) + ); + server.stop(); +} + +/// 端到端:目标目录**还不存在**时分块下载也要能落盘。 +/// +/// 回归用例。分块路径先 `OpenOptions` 预分配整文件(不走 `PartFile::open_append` +/// 那层建目录),所以「下到新建目录」曾让所有候选源瞬间全部失败 +/// (现场:`data/java-managed/.cache/` 未创建时装 JDK 六个源全挂)。 +#[tokio::test] +async fn chunked_download_creates_the_destination_directory() { + let payload: Vec = (0..8 * 1024).map(|index| (index % 31) as u8).collect(); + let responses = vec![ + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + ]; + let server = MockServer::start(responses).await; + let url = server.url("/jdk.zip"); + let dir = temp_dir("chunked-mkdir"); + let engine = DownloadEngine::new( + EngineConfig { + max_attempts_per_source: 1, + backoff: BackoffPolicy { + base_ms: 1, + max_ms: 2, + jitter_ratio: 0.0, + max_attempts: 1, + }, + read_timeout: Duration::from_secs(5), + connect_timeout: Duration::from_secs(5), + chunked: ChunkedConfig { + enabled: true, + connections: 4, + min_size: 1024, + }, + ..EngineConfig::default() + }, + Arc::new(MirrorTable::builtin()), + Arc::new(RegionProfile::direct(0)), + Arc::new(HealthRegistry::in_memory()), + Arc::new(ContentCache::open(dir.join("cache"))), + ); + + let dest = dir.join("java-managed/.cache/jdk.zip"); + assert!( + !dest.parent().expect("父目录").exists(), + "前置条件:目录不存在" + ); + let outcome = engine + .download( + DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64), + ) + .await + .expect("分块下载应成功"); + assert_eq!(outcome.bytes, payload.len() as u64); + assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload); + server.stop(); +} + +/// 端到端:服务器声明支持 Range 但实际无视(返回 200)→ 自动退化单连接,结果仍然正确。 +#[tokio::test] +async fn falls_back_to_single_connection_when_server_ignores_range() { + let payload: Vec = (0..8 * 1024).map(|index| (index % 97) as u8).collect(); + // 每个请求都会拿到 200 + 整份内容(range_support = false)。 + // 响应条数给足:4 个分块请求可能都发出去才被 abort,退化后的整份请求是第 5 条。 + let no_range = || MockResponse::ok(payload.clone()).without_range(); + let server = MockServer::start(vec![ + no_range(), + no_range(), + no_range(), + no_range(), + no_range(), + no_range(), + ]) + .await; + let url = server.url("/client.jar"); + let dir = temp_dir("chunked-fallback"); + let engine = DownloadEngine::new( + EngineConfig { + max_attempts_per_source: 1, + backoff: BackoffPolicy::none(), + chunked: ChunkedConfig { + enabled: true, + connections: 4, + min_size: 1024, + }, + ..EngineConfig::default() + }, + Arc::new(MirrorTable::builtin()), + Arc::new(RegionProfile::direct(0)), + Arc::new(HealthRegistry::in_memory()), + Arc::new(ContentCache::open(dir.join("cache"))), + ); + + let dest = dir.join("client.jar"); + let outcome = engine + .download( + DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64), + ) + .await + .expect("退化后应仍能下载成功"); + + assert_eq!(outcome.bytes, payload.len() as u64); + assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload); + server.stop(); +} + +#[test] +fn content_range_must_match_requested_segment() { + let requested = ByteRange::new(5120, 10239); + // 严格一致才放行。 + assert!(content_range_matches( + requested, + Some("bytes 5120-10239/20480") + )); + // 起止任一不符 → 判失败(否则「长度恰好但位置错」的响应会拼出坏文件)。 + assert!(!content_range_matches( + requested, + Some("bytes 0-5119/20480") + )); + assert!(!content_range_matches( + requested, + Some("bytes 5120-20479/20480") + )); + // 畸形头判失败。 + assert!(!content_range_matches(requested, Some("garbage"))); + // 缺头从宽(长度校验仍兜底)。 + assert!(content_range_matches(requested, None)); +} + +/// 端到端:服务器回 206 但 `Content-Range` 谎报段位置 → 分块路径判不符、丢弃中间文件、 +/// 退化单连接重下,最终文件仍逐字节正确(不会交付拼错的文件)。 +#[tokio::test] +async fn rejects_lying_content_range_and_falls_back() { + let payload: Vec = (0..8 * 1024).map(|index| (index % 89) as u8).collect(); + let lying = || MockResponse::ok(payload.clone()).with_wrong_content_range(); + let server = MockServer::start(vec![ + lying(), + lying(), + lying(), + lying(), + MockResponse::ok(payload.clone()), + ]) + .await; + let url = server.url("/jdk.zip"); + let dir = temp_dir("chunked-lying-range"); + let engine = DownloadEngine::new( + EngineConfig { + max_attempts_per_source: 1, + backoff: BackoffPolicy::none(), + chunked: ChunkedConfig { + enabled: true, + connections: 4, + min_size: 1024, + }, + ..EngineConfig::default() + }, + Arc::new(MirrorTable::builtin()), + Arc::new(RegionProfile::direct(0)), + Arc::new(HealthRegistry::in_memory()), + Arc::new(ContentCache::open(dir.join("cache"))), + ); + + let dest = dir.join("jdk.zip"); + let outcome = engine + .download( + DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64), + ) + .await + .expect("谎报 Content-Range 后应退化单连接成功"); + + assert_eq!(outcome.bytes, payload.len() as u64); + assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload); + server.stop(); +} diff --git a/crates/core/tests/download/engine_mock_test.rs b/crates/core/tests/download/engine_mock_test.rs new file mode 100644 index 0000000..528e664 --- /dev/null +++ b/crates/core/tests/download/engine_mock_test.rs @@ -0,0 +1,350 @@ +//! 引擎端到端(mock 源):成功 / 退避重试 / 换源 / 校验失败 / 缓存命中 / 断点续传。 +//! +//! 用 `tests/download/support` 里的进程内 HTTP 服务器,不发真实网络请求。 + +mod support; + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use unlucky_core::download::backoff::BackoffPolicy; +use unlucky_core::download::cache::ContentCache; +use unlucky_core::download::chunked::ChunkedConfig; +use unlucky_core::download::config::EngineConfig; +use unlucky_core::download::engine::DownloadEngine; +use unlucky_core::download::error::DownloadErrorKind; +use unlucky_core::download::health::HealthRegistry; +use unlucky_core::download::mirrors::{MirrorTable, PrefixRule}; +use unlucky_core::download::progress::{ProgressEmitter, ProgressEvent}; +use unlucky_core::download::region::RegionProfile; +use unlucky_core::download::resume::ResumeMeta; +use unlucky_core::download::types::{DownloadRequest, HashAlgo, ResourceKind}; + +use support::{sha1_hex, temp_dir, MockResponse, MockServer}; + +fn fast_config(attempts: u32) -> EngineConfig { + EngineConfig { + max_attempts_per_source: attempts, + backoff: BackoffPolicy { + base_ms: 1, + max_ms: 2, + jitter_ratio: 0.0, + max_attempts: attempts, + }, + connect_timeout: Duration::from_secs(5), + read_timeout: Duration::from_secs(5), + chunked: ChunkedConfig::disabled(), + ..EngineConfig::default() + } +} + +struct Harness { + engine: DownloadEngine, + dir: PathBuf, +} + +fn harness(cfg: EngineConfig, table: MirrorTable, region: RegionProfile, tag: &str) -> Harness { + let dir = temp_dir(tag); + let cache = Arc::new(ContentCache::open(dir.join("cache"))); + let health = Arc::new(HealthRegistry::in_memory()); + let engine = DownloadEngine::new(cfg, Arc::new(table), Arc::new(region), health, cache); + Harness { engine, dir } +} + +fn direct_region() -> RegionProfile { + RegionProfile::direct(0) +} + +#[tokio::test] +async fn downloads_verifies_and_leaves_no_part_file() { + let payload = b"unlucky launcher payload".to_vec(); + let server = MockServer::start(vec![MockResponse::ok(payload.clone())]).await; + let url = server.url("/client.jar"); + let harness = harness( + fast_config(2), + MirrorTable::builtin(), + direct_region(), + "engine-ok", + ); + let dest = harness.dir.join("versions/1.20.1/client.jar"); + + let request = DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64); + let outcome = harness.engine.download(request).await.expect("下载应成功"); + + assert_eq!(outcome.bytes, payload.len() as u64); + assert!(!outcome.from_cache); + assert_eq!(outcome.resumed_from, 0); + assert_eq!(std::fs::read(&dest).unwrap(), payload); + let version_dir = harness.dir.join("versions/1.20.1"); + assert!( + support::part_files(&version_dir).is_empty(), + "中间文件必须被提交掉:{:?}", + support::part_files(&version_dir) + ); + server.stop(); +} + +#[tokio::test] +async fn retries_same_source_after_server_error() { + let payload = b"retry me".to_vec(); + let server = MockServer::start(vec![ + MockResponse::status(503), + MockResponse::ok(payload.clone()), + ]) + .await; + let url = server.url("/lib.jar"); + let harness = harness( + fast_config(3), + MirrorTable::builtin(), + direct_region(), + "engine-retry", + ); + let dest = harness.dir.join("lib.jar"); + + let outcome = harness + .engine + .download( + DownloadRequest::new(ResourceKind::Library, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)), + ) + .await + .expect("退避后应成功"); + assert_eq!(outcome.bytes, payload.len() as u64); + assert_eq!(server.hits(), 2, "第一次 503 之后应重试一次"); + server.stop(); +} + +#[tokio::test] +async fn switches_to_official_when_mirror_keeps_failing() { + let payload = b"official wins".to_vec(); + let official = MockServer::start(vec![MockResponse::ok(payload.clone())]).await; + let mirror = MockServer::start(vec![ + MockResponse::status(500), + MockResponse::status(500), + MockResponse::status(500), + ]) + .await; + + let mut table = MirrorTable::builtin(); + let official_prefix = format!("http://127.0.0.1:{}", official.port()); + let mirror_prefix = format!("http://localhost:{}", mirror.port()); + table.add_custom_rule(PrefixRule::new( + "mock-mirror", + official_prefix, + mirror_prefix, + )); + + let harness = harness( + fast_config(2), + table, + RegionProfile::mirror_first(0), + "engine-switch", + ); + let dest = harness.dir.join("client.jar"); + let url = official.url("/client.jar"); + + let outcome = harness + .engine + .download( + DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)), + ) + .await + .expect("镜像全挂后应切到官方"); + assert_eq!(outcome.source, "official"); + assert_eq!(std::fs::read(&dest).unwrap(), payload); + assert_eq!(mirror.hits(), 2, "每个源最多尝试 max_attempts 次"); + official.stop(); + mirror.stop(); +} + +#[tokio::test] +async fn rejects_wrong_checksum_and_falls_back() { + let payload = b"the real jar".to_vec(); + let official = MockServer::start(vec![MockResponse::ok(payload.clone())]).await; + let mirror = MockServer::start(vec![MockResponse::ok(b"tampered by mirror".to_vec())]).await; + + let mut table = MirrorTable::builtin(); + table.add_custom_rule(PrefixRule::new( + "mock-mirror", + format!("http://127.0.0.1:{}", official.port()), + format!("http://localhost:{}", mirror.port()), + )); + + let harness = harness( + fast_config(1), + table, + RegionProfile::mirror_first(0), + "engine-checksum", + ); + let dest = harness.dir.join("client.jar"); + let url = official.url("/client.jar"); + + let outcome = harness + .engine + .download( + DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64), + ) + .await + .expect("校验失败应换源而不是交付坏文件"); + assert_eq!(outcome.source, "official"); + assert_eq!(std::fs::read(&dest).unwrap(), payload); + assert!(mirror.hits() >= 1); + official.stop(); + mirror.stop(); +} + +#[tokio::test] +async fn second_download_hits_content_cache_without_network() { + let payload = b"cached payload".to_vec(); + let server = MockServer::start(vec![ + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + ]) + .await; + let url = server.url("/lib.jar"); + let harness = harness( + fast_config(2), + MirrorTable::builtin(), + direct_region(), + "engine-cache", + ); + let integrity = sha1_hex(&payload); + + let first = harness + .engine + .download( + DownloadRequest::new( + ResourceKind::Library, + url.as_str(), + harness.dir.join("a/lib.jar"), + ) + .with_sha1(integrity.as_str()), + ) + .await + .expect("首次下载失败"); + assert!(!first.from_cache); + + let hits_after_first = server.hits(); + let second = harness + .engine + .download( + DownloadRequest::new( + ResourceKind::Library, + url.as_str(), + harness.dir.join("b/lib.jar"), + ) + .with_sha1(integrity.as_str()), + ) + .await + .expect("缓存命中失败"); + assert!(second.from_cache); + assert_eq!(second.bytes, payload.len() as u64); + assert_eq!(server.hits(), hits_after_first, "缓存命中不能再发请求"); + assert_eq!( + std::fs::read(harness.dir.join("b/lib.jar")).unwrap(), + payload + ); + server.stop(); +} + +#[tokio::test] +async fn resumes_from_existing_part_file() { + let payload = b"0123456789abcdefghij".to_vec(); + let split = 8_usize; + let server = MockServer::start(vec![MockResponse::ok(payload.clone())]).await; + let url = server.url("/client.jar"); + let harness = harness( + fast_config(2), + MirrorTable::builtin(), + direct_region(), + "engine-resume", + ); + let dest = harness.dir.join("client.jar"); + let part = harness.dir.join("client.jar.ullpart"); + let meta_path = harness.dir.join("client.jar.ullpart.ullmeta.json"); + + std::fs::write(&part, &payload[..split]).unwrap(); + ResumeMeta { + url: url.clone(), + etag: Some("\"v1\"".to_string()), + last_modified: None, + total: Some(payload.len() as u64), + algo: HashAlgo::Sha1, + hash: sha1_hex(&payload), + saved_at_ms: 1, + } + .save(&meta_path) + .expect("写续传元数据失败"); + + let outcome = harness + .engine + .download( + DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64), + ) + .await + .expect("续传失败"); + + assert_eq!(outcome.resumed_from, split as u64, "应从已有偏移续传"); + assert_eq!(std::fs::read(&dest).unwrap(), payload); + assert_eq!(server.hits(), 1); + server.stop(); +} + +#[tokio::test] +async fn reports_progress_events_and_errors_when_all_sources_fail() { + let server = MockServer::start(vec![ + MockResponse::status(500), + MockResponse::status(500), + MockResponse::status(500), + MockResponse::status(500), + ]) + .await; + let url = server.url("/dead.jar"); + let dir = temp_dir("engine-fail"); + let cache = Arc::new(ContentCache::open(dir.join("cache"))); + let (emitter, mut events) = ProgressEmitter::channel(); + let engine = DownloadEngine::new( + fast_config(1), + Arc::new(MirrorTable::builtin()), + Arc::new(direct_region()), + Arc::new(HealthRegistry::in_memory()), + cache, + ) + .with_progress(emitter); + + let error = engine + .download(DownloadRequest::new( + ResourceKind::Library, + url.as_str(), + dir.join("dead.jar"), + )) + .await + .expect_err("所有源都 500 时必须失败"); + assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed); + + let mut started = false; + let mut failed_source = false; + while let Ok(event) = events.try_recv() { + match event { + ProgressEvent::Started { candidates, .. } => { + started = true; + assert!(candidates >= 1); + } + ProgressEvent::SourceFailed { will_retry, .. } => { + failed_source = true; + assert!(!will_retry, "单次尝试用尽后不应再声明重试"); + } + _ => {} + } + } + assert!(started && failed_source, "进度事件缺失"); + server.stop(); +} diff --git a/crates/core/tests/download/health_test.rs b/crates/core/tests/download/health_test.rs new file mode 100644 index 0000000..3899cb9 --- /dev/null +++ b/crates/core/tests/download/health_test.rs @@ -0,0 +1,268 @@ +//! 健康度记忆的单测:冷却、恢复探测、排序、持久化 + 引擎驱动的落盘接线。 + +mod support; + +use std::sync::Arc; +use std::time::Duration; + +use unlucky_core::download::backoff::BackoffPolicy; +use unlucky_core::download::cache::{now_ms, ContentCache}; +use unlucky_core::download::config::EngineConfig; +use unlucky_core::download::engine::DownloadEngine; +use unlucky_core::download::error::DownloadErrorKind; +use unlucky_core::download::health::{ + HealthRegistry, SourceHealth, COOLDOWN_MS, FAILURE_THRESHOLD, +}; +use unlucky_core::download::mirrors::MirrorTable; +use unlucky_core::download::region::RegionProfile; +use unlucky_core::download::types::{Candidate, DownloadRequest, ResourceKind, SOURCE_OFFICIAL}; + +use support::{MockResponse, MockServer}; + +const NOW: u64 = 1_000_000; + +fn registry() -> HealthRegistry { + HealthRegistry::in_memory() +} + +#[test] +fn fresh_source_is_neutral_not_penalized() { + let health = SourceHealth::default(); + assert!(!health.is_cooling_down(NOW)); + assert_eq!(health.failure_rate(), 0.0); + // 未知源必须是中间分,不能比刚成功的源更优先。 + let healthy = SourceHealth { + samples: 3, + successes: 3, + ema_rtt_ms: 120.0, + ..SourceHealth::default() + }; + assert!(healthy.score(NOW) < health.score(NOW)); +} + +#[test] +fn three_consecutive_failures_trigger_cooldown() { + let mut health = SourceHealth::default(); + for _ in 0..FAILURE_THRESHOLD { + health.record_failure("HTTP 502", NOW); + } + assert!(health.is_cooling_down(NOW)); + assert!(health.is_cooling_down(NOW + COOLDOWN_MS - 1)); + // 冷却到期后不再是冷却态,而是「可以探测」。 + assert!(!health.is_cooling_down(NOW + COOLDOWN_MS)); + assert!(health.is_probe(NOW + COOLDOWN_MS)); + // 冷却中的源排序惩罚要明显高于健康源。 + assert!(health.score(NOW) >= 1_000.0); +} + +#[test] +fn missing_artifact_is_recorded_without_cooling_down() { + // 院校镜像只留最新补丁:请求旧补丁必然 404,不能把源判成「坏」。 + let mut health = SourceHealth::default(); + for _ in 0..(FAILURE_THRESHOLD * 2) { + health.record_missing("HTTP 404", NOW); + } + assert!(!health.is_cooling_down(NOW), "4xx 不进冷却"); + assert!(!health.is_probe(NOW), "4xx 不该产生恢复探测状态"); + assert_eq!(health.failures, 0, "4xx 不计失败次数"); + assert_eq!(health.not_found, u64::from(FAILURE_THRESHOLD * 2)); + assert_eq!(health.failure_rate(), 0.0); + assert!(health.last_error.is_some(), "诊断仍要留痕"); + // 打分不应被 404 拉高(仍是未知源的中位分)。 + assert_eq!(health.score(NOW), SourceHealth::default().score(NOW)); + assert_eq!( + health.score(NOW), + unlucky_core::download::health::SCORE_UNKNOWN + ); +} + +#[test] +fn not_found_ageing_does_not_persist_a_cooldown() { + let registry = HealthRegistry::in_memory(); + for _ in 0..10 { + registry.record_missing("tuna", ResourceKind::JavaRuntime, "HTTP 404", NOW); + } + let health = registry.health_of("tuna", ResourceKind::JavaRuntime); + assert!(!health.is_cooling_down(NOW)); + assert_eq!(health.not_found, 10); + assert_eq!(health.consecutive_failures, 0); +} + +#[test] +fn probe_failure_re_arms_cooldown_and_success_clears_it() { + let mut health = SourceHealth::default(); + for _ in 0..FAILURE_THRESHOLD { + health.record_failure("timeout", NOW); + } + let after_cooldown = NOW + COOLDOWN_MS + 1; + assert!(!health.is_cooling_down(after_cooldown)); + // 探测再一次失败 → 立即重新冷却。 + health.record_failure("timeout again", after_cooldown); + assert!(health.is_cooling_down(after_cooldown)); + + // 成功则全部清零。 + health.record_success(90, after_cooldown + 1); + assert!(!health.is_cooling_down(after_cooldown + 1)); + assert!(!health.is_probe(after_cooldown + 1)); + assert_eq!(health.consecutive_failures, 0); + assert_eq!(health.cooldown_until_ms, 0); + assert_eq!(health.last_error, None); +} + +#[test] +fn ema_smooths_latency_and_counts_samples() { + let mut health = SourceHealth::default(); + health.record_success(100, NOW); + assert_eq!(health.ema_rtt_ms, 100.0); + health.record_success(200, NOW); + // 0.3 * 200 + 0.7 * 100 = 130 + assert!((health.ema_rtt_ms - 130.0).abs() < 1e-9); + assert_eq!(health.samples, 2); + assert_eq!(health.successes, 2); +} + +#[test] +fn failure_rate_counts_both_sides() { + let mut health = SourceHealth::default(); + health.record_success(50, NOW); + health.record_failure("boom", NOW); + assert!((health.failure_rate() - 0.5).abs() < 1e-9); +} + +#[test] +fn registry_ranks_healthy_first_and_cooling_last() { + let registry = registry(); + for _ in 0..FAILURE_THRESHOLD { + registry.record_failure("mirror-b", ResourceKind::ClientJar, "HTTP 500", NOW); + } + registry.record_success( + "mirror-a", + ResourceKind::ClientJar, + Duration::from_millis(80), + NOW, + ); + + let candidates = vec![ + Candidate::new("mirror-b", "https://b.example/file"), + Candidate::new("unknown", "https://u.example/file"), + Candidate::new("mirror-a", "https://a.example/file"), + ]; + let ranked = registry.rank(&candidates, ResourceKind::ClientJar, NOW); + assert_eq!(ranked[0].source, "mirror-a"); + assert_eq!(ranked[1].source, "unknown"); + assert_eq!( + ranked[2].source, "mirror-b", + "冷却中的源排最后但仍保留为兜底" + ); +} + +#[test] +fn health_is_tracked_per_resource_kind() { + let registry = registry(); + for _ in 0..FAILURE_THRESHOLD { + registry.record_failure("bmclapi", ResourceKind::Asset, "404", NOW); + } + assert!(registry.is_cooling_down("bmclapi", ResourceKind::Asset, NOW)); + // 另一种资源类型不受影响(资产挂了不代表 JDK 也挂)。 + assert!(!registry.is_cooling_down("bmclapi", ResourceKind::JavaRuntime, NOW)); +} + +#[test] +fn persistence_roundtrip_and_age_pruning() { + let dir = std::env::temp_dir().join(format!("ull-health-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("health.json"); + + let registry = HealthRegistry::open(&path, 7 * 24 * 3600 * 1000, NOW); + registry.record_failure("bmclapi", ResourceKind::Library, "HTTP 503", NOW); + registry.record_success( + "official", + ResourceKind::Library, + Duration::from_millis(55), + NOW, + ); + registry.save().expect("持久化失败"); + + let reloaded = HealthRegistry::open(&path, 7 * 24 * 3600 * 1000, NOW + 1_000); + assert_eq!(reloaded.snapshot().len(), 2); + assert_eq!( + reloaded + .health_of("bmclapi", ResourceKind::Library) + .failures, + 1 + ); + assert!( + reloaded + .health_of("official", ResourceKind::Library) + .ema_rtt_ms + > 0.0 + ); + + // 超出最大保留期 → 丢弃旧记录(缓存目录被长时间放着的情况)。 + let pruned = HealthRegistry::open(&path, 10, NOW + 60_000); + assert!(pruned.snapshot().is_empty()); + + let _ = std::fs::remove_dir_all(&dir); +} + +/// P1 回归:健康度必须由**引擎的生产路径**落盘——下载→(模拟进程退出)→重开 registry, +/// 冷却状态与失败计数都要还在,否则「连续 3 败冷却 10 分钟」每轮启动从零学起。 +#[tokio::test] +async fn engine_persists_health_so_a_restart_keeps_the_cooldown() { + let failed = || MockResponse::status(500); + let server = MockServer::start(vec![failed(), failed(), failed(), failed()]).await; + let url = server.url("/lib.jar"); + let dir = support::temp_dir("health-engine-persist"); + let path = dir.join("cache/health.json"); + let now = now_ms(); + let max_age_ms = 7 * 24 * 3600 * 1000; + + let registry = Arc::new(HealthRegistry::open(&path, max_age_ms, now)); + let engine = DownloadEngine::new( + EngineConfig { + max_attempts_per_source: FAILURE_THRESHOLD, + backoff: BackoffPolicy { + base_ms: 1, + max_ms: 2, + jitter_ratio: 0.0, + max_attempts: FAILURE_THRESHOLD, + }, + ..EngineConfig::default() + }, + Arc::new(MirrorTable::builtin()), + Arc::new(RegionProfile::direct(0)), + Arc::clone(®istry), + Arc::new(ContentCache::open(dir.join("cache"))), + ); + + let error = engine + .download(DownloadRequest::new( + ResourceKind::Library, + url.as_str(), + dir.join("lib.jar"), + )) + .await + .expect_err("全 500 必须失败"); + assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed); + server.stop(); + + // 第 3 次连续失败进入冷却时就该落盘(不等收尾 flush)。 + assert!(path.is_file(), "健康度必须已经落盘到 cache/health.json"); + let before = registry.health_of(SOURCE_OFFICIAL, ResourceKind::Library); + assert!(before.consecutive_failures >= FAILURE_THRESHOLD); + assert!(before.is_cooling_down(now_ms())); + + // 「进程退出后重开」:从同一个文件恢复,冷却状态与失败计数都还在。 + let reopened = HealthRegistry::open(&path, max_age_ms, now_ms()); + let restored = reopened.health_of(SOURCE_OFFICIAL, ResourceKind::Library); + assert_eq!(restored.consecutive_failures, before.consecutive_failures); + assert_eq!(restored.failures, before.failures); + assert!( + restored.is_cooling_down(now_ms()), + "重开后仍应处于冷却:{restored:?}" + ); + assert!(restored.last_error.is_some(), "失败原因也要持久化"); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/core/tests/download/mirrors_test.rs b/crates/core/tests/download/mirrors_test.rs new file mode 100644 index 0000000..61c0696 --- /dev/null +++ b/crates/core/tests/download/mirrors_test.rs @@ -0,0 +1,281 @@ +//! 镜像清单与前缀重写的单测(纯字符串,无网络)。 + +use unlucky_core::download::adoptium::{parse_adoptium_artifact, JdkFlavor}; +use unlucky_core::download::mirrors::{host_of, source_id_for, MirrorTable, PrefixRule}; +use unlucky_core::download::region::RegionProfile; +use unlucky_core::download::types::ResourceKind; + +const LIBRARIES_URL: &str = + "https://libraries.minecraft.net/com/google/guava/guava/32.0.0/guava-32.0.0.jar"; +const ASSET_URL: &str = "https://resources.download.minecraft.net/ab/abcdef0123456789"; +const GITHUB_URL: &str = + "https://github.com/AskaEth/Unlucky-Launcher/releases/download/v1.0.0/ull-windows-x64.exe"; +const ADOPTIUM_URL: &str = "https://github.com/adoptium/temurin17-binaries/releases/download/JDK17.0.13%2B11/OpenJDK17U-jdk_x64_windows_hotspot_17.0.13_11.zip"; + +#[test] +fn builtin_table_is_embedded_and_complete() { + let table = MirrorTable::builtin(); + assert!(table.version >= 1); + assert!(table.rules.len() >= 10, "内置规则太少"); + assert!(table.adoptium.len() >= 3, "院校镜像至少要 TUNA/USTC/SJTU"); + assert!(!table.gh_proxies.is_empty()); + assert!(table.gh_proxies[0].prefix.starts_with("https://gh-proxy")); +} + +#[test] +fn prefix_rule_preserves_sub_path() { + let table = MirrorTable::builtin(); + let rule = table + .prefix_rule_for(LIBRARIES_URL, ResourceKind::Library) + .expect("libraries 规则缺失"); + let candidate = rule.apply(LIBRARIES_URL).expect("重写失败"); + assert_eq!(candidate.source, "bmclapi"); + assert_eq!( + candidate.url, + LIBRARIES_URL.replace( + "https://libraries.minecraft.net", + "https://bmclapi2.bangbang93.com" + ) + ); +} + +#[test] +fn asset_rule_injects_path_prefix() { + let table = MirrorTable::builtin(); + let candidate = table + .prefix_rule_for(ASSET_URL, ResourceKind::Asset) + .and_then(|rule| rule.apply(ASSET_URL)) + .expect("资源对象规则缺失"); + assert_eq!( + candidate.url, + "https://bmclapi2.bangbang93.com/assets/ab/abcdef0123456789" + ); +} + +#[test] +fn direct_mode_puts_official_first() { + let table = MirrorTable::builtin(); + let region = RegionProfile::direct(0); + let candidates = table.expand(LIBRARIES_URL, ResourceKind::Library, ®ion); + assert_eq!(candidates[0].source, "official"); + assert_eq!(candidates[0].url, LIBRARIES_URL); + // 镜像仍然保留为兜底(官方失败时能救回来)。 + assert!(candidates + .iter() + .any(|candidate| candidate.source == "bmclapi")); +} + +#[test] +fn mirror_first_mode_puts_mirror_first() { + let table = MirrorTable::builtin(); + let region = RegionProfile::mirror_first(0); + let candidates = table.expand(LIBRARIES_URL, ResourceKind::Library, ®ion); + assert_eq!(candidates[0].source, "bmclapi"); + assert_eq!( + candidates.last().map(|c| c.source.as_str()), + Some("official") + ); +} + +#[test] +fn gh_proxy_only_for_github_hosts_in_domestic_mode() { + let table = MirrorTable::builtin(); + let domestic = RegionProfile::mirror_first(0); + let direct = RegionProfile::direct(0); + + let github = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &domestic); + assert!( + github.iter().any(|candidate| candidate.source == "gh_proxy" + && candidate.url.starts_with("https://gh-proxy.com/")), + "国内模式下 GitHub 资源必须套 gh-proxy:{github:?}" + ); + + let non_github = table.expand(LIBRARIES_URL, ResourceKind::Library, &domestic); + assert!( + non_github + .iter() + .all(|candidate| candidate.source != "gh_proxy"), + "非 GitHub 资源不受 gh-proxy 影响" + ); + + let github_direct = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &direct); + assert!(github_direct + .iter() + .all(|candidate| candidate.source != "gh_proxy")); +} + +#[test] +fn pinned_or_disabled_gh_proxy_is_respected() { + let table = MirrorTable::builtin(); + + // 用户关掉:地区档案显式不带前缀 → 一条 gh_proxy 候选都不出。 + let off = RegionProfile::mirror_first(0).with_gh_proxy(None); + let candidates = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &off); + assert!(candidates.iter().all(|c| c.source != "gh_proxy")); + + // 自定义前缀(设置项写进地区档案)。 + let custom = RegionProfile::mirror_first(0).with_gh_proxy(Some("https://proxy.example/")); + let candidates = table.expand(GITHUB_URL, ResourceKind::GitHubRelease, &custom); + let proxy = candidates + .iter() + .find(|candidate| candidate.source == "gh_proxy") + .expect("自定义前缀未生效"); + assert_eq!(proxy.url, format!("https://proxy.example/{GITHUB_URL}")); + + // 清单给设置做默认值。 + assert_eq!(table.default_gh_proxy(), Some("https://gh-proxy.com/")); +} + +#[test] +fn adoptium_artifact_parsing_and_mirror_ordering() { + let parsed = parse_adoptium_artifact(ADOPTIUM_URL).expect("解析 Temurin 产物名失败"); + assert_eq!(parsed.major, "17"); + assert_eq!(parsed.flavor, JdkFlavor::Jdk); + assert_eq!(parsed.arch, "x64"); + assert_eq!(parsed.os, "windows"); + assert!(parsed.filename.starts_with("OpenJDK17U-jdk_x64_windows")); + + let table = MirrorTable::builtin(); + let region = RegionProfile::mirror_first(0); + let candidates = table.expand(ADOPTIUM_URL, ResourceKind::JavaRuntime, ®ion); + let first = &candidates[0]; + assert_eq!(first.source, "tuna"); + assert!( + first + .url + .starts_with("https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jdk/x64/windows/"), + "院校镜像路径不符合模板:{}", + first.url + ); + // 官方仍在候选里(全部镜像不可达时的兜底)。 + assert!(candidates + .iter() + .any(|candidate| candidate.source == "official")); +} + +/// JRE 产物必须落到镜像的 `/jre/` 子树(否则 4 个镜像全 404,加速白费)。 +#[test] +fn adoptium_jre_uses_jre_path_segment() { + let jre_url = ADOPTIUM_URL.replace("OpenJDK17U-jdk_", "OpenJDK17U-jre_"); + let parsed = parse_adoptium_artifact(&jre_url).expect("解析 JRE 产物名失败"); + assert_eq!(parsed.flavor, JdkFlavor::Jre); + assert_eq!( + parsed.mirror_url("https://mirrors.tuna.tsinghua.edu.cn/Adoptium"), + format!( + "https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jre/x64/windows/{}", + parsed.filename + ) + ); + + let table = MirrorTable::builtin(); + let candidates = table.expand( + &jre_url, + ResourceKind::JavaRuntime, + &RegionProfile::mirror_first(0), + ); + let first = &candidates[0]; + assert_eq!(first.source, "tuna"); + assert!( + first.url.contains("/17/jre/x64/windows/"), + "JRE 必须走 /jre/ 子树:{}", + first.url + ); + + // `-jre-17…` 这种兜底形态同样识别。 + let fallback = "https://github.com/adoptium/temurin21-binaries/releases/download/jre-21.0.5%2B11/OpenJDK21U-jre_x64_linux_hotspot_21.0.5_11.tar.gz"; + assert_eq!( + parse_adoptium_artifact(fallback).map(|artifact| artifact.flavor), + Some(JdkFlavor::Jre) + ); +} + +#[test] +fn custom_rules_take_precedence() { + let mut table = MirrorTable::builtin(); + table.add_custom_rule(PrefixRule::new( + "my-mirror", + "https://libraries.minecraft.net", + "https://mirror.example/libraries", + )); + let candidate = table + .prefix_rule_for(LIBRARIES_URL, ResourceKind::Library) + .and_then(|rule| rule.apply(LIBRARIES_URL)) + .expect("自定义规则未生效"); + assert_eq!(candidate.source, "mirror.example"); + assert_eq!( + candidate.url, + "https://mirror.example/libraries/com/google/guava/guava/32.0.0/guava-32.0.0.jar" + ); +} + +#[test] +fn fallback_rule_keeps_official_first_even_in_domestic_mode() { + let mut table = MirrorTable { + version: 1, + notes: Vec::new(), + rules: vec![PrefixRule::new( + "modrinth-accelerator", + "https://api.modrinth.com", + "https://cdn.example", + ) + .fallback()], + // v3 新增的三张 JDK 源表在这张夹具里必须为空(否则会混进别的候选)。 + adoptium: Vec::new(), + oracle_openjdk: Vec::new(), + microsoft_openjdk: None, + zulu: None, + gh_proxies: Vec::new(), + }; + let region = RegionProfile::mirror_first(0); + let candidates = table.expand( + "https://api.modrinth.com/v2/project/sodium", + ResourceKind::ModFile, + ®ion, + ); + assert_eq!( + candidates[0].source, "official", + "「仅加速」规则不能顶掉官方" + ); + assert_eq!(candidates[1].source, "cdn.example"); + + // 关掉规则后只剩官方一条。 + table.rules.clear(); + let candidates = table.expand( + "https://api.modrinth.com/v2/project/sodium", + ResourceKind::ModFile, + ®ion, + ); + assert_eq!(candidates.len(), 1); +} + +#[test] +fn host_and_source_id_helpers() { + assert_eq!( + host_of("https://libraries.minecraft.net/a/b"), + Some("libraries.minecraft.net") + ); + assert_eq!( + host_of("https://user:pass@mirror.example:8443/x/y"), + Some("mirror.example") + ); + assert_eq!(host_of("http://127.0.0.1:8080/file"), Some("127.0.0.1")); + assert_eq!(host_of("not-a-url"), Some("not-a-url")); + + assert_eq!( + source_id_for("https://bmclapi2.bangbang93.com/assets/ab/cd"), + "bmclapi" + ); + assert_eq!( + source_id_for("https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/x.zip"), + "tuna" + ); + assert_eq!( + source_id_for("https://mirrors.ustc.edu.cn/adoptium/17/x.zip"), + "ustc" + ); + assert_eq!( + source_id_for("https://gh-proxy.com/https://github.com/a/b"), + "gh_proxy" + ); + assert_eq!(source_id_for("https://example.org/file"), "example.org"); +} diff --git a/crates/core/tests/download/partfile_test.rs b/crates/core/tests/download/partfile_test.rs new file mode 100644 index 0000000..f267d79 --- /dev/null +++ b/crates/core/tests/download/partfile_test.rs @@ -0,0 +1,233 @@ +//! `download::atomic::PartFile`:中间文件的进程/实例隔离、续传收养与孤儿清理。 +//! +//! 背景(M35 真机 finding):中间文件路径原先只由目标路径决定,两个启动器进程装同一 +//! 数据根时共用一个 `.ullpart`,先提交的一方 `rename` 走,另一方 `os error 2` +//! (实测两次 `ull install` 各失败 819 / 1116 个资源)。这里守住三条:名字带会话标识、 +//! 旧材料能收养、孤儿清理不许误删有人在用的中间文件。 + +mod support; + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use unlucky_core::download::backoff::BackoffPolicy; +use unlucky_core::download::cache::ContentCache; +use unlucky_core::download::chunked::ChunkedConfig; +use unlucky_core::download::config::EngineConfig; +use unlucky_core::download::engine::DownloadEngine; +use unlucky_core::download::health::HealthRegistry; +use unlucky_core::download::mirrors::MirrorTable; +use unlucky_core::download::region::RegionProfile; +use unlucky_core::download::types::{DownloadRequest, ResourceKind}; +use unlucky_core::download::{PartFile, ADOPT_MIN_AGE, META_SUFFIX, ORPHAN_MAX_AGE, PART_SUFFIX}; + +use support::{age_file, part_files, sha1_hex, temp_dir, MockResponse, MockServer}; + +/// 另一个会话号(保证与本进程不同)。 +fn foreign_session() -> u32 { + std::process::id() ^ 1 +} + +fn part_name(name: &str, session: u32, seq: u64) -> String { + format!("{name}{PART_SUFFIX}-{session}-{seq}") +} + +fn write_part(dir: &Path, name: &str) -> PathBuf { + let path = dir.join(name); + fs::write(&path, b"0123456789").expect("写中间文件失败"); + path +} + +#[test] +fn part_file_names_carry_session_and_sequence() { + let dir = temp_dir("partfile-identity"); + let dest = dir.join("client.jar"); + + let first = PartFile::for_dest(&dest); + let second = PartFile::for_dest(&dest); + + assert_ne!( + first.path(), + second.path(), + "同一 dest 的两个守卫不能共用文件" + ); + assert_eq!(first.path().parent(), Some(dir.as_path())); + let name = first.path().file_name().unwrap().to_string_lossy(); + assert!( + name.starts_with(&format!("client.jar{PART_SUFFIX}-")), + "中间文件名要带进程标识:{name}" + ); + assert!( + name.contains(&std::process::id().to_string()), + "进程号要在名字里(跨进程隔离的依据):{name}" + ); +} + +#[test] +fn two_threads_writing_the_same_dest_commit_independently() { + let dir = temp_dir("partfile-threads"); + let dest = dir.join("client.jar"); + + let handles: Vec<_> = (0..2_u8) + .map(|index| { + let dest = dest.clone(); + std::thread::spawn(move || { + let payload = vec![b'a' + index; 4096]; + let part = PartFile::for_dest(&dest); + part.open_truncate() + .expect("打开中间文件失败") + .write_all(&payload) + .expect("写中间文件失败"); + part.commit(&dest).expect("原子落盘失败"); + payload + }) + }) + .collect(); + + let payloads: Vec> = handles + .into_iter() + .map(|handle| handle.join().expect("下载线程 panic")) + .collect(); + let written = fs::read(&dest).expect("读落盘文件失败"); + assert!( + payloads.contains(&written), + "落盘内容必须是某个线程的完整产物" + ); + assert!( + part_files(&dir).is_empty(), + "两线程各自的中间文件都要被提交掉:{:?}", + part_files(&dir) + ); +} + +#[test] +fn legacy_part_file_is_adopted_for_resume() { + let dir = temp_dir("partfile-legacy"); + let dest = dir.join("client.jar"); + let legacy = write_part(&dir, &format!("client.jar{PART_SUFFIX}")); + let legacy_meta = dir.join(format!("client.jar{PART_SUFFIX}{META_SUFFIX}")); + fs::write(&legacy_meta, b"{}").expect("写元数据失败"); + + let part = PartFile::for_dest(&dest); + + assert_ne!(part.path(), legacy.as_path(), "新守卫要换自己的名字"); + assert_eq!(part.len(), 10, "旧版中间文件必须被收养(续传语义不变)"); + assert!(!legacy.exists(), "收养后不许留下旧名字"); + assert!(!legacy_meta.exists(), "元数据要跟着中间文件一起搬"); + assert!(part.meta_path().is_file(), "收养后的元数据路径要就位"); +} + +#[test] +fn foreign_part_file_is_adopted_only_after_it_cools_down() { + let dir = temp_dir("partfile-foreign"); + let dest = dir.join("client.jar"); + let foreign = write_part(&dir, &part_name("client.jar", foreign_session(), 1)); + + let fresh = PartFile::for_dest(&dest); + assert!(!fresh.exists(), "外进程刚写过的中间文件不能抢"); + assert!(foreign.is_file(), "更不能删(可能正被那个进程写着)"); + + age_file(&foreign, ADOPT_MIN_AGE + Duration::from_secs(60)); + let adopted = PartFile::for_dest(&dest); + assert_eq!(adopted.len(), 10, "静置够久的外来中间文件应被收养续传"); + assert!(!foreign.exists(), "收养后旧名字应消失"); +} + +#[test] +fn sweeps_ownerless_orphans_but_keeps_fresh_and_live_files() { + let dir = temp_dir("partfile-sweep"); + let stale = write_part(&dir, &format!("other.jar{PART_SUFFIX}")); + let stale_meta = dir.join(format!("other.jar{PART_SUFFIX}{META_SUFFIX}")); + fs::write(&stale_meta, b"{}").expect("写元数据失败"); + age_file(&stale, ORPHAN_MAX_AGE + Duration::from_secs(60)); + let fresh = write_part(&dir, &format!("fresh.jar{PART_SUFFIX}")); + + let _guard = PartFile::for_dest(&dir.join("client.jar")); + + assert!(!stale.exists(), "无主且超龄的中间文件应被清理"); + assert!(!stale_meta.exists(), "它的元数据也要一起清"); + assert_eq!( + fs::read(&fresh).unwrap().len(), + 10, + "还新鲜的无主文件不能删" + ); +} + +#[test] +fn keeps_another_live_guard_of_the_same_dest() { + let dir = temp_dir("partfile-live"); + let dest = dir.join("client.jar"); + let running = PartFile::for_dest(&dest); + running + .open_truncate() + .expect("打开中间文件失败") + .write_all(&[b'x'; 1024]) + .expect("写中间文件失败"); + + let other = PartFile::for_dest(&dest); + + assert!( + running.path().is_file(), + "有人在用的中间文件不能被清理或收养" + ); + assert_eq!(running.len(), 1024); + assert_ne!(other.path(), running.path()); +} + +fn fast_engine(cache: &Path) -> DownloadEngine { + DownloadEngine::new( + EngineConfig { + max_attempts_per_source: 1, + backoff: BackoffPolicy { + base_ms: 1, + max_ms: 2, + jitter_ratio: 0.0, + max_attempts: 1, + }, + read_timeout: Duration::from_secs(5), + connect_timeout: Duration::from_secs(5), + chunked: ChunkedConfig::disabled(), + ..EngineConfig::default() + }, + Arc::new(MirrorTable::builtin()), + Arc::new(RegionProfile::direct(0)), + Arc::new(HealthRegistry::in_memory()), + Arc::new(ContentCache::open(cache)), + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn two_engines_downloading_the_same_dest_do_not_share_parts() { + let payload = b"same destination payload".to_vec(); + let server = MockServer::start(vec![ + MockResponse::ok(payload.clone()), + MockResponse::ok(payload.clone()), + ]) + .await; + let url = server.url("/client.jar"); + let dir = temp_dir("partfile-engine"); + let dest = dir.join("client.jar"); + // 两份独立的缓存目录:否则后到的那次会直接吃内容缓存,绕过中间文件路径。 + let left = fast_engine(&dir.join("cache-a")); + let right = fast_engine(&dir.join("cache-b")); + let request = || { + DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest) + .with_sha1(sha1_hex(&payload)) + .with_size(payload.len() as u64) + }; + + let (first, second) = tokio::join!(left.download(request()), right.download(request())); + + first.expect("并行下载 A 失败"); + second.expect("并行下载 B 失败"); + assert_eq!(fs::read(&dest).expect("读落盘文件失败"), payload); + assert!( + part_files(&dir).is_empty(), + "并发下载不能留下中间文件:{:?}", + part_files(&dir) + ); + server.stop(); +} diff --git a/crates/core/tests/download/region_test.rs b/crates/core/tests/download/region_test.rs new file mode 100644 index 0000000..9f84b7d --- /dev/null +++ b/crates/core/tests/download/region_test.rs @@ -0,0 +1,121 @@ +//! 地区判定的单测:只测纯判定与缓存,不真发探测请求。 + +use unlucky_core::download::region::{ + decide_from, load_cached, store_cached, RegionMode, RegionProfile, RegionTrigger, Signals, + DECISION_TTL_MS, DEFAULT_GH_PROXY, +}; + +fn signals(timezone: Option<&str>, language: Option<&str>, google: Option) -> Signals { + Signals { + timezone: timezone.map(str::to_string), + languages: language.map(str::to_string).into_iter().collect(), + google_reachable: google, + baidu_reachable: None, + } +} + +#[test] +fn timezone_signal_detects_greater_china() { + assert!(signals(Some("Asia/Shanghai"), None, None).is_greater_china()); + assert!(signals(Some("Asia/Taipei"), None, None).is_greater_china()); + // 大小写/空白容错。 + assert!(signals(Some(" asia/hong_kong "), None, None).is_greater_china()); + assert!(!signals(Some("Europe/Berlin"), None, None).is_greater_china()); +} + +#[test] +fn language_signal_handles_posix_and_bcp47() { + assert!(signals(None, Some("zh_CN.UTF-8"), None).is_greater_china()); + assert!(signals(None, Some("zh-Hant-TW"), None).is_greater_china()); + assert!(!signals(None, Some("en_US.UTF-8"), None).is_greater_china()); + // 时区不命中、语言命中也算。 + assert!(signals(Some("UTC"), Some("zh_TW"), None).is_greater_china()); +} + +#[test] +fn domestic_without_google_goes_mirror_first() { + let profile = decide_from(&signals(Some("Asia/Shanghai"), None, Some(false)), 1); + assert_eq!(profile.mode, RegionMode::MirrorFirst); + assert!(profile.greater_china); + assert_eq!(profile.trigger, RegionTrigger::Auto); + assert_eq!(profile.gh_proxy.as_deref(), Some(DEFAULT_GH_PROXY)); + assert!(profile.gh_proxy_enabled()); +} + +#[test] +fn domestic_with_google_reachable_stays_direct() { + // 已有全局代理:尊重用户代理,不再插镜像。 + let profile = decide_from(&signals(Some("Asia/Shanghai"), None, Some(true)), 1); + assert_eq!(profile.mode, RegionMode::Direct); + assert!(profile.gh_proxy.is_none()); + assert!(!profile.gh_proxy_enabled()); +} + +#[test] +fn non_greater_china_is_always_direct() { + for google in [Some(true), Some(false), None] { + let profile = decide_from(&signals(Some("America/New_York"), Some("en-US"), google), 1); + assert_eq!(profile.mode, RegionMode::Direct, "google={google:?}"); + assert!(!profile.greater_china); + } +} + +#[test] +fn probes_disabled_defaults_to_mirror_first_for_chinese_env() { + let profile = decide_from(&signals(None, Some("zh_CN.UTF-8"), None), 1); + assert_eq!(profile.mode, RegionMode::MirrorFirst); + assert!(!profile.probes_enabled); +} + +#[test] +fn pinned_mode_never_goes_stale() { + let pinned = RegionProfile::pinned(RegionMode::Direct, 10); + assert_eq!(pinned.trigger, RegionTrigger::UserPinned); + assert!(!pinned.is_stale(10 + DECISION_TTL_MS * 10)); + + let auto = decide_from(&signals(Some("Asia/Shanghai"), None, Some(false)), 10); + assert!(!auto.is_stale(10 + 1_000)); + assert!(auto.is_stale(10 + DECISION_TTL_MS + 1)); +} + +#[test] +fn gh_proxy_helpers() { + let mut profile = RegionProfile::mirror_first(0); + assert!(profile.gh_proxy_enabled()); + profile = profile.with_gh_proxy(Some(" ")); + assert!(!profile.gh_proxy_enabled(), "空白前缀视为不启用"); + profile = profile.with_gh_proxy(Some("https://proxy.example/")); + assert!(profile.gh_proxy_enabled()); + + // 直连模式下即便配了前缀也不生效(gh-proxy 只在国内模式用)。 + let direct = RegionProfile::direct(0).with_gh_proxy(Some("https://proxy.example/")); + assert!(!direct.gh_proxy_enabled()); + assert_eq!(direct.label(), "直连"); + assert_eq!(RegionProfile::mirror_first(0).label(), "镜像优先"); +} + +#[test] +fn cache_roundtrip_and_ttl_expiry() { + let dir = std::env::temp_dir().join(format!("ull-region-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("region.json"); + + let profile = decide_from(&signals(Some("Asia/Shanghai"), None, Some(false)), 1_000); + store_cached(&path, &profile).expect("写缓存失败"); + + let cached = load_cached(&path, 1_000 + 60_000).expect("应有缓存"); + assert_eq!(cached, profile); + + // 过期 → 视为没有缓存。 + assert!(load_cached(&path, 1_000 + DECISION_TTL_MS + 1).is_none()); + + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn missing_cache_file_is_not_an_error() { + let path = std::env::temp_dir().join("ull-region-does-not-exist.json"); + let _ = std::fs::remove_file(&path); + assert!(load_cached(&path, 0).is_none()); +} diff --git a/crates/core/tests/download/resume_test.rs b/crates/core/tests/download/resume_test.rs new file mode 100644 index 0000000..5c966fd --- /dev/null +++ b/crates/core/tests/download/resume_test.rs @@ -0,0 +1,223 @@ +//! 断点续传保守判定的单测:矩阵覆盖 + 416 处理。 + +use unlucky_core::download::resume::{ + decide, on_range_unsatisfiable, RestartReason, ResumeDecision, ResumeMeta, Validator, +}; +use unlucky_core::download::types::{HashAlgo, ResumePolicy}; + +const URL: &str = "https://bmclapi2.bangbang93.com/versions/1.20.1/client.jar"; +const HASH: &str = "0123456789abcdef0123456789abcdef01234567"; + +fn meta(url: &str) -> ResumeMeta { + ResumeMeta { + url: url.to_string(), + etag: Some("\"abc\"".to_string()), + last_modified: None, + total: Some(1_000), + algo: HashAlgo::Sha1, + hash: HASH.to_string(), + saved_at_ms: 1, + } +} + +fn resume_ok(part_len: u64, meta: &ResumeMeta) -> ResumeDecision { + decide( + ResumePolicy::Conservative, + part_len, + Some(meta), + URL, + Some((HashAlgo::Sha1, HASH)), + Some(1_000), + ) +} + +#[test] +fn resumes_when_everything_matches() { + let decision = resume_ok(400, &meta(URL)); + assert_eq!( + decision, + ResumeDecision::Resume { + offset: 400, + validator: Validator { + etag: Some("\"abc\"".to_string()), + last_modified: None, + }, + } + ); +} + +#[test] +fn never_policy_and_missing_files_restart() { + let meta = meta(URL); + assert_eq!( + decide( + ResumePolicy::Never, + 400, + Some(&meta), + URL, + Some((HashAlgo::Sha1, HASH)), + Some(1_000) + ), + ResumeDecision::Restart { + reason: RestartReason::PolicyNever + } + ); + assert_eq!( + resume_ok(0, &meta), + ResumeDecision::Restart { + reason: RestartReason::EmptyPart + } + ); + // 没有元数据:无法证明中间文件是同一次下载的产物。 + assert_eq!( + decide( + ResumePolicy::Conservative, + 400, + None, + URL, + Some((HashAlgo::Sha1, HASH)), + Some(1_000) + ), + ResumeDecision::Restart { + reason: RestartReason::NoMeta + } + ); +} + +#[test] +fn changed_url_or_hash_restarts() { + assert_eq!( + decide( + ResumePolicy::Conservative, + 400, + Some(&meta("https://other.example/client.jar")), + URL, + Some((HashAlgo::Sha1, HASH)), + Some(1_000) + ), + ResumeDecision::Restart { + reason: RestartReason::UrlChanged + } + ); + assert_eq!( + decide( + ResumePolicy::Conservative, + 400, + Some(&meta(URL)), + URL, + Some((HashAlgo::Sha1, "ffffffffffffffffffffffffffffffffffffffff")), + Some(1_000) + ), + ResumeDecision::Restart { + reason: RestartReason::HashChanged + } + ); +} + +#[test] +fn unknown_total_or_oversize_restarts() { + let mut unknown = meta(URL); + unknown.total = None; + assert_eq!( + decide( + ResumePolicy::Conservative, + 400, + Some(&unknown), + URL, + None, + None + ), + ResumeDecision::Restart { + reason: RestartReason::TotalUnknown + } + ); + + assert_eq!( + resume_ok(2_000, &meta(URL)), + ResumeDecision::Restart { + reason: RestartReason::Oversize + } + ); +} + +#[test] +fn complete_part_skips_transfer() { + assert_eq!( + resume_ok(1_000, &meta(URL)), + ResumeDecision::Complete { size: 1_000 } + ); +} + +#[test] +fn missing_validator_restarts() { + let mut weak = meta(URL); + weak.etag = None; + weak.last_modified = None; + assert_eq!( + resume_ok(400, &weak), + ResumeDecision::Restart { + reason: RestartReason::NoValidator + } + ); + // Last-Modified 单独也能当校验器。 + weak.last_modified = Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()); + assert!(matches!( + resume_ok(400, &weak), + ResumeDecision::Resume { offset: 400, .. } + )); +} + +#[test] +fn expected_size_can_stand_in_for_missing_total() { + let mut unknown = meta(URL); + unknown.total = None; + let decision = decide( + ResumePolicy::Conservative, + 400, + Some(&unknown), + URL, + Some((HashAlgo::Sha1, HASH)), + Some(1_000), + ); + assert!(matches!( + decision, + ResumeDecision::Resume { offset: 400, .. } + )); +} + +#[test] +fn range_not_satisfiable_handling() { + // 已下满:直接进入校验,不要白删一个好文件。 + assert_eq!( + on_range_unsatisfiable(1_000, Some(1_000)), + ResumeDecision::Complete { size: 1_000 } + ); + // 没下满却 416:中间文件不可信,丢掉重来。 + let decision = on_range_unsatisfiable(400, Some(1_000)); + assert_eq!( + decision, + ResumeDecision::Restart { + reason: RestartReason::RangeUnsatisfiable + } + ); + assert!(decision.should_drop_part()); + // 不允许丢掉「已完成」的中间文件。 + assert!(!ResumeDecision::Complete { size: 1 }.should_drop_part()); +} + +#[test] +fn resume_meta_roundtrip_and_removal() { + let dir = std::env::temp_dir().join(format!("ull-resume-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("client.jar.ullpart.ullmeta.json"); + + let original = meta(URL); + original.save(&path).expect("写元数据失败"); + assert_eq!(ResumeMeta::load(&path), Some(original)); + + ResumeMeta::remove(&path); + assert!(ResumeMeta::load(&path).is_none()); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/core/tests/download/support/mod.rs b/crates/core/tests/download/support/mod.rs new file mode 100644 index 0000000..fb9330d --- /dev/null +++ b/crates/core/tests/download/support/mod.rs @@ -0,0 +1,278 @@ +//! 测试支撑:临时目录、摘要工具、进程内极简 HTTP 服务器(零额外依赖)。 +//! +//! 为什么自己写 mock server 而不用 wiremock:P0 阶段控制依赖面(Win7 Tier-3 构建 + +//! 单文件体积),一个 60 行的 `TcpListener` 足够表达「成功 / 500 / 校验不符 / 无视 Range」。 + +#![allow(dead_code)] + +use std::net::SocketAddr; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +/// 断言用的小工具:唯一临时目录(进程内计数 + 时间戳,避免并行测试互相踩)。 +pub fn temp_dir(tag: &str) -> std::path::PathBuf { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_nanos()); + let dir = std::env::temp_dir().join(format!("ull-download-test-{tag}-{stamp}-{unique}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("创建临时目录失败"); + dir +} + +/// 算一段字节的 SHA-1(测试断言用)。 +pub fn sha1_hex(bytes: &[u8]) -> String { + use sha1::Digest; + let digest = sha1::Sha1::digest(bytes); + let mut out = String::with_capacity(40); + for byte in digest { + out.push_str(&format!("{byte:02x}")); + } + out +} + +/// 目录里(不含子目录)所有中间文件家族的名字(`.ullpart` 与 `.ullmeta.json`)。 +pub fn part_files(dir: &std::path::Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut names: Vec = entries + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.contains(unlucky_core::download::PART_SUFFIX)) + .collect(); + names.sort(); + names +} + +/// 把文件的修改时间挪到「`age` 之前」(清理/收养的年龄判定用)。 +pub fn age_file(path: &std::path::Path, age: std::time::Duration) { + let times = std::fs::FileTimes::new().set_modified(std::time::SystemTime::now() - age); + std::fs::File::options() + .write(true) + .open(path) + .expect("打开待改 mtime 的文件失败") + .set_times(times) + .expect("改 mtime 失败"); +} + +/// mock 服务器给一次请求的应答。 +#[derive(Debug, Clone)] +pub struct MockResponse { + /// HTTP 状态码。 + pub status: u16, + /// 响应体。 + pub body: Vec, + /// 是否声明 `Accept-Ranges: bytes` 并在收到 `Range` 时真的切段。 + pub range_support: bool, + /// `ETag`(续传测试用)。 + pub etag: Option, + /// 返回 206 时故意把 `Content-Range` 写成从 0 开始(模拟段错位的坏服务器)。 + pub lie_content_range: bool, +} + +impl MockResponse { + /// 200 + 完整内容(支持 Range)。 + pub fn ok(body: impl Into>) -> Self { + Self { + status: 200, + body: body.into(), + range_support: true, + etag: Some("\"v1\"".to_string()), + lie_content_range: false, + } + } + + /// 固定状态码 + 空体。 + pub fn status(status: u16) -> Self { + Self { + status, + body: Vec::new(), + range_support: false, + etag: None, + lie_content_range: false, + } + } + + /// 声明不支持 Range(模拟部分镜像)。 + pub fn without_range(mut self) -> Self { + self.range_support = false; + self + } + + /// 206 但 `Content-Range` 谎报(模拟段错位的坏 CDN)。 + pub fn with_wrong_content_range(mut self) -> Self { + self.lie_content_range = true; + self + } +} + +/// 进程内极简 HTTP/1.1 服务器:按脚本顺序返回响应,脚本用尽后一律 500。 +pub struct MockServer { + addr: SocketAddr, + hits: Arc, + handle: tokio::task::JoinHandle<()>, +} + +impl MockServer { + /// 启动服务器(脚本是「每次请求一个响应」的队列)。 + pub async fn start(script: Vec) -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("绑定端口失败"); + let addr = listener.local_addr().expect("取本地地址失败"); + let hits = Arc::new(AtomicUsize::new(0)); + let script = Arc::new(std::sync::Mutex::new(script)); + let counter = Arc::clone(&hits); + + let handle = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let script = Arc::clone(&script); + let counter = Arc::clone(&counter); + tokio::spawn(async move { + let index = counter.fetch_add(1, Ordering::Relaxed); + let response = { + let script = script.lock().unwrap_or_else(|err| err.into_inner()); + script.get(index).cloned() + }; + let response = response.unwrap_or_else(|| MockResponse::status(500)); + let _ = serve(stream, response).await; + }); + } + }); + + Self { addr, hits, handle } + } + + /// 第 `index` 个请求该用的 URL(换成不同路径可模拟「不同源」)。 + pub fn url(&self, path: &str) -> String { + format!("http://{}{}", self.addr, path) + } + + /// 换 host 拼 URL(用 `localhost` vs `127.0.0.1` 造出两个不同源)。 + pub fn url_with(&self, host: &str, path: &str) -> String { + format!("http://{host}:{}{}", self.addr.port(), path) + } + + /// 端口号。 + pub fn port(&self) -> u16 { + self.addr.port() + } + + /// 已收到的请求数。 + pub fn hits(&self) -> usize { + self.hits.load(Ordering::Relaxed) + } + + /// 关停。 + pub fn stop(self) { + self.handle.abort(); + } +} + +async fn serve(mut stream: TcpStream, response: MockResponse) -> std::io::Result<()> { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + loop { + let read = stream.read(&mut chunk).await?; + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + if buffer.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let request = String::from_utf8_lossy(&buffer).to_ascii_lowercase(); + let range = request + .lines() + .find_map(|line| line.strip_prefix("range: bytes=")) + .map(str::trim) + .and_then(parse_range_header); + + let total = response.body.len() as u64; + let (status, headers, body) = match (range, response.range_support) { + (Some((start, requested_end)), true) if start < total => { + // 尊重请求里的结束偏移(分块下载必须逐段精确,否则组装后长度对不上)。 + let end = requested_end.unwrap_or(total - 1).min(total - 1); + let slice = response.body[start as usize..=end as usize].to_vec(); + // 谎报模式:body 仍是正确的那一段,但头部声称从 0 开始(长度恰好 → 只有 + // 严格比对 Content-Range 才抓得住)。 + let (claimed_start, claimed_end) = if response.lie_content_range { + (0, slice.len().saturating_sub(1) as u64) + } else { + (start, end) + }; + ( + 206, + vec![ + format!("Content-Range: bytes {claimed_start}-{claimed_end}/{total}"), + format!("Content-Length: {}", slice.len()), + "Accept-Ranges: bytes".to_string(), + ], + slice, + ) + } + (Some(_), true) => ( + 416, + vec![ + format!("Content-Range: bytes */{total}"), + "Content-Length: 0".to_string(), + ], + Vec::new(), + ), + _ => { + let mut headers = vec![format!("Content-Length: {total}")]; + if response.range_support { + headers.push("Accept-Ranges: bytes".to_string()); + } + (response.status, headers, response.body.clone()) + } + }; + let mut head = format!("HTTP/1.1 {status} {}\r\n", reason(status)); + if let Some(etag) = &response.etag { + head.push_str(&format!("ETag: {etag}\r\n")); + } + for header in headers { + head.push_str(&header); + head.push_str("\r\n"); + } + head.push_str("Connection: close\r\n\r\n"); + stream.write_all(head.as_bytes()).await?; + stream.write_all(&body).await?; + stream.flush().await?; + Ok(()) +} + +/// 解析 `Range: bytes=-`(`end` 可缺省 = 到文件末尾)。 +fn parse_range_header(value: &str) -> Option<(u64, Option)> { + let (start, end) = value.split_once('-')?; + let start = start.trim().parse::().ok()?; + let end = end.trim(); + let end = if end.is_empty() { + None + } else { + Some(end.parse::().ok()?) + }; + Some((start, end)) +} + +fn reason(status: u16) -> &'static str { + match status { + 200 => "OK", + 206 => "Partial Content", + 404 => "Not Found", + 416 => "Range Not Satisfiable", + 500 => "Internal Server Error", + 503 => "Service Unavailable", + _ => "Status", + } +} diff --git a/crates/core/tests/game/arguments_rules.rs b/crates/core/tests/game/arguments_rules.rs new file mode 100644 index 0000000..b7350af --- /dev/null +++ b/crates/core/tests/game/arguments_rules.rs @@ -0,0 +1,256 @@ +//! 规则求值、参数展开、natives 选择(launch 直接消费这些结果)。 + +use std::collections::BTreeMap; + +use unlucky_core::game::arguments::{ + matches_simple_pattern, rules_allow, Argument, ArgumentValue, ConditionalArgument, Environment, + OsRestriction, Rule, RuleAction, +}; +use unlucky_core::game::library::Library; + +use crate::helpers::parse; + +fn env(os: &str, arch: &str) -> Environment { + Environment { + os: os.to_owned(), + os_version: "10.0".to_owned(), + arch: arch.to_owned(), + features: BTreeMap::new(), + } +} + +fn allow_os(name: &str) -> Rule { + Rule { + action: RuleAction::Allow, + os: Some(OsRestriction { + name: Some(name.to_owned()), + ..OsRestriction::default() + }), + features: None, + } +} + +fn disallow_os(name: &str) -> Rule { + Rule { + action: RuleAction::Disallow, + ..allow_os(name) + } +} + +#[test] +fn empty_rules_always_allow() { + assert!(rules_allow(&[], &env("linux", "x86_64"))); +} + +#[test] +fn allow_rule_must_match_environment() { + let rules = [allow_os("windows")]; + assert!(rules_allow(&rules, &env("windows", "x86_64"))); + assert!(!rules_allow(&rules, &env("linux", "x86_64"))); +} + +#[test] +fn later_rules_override_earlier_ones() { + // 白名单语义:列表非空时从 DISALLOW 起步。 + let allow_after_disallow = [disallow_os("windows"), allow_os("windows")]; + assert!(rules_allow( + &allow_after_disallow, + &env("windows", "x86_64") + )); + + let disallow_after_allow = [allow_os("windows"), disallow_os("windows")]; + assert!(!rules_allow( + &disallow_after_allow, + &env("windows", "x86_64") + )); +} + +#[test] +fn feature_gate_blocks_argument() { + let mut features = BTreeMap::new(); + features.insert("has_custom_resolution".to_owned(), true); + let rule = Rule { + action: RuleAction::Allow, + os: None, + features: Some(features), + }; + + let enabled = env("windows", "x86_64").with_feature("has_custom_resolution", true); + let disabled = env("windows", "x86_64"); + + assert_eq!(rule.action_in(&enabled), Some(RuleAction::Allow)); + assert_eq!(rule.action_in(&disabled), None); +} + +#[test] +fn host_environment_is_mojang_shaped() { + let host = Environment::host(); + assert!(matches!(host.os.as_str(), "windows" | "osx" | "linux")); + assert!(!host.arch.is_empty()); + assert!(!host.feature("has_custom_resolution")); + // 空串是「没探」,真值来自 platform::os_info::current();只要探得到就不能再是空的。 + let probed = unlucky_core::platform::os_info::current(); + assert_eq!(host.os_version, probed.os_version()); + assert_eq!(host.os, probed.family.manifest_name()); + assert_eq!(host.arch, probed.arch); +} + +#[test] +fn environment_from_an_injected_os_snapshot_carries_os_version() { + use unlucky_core::platform::linux_caps::SessionKind; + use unlucky_core::platform::os_info::{support_level_for, OsFamily, OsInfo}; + use unlucky_core::platform::vk_probe::VkProbe; + + let fake = OsInfo { + family: OsFamily::Windows, + arch: "x86_64".to_string(), + major_minor: Some((10, 0)), + build: Some(19_045), + product: "Windows 10".to_string(), + version_string: "10.0.19045".to_string(), + session: SessionKind::Unknown, + gl: unlucky_core::platform::linux_caps::GlProbe::Unknown, + vk: VkProbe::Available, + kernel: None, + glibc: None, + support_level: support_level_for(OsFamily::Windows, Some((10, 0)), None, None), + }; + + let environment = Environment::from_os_info(&fake); + assert_eq!(environment.os, "windows"); + assert_eq!(environment.os_version, "10.0.19045"); + assert_eq!(environment.arch, "x86_64"); + assert!(environment.features.is_empty(), "注入快照不该带特性开关"); + + // 系统版本真的参与规则求值(这正是不再用空串的原因)。 + let rule = Rule { + os: Some(OsRestriction { + name: None, + version: Some("^10\\.".to_string()), + arch: None, + }), + ..Rule::default() + }; + assert_eq!(rule.action_in(&environment), Some(RuleAction::Allow)); + + let mac_rule = Rule { + os: Some(OsRestriction { + name: None, + version: Some("^11\\.".to_string()), + arch: None, + }), + ..Rule::default() + }; + assert_eq!(mac_rule.action_in(&environment), None); +} + +#[test] +fn os_version_patterns_match_conservatively() { + assert!(matches_simple_pattern("^10\\.", "10.0")); + assert!(!matches_simple_pattern("^10\\.", "11.0")); + assert!(matches_simple_pattern("x86_64", "x86_64")); + assert!(!matches_simple_pattern("x86_64", "x86")); + assert!(matches_simple_pattern("10\\.0$", "10.0")); + // 不认识的复杂正则放宽为匹配(宁可多带参数也不漏参数)。 + assert!(matches_simple_pattern("^(10|11)\\.", "11.0")); +} + +#[test] +fn expand_arguments_by_environment() { + let version = parse( + r#"{ + "id": "x", + "arguments": { + "game": [ + "--username", + { "rules": [{ "action": "allow", "features": { "is_demo_user": true } }], + "value": "--demo" }, + { "value": ["--pair", "1"] } + ], + "jvm": [ "-Dfoo=1" ] + } + }"#, + ); + let arguments = version.arguments.as_ref().expect("arguments 应解析"); + + let plain = arguments.expand(&env("windows", "x86_64")); + assert_eq!(plain.game, vec!["--username", "--pair", "1"]); + assert_eq!(plain.jvm, vec!["-Dfoo=1"]); + + let demo = env("windows", "x86_64").with_feature("is_demo_user", true); + assert!(arguments.expand(&demo).game.contains(&"--demo".to_owned())); +} + +#[test] +fn conditional_argument_expands_single_value() { + let argument = Argument::Conditional(ConditionalArgument { + rules: vec![allow_os("windows")], + value: ArgumentValue::One("--single".to_owned()), + }); + let mut out = Vec::new(); + argument.expand_into(&env("windows", "x86_64"), &mut out); + assert_eq!(out, vec!["--single"]); + + let mut out = Vec::new(); + argument.expand_into(&env("linux", "x86_64"), &mut out); + assert!(out.is_empty()); +} + +#[test] +fn native_classifier_expands_arch() { + let version = parse( + r#"{ "id": "x", + "libraries": [{ + "name": "org.lwjgl:lwjgl:3.3.2", + "natives": { "windows": "natives-windows-${arch}", "linux": "natives-linux" }, + "downloads": { + "artifact": { "path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2.jar" }, + "classifiers": { + "natives-windows-64": { "path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-64.jar" } + } + } + }] + }"#, + ); + let library = &version.libraries.as_ref().expect("libraries 应解析")[0]; + + assert_eq!( + library.native_classifier(&env("windows", "x86_64")), + Some("natives-windows-64".to_owned()) + ); + assert_eq!( + library.native_classifier(&env("windows", "x86")), + Some("natives-windows-".to_owned()) + ); + assert_eq!( + library.native_classifier(&env("linux", "x86_64")), + Some("natives-linux".to_owned()) + ); + assert_eq!(library.native_classifier(&env("osx", "arm64")), None); + + let classifier = "natives-windows-64"; + assert!(library.native_artifact(classifier).is_some()); + assert_eq!( + library + .native_relative_path(classifier) + .expect("路径应可推导"), + std::path::PathBuf::from("org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-64.jar") + ); +} + +#[test] +fn library_rules_and_local_hint() { + let library = Library { + name: "com.example:inst:1".to_owned(), + rules: vec![allow_os("linux")], + hint: Some("local".to_owned()), + ..Library::default() + }; + assert!(library.applies(&env("linux", "x86_64"))); + assert!(!library.applies(&env("windows", "x86_64"))); + assert!(library.is_local()); + assert_eq!( + library.relative_path().expect("路径应可推导"), + std::path::PathBuf::from("com/example/inst/1/inst-1.jar") + ); +} diff --git a/crates/core/tests/game/helpers.rs b/crates/core/tests/game/helpers.rs new file mode 100644 index 0000000..59461e2 --- /dev/null +++ b/crates/core/tests/game/helpers.rs @@ -0,0 +1,66 @@ +//! 测试公用件:fixture 解析、内存父版本表、临时目录守卫。 + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use unlucky_core::game::manifest::VersionJson; + +/// 解析 fixture JSON(fixture 是测试常量,解析失败即 panic)。 +pub fn parse(json: &str) -> VersionJson { + VersionJson::from_json_str(json).expect("fixture 必须可解析") +} + +/// 用 `(id, json)` 对建内存父版本表,喂给 `inherit::resolve`。 +pub fn source(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(id, json)| ((*id).to_owned(), parse(json))) + .collect() +} + +/// 唯一临时目录守卫:`drop` 时递归删除。 +pub struct TempDir { + path: PathBuf, +} + +impl TempDir { + /// 建一个唯一临时目录。 + pub fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "ull-game-{tag}-{}-{nanos}-{seq}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("临时目录应可创建"); + Self { path } + } + + /// 目录路径。 + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +/// 建一个游戏根布局(纯路径计算,不建目录)。 +pub fn layout(root: &Path) -> unlucky_core::game::GameLayout { + unlucky_core::game::GameLayout::new(root) +} + +/// 造 `dir/` 并写一个文件进去(模拟玩家内容:`mods`/`saves`/`config` 的条目)。 +pub fn seed(dir: &Path, name: &str) { + let target = dir.join(name); + std::fs::create_dir_all(&target).expect("建目录"); + std::fs::write(target.join("entry.txt"), b"x").expect("写文件"); +} diff --git a/crates/core/tests/game/inherit_chain.rs b/crates/core/tests/game/inherit_chain.rs new file mode 100644 index 0000000..1172572 --- /dev/null +++ b/crates/core/tests/game/inherit_chain.rs @@ -0,0 +1,265 @@ +//! `inheritsFrom` 解析:逐字段 merge 语义、patch 优先级、环检测、缺失父版本。 + +use unlucky_core::game::error::GameError; +use unlucky_core::game::inherit::{apply_patches, merge, resolve, sorted_patches}; +use unlucky_core::game::version::ReleaseType; + +use crate::helpers::{parse, source}; + +const VANILLA: &str = r#"{ + "id": "1.20.1", + "type": "release", + "releaseTime": "2023-06-12T13:25:51+00:00", + "mainClass": "net.minecraft.client.main.Main", + "minecraftArguments": "--username ${auth_player_name}", + "assets": "5", + "assetIndex": { "id": "5" }, + "complianceLevel": 1, + "minimumLauncherVersion": 21, + "javaVersion": { "component": "java-runtime-gamma", "majorVersion": 17 }, + "libraries": [ { "name": "lib:a:1" }, { "name": "lib:b:1" } ], + "downloads": { + "client": { "url": "https://example.invalid/client.jar", "sha1": "c", "size": 1 }, + "server": { "url": "https://example.invalid/server.jar", "sha1": "s", "size": 1 } + }, + "arguments": { "game": ["--game-parent"], "jvm": ["-Dparent=1"] }, + "logging": { "client": { "argument": "-Dlog4j", "file": { "id": "client-1.12.xml" } } } +}"#; + +const FORGE: &str = r#"{ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "time": "2024-01-01T00:00:00+00:00", + "minimumLauncherVersion": 14, + "javaVersion": { "component": "java-runtime-delta", "majorVersion": 21 }, + "libraries": [ { "name": "net.minecraftforge:forge:1" } ], + "downloads": { "server": { "url": "https://example.invalid/server2.jar" } }, + "arguments": { "game": ["--game-child"], "jvm": ["-Dchild=1"] } +}"#; + +fn vanilla_and_forge( +) -> std::collections::BTreeMap { + source(&[("1.20.1", VANILLA), ("1.20.1-forge-47.2.0", FORGE)]) +} + +#[test] +fn merge_table_follows_analyse_03() { + let sources = vanilla_and_forge(); + let child = parse(FORGE); + let resolved = resolve(&sources, &child).expect("继承链应可解析"); + + // 子覆盖父。 + assert_eq!( + resolved.main_class.as_deref(), + Some("cpw.mods.bootstraplauncher.BootstrapLauncher") + ); + // 子没写就继承父。 + assert_eq!( + resolved.minecraft_arguments.as_deref(), + Some("--username ${auth_player_name}") + ); + assert_eq!(resolved.assets.as_deref(), Some("5")); + assert_eq!(resolved.version_type, Some(ReleaseType::Release)); + + // arguments 父在前、子在后追加。 + let arguments = resolved.arguments.as_ref().expect("arguments 应保留"); + assert_eq!(arguments.game.len(), 2); + assert_eq!(arguments.jvm.len(), 2); + + // libraries 父在前、子在后。 + let libraries = resolved.libraries.as_ref().expect("libraries 应保留"); + let names: Vec<&str> = libraries.iter().map(|l| l.name.as_str()).collect(); + assert_eq!( + names, + vec!["lib:a:1", "lib:b:1", "net.minecraftforge:forge:1"] + ); + + // downloads 整体覆盖:子只写 server,client 就没了(非直觉点 1,必须显式守门)。 + let downloads = resolved.downloads.as_ref().expect("downloads 应存在"); + assert!(downloads.client().is_none(), "整体覆盖会让父层 client 消失"); + assert_eq!( + downloads.server().and_then(|s| s.url.as_deref()), + Some("https://example.invalid/server2.jar") + ); + + // complianceLevel 只取子(非直觉点 2):父层 1 被丢弃。 + assert_eq!(resolved.compliance_level, None); + + // javaVersion 子覆盖父。 + let java = resolved.java_version.as_ref().expect("javaVersion 应存在"); + assert_eq!(java.major_version, Some(21)); + + // minimumLauncherVersion 取 max(21, 14)=21。 + assert_eq!(resolved.minimum_launcher_version, Some(21)); + + // logging 子没有 → 继承父。 + assert!(resolved.logging.is_some()); + + // 解析结果的不变量。 + assert_eq!(resolved.jar.as_deref(), Some("1.20.1-forge-47.2.0")); + assert!(resolved.inherits_from.is_none()); + assert!(resolved.patches.is_none()); + assert_eq!(resolved.root, Some(true)); + assert!(resolved.is_resolved()); +} + +#[test] +fn three_level_chain_orders_ancestor_first() { + const A: &str = r#"{ "id": "a", "mainClass": "a.Main", "libraries": [{ "name": "l:a:1" }] }"#; + const B: &str = r#"{ "id": "b", "inheritsFrom": "a", "libraries": [{ "name": "l:b:1" }] }"#; + const C: &str = r#"{ "id": "c", "inheritsFrom": "b", "mainClass": "c.Main", + "libraries": [{ "name": "l:c:1" }] }"#; + let sources = source(&[("a", A), ("b", B), ("c", C)]); + + let resolved = resolve(&sources, &parse(C)).expect("三层链应可解析"); + + assert_eq!(resolved.main_class.as_deref(), Some("c.Main")); + let names: Vec<&str> = resolved + .libraries + .as_ref() + .expect("libraries 应存在") + .iter() + .map(|l| l.name.as_str()) + .collect(); + assert_eq!(names, vec!["l:a:1", "l:b:1", "l:c:1"]); +} + +#[test] +fn patch_priority_sorts_ascending() { + let root = parse( + r#"{ + "id": "patched", + "inheritsFrom": "1.20.1", + "patches": [ + { "id": "forge", "priority": 30000, "mainClass": "loader.Main", + "libraries": [{ "name": "loader:a:1" }] }, + { "id": "base", "mainClass": "base.Main", + "arguments": { "game": ["--base"] } } + ] + }"#, + ); + let resolved = resolve(&vanilla_and_forge(), &root).expect("带 patches 的清单应可解析"); + + // 无 priority 的补丁排最前(Integer.MIN_VALUE 语义),loader 后合并 → 覆盖 mainClass。 + assert_eq!(resolved.main_class.as_deref(), Some("loader.Main")); + let libraries = resolved.libraries.as_ref().expect("libraries 应存在"); + assert_eq!( + libraries.last().map(|l| l.name.as_str()), + Some("loader:a:1") + ); + + let arguments = resolved.arguments.as_ref().expect("arguments 应存在"); + let flat: Vec = arguments.game.iter().map(|a| format!("{a:?}")).collect(); + assert!(flat.iter().any(|a| a.contains("--base"))); +} + +#[test] +fn equal_priority_patches_keep_insertion_order() { + let root = parse( + r#"{ + "id": "patched", + "patches": [ + { "id": "first", "priority": 10, "mainClass": "first.Main" }, + { "id": "second", "priority": 10, "mainClass": "second.Main" } + ] + }"#, + ); + let resolved = resolve(&source(&[]), &root).expect("无继承的清单应可解析"); + assert_eq!( + resolved.main_class.as_deref(), + Some("second.Main"), + "同优先级按插入顺序合并,后者覆盖前者" + ); +} + +#[test] +fn sorted_patches_puts_missing_priority_first() { + let patches = vec![ + parse(r#"{ "id": "loader", "priority": 30000 }"#), + parse(r#"{ "id": "base" }"#), + parse(r#"{ "id": "mc", "priority": 0 }"#), + ]; + let order: Vec<&str> = sorted_patches(&patches) + .iter() + .filter_map(|p| p.id.as_deref()) + .collect(); + assert_eq!(order, vec!["base", "mc", "loader"]); +} + +#[test] +fn apply_patches_stacks_layers() { + let base = + parse(r#"{ "id": "root", "mainClass": "base.Main", "libraries": [{ "name": "l:a:1" }] }"#); + let patched = apply_patches( + base, + &[parse( + r#"{ "id": "loader", "priority": 30000, "mainClass": "loader.Main", + "libraries": [{ "name": "l:loader:1" }] }"#, + )], + ); + assert_eq!(patched.main_class.as_deref(), Some("loader.Main")); + let names: Vec<&str> = patched + .libraries + .as_ref() + .expect("libraries 应存在") + .iter() + .map(|l| l.name.as_str()) + .collect(); + assert_eq!(names, vec!["l:a:1", "l:loader:1"]); +} + +#[test] +fn circular_inheritance_is_reported() { + let sources = source(&[ + ("a", r#"{ "id": "a", "inheritsFrom": "b" }"#), + ("b", r#"{ "id": "b", "inheritsFrom": "a" }"#), + ]); + let error = resolve(&sources, &parse(r#"{ "id": "a", "inheritsFrom": "b" }"#)) + .expect_err("成环必须报错而不是静默降级"); + match error { + GameError::CircularInheritance { chain } => { + assert_eq!(chain, vec!["a", "b", "a"], "链要能看出环的位置"); + } + other => panic!("期望 CircularInheritance,得到 {other:?}"), + } +} + +#[test] +fn missing_parent_is_reported() { + let error = resolve(&source(&[]), &parse(FORGE)).expect_err("缺失父版本必须报错"); + match error { + GameError::MissingParent { + parent, + required_by, + } => { + assert_eq!(parent, "1.20.1"); + assert_eq!(required_by, "1.20.1-forge-47.2.0"); + } + other => panic!("期望 MissingParent,得到 {other:?}"), + } +} + +#[test] +fn manifest_without_id_is_rejected() { + let error = + resolve(&source(&[]), &parse(r#"{ "mainClass": "a.B" }"#)).expect_err("缺 id 应报错"); + assert!(matches!(error, GameError::MissingVersionId)); +} + +#[test] +fn merge_preserves_unknown_fields_with_child_winning() { + let parent = parse(r#"{ "id": "p", "customParentOnly": 1, "shared": "parent" }"#); + let child = parse(r#"{ "id": "c", "shared": "child" }"#); + let merged = merge(&child, &parent); + + assert_eq!( + merged.extra.get("customParentOnly"), + Some(&serde_json::json!(1)) + ); + assert_eq!( + merged.extra.get("shared"), + Some(&serde_json::json!("child")) + ); + assert_eq!(merged.id.as_deref(), Some("c")); +} diff --git a/crates/core/tests/game/isolation.rs b/crates/core/tests/game/isolation.rs new file mode 100644 index 0000000..7e0e96f --- /dev/null +++ b/crates/core/tests/game/isolation.rs @@ -0,0 +1,194 @@ +//! D20 实例隔离:覆盖链派生、双侧切换检测、稀疏落盘不变量。 +//! +//! 对应 mission 的验收点:三档派生 / 覆盖链优先级 / 切换检测(双侧空、单侧、双侧)/ +//! `inherit` 不落盘 / 恒共享层不可配。 + +use std::path::Path; + +use unlucky_core::game::install::instance::{InstanceConfig, Isolation}; +use unlucky_core::game::isolation::{ + derive_game_dir, probe_both_sides, probe_side, IsolationMode, IsolationSource, + PROBED_CONTENT_DIRS, SHARED_LAYERS, +}; +use unlucky_core::game::GameError; + +use super::helpers::{layout, seed, TempDir}; + +// ---------------------------------------------------------------- 派生(三档) + +#[test] +fn isolated_mode_derives_version_dir() { + let temp = TempDir::new("iso-derived"); + let game = layout(temp.path()); + let derived = derive_game_dir(&game, "1.20.1", IsolationMode::Isolated, None, None) + .expect("isolated 一定能派生"); + assert_eq!(derived.isolation, Isolation::Isolated); + assert_eq!(derived.game_dir, game.version_dir("1.20.1")); + assert_eq!(derived.source, IsolationSource::Global); +} + +#[test] +fn shared_mode_derives_game_root() { + let temp = TempDir::new("iso-shared"); + let game = layout(temp.path()); + let derived = derive_game_dir(&game, "1.20.1", IsolationMode::Shared, None, None) + .expect("shared 一定能派生"); + assert_eq!(derived.isolation, Isolation::Shared); + assert_eq!(derived.game_dir, game.root()); +} + +#[test] +fn custom_mode_uses_instance_game_dir() { + let temp = TempDir::new("iso-custom"); + let game = layout(temp.path()); + let custom = temp.path().join("elsewhere").join("farm"); + let mut config = InstanceConfig::new("id-1", "1.20.1"); + config.isolation = Some(Isolation::Custom); + config.game_dir = Some(custom.clone()); + let derived = derive_game_dir( + &game, + "1.20.1", + IsolationMode::Isolated, + Some(&config), + None, + ) + .expect("custom 有目录就能派生"); + assert_eq!(derived.isolation, Isolation::Custom); + assert_eq!(derived.game_dir, custom); + assert_eq!(derived.source, IsolationSource::Instance); +} + +#[test] +fn custom_without_game_dir_is_an_error() { + let temp = TempDir::new("iso-custom-missing"); + let game = layout(temp.path()); + let mut config = InstanceConfig::new("id-missing", "1.20.1"); + config.isolation = Some(Isolation::Custom); + let error = derive_game_dir( + &game, + "1.20.1", + IsolationMode::Isolated, + Some(&config), + None, + ) + .expect_err("custom 缺目录必须报错,不许猜默认值"); + assert!(matches!(error, GameError::CustomIsolationWithoutDir { .. })); + assert!(error.to_string().contains("id-missing"), "{error}"); +} + +// ------------------------------------------------------------ 覆盖链(优先级) + +#[test] +fn instance_isolation_beats_global_mode() { + let temp = TempDir::new("iso-priority"); + let game = layout(temp.path()); + let mut config = InstanceConfig::new("id-2", "1.20.1"); + config.isolation = Some(Isolation::Shared); + let derived = derive_game_dir( + &game, + "1.20.1", + IsolationMode::Isolated, + Some(&config), + None, + ) + .expect("派生"); + assert_eq!(derived.isolation, Isolation::Shared); + assert_eq!(derived.game_dir, game.root()); + assert_eq!(derived.source, IsolationSource::Instance); +} + +#[test] +fn inherit_falls_back_to_global_mode() { + let temp = TempDir::new("iso-inherit"); + let game = layout(temp.path()); + let config = InstanceConfig::new("id-3", "1.20.1"); + assert!(config.inherits_isolation(), "新实例默认 inherit"); + for (mode, expected) in [ + (IsolationMode::Isolated, game.version_dir("1.20.1")), + (IsolationMode::Shared, game.root().to_path_buf()), + ] { + let derived = derive_game_dir(&game, "1.20.1", mode, Some(&config), None).expect("派生"); + assert_eq!( + derived.game_dir, + expected, + "全局 {} 应决定 inherit", + mode.as_str() + ); + assert_eq!(derived.source, IsolationSource::Global); + } +} + +#[test] +fn temporary_override_wins_over_everything() { + let temp = TempDir::new("iso-temporary"); + let game = layout(temp.path()); + let mut config = InstanceConfig::new("id-4", "1.20.1"); + config.isolation = Some(Isolation::Isolated); + let forced = temp.path().join("forced"); + let derived = derive_game_dir( + &game, + "1.20.1", + IsolationMode::Shared, + Some(&config), + Some(&forced), + ) + .expect("派生"); + assert_eq!(derived.game_dir, forced); + assert_eq!(derived.source, IsolationSource::Temporary); + // 档位本身仍是实例档位(临时参数只改目录,不改语义)。 + assert_eq!(derived.isolation, Isolation::Isolated); + + // 空路径 = 没给临时参数。 + let derived = derive_game_dir( + &game, + "1.20.1", + IsolationMode::Shared, + Some(&config), + Some(Path::new("")), + ) + .expect("派生"); + assert_eq!(derived.source, IsolationSource::Instance); +} + +#[test] +fn shared_layers_are_fixed_and_unconfigurable() { + assert_eq!(SHARED_LAYERS, ["versions", "assets", "libraries"]); + // natives 不在恒共享层里:它按实例解包(两家启动器一致)。 + assert!(!SHARED_LAYERS.contains(&"natives")); + assert_eq!(PROBED_CONTENT_DIRS, ["mods", "saves", "config"]); +} + +// -------------------------------------------------------------- 切换检测(探测) + +#[test] +fn probe_counts_only_player_content() { + let temp = TempDir::new("iso-probe"); + seed(temp.path(), "mods"); + seed(temp.path(), "saves"); + seed(temp.path(), "screenshots"); + let side = probe_side(temp.path()).expect("探测"); + assert_eq!(side.entries, 2, "screenshots 不算玩家内容"); + assert!(side.occupied()); +} + +#[test] +fn probe_missing_dir_counts_as_zero() { + let temp = TempDir::new("iso-probe-empty"); + let side = probe_side(&temp.path().join("nope")).expect("目录不存在不算错误"); + assert_eq!(side.entries, 0); + assert!(!side.occupied()); +} + +#[test] +fn probe_both_sides_reads_isolated_and_shared() { + let temp = TempDir::new("iso-probe-both"); + let game = layout(temp.path()); + seed(&game.version_dir("1.20.1"), "saves"); + seed(game.root(), "mods"); + seed(game.root(), "config"); + let (isolated, shared) = probe_both_sides(&game, "1.20.1").expect("探测"); + assert_eq!(isolated.entries, 1); + assert_eq!(shared.entries, 2); + assert_eq!(isolated.path, game.version_dir("1.20.1")); + assert_eq!(shared.path, game.root()); +} diff --git a/crates/core/tests/game/isolation_switch.rs b/crates/core/tests/game/isolation_switch.rs new file mode 100644 index 0000000..80dba99 --- /dev/null +++ b/crates/core/tests/game/isolation_switch.rs @@ -0,0 +1,222 @@ +//! D20 切换检测与稀疏落盘:observe/warn 语义、`inherit` 与 `lastModeChange` 不落盘、 +//! 旧字面量读兼容(从 `isolation.rs` 拆出,保持单文件 ≤400 行)。 + +use std::path::PathBuf; + +use unlucky_core::game::install::instance::{InstanceConfig, Isolation}; +use unlucky_core::game::isolation::{ + derive_game_dir, observe_mode, IsolationMode, IsolationSource, +}; + +use super::helpers::{layout, seed, TempDir}; + +// ------------------------------------------------- 切换记录(幂等 + 只提示一次) + +#[test] +fn observe_mode_records_baseline_without_warning() { + let temp = TempDir::new("iso-observe-baseline"); + let game = layout(temp.path()); + seed(game.root(), "saves"); + let mut config = InstanceConfig::new("id-5", "1.20.1"); + let changed = observe_mode( + &mut config, + &game, + "2026-10-30T00:00:00Z", + Isolation::Isolated, + ) + .expect("观察"); + assert!(changed, "首次观察要建基线"); + let change = config.last_mode_change.clone().expect("基线"); + assert_eq!(change.from, change.to); + assert!(!change.is_real_change()); + assert!(change.warned, "基线不该弹提示"); + assert_eq!(change.probe.shared_side, 1); + assert!( + config.take_mode_change_warning().is_none(), + "基线即使两侧都有内容也不提示" + ); +} + +#[test] +fn observe_mode_is_idempotent() { + let temp = TempDir::new("iso-observe-idempotent"); + let game = layout(temp.path()); + let mut config = InstanceConfig::new("id-6", "1.20.1"); + assert!( + observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("观察"), + "首次写入" + ); + assert!( + !observe_mode(&mut config, &game, "t2", Isolation::Isolated).expect("观察"), + "同一档位不重复记" + ); + assert_eq!( + config.last_mode_change.expect("记录仍在").at, + "t1", + "时间戳不该被刷新" + ); +} + +#[test] +fn mode_change_with_both_sides_warns_exactly_once() { + let temp = TempDir::new("iso-warn-once"); + let game = layout(temp.path()); + seed(&game.version_dir("1.20.1"), "saves"); + seed(&game.version_dir("1.20.1"), "mods"); + seed(game.root(), "saves"); + let mut config = InstanceConfig::new("id-7", "1.20.1"); + config.isolation = Some(Isolation::Shared); + observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线"); + config.isolation = Some(Isolation::Isolated); + assert!( + observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("记切换"), + "档位变了要写记录" + ); + let change = config.last_mode_change.clone().expect("记录"); + assert_eq!(change.from, Isolation::Shared); + assert_eq!(change.to, Isolation::Isolated); + assert!(change.is_real_change()); + assert_eq!(change.probe.isolated_side, 2); + assert_eq!(change.probe.shared_side, 1); + + let warning = config + .take_mode_change_warning() + .expect("两侧都有内容要提示"); + assert!(warning.probe.both_occupied()); + let message = warning.message(); + assert!(message.contains("isolated"), "{message}"); + assert!( + message.contains("不会自动搬"), + "文案必须说清不迁移:{message}" + ); + assert!( + config.take_mode_change_warning().is_none(), + "同一次切换只提示一次" + ); + assert!(config.last_mode_change.expect("记录").warned); +} + +#[test] +fn mode_change_with_one_empty_side_is_quiet() { + let temp = TempDir::new("iso-warn-single-side"); + let game = layout(temp.path()); + // 只有共享侧有内容:隔离侧空 → 不提示(切到共享还能看见存档,反之亦然)。 + seed(game.root(), "saves"); + let mut config = InstanceConfig::new("id-8", "1.20.1"); + observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线"); + observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("记切换"); + assert!(config.take_mode_change_warning().is_none()); + + // 两侧都空同样安静。 + let empty = TempDir::new("iso-warn-both-empty"); + let game = layout(empty.path()); + let mut config = InstanceConfig::new("id-9", "1.20.1"); + observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线"); + observe_mode(&mut config, &game, "t1", Isolation::Isolated).expect("记切换"); + assert!(config.take_mode_change_warning().is_none()); +} + +// -------------------------------------------------------------- 稀疏落盘不变量 + +#[test] +fn inherit_and_no_change_stay_out_of_the_file() { + let config = InstanceConfig::new("id-10", "1.20.1"); + let json = config.to_json_string().expect("序列化"); + assert!(!json.contains("isolation"), "inherit 不许落盘:{json}"); + assert!(!json.contains("lastModeChange"), "没切过就不写:{json}"); + assert!(!json.contains("gameDir"), "没目录就不写:{json}"); + let back = InstanceConfig::from_json_str(&json).expect("回读"); + assert_eq!(back, config); +} + +#[test] +fn last_mode_change_round_trips_in_camel_case() { + let temp = TempDir::new("iso-roundtrip"); + let game = layout(temp.path()); + let mut config = InstanceConfig::new("id-11", "1.20.1"); + observe_mode(&mut config, &game, "t0", Isolation::Shared).expect("基线"); + let json = config.to_json_string().expect("序列化"); + assert!(json.contains("\"lastModeChange\""), "{json}"); + assert!(json.contains("\"isolatedSide\""), "{json}"); + assert!(json.contains("\"sharedSide\""), "{json}"); + let back = InstanceConfig::from_json_str(&json).expect("回读"); + assert_eq!(back, config); +} + +#[test] +fn legacy_per_version_value_reads_as_isolated() { + let raw = r#"{ + "id": "id-12", + "versionId": "1.20.1", + "isolation": "per_version" + }"#; + let config = InstanceConfig::from_json_str(raw).expect("旧字面量读兼容"); + assert_eq!(config.isolation, Some(Isolation::Isolated)); + assert_eq!(Isolation::parse("per_version"), Some(Isolation::Isolated)); + assert_eq!(Isolation::parse("isolated"), Some(Isolation::Isolated)); + assert_eq!(Isolation::parse("nonsense"), None); +} + +#[test] +fn effective_isolation_resolves_inherit() { + let config = InstanceConfig::new("id-13", "1.20.1"); + assert_eq!( + config.effective_isolation(IsolationMode::Shared), + Isolation::Shared + ); + let mut config = InstanceConfig::new("id-14", "1.20.1"); + config.isolation = Some(Isolation::Custom); + assert_eq!( + config.effective_isolation(IsolationMode::Isolated), + Isolation::Custom + ); +} + +#[test] +fn custom_dir_from_relative_path_is_kept_verbatim() { + // custom 目录不做规范化(可能是相对路径或系统符号链接),派生层只透传。 + let temp = TempDir::new("iso-custom-relative"); + let game = layout(temp.path()); + let mut config = InstanceConfig::new("id-15", "1.20.1"); + config.isolation = Some(Isolation::Custom); + config.game_dir = Some(PathBuf::from("relative/mc")); + let derived = derive_game_dir( + &game, + "1.20.1", + IsolationMode::Isolated, + Some(&config), + None, + ) + .expect("派生"); + assert_eq!(derived.game_dir, PathBuf::from("relative/mc")); +} + +#[test] +fn explicit_inherit_reads_back_as_absent() { + // 回归 P2-2(review r1):schema 允许写 `"isolation": "inherit"`, + // 读路径必须把它折叠成「键不存在」,否则设置层写回默认值后启动会报 unknown variant。 + let raw = r#"{ + "id": "id-16", + "versionId": "1.20.1", + "isolation": "inherit" + }"#; + let config = InstanceConfig::from_json_str(raw).expect("显式 inherit 必须能读"); + assert!(config.inherits_isolation()); + assert_eq!(config.isolation, None); + let json = config.to_json_string().expect("序列化"); + assert!( + !json.contains("isolation"), + "写回时把 inherit 落成键缺失:{json}" + ); + // 走到派生层就是「跟随全局」,不再是错误路径。 + let temp = TempDir::new("iso-inherit-explicit"); + let game = layout(temp.path()); + let derived = + derive_game_dir(&game, "1.20.1", IsolationMode::Shared, Some(&config), None).expect("派生"); + assert_eq!(derived.isolation, Isolation::Shared); + assert_eq!(derived.source, IsolationSource::Global); + + // 其他非法字面量仍然报错(不静默取默认值)。 + let bad = r#"{ "id": "id-17", "versionId": "1.20.1", "isolation": "maybe" }"#; + assert!(InstanceConfig::from_json_str(bad).is_err()); +} diff --git a/crates/core/tests/game/java_discover.rs b/crates/core/tests/game/java_discover.rs new file mode 100644 index 0000000..cb25780 --- /dev/null +++ b/crates/core/tests/game/java_discover.rs @@ -0,0 +1,126 @@ +//! `core::game::java::discover`:标准安装位置扫描与来源优先级。 +//! +//! 真机上扫到什么取决于本机装了什么,所以断言只钉**不变量**: +//! 扫描结果形状、来源优先级(env 胜过扫盘)、去重保留高优先级、缺目录不报错。 + +use unlucky_core::game::java::discover; +use unlucky_core::game::java::mapping::{JavaRequirement, RequirementSource}; +use unlucky_core::game::java::{pick, JavaOrigin, JavaRuntime}; + +fn runtime(path: &str, major: u32, origin: JavaOrigin) -> JavaRuntime { + JavaRuntime { + binary: std::path::PathBuf::from(path), + origin, + version: Some(format!("openjdk version \"{major}.0.1\"")), + major: Some(major), + bits: 64, + vendor: None, + } +} + +fn at_least(minimum: u32) -> JavaRequirement { + JavaRequirement::new(minimum, None, RequirementSource::Table) +} + +#[test] +fn scanned_paths_always_point_at_a_java_binary() { + // 本机可能一个都没有(裸机/容器)——那也算通过,只是列表为空。 + for binary in discover::scan_java_binaries() { + assert!( + binary.is_file(), + "扫描结果必须是真实存在的文件:{}", + binary.display() + ); + assert_eq!( + binary + .file_name() + .map(|name| name.to_string_lossy().into_owned()), + Some(unlucky_core::game::java::java_binary_name().to_string()), + "扫描结果必须是 bin/java[.exe]:{}", + binary.display() + ); + } +} + +#[test] +fn standard_roots_are_absolute_and_unique() { + let roots = discover::standard_roots(); + for root in &roots { + assert!( + root.is_absolute(), + "候选根必须是绝对路径:{}", + root.display() + ); + } + let mut keys: Vec = roots.iter().map(|root| discover::path_key(root)).collect(); + let total = keys.len(); + keys.sort(); + keys.dedup(); + assert_eq!(keys.len(), total, "候选根不该重复"); +} + +#[test] +fn environment_java_beats_scanned_java_when_both_satisfy() { + // D13 的「优先级链不变」:环境变量是用户意图,扫盘只是候选。 + let candidates = [ + runtime( + "C:/Program Files/Eclipse Adoptium/jdk-21/bin/java.exe", + 21, + JavaOrigin::System, + ), + runtime( + "C:/Program Files/Microsoft/jdk-21/bin/java.exe", + 21, + JavaOrigin::JavaHome, + ), + ]; + let picked = pick(&candidates, &at_least(17)).expect("应有候选"); + assert_eq!(picked.origin, JavaOrigin::JavaHome); +} + +#[test] +fn scanned_java_is_still_used_when_nothing_else_matches() { + // MSI 装了 Temurin 21、没设环境变量、PATH 上是 Java 8:1.20.5 只认扫盘那个。 + let candidates = [ + runtime("java", 8, JavaOrigin::Path), + runtime( + "C:/Program Files/Java/jdk-21/bin/java.exe", + 21, + JavaOrigin::System, + ), + ]; + let picked = pick(&candidates, &at_least(21)).expect("应有候选"); + assert_eq!(picked.origin, JavaOrigin::System); +} + +#[test] +fn dedupe_collapses_one_install_found_twice() { + // `JAVA_HOME` 指向 Program Files 里的 JDK → 同一个 java 不应列两次。 + let candidates = discover::dedupe(vec![ + runtime( + "C:/Program Files/Java/jdk-21/bin/java.exe", + 21, + JavaOrigin::JavaHome, + ), + runtime( + "c:/program files/java/jdk-21/bin/java.exe", + 21, + JavaOrigin::System, + ), + ]); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].origin, JavaOrigin::JavaHome); +} + +#[test] +fn registry_absence_is_tolerated() { + // 非 Windows 必然为空;Windows 上没装老 Oracle JDK 也是空。 + let homes = discover::registry_homes(); + for home in &homes { + assert!( + home.is_absolute(), + "注册表里的 JavaHome 应是绝对路径:{}", + home.display() + ); + } +} diff --git a/crates/core/tests/game/java_install.rs b/crates/core/tests/game/java_install.rs new file mode 100644 index 0000000..5d69239 --- /dev/null +++ b/crates/core/tests/game/java_install.rs @@ -0,0 +1,291 @@ +//! `core::game::java` 的安装侧集成测试:归档解压(幂等 / 顶层剥离 / 越界拒绝)、 +//! 托管目录命名、下载请求装配、平台归档形态。 +//! +//! 镜像选源见 `java_mirrors.rs`,MC↔Java 映射见 `java_mapping.rs`。 + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use flate2::write::GzEncoder; +use flate2::Compression; +use unlucky_core::download::ResourceKind; +use unlucky_core::game::java::{ + self, extract, install, probe, AdoptiumTarget, JdkArchive, JdkRelease, +}; + +/// 64 位十六进制(解压戳记只当字符串比对,不校验内容)。 +const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +/// 另一个「新版本」的摘要。 +const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +fn temp_dir(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "ull-java-{tag}-{}-{nanos}-{seq}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir +} +/// 造一个 Temurin 形状的 zip(顶层 `jdk-21.0.13+9/`)。 +fn write_zip(path: &Path, entries: &[(&str, &[u8])]) { + let file = File::create(path).expect("建 zip"); + let mut writer = zip::ZipWriter::new(file); + let options = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, body) in entries { + writer.start_file(*name, options).expect("写 zip 条目"); + writer.write_all(body).expect("写 zip 内容"); + } + writer.finish().expect("收尾 zip"); +} + +/// 造一个 Temurin 形状的 tar.gz。 +fn write_tar_gz(path: &Path, entries: &[(&str, &[u8])]) { + let file = File::create(path).expect("建 tar.gz"); + let encoder = GzEncoder::new(file, Compression::fast()); + let mut builder = tar::Builder::new(encoder); + for (name, body) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(body.len() as u64); + header.set_mode(0o755); + header.set_entry_type(tar::EntryType::Regular); + builder + .append_data(&mut header, name, *body) + .expect("写 tar 条目"); + } + let encoder = builder.into_inner().expect("收尾 tar"); + encoder.finish().expect("收尾 gzip"); +} + +fn java_bin(root: &Path) -> PathBuf { + root.join("bin").join(java::java_binary_name()) +} + +#[test] +fn zip_archive_loses_the_temurin_top_level_directory() { + let dir = temp_dir("zip"); + let archive = dir.join("jdk.zip"); + let top = "jdk-21.0.13+9"; + write_zip( + &archive, + &[ + ( + &format!("{top}/bin/{}", java::java_binary_name()), + b"#!/bin/true\n", + ), + (&format!("{top}/release"), b"JAVA_VERSION=\"21.0.13\"\n"), + ], + ); + let dest = dir.join("jdk-21-temurin"); + let report = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("解压应成功"); + + assert!(!report.skipped); + assert_eq!(report.files, 2); + assert!(java_bin(&dest).is_file(), "bin/java 应在顶层"); + assert!(!dest.join(top).exists(), "顶层目录必须被剥掉"); + let stamp = extract::read_stamp(&dest).expect("应写戳记"); + assert_eq!(stamp.major, 21); + assert_eq!(stamp.sha256, SHA_A); + assert_eq!(stamp.files, 2); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn second_extract_with_same_sha_touches_nothing() { + let dir = temp_dir("idem"); + let archive = dir.join("jdk.zip"); + write_zip( + &archive, + &[( + &format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()), + b"bin\n", + )], + ); + let dest = dir.join("jdk-21-temurin"); + extract::extract_archive(&archive, SHA_A, 21, &dest).expect("首解"); + + // 用户手工动过的痕迹:幂等命中时不能被抹掉。 + let marker = dest.join("安装后手工放的.txt"); + std::fs::write(&marker, b"keep").expect("写标记"); + + let again = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("重解"); + assert!(again.skipped, "同一 sha256 应整体跳过"); + assert_eq!(again.files, 0); + assert!(marker.is_file(), "幂等命中时不应重写目录"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn new_sha_replaces_the_managed_directory() { + let dir = temp_dir("upgrade"); + let archive = dir.join("jdk.zip"); + write_zip( + &archive, + &[( + &format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()), + b"v1\n", + )], + ); + let dest = dir.join("jdk-21-temurin"); + extract::extract_archive(&archive, SHA_A, 21, &dest).expect("首解"); + let marker = dest.join("旧版残留"); + std::fs::write(&marker, b"old").expect("写标记"); + + let report = extract::extract_archive(&archive, SHA_B, 21, &dest).expect("新摘要重解"); + assert!(!report.skipped); + assert!(!marker.exists(), "官方发新版后旧目录应被替换"); + assert_eq!( + extract::read_stamp(&dest).map(|stamp| stamp.sha256), + Some(SHA_B.to_string()) + ); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn tar_gz_archive_is_supported_for_linux_and_mac() { + let dir = temp_dir("targz"); + let archive = dir.join("OpenJDK21U-jdk_x64_linux_hotspot_21.0.13_9.tar.gz"); + let top = "jdk-21.0.13+9"; + write_tar_gz( + &archive, + &[ + (&format!("{top}/bin/{}", java::java_binary_name()), b"bin\n"), + (&format!("{top}/lib/modules"), b"modules\n"), + ], + ); + let dest = dir.join("jdk-21-temurin"); + let report = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("解压 tar.gz"); + assert_eq!(report.files, 2); + assert!(dest.join("bin").join(java::java_binary_name()).is_file()); + assert!(dest.join("lib").join("modules").is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn archive_without_a_single_top_directory_is_promoted_as_is() { + // 有的魔改发行版直接把 bin/ 放在根上:不能硬剥一层。 + let dir = temp_dir("flat"); + let archive = dir.join("flat.zip"); + write_zip( + &archive, + &[ + ( + &format!("bin/{}", java::java_binary_name()), + b"bin\n".as_slice(), + ), + ("release", b"x\n".as_slice()), + ], + ); + let dest = dir.join("jdk-17-temurin"); + extract::extract_archive(&archive, SHA_A, 17, &dest).expect("解压"); + assert!(dest.join("bin").join(java::java_binary_name()).is_file()); + assert!(dest.join("release").is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn foreign_directory_is_never_clobbered() { + let dir = temp_dir("foreign"); + let archive = dir.join("jdk.zip"); + write_zip( + &archive, + &[( + &format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()), + b"bin\n", + )], + ); + let dest = dir.join("jdk-21-temurin"); + std::fs::create_dir_all(&dest).expect("建目标目录"); + std::fs::write(dest.join("用户的文件.txt"), b"mine").expect("写用户文件"); + + let error = extract::extract_archive(&archive, SHA_A, 21, &dest).expect_err("应拒绝覆盖"); + assert_eq!(error.kind(), java::JavaErrorKind::Io); + assert!(dest.join("用户的文件.txt").is_file(), "用户文件必须还在"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn zip_slip_entries_do_not_escape_the_target_directory() { + let dir = temp_dir("slip"); + let archive = dir.join("evil.zip"); + write_zip( + &archive, + &[ + ( + &format!("jdk-21.0.13+9/bin/{}", java::java_binary_name()), + b"bin\n", + ), + ("../../escaped.txt", b"nope"), + ], + ); + let dest = dir.join("jdk-21-temurin"); + let report = extract::extract_archive(&archive, SHA_A, 21, &dest).expect("越界条目只跳过"); + assert_eq!(report.files, 1, "越界条目不应被写盘"); + assert!(!dir.join("escaped.txt").exists()); + assert!(!dir.parent().unwrap_or(&dir).join("escaped.txt").exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn managed_layout_names_carry_major_and_vendor() { + let root = Path::new("/tmp/java-managed"); + let dest = install::managed_root_dir(root, 21, java::JDK_VENDOR); + assert_eq!(dest, root.join("jdk-21-temurin")); + assert_eq!( + probe::parse_managed_dir("jdk-21-temurin"), + Some((21, "temurin")) + ); + assert_eq!(probe::managed_dir_name(8, "temurin"), "jdk-8-temurin"); +} + +#[test] +fn archive_request_carries_sha256_and_the_java_runtime_kind() { + let release = JdkRelease { + vendor: java::JdkVendor::Temurin, + major: 21, + version: "21.0.13+9".to_string(), + semver: Some("21.0.13+9.0.LTS".to_string()), + release_name: "jdk-21.0.13+9".to_string(), + archive: JdkArchive { + name: "OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip".to_string(), + url: "https://github.com/adoptium/temurin21-binaries/releases/download/x.zip" + .to_string(), + sha256: SHA_A.to_string(), + size: 1024, + }, + }; + let dest = Path::new("/tmp/x.zip"); + let request = install::archive_request(&release, dest.to_path_buf()); + assert_eq!(request.kind, ResourceKind::JavaRuntime); + assert_eq!(request.expected_size, Some(1024)); + assert_eq!( + request + .integrity + .as_ref() + .map(|integrity| integrity.hex.as_str()), + Some(SHA_A) + ); + assert!(request.validate().is_ok()); + assert_eq!( + install::archive_path(Path::new("/tmp/java-managed"), &release), + Path::new("/tmp/java-managed/.cache/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip") + ); +} + +#[test] +fn host_target_picks_the_platform_archive_shape() { + let target = AdoptiumTarget::host().expect("宿主应受支持"); + assert_eq!(target.is_zip(), cfg!(windows)); + assert_eq!( + target.archive_ext(), + if cfg!(windows) { "zip" } else { "tar.gz" } + ); +} diff --git a/crates/core/tests/game/java_location.rs b/crates/core/tests/game/java_location.rs new file mode 100644 index 0000000..55eaef3 --- /dev/null +++ b/crates/core/tests/game/java_location.rs @@ -0,0 +1,136 @@ +//! `core::game::java::location`:落点三模式(Portable / Standard / Custom)+ 孤儿目录清理。 +//! +//! 三模式的口径来自 D23/D6:Portable 保持 v1 布局、Standard 走 OS 标准目录的 +//! `<发行方>/<版本>` 分层、Custom 由 `java.installRoot` 覆盖;清理只碰我们自己的形状。 + +use std::path::PathBuf; + +use unlucky_core::game::java::location::{ + cleanup_orphans, sanitize_version, standard_java_root, JavaInstallRoot, LocationMode, +}; +use unlucky_core::game::java::JdkVendor; +use unlucky_core::platform::layout::LauncherLayout; +use unlucky_core::platform::paths::{MapEnv, Platform}; + +fn temp_dir(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "ull-java-loc-{tag}-{}-{nanos}-{seq}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir +} + +#[test] +fn standard_root_follows_each_platform_convention() { + let env = MapEnv::new() + .with("APPDATA", "C:/Users/u/AppData/Roaming") + .with("HOME", "/home/u") + .with("USERPROFILE", "C:/Users/u"); + // 期望值一律用 `join` 拼(Windows 上的分隔符与 `Path` 比较细节交给标准库)。 + let unix_home = PathBuf::from("/home/u"); + assert_eq!( + standard_java_root(Platform::Windows, &env), + Some(PathBuf::from("C:/Users/u/AppData/Roaming").join("Java")), + "Windows 用户原话:%APPDATA%\\Java" + ); + assert_eq!( + standard_java_root(Platform::MacOs, &env), + Some(unix_home.join("Library/Application Support").join("Java")) + ); + assert_eq!( + standard_java_root(Platform::Linux, &env), + Some(unix_home.join(".local/share").join("java")), + "Linux 走 XDG 约定(无 XDG_DATA_HOME 时回落 ~/.local/share)" + ); + // `Path::is_absolute()` 的口径是编译目标决定的:Windows 上 `C:/data/u` 才是绝对路径, + // 而 `/data/u` 只是「rooted」。夹具按目标平台给,才能同时验到「绝对 → 采用」。 + let xdg_root = if cfg!(windows) { + "C:/data/u" + } else { + "/data/u" + }; + let xdg = MapEnv::new() + .with("HOME", "/home/u") + .with("XDG_DATA_HOME", xdg_root); + assert_eq!( + standard_java_root(Platform::Linux, &xdg), + Some(PathBuf::from(xdg_root).join("java")) + ); + // 相对路径的 XDG_DATA_HOME 按规范忽略 → 回落 HOME。 + let relative = MapEnv::new() + .with("HOME", "/home/u") + .with("XDG_DATA_HOME", "data/u"); + assert_eq!( + standard_java_root(Platform::Linux, &relative), + Some(unix_home.join(".local/share").join("java")) + ); + assert_eq!(standard_java_root(Platform::Linux, &MapEnv::new()), None); +} + +#[test] +fn portable_and_custom_modes_derive_the_documented_directories() { + let launcher = LauncherLayout::new("/data/.ULL"); + let portable = JavaInstallRoot::portable(&launcher); + assert_eq!(portable.mode, LocationMode::Portable); + assert_eq!( + portable.jdk_dir(21, JdkVendor::Temurin, "21.0.13+9"), + PathBuf::from("/data/.ULL/data/java-managed/jdk-21-temurin"), + "便携模式必须保持 v1 布局(M33/M35 兼容)" + ); + let custom = JavaInstallRoot::custom("/opt/java"); + assert_eq!(custom.mode, LocationMode::Custom); + assert_eq!( + custom.jdk_dir(21, JdkVendor::Oracle, "21.0.2"), + PathBuf::from("/opt/java/oracle/21.0.2"), + "Custom 用 /(D23)" + ); + assert_eq!(custom.cache_dir(), PathBuf::from("/opt/java/.cache")); + assert!(custom.describe().contains("custom")); +} + +#[test] +fn version_strings_are_sanitized_for_directory_names() { + assert_eq!(sanitize_version("21.0.13+9"), "21.0.13+9"); + assert_eq!(sanitize_version("21.0.13.0.2"), "21.0.13.0.2"); + assert_eq!(sanitize_version("21.0.13+3"), "21.0.13+3"); + assert_eq!(sanitize_version(" 21.0.2 "), "21.0.2"); + assert_eq!(sanitize_version("../evil"), ".._evil"); + assert_eq!(sanitize_version(""), "unknown"); +} + +#[test] +fn cleanup_removes_staging_and_empty_shells_but_never_foreign_dirs() { + let root = temp_dir("cleanup"); + // 半成品:解压中断的暂存目录。 + std::fs::create_dir_all(root.join(".staging-jdk-21-temurin")).expect("建暂存"); + std::fs::write(root.join(".staging-jdk-21-temurin/bin"), b"x").expect("写暂存文件"); + // 空壳 vendor 目录(Standard 布局的残留)。 + std::fs::create_dir_all(root.join("oracle")).expect("建 vendor"); + // 用户自己的目录(非空、无戳记):绝不能删。 + std::fs::create_dir_all(root.join("my-own-jdk")).expect("建用户目录"); + std::fs::write(root.join("my-own-jdk/notes.txt"), b"mine").expect("写用户文件"); + // 归档缓存目录:保留。 + std::fs::create_dir_all(root.join(".cache")).expect("建缓存"); + + let report = cleanup_orphans(&root); + assert!( + !root.join(".staging-jdk-21-temurin").exists(), + "暂存目录应删" + ); + assert!(!root.join("oracle").exists(), "空 vendor 目录应删"); + assert!( + root.join("my-own-jdk/notes.txt").is_file(), + "用户目录必须还在" + ); + assert!(root.join(".cache").is_dir(), "缓存目录不能删"); + assert_eq!(report.removed.len(), 2, "{:?}", report.removed); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/core/tests/game/java_mapping.rs b/crates/core/tests/game/java_mapping.rs new file mode 100644 index 0000000..8b98e66 --- /dev/null +++ b/crates/core/tests/game/java_mapping.rs @@ -0,0 +1,106 @@ +//! MC↔Java 映射:版本 JSON 的 `javaVersion` 优先(含继承链),上限取自静态表。 + +use std::path::PathBuf; + +use unlucky_core::game::java::mapping; + +fn temp_dir(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "ull-java-{tag}-{}-{nanos}-{seq}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("建临时目录"); + dir +} + +#[test] +fn version_json_java_version_beats_the_static_table() { + let root = temp_dir("mapping"); + let layout = unlucky_core::game::GameLayout::new(&root); + let write = |id: &str, json: serde_json::Value| { + let dir = root.join("versions").join(id); + std::fs::create_dir_all(&dir).expect("建版本目录"); + std::fs::write( + dir.join(format!("{id}.json")), + serde_json::to_vec(&json).expect("序列化"), + ) + .expect("写版本 JSON"); + }; + + // 1.17 的清单自己说是 16(静态表也是 16,但来源必须标 manifest)。 + write( + "1.17.1", + serde_json::json!({"id": "1.17.1", "javaVersion": {"component": "java-runtime-alpha", "majorVersion": 16}}), + ); + let manifest = mapping::requirement_for_version(&layout, "1.17.1"); + assert_eq!(manifest.minimum, 16); + assert_eq!(manifest.source.as_str(), "manifest"); + + // 魔改实例覆盖父版本:21 应当生效。 + write("1.20.1", serde_json::json!({"id": "1.20.1"})); + write( + "1.20.1-forge", + serde_json::json!({"id": "1.20.1-forge", "inheritsFrom": "1.20.1", "javaVersion": {"majorVersion": 21}}), + ); + assert_eq!( + mapping::requirement_for_version(&layout, "1.20.1-forge").minimum, + 21 + ); + + // 清单没写 javaVersion:父版本也没有 → 静态表兜底。 + let table = mapping::requirement_for_version(&layout, "1.20.1"); + assert_eq!(table.minimum, 17); + assert_eq!(table.source.as_str(), "table"); + + // 1.12.2:清单只说「最低 8」,但上限(LaunchWrapper 只能 Java 8)由静态表补上。 + write( + "1.12.2", + serde_json::json!({"id": "1.12.2", "javaVersion": {"component": "jre-legacy", "majorVersion": 8}}), + ); + let legacy = mapping::requirement_for_version(&layout, "1.12.2"); + assert_eq!((legacy.minimum, legacy.maximum), (8, Some(8))); + assert_eq!(legacy.source.as_str(), "manifest"); + assert!( + mapping::check(21, &legacy).is_some(), + "1.12.2 + Java 21 必须报不符" + ); + + // 版本根本没装:按 id 走静态表,不报错。 + let missing = mapping::requirement_for_version(&layout, "26.3"); + assert_eq!(missing.minimum, 25); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn parent_java_version_is_inherited() { + let root = temp_dir("inherit"); + let layout = unlucky_core::game::GameLayout::new(&root); + let write = |id: &str, json: serde_json::Value| { + let dir = root.join("versions").join(id); + std::fs::create_dir_all(&dir).expect("建版本目录"); + std::fs::write( + dir.join(format!("{id}.json")), + serde_json::to_vec(&json).expect("序列化"), + ) + .expect("写版本 JSON"); + }; + write( + "1.20.5", + serde_json::json!({"id": "1.20.5", "javaVersion": {"component": "java-runtime-delta", "majorVersion": 21}}), + ); + write( + "1.20.5-fabric", + serde_json::json!({"id": "1.20.5-fabric", "inheritsFrom": "1.20.5"}), + ); + let requirement = mapping::requirement_for_version(&layout, "1.20.5-fabric"); + assert_eq!(requirement.minimum, 21); + assert_eq!(requirement.source.as_str(), "manifest"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/core/tests/game/java_mirrors.rs b/crates/core/tests/game/java_mirrors.rs new file mode 100644 index 0000000..0fbc484 --- /dev/null +++ b/crates/core/tests/game/java_mirrors.rs @@ -0,0 +1,96 @@ +//! 镜像选源(D7 地区策略注入样本):国内镜像优先 / 国外官方优先,候选里都留兜底。 + +use unlucky_core::download::mirrors::MirrorTable; +use unlucky_core::download::region::RegionProfile; +use unlucky_core::download::ResourceKind; + +#[test] +fn domestic_region_puts_college_mirrors_first() { + let table = MirrorTable::builtin(); + let url = "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.13%2B9/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip"; + let domestic = table.expand( + url, + ResourceKind::JavaRuntime, + &RegionProfile::mirror_first(0), + ); + assert_eq!( + domestic.first().map(|candidate| candidate.source.as_str()), + Some("tuna") + ); + assert_eq!( + domestic.first().map(|candidate| candidate.url.as_str()), + Some("https://mirrors.tuna.tsinghua.edu.cn/Adoptium/21/jdk/x64/windows/OpenJDK21U-jdk_x64_windows_hotspot_21.0.13_9.zip") + ); + let sources: Vec<&str> = domestic + .iter() + .map(|candidate| candidate.source.as_str()) + .collect(); + // USTC/SJTU/BFSU 没有这套目录树(实测 404)、华为云是门户假阳性(M34 实测) + // → 清单里全停用,不该出现在候选里。 + for dead in ["ustc", "sjtu", "bfsu", "huaweicloud"] { + assert!(!sources.contains(&dead), "{dead} 应已停用:{sources:?}"); + } + assert_eq!(sources.last().copied(), Some("official"), "官方兜底在最后"); +} + +#[test] +fn foreign_region_keeps_official_first_but_still_has_mirrors() { + let table = MirrorTable::builtin(); + let url = "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.13%2B11/OpenJDK17U-jdk_aarch64_linux_hotspot_17.0.13_11.tar.gz"; + let abroad = table.expand(url, ResourceKind::JavaRuntime, &RegionProfile::direct(0)); + assert_eq!( + abroad.first().map(|candidate| candidate.source.as_str()), + Some("official") + ); + assert_eq!( + abroad.first().map(|candidate| candidate.url.as_str()), + Some(url) + ); + // 镜像仍然排在后面(国外也可能是镜像更快;健康度会让它上位)。 + assert!(abroad.len() > 1); + assert!(abroad + .iter() + .any(|candidate| candidate.url.contains("/Adoptium/17/jdk/aarch64/linux/"))); +} + +#[test] +fn vendor_cdn_and_mirror_urls_are_labelled_by_their_real_host() { + let table = MirrorTable::builtin(); + let direct = RegionProfile::direct(0); + // Oracle 走国内镜像时,直连 URL 就是华为云——不能标成 official(否则用户以为在啃官方 5.3KB/s)。 + let oracle = + "https://mirrors.huaweicloud.com/openjdk/21.0.2/openjdk-21.0.2_windows-x64_bin.zip"; + assert_eq!( + table + .expand(oracle, ResourceKind::JavaRuntime, &direct) + .first() + .map(|candidate| candidate.source.as_str()), + Some("huawei") + ); + // MS 的 aka.ms 与 Zulu 的 CDN 同理。 + let microsoft = "https://aka.ms/download-jdk/microsoft-jdk-21.0.13.0.2-windows-x64.zip"; + assert_eq!( + table + .expand(microsoft, ResourceKind::JavaRuntime, &direct) + .first() + .map(|candidate| candidate.source.as_str()), + Some("microsoft") + ); + let zulu = "https://cdn.azul.com/zulu/bin/zulu21.54.109-ca-jdk21.0.13.0.3-win_x64.zip"; + assert_eq!( + table + .expand(zulu, ResourceKind::JavaRuntime, &direct) + .first() + .map(|candidate| candidate.source.as_str()), + Some("azul") + ); + // Mojang 系资源仍是 official(别把官方源误标成厂商)。 + let mojang = "https://piston-data.mojang.com/v1/objects/abc/client.jar"; + assert_eq!( + table + .expand(mojang, ResourceKind::ClientJar, &direct) + .first() + .map(|candidate| candidate.source.as_str()), + Some("official") + ); +} diff --git a/crates/core/tests/game/java_vendor_chain.rs b/crates/core/tests/game/java_vendor_chain.rs new file mode 100644 index 0000000..0647e22 --- /dev/null +++ b/crates/core/tests/game/java_vendor_chain.rs @@ -0,0 +1,183 @@ +//! D23 发行版回退链:地区默认序、用户优先级覆盖、能力过滤、mirrors v3 三张新表。 +//! +//! 全部离线:只跑纯函数与清单解析,不发一个请求。 +//! 真机验收(TUNA 装 17 / 华为云装 Oracle 21 / `%APPDATA%` 落点)见 mission 报告。 + +use unlucky_core::download::MirrorTable; +use unlucky_core::game::java::install::chain_order; +use unlucky_core::game::java::release::{parse_sha256_text, JdkArchive, JdkRelease}; +use unlucky_core::game::java::vendor::{ids, order, parse_priority, JdkVendor, VENDOR_IDS}; +use unlucky_core::game::java::InstallRequest; + +fn vendors(items: &[&str]) -> Vec { + parse_priority( + &items + .iter() + .map(|item| (*item).to_string()) + .collect::>(), + ) +} + +#[test] +fn domestic_default_is_temurin_oracle_microsoft_zulu() { + assert_eq!( + ids(&order(&[], true)), + vec!["temurin", "oracle", "microsoft", "zulu"], + "国内:Oracle 国内镜像 11.5MB/s vs MS 410KB/s(D23 实测修正)" + ); + assert_eq!( + ids(&order(&[], false)), + vec!["temurin", "microsoft", "oracle", "zulu"], + "国外:维持用户原话 Temurin→MS→Oracle" + ); +} + +#[test] +fn setting_priority_overrides_the_region_default_but_never_drops_a_vendor() { + let priority = vendors(&["microsoft"]); + assert_eq!( + ids(&order(&priority, true)), + vec!["microsoft", "temurin", "oracle", "zulu"] + ); + // 写全四家也等于顺序覆盖。 + let full = vendors(&["zulu", "microsoft", "oracle", "temurin"]); + assert_eq!( + ids(&order(&full, false)), + vec!["zulu", "microsoft", "oracle", "temurin"] + ); + // 未知值被忽略(老配置里可能残留 graalvm 之类)。 + let messy = vendors(&["graalvm", "oracle"]); + assert_eq!( + ids(&order(&messy, true)), + vec!["oracle", "temurin", "microsoft", "zulu"] + ); +} + +#[test] +fn capability_filter_drops_microsoft_and_oracle_for_java_8() { + // Java 8:MS 已停发、Oracle archive 从 9 起 → 链里只剩 Temurin 与 Zulu。 + assert_eq!( + ids(&chain_order(&InstallRequest::new(8), true)), + vec!["temurin", "zulu"] + ); + // Java 21:四家都发,顺序完整。 + assert_eq!( + ids(&chain_order(&InstallRequest::new(21), true)), + vec!["temurin", "oracle", "microsoft", "zulu"] + ); + // 钉死一家:链里只放它,即使它不支持这个大版本(由 install 报明确原因,不偷偷换家)。 + let pinned = InstallRequest::with_vendor(8, JdkVendor::Microsoft); + assert_eq!( + ids(&chain_order(&pinned, true)), + vec!["microsoft"], + "--vendor 不做能力过滤:用户点名的那家要显式失败" + ); +} + +#[test] +fn pinned_vendor_wins_over_the_user_priority() { + let request = + InstallRequest::with_vendor(17, JdkVendor::Oracle).with_priority(vendors(&["zulu"])); + assert_eq!(ids(&request.order(true)), vec!["oracle"]); + let chained = InstallRequest::new(17).with_priority(vendors(&["zulu"])); + assert_eq!( + ids(&chained.order(true)), + vec!["zulu", "temurin", "oracle", "microsoft"] + ); +} + +#[test] +fn mirror_table_v3_registers_all_four_source_families() { + let table = MirrorTable::builtin(); + assert_eq!(table.version, 3); + // adoptium 收敛:只有 TUNA 启用,其余六家登记为 disabled。 + assert_eq!( + table + .adoptium + .iter() + .filter(|mirror| mirror.enabled) + .map(|mirror| mirror.id.as_str()) + .collect::>(), + vec!["tuna"] + ); + assert_eq!(table.adoptium.len(), 7, "TUNA + USTC/SJTU/BFSU/NJU/ZJU/163"); + // 华为云的假阳性不再登记成 adoptium 源(它没有 Adoptium 镜像)。 + assert!(table + .adoptium + .iter() + .all(|mirror| mirror.id != "huaweicloud")); + // Oracle:华为云在前、南大在后(顺序即默认尝试顺序)。 + assert_eq!( + table + .oracle_mirrors() + .map(|mirror| mirror.id.as_str()) + .collect::>(), + vec!["huawei", "nju"] + ); + // MS:模板 + 无国内镜像 + 四个大版本。 + let microsoft = table.microsoft_openjdk().expect("MS 表"); + assert!(microsoft.domestic_mirrors.is_empty()); + assert_eq!(microsoft.majors, vec![25, 21, 17, 11]); + // Zulu:size 不可信,只信 sha256。 + assert!(!table.zulu().expect("Zulu 表").size_field_reliable); +} + +#[test] +fn every_vendor_release_shape_carries_its_own_version_semantics() { + // 四家的版本串并列:目录名、戳记、--json 都直接用 vendor 原生串。 + let samples = [ + (JdkVendor::Temurin, "21.0.13+9"), + (JdkVendor::Oracle, "21.0.2"), + (JdkVendor::Microsoft, "21.0.13.0.2"), + (JdkVendor::Zulu, "21.0.13+3"), + ]; + for (vendor, version) in samples { + let release = JdkRelease { + vendor, + major: 21, + version: version.to_string(), + semver: None, + release_name: version.to_string(), + archive: JdkArchive { + name: format!("{}-{version}.zip", vendor.id()), + url: format!("https://example.invalid/{}-{version}.zip", vendor.id()), + sha256: "a".repeat(64), + size: 0, + }, + }; + assert!(release.matches(vendor, 21)); + assert!(release.summary().contains(version), "{}", release.summary()); + assert_eq!(release.archive_json()["vendor"], vendor.id()); + assert_eq!( + unlucky_core::game::java::sanitize_version(&release.version), + version, + "版本串要能直接当目录名" + ); + } +} + +#[test] +fn sha256_texts_are_parsed_the_same_way_for_every_upstream() { + let hash = "b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"; + // 华为云 .sha256:纯哈希。 + assert_eq!(parse_sha256_text(hash).as_deref(), Some(hash)); + // MS .sha256sum.txt:` `(大写 + CRLF)。 + assert_eq!( + parse_sha256_text(&format!( + "{} microsoft-jdk-21.0.13-windows-x64.zip\r\n", + hash.to_uppercase() + )) + .as_deref(), + Some(hash) + ); + assert_eq!(parse_sha256_text("404 not found"), None); +} + +#[test] +fn vendor_ids_are_the_setting_whitelist() { + assert_eq!(VENDOR_IDS, &["temurin", "oracle", "microsoft", "zulu"]); + for id in VENDOR_IDS { + assert_eq!(JdkVendor::parse(id).map(|vendor| vendor.id()), Some(*id)); + } + assert!(JdkVendor::parse("graalvm").is_none()); +} diff --git a/crates/core/tests/game/layout.rs b/crates/core/tests/game/layout.rs new file mode 100644 index 0000000..bd034ab --- /dev/null +++ b/crates/core/tests/game/layout.rs @@ -0,0 +1,143 @@ +//! `.minecraft` 目录布局:路径规则、Maven 坐标、已安装版本枚举。 + +use std::path::PathBuf; + +use unlucky_core::game::error::GameError; +use unlucky_core::game::layout::GameLayout; +use unlucky_core::game::library::MavenCoordinate; + +use crate::helpers::TempDir; + +fn layout() -> (TempDir, GameLayout) { + let temp = TempDir::new("layout"); + let layout = GameLayout::new(temp.path().to_path_buf()); + (temp, layout) +} + +#[test] +fn version_paths_follow_mojang_layout() { + let (_temp, layout) = layout(); + let root = layout.root(); + + assert_eq!(layout.versions_dir(), root.join("versions")); + assert_eq!(layout.version_dir("1.20.1"), root.join("versions/1.20.1")); + assert_eq!( + layout.version_json("1.20.1"), + root.join("versions/1.20.1/1.20.1.json") + ); + assert_eq!( + layout.version_jar("1.20.1"), + root.join("versions/1.20.1/1.20.1.jar") + ); + // ULL 扩展:实例私有状态与 natives 解压目录。 + assert_eq!( + layout.version_state_dir("1.20.1"), + root.join("versions/1.20.1/.ull") + ); + assert_eq!( + layout.natives_dir("1.20.1"), + root.join("versions/1.20.1/natives") + ); + assert_eq!( + layout.version_libraries_dir("1.20.1"), + root.join("versions/1.20.1/libraries") + ); + assert_eq!(layout.ull_root(), root.join(".ull")); +} + +#[test] +fn asset_paths_use_two_level_sharding() { + let (_temp, layout) = layout(); + let root = layout.root(); + + assert_eq!(layout.asset_index("5"), root.join("assets/indexes/5.json")); + let hash = "a3b3b1b0e0d8f2a8f0fbe1a4b0a8f6a1c6c6a7a2"; + assert_eq!( + layout.asset_object(hash).expect("哈希合法"), + root.join("assets/objects/a3").join(hash) + ); + assert_eq!( + layout.log_config("client-1.12.xml"), + root.join("assets/log_configs/client-1.12.xml") + ); + + let error = layout.asset_object("a").expect_err("短哈希应报错"); + assert!(matches!(error, GameError::InvalidAssetHash { .. })); +} + +#[test] +fn maven_coordinates_parse_and_lay_out() { + let coordinate = MavenCoordinate::parse("org.lwjgl:lwjgl:3.3.2").expect("坐标应可解析"); + assert_eq!(coordinate.group, "org.lwjgl"); + assert_eq!(coordinate.group_path(), "org/lwjgl"); + assert_eq!(coordinate.file_name(), "lwjgl-3.3.2.jar"); + assert_eq!( + coordinate.relative_path(), + PathBuf::from("org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2.jar") + ); + + let natives = + MavenCoordinate::parse("org.lwjgl:lwjgl:3.3.2:natives-windows-64").expect("带分类器"); + assert_eq!( + natives.relative_path(), + PathBuf::from("org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-64.jar") + ); + + let non_jar = MavenCoordinate::parse("net.java:jinput:1.0.0@zip").expect("带扩展名"); + assert_eq!(non_jar.file_name(), "jinput-1.0.0.zip"); +} + +#[test] +fn invalid_coordinates_are_rejected() { + for raw in ["a:b", "a::1", "a:b:c:d:e"] { + let error = MavenCoordinate::parse(raw).expect_err("不合法坐标应报错"); + assert!( + matches!(error, GameError::InvalidCoordinate { .. }), + "{raw}" + ); + } +} + +#[test] +fn library_paths_land_under_libraries() { + let (_temp, layout) = layout(); + let root = layout.root(); + let coordinate = MavenCoordinate::parse("com.mojang:logging:1.1.1").expect("坐标应可解析"); + + assert_eq!( + layout.library(&coordinate), + root.join("libraries/com/mojang/logging/1.1.1/logging-1.1.1.jar") + ); + assert_eq!( + layout.version_library("1.20.1", &coordinate), + root.join("versions/1.20.1/libraries/com/mojang/logging/1.1.1/logging-1.1.1.jar") + ); +} + +#[test] +fn installed_versions_requires_manifest_file() { + let (_temp, layout) = layout(); + + assert!( + layout + .installed_versions() + .expect("缺目录视为空") + .is_empty(), + "目录不存在时不能报错" + ); + + let version_dir = layout.version_dir("1.20.1"); + std::fs::create_dir_all(&version_dir).expect("建目录"); + std::fs::write(layout.version_json("1.20.1"), "{}").expect("写清单"); + // 只有 jar 没有 json 的目录不算已安装。 + let broken = layout.version_dir("1.19.4"); + std::fs::create_dir_all(&broken).expect("建目录"); + std::fs::write(layout.version_jar("1.19.4"), b"jar").expect("写 jar"); + + assert!(layout.is_version_installed("1.20.1")); + assert!(!layout.is_version_installed("1.19.4")); + assert_eq!( + layout.installed_versions().expect("应可枚举"), + vec!["1.20.1".to_owned()] + ); +} diff --git a/crates/core/tests/game/main.rs b/crates/core/tests/game/main.rs new file mode 100644 index 0000000..25cc1de --- /dev/null +++ b/crates/core/tests/game/main.rs @@ -0,0 +1,30 @@ +//! `core::game` 的集成测试入口(阶段 A:先写好,mod.rs 接线在阶段 B 完成)。 +//! +//! 每个子模块对应 mission 清单里的一项: +//! - `manifest_parse`:版本 JSON 解析与保真 +//! - `inherit_chain`:inheritsFrom 解析 / 环检测 / 缺失父 / merge 语义 / patch 优先级 +//! - `arguments_rules`:规则求值、参数展开、natives 选择 +//! - `version_list`:清单 v2 解析、分类、排序、unlisted 合并 +//! - `layout`:`.minecraft` 目录布局 +//! - `snapshot`:不可变快照 + 单写者草稿 +//! - `profiles`:实例清单与稳定 UUID +//! - `vulkan_capability`:版本库表 → `has_vulkan_backend` 能力位 + +mod arguments_rules; +mod helpers; +mod inherit_chain; +mod isolation; +mod isolation_switch; +mod java_discover; +mod java_install; +mod java_location; +mod java_mapping; +mod java_mirrors; +mod java_vendor_chain; +mod layout; +mod manifest_parse; +mod profiles; +mod snapshot; +mod unlisted; +mod version_list; +mod vulkan_capability; diff --git a/crates/core/tests/game/manifest_parse.rs b/crates/core/tests/game/manifest_parse.rs new file mode 100644 index 0000000..83610fa --- /dev/null +++ b/crates/core/tests/game/manifest_parse.rs @@ -0,0 +1,186 @@ +//! 版本 JSON 解析:宽松解析、未知字段保真、旧版字段并存。 + +use unlucky_core::game::arguments::{Argument, ArgumentValue, RuleAction}; +use unlucky_core::game::manifest::VersionJson; +use unlucky_core::game::version::ReleaseType; + +use crate::helpers::parse; + +const VANILLA_1_20_1: &str = r#"{ + "id": "1.20.1", + "type": "release", + "time": "2023-06-12T13:25:51+00:00", + "releaseTime": "2023-06-12T13:25:51+00:00", + "complianceLevel": 1, + "minimumLauncherVersion": 21, + "mainClass": "net.minecraft.client.main.Main", + "assets": "5", + "assetIndex": { + "id": "5", + "sha1": "a3b3b1b0e0d8f2a8f0fbe1a4b0a8f6a1c6c6a7a2", + "size": 404297, + "totalSize": 598385820, + "url": "https://piston-meta.mojang.com/v1/packages/sha/5.json" + }, + "javaVersion": { "component": "java-runtime-gamma", "majorVersion": 17 }, + "downloads": { + "client": { "sha1": "abc", "size": 100, "url": "https://example.invalid/client.jar" }, + "client_mappings": { "sha1": "def", "size": 50, "url": "https://example.invalid/mappings.txt" } + }, + "arguments": { + "game": [ + "--username", + "${auth_player_name}", + { "rules": [{ "action": "allow", "features": { "has_custom_resolution": true } }], + "value": ["--width", "${resolution_width}"] } + ], + "jvm": [ + { "rules": [{ "action": "allow", "os": { "name": "windows", "version": "^10\\." } }], + "value": "-Dos.name=Windows 10" } + ] + }, + "libraries": [ + { "name": "com.mojang:logging:1.1.1", + "downloads": { "artifact": { "path": "com/mojang/logging/1.1.1/logging-1.1.1.jar", + "sha1": "aaa", "size": 10, + "url": "https://example.invalid/logging.jar" } } } + ], + "logging": { + "client": { "argument": "-Dlog4j.configurationFile=${path}", + "file": { "id": "client-1.12.xml", "sha1": "bbb", "size": 20, + "url": "https://example.invalid/log4j.xml" }, + "type": "log4j2-xml" } + }, + "tlauncherVersion": 1, + "MMC-hint": "local" +}"#; + +#[test] +fn parses_full_version_json() { + let version = parse(VANILLA_1_20_1); + + assert_eq!(version.id.as_deref(), Some("1.20.1")); + assert_eq!(version.version_type, Some(ReleaseType::Release)); + assert_eq!( + version.main_class.as_deref(), + Some("net.minecraft.client.main.Main") + ); + assert_eq!(version.compliance_level, Some(1)); + assert_eq!(version.minimum_launcher_version, Some(21)); + assert_eq!(version.assets_id(), "5"); + + let asset_index = version.asset_index.as_ref().expect("assetIndex 应解析"); + assert_eq!(asset_index.id, "5"); + assert_eq!(asset_index.total_size, Some(598_385_820)); + + let java = version.java_version.as_ref().expect("javaVersion 应解析"); + assert_eq!(java.component.as_deref(), Some("java-runtime-gamma")); + assert_eq!(java.major_version, Some(17)); + + let downloads = version.downloads.as_ref().expect("downloads 应解析"); + assert_eq!(downloads.len(), 2, "未知键也要保留在映射里"); + assert_eq!( + downloads.client().and_then(|c| c.url.as_deref()), + Some("https://example.invalid/client.jar") + ); + assert!(downloads.get("client_mappings").is_some()); +} + +#[test] +fn keeps_unknown_fields_verbatim() { + let version = parse(VANILLA_1_20_1); + // 第三方启动器私有字段(rawJson 保真,analyse/03 §4.2)。 + assert_eq!( + version.extra.get("tlauncherVersion"), + Some(&serde_json::json!(1)) + ); + assert_eq!( + version.extra.get("MMC-hint"), + Some(&serde_json::json!("local")) + ); + + let roundtrip = version.to_json_string().expect("应可序列化"); + let reparsed = VersionJson::from_json_str(&roundtrip).expect("应可再解析"); + assert_eq!(reparsed.extra, version.extra); +} + +#[test] +fn parses_arguments_with_rules() { + let version = parse(VANILLA_1_20_1); + let arguments = version.arguments.as_ref().expect("arguments 应解析"); + + assert_eq!(arguments.game.len(), 3); + assert_eq!(arguments.jvm.len(), 1); + assert!(matches!(&arguments.game[0], Argument::Plain(value) if value == "--username")); + + let conditional = match &arguments.game[2] { + Argument::Conditional(cond) => cond, + Argument::Plain(_) => panic!("第三项应是条件参数"), + }; + assert_eq!(conditional.rules.len(), 1); + assert_eq!(conditional.rules[0].action, RuleAction::Allow); + assert_eq!( + conditional.rules[0] + .features + .as_ref() + .and_then(|f| f.get("has_custom_resolution")), + Some(&true) + ); + assert_eq!( + conditional.value, + ArgumentValue::Many(vec!["--width".into(), "${resolution_width}".into()]) + ); + + // jvm 里的 os 限制带正则版本模式。 + let jvm = match &arguments.jvm[0] { + Argument::Conditional(cond) => cond, + Argument::Plain(_) => panic!("jvm 第一项应是条件参数"), + }; + let os = jvm.rules[0].os.as_ref().expect("应有 os 限制"); + assert_eq!(os.name.as_deref(), Some("windows")); + assert_eq!(os.version.as_deref(), Some("^10\\.")); +} + +#[test] +fn parses_libraries_and_logging() { + let version = parse(VANILLA_1_20_1); + let libraries = version.libraries.as_ref().expect("libraries 应解析"); + assert_eq!(libraries.len(), 1); + let artifact = libraries[0].artifact().expect("应有 artifact"); + assert_eq!( + artifact.path.as_deref(), + Some("com/mojang/logging/1.1.1/logging-1.1.1.jar") + ); + + let logging = version.logging.as_ref().expect("logging 应解析"); + let client = logging.client().expect("应有 client 日志配置"); + assert_eq!(client.kind.as_deref(), Some("log4j2-xml")); + assert_eq!(client.file.id.as_deref(), Some("client-1.12.xml")); +} + +#[test] +fn tolerant_defaults_for_minimal_json() { + let version = parse(r#"{ "id": "b1.7.3" }"#); + assert_eq!(version.id.as_deref(), Some("b1.7.3")); + assert_eq!(version.version_type, None, "未知 type 不报错"); + assert_eq!(version.client_jar(), None, "老 JSON 没有 downloads.client"); + assert_eq!(version.assets_id(), "legacy", "assets 缺失按 legacy"); + assert!(!version.is_resolved()); +} + +#[test] +fn unknown_type_value_falls_back_to_unknown() { + let version = parse(r#"{ "id": "x", "type": "some_new_kind" }"#); + assert_eq!(version.version_type, Some(ReleaseType::Unknown)); +} + +#[test] +fn missing_id_is_reported() { + let version = parse(r#"{ "mainClass": "a.B" }"#); + let error = version.require_id().expect_err("缺 id 应报错"); + assert_eq!( + error.to_string(), + "版本 JSON 缺少 id", + "错误文案要给用户看得懂" + ); +} diff --git a/crates/core/tests/game/profiles.rs b/crates/core/tests/game/profiles.rs new file mode 100644 index 0000000..76e0fa0 --- /dev/null +++ b/crates/core/tests/game/profiles.rs @@ -0,0 +1,109 @@ +//! 实例清单:稳定 UUID、增删查、落盘格式兼容。 + +use unlucky_core::game::profiles::{ + InstanceProfile, ProfileId, ProfileList, PROFILE_SCHEMA_VERSION, +}; + +#[test] +fn generated_ids_are_unique_and_uuid_shaped() { + let ids: Vec = (0..256).map(|_| ProfileId::generate()).collect(); + for id in &ids { + assert!(id.is_uuid_shaped(), "id 形状不对: {id}"); + } + let mut unique: Vec<&str> = ids.iter().map(ProfileId::as_str).collect(); + let total = unique.len(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!(unique.len(), total, "同一批生成的 id 不能重复"); +} + +#[test] +fn id_shape_validation_rejects_junk() { + assert!(!ProfileId::new("not-a-uuid").is_uuid_shaped()); + assert!(!ProfileId::new("").is_uuid_shaped()); + assert!(ProfileId::new("7c9e6679-7425-40de-944b-e07fc1f90ae7").is_uuid_shaped()); +} + +#[test] +fn upsert_and_remove_keep_one_entry_per_id() { + let mut list = ProfileList::default(); + assert_eq!(list.schema_version, PROFILE_SCHEMA_VERSION); + assert!(list.is_empty()); + + let mut profile = InstanceProfile::new("主世界", "1.20.1"); + assert_eq!(list.upsert(profile.clone()), None, "首次插入没有旧值"); + assert_eq!(list.len(), 1); + + profile.name = "主世界(Fabric)".to_owned(); + let replaced = list.upsert(profile.clone()).expect("同 id 应替换"); + assert_eq!(replaced.name, "主世界"); + assert_eq!(list.len(), 1); + assert_eq!( + list.get(&profile.id).map(|p| p.name.as_str()), + Some("主世界(Fabric)") + ); + + let removed = list.remove(&profile.id).expect("应可删除"); + assert_eq!(removed.id, profile.id); + assert!(list.is_empty()); + assert!(list.remove(&profile.id).is_none()); +} + +#[test] +fn profiles_group_by_version() { + let mut list = ProfileList::default(); + list.upsert(InstanceProfile::new("a", "1.20.1")); + list.upsert(InstanceProfile::new("b", "1.20.1")); + list.upsert(InstanceProfile::new("c", "1.19.4")); + + assert_eq!(list.for_version("1.20.1").len(), 2); + assert_eq!(list.for_version("1.19.4").len(), 1); + assert!(list.for_version("1.18.2").is_empty()); +} + +#[test] +fn roundtrips_profiles_json() { + let mut list = ProfileList::default(); + let mut profile = InstanceProfile::new("整合包", "1.20.1-forge-47.2.0"); + profile.running_dir = Some(std::path::PathBuf::from("D:/mc/runs/pack")); + profile + .extra + .insert("thirdPartyFlag".to_owned(), serde_json::json!(true)); + list.upsert(profile.clone()); + + let json = list.to_json_string().expect("应可序列化"); + let reparsed = ProfileList::from_json_str(&json).expect("应可再解析"); + assert_eq!(reparsed, list); + assert_eq!(reparsed.schema_version, PROFILE_SCHEMA_VERSION); +} + +#[test] +fn reads_minimal_profile_file() { + // 用户手改/旧版本写下的最小档案:只有三个必填语义字段,其它缺省。 + let list = ProfileList::from_json_str( + r#"{ "profiles": [ { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "name": "旧档案", "version_id": "1.12.2" } ] }"#, + ) + .expect("最小档案应可解析"); + + assert_eq!( + list.schema_version, PROFILE_SCHEMA_VERSION, + "schemaVersion 缺省补齐" + ); + assert_eq!(list.len(), 1); + let profile = &list.profiles[0]; + assert_eq!(profile.name, "旧档案"); + assert!(profile.running_dir.is_none()); + assert!(profile.id.is_uuid_shaped()); +} + +#[test] +fn invalid_profiles_json_reports_context() { + let error = ProfileList::from_json_str("{").expect_err("非法 JSON 应报错"); + match error { + unlucky_core::game::error::GameError::Json { context, .. } => { + assert_eq!(context, "profiles") + } + other => panic!("期望 Json 错误,得到 {other:?}"), + } +} diff --git a/crates/core/tests/game/snapshot.rs b/crates/core/tests/game/snapshot.rs new file mode 100644 index 0000000..b8876cc --- /dev/null +++ b/crates/core/tests/game/snapshot.rs @@ -0,0 +1,75 @@ +//! 快照仓库:不可变读、单写者草稿、发布即指针交换。 + +use std::sync::Arc; + +use unlucky_core::game::error::GameError; +use unlucky_core::game::snapshot::{Snapshot, Store}; + +#[test] +fn snapshot_survives_draft_publish() { + let store = Store::new(vec!["1.20.1".to_owned()]); + let before = store.snapshot(); + + let mut draft = store.open_draft().expect("首个草稿应可打开"); + draft.edit().push("1.20.2".to_owned()); + let published = draft.commit(); + + // 旧快照不变(读端遍历时不会看到半新半旧的状态)。 + assert_eq!(before.get().len(), 1); + assert_eq!(published.get().len(), 2); + assert_eq!(store.snapshot().get().len(), 2); +} + +#[test] +fn only_one_draft_at_a_time() { + let store = Store::new(0u32); + let draft = store.open_draft().expect("首个草稿应可打开"); + + match store.open_draft() { + Err(GameError::DraftBusy) => {} + other => panic!("第二个草稿必须被拒绝,得到 {other:?}"), + } + + drop(draft); + let reopened = store.open_draft(); + assert!(reopened.is_ok(), "放弃草稿后写权必须释放"); +} + +#[test] +fn dropping_draft_discards_changes() { + let store = Store::new(vec![1u32]); + { + let mut draft = store.open_draft().expect("草稿应可打开"); + draft.edit().push(2); + } + assert_eq!(store.snapshot().get(), &vec![1u32]); +} + +#[test] +fn replace_publishes_whole_table() { + let store = Store::new(vec!["old".to_owned()]); + let snapshot = store.replace(vec!["new".to_owned()]).expect("替换应成功"); + assert_eq!(snapshot.get(), &vec!["new".to_owned()]); + assert_eq!(store.snapshot().get(), &vec!["new".to_owned()]); +} + +#[test] +fn clones_share_one_allocation() { + let store = Store::new(vec![1u32]); + let first = store.snapshot(); + let second = first.clone(); + + assert!(Arc::ptr_eq(&first.shared(), &second.shared())); + assert_eq!(first, second); + + let published = store.replace(vec![2u32]).expect("替换应成功"); + assert!(!Arc::ptr_eq(&first.shared(), &published.shared())); + assert_eq!(first.get(), &vec![1u32], "旧快照保持旧内容"); +} + +#[test] +fn snapshot_new_wraps_value() { + let snapshot = Snapshot::new("payload".to_owned()); + assert_eq!(snapshot.get(), "payload"); + assert_eq!(snapshot.clone().get(), "payload"); +} diff --git a/crates/core/tests/game/unlisted.rs b/crates/core/tests/game/unlisted.rs new file mode 100644 index 0000000..e891d29 --- /dev/null +++ b/crates/core/tests/game/unlisted.rs @@ -0,0 +1,90 @@ +//! 内嵌补充清单 `unlisted-versions.json`:条目数、分类分布、合并规则。 +//! +//! 这些断言同时是**数据完整性守门**:内嵌数据换版本时如果条目数/分类崩了, +//! 这里先红,而不是等用户在旧版列表里少看到几十个版本。 + +use unlucky_core::game::unlisted::{self, EMBEDDED_VERSION_COUNT}; +use unlucky_core::game::version::{ReleaseType, RemoteVersion, VersionCategory, VersionList}; + +#[test] +fn embedded_list_has_expected_shape() { + let embedded = unlisted::versions(); + assert_eq!(embedded.len(), EMBEDDED_VERSION_COUNT); + assert_eq!( + embedded.versions.len(), + unlisted::entries().len(), + "借用视图与清单一致" + ); + assert!(embedded.latest.is_none(), "补充清单没有 latest 对象"); + + for entry in unlisted::entries() { + assert!(!entry.id.is_empty()); + assert!(!entry.url.is_empty(), "每条都要有下载地址: {}", entry.id); + assert_ne!( + entry.version_type, + ReleaseType::Unknown, + "内嵌数据的 type 必须能归类: {}", + entry.id + ); + } +} + +#[test] +fn embedded_categories_match_analysis() { + // analyse/03 §2.3 实测口径:old_alpha 88 / old_beta 16 / snapshot 70 / + // pending 20 / unobfuscated 11。 + let counts = |category: VersionCategory| { + unlisted::entries() + .iter() + .filter(|v| v.version_type.category() == category) + .count() + }; + assert_eq!(counts(VersionCategory::Old), 88 + 16); + assert_eq!(counts(VersionCategory::Snapshot), 70); + assert_eq!(counts(VersionCategory::Pending), 20); + assert_eq!(counts(VersionCategory::Unobfuscated), 11); + assert_eq!(counts(VersionCategory::Release), 0); +} + +#[test] +fn embedded_ids_are_unique() { + let mut ids: Vec<&str> = unlisted::entries().iter().map(|v| v.id.as_str()).collect(); + let total = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), total, "同 id 重复会让合并结果不确定"); +} + +#[test] +fn merge_adds_missing_entries_only() { + let mut official = VersionList { + latest: None, + versions: vec![RemoteVersion { + id: "a1.1.1".to_owned(), + version_type: ReleaseType::OldAlpha, + url: "https://example.invalid/official-a1.1.1.json".to_owned(), + time: None, + release_time: None, + sha1: Some("official".to_owned()), + size: Some(1), + }], + }; + + let added = unlisted::merge_into(&mut official); + assert_eq!(added, EMBEDDED_VERSION_COUNT - 1, "只补官方缺的条目"); + assert_eq!(official.len(), EMBEDDED_VERSION_COUNT); + + // 同 id 冲突时官方条目胜出(我们与 HMCL 的差异,见 unlisted 模块注释)。 + assert_eq!( + official.find("a1.1.1").map(|v| v.url.as_str()), + Some("https://example.invalid/official-a1.1.1.json") + ); +} + +#[test] +fn merge_into_empty_list_is_identity() { + let mut empty = VersionList::default(); + assert!(empty.is_empty()); + assert_eq!(unlisted::merge_into(&mut empty), EMBEDDED_VERSION_COUNT); + assert_eq!(empty, *unlisted::versions()); +} diff --git a/crates/core/tests/game/version_list.rs b/crates/core/tests/game/version_list.rs new file mode 100644 index 0000000..15407b3 --- /dev/null +++ b/crates/core/tests/game/version_list.rs @@ -0,0 +1,137 @@ +//! 版本清单(`version_manifest_v2.json`):解析、分类、排序、与补充清单合并。 + +use unlucky_core::game::error::GameError; +use unlucky_core::game::version::{ReleaseType, RemoteVersion, VersionCategory, VersionList}; + +const MANIFEST_V2: &str = r#"{ + "latest": { "release": "1.20.1", "snapshot": "23w31a" }, + "versions": [ + { "id": "1.20.1", "type": "release", "url": "https://example.invalid/1.20.1.json", + "time": "2023-06-12T13:25:51+00:00", "releaseTime": "2023-06-12T13:25:51+00:00", + "sha1": "aaa", "size": 1000 }, + { "id": "23w31a", "type": "snapshot", "url": "https://example.invalid/23w31a.json", + "time": "2023-08-02T12:00:00+00:00", "releaseTime": "2023-08-02T12:00:00+00:00", + "sha1": "bbb", "size": 900 }, + { "id": "b1.7.3", "type": "old_beta", "url": "https://example.invalid/b1.7.3.json", + "time": "2011-07-08T00:00:00+00:00", "releaseTime": "2011-07-08T00:00:00+00:00" } + ] +}"#; + +fn manifest() -> VersionList { + VersionList::from_json_str(MANIFEST_V2).expect("清单应可解析") +} + +#[test] +fn parses_manifest_v2_with_checksums() { + let list = manifest(); + assert_eq!(list.len(), 3); + + let latest = list.latest.as_ref().expect("latest 应解析"); + assert_eq!(latest.release.as_deref(), Some("1.20.1")); + assert_eq!(latest.snapshot.as_deref(), Some("23w31a")); + + let release = list.find("1.20.1").expect("1.20.1 应存在"); + assert!(release.is_verifiable()); + assert_eq!(release.size, Some(1000)); + // 内嵌旧清单没有 sha1/size(v1 形态)。 + assert!(!list.find("b1.7.3").expect("b1.7.3 应存在").is_verifiable()); +} + +#[test] +fn classifies_release_types() { + assert_eq!( + ReleaseType::from_json_str("release").category(), + VersionCategory::Release + ); + assert_eq!( + ReleaseType::from_json_str("old_beta").category(), + VersionCategory::Old + ); + assert_eq!( + ReleaseType::from_json_str("old_alpha").category(), + VersionCategory::Old + ); + assert_eq!( + ReleaseType::from_json_str("pending").category(), + VersionCategory::Pending + ); + assert_eq!( + ReleaseType::from_json_str("unobfuscated").category(), + VersionCategory::Unobfuscated + ); + assert_eq!( + ReleaseType::from_json_str("未见过").category(), + VersionCategory::Uncategorized + ); + // 序列化回原始字符串,保证能原样写回清单。 + assert_eq!(ReleaseType::OldBeta.as_json_str(), "old_beta"); + assert_eq!(ReleaseType::Unknown.as_json_str(), "unknown"); +} + +#[test] +fn sorts_newest_first() { + let mut list = manifest(); + list.sort_newest_first(); + let ids: Vec<&str> = list.versions.iter().map(|v| v.id.as_str()).collect(); + assert_eq!(ids, vec!["23w31a", "1.20.1", "b1.7.3"]); +} + +#[test] +fn filters_by_ui_category() { + let list = manifest(); + assert_eq!(list.filter_category(VersionCategory::Release).len(), 1); + assert_eq!(list.filter_category(VersionCategory::Snapshot).len(), 1); + assert_eq!(list.filter_category(VersionCategory::Old).len(), 1); + assert!(list.filter_category(VersionCategory::Pending).is_empty()); +} + +#[test] +fn merging_keeps_existing_entries() { + let mut list = manifest(); + let added = list.merge_missing(vec![ + // 同 id 冲突:已有条目(官方)胜出,URL 不被覆盖。 + RemoteVersion { + id: "1.20.1".to_owned(), + version_type: ReleaseType::Release, + url: "https://mirror.invalid/1.20.1.json".to_owned(), + time: None, + release_time: None, + sha1: None, + size: None, + }, + RemoteVersion { + id: "a1.1.1".to_owned(), + version_type: ReleaseType::OldAlpha, + url: "https://mirror.invalid/a1.1.1.json".to_owned(), + time: None, + release_time: None, + sha1: None, + size: None, + }, + ]); + + assert_eq!(added, 1, "只应新增缺失条目"); + assert_eq!(list.len(), 4); + assert_eq!( + list.find("1.20.1").map(|v| v.url.as_str()), + Some("https://example.invalid/1.20.1.json") + ); + assert!(list.find("a1.1.1").is_some()); +} + +#[test] +fn roundtrips_manifest_json() { + let list = manifest(); + let json = list.to_json_string().expect("应可序列化"); + let reparsed = VersionList::from_json_str(&json).expect("应可再解析"); + assert_eq!(reparsed, list); +} + +#[test] +fn invalid_json_reports_context() { + let error = VersionList::from_json_str("not json").expect_err("非法 JSON 应报错"); + match error { + GameError::Json { context, .. } => assert_eq!(context, "version_manifest"), + other => panic!("期望 Json 错误,得到 {other:?}"), + } +} diff --git a/crates/core/tests/game/vulkan_capability.rs b/crates/core/tests/game/vulkan_capability.rs new file mode 100644 index 0000000..2f4fe6e --- /dev/null +++ b/crates/core/tests/game/vulkan_capability.rs @@ -0,0 +1,125 @@ +//! 版本能力位:从版本 JSON 的库表推 `has_vulkan_backend`。 +//! +//! 事实基线(`research/05-mc-vulkan.md` §2.2,piston-meta 921 个版本逐版扫过): +//! `org.lwjgl:lwjgl-vulkan` 从 **26.2-snapshot-1** 起出现,26.1 全线(含 `26w14a`)没有。 +//! 所以「库在不在」是判断版本带不带 Vulkan 后端的可靠依据。 + +use unlucky_core::game::capability::{ + is_vulkan_library, library_names_have_vulkan, VersionCapabilities, +}; +use unlucky_core::game::manifest::VersionJson; + +use crate::helpers::parse; + +/// 26.2-snapshot-1 的库表形态(Vulkan 三件套 + 常规库,实测结构)。 +const VULKAN_ERA_JSON: &str = r#"{ + "id": "26.2-snapshot-1", + "type": "snapshot", + "mainClass": "net.minecraft.client.main.Main", + "libraries": [ + { "name": "org.lwjgl:lwjgl:3.4.1", "downloads": { "artifact": { "path": "org/lwjgl/lwjgl/3.4.1/lwjgl-3.4.1.jar" } } }, + { "name": "org.lwjgl:lwjgl-vulkan:3.4.1", "downloads": { "artifact": { "path": "org/lwjgl/lwjgl-vulkan/3.4.1/lwjgl-vulkan-3.4.1.jar" } } }, + { "name": "org.lwjgl:lwjgl-vulkan:3.4.1:natives-macos" }, + { "name": "org.lwjgl:lwjgl-shaderc:3.4.1" }, + { "name": "com.mojang:brigadier:1.3.10" } + ] +}"#; + +/// 26.1(含 `26w14a` 周快照那一段)的库表:只有 opengl,没有 vulkan。 +const PRE_VULKAN_JSON: &str = r#"{ + "id": "26.1", + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "libraries": [ + { "name": "org.lwjgl:lwjgl:3.4.1" }, + { "name": "org.lwjgl:lwjgl-glfw:3.4.1" }, + { "name": "org.lwjgl:lwjgl-opengl:3.4.1" } + ] +}"#; + +#[test] +fn version_json_with_lwjgl_vulkan_carries_the_capability() { + let version = parse(VULKAN_ERA_JSON); + assert!(version.has_vulkan_backend()); + let capabilities = VersionCapabilities::from_json(&version); + assert!(capabilities.has_vulkan_backend()); +} + +#[test] +fn version_json_without_lwjgl_vulkan_lacks_the_capability() { + let version = parse(PRE_VULKAN_JSON); + assert!(!version.has_vulkan_backend()); + assert_eq!( + VersionCapabilities::from_json(&version), + VersionCapabilities::default() + ); +} + +#[test] +fn missing_libraries_field_is_not_a_capability() { + let version = parse(r#"{ "id": "custom", "mainClass": "a.B" }"#); + assert!(!version.has_vulkan_backend()); +} + +#[test] +fn coordinate_forms_are_all_recognised() { + // 主构件、带 classifier 的 natives、带 @ext 的写法都算命中(只比对 group:artifact)。 + assert!(is_vulkan_library("org.lwjgl:lwjgl-vulkan:3.4.3")); + assert!(is_vulkan_library( + "org.lwjgl:lwjgl-vulkan:3.4.3:natives-macos" + )); + assert!(is_vulkan_library("org.lwjgl:lwjgl-vulkan:3.4.3@jar")); + + // 相邻但不该命中的(前缀相同 / group 不同)。 + assert!(!is_vulkan_library("org.lwjgl:lwjgl-opengl:3.4.3")); + assert!(!is_vulkan_library("org.lwjgl:lwjgl:3.4.3")); + assert!(!is_vulkan_library("com.mojang:lwjgl-vulkan:1.0")); + assert!(!is_vulkan_library("lwjgl-vulkan")); + assert!(!is_vulkan_library("")); +} + +#[test] +fn library_name_slice_helper_matches_the_json_path() { + let names = ["org.lwjgl:lwjgl:3.4.1", "org.lwjgl:lwjgl-vulkan:3.4.1"]; + assert!(library_names_have_vulkan(names)); + assert!(!library_names_have_vulkan(["org.lwjgl:lwjgl:3.4.1"])); + assert!(!library_names_have_vulkan(std::iter::empty())); + + // 与 game 侧的库表判定同源:同一份 JSON 两条路径给同一结论。 + let version = parse(VULKAN_ERA_JSON); + assert_eq!( + library_names_have_vulkan( + version + .libraries + .as_deref() + .unwrap_or_default() + .iter() + .map(|l| l.name.as_str()) + ), + version.has_vulkan_backend() + ); +} + +#[test] +fn capability_bit_is_not_the_parameter_gate() { + // 26.2-snapshot-1 已经有库,却还没有 `--graphicsBackend`(实测差两天)。 + // 能力位是「必要条件」,下发门限另有精确版本门(见 launch 侧 + // `graphics_backend_allowed`),两者不能被压成一个判断。 + let snapshot_one = parse(VULKAN_ERA_JSON); + assert!( + snapshot_one.has_vulkan_backend(), + "26.2-snapshot-1 有 Vulkan 库" + ); + assert_eq!(snapshot_one.id.as_deref(), Some("26.2-snapshot-1")); +} + +#[test] +fn version_json_accessor_delegates_to_capability_module() { + for json in [VULKAN_ERA_JSON, PRE_VULKAN_JSON] { + let version: VersionJson = parse(json); + assert_eq!( + version.has_vulkan_backend(), + VersionCapabilities::from_json(&version).has_vulkan_backend() + ); + } +} diff --git a/crates/core/tests/launch/adapter_cases.rs b/crates/core/tests/launch/adapter_cases.rs new file mode 100644 index 0000000..ee7f73d --- /dev/null +++ b/crates/core/tests/launch/adapter_cases.rs @@ -0,0 +1,166 @@ +//! game 快照 → `LaunchRequest` 适配层测试(缺失库显式记录、features 派生)。 + +use crate::prelude::*; + +fn write_file(path: &std::path::Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建目录"); + } + std::fs::write(path, content).expect("写文件"); +} + +fn jvm_tokens() -> Vec { + vec![ + "-Djava.library.path=${natives_directory}".to_string(), + "-cp".to_string(), + "${classpath}".to_string(), + ] +} + +fn game_tokens() -> Vec { + vec![ + "--username".to_string(), + "${auth_player_name}".to_string(), + "--version".to_string(), + "${version_name}".to_string(), + ] +} + +#[test] +fn adapter_records_missing_libraries_and_fails_before_start() { + let dir = temp_dir("adapter-strict"); + let lib_ok = dir.join("libraries/a.jar"); + let lib_missing = dir.join("libraries/b.jar"); + let primary = dir.join("versions/1.20.1/1.20.1.jar"); + write_file(&lib_ok, "x"); + write_file(&primary, "x"); + let libraries = vec![ + LibraryInput::new(lib_ok.clone(), "org.example:a:1"), + LibraryInput::new(lib_missing.clone(), "org.example:b:2"), + ]; + let jvm = jvm_tokens(); + let game = game_tokens(); + let snapshot = SnapshotInput { + version_name: Some("1.20.1 Fabric"), + version_type: Some("release"), + game_version: Some("1.20.1"), + main_class: Some("net.fabricmc.loader.impl.launch.knot.KnotClient"), + jvm_arguments: &jvm, + game_arguments: &game, + libraries: &libraries, + primary_jar: primary.clone(), + game_dir: dir.clone(), + assets_index: Some("17"), + natives_dir: dir.join("versions/1.20.1/natives"), + library_dir: dir.join("libraries"), + assets_root: dir.join("assets"), + game_assets: dir.join("assets/virtual/legacy"), + ..SnapshotInput::minimal("1.20.1", "") + }; + let mut options = AdapterOptions::strict(); + options.java = JavaSelection::new("C:/java/bin/java.exe", 21, 64); + options.memory = MemoryOptions { + min_mb: Some(512), + max_mb: Some(2048), + }; + options.resolution = Some(Resolution { + width: 1280, + height: 720, + }); + + let output = build_request(&snapshot, options); + assert_eq!(output.classpath_entries, 2, "a.jar + 主 jar"); + assert_eq!(output.missing.len(), 1); + assert_eq!(output.missing[0].name, "org.example:b:2"); + assert!(output.warnings.iter().any(|warning| matches!( + warning, + AdapterWarning::MissingLibrary { name, .. } if name == "org.example:b:2" + ))); + // features 派生:给了分辨率就打开 has_custom_resolution;1.20.1 支持 QuickPlay。 + assert!(output.request.features.has_custom_resolution); + assert!(output.request.features.has_quick_plays_support); + assert_eq!(output.request.instance.name, "1.20.1 Fabric"); + assert_eq!( + output.request.manifest.main_class.as_deref(), + Some("net.fabricmc.loader.impl.launch.knot.KnotClient") + ); + + // 严格模式下缺失库 → 组装前显式失败(不再交给运行时 NoClassDefFoundError)。 + let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64"); + match assemble(&output.request, &ctx) { + Err(LaunchError::Assembly(AssemblyError::MissingComponents(items))) => { + assert_eq!(items.len(), 1); + } + other => panic!("应因缺组件失败,实际:{other:?}"), + } +} + +#[test] +fn adapter_tolerant_mode_skips_missing_libraries() { + let dir = temp_dir("adapter-tolerant"); + let lib_missing = dir.join("libraries/b.jar"); + let libraries = vec![LibraryInput::new(lib_missing.clone(), "org.example:b:2")]; + let jvm = jvm_tokens(); + let snapshot = SnapshotInput { + main_class: Some("net.minecraft.client.main.Main"), + jvm_arguments: &jvm, + libraries: &libraries, + primary_jar: dir.join("client.jar"), + game_dir: dir.clone(), + assets_index: Some("5"), + ..SnapshotInput::minimal("1.12.2", "") + }; + let mut options = AdapterOptions::strict(); + options.check_classpath = false; + options.java = JavaSelection::new("java", 8, 64); + + let output = build_request(&snapshot, options); + assert!( + !output.request.classpath.is_empty(), + "宽松模式仍保留库(交给启动期报错)" + ); + assert!(output.request.missing_components.is_empty()); + // 宽松模式不报缺失库(其它告警——如缺游戏参数——与本用例无关)。 + assert!(!output + .warnings + .iter() + .any(|warning| matches!(warning, AdapterWarning::MissingLibrary { .. }))); +} + +#[test] +fn adapter_warns_on_incomplete_manifest() { + let dir = temp_dir("adapter-incomplete"); + let snapshot = SnapshotInput { + main_class: None, + ..SnapshotInput::minimal("1.7.10", "") + }; + let output = build_request(&snapshot, AdapterOptions::strict()); + let warnings = &output.warnings; + assert!(warnings.contains(&AdapterWarning::MissingMainClass)); + assert!(warnings.contains(&AdapterWarning::MissingAssetsIndex)); + assert!(warnings.contains(&AdapterWarning::MissingGameArguments)); + // 1.7.10 < 1.20:不支持 QuickPlay。 + assert!(!output.request.features.has_quick_plays_support); + assert_eq!( + output.request.dirs.assets_index, "legacy", + "缺索引时兜底 legacy" + ); + let _ = dir; +} + +#[test] +fn adapter_legacy_arguments_are_passed_through() { + let dir = temp_dir("adapter-legacy"); + let snapshot = SnapshotInput { + main_class: Some("net.minecraft.launchwrapper.Launch"), + legacy_arguments: Some("--username ${auth_player_name} --gameDir ${game_directory}"), + game_dir: dir.clone(), + ..SnapshotInput::minimal("1.12.2", "") + }; + let output = build_request(&snapshot, AdapterOptions::strict()); + assert!(output.request.manifest.is_legacy()); + assert!(output.request.manifest.game.is_empty()); + assert!(!output + .warnings + .contains(&AdapterWarning::MissingGameArguments)); +} diff --git a/crates/core/tests/launch/cmdline_snapshot.rs b/crates/core/tests/launch/cmdline_snapshot.rs new file mode 100644 index 0000000..906649f --- /dev/null +++ b/crates/core/tests/launch/cmdline_snapshot.rs @@ -0,0 +1,338 @@ +//! 命令行组装快照测试(`analyse/02` §4.1 的 33 项顺序表 + §4.2 默认值抑制)。 +//! +//! 期望值是手工核对过的**完整 argv**:一旦组装顺序变了,这里必须一起改—— +//! 顺序本身有语义(后加的默认值会被先加的显式值抑制)。 + +use crate::prelude::*; + +#[test] +fn legacy_forge_full_argv() { + let (mut request, ctx) = legacy_forge_request(); + request.mod_loader = ModLoaderKind::Forge; + request.manifest.legacy_arguments = Some( + "--username ${auth_player_name} --version ${version_name} --gameDir ${game_directory} \ + --assetsDir ${assets_root} --assetIndex ${assets_index_name} --uuid ${auth_uuid} \ + --accessToken ${auth_session} --userType ${user_type} \ + --tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker" + .to_string(), + ); + // 用户指定 GBK → 日志泵也必须用 GBK 解码(`analyse/02` §4.4)。 + request.user.jvm = vec!["-Dfile.encoding=GBK".to_string()]; + + let command = assemble(&request, &ctx).expect("组装应成功"); + assert_eq!(command.encoding, "GBK"); + assert_eq!(command.main_class, "net.minecraft.launchwrapper.Launch"); + assert_eq!(command.classpath.len(), 3, "主 jar 必须追加在末位"); + + assert_argv( + &command, + &[ + "C:/java/bin/java.exe", + "-Xmx4096m", + "-Xms1024m", + "-XX:MetaspaceSize=64m", + "-Dfile.encoding=GBK", + "-Dsun.stdout.encoding=GBK", + "-Dsun.stderr.encoding=GBK", + "-Djava.rmi.server.useCodebaseOnly=true", + "-Dcom.sun.jndi.rmi.object.trustURLCodebase=false", + "-Dcom.sun.jndi.cosnaming.object.trustURLCodebase=false", + "-Dlog4j2.formatMsgNoLookups=true", + "-Dlog4j.configurationFile=C:/mc/log4j2.xml", + "-Dminecraft.client.jar=C:/mc/versions/1.12.2/1.12.2.jar", + "-Dfml.ignoreInvalidMinecraftCertificates=true", + "-Dfml.ignorePatchDiscrepancies=true", + "-Djava.library.path=C:/mc/versions/1.12.2/natives-windows-x86_64", + "-cp", + "C:/mc/libraries/a.jar;C:/mc/libraries/b.jar;C:/mc/versions/1.12.2/1.12.2.jar", + "net.minecraft.launchwrapper.Launch", + "--username", + "Steve", + "--version", + "1.12.2-Forge", + "--gameDir", + "C:/mc", + "--assetsDir", + "C:/mc/assets", + "--assetIndex", + "1.12", + "--uuid", + "0123456789abcdef0123456789abcdef", + "--accessToken", + "SECRET-TOKEN-123456", + "--userType", + "msa", + "--tweakClass", + "net.minecraftforge.fml.common.launcher.FMLTweaker", + "--width", + "854", + "--height", + "480", + ], + ); +} + +#[test] +fn modern_fabric_full_argv_with_rules() { + let (mut request, ctx) = legacy_forge_request(); + request.instance.name = "1.20.1-Fabric".to_string(); + request.instance.version = GameVersion::parse("1.20.1"); + request.manifest.main_class = + Some("net.fabricmc.loader.impl.launch.knot.KnotClient".to_string()); + request.manifest.legacy_arguments = None; + request.manifest.jvm = vec![ + ArgumentEntry::plain(["-Djava.library.path=${natives_directory}"]), + ArgumentEntry::plain(["-cp", "${classpath}"]), + ]; + request.manifest.game = vec![ + ArgumentEntry::plain(["--username", "${auth_player_name}"]), + ArgumentEntry::plain(["--version", "${version_name}"]), + ArgumentEntry::ruled( + vec![Rule::allow().with_os_name("windows")], + ["-Dos.windows=true"], + ), + ArgumentEntry::ruled(vec![Rule::allow().with_os_name("osx")], ["-Dos.macos=true"]), + ArgumentEntry::ruled( + vec![Rule::allow().with_feature("has_custom_resolution", true)], + [ + "--width", + "${resolution_width}", + "--height", + "${resolution_height}", + ], + ), + // 先 allow 后 disallow:按“最后一个命中的 action”生效(`analyse/02` §4.7)。 + ArgumentEntry::ruled( + vec![ + Rule::allow().with_os_name("windows"), + Rule::disallow().with_feature("is_demo_user", true), + ], + ["--windows-only"], + ), + ]; + request.resolution = Some(Resolution { + width: 1280, + height: 720, + }); + request.user.game = vec!["--fullscreen".to_string()]; + + let command = assemble(&request, &ctx).expect("组装应成功"); + // 主 jar 由组装器追加到最后,manifest 里的 `-cp` 只覆盖库。 + assert_eq!(command.classpath.len(), 3); + + assert_argv( + &command, + &[ + "C:/java/bin/java.exe", + "-Xmx4096m", + "-Xms1024m", + "-XX:MetaspaceSize=64m", + "-Dfile.encoding=UTF-8", + "-Dsun.stdout.encoding=UTF-8", + "-Dsun.stderr.encoding=UTF-8", + "-Djava.rmi.server.useCodebaseOnly=true", + "-Dcom.sun.jndi.rmi.object.trustURLCodebase=false", + "-Dcom.sun.jndi.cosnaming.object.trustURLCodebase=false", + "-Dlog4j2.formatMsgNoLookups=true", + "-Dlog4j.configurationFile=C:/mc/log4j2.xml", + "-Dminecraft.client.jar=C:/mc/versions/1.12.2/1.12.2.jar", + "-Djava.library.path=C:/mc/versions/1.12.2/natives-windows-x86_64", + "-cp", + "C:/mc/libraries/a.jar;C:/mc/libraries/b.jar;C:/mc/versions/1.12.2/1.12.2.jar", + "net.fabricmc.loader.impl.launch.knot.KnotClient", + "--username", + "Steve", + "--version", + "1.20.1-Fabric", + "-Dos.windows=true", + "--width", + "1280", + "--height", + "720", + "--windows-only", + "--fullscreen", + ], + ); + assert!(!command.args.iter().any(|arg| arg == "-Dos.macos=true")); +} + +#[test] +fn user_override_beats_defaults() { + let (mut request, ctx) = legacy_forge_request(); + request.java = JavaSelection::new("C:/java8/bin/java.exe", 8, 32); + request.user.override_jvm = vec!["-Xmx8G".to_string(), "-XX:+UseSerialGC".to_string()]; + request.user.jvm = vec!["-Xmx2G".to_string()]; + request.optimization = OptimizationOptions { + enabled: true, + physical_memory_mb: Some(8192), + }; + request.manifest.legacy_arguments = None; + + let command = assemble(&request, &ctx).expect("组装应成功"); + let args = command.args; + // override 的 -Xmx8G 生效,默认的 -Xmx4096m 被抑制。 + assert!(args.iter().any(|arg| arg == "-Xmx8G")); + assert!(!args.iter().any(|arg| arg == "-Xmx4096m")); + // 用户 JVM 参数里的 -Xmx2G 属于“默认值”,被 override 抑制。 + assert!(!args.iter().any(|arg| arg == "-Xmx2G")); + // 用户显式挑了 GC → 不注入 G1 整组,但诊断关键项照旧。 + assert!(args.iter().any(|arg| arg == "-XX:+UseSerialGC")); + assert!(!args.iter().any(|arg| arg == "-XX:+UseG1GC")); + assert!(args + .iter() + .any(|arg| arg == "-XX:-OmitStackTraceInFastThrow")); + // 32 位老 Java → PermSize,且不因内存充裕注入 CodeCache 组。 + assert!(args.iter().any(|arg| arg == "-XX:MetaspaceSize=64m")); + assert!(!args + .iter() + .any(|arg| arg.starts_with("-XX:ReservedCodeCacheSize"))); +} + +#[test] +fn java8_uses_legacy_encoding_keys_and_keeps_xincgc() { + let (mut request, ctx) = legacy_forge_request(); + request.java = JavaSelection::new("C:/java8/bin/java.exe", 8, 32); + request.user.jvm = vec!["-Xincgc".to_string()]; + request.manifest.legacy_arguments = None; + let command = assemble(&request, &ctx).expect("组装应成功"); + assert!(command + .args + .iter() + .any(|arg| arg == "-Dsun.stdout.encoding=UTF-8")); + assert!(!command + .args + .iter() + .any(|arg| arg == "-Dstdout.encoding=UTF-8")); + // Java 8 上 -Xincgc 仍合法(只在 ≥9 上被禁,`analyse/02` §4.1 #33)。 + assert!(command.args.iter().any(|arg| arg == "-Xincgc")); + + let mut modern = request.clone(); + modern.java = JavaSelection::new("C:/java21/bin/java.exe", 21, 64); + let command = assemble(&modern, &ctx).expect("组装应成功"); + assert!(command + .args + .iter() + .any(|arg| arg == "-Dstdout.encoding=UTF-8")); + assert!( + !command.args.iter().any(|arg| arg == "-Xincgc"), + "Java ≥9 必须删掉 -Xincgc" + ); +} + +#[test] +fn quickplay_and_graphics_backend_are_version_gated() { + let (mut request, ctx) = legacy_forge_request(); + request.manifest.legacy_arguments = None; + request.quick_play = Some(QuickPlay::Multiplayer { + address: "mc.example.com:25565".to_string(), + }); + request.graphics_backend = Some("vulkan".to_string()); + + // 1.12.2:QuickPlay 退化成 --server/--port,--graphicsBackend 丢弃。 + let legacy = assemble(&request, &ctx).expect("组装应成功"); + let server = legacy + .args + .windows(2) + .any(|pair| pair[0] == "--server" && pair[1] == "mc.example.com"); + assert!(server, "老版本应退化为 --server"); + assert!(legacy + .args + .windows(2) + .any(|pair| pair[0] == "--port" && pair[1] == "25565")); + assert!(!legacy.args.iter().any(|arg| arg == "--graphicsBackend")); + assert!(legacy + .warnings + .contains(&AssemblyWarning::QuickPlayDegraded { + address: "mc.example.com:25565".to_string() + })); + assert!(legacy + .warnings + .iter() + .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. }))); + + // 26.3:QuickPlay 直用,--graphicsBackend 允许。 + let mut modern = request.clone(); + modern.instance.version = GameVersion::parse("26.3"); + let modern = assemble(&modern, &ctx).expect("组装应成功"); + assert!(modern + .args + .windows(2) + .any(|pair| pair[0] == "--quickPlayMultiplayer" && pair[1] == "mc.example.com:25565")); + assert!(modern + .args + .windows(2) + .any(|pair| pair[0] == "--graphicsBackend" && pair[1] == "vulkan")); +} + +#[test] +fn assembly_fails_fast_on_missing_components() { + let (mut request, ctx) = legacy_forge_request(); + request.missing_components = vec![ + MissingComponent::named("org.lwjgl:lwjgl:3.3.1"), + MissingComponent::at_path("net.minecraft:client", "C:/mc/versions/1.12.2/1.12.2.jar"), + ]; + let error = assemble(&request, &ctx).expect_err("缺组件必须启动前失败"); + match error { + LaunchError::Assembly(AssemblyError::MissingComponents(items)) => { + assert_eq!(items.len(), 2); + assert_eq!(items[0].name, "org.lwjgl:lwjgl:3.3.1"); + } + other => panic!("错误类型不对:{other}"), + } + + let (mut request, ctx) = legacy_forge_request(); + request.manifest.main_class = None; + assert!(matches!( + assemble(&request, &ctx), + Err(LaunchError::Assembly(AssemblyError::MissingMainClass)) + )); + + let (mut request, ctx) = legacy_forge_request(); + request.classpath.clear(); + request.dirs.primary_jar = std::path::PathBuf::new(); + assert!(matches!( + assemble(&request, &ctx), + Err(LaunchError::Assembly(AssemblyError::EmptyClasspath)) + )); +} + +#[test] +fn posix_gets_nice_and_colon_classpath() { + let mut ctx = PlatformCtx::new(OsFamily::Linux, "x86_64"); + ctx.home_dir = Some(std::path::PathBuf::from("/home/aska")); + let (mut request, _) = legacy_forge_request(); + request.nice = Some(5); + request.wrapper = vec!["prime-run".to_string()]; + request.manifest.legacy_arguments = None; + + let command = assemble(&request, &ctx).expect("组装应成功"); + assert_eq!(command.argv()[0], "nice"); + assert_eq!(command.argv()[1], "-n"); + assert_eq!(command.argv()[2], "5"); + assert_eq!(command.argv()[3], "prime-run"); + assert_eq!(command.argv()[4], "C:/java/bin/java.exe"); + assert!(command + .args + .iter() + .any(|arg| arg + == "C:/mc/libraries/a.jar:C:/mc/libraries/b.jar:C:/mc/versions/1.12.2/1.12.2.jar")); + // 非 Windows 才注入 -Duser.home。 + assert!(command + .args + .iter() + .any(|arg| arg.starts_with("-Duser.home="))); +} + +#[test] +fn placeholder_table_is_complete() { + let (request, ctx) = legacy_forge_request(); + let map = unlucky_core::launch::placeholders::PlaceholderMap::from_request(&request, &ctx); + for key in unlucky_core::launch::placeholders::PLACEHOLDER_KEYS { + assert!(map.get(key).is_some(), "占位符 ${key} 未登记"); + } + assert_eq!(map.get("primary_jar_name"), Some("1.12.2.jar")); + assert_eq!(map.get("version_name"), Some("1.12.2-Forge")); + // 未定义的占位符原样保留(便于发现清单漏项)。 + assert_eq!(map.render("${nope}"), "${nope}"); + assert_eq!(map.render("a${version_name}b"), "a1.12.2-Forgeb"); +} diff --git a/crates/core/tests/launch/command_builder_cases.rs b/crates/core/tests/launch/command_builder_cases.rs new file mode 100644 index 0000000..22934e8 --- /dev/null +++ b/crates/core/tests/launch/command_builder_cases.rs @@ -0,0 +1,148 @@ +//! `CommandBuilder` 默认值抑制语义测试(`analyse/02` §4.2)。 + +use crate::prelude::*; + +fn strings(items: &[&str]) -> Vec { + items.iter().map(|item| (*item).to_string()).collect() +} + +#[test] +fn add_default_suppresses_when_prefix_exists() { + let mut builder = CommandBuilder::new(); + builder.add("-Xmx8G"); + let existing = builder.add_default("-Xmx", "4096m"); + assert_eq!(existing.as_deref(), Some("-Xmx8G")); + assert_eq!(builder.args(), &strings(&["-Xmx8G"])); + + let added = builder.add_default("-Xms", "1024m"); + assert_eq!(added, None); + assert_eq!(builder.args(), &strings(&["-Xmx8G", "-Xms1024m"])); +} + +#[test] +fn unstable_flags_do_not_conflict() { + let mut builder = CommandBuilder::new(); + builder.add("-XX:-UseG1GC"); + // 用户已经用 `-XX:-UseG1GC` 关掉了 G1,就不再补 `-XX:+UseG1GC`。 + assert!(builder.add_unstable_flag("UseG1GC", true).is_some()); + assert!(!builder.contains_exact("-XX:+UseG1GC")); + + let mut builder = CommandBuilder::new(); + builder.add("-XX:MaxInlineLevel=20"); + assert!(builder + .add_unstable_default("MaxInlineLevel", "15") + .is_some()); + assert_eq!(builder.args(), &strings(&["-XX:MaxInlineLevel=20"])); +} + +#[test] +fn add_all_default_handles_each_shape() { + let mut builder = CommandBuilder::new(); + builder.add("-Dfile.encoding=GBK"); + builder.add("-XX:+UseSerialGC"); + builder.add("-Xmx8G"); + builder.add("-cp"); + builder.add("user-cp"); + + builder.add_all_default(&strings(&[ + "-Dfile.encoding=UTF-8", + "-Dsun.stdout.encoding=UTF-8", + "-XX:+UseG1GC", + "-XX:G1MixedGCCountTarget=5", + "-Xmx4096m", + "-Xss1m", + "-cp", + "default-cp", + "exact-arg", + ])); + + assert_eq!( + builder.args(), + &strings(&[ + "-Dfile.encoding=GBK", + "-XX:+UseSerialGC", + "-Xmx8G", + "-cp", + "user-cp", + "-Dsun.stdout.encoding=UTF-8", + "-XX:+UseG1GC", + "-XX:G1MixedGCCountTarget=5", + "-Xss1m", + "exact-arg", + ]) + ); +} + +#[test] +fn argfile_expansion_suppresses_later_defaults() { + let mut builder = CommandBuilder::new(); + builder.add_all_without_parsing_and_read_external( + &strings(&["@options.txt"]), + &|path: &str| { + if path == "options.txt" { + Some("-Xmx12G\n-Dfile.encoding=COMPAT\n".to_string()) + } else { + None + } + }, + ); + // @argfile 里的 -Xmx12G 已登记为外部参数,默认值必须被抑制。 + assert!(builder.add_default("-Xmx", "4096m").is_some()); + assert!(builder.add_default("-Dfile.encoding=", "UTF-8").is_some()); + // 外部参数只参与判重,不进 argv。 + assert!(!builder.contains_exact("-Xmx12G")); + assert_eq!(builder.args(), &strings(&["@options.txt"])); +} + +#[test] +fn tokenize_handles_quotes_and_escapes() { + assert_eq!( + command_builder::tokenize(r#"--username "a b" --path 'C:\x y' plain"#), + strings(&["--username", "a b", "--path", "C:\\x y", "plain"]) + ); + assert_eq!( + command_builder::tokenize(r#"-Dmsg="say \"hi\"""#), + strings(&[r#"-Dmsg=say "hi""#]) + ); + assert!(command_builder::tokenize(" ").is_empty()); +} + +#[test] +fn parse_argfile_ignores_comments_and_blank_lines() { + let contents = "# 注释\n-Xmx2G\n\n-Xms512m -Dfoo=bar\n"; + assert_eq!( + command_builder::parse_argfile(contents), + strings(&["-Xmx2G", "-Xms512m", "-Dfoo=bar"]) + ); +} + +#[test] +fn forbidden_args_are_removed_at_the_end() { + let mut builder = CommandBuilder::new(); + builder.add_all(&strings(&["-Xincgc", "-Xmx1G", "-Xincgc"])); + let removed = builder.remove_forbidden(&["-Xincgc"]); + assert_eq!(removed.len(), 2); + assert_eq!(builder.args(), &strings(&["-Xmx1G"])); +} + +#[test] +fn placeholders_are_rendered_on_add_but_not_on_raw_add() { + let mut map = unlucky_core::launch::placeholders::PlaceholderMap::new(); + map.insert("version_name", "1.20.1"); + let mut builder = CommandBuilder::with_placeholders(map); + builder.add("--version"); + builder.add("${version_name}"); + builder.add_without_parsing("--raw ${version_name}"); + assert_eq!( + builder.args(), + &strings(&["--version", "1.20.1", "--raw ${version_name}"]) + ); + // 人读形式给含空格的参数加引号(日志与快照测试用)。 + assert_eq!( + builder.display_args(), + "--version 1.20.1 \"--raw ${version_name}\"" + ); + let mut quoted = CommandBuilder::new(); + quoted.add("-Dmsg=hello world"); + assert_eq!(quoted.display_args(), "\"-Dmsg=hello world\""); +} diff --git a/crates/core/tests/launch/exit_cases.rs b/crates/core/tests/launch/exit_cases.rs new file mode 100644 index 0000000..c861b6b --- /dev/null +++ b/crates/core/tests/launch/exit_cases.rs @@ -0,0 +1,169 @@ +//! 退出分析测试:退出码语义 + `exit 0` 静默失败(`analyse/02` §6.3 §7.2 §7.10-10)。 + +use crate::prelude::*; + +fn line(stream: Stream, text: &str) -> LogLine { + LogLine::new(stream, text) +} + +fn analyze( + code: i32, + uptime_ms: u64, + lines: &[LogLine], + posix: bool, +) -> unlucky_core::launch::exit_analysis::ExitAnalysis { + let mut signals = exit_analysis::ExitSignals::new(code, uptime_ms, lines); + signals.is_posix = posix; + exit_analysis::analyze(&signals) +} + +#[test] +fn clean_long_run_is_normal() { + let lines = vec![line( + Stream::Stdout, + "[12:00:00] [Render thread/INFO]: started", + )]; + let analysis = analyze(0, 120_000, &lines, true); + assert_eq!(analysis.exit_type, ExitType::Normal); + assert!(analysis.silent_failure.is_none()); + assert!(!analysis.needs_diagnosis); +} + +#[test] +fn exit_zero_short_lived_is_silent_failure() { + let lines = vec![line(Stream::Stdout, "Stopping!")]; + let analysis = analyze(0, 8_000, &lines, true); + assert_eq!(analysis.exit_type, ExitType::Normal); + let silent = analysis.silent_failure.expect("应标记静默失败"); + assert!(silent.short_lived); + assert!(!silent.error_output); + assert!(analysis.needs_diagnosis, "静默失败必须进诊断流水线"); +} + +#[test] +fn exit_zero_with_error_output_is_silent_failure() { + let lines = vec![ + line(Stream::Stdout, "[12:00:00] [main/INFO]: loading"), + line( + Stream::Stderr, + "java.lang.RuntimeException: mixin apply failed", + ), + ]; + let analysis = analyze(0, 90_000, &lines, true); + assert_eq!(analysis.exit_type, ExitType::Normal); + let silent = analysis.silent_failure.expect("应标记静默失败"); + assert!(silent.error_output); + assert!(!silent.short_lived); + assert!(silent.describe().contains("错误特征")); + assert_eq!(analysis.error_lines, 1); +} + +#[test] +fn jvm_keywords_take_precedence() { + let lines = vec![line( + Stream::Stderr, + "Error occurred during initialization of VM", + )]; + let analysis = analyze(1, 500, &lines, true); + assert_eq!(analysis.exit_type, ExitType::JvmError); + assert_eq!(analysis.jvm_keywords.len(), 1); + assert!(analysis.needs_diagnosis); +} + +#[test] +fn nonzero_without_keywords_is_application_error() { + let lines = vec![line(Stream::Stdout, "[12:00:00] [main/INFO]: hi")]; + let analysis = analyze(1, 40_000, &lines, true); + assert_eq!(analysis.exit_type, ExitType::ApplicationError); + assert!(analysis.needs_diagnosis); +} + +#[test] +fn crash_keywords_promote_exit_zero() { + let lines = vec![line( + Stream::Stdout, + "[12:00:00] [Render thread/ERROR]: Crash report saved to ./crash-reports/crash-1.txt", + )]; + let analysis = analyze(0, 60_000, &lines, true); + assert_eq!(analysis.exit_type, ExitType::ApplicationError); + assert_eq!(analysis.crash_keywords.len(), 1); + assert!( + analysis.silent_failure.is_none(), + "已判异常就不再是静默失败" + ); +} + +#[test] +fn posix_signals_are_recognized() { + assert_eq!(analyze(137, 60_000, &[], true).exit_type, ExitType::SigKill); + assert_eq!(analyze(143, 60_000, &[], true).exit_type, ExitType::SigTerm); + // Windows 上 137 没有信号语义,按应用错误处理。 + assert_eq!( + analyze(137, 60_000, &[], false).exit_type, + ExitType::ApplicationError + ); +} + +#[test] +fn windows_ntstatus_is_explained() { + let analysis = analyze(-1_073_741_819, 60_000, &[], false); + assert_eq!(analysis.exit_type, ExitType::ApplicationError); + assert_eq!( + analysis.windows_status.as_deref(), + Some("0xC0000005 ACCESS_VIOLATION") + ); + + let dll = analyze(-1_073_741_515, 60_000, &[], false); + assert_eq!( + dll.windows_status.as_deref(), + Some("0xC0000135 DLL_NOT_FOUND") + ); + + // 0xC000013A = STATUS_CONTROL_C_EXIT(控制台 Ctrl+C / 关窗口),十进制 -1073741510。 + let control_c = analyze(-1_073_741_510, 60_000, &[], false); + assert_eq!( + control_c.windows_status.as_deref(), + Some("0xC000013A CONTROL_C_EXIT") + ); + // 回归:-1073741743 其实是 0xC0000051,不得被当成 CONTROL_C_EXIT。 + assert!(analyze(-1_073_741_743, 60_000, &[], false) + .windows_status + .is_none()); +} + +#[test] +fn killed_and_interrupted_override_exit_code() { + let mut signals = exit_analysis::ExitSignals::new(1, 5_000, &[]); + signals.killed = true; + assert_eq!(exit_analysis::analyze(&signals).exit_type, ExitType::Killed); + + let mut signals = exit_analysis::ExitSignals::new(1, 5_000, &[]); + signals.interrupted = true; + assert_eq!( + exit_analysis::analyze(&signals).exit_type, + ExitType::Interrupted + ); +} + +#[test] +fn exit_type_strings_match_contract() { + // 与 M15 刻意保持一致的字符串集合(`launch-接口约定.md` §3)。 + assert_eq!(ExitType::Normal.as_str(), "normal"); + assert_eq!(ExitType::ApplicationError.as_str(), "application_error"); + assert_eq!(ExitType::JvmError.as_str(), "jvm_error"); + assert_eq!(ExitType::SigKill.as_str(), "sigkill"); + assert_eq!(ExitType::SigTerm.as_str(), "sigterm"); + assert_eq!(ExitType::Killed.as_str(), "killed"); + assert_eq!(ExitType::Interrupted.as_str(), "interrupted"); + assert!(!ExitType::Normal.is_abnormal()); + assert!(ExitType::JvmError.is_abnormal()); +} + +#[test] +fn summary_is_human_readable() { + let analysis = analyze(-1_073_741_819, 61_500, &[], false); + let summary = analysis.summary(); + assert!(summary.contains("-1073741819")); + assert!(summary.contains("61.5s")); + assert!(summary.contains("ACCESS_VIOLATION")); +} diff --git a/crates/core/tests/launch/graphics_backend_cases.rs b/crates/core/tests/launch/graphics_backend_cases.rs new file mode 100644 index 0000000..d8c5759 --- /dev/null +++ b/crates/core/tests/launch/graphics_backend_cases.rs @@ -0,0 +1,147 @@ +//! `--graphicsBackend` 的两套判据(能力位 vs 精确版本门)与周快照放行。 +//! +//! 事实基线(`research/05-mc-vulkan.md` §3.2/§4.2,实测): +//! - `--graphicsBackend` 从 **26.2-snapshot-2**(2026-04-09)才有; +//! - `org.lwjgl:lwjgl-vulkan` 从 **26.2-snapshot-1**(2026-04-07)就有 —— 差两天; +//! - 周快照(`26w14a`)无法与 `26.2-snapshot-N` 排序,只能靠库表判。 +//! +//! 结论:能力位**不能**替代精确版本门,两者的分工是本文件的断言对象。 + +use unlucky_core::launch::game_args::{graphics_backend_allowed, GRAPHICS_BACKEND_MIN_VERSION}; + +use crate::prelude::*; + +#[test] +fn known_versions_use_the_exact_threshold_and_ignore_the_capability_bit() { + // 26.2-snapshot-1:有 Vulkan 库却没有参数,绝不能因为能力位放行 + // (jopt-simple 未开 allowsUnrecognizedOptions,未声明选项会让游戏直接起不来)。 + assert!(!graphics_backend_allowed( + &GameVersion::parse("26.2-snapshot-1"), + true + )); + assert!(!graphics_backend_allowed( + &GameVersion::parse("26.2-snapshot-1"), + false + )); + // 门限上的那一版起放行(能力位无关)。 + assert!(graphics_backend_allowed( + &GameVersion::parse(GRAPHICS_BACKEND_MIN_VERSION), + false + )); + assert!(graphics_backend_allowed(&GameVersion::parse("26.2"), false)); + assert!(graphics_backend_allowed(&GameVersion::parse("26.3"), false)); + // 可解析的老版本:能力位再真也不放行。 + assert!(!graphics_backend_allowed( + &GameVersion::parse("1.20.1"), + true + )); +} + +#[test] +fn weekly_snapshots_fall_back_to_the_capability_bit() { + let weekly = GameVersion::parse("26w14a"); + assert!(!weekly.is_known(), "周快照不做数字排序(不猜)"); + assert!(weekly.is_weekly_snapshot()); + + // 26.1 线(含周快照)没有 lwjgl-vulkan → 丢弃,和以前一样。 + assert!(!graphics_backend_allowed(&weekly, false)); + // 带 Vulkan 库的周快照(26.2 之后的周快照)→ 不再一律丢弃(这就是被修的误伤)。 + assert!(graphics_backend_allowed(&weekly, true)); + assert!(graphics_backend_allowed( + &GameVersion::parse("26w30a"), + true + )); +} + +#[test] +fn non_weekly_unknown_versions_are_still_dropped() { + // 第三方自定义版本串没有可依据的语义,能力位为真也不猜。 + let custom = GameVersion::parse("custom-build"); + assert!(!custom.is_known()); + assert!(!custom.is_weekly_snapshot()); + assert!(!graphics_backend_allowed(&custom, true)); + assert!(!graphics_backend_allowed(&GameVersion::default(), true)); +} + +#[test] +fn weekly_snapshot_shape_is_parsed_narrowly() { + for shape in ["20w14a", "26w14a", "26W14A", "12w01a"] { + assert!( + GameVersion::parse(shape).is_weekly_snapshot(), + "{shape} 应识别为周快照" + ); + } + for other in ["26.2-snapshot-1", "26w14", "26ww14a", "w14a", "26w14ab", ""] { + assert!( + !GameVersion::parse(other).is_weekly_snapshot(), + "{other} 不该被当成周快照" + ); + } +} + +#[test] +fn assemble_lets_a_vulkan_weekly_snapshot_through() { + let (request, ctx) = weekly_request("26w30a", true); + let assembled = assemble(&request, &ctx).expect("组装应成功"); + assert!( + assembled + .args + .windows(2) + .any(|pair| pair[0] == "--graphicsBackend" && pair[1] == "vulkan"), + "带 Vulkan 库的周快照应下发 --graphicsBackend:{:?}", + assembled.args + ); + assert!(!assembled + .warnings + .iter() + .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. }))); +} + +#[test] +fn assemble_still_drops_it_for_a_pre_vulkan_weekly_snapshot() { + let (request, ctx) = weekly_request("26w14a", false); + let assembled = assemble(&request, &ctx).expect("组装应成功"); + assert!(!assembled.args.iter().any(|arg| arg == "--graphicsBackend")); + assert!(assembled + .warnings + .iter() + .any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. }))); +} + +#[test] +fn adapter_derives_the_capability_bit_from_the_library_names() { + let dir = temp_dir("adapter-vulkan-capability"); + let with_vulkan = vec![ + LibraryInput::new(dir.join("libraries/lwjgl.jar"), "org.lwjgl:lwjgl:3.4.3"), + LibraryInput::new( + dir.join("libraries/lwjgl-vulkan.jar"), + "org.lwjgl:lwjgl-vulkan:3.4.3", + ), + ]; + let without_vulkan = vec![LibraryInput::new( + dir.join("libraries/lwjgl.jar"), + "org.lwjgl:lwjgl:3.4.3", + )]; + + for (libraries, expected) in [(&with_vulkan, true), (&without_vulkan, false)] { + let snapshot = SnapshotInput { + main_class: Some("net.minecraft.client.main.Main"), + libraries, + ..SnapshotInput::minimal("26.3", "") + }; + let output = build_request(&snapshot, AdapterOptions::strict()); + assert_eq!( + output.request.manifest.has_vulkan_backend, expected, + "库表 {libraries:?} 的能力位判定不对" + ); + } +} + +/// 一个只关心 `--graphicsBackend` 的请求(周快照版本 + 指定能力位)。 +fn weekly_request(version: &str, has_vulkan_backend: bool) -> (LaunchRequest, PlatformCtx) { + let (mut request, ctx) = legacy_forge_request(); + request.instance.version = GameVersion::parse(version); + request.manifest.has_vulkan_backend = has_vulkan_backend; + request.graphics_backend = Some("vulkan".to_string()); + (request, ctx) +} diff --git a/crates/core/tests/launch/main.rs b/crates/core/tests/launch/main.rs new file mode 100644 index 0000000..a4a2d7b --- /dev/null +++ b/crates/core/tests/launch/main.rs @@ -0,0 +1,15 @@ +//! launch 模块集成测试入口(`cargo test --test launch`)。 + +mod adapter_cases; +mod cmdline_snapshot; +mod command_builder_cases; +mod exit_cases; +mod graphics_backend_cases; +mod natives_args_cases; +mod natives_cases; +mod pipeline_cases; +mod prelude; +mod process_smoke; +mod pump_buffer; +mod session_events; +mod sink_cases; diff --git a/crates/core/tests/launch/natives_args_cases.rs b/crates/core/tests/launch/natives_args_cases.rs new file mode 100644 index 0000000..027345a --- /dev/null +++ b/crates/core/tests/launch/natives_args_cases.rs @@ -0,0 +1,136 @@ +//! natives 时代分界与参数接线:清单口径收集、`-Djava.library.path` 归属、1.19+ 四个目录属性。 +//! +//! 抽取行为本身在 `natives_cases.rs`;这里只验「参数从哪来、收集口径对不对」。 + +use unlucky_core::launch::natives; + +use crate::prelude::*; + +/// 参数接线:legacy 清单没有 `arguments.jvm`,`-Djava.library.path` 由启动器补且只补一次。 +#[test] +fn legacy_manifest_gets_single_java_library_path() { + let (request, ctx) = legacy_forge_request(); + let command = assemble(&request, &ctx).expect("组装应成功"); + let natives = request.dirs.natives_dir.display().to_string(); + let library_path: Vec<&String> = command + .args + .iter() + .filter(|arg| arg.starts_with("-Djava.library.path=")) + .collect(); + assert_eq!(library_path.len(), 1, "legacy 只该有一条:{library_path:?}"); + assert_eq!(library_path[0], &format!("-Djava.library.path={natives}")); +} + +/// 参数接线:1.13+ 清单自带 `-Djava.library.path=${natives_directory}`,启动器不得重复注入。 +#[test] +fn modern_manifest_keeps_its_own_library_path_once() { + let (mut request, ctx) = legacy_forge_request(); + let natives = request.dirs.natives_dir.display().to_string(); + request.manifest.jvm = vec![ + ArgumentEntry::plain(["-Djava.library.path=${natives_directory}".to_string()]), + ArgumentEntry::plain(["-cp".to_string(), "${classpath}".to_string()]), + ]; + request.manifest.legacy_arguments = None; + let command = assemble(&request, &ctx).expect("组装应成功"); + let library_path: Vec<&String> = command + .args + .iter() + .filter(|arg| arg.starts_with("-Djava.library.path=")) + .collect(); + assert_eq!(library_path.len(), 1, "清单 + 默认值不该叠加"); + assert_eq!(library_path[0], &format!("-Djava.library.path={natives}")); +} + +/// 参数接线:1.19+ 的四个目录属性都要渲染到同一目录(LWJGL 3.3 运行期往那里解压)。 +#[test] +fn classpath_native_era_renders_all_four_directory_properties() { + let (mut request, ctx) = legacy_forge_request(); + let natives = request.dirs.natives_dir.display().to_string(); + request.manifest.jvm = vec![ArgumentEntry::plain([ + "-Djava.library.path=${natives_directory}".to_string(), + "-Djna.tmpdir=${natives_directory}".to_string(), + "-Dorg.lwjgl.system.SharedLibraryExtractPath=${natives_directory}".to_string(), + "-Dio.netty.native.workdir=${natives_directory}".to_string(), + ])]; + request.manifest.legacy_arguments = None; + let command = assemble(&request, &ctx).expect("组装应成功"); + for key in [ + "-Djava.library.path=", + "-Djna.tmpdir=", + "-Dorg.lwjgl.system.SharedLibraryExtractPath=", + "-Dio.netty.native.workdir=", + ] { + assert!( + command + .args + .iter() + .any(|arg| arg == &format!("{key}{natives}")), + "缺少 {key}{natives}:{:?}", + command.args + ); + } +} + +/// 收集口径:`natives` 表按 OS 命中 → 分类器 jar;1.19+ 无表 → 空; +/// `hint=local` 走实例私有目录(HMCL `analyse/03` §6.8 口径)。 +/// +/// 注意:安装层当前的 natives 分类器分支**不认** `hint=local`(走共享 `libraries/`), +/// 所以这一条断言测的是 `collect` 的正确行为,不是「与安装层一致」——见 +/// `collect` 文档里的已知缺口说明。 +#[test] +fn collect_picks_classifier_jars_per_environment() { + use unlucky_core::game::{Environment, GameLayout, Library}; + + let libraries: Vec = serde_json::from_str( + r#"[ + {"name":"org.lwjgl:lwjgl:3.2.2", + "natives":{"linux":"natives-linux","windows":"natives-windows"}, + "extract":{"exclude":["META-INF/"]}, + "downloads":{"artifact":{"path":"org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2.jar","url":"https://libraries.minecraft.net/a.jar"}, + "classifiers":{"natives-windows":{"path":"org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar","url":"https://libraries.minecraft.net/b.jar"}}}}, + {"name":"org.lwjgl:lwjgl-tinyfd:3.2.2","natives":{"windows":"natives-windows"}, + "hint":"local", + "downloads":{"classifiers":{"natives-windows":{"path":"org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-windows.jar","url":"https://libraries.minecraft.net/c.jar"}}}}, + {"name":"org.lwjgl:lwjgl:3.3.3:natives-linux", + "rules":[{"action":"allow","os":{"name":"linux"}}], + "downloads":{"artifact":{"path":"org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux.jar","url":"https://libraries.minecraft.net/d.jar"}}} + ]"#, + ) + .expect("库 JSON 应可解析"); + let layout = GameLayout::new("C:/mc"); + let environment = Environment { + os: "windows".to_string(), + os_version: "10.0.19045".to_string(), + arch: "x86_64".to_string(), + ..Environment::default() + }; + + let natives = natives::collect(&layout, "1.16.5", &libraries, &environment); + assert_eq!(natives.len(), 2, "只该收 windows 分类器:{natives:?}"); + assert!( + natives[0] + .path + .ends_with("libraries/org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar"), + "{:?}", + natives[0].path + ); + assert_eq!(natives[0].name, "org.lwjgl:lwjgl:3.2.2:natives-windows"); + assert_eq!(natives[0].exclude, vec!["META-INF/".to_string()]); + // `hint=local`:按 HMCL 口径落实例私有目录(安装层尚未跟进,见函数文档)。 + assert!( + natives[1] + .path + .ends_with("versions/1.16.5/libraries/org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-windows.jar"), + "{:?}", + natives[1].path + ); + + // 1.19+ 形态(无 natives 表)→ 空表(natives jar 在 classpath 上)。 + let modern: Vec = serde_json::from_str( + r#"[{"name":"org.lwjgl:lwjgl:3.3.3:natives-windows", + "rules":[{"action":"allow","os":{"name":"windows"}}], + "downloads":{"artifact":{"path":"org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows.jar","url":"https://libraries.minecraft.net/e.jar"}}}]"#, + ) + .expect("库 JSON 应可解析"); + assert!(natives::collect(&layout, "1.20.1", &modern, &environment).is_empty()); +} diff --git a/crates/core/tests/launch/natives_cases.rs b/crates/core/tests/launch/natives_cases.rs new file mode 100644 index 0000000..d289cc2 --- /dev/null +++ b/crates/core/tests/launch/natives_cases.rs @@ -0,0 +1,281 @@ +//! natives 解压(`launch::natives`)的用例:解析正确性 / 排除项 / 幂等 / 时代参数 / 事件流。 +//! +//! 假 jar 用 `zip` 的 Stored 模式现造(不依赖真下载),真机验收另有人工步骤。 + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipWriter}; + +use unlucky_core::launch::natives::{self, NativeJar, NativesEra, NativesPlan, STAMP_FILE}; + +use crate::prelude::*; + +/// 造一个 natives jar:`entries` 是(条目名, 内容)。 +fn make_jar(path: &Path, entries: &[(&str, &[u8])]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建 jar 目录"); + } + let file = File::create(path).expect("建 jar"); + let mut writer = ZipWriter::new(file); + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + for (name, bytes) in entries { + writer.start_file(*name, options).expect("写条目"); + writer.write_all(bytes).expect("写条目内容"); + } + writer.finish().expect("收尾 jar"); +} + +/// 一份 1.16.5 风格的 natives jar 的条目集(原生库 + 该被排除的噪音)。 +fn lwjgl_jar_entries() -> Vec<(&'static str, &'static [u8])> { + vec![ + ("lwjgl.dll", b"DLL-BYTES".as_slice()), + ("OpenAL.dll", b"OPENAL-BYTES".as_slice()), + ("META-INF/MANIFEST.MF", b"manifest".as_slice()), + ("META-INF/LWJGL.SF", b"signature".as_slice()), + ("lwjgl.dll.sha1", b"deadbeef".as_slice()), + ("README.txt", b"noise".as_slice()), + ] +} + +fn plan(jars: Vec) -> NativesPlan { + NativesPlan::new(jars) +} + +#[test] +fn extracts_native_libraries_and_drops_noise() { + let root = temp_dir("natives-extract"); + let jar = root.join("libraries/lwjgl-natives-windows.jar"); + make_jar(&jar, &lwjgl_jar_entries()); + let target = root.join("versions/1.16.5/natives"); + + let report = + natives::extract(&target, &plan(vec![NativeJar::new(&jar, "lwjgl")])).expect("解压应成功"); + + assert_eq!(report.files, 2, "只该解出两个原生库:{report:?}"); + assert!(!report.up_to_date); + assert_eq!(report.jars, 1); + // META-INF/ 两条 + .sha1 + README.txt。 + assert_eq!(report.ignored, 4, "噪音条目数:{report:?}"); + assert_eq!( + std::fs::read(target.join("lwjgl.dll")).expect("读 dll"), + b"DLL-BYTES" + ); + assert!(target.join("OpenAL.dll").is_file()); + assert!(!target.join("META-INF").exists(), "META-INF/ 不该落地"); + assert!(!target.join("lwjgl.dll.sha1").exists(), "签名文件不该落地"); + assert!(!target.join("README.txt").exists(), "非原生库不该落地"); + assert!(target.join(STAMP_FILE).is_file(), "应写戳记"); +} + +#[test] +fn second_run_is_idempotent_and_source_change_reextracts() { + let root = temp_dir("natives-idempotent"); + let jar = root.join("natives-windows.jar"); + make_jar(&jar, &[("lwjgl.dll", b"V1".as_slice())]); + let target = root.join("natives"); + let extract_plan = plan(vec![NativeJar::new(&jar, "lwjgl")]); + + let first = natives::extract(&target, &extract_plan).expect("首次解压"); + assert_eq!(first.files, 1); + assert!(!first.up_to_date); + + // 第二次:戳记命中,零写盘(文件 mtime 不变)。 + let before = std::fs::metadata(target.join("lwjgl.dll")) + .expect("stat") + .modified() + .expect("mtime"); + let second = natives::extract(&target, &extract_plan).expect("二次解压"); + assert!(second.up_to_date, "应判定目录已就绪:{second:?}"); + assert_eq!(second.files, 0); + assert_eq!(second.skipped, 1); + let after = std::fs::metadata(target.join("lwjgl.dll")) + .expect("stat") + .modified() + .expect("mtime"); + assert_eq!(before, after, "幂等路径不该重写文件"); + + // 源 jar 变了(内容 + 长度都变):必须重解,且旧的原生库被清掉。 + make_jar(&jar, &[("lwjgl.dll", b"V2-LONGER".as_slice())]); + let third = natives::extract(&target, &extract_plan).expect("三次解压"); + assert!(!third.up_to_date, "源变了必须重解:{third:?}"); + assert_eq!( + std::fs::read(target.join("lwjgl.dll")).expect("读 dll"), + b"V2-LONGER" + ); +} + +#[test] +fn stale_native_libraries_are_cleaned_before_reextract() { + let root = temp_dir("natives-clean"); + let jar = root.join("natives-windows.jar"); + make_jar(&jar, &[("lwjgl.dll", b"NEW".as_slice())]); + let target = root.join("natives"); + std::fs::create_dir_all(&target).expect("建目录"); + // 上一代遗留(旧版本 / 旧 loader 解出来的),必须清掉,否则两代 dll 混在 java.library.path 里。 + std::fs::write(target.join("old-lwjgl.dll"), b"stale").expect("写旧 dll"); + std::fs::write(target.join("keep-me.txt"), b"user file").expect("写用户文件"); + + natives::extract(&target, &plan(vec![NativeJar::new(&jar, "lwjgl")])).expect("解压"); + + assert!(!target.join("old-lwjgl.dll").exists(), "旧原生库应被清理"); + assert!(target.join("keep-me.txt").is_file(), "非原生库文件不该被删"); + assert!(target.join("lwjgl.dll").is_file()); +} + +#[test] +fn empty_plan_still_creates_directory_for_1_19() { + // 1.19+ 没有 natives 分类器:目录仍要存在(SharedLibraryExtractPath 指向它)。 + let root = temp_dir("natives-empty"); + let target = root.join("versions/1.21.4/natives"); + let report = natives::extract(&target, &NativesPlan::default()).expect("建目录"); + assert!(target.is_dir()); + assert!(report.up_to_date); + assert_eq!(report.files, 0); + assert_eq!(report.jars, 0); + assert_eq!( + report.era, + NativesEra::ClasspathNative, + "默认时代(无 jar)" + ); +} + +#[test] +fn missing_directory_is_an_error_not_a_silent_pass() { + let plan = NativesPlan::new(vec![NativeJar::new("x.jar", "x")]); + let err = natives::extract(Path::new(""), &plan).expect_err("空目录必须报错"); + assert!(err.message().contains("natives 目录"), "{err}"); +} + +#[test] +fn broken_jar_reports_zip_error() { + let root = temp_dir("natives-broken"); + let jar = root.join("natives-broken.jar"); + std::fs::write(&jar, b"not a zip at all").expect("写坏 jar"); + let target = root.join("natives"); + let err = natives::extract(&target, &plan(vec![NativeJar::new(&jar, "broken")])) + .expect_err("坏 jar 必须报错"); + assert!(err.message().contains("zip"), "错误应点明 zip:{err}"); +} + +#[test] +fn era_detect_covers_three_generations() { + assert_eq!(NativesEra::detect(0, 3), NativesEra::Legacy); + assert_eq!(NativesEra::detect(9, 3), NativesEra::Lwjgl3); + assert_eq!(NativesEra::detect(9, 0), NativesEra::ClasspathNative); + assert_eq!(NativesEra::Legacy.as_str(), "legacy"); + assert_eq!(NativesEra::Lwjgl3.as_str(), "lwjgl3"); + assert_eq!(NativesEra::ClasspathNative.as_str(), "classpath_native"); +} + +/// 事件流:`natives_extracted` 落盘字段与接口约定一致(dir/files/skipped/custom)。 +#[test] +fn natives_extracted_event_carries_report_fields() { + let root = temp_dir("natives-event"); + let jar = root.join("natives-windows.jar"); + make_jar(&jar, &[("lwjgl.dll", b"DLL".as_slice())]); + let target = root.join("natives"); + let report = + natives::extract(&target, &plan(vec![NativeJar::new(&jar, "lwjgl")])).expect("解压"); + + let sessions = temp_sessions_root("natives-event-sessions"); + let mut writer = SessionWriter::create( + &sessions, + SessionMeta::new("inst", "1.16.5", "1.16.5"), + Redactor::new(), + ) + .expect("建会话"); + writer.natives_extracted(&report).expect("写事件"); + let path = writer.path().to_path_buf(); + writer.finish(EndReason::Exit).expect("收尾"); + + let events = read_events(&path); + let event = events + .iter() + .find(|event| event["kind"] == "natives_extracted") + .expect("应有 natives_extracted 事件"); + let payload = &event["payload"]; + assert_eq!(payload["files"], 1); + assert_eq!(payload["skipped"], 0); + assert_eq!(payload["custom"], false); + assert_eq!(payload["jars"], 1); + assert_eq!(payload["up_to_date"], false); + assert_eq!(payload["era"], "lwjgl3"); + assert!(payload["dir"] + .as_str() + .expect("dir 是字符串") + .ends_with("natives")); +} + +/// `--dry-run` 也做环境准备:第二次跑要看到 up_to_date(真机验收的判定依据)。 +#[test] +fn dry_run_prepares_natives_and_is_idempotent() { + let root = temp_dir("natives-dry-run"); + let jar = root.join("natives-windows.jar"); + make_jar(&jar, &[("lwjgl.dll", b"DLL".as_slice())]); + let natives_dir: PathBuf = root.join("versions/1.16.5/natives"); + + let (mut request, _) = legacy_forge_request(); + request.dirs.natives_dir = natives_dir.clone(); + request.natives = NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]); + request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string()); + + let sessions = temp_sessions_root("natives-dry-run-sessions"); + let launcher = Launcher::new(LauncherConfig::new(&sessions)); + + let first = launcher.dry_run(&request).expect("首次 dry-run"); + let report = first.natives.expect("应有 natives 报告"); + assert_eq!(report.files, 1, "{report:?}"); + assert!(!report.up_to_date); + assert!(natives_dir.join("lwjgl.dll").is_file()); + + let second = launcher.dry_run(&request).expect("二次 dry-run"); + let report = second.natives.expect("应有 natives 报告"); + assert!(report.up_to_date, "第二次应命中戳记:{report:?}"); + assert_eq!(report.skipped, 1); +} + +/// 缺 natives jar:适配层当成组件缺失(组装前显式失败),而不是等到 UnsatisfiedLinkError。 +#[test] +fn missing_native_jar_becomes_missing_component() { + let (mut request, ctx) = legacy_forge_request(); + request.natives = NativesPlan::new(vec![NativeJar::new( + "C:/mc/libraries/missing-natives-windows.jar", + "org.lwjgl:lwjgl:3.2.2:natives-windows", + )]); + // 直接验证适配层:给一个不存在的 natives jar,严格模式应产出 MissingNativeJar 告警。 + let libraries: Vec = request + .classpath + .iter() + .map(|path| LibraryInput::new(path.clone(), "x")) + .collect(); + let natives = request.natives.jars.clone(); + let snapshot = SnapshotInput { + version_id: "1.16.5", + main_class: Some("net.minecraft.client.main.Main"), + libraries: &libraries, + natives: &natives, + ..SnapshotInput::minimal("1.16.5", "net.minecraft.client.main.Main") + }; + let options = AdapterOptions { + java: JavaSelection::new("C:/java/bin/java.exe", 17, 64), + ..AdapterOptions::strict() + }; + let output = build_request(&snapshot, options); + assert!( + output + .warnings + .iter() + .any(|warning| matches!(warning, AdapterWarning::MissingNativeJar { .. })), + "应有 MissingNativeJar 告警:{:?}", + output.warnings + ); + assert_eq!(output.request.natives.era, NativesEra::Legacy); + // 组装按既有语义直接失败(missing_components 非空)。 + assert!(matches!( + assemble(&output.request, &ctx).err(), + Some(LaunchError::Assembly(AssemblyError::MissingComponents(_))) + )); +} diff --git a/crates/core/tests/launch/pipeline_cases.rs b/crates/core/tests/launch/pipeline_cases.rs new file mode 100644 index 0000000..b14020f --- /dev/null +++ b/crates/core/tests/launch/pipeline_cases.rs @@ -0,0 +1,347 @@ +//! 顶层启动管线测试:dry-run(D18)、完整会话、停止、组装失败、崩溃现场。 +//! +//! 真进程来自 shell(`cmd /C` 或 `sh -c`),所以这些是端到端用例: +//! 命令行组装 → 事件流落盘 → 退出判定 → 诊断挂钩 → 索引。 + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::json; + +use crate::prelude::*; + +/// 记录调用并返回固定结论的诊断挂钩。 +#[derive(Default)] +struct RecordingHook { + calls: Mutex>, +} + +impl DiagnosisHook for RecordingHook { + fn analyze(&self, request: &DiagnosisRequest) -> Option { + self.calls.lock().unwrap().push(request.summary()); + Some(json!({ + "source": "rules", + "needs_more_info": false, + "candidates": [{ "id": "stub_candidate", "severity": "high", "confidence": 0.9 }], + })) + } +} + +fn launcher(sessions_root: &std::path::Path, hook: Option>) -> Launcher { + let mut config = LauncherConfig::new(sessions_root); + // 固定平台上下文,让命令行断言与运行平台无关。 + config.platform = PlatformCtx::new(OsFamily::Windows, "x86_64"); + config.process = ProcessConfig { + graceful_stop: Duration::from_millis(200), + ..ProcessConfig::default() + }; + let launcher = Launcher::new(config); + match hook { + Some(hook) => launcher.with_diagnosis_hook(hook), + None => launcher, + } +} + +/// 用 shell 冒充 java:只为验证管线,不验证 JVM 语义。 +fn shell_request(script: &str, run_dir: &std::path::Path) -> (LaunchRequest, LaunchRequest) { + let (mut request, ctx) = legacy_forge_request(); + let _ = ctx; + #[cfg(windows)] + { + request.wrapper = vec!["cmd".to_string(), "/C".to_string(), script.to_string()]; + } + #[cfg(not(windows))] + { + request.wrapper = vec!["/bin/sh".to_string(), "-c".to_string(), script.to_string()]; + } + request.java = JavaSelection::new("fake-java", 17, 64); + request.manifest.legacy_arguments = None; + request.dirs.game_dir = run_dir.to_path_buf(); + // natives 解压是管线第 6 段,会真写盘——指到临时目录,绝不碰 `C:/mc`。 + request.dirs.natives_dir = run_dir.join("natives"); + request.user.jvm = Vec::new(); + request.user.game = Vec::new(); + (request.clone(), request) +} + +fn echo_script() -> String { + // 命令里带上 access_token 明文,用来验证“落盘前脱敏”。 + #[cfg(windows)] + { + "echo LWJGL Version: 3.3.1 SECRET-TOKEN-123456".to_string() + } + #[cfg(not(windows))] + { + "echo 'LWJGL Version: 3.3.1 SECRET-TOKEN-123456'".to_string() + } +} + +fn long_running_script() -> String { + #[cfg(windows)] + { + "ping -n 30 127.0.0.1 >nul".to_string() + } + #[cfg(not(windows))] + { + "sleep 30".to_string() + } +} + +#[test] +fn dry_run_returns_command_without_spawning_or_writing() { + let sessions = temp_sessions_root("dry"); + let run_dir = temp_dir("dry-run"); + let launcher = launcher(&sessions, None); + let (request, _) = shell_request(&echo_script(), &run_dir); + + let dry: DryRun = launcher.dry_run(&request).expect("dry-run 应成功"); + assert!(!dry.argv.is_empty()); + assert!( + dry.display.contains("fake-java") + || dry.display.contains("cmd") + || dry.display.contains("/bin/sh") + ); + assert!( + dry.display.contains(""), + "命令行必须脱敏:{}", + dry.display + ); + assert!(!dry.display.contains("SECRET-TOKEN-123456")); + assert_eq!(dry.classpath_entries, 3); + assert_eq!(dry.line(), dry.display.as_str()); + // 不 spawn、不写会话文件。 + assert!(!sessions.exists(), "dry-run 不能创建会话目录"); +} + +#[test] +fn pipeline_records_session_events_diagnosis_and_index() { + let sessions = temp_sessions_root("pipeline"); + let run_dir = temp_dir("pipeline-run"); + let hook = Arc::new(RecordingHook::default()); + let launcher = launcher(&sessions, Some(hook.clone())); + let (request, _) = shell_request(&echo_script(), &run_dir); + + let handle: SessionHandle = launcher.launch(&request).expect("启动应成功"); + assert_eq!(handle.exit.exit_type, ExitType::Normal); + assert_eq!(handle.exit.code, 0); + // 存活 <30s → 静默失败 + 需要诊断。 + assert!(handle.exit.silent_failure.is_some()); + assert!(handle.needs_diagnosis()); + assert_eq!(hook.calls.lock().unwrap().len(), 1, "诊断挂钩应被调用一次"); + assert_eq!( + handle + .diagnostics + .as_ref() + .and_then(|value| value["candidates"][0]["id"].as_str()), + Some("stub_candidate") + ); + assert!( + handle.event_write_errors.is_empty(), + "{:?}", + handle.event_write_errors + ); + // 对外展示的 argv 已是脱敏版本(脚本里带着 token 明文)。 + assert!(handle + .redacted_argv() + .iter() + .any(|arg| arg.contains(""))); + assert!(!handle + .redacted_argv() + .iter() + .any(|arg| arg.contains("SECRET-TOKEN-123456"))); + assert!(handle.redacted_display().contains("")); + assert!(handle.index.is_some()); + assert!(handle.uptime_ms < 30_000); + + let events = read_events(&handle.log_path); + let kinds: Vec<&str> = events + .iter() + .filter_map(|event| event["kind"].as_str()) + .collect(); + for expected in [ + "session_start", + "cmd_built", + "proc_start", + "stdout", + "window_detected", + "exit", + "diagnosis", + "session_end", + ] { + assert!(kinds.contains(&expected), "缺少事件 {expected}:{kinds:?}"); + } + + // 日志行里的 access_token 必须已脱敏。 + let raw = std::fs::read_to_string(&handle.log_path).expect("读会话文件"); + assert!(!raw.contains("SECRET-TOKEN-123456"), "事件流不得含凭据明文"); + + // 索引落盘且字段正确。 + let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引"); + assert_eq!(index.len(), 1); + assert_eq!(index[0].session_id, handle.session_id); + assert!(index[0].silent_failure); + assert!(index[0].window_detected); + assert_eq!(index[0].exit_code, Some(0)); + assert_eq!(index[0].diagnosis_top.as_deref(), Some("stub_candidate")); +} + +#[test] +fn pipeline_stop_records_killed_exit() { + let sessions = temp_sessions_root("killed"); + let run_dir = temp_dir("killed-run"); + let launcher = launcher(&sessions, None); + let (request, _) = shell_request(&long_running_script(), &run_dir); + + let mut session = launcher.spawn(&request).expect("spawn 应成功"); + assert!(session.pid() > 0); + session.stop().expect("停止应成功"); + let handle = session.finish().expect("收尾应成功"); + + assert_eq!(handle.exit.exit_type, ExitType::Killed); + assert!(!handle.succeeded()); + let events = read_events(&handle.log_path); + let kinds: Vec<&str> = events + .iter() + .filter_map(|event| event["kind"].as_str()) + .collect(); + assert!(kinds.contains(&"killed"), "应记录 killed 事件:{kinds:?}"); + let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引"); + assert_eq!(index.len(), 1); + assert_eq!(index[0].exit_type.as_deref(), Some("killed")); +} + +#[test] +fn pipeline_assembly_failure_still_writes_evidence() { + let sessions = temp_sessions_root("assembly-fail"); + let run_dir = temp_dir("assembly-fail-run"); + let launcher = launcher(&sessions, None); + let (mut request, _) = shell_request(&echo_script(), &run_dir); + request.missing_components = vec![MissingComponent::named("org.lwjgl:lwjgl:3.3.1")]; + + let error = launcher.spawn(&request).expect_err("缺组件必须启动前失败"); + assert!(matches!( + error, + LaunchError::Assembly(AssemblyError::MissingComponents(_)) + )); + + let game_dir = sessions.join("game"); + let files: Vec = std::fs::read_dir(&game_dir) + .expect("会话目录应存在") + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().map(|ext| ext == "jsonl").unwrap_or(false)) + .filter(|path| { + path.file_stem() + .map(|stem| stem != "index") + .unwrap_or(false) + }) + .collect(); + assert_eq!(files.len(), 1, "应留下一个会话文件"); + let events = read_events(&files[0]); + let kinds: Vec<&str> = events + .iter() + .filter_map(|event| event["kind"].as_str()) + .collect(); + assert_eq!( + kinds, + vec![ + "session_start", + "natives_extracted", + "assembly_failed", + "session_end" + ] + ); + let missing = &events[2]["payload"]["missing_components"][0]["name"]; + assert_eq!(missing.as_str(), Some("org.lwjgl:lwjgl:3.3.1")); + + let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引"); + assert_eq!(index.len(), 1); + assert!(index[0].exit_type.is_none(), "没有进程就没有退出判定"); +} + +#[test] +fn pipeline_records_crash_report_artifact() { + let sessions = temp_sessions_root("crash"); + let run_dir = temp_dir("crash-run"); + let crash_dir = run_dir.join("crash-reports"); + std::fs::create_dir_all(&crash_dir).expect("建 crash-reports"); + std::fs::write( + crash_dir.join("crash-2026-10-29.txt"), + "---- Minecraft Crash Report ----\nDescription: Rendering screen\n", + ) + .expect("写崩溃报告"); + + let launcher = launcher(&sessions, None); + let (request, _) = shell_request(&echo_script(), &run_dir); + let handle = launcher.launch(&request).expect("启动应成功"); + + assert!( + handle + .artifacts + .iter() + .any(|artifact| artifact.source == CrashSource::CrashReport), + "应发现崩溃报告:{:?}", + handle.artifacts + ); + let events = read_events(&handle.log_path); + let crash = events + .iter() + .find(|event| event["kind"] == "crash_found") + .expect("应有 crash_found 事件"); + let path = crash["payload"]["path"].as_str().unwrap_or_default(); + assert!(path.ends_with("crash-2026-10-29.txt")); + assert!(crash["payload"]["excerpt"] + .as_str() + .unwrap_or_default() + .contains("Minecraft Crash Report")); +} + +#[test] +fn pipeline_spawn_failure_writes_session_end() { + let sessions = temp_sessions_root("spawn-fail"); + let run_dir = temp_dir("spawn-fail-run"); + let launcher = launcher(&sessions, None); + let (mut request, _) = shell_request(&echo_script(), &run_dir); + // 包装器就是 program:给一个不存在的可执行文件,走派生失败分支。 + request.wrapper = vec!["definitely-not-a-real-binary-ull".to_string()]; + + let error = launcher.spawn(&request).expect_err("程序不存在必须失败"); + assert!( + matches!(error, LaunchError::Spawn { .. }), + "错误类型:{error:?}" + ); + + let game_dir = sessions.join("game"); + let files: Vec = std::fs::read_dir(&game_dir) + .expect("会话目录应存在") + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().map(|ext| ext == "jsonl").unwrap_or(false)) + .filter(|path| { + path.file_stem() + .map(|stem| stem != "index") + .unwrap_or(false) + }) + .collect(); + assert_eq!(files.len(), 1, "应留下一个会话文件"); + let events = read_events(&files[0]); + let kinds: Vec<&str> = events + .iter() + .filter_map(|event| event["kind"].as_str()) + .collect(); + assert_eq!( + kinds, + vec![ + "session_start", + "natives_extracted", + "cmd_built", + "session_end" + ] + ); + assert_eq!(events[3]["payload"]["reason"], "spawn_failed"); + + let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引"); + assert_eq!(index.len(), 1, "派生失败也要进索引"); + assert!(index[0].exit_type.is_none()); +} diff --git a/crates/core/tests/launch/prelude.rs b/crates/core/tests/launch/prelude.rs new file mode 100644 index 0000000..a24d2b5 --- /dev/null +++ b/crates/core/tests/launch/prelude.rs @@ -0,0 +1,149 @@ +//! 测试共用设施。 +//! +//! **crate 名只在本文件出现**(tower 定案 `unlucky_core`;若 M11 改包名,只改这里)。 + +use std::path::PathBuf; + +pub use unlucky_core::launch::args_table::{assemble, AssembledCommand, AssemblyWarning}; +pub use unlucky_core::launch::argument::{ArgumentEntry, Rule}; +pub use unlucky_core::launch::command_builder::{self, CommandBuilder}; +pub use unlucky_core::launch::error::{AssemblyError, LaunchError, MissingComponent}; +pub use unlucky_core::launch::exit_analysis::{self, ExitType}; +pub use unlucky_core::launch::game_version::GameVersion; +pub use unlucky_core::launch::log_level::{self, Level}; +pub use unlucky_core::launch::platform::{OsFamily, PlatformCtx}; +pub use unlucky_core::launch::pump::{LineSink, LogLine, RingBuffer, RingBufferConfig, Stream}; +pub use unlucky_core::launch::redact::{Redactor, TOKEN_PLACEHOLDER}; +pub use unlucky_core::launch::request::*; +pub use unlucky_core::launch::session::{EndReason, SessionMeta, SessionSink, SessionWriter}; +pub use unlucky_core::launch::session_index; +pub use unlucky_core::launch::window_detect::WindowDetector; + +pub use unlucky_core::launch::adapter::{ + build_request, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput, +}; +pub use unlucky_core::launch::crash_scan::CrashSource; +pub use unlucky_core::launch::diagnosis_hook::{DiagnosisHook, DiagnosisRequest}; +pub use unlucky_core::launch::launch_session::SessionHandle; +pub use unlucky_core::launch::launcher::{DryRun, Launcher, LauncherConfig}; +pub use unlucky_core::launch::process::ProcessConfig; +pub use unlucky_core::launch::sink::{CompositeSink, CountingSink, FnSink}; + +/// 断言 argv 逐项相等,失败时打印全部实际值。 +pub fn assert_argv(command: &AssembledCommand, expected: &[&str]) { + let actual = command.argv(); + if actual.len() != expected.len() { + panic!( + "argv 长度 {} != 期望 {}\n实际:\n{:#?}", + actual.len(), + expected.len(), + actual + ); + } + for (index, (actual, expected)) in actual.iter().zip(expected.iter()).enumerate() { + assert_eq!(actual, expected, "第 {index} 个参数不同"); + } +} + +/// 一个“1.12.2 + Forge”的基准请求(Windows/x86_64)。 +pub fn legacy_forge_request() -> (LaunchRequest, PlatformCtx) { + let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64"); + let request = LaunchRequest { + instance: InstanceInfo { + id: "inst-1".to_string(), + name: "1.12.2-Forge".to_string(), + version: GameVersion::parse("1.12.2"), + version_type: "release".to_string(), + }, + launcher: LauncherInfo { + name: "UnluckyLauncher".to_string(), + version: "0.1.0".to_string(), + }, + java: JavaSelection::new("C:/java/bin/java.exe", 17, 64), + memory: MemoryOptions { + min_mb: Some(1024), + max_mb: Some(4096), + }, + manifest: ManifestArgs { + main_class: Some("net.minecraft.launchwrapper.Launch".to_string()), + ..ManifestArgs::default() + }, + dirs: LaunchDirs { + game_dir: PathBuf::from("C:/mc"), + assets_root: PathBuf::from("C:/mc/assets"), + game_assets: PathBuf::from("C:/mc/assets/virtual/legacy"), + assets_index: "1.12".to_string(), + natives_dir: PathBuf::from("C:/mc/versions/1.12.2/natives-windows-x86_64"), + library_dir: PathBuf::from("C:/mc/libraries"), + primary_jar: PathBuf::from("C:/mc/versions/1.12.2/1.12.2.jar"), + }, + classpath: vec![ + PathBuf::from("C:/mc/libraries/a.jar"), + PathBuf::from("C:/mc/libraries/b.jar"), + ], + auth: AuthPlaceholders { + player_name: "Steve".to_string(), + uuid: "0123456789abcdef0123456789abcdef".to_string(), + access_token: "SECRET-TOKEN-123456".to_string(), + user_type: "msa".to_string(), + user_properties: "{}".to_string(), + }, + log4j: Log4jOptions { + enabled: true, + config_file: Some(PathBuf::from("C:/mc/log4j2.xml")), + debug: false, + }, + // 大部分断言只关心“命令骨架”,优化参数会淹没差异;需要时单独打开。 + optimization: OptimizationOptions { + enabled: false, + physical_memory_mb: None, + }, + language: "zh-CN".to_string(), + features: FeatureSet { + has_custom_resolution: true, + ..FeatureSet::default() + }, + resolution: Some(Resolution { + width: 854, + height: 480, + }), + ..LaunchRequest::default() + }; + (request, ctx) +} + +/// 一个唯一的临时目录(不自动清理,便于失败时排查;OS 会回收)。 +pub fn temp_dir(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|value| value.as_nanos()) + .unwrap_or_default(); + let dir = + std::env::temp_dir().join(format!("ull-launch-{name}-{}-{nanos}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("创建临时目录"); + dir +} + +/// 一个唯一的临时会话根目录(测试结束不自动清理,便于失败时排查;OS 会回收)。 +pub fn temp_sessions_root(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|value| value.as_nanos()) + .unwrap_or_default(); + let root = std::env::temp_dir().join(format!( + "ull-launch-test-{name}-{}-{nanos}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + root +} + +/// 读回 jsonl 的每一行并解析成 JSON。 +pub fn read_events(path: &std::path::Path) -> Vec { + let text = std::fs::read_to_string(path).expect("读取会话文件"); + text.lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("解析事件行")) + .collect() +} diff --git a/crates/core/tests/launch/process_smoke.rs b/crates/core/tests/launch/process_smoke.rs new file mode 100644 index 0000000..dc954b4 --- /dev/null +++ b/crates/core/tests/launch/process_smoke.rs @@ -0,0 +1,127 @@ +//! 进程冒烟测试:真实 spawn 一个 shell,验证泵、退出码、进程树 kill。 +//! +//! 这些用例跑的是真进程(不是 mock),是“事件流 + 退出分析 + 树 kill”端到端的最小证据。 + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use crate::prelude::*; + +use unlucky_core::launch::process::{self, ProcessConfig, RunningProcess}; +use unlucky_core::launch::pump::{LineSink, NullSink}; + +fn shell_command(script: &str) -> AssembledCommand { + #[cfg(windows)] + let (program, args) = ( + "cmd".to_string(), + vec!["/C".to_string(), script.to_string()], + ); + #[cfg(not(windows))] + let (program, args) = ( + "/bin/sh".to_string(), + vec!["-c".to_string(), script.to_string()], + ); + AssembledCommand { + program: PathBuf::from(program), + args, + classpath: Vec::new(), + encoding: "UTF-8".to_string(), + memory: MemoryOptions::default(), + env: Vec::new(), + warnings: Vec::new(), + main_class: String::new(), + } +} + +fn config() -> ProcessConfig { + ProcessConfig { + graceful_stop: Duration::from_millis(200), + ..ProcessConfig::default() + } +} + +#[test] +fn spawn_collects_both_streams_and_exit_code() { + let root = temp_sessions_root("smoke"); + let meta = SessionMeta::new("inst-1", "smoke", "1.20.1"); + let writer = + SessionWriter::create_with_id(&root, "smoke-1", meta, Redactor::new()).expect("创建会话"); + let path = writer.path().to_path_buf(); + let sink = Arc::new(SessionSink::new(writer)); + + let mut child = RunningProcess::spawn( + &shell_command("echo hello-from-stdout & echo hello-from-stderr 1>&2"), + None, + sink.clone(), + config(), + ) + .expect("spawn 应成功"); + + let status = child.wait().expect("wait 应成功"); + assert_eq!(process::exit_code(&status), 0); + assert!(child.pid() > 0); + + let verdict = child.supervise(); + assert!(verdict.is_complete(), "两条流都应在预算内结束"); + + let texts: Vec = child.lines().into_iter().map(|line| line.text).collect(); + assert!( + texts.iter().any(|text| text.contains("hello-from-stdout")), + "stdout 未收集到:{texts:?}" + ); + assert!( + texts.iter().any(|text| text.contains("hello-from-stderr")), + "stderr 未收集到:{texts:?}" + ); + + // 事件流里应有 stdout/stderr 行事件。 + let events = read_events(&path); + let stdout_lines = events + .iter() + .filter(|event| event["kind"] == "stdout") + .count(); + let stderr_lines = events + .iter() + .filter(|event| event["kind"] == "stderr") + .count(); + assert!(stdout_lines >= 1, "应有 stdout 事件"); + assert!(stderr_lines >= 1, "应有 stderr 事件"); +} + +#[test] +fn stop_kills_process_tree() { + #[cfg(windows)] + let script = "ping -n 30 127.0.0.1 >nul"; + #[cfg(not(windows))] + let script = "sleep 30"; + + let mut child = + RunningProcess::spawn(&shell_command(script), None, Arc::new(NullSink), config()) + .expect("spawn 应成功"); + assert!(child.is_running()); + + child.stop().expect("停止应成功"); + let status = child.wait().expect("wait 应成功"); + assert_ne!( + process::exit_code(&status), + 0, + "被结束的进程不应返回退出码 0" + ); + assert!(!child.is_running()); +} + +#[test] +fn spawn_reports_missing_program() { + let mut command = shell_command("echo hi"); + command.program = PathBuf::from("definitely-not-a-real-binary-ull"); + let error = RunningProcess::spawn(&command, None, Arc::new(NullSink), config()) + .expect_err("程序不存在必须报错"); + assert!(matches!(error, LaunchError::Spawn { .. })); +} + +#[test] +fn line_sink_trait_object_is_usable() { + let sink: Arc = Arc::new(NullSink); + sink.on_line(&LogLine::new(Stream::Stdout, "ignored")); +} diff --git a/crates/core/tests/launch/pump_buffer.rs b/crates/core/tests/launch/pump_buffer.rs new file mode 100644 index 0000000..9cd4523 --- /dev/null +++ b/crates/core/tests/launch/pump_buffer.rs @@ -0,0 +1,111 @@ +//! 环形缓冲 / 级别猜测 / 窗口检测。 + +use crate::prelude::*; + +fn line(stream: Stream, text: &str) -> LogLine { + LogLine::new(stream, text) +} + +#[test] +fn ring_buffer_respects_line_limit() { + let mut buffer = RingBuffer::new(RingBufferConfig { + max_lines: 3, + max_bytes: 1024 * 1024, + }); + for index in 0..5 { + buffer.push(line(Stream::Stdout, &format!("line-{index}"))); + } + assert_eq!(buffer.len(), 3); + let texts: Vec = buffer.lines().map(|item| item.text.clone()).collect(); + assert_eq!(texts, vec!["line-2", "line-3", "line-4"]); + assert_eq!(buffer.total_pushed(), 5); +} + +#[test] +fn ring_buffer_keeeps_key_lines_after_eviction() { + let mut buffer = RingBuffer::new(RingBufferConfig { + max_lines: 2, + max_bytes: 1024 * 1024, + }); + buffer.push(line( + Stream::Stderr, + "java.lang.ExceptionInInitializerError", + )); + buffer.push(line(Stream::Stdout, "noise-1")); + buffer.push(line(Stream::Stdout, "noise-2")); + buffer.push(line(Stream::Stdout, "noise-3")); + + let texts: Vec = buffer.lines().map(|item| item.text.clone()).collect(); + assert!(texts + .iter() + .any(|text| text.contains("ExceptionInInitializer"))); + assert!(texts.contains(&"noise-3".to_string())); + assert!(!texts.contains(&"noise-1".to_string())); + assert_eq!(buffer.error_lines().len(), 1); +} + +#[test] +fn ring_buffer_respects_byte_limit() { + let mut buffer = RingBuffer::new(RingBufferConfig { + max_lines: 1000, + max_bytes: 32, + }); + buffer.push(line(Stream::Stdout, "0123456789")); + buffer.push(line(Stream::Stdout, "abcdefghij")); + buffer.push(line(Stream::Stdout, "ABCDEFGHIJ")); + assert!(buffer.len() < 4, "字节上限应触发淘汰"); + assert!(buffer.text().contains("ABCDEFGHIJ")); +} + +#[test] +fn level_guessing_matches_hmcl_rules() { + assert_eq!( + log_level::guess(false, "[12:34:56] [Render thread/ERROR]: boom"), + Level::Error + ); + assert_eq!( + log_level::guess(false, "[12:34:56] [main/INFO]: hi"), + Level::Info + ); + assert_eq!( + log_level::guess(false, "[12:34:56] [Worker-Main-1/WARN] [mixin/]: careful"), + Level::Warn + ); + assert_eq!(log_level::guess(false, "[SEVERE] java error"), Level::Error); + assert_eq!(log_level::guess(false, "plain text"), Level::Unknown); + // stderr 一律当错误,authlib-injector 例外。 + assert_eq!(log_level::guess(true, "anything"), Level::Error); + assert_eq!( + log_level::guess(true, "[authlib-injector] loaded"), + Level::Info + ); +} + +#[test] +fn line_level_uses_stream_and_text() { + assert_eq!(line(Stream::Stderr, "boom").level(), Level::Error); + assert_eq!( + line(Stream::Stdout, "[12:34:56] [main/DEBUG]: detail").level(), + Level::Debug + ); +} + +#[test] +fn window_detector_reports_heuristic_once() { + let mut detector = WindowDetector::new(); + assert!(detector + .observe("[12:00:00] [main/INFO]: Loading LWJGL") + .is_none()); + let detection = detector + .observe("[12:00:01] [Render thread/INFO]: LWJGL Version: 3.3.1") + .expect("应命中 lwjgl version"); + assert_eq!(detection.heuristic, "lwjgl_version"); + assert!(detection.evidence.contains("LWJGL Version")); + assert!(detector.is_detected()); + // 只报一次。 + assert!(detector.observe("LWJGL Version: 3.3.1").is_none()); + assert_eq!( + detector.detection().map(|d| d.heuristic.as_str()), + Some("lwjgl_version") + ); +} diff --git a/crates/core/tests/launch/session_events.rs b/crates/core/tests/launch/session_events.rs new file mode 100644 index 0000000..b6ffab4 --- /dev/null +++ b/crates/core/tests/launch/session_events.rs @@ -0,0 +1,257 @@ +//! 会话事件流(JSONL)与索引测试。 +//! +//! 校验 `DevDocs/SubAgentsTalk/launch-接口约定.md` 的硬约定: +//! `seq` 单调、写前脱敏、索引同 id 取最后一行。 + +use std::sync::Arc; + +use serde_json::json; +use unlucky_core::launch::pump::LineSink; + +use crate::prelude::*; + +fn command_with_token() -> AssembledCommand { + let (mut request, ctx) = legacy_forge_request(); + request.manifest.legacy_arguments = + Some("--accessToken ${auth_session} --username ${auth_player_name}".to_string()); + assemble(&request, &ctx).expect("组装应成功") +} + +#[test] +fn session_stream_records_all_event_kinds() { + let root = temp_sessions_root("events"); + let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64"); + let meta = SessionMeta::new("inst-1", "1.12.2-Forge", "1.12.2") + .with_launcher("UnluckyLauncher", "0.1.0") + .with_platform(&ctx); + let redactor = Redactor::new().with_secret("SECRET-TOKEN-123456"); + let mut writer = SessionWriter::create_with_id(&root, "20261029T120000-abcdef", meta, redactor) + .expect("创建会话"); + + writer.session_start().expect("session_start"); + writer.cmd_built(&command_with_token()).expect("cmd_built"); + writer.proc_start(4242).expect("proc_start"); + writer + .line(&LogLine::new(Stream::Stdout, "LWJGL Version: 3.3.1")) + .expect("stdout"); + writer + .line(&LogLine::new( + Stream::Stderr, + "java.lang.IllegalStateException: boom", + )) + .expect("stderr"); + writer + .window_detected(&unlucky_core::launch::window_detect::Detection { + heuristic: "lwjgl_version".to_string(), + took_ms: 820, + evidence: "LWJGL Version: 3.3.1".to_string(), + }) + .expect("window_detected"); + + let lines = vec![LogLine::new(Stream::Stderr, "java.lang.Exception: boom")]; + let analysis = exit_analysis::analyze(&exit_analysis::ExitSignals::new(1, 40_000, &lines)); + writer.exit(&analysis).expect("exit"); + writer + .diagnosis(json!({ + "source": "rules", + "needs_more_info": false, + "candidates": [{ "id": "mixin_apply_failed", "confidence": 0.9 }], + })) + .expect("diagnosis"); + + let entry = writer.finish(EndReason::Exit).expect("session_end"); + assert!(writer.is_finished()); + assert_eq!(entry.session_id, "20261029T120000-abcdef"); + assert_eq!(entry.exit_code, Some(1)); + assert_eq!(entry.exit_type.as_deref(), Some("application_error")); + assert!(entry.window_detected); + assert_eq!(entry.diagnosis_top.as_deref(), Some("mixin_apply_failed")); + assert_eq!(entry.log_path, "game/20261029T120000-abcdef.jsonl"); + + let events = read_events(writer.path()); + let kinds: Vec<&str> = events + .iter() + .filter_map(|event| event.get("kind").and_then(|value| value.as_str())) + .collect(); + assert_eq!( + kinds, + vec![ + "session_start", + "cmd_built", + "proc_start", + "stdout", + "stderr", + "window_detected", + "exit", + "diagnosis", + "session_end", + ] + ); + + // seq 从 0 单调递增,v/ts/t_ms/session_id 齐全。 + for (index, event) in events.iter().enumerate() { + assert_eq!(event["v"], 1); + assert_eq!(event["seq"], index as u64); + assert_eq!(event["session_id"], "20261029T120000-abcdef"); + assert!(event["ts"].as_str().unwrap_or_default().ends_with('Z')); + assert!(event["t_ms"].is_u64()); + } + assert_eq!(entry.event_count, events.len() as u64); + + // 索引落盘且可读回。 + let index = session_index::index_path(&root); + let listed = session_index::read_all(&index).expect("读索引"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].session_id, entry.session_id); +} + +#[test] +fn cmd_built_argv_is_redacted_before_writing() { + let root = temp_sessions_root("redact"); + let meta = SessionMeta::new("inst-1", "1.12.2-Forge", "1.12.2"); + let redactor = Redactor::new().with_secret("SECRET-TOKEN-123456"); + let mut writer = + SessionWriter::create_with_id(&root, "redact-1", meta, redactor).expect("创建会话"); + writer.cmd_built(&command_with_token()).expect("cmd_built"); + + let raw = std::fs::read_to_string(writer.path()).expect("读文件"); + assert!(!raw.contains("SECRET-TOKEN-123456"), "命令行必须脱敏后落盘"); + assert!(raw.contains(TOKEN_PLACEHOLDER)); + + let events = read_events(writer.path()); + let argv = events[0]["payload"]["argv"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!(argv + .iter() + .any(|arg| arg.as_str() == Some(TOKEN_PLACEHOLDER))); +} + +#[test] +fn diagnosis_payload_is_redacted_before_writing() { + let root = temp_sessions_root("diagnosis-redact"); + let meta = SessionMeta::new("inst-1", "1.20.1", "1.20.1"); + let redactor = Redactor::new() + .with_secret("SECRET-TOKEN-123456") + .with_home_str("C:/Users/aska"); + let mut writer = + SessionWriter::create_with_id(&root, "diag-redact-1", meta, redactor).expect("创建会话"); + writer + .diagnosis(json!({ + "source": "rules", + "needs_more_info": false, + "candidates": [{ + "id": "mixin_failed", + "confidence": 0.9, + "message_key": "launch.crash.reason.mixin_failed", + "evidence": [ + "hs_err:C:/Users/aska/.minecraft/hs_err_pid123.log: java.lang.Exception", + "token=SECRET-TOKEN-123456", + ], + }], + })) + .expect("写 diagnosis"); + + let raw = std::fs::read_to_string(writer.path()).expect("读文件"); + assert!(!raw.contains("C:/Users/aska"), "诊断证据里的主目录必须脱敏"); + assert!( + !raw.contains("SECRET-TOKEN-123456"), + "诊断证据里的凭据必须脱敏" + ); + assert!(raw.contains("")); + assert!(raw.contains(""), "同一个值内也允许混合脱敏"); + + // 字段名/数值/布尔原样保留(launch 只脱敏、不改写字段)。 + let events = read_events(writer.path()); + let payload = &events[0]["payload"]; + assert_eq!(payload["needs_more_info"], json!(false)); + assert_eq!(payload["candidates"][0]["id"], json!("mixin_failed")); + assert_eq!(payload["candidates"][0]["confidence"], json!(0.9)); + assert_eq!( + payload["candidates"][0]["message_key"], + json!("launch.crash.reason.mixin_failed") + ); + assert_eq!(payload["source"], json!("rules")); + // diagnosis_top 仍能从脱敏后的 candidates 取出。 + assert_eq!( + writer + .finish(EndReason::Exit) + .expect("结束会话") + .diagnosis_top + .as_deref(), + Some("mixin_failed") + ); +} + +#[test] +fn index_keeps_last_line_per_session_id() { + let root = temp_sessions_root("index"); + let mut entry = session_index::SessionIndexEntry { + v: 1, + session_id: "same-id".to_string(), + instance_id: "inst-1".to_string(), + instance_name: "one".to_string(), + game_version: "1.20.1".to_string(), + started_at: "2026-10-29T12:00:00.000Z".to_string(), + ended_at: "2026-10-29T12:01:00.000Z".to_string(), + duration_ms: 60_000, + exit_code: Some(0), + exit_type: Some("normal".to_string()), + ..session_index::SessionIndexEntry::default() + }; + let index = session_index::index_path(&root); + session_index::append(&index, &entry).expect("第一次写索引"); + + // 崩溃重启后重写同一会话的索引行。 + entry.exit_code = Some(1); + entry.exit_type = Some("application_error".to_string()); + entry.duration_ms = 30_000; + session_index::append(&index, &entry).expect("第二次写索引"); + + let listed = session_index::read_all(&index).expect("读索引"); + assert_eq!(listed.len(), 1, "同 id 只保留一条"); + assert_eq!(listed[0].exit_code, Some(1)); + assert_eq!(listed[0].duration_ms, 30_000); + + // 追加另一实例的会话,按实例过滤生效。 + let mut other = listed[0].clone(); + other.session_id = "other-id".to_string(); + other.instance_id = "inst-2".to_string(); + other.started_at = "2026-10-30T12:00:00.000Z".to_string(); + session_index::append(&index, &other).expect("第三次写索引"); + let only = session_index::list(&index, 10, Some("inst-2")).expect("过滤"); + assert_eq!(only.len(), 1); + assert_eq!(only[0].session_id, "other-id"); + // 倒序:最新的在前。 + let all = session_index::list(&index, 10, None).expect("全部"); + assert_eq!( + all.first().map(|item| item.session_id.as_str()), + Some("other-id") + ); +} + +#[test] +fn session_sink_writes_lines_and_detects_window() { + let root = temp_sessions_root("sink"); + let meta = SessionMeta::new("inst-1", "1.20.1", "1.20.1"); + let writer = + SessionWriter::create_with_id(&root, "sink-1", meta, Redactor::new()).expect("创建会话"); + let path = writer.path().to_path_buf(); + let sink = Arc::new(SessionSink::new(writer)); + + sink.on_line(&LogLine::new(Stream::Stdout, "preparing")); + sink.on_line(&LogLine::new(Stream::Stdout, "LWJGL Version: 3.3.1")); + sink.on_line(&LogLine::new(Stream::Stderr, "warn: something")); + + let kinds: Vec = read_events(&path) + .iter() + .filter_map(|event| event["kind"].as_str().map(str::to_string)) + .collect(); + assert!( + kinds.contains(&"window_detected".to_string()), + "sink 应自动检测窗口" + ); + assert_eq!(kinds.iter().filter(|kind| *kind == "stdout").count(), 2); + assert_eq!(kinds.iter().filter(|kind| *kind == "stderr").count(), 1); +} diff --git a/crates/core/tests/launch/sink_cases.rs b/crates/core/tests/launch/sink_cases.rs new file mode 100644 index 0000000..62887bf --- /dev/null +++ b/crates/core/tests/launch/sink_cases.rs @@ -0,0 +1,73 @@ +//! sink 组合测试(复合 sink 是接 M15 采集器 / UI 日志页的接缝)。 + +use std::sync::{Arc, Mutex}; + +use crate::prelude::*; + +fn line(stream: Stream, text: &str) -> LogLine { + LogLine::new(stream, text) +} + +#[test] +fn composite_sink_broadcasts_in_registration_order() { + let order = Arc::new(Mutex::new(Vec::::new())); + let first = { + let order = order.clone(); + FnSink::new(move |line: &LogLine| { + order.lock().unwrap().push(format!("first:{}", line.text)); + }) + }; + let second = { + let order = order.clone(); + FnSink::new(move |line: &LogLine| { + order.lock().unwrap().push(format!("second:{}", line.text)); + }) + }; + let composite = CompositeSink::new() + .with(first.shared()) + .with(second.shared()); + assert_eq!(composite.len(), 2); + composite.on_line(&line(Stream::Stdout, "hello")); + + let recorded = order.lock().unwrap().clone(); + assert_eq!(recorded, vec!["first:hello", "second:hello"]); +} + +#[test] +fn composite_sink_passes_stream_and_text() { + let seen = Arc::new(Mutex::new(Vec::<(Stream, String)>::new())); + let recorder = { + let seen = seen.clone(); + FnSink::new(move |line: &LogLine| { + seen.lock().unwrap().push((line.stream, line.text.clone())); + }) + }; + // 模拟 diagnose 侧:只记文本 + 流标记(不解析 jsonl、不 import launch 类型)。 + let mut composite = CompositeSink::new(); + composite.push(recorder.shared()); + composite.on_line(&line(Stream::Stdout, "out")); + composite.on_line(&line(Stream::Stderr, "err")); + + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 2); + assert_eq!(seen[0], (Stream::Stdout, "out".to_string())); + assert_eq!(seen[1], (Stream::Stderr, "err".to_string())); +} + +#[test] +fn counting_sink_counts_per_stream() { + let counter = Arc::new(CountingSink::new()); + counter.on_line(&line(Stream::Stdout, "a")); + counter.on_line(&line(Stream::Stdout, "b")); + counter.on_line(&line(Stream::Stderr, "c")); + assert_eq!(counter.stdout_lines(), 2); + assert_eq!(counter.stderr_lines(), 1); + assert_eq!(counter.total_lines(), 3); +} + +#[test] +fn empty_composite_is_harmless() { + let composite = CompositeSink::new(); + assert!(composite.is_empty()); + composite.on_line(&line(Stream::Stdout, "ignored")); +} diff --git a/crates/core/tests/platform/linux_caps_cases.rs b/crates/core/tests/platform/linux_caps_cases.rs new file mode 100644 index 0000000..9bab8f9 --- /dev/null +++ b/crates/core/tests/platform/linux_caps_cases.rs @@ -0,0 +1,136 @@ +//! Linux 探测:`/etc/os-release`、内核 release、会话类型、glibc 版本。 + +use std::collections::HashMap; + +use crate::prelude::*; + +const UBUNTU_OS_RELEASE: &str = "\ +PRETTY_NAME=\"Ubuntu 24.04.1 LTS\" +NAME=\"Ubuntu\" +VERSION_ID=\"24.04\" +ID=ubuntu +"; + +#[test] +fn os_release_prefers_pretty_name() { + assert_eq!( + linux_caps::parse_os_release(UBUNTU_OS_RELEASE), + Some("Ubuntu 24.04.1 LTS".to_string()) + ); + // 没有 PRETTY_NAME 的裁剪发行版:NAME + VERSION_ID 拼一个(不够精确但够日志用)。 + let minimal = "NAME=\"Alpine Linux\"\nVERSION_ID=3.19\n"; + assert_eq!( + linux_caps::parse_os_release(minimal), + Some("Alpine Linux 3.19".to_string()) + ); + assert_eq!(linux_caps::parse_os_release(""), None); + assert_eq!(linux_caps::parse_os_release("ID=arch\n"), None); +} + +#[test] +fn kernel_release_yields_major_minor() { + assert_eq!( + linux_caps::parse_kernel_version("6.8.0-45-generic"), + Some((6, 8)) + ); + assert_eq!(linux_caps::parse_kernel_version("5.15.0"), Some((5, 15))); + assert_eq!(linux_caps::parse_kernel_version("3.2"), Some((3, 2))); + assert_eq!(linux_caps::parse_kernel_version("6.8"), Some((6, 8))); + assert_eq!(linux_caps::parse_kernel_version(""), None); + assert_eq!(linux_caps::parse_kernel_version("not-a-version"), None); +} + +#[test] +fn session_detection_order_is_wayland_then_x11_then_headless() { + fn detect(pairs: &[(&str, &str)]) -> SessionKind { + let map: HashMap = pairs + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect(); + linux_caps::session_from_env(|key| map.get(key).cloned()) + } + + assert_eq!( + detect(&[("WAYLAND_DISPLAY", "wayland-0")]), + SessionKind::Wayland + ); + assert_eq!( + detect(&[("XDG_SESSION_TYPE", "wayland"), ("DISPLAY", ":0")]), + SessionKind::Wayland + ); + assert_eq!( + detect(&[("XDG_SESSION_TYPE", "x11"), ("DISPLAY", ":0")]), + SessionKind::X11 + ); + assert_eq!(detect(&[("DISPLAY", ":0")]), SessionKind::X11); + // 空值不算有会话(SSH 转发里 `WAYLAND_DISPLAY=` 是常见的空串形态)。 + assert_eq!( + detect(&[("WAYLAND_DISPLAY", " "), ("DISPLAY", ":0")]), + SessionKind::X11 + ); + assert_eq!(detect(&[]), SessionKind::Headless); +} + +#[test] +fn session_kind_helpers() { + assert!(SessionKind::Wayland.has_display()); + assert!(SessionKind::X11.has_display()); + assert!(!SessionKind::Headless.has_display()); + assert!(!SessionKind::Unknown.has_display()); + assert_eq!(SessionKind::Wayland.as_str(), "wayland"); + assert_eq!(SessionKind::Headless.as_str(), "headless"); + assert_eq!(GlProbe::Unknown.as_str(), "unknown"); + assert_eq!(GlProbe::Missing.as_str(), "missing"); +} + +#[test] +fn glibc_version_comes_from_the_first_ldd_line() { + assert_eq!( + linux_caps::parse_glibc_version( + "ldd (Ubuntu GLIBC 2.39-0ubuntu8.3) 2.39\nCopyright (C) 2024\n" + ), + Some((2, 39)) + ); + assert_eq!( + linux_caps::parse_glibc_version("ldd (GNU libc) 2.31"), + Some((2, 31)) + ); + assert_eq!( + linux_caps::parse_glibc_version("ldd (Debian GLIBC 2.36-9+deb12u4) 2.36"), + Some((2, 36)) + ); + // musl / 命令不存在:不能瞎猜,交给上层「不阻塞」。 + assert_eq!(linux_caps::parse_glibc_version("ldd: not found"), None); + assert_eq!(linux_caps::parse_glibc_version(""), None); +} + +#[test] +fn format_version_is_dotted() { + assert_eq!(linux_caps::format_version((2, 31)), "2.31"); + assert_eq!(linux_caps::format_version((6, 8)), "6.8"); +} + +#[test] +fn gl_probe_is_a_hook_returning_unknown_for_now() { + // P1 只留钩子:真探测要 dlopen(FFI),core 禁 unsafe,故恒为 Unknown 且不阻塞。 + assert_eq!(linux_caps::probe_gl(), GlProbe::Unknown); +} + +#[test] +fn caps_probe_matches_the_host() { + let caps: LinuxCaps = linux_caps::probe(); + if cfg!(target_os = "linux") { + // 容器里可能全空,但会话类型至少是「探过了」。 + assert_ne!(caps.session, SessionKind::Unknown); + if let Some(kernel) = &caps.kernel { + assert!( + caps.kernel_version.is_some(), + "内核串 {kernel} 应能取 major.minor" + ); + } + } else { + assert_eq!(caps.session, SessionKind::Unknown); + assert!(caps.kernel.is_none()); + assert!(caps.distro.is_none()); + } +} diff --git a/crates/core/tests/platform/macos_cases.rs b/crates/core/tests/platform/macos_cases.rs new file mode 100644 index 0000000..f645fb3 --- /dev/null +++ b/crates/core/tests/platform/macos_cases.rs @@ -0,0 +1,58 @@ +//! macOS 探测:`sw_vers -productVersion` 与 `sysctl hw.optional.arm64`。 + +use crate::prelude::*; + +#[test] +fn product_version_parses_sw_vers_output() { + assert_eq!( + macos_version::parse_product_version("12.3.1\n"), + Some((12, 3, 1)) + ); + assert_eq!( + macos_version::parse_product_version("12.3"), + Some((12, 3, 0)) + ); + assert_eq!(macos_version::parse_product_version("12"), Some((12, 0, 0))); + assert_eq!( + macos_version::parse_product_version(" 14.2.1 "), + Some((14, 2, 1)) + ); + assert_eq!(macos_version::parse_product_version(""), None); + assert_eq!(macos_version::parse_product_version("ProductVersion"), None); +} + +#[test] +fn sysctl_arm64_flag() { + assert!(macos_version::parse_sysctl_bool("1\n")); + assert!(!macos_version::parse_sysctl_bool("0\n")); + // Intel 机器上 sysctl 以非零码退出 → stdout 为空 → 不是 arm64。 + assert!(!macos_version::parse_sysctl_bool("")); +} + +#[test] +fn version_strings_and_runtime_arch() { + let apple_silicon = MacOsVersion::new(12, 3, 1, true); + assert_eq!(apple_silicon.version_string(), "12.3.1"); + assert_eq!(apple_silicon.runtime_arch(), "arm64"); + assert_eq!(apple_silicon.display_name(), "macOS 12.3.1 (arm64)"); + + // Rosetta 2 下编译目标是 x86_64,但真实架构仍是 arm64: + // 探到 arm64 时 runtime_arch 必须是 arm64(MC 原生库与渲染路径都按它选)。 + let rosetta = MacOsVersion::new(12, 3, 1, true); + assert_eq!(rosetta.runtime_arch(), "arm64"); + + let intel = MacOsVersion::new(15, 0, 0, false); + assert_eq!(intel.runtime_arch(), "x86_64"); + assert_eq!(intel.display_name(), "macOS 15.0.0 (x86_64)"); +} + +#[test] +fn probe_matches_the_host() { + let probed = macos_version::probe(); + if cfg!(target_os = "macos") { + let version = probed.expect("macOS 宿主必须能探到产品版本"); + assert!(version.major >= 10, "探到 {version:?}"); + } else { + assert!(probed.is_none(), "非 macOS 平台不应探到 macOS 版本"); + } +} diff --git a/crates/core/tests/platform/main.rs b/crates/core/tests/platform/main.rs new file mode 100644 index 0000000..06767ae --- /dev/null +++ b/crates/core/tests/platform/main.rs @@ -0,0 +1,17 @@ +//! platform 模块集成测试入口(`cargo test -p unlucky-core --test platform`)。 +//! +//! 覆盖 D19(`PlanDoc/11-os-detection.md`)的运行期探测: +//! - 等级映射 / 文案:注入式假数据,不改机器真实版本; +//! - 各平台探测的**解析**(Windows 注册表与 `ver`、Linux `/etc/os-release`+内核+glibc、 +//! macOS `sw_vers`+`sysctl`):解析函数跨平台编译,所以能在任意宿主上单测; +//! - `rules.os.version` 用真实探测值求值的接线; +//! - 系统主题探测(`theme_probe`:注册表 / `defaults` / `gsettings` 解析 + 三态契约)。 + +mod linux_caps_cases; +mod macos_cases; +mod os_info_cases; +mod prelude; +mod rules_wiring; +mod theme_cases; +mod vk_cases; +mod windows_version_cases; diff --git a/crates/core/tests/platform/os_info_cases.rs b/crates/core/tests/platform/os_info_cases.rs new file mode 100644 index 0000000..ab5e107 --- /dev/null +++ b/crates/core/tests/platform/os_info_cases.rs @@ -0,0 +1,299 @@ +//! 支持等级映射与双语文案:全部用注入式假数据,不改机器真实版本。 +//! +//! 基线(用户拍板):Windows 10+ / macOS 12+ / Linux 达标 → 一等,其余一律不支持; +//! Win8.1/8/7 也不承诺 CLI,所以没有中间档。 + +use crate::prelude::*; + +fn windows_info(major_minor: Option<(u32, u32)>) -> OsInfo { + let product = match major_minor { + Some(version) if version >= os_info::MIN_WINDOWS => "Windows 10", + Some(_) => "Windows 7", + None => "Windows", + }; + OsInfo { + family: OsFamily::Windows, + arch: "x86_64".to_string(), + major_minor, + build: Some(19_045), + product: product.to_string(), + version_string: "10.0.19045".to_string(), + session: SessionKind::Unknown, + gl: GlProbe::Unknown, + vk: VkProbe::Unknown, + kernel: None, + glibc: None, + support_level: os_info::support_level_for(OsFamily::Windows, major_minor, None, None), + } +} + +fn linux_info(kernel: Option<(u32, u32)>, glibc: Option<(u32, u32)>) -> OsInfo { + OsInfo { + family: OsFamily::Linux, + arch: "x86_64".to_string(), + major_minor: kernel, + build: None, + product: "Ubuntu 24.04.1 LTS".to_string(), + version_string: "6.8.0-45-generic".to_string(), + session: SessionKind::Wayland, + gl: GlProbe::Unknown, + vk: VkProbe::Unknown, + kernel, + glibc, + support_level: os_info::support_level_for(OsFamily::Linux, kernel, kernel, glibc), + } +} + +#[test] +fn windows_levels_require_windows_10() { + // Win10 / Win11(都报 10.0,靠 build 区分产品名)→ 一等。 + assert_eq!(os_info::windows_level(Some((10, 0))), SupportLevel::One); + // Win8.1 / Win8 / Win7:低于基线,GUI 与 CLI 都不承诺。 + assert_eq!( + os_info::windows_level(Some((6, 3))), + SupportLevel::Unsupported + ); + assert_eq!( + os_info::windows_level(Some((6, 2))), + SupportLevel::Unsupported + ); + assert_eq!( + os_info::windows_level(Some((6, 1))), + SupportLevel::Unsupported + ); + // Vista / XP。 + assert_eq!( + os_info::windows_level(Some((6, 0))), + SupportLevel::Unsupported + ); + assert_eq!( + os_info::windows_level(Some((5, 1))), + SupportLevel::Unsupported + ); + // 探测失败 → 保守(无法确认 Win10+ 就不放行)。 + assert_eq!(os_info::windows_level(None), SupportLevel::Unsupported); +} + +#[test] +fn macos_levels_use_twelve_as_the_floor() { + assert_eq!(os_info::macos_level(Some(12)), SupportLevel::One); + assert_eq!(os_info::macos_level(Some(15)), SupportLevel::One); + assert_eq!(os_info::macos_level(Some(11)), SupportLevel::Unsupported); + assert_eq!(os_info::macos_level(None), SupportLevel::Unsupported); +} + +#[test] +fn linux_levels_gate_on_kernel_and_glibc() { + assert_eq!( + os_info::linux_level(Some((6, 8)), Some((2, 39))), + SupportLevel::One + ); + assert_eq!( + os_info::linux_level(Some((2, 6)), Some((2, 39))), + SupportLevel::Unsupported + ); + assert_eq!( + os_info::linux_level(Some((6, 8)), Some((2, 16))), + SupportLevel::Unsupported + ); + // musl / Termux / 容器里取不到 glibc 与内核 → 不阻塞。 + assert_eq!(os_info::linux_level(None, None), SupportLevel::One); + assert_eq!(os_info::linux_level(Some((3, 2)), None), SupportLevel::One); + // 恰好等于下限也算支持。 + assert_eq!( + os_info::linux_level(Some((6, 8)), Some(os_info::MIN_GLIBC)), + SupportLevel::One + ); +} + +#[test] +fn support_level_for_dispatches_by_family() { + assert_eq!( + os_info::support_level_for(OsFamily::Windows, Some((10, 0)), None, None), + SupportLevel::One + ); + assert_eq!( + os_info::support_level_for(OsFamily::Windows, Some((6, 1)), None, None), + SupportLevel::Unsupported + ); + assert_eq!( + os_info::support_level_for(OsFamily::MacOs, Some((12, 3)), None, None), + SupportLevel::One + ); + assert_eq!( + os_info::support_level_for(OsFamily::MacOs, Some((11, 7)), None, None), + SupportLevel::Unsupported + ); + // Linux 的 major_minor 不参与判定,看 kernel/glibc。 + assert_eq!( + os_info::support_level_for(OsFamily::Linux, Some((10, 0)), Some((2, 6)), None), + SupportLevel::Unsupported + ); + assert_eq!( + os_info::support_level_for(OsFamily::Linux, None, Some((6, 8)), Some((2, 39))), + SupportLevel::One + ); +} + +#[test] +fn windows_7_is_reported_as_unsupported_with_the_new_baseline() { + let info = windows_info(Some((6, 1))); + assert_eq!(info.support_level, SupportLevel::Unsupported); + let text = info.describe(); + assert!(text.zh.contains("不支持"), "zh: {}", text.zh); + assert!(text.zh.contains("Windows 10"), "zh: {}", text.zh); + // 不再有「受限模式 / HeadlessOnly」文案。 + assert!(!text.zh.contains("受限模式"), "zh: {}", text.zh); + assert!(text.en.contains("Unsupported"), "en: {}", text.en); + assert!(text.en.contains("Windows 10 or newer"), "en: {}", text.en); + // combined() 是 CLI 打印形态:中文在前、英文在后。 + let combined = text.combined(); + let (zh, en) = combined.split_once('\n').expect("两行"); + assert!(zh.contains("不支持")); + assert!(en.contains("Unsupported")); +} + +#[test] +fn unsupported_macos_and_linux_name_their_own_minimums() { + let mac = OsInfo { + family: OsFamily::MacOs, + arch: "arm64".to_string(), + major_minor: Some((11, 7)), + build: None, + product: "macOS 11.7.0 (arm64)".to_string(), + version_string: "11.7.0".to_string(), + session: SessionKind::Unknown, + gl: GlProbe::Unknown, + vk: VkProbe::Unknown, + kernel: None, + glibc: None, + support_level: SupportLevel::Unsupported, + }; + let text = mac.describe(); + assert!(text.zh.contains("macOS 12"), "zh: {}", text.zh); + assert!(text.en.contains("macOS 12"), "en: {}", text.en); + assert!(!text.zh.contains("Windows"), "zh: {}", text.zh); + + let linux = linux_info(Some((2, 6)), None); + assert_eq!(linux.support_level, SupportLevel::Unsupported); + let text = linux.describe(); + assert!(text.zh.contains("内核"), "zh: {}", text.zh); + assert!(text.en.contains("kernel"), "en: {}", text.en); +} + +#[test] +fn unknown_windows_version_is_unsupported_and_says_so() { + let info = windows_info(None); + assert_eq!(info.support_level, SupportLevel::Unsupported); + let text = info.describe(); + assert!(text.zh.contains("未能探测到系统版本"), "zh: {}", text.zh); + assert!(text.zh.contains("Windows 10"), "zh: {}", text.zh); +} + +#[test] +fn supported_linux_without_display_adds_a_hint() { + let mut info = linux_info(Some((6, 8)), Some((2, 39))); + info.session = SessionKind::Headless; + let text = info.describe(); + assert!(text.zh.contains("图形会话"), "zh: {}", text.zh); + assert!(text.en.contains("graphical session"), "en: {}", text.en); + + // 有显示服务器时不加提示。 + let text = linux_info(Some((6, 8)), Some((2, 39))).describe(); + assert!(!text.zh.contains("图形会话"), "zh: {}", text.zh); +} + +#[test] +fn summary_line_carries_the_diagnostic_fields() { + let line = linux_info(Some((6, 8)), Some((2, 39))).summary_line(); + assert!(line.contains("os=linux"), "{line}"); + assert!(line.contains("version=6.8.0-45-generic"), "{line}"); + assert!(line.contains("arch=x86_64"), "{line}"); + assert!(line.contains("level=one"), "{line}"); + assert!(line.contains("session=wayland"), "{line}"); + assert!(line.contains("gl=unknown"), "{line}"); + assert!(line.contains("vk=unknown"), "{line}"); +} + +#[test] +fn summary_line_reports_the_vulkan_probe() { + let mut info = linux_info(Some((6, 8)), Some((2, 39))); + info.vk = VkProbe::LoaderOnly; + assert!(info.summary_line().contains("vk=loader_only")); + info.vk = VkProbe::Available; + assert!(info.summary_line().contains("vk=available")); + info.vk = VkProbe::Missing; + assert!(info.summary_line().contains("vk=missing")); +} + +#[test] +fn summary_line_marks_missing_version_as_unknown() { + let mut info = windows_info(None); + info.version_string = String::new(); + let line = info.summary_line(); + assert!(line.contains("version=unknown"), "{line}"); + assert!(line.contains("level=unsupported"), "{line}"); +} + +#[test] +fn support_level_helpers() { + assert_eq!(SupportLevel::One.as_str(), "one"); + assert_eq!(SupportLevel::Unsupported.as_str(), "unsupported"); + assert!(SupportLevel::One.is_usable()); + assert!(!SupportLevel::Unsupported.is_usable()); +} + +#[test] +fn os_family_facts_and_launch_reexport_are_the_same_type() { + // launch::platform 只是把 OsFamily 从 platform::os_info 再导出(避免两套枚举)。 + let family: unlucky_core::platform::os_info::OsFamily = OsFamily::Windows; + assert_eq!(family, OsFamily::Windows); + assert_eq!(OsFamily::Windows.manifest_name(), "windows"); + assert_eq!(OsFamily::MacOs.manifest_name(), "osx"); + assert_eq!(OsFamily::Linux.manifest_name(), "linux"); + assert!(!OsFamily::Windows.is_posix()); + assert!(OsFamily::Linux.is_posix()); + assert!(!OsFamily::current().manifest_arch().is_empty()); + assert_eq!( + OsFamily::current().manifest_arch(), + OsFamily::Linux.manifest_arch() + ); +} + +#[test] +fn detect_reports_the_host_it_runs_on() { + let info = os_info::detect(); + // 家族与当前编译目标一致。 + assert_eq!(info.family, OsFamily::current()); + assert!(!info.arch.is_empty()); + assert!(!info.product.is_empty()); + // 缓存入口返回同一份事实。 + let cached = os_info::current(); + assert_eq!(cached, &info); + assert_eq!(cached.family, info.family); +} + +#[test] +fn windows_host_probes_a_real_version() { + if !cfg!(target_os = "windows") { + return; + } + let info = os_info::detect(); + let (major, _minor) = info.major_minor.expect("Windows 上必须探到 major.minor"); + assert!(major >= 6, "探到的版本不合理: {:?}", info.major_minor); + assert!(!info.os_version().is_empty(), "os_version 不能是空串"); + assert!(info.build.is_some(), "Windows 上应有 build 号"); + // 非 Windows 分支的事实不应泄漏进来。 + assert!(info.kernel.is_none()); + assert!(info.glibc.is_none()); + assert_eq!(info.session, SessionKind::Unknown); +} + +#[test] +fn check_returns_level_message_and_os_info() { + let check: SupportCheck = os_info::check(); + assert_eq!(check.level, os_info::current().support_level); + assert_eq!(check.is_usable(), check.level.is_usable()); + assert!(check.message.contains('\n'), "文案应为中英双语多行"); + assert_eq!(check.message, check.os.describe().combined()); +} diff --git a/crates/core/tests/platform/prelude.rs b/crates/core/tests/platform/prelude.rs new file mode 100644 index 0000000..f22c9ff --- /dev/null +++ b/crates/core/tests/platform/prelude.rs @@ -0,0 +1,53 @@ +//! 测试共用设施(crate 名只出现在这里)。 + +use std::path::PathBuf; + +pub use unlucky_core::launch::args_table::assemble; +pub use unlucky_core::launch::argument::{ArgumentEntry, OsRule, Rule, RuleContext}; +pub use unlucky_core::launch::game_version::GameVersion; +pub use unlucky_core::launch::platform::{OsFamily, PlatformCtx}; +pub use unlucky_core::launch::request::*; +pub use unlucky_core::platform::linux_caps::{self, GlProbe, LinuxCaps, SessionKind}; +pub use unlucky_core::platform::macos_version::{self, MacOsVersion}; +pub use unlucky_core::platform::os_info::{self, OsInfo, SupportCheck, SupportLevel}; +pub use unlucky_core::platform::vk_probe::{ + classify, count_icd_manifests, env_icd_path, ldconfig_has_libvulkan, loader_candidates, + probe_vk, registry_has_driver_entry, VkEvidence, VkProbe, ICD_DIRS, ICD_ENV_VARS, + VULKAN_DRIVERS_KEY, +}; +pub use unlucky_core::platform::windows_version::{self, WindowsVersion}; + +/// 一个最小可组装请求:现代清单,游戏参数由调用方给。 +pub fn request_with_game(game: Vec) -> LaunchRequest { + LaunchRequest { + instance: InstanceInfo { + id: "inst-1".to_string(), + name: "1.20.1".to_string(), + version: GameVersion::parse("1.20.1"), + version_type: "release".to_string(), + }, + launcher: LauncherInfo { + name: "UnluckyLauncher".to_string(), + version: "0.1.0".to_string(), + }, + java: JavaSelection::new("C:/java/bin/java.exe", 17, 64), + manifest: ManifestArgs { + main_class: Some("net.minecraft.client.main.Main".to_string()), + game, + ..ManifestArgs::default() + }, + classpath: vec![PathBuf::from("C:/mc/libraries/a.jar")], + ..LaunchRequest::default() + } +} + +/// 一条带 `rules.os.version` 的游戏参数项。 +pub fn version_ruled_entry(pattern: &str, value: &str) -> ArgumentEntry { + let mut rule = Rule::allow(); + rule.os = Some(OsRule { + name: None, + arch: None, + version: Some(pattern.to_string()), + }); + ArgumentEntry::ruled(vec![rule], [value]) +} diff --git a/crates/core/tests/platform/rules_wiring.rs b/crates/core/tests/platform/rules_wiring.rs new file mode 100644 index 0000000..7b592f3 --- /dev/null +++ b/crates/core/tests/platform/rules_wiring.rs @@ -0,0 +1,96 @@ +//! 接线:`rules.os.version` 用真实探测值求值(D19 §2 —— 之前 `os_version` 是空串, +//! 带版本规则的参数项永远命中不了)。 + +use crate::prelude::*; + +fn argv_of(entries: Vec, ctx: &PlatformCtx) -> Vec { + let request = request_with_game(entries); + assemble(&request, ctx).expect("组装失败").argv().to_vec() +} + +#[test] +fn windows_10_rule_hits_with_detected_version() { + let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64").with_os_version("10.0.19045"); + let entries = vec![version_ruled_entry(r"^10\.", "--demo-windows10")]; + let argv = argv_of(entries, &ctx); + assert!( + argv.iter().any(|arg| arg == "--demo-windows10"), + "argv 应包含带版本规则的参数: {argv:?}" + ); +} + +#[test] +fn same_rule_misses_when_version_is_empty() { + // 空版本串是修复前的常态:规则求值走保守分支(不放参数)。 + let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64"); + assert!(ctx.os_version.is_empty()); + let entries = vec![version_ruled_entry(r"^10\.", "--demo-windows10")]; + let argv = argv_of(entries, &ctx); + assert!( + !argv.iter().any(|arg| arg == "--demo-windows10"), + "空版本串不该命中: {argv:?}" + ); +} + +#[test] +fn escaped_dot_patterns_match_real_versions() { + // `^10\.15` 是 macOS 上常见的写法:反转义后按 `10.15` 前缀比较。 + let ctx = PlatformCtx::new(OsFamily::MacOs, "x86_64").with_os_version("10.15.7"); + let entries = vec![version_ruled_entry(r"^10\.15", "--demo-catalina")]; + let argv = argv_of(entries, &ctx); + assert!(argv.iter().any(|arg| arg == "--demo-catalina"), "{argv:?}"); +} + +#[test] +fn regex_shaped_patterns_stay_conservative() { + let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64").with_os_version("10.0.19045"); + // 需要真正则的模式:宁可少加参数,也不猜。 + let entries = vec![version_ruled_entry(r"^10\.[0-9]+", "--demo-regex")]; + let argv = argv_of(entries, &ctx); + assert!(!argv.iter().any(|arg| arg == "--demo-regex"), "{argv:?}"); +} + +#[test] +fn rule_context_uses_the_injected_version() { + let features = FeatureSet::default(); + let entry = version_ruled_entry(r"^6\.1\.", "--demo-win7"); + assert!(entry.applies(&RuleContext::new( + "windows", "x86_64", "6.1.7601", &features + ))); + assert!(!entry.applies(&RuleContext::new( + "windows", + "x86_64", + "10.0.19045", + &features + ))); + // 空版本串:任何版本规则都不命中。 + assert!(!entry.applies(&RuleContext::new("windows", "x86_64", "", &features))); +} + +#[test] +fn platform_ctx_current_carries_the_probed_version() { + let ctx = PlatformCtx::current(); + let info = os_info::current(); + assert_eq!(ctx.os, info.family); + assert_eq!(ctx.arch, info.arch); + assert_eq!(ctx.os_version, info.os_version()); + if cfg!(target_os = "windows") { + assert!(!ctx.os_version.is_empty(), "Windows 上版本串不能为空"); + // 版本串应能被规则前缀匹配到(形如 10.0.19045)。 + assert!( + ctx.os_version.starts_with(|c: char| c.is_ascii_digit()), + "版本串形态异常: {}", + ctx.os_version + ); + } +} + +#[test] +fn default_matches_current() { + let ctx = PlatformCtx::default(); + assert_eq!(ctx.os_version, PlatformCtx::current().os_version); + assert_eq!( + ctx.path_separator(), + PlatformCtx::current().path_separator() + ); +} diff --git a/crates/core/tests/platform/theme_cases.rs b/crates/core/tests/platform/theme_cases.rs new file mode 100644 index 0000000..6fc20c5 --- /dev/null +++ b/crates/core/tests/platform/theme_cases.rs @@ -0,0 +1,102 @@ +//! 系统主题探测的解析与判定(`core::platform::theme_probe`)。 +//! +//! 解析函数跨平台编译,所以本用例在任意宿主上都能跑;真实探测只是把同一份解析套到 +//! `reg query` / `defaults` / `gsettings` 的输出上(`probe()` 的非确定性不进断言)。 + +use unlucky_core::platform::theme_probe::{ + self, parse_apps_use_light_theme, parse_gsettings_color_scheme, parse_gtk_theme, + parse_macos_interface_style, SystemTheme, WINDOWS_PERSONALIZE_KEY, +}; + +#[test] +fn windows_registry_apps_use_light_theme_maps_dword() { + // 真实 `reg query` 输出(REG_DWORD 以 0x 十六进制打印)。 + let light = "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize\n \ + AppsUseLightTheme REG_DWORD 0x1\n SystemUsesLightTheme REG_DWORD 0x0\n"; + assert_eq!(parse_apps_use_light_theme(light), Some(SystemTheme::Light)); + + let dark = " AppsUseLightTheme REG_DWORD 0x0\n"; + assert_eq!(parse_apps_use_light_theme(dark), Some(SystemTheme::Dark)); + + // 十进制形式的输出(某些区域设置/工具会把 DWORD 打成十进制)。 + assert_eq!( + parse_apps_use_light_theme(" AppsUseLightTheme REG_DWORD 1"), + Some(SystemTheme::Light) + ); + + // 键不存在(老系统 / 被策略删掉)与别的值都不猜。 + assert_eq!(parse_apps_use_light_theme(""), None); + assert_eq!( + parse_apps_use_light_theme(" SystemUsesLightTheme REG_DWORD 0x0"), + None + ); + assert_eq!( + parse_apps_use_light_theme(" AppsUseLightTheme REG_SZ yes"), + None + ); +} + +#[test] +fn windows_key_points_at_personalize_hive() { + assert!(WINDOWS_PERSONALIZE_KEY.contains("Themes\\Personalize")); +} + +#[test] +fn macos_interface_style_maps_dark_and_empty() { + assert_eq!( + parse_macos_interface_style("Dark\n"), + Some(SystemTheme::Dark) + ); + assert_eq!( + parse_macos_interface_style("Light\n"), + Some(SystemTheme::Light) + ); + // 浅色时 `AppleInterfaceStyle` 键不存在,defaults 无输出。 + assert_eq!(parse_macos_interface_style(""), None); +} + +#[test] +fn gsettings_color_scheme_prefers_explicit_preference() { + assert_eq!( + parse_gsettings_color_scheme("'prefer-dark'\n"), + Some(SystemTheme::Dark) + ); + assert_eq!( + parse_gsettings_color_scheme("'prefer-light'\n"), + Some(SystemTheme::Light) + ); + // `default` = 未表态,交给 gtk-theme 名字兜底。 + assert_eq!(parse_gsettings_color_scheme("'default'\n"), None); +} + +#[test] +fn gtk_theme_name_falls_back_to_dark_suffix() { + assert_eq!(parse_gtk_theme("'Yaru-dark'\n"), Some(SystemTheme::Dark)); + assert_eq!(parse_gtk_theme("'Adwaita-dark'\n"), Some(SystemTheme::Dark)); + assert_eq!(parse_gtk_theme("'Yaru'\n"), Some(SystemTheme::Light)); + assert_eq!(parse_gtk_theme("''\n"), None); +} + +#[test] +fn system_theme_tristate_contract() { + assert_eq!(SystemTheme::Light.is_dark(), Some(false)); + assert_eq!(SystemTheme::Dark.is_dark(), Some(true)); + assert_eq!( + SystemTheme::Unknown.is_dark(), + None, + "Unknown 必须让调用方自己决定回落(不假装浅色)" + ); + assert_eq!(SystemTheme::Light.as_str(), "light"); + assert_eq!(SystemTheme::Dark.as_str(), "dark"); + assert_eq!(SystemTheme::Unknown.as_str(), "unknown"); +} + +#[test] +fn probe_never_panics_and_reports_one_of_three_states() { + // 真实探测(Windows 读注册表,其余平台问 DE);不断言具体值,只要求落在三态内。 + let probed = theme_probe::probe(); + assert!(matches!( + probed, + SystemTheme::Light | SystemTheme::Dark | SystemTheme::Unknown + )); +} diff --git a/crates/core/tests/platform/vk_cases.rs b/crates/core/tests/platform/vk_cases.rs new file mode 100644 index 0000000..44c9ae3 --- /dev/null +++ b/crates/core/tests/platform/vk_cases.rs @@ -0,0 +1,240 @@ +//! Vulkan 能力探测(`platform::vk_probe`)的三平台注入样本。 +//! +//! 全部用 [`VkEvidence`] 注入,不读真机、不起子进程: +//! 三平台解析函数是纯函数且跨平台编译,任意宿主都能跑这几个用例。 +//! +//! 关键语义(`research/05-mc-vulkan.md` §4.3 的真机证据): +//! **Windows 注册表 `HKLM\SOFTWARE\Khronos\Vulkan\Drivers` 缺失不等于没有 Vulkan** +//! (实测机 Vulkan 1.3.217 可用但该键不存在),所以只能降级到 `LoaderOnly`,不能判 `Missing`。 + +use std::path::PathBuf; + +use crate::prelude::*; + +#[test] +fn windows_loader_without_registry_key_is_only_loader_only() { + // 真机证据的复刻:vulkan-1.dll 在,注册表键缺失 → 只能说「loader 在、驱动未确认」。 + let probe = classify(VkEvidence::new(OsFamily::Windows, true, false)); + assert_eq!(probe, VkProbe::LoaderOnly); + assert!(probe.has_loader()); + assert!(!probe.is_missing(), "注册表缺失被误判成没有 Vulkan"); + assert!(!probe.is_available(), "没有驱动信号不该承诺可用"); +} + +#[test] +fn windows_loader_plus_registry_entry_is_available() { + assert_eq!( + classify(VkEvidence::new(OsFamily::Windows, true, true)), + VkProbe::Available + ); +} + +#[test] +fn windows_without_loader_is_missing() { + assert_eq!( + classify(VkEvidence::new(OsFamily::Windows, false, true)), + VkProbe::Missing + ); +} + +#[test] +fn linux_loader_with_icd_manifests_is_available() { + assert_eq!( + classify(VkEvidence::new(OsFamily::Linux, true, true)), + VkProbe::Available + ); + // 只有 loader、没有 ICD 目录/环境变量:同样不否定(驱动可能由 loader 自寻)。 + assert_eq!( + classify(VkEvidence::new(OsFamily::Linux, true, false)), + VkProbe::LoaderOnly + ); + assert_eq!( + classify(VkEvidence::new(OsFamily::Linux, false, false)), + VkProbe::Missing + ); +} + +#[test] +fn macos_molten_vk_needs_no_separate_icd_signal() { + // macOS 没有系统 Vulkan:MoltenVK 既是 loader 又是 ICD,没有 LoaderOnly 这一档。 + assert_eq!( + classify(VkEvidence::new(OsFamily::MacOs, true, false)), + VkProbe::Available + ); + assert_eq!( + classify(VkEvidence::new(OsFamily::MacOs, false, true)), + VkProbe::Missing + ); +} + +#[test] +fn probe_names_are_stable_and_lower_snake() { + assert_eq!(VkProbe::Unknown.as_str(), "unknown"); + assert_eq!(VkProbe::LoaderOnly.as_str(), "loader_only"); + assert_eq!(VkProbe::Available.as_str(), "available"); + assert_eq!(VkProbe::Missing.as_str(), "missing"); + assert_eq!(VkProbe::default(), VkProbe::Unknown); + assert!(!VkProbe::Unknown.has_loader()); +} + +#[test] +fn loader_candidates_cover_each_platform_signature() { + let windows = loader_candidates(OsFamily::Windows); + assert!( + windows + .iter() + .any(|path| path.ends_with("System32/vulkan-1.dll") + || path.ends_with(r"System32\vulkan-1.dll")), + "Windows 判据应是 System32\\vulkan-1.dll:{windows:?}" + ); + + let linux = loader_candidates(OsFamily::Linux); + assert!( + linux + .iter() + .any(|path| path.to_string_lossy().contains("libvulkan.so.1")), + "Linux 判据应含 libvulkan.so.1:{linux:?}" + ); + + let macos = loader_candidates(OsFamily::MacOs); + let macos_names = macos + .iter() + .map(|path| { + path.file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + }) + .collect::>(); + assert!( + macos_names.iter().any(|name| name == "libvulkan.1.dylib"), + "macOS 判据应含 libvulkan.1.dylib:{macos_names:?}" + ); + assert!( + macos_names.iter().any(|name| name == "libMoltenVK.dylib"), + "macOS 判据应含 MoltenVK:{macos_names:?}" + ); +} + +#[test] +fn registry_query_output_is_parsed_for_driver_entries() { + // 键不存在:只有本地化错误行(中英各一例,真机输出)。 + let missing_en = + "ERROR: The system was unable to find the specified registry key or value.\r\n"; + let missing_zh = "\r\n错误: 系统找不到指定的注册表项或值。\r\n"; + assert!(!registry_has_driver_entry(missing_en)); + assert!(!registry_has_driver_entry(missing_zh)); + assert!(!registry_has_driver_entry("")); +} + +#[test] +fn registry_query_real_format_is_recognised() { + // 真实 `reg query KEY`(不带 /s)格式,本机实测: + // 空行 → 键头行(顶格、完整 hive 名、无尾反斜杠)→ 缩进的值行 + // ` <值名> REG_DWORD 0x0` + // Vulkan 的 ICD 注册是「值名 = manifest 绝对路径」,名字里可能有空格,所以列宽不定。 + let present = "\r\n\ +HKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\Vulkan\\Drivers\r\n \ +C:\\Windows\\System32\\DriverStore\\FileRepository\\amdvlk64.inf_amd64_1b2c\\amdvlk64.json REG_DWORD 0x0\r\n \ +C:\\vendor a\\vk_vendor_a.json REG_DWORD 0x0\r\n"; + assert!(registry_has_driver_entry(present)); + + // 值可能是 REG_SZ(第三方 loader/层注册的写法)。 + let reg_sz = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\Vulkan\\Drivers\r\n \ +C:\\vendor\\vk.json REG_SZ \r\n"; + assert!(registry_has_driver_entry(reg_sz)); +} + +#[test] +fn registry_key_without_values_is_not_a_driver_signal() { + // 键存在但没有任何值项:只有键头行。 + let header_only = "\r\nHKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\Vulkan\\Drivers\r\n"; + assert!(!registry_has_driver_entry(header_only)); + + // 键存在但只有子键(无 /s 时子键也缩进打印,且不带 REG_ 类型标记)。 + let subkeys_only = "\r\n\ +HKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\Vulkan\\Drivers\r\n \ +LayerOne\r\n LayerTwo\r\n"; + assert!(!registry_has_driver_entry(subkeys_only)); + + // 键头是**别的**键时,其值行不能算数(防止把上层/邻居键的值当成 ICD 注册)。 + let other_key = "\r\n\ +HKEY_LOCAL_MACHINE\\SOFTWARE\\Khronos\\Vulkan\r\n \ +ImplicitLayers REG_DWORD 0x0\r\n"; + assert!(!registry_has_driver_entry(other_key)); +} + +#[test] +fn ldconfig_output_is_parsed_for_the_loader() { + let with_loader = + "\tlibvulkan.so.1 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libvulkan.so.1\n\ +\tlibvulkan.so (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libvulkan.so\n"; + assert!(ldconfig_has_libvulkan(with_loader)); + assert!(!ldconfig_has_libvulkan( + "\tlibGL.so.1 (libc6,x86-64) => /usr/lib/libGL.so.1\n" + )); +} + +#[test] +fn icd_env_prefers_the_legacy_variable_and_skips_blanks() { + let vars = |pairs: &'static [(&'static str, &'static str)]| { + env_icd_path(move |key| { + pairs + .iter() + .find(|(name, _)| *name == key) + .map(|(_, value)| (*value).to_string()) + }) + }; + assert_eq!( + vars(&[("VK_ICD_FILENAMES", "/a/nvidia_icd.json")]).as_deref(), + Some("/a/nvidia_icd.json") + ); + assert_eq!( + vars(&[("VK_DRIVER_FILES", "/b/intel_icd.json")]).as_deref(), + Some("/b/intel_icd.json") + ); + // 旧名优先(两者都设时 Khronos loader 也是旧名优先)。 + assert_eq!( + vars(&[ + ("VK_ICD_FILENAMES", "/a/one.json"), + ("VK_DRIVER_FILES", "/b/two.json"), + ]) + .as_deref(), + Some("/a/one.json") + ); + // 空白值当作没设。 + assert_eq!(vars(&[("VK_ICD_FILENAMES", " ")]), None); + assert_eq!(vars(&[]), None); + assert_eq!(ICD_ENV_VARS.len(), 2); + assert_eq!(ICD_DIRS.len(), 3); + assert!(VULKAN_DRIVERS_KEY.contains("Khronos")); +} + +#[test] +fn icd_manifest_count_ignores_non_json_and_missing_dirs() { + let dir = temp_dir("vk-icd"); + assert_eq!(count_icd_manifests(&dir), 0); + std::fs::write(dir.join("nvidia_icd.json"), "{}").expect("写 json"); + std::fs::write(dir.join("intel_icd.json"), "{}").expect("写 json"); + std::fs::write(dir.join("README"), "x").expect("写非 json"); + assert_eq!(count_icd_manifests(&dir), 2); + assert_eq!(count_icd_manifests(&dir.join("nope")), 0); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn probe_vk_on_this_host_is_not_unknown() { + // 真探一次:只断言「给了确定结论」(本机有没有 Vulkan 都合法,不许恒 Unknown)。 + assert_ne!(probe_vk(), VkProbe::Unknown); +} + +/// 唯一临时目录(不引 tempfile 依赖)。 +fn temp_dir(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let index = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("ull-vk-{tag}-{}-{index}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("创建临时目录"); + dir +} diff --git a/crates/core/tests/platform/windows_version_cases.rs b/crates/core/tests/platform/windows_version_cases.rs new file mode 100644 index 0000000..0a91068 --- /dev/null +++ b/crates/core/tests/platform/windows_version_cases.rs @@ -0,0 +1,179 @@ +//! Windows 版本探测:注册表 / `ver` 输出的解析 + 本机真实探测。 + +use crate::prelude::*; + +/// Win11(`CurrentMajorVersionNumber` 是 REG_DWORD,`reg query` 输出 0x..)。 +const WIN11_REG: &str = "\ +HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion + CurrentBuildNumber REG_SZ 22631 + CurrentMajorVersionNumber REG_DWORD 0xa + CurrentMinorVersionNumber REG_DWORD 0x0 + CurrentVersion REG_SZ 6.3 + ProductName REG_SZ Windows 10 Pro +"; + +/// Win10:数字键存在,`CurrentVersion` 是过期的 6.3,不能拿它当版本。 +const WIN10_REG: &str = "\ +HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion + CurrentBuildNumber REG_SZ 19045 + CurrentMajorVersionNumber REG_DWORD 0xa + CurrentMinorVersionNumber REG_DWORD 0x0 + CurrentVersion REG_SZ 6.3 +"; + +/// Win7:没有数字键,只有 `CurrentVersion` 字符串(`CurrentBuildNumber` Win7 也有)。 +const WIN7_REG: &str = "\ +HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion + CurrentBuildNumber REG_SZ 7601 + CurrentVersion REG_SZ 6.1 + ProductName REG_SZ Windows 7 Ultimate +"; + +#[test] +fn reg_query_parses_windows_11() { + let version = windows_version::parse_reg_query(WIN11_REG).expect("解析失败"); + assert_eq!(version, WindowsVersion::new(10, 0, 22631)); + assert_eq!(version.version_string(), "10.0.22631"); + assert_eq!(version.display_name(), "Windows 11"); +} + +#[test] +fn reg_query_prefers_numeric_keys_over_stale_current_version() { + let version = windows_version::parse_reg_query(WIN10_REG).expect("解析失败"); + assert_eq!(version, WindowsVersion::new(10, 0, 19045)); + assert_eq!(version.display_name(), "Windows 10"); +} + +#[test] +fn reg_query_falls_back_to_current_version_on_windows_7() { + let version = windows_version::parse_reg_query(WIN7_REG).expect("解析失败"); + assert_eq!(version, WindowsVersion::new(6, 1, 7601)); + assert_eq!(version.major_minor(), (6, 1)); + assert_eq!(version.display_name(), "Windows 7"); +} + +#[test] +fn reg_query_without_build_number_is_unparsable() { + let text = " CurrentVersion REG_SZ 6.1\n"; + assert_eq!(windows_version::parse_reg_query(text), None); + // 空输出(reg 不可用 / 被策略禁用)也不能崩。 + assert_eq!(windows_version::parse_reg_query(""), None); + assert_eq!( + windows_version::parse_reg_query("ERROR: 找不到指定的注册表项"), + None + ); +} + +#[test] +fn ver_output_parses_english_and_chinese() { + let english = + windows_version::parse_ver_output("Microsoft Windows [Version 10.0.19045.4046]\r\n") + .expect("解析失败"); + assert_eq!(english, WindowsVersion::new(10, 0, 19045)); + + let chinese = windows_version::parse_ver_output( + "Microsoft Windows [\u{7248}\u{672c} 10.0.19045.4046]\r\n", + ) + .expect("解析失败"); + assert_eq!(chinese, english); + + let win7 = windows_version::parse_ver_output("Microsoft Windows [Version 6.1.7601]") + .expect("解析失败"); + assert_eq!(win7, WindowsVersion::new(6, 1, 7601)); + assert_eq!(win7.display_name(), "Windows 7"); +} + +#[test] +fn ver_output_rejects_garbage() { + assert_eq!(windows_version::parse_ver_output(""), None); + assert_eq!(windows_version::parse_ver_output("no brackets here"), None); + assert_eq!( + windows_version::parse_ver_output("Microsoft Windows [Version ]"), + None + ); +} + +#[test] +fn build_number_maps_to_version_families() { + // 快路径:单值查询只给 build,major.minor 由 build 族反推。 + assert_eq!( + windows_version::from_build(7601), + Some(WindowsVersion::new(6, 1, 7601)) + ); + assert_eq!( + windows_version::from_build(9200).map(|v| v.major_minor()), + Some((6, 2)) + ); + assert_eq!( + windows_version::from_build(9600).map(|v| v.major_minor()), + Some((6, 3)) + ); + assert_eq!( + windows_version::from_build(19045).map(|v| v.major_minor()), + Some((10, 0)) + ); + assert_eq!( + windows_version::from_build(22631).map(|v| v.display_name()), + Some("Windows 11") + ); + assert_eq!( + windows_version::from_build(26100).map(|v| v.major_minor()), + Some((10, 0)) + ); + // Vista 之前不猜:交给整键/`ver` 解析兜底。 + assert_eq!(windows_version::from_build(3790), None); + assert_eq!(windows_version::from_build(0), None); +} + +#[test] +fn single_value_query_yields_build_number() { + let text = "\ +HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion + CurrentBuildNumber REG_SZ 26100 +"; + assert_eq!(windows_version::parse_build_number(text), Some(26100)); + assert_eq!( + windows_version::parse_build_number("ERROR: \u{627e}\u{4e0d}\u{5230}\u{6307}\u{5b9a}\u{7684}\u{6ce8}\u{518c}\u{8868}\u{9879}"), + None + ); + assert_eq!(windows_version::parse_build_number(""), None); +} + +#[test] +fn display_name_covers_the_matrix() { + assert_eq!( + WindowsVersion::new(10, 0, 22_000).display_name(), + "Windows 11" + ); + assert_eq!( + WindowsVersion::new(10, 0, 21_999).display_name(), + "Windows 10" + ); + assert_eq!( + WindowsVersion::new(6, 3, 9600).display_name(), + "Windows 8.1" + ); + assert_eq!(WindowsVersion::new(6, 2, 9200).display_name(), "Windows 8"); + assert_eq!(WindowsVersion::new(6, 1, 7601).display_name(), "Windows 7"); + assert_eq!( + WindowsVersion::new(6, 0, 6002).display_name(), + "Windows Vista" + ); + assert_eq!(WindowsVersion::new(5, 1, 2600).display_name(), "Windows XP"); +} + +#[test] +fn probe_matches_the_host() { + let probed = windows_version::probe(); + if cfg!(target_os = "windows") { + let version = probed.expect("Windows 宿主必须能探到真实版本"); + // 真实的 Windows 至少是 6.x(XP 是 5.x,不会被这个仓库支持)。 + assert!(version.major >= 6, "探到 {version:?}"); + assert!(!version.display_name().is_empty()); + // 重新探一次结果稳定(reg 与 ver 两条路径应给同一个 major.minor)。 + let again = windows_version::probe().expect("二次探测失败"); + assert_eq!(again.major_minor(), version.major_minor()); + } else { + assert!(probed.is_none(), "非 Windows 平台不应探到 Windows 版本"); + } +} diff --git a/crates/core/tests/setting/appearance_keys.rs b/crates/core/tests/setting/appearance_keys.rs new file mode 100644 index 0000000..e66fba0 --- /dev/null +++ b/crates/core/tests/setting/appearance_keys.rs @@ -0,0 +1,196 @@ +//! 「外观(高级)」键簇(`setting::appearance` + `keys::launcher_schema` 的 appearance.* 段)。 +//! +//! 守三条设计约束(09-ui-layout §111): +//! 1. 一切高级特性**默认关闭**(出厂 Metro 纯色)——默认值即出厂态; +//! 2. 全部走 D6 schema 且 **AI 可写**(白名单内,可被 agent 调节); +//! 3. 值域有硬边界(滑条夹取),坏值/手改配置不崩(回落默认)。 + +use serde_json::json; +use unlucky_core::setting::appearance::{ + hex_to_rgb, normalize_hex, relative_luminance, Appearance, BgFill, DEFAULT_ACCENT, + PANEL_ALPHA_MIN, +}; +use unlucky_core::setting::document::SettingDocument; +use unlucky_core::setting::keys::{launcher_schema, launcher_version}; +use unlucky_core::setting::spec::ValueKind; + +fn doc(raw: serde_json::Value) -> SettingDocument { + SettingDocument::load(&launcher_schema(), &raw).0 +} + +#[test] +fn factory_defaults_are_all_advanced_features_off() { + let appearance = Appearance::from_store(&unlucky_core::setting::store::SettingStore::load( + std::env::temp_dir().join(format!("ull-appearance-none-{}.json", std::process::id())), + launcher_schema(), + )); + assert_eq!(appearance.theme_mode, "auto", "D17:出厂跟随系统"); + assert!(!appearance.bg_image_enabled, "背景图默认关"); + assert!(!appearance.bg_image_active()); + assert_eq!(appearance.bg_fill, BgFill::Cover); + assert_eq!(appearance.panel_alpha, 1.0, "出厂完全不透明(Metro 纯色)"); + assert!(!appearance.glass, "玻璃拟态默认关"); + assert_eq!(appearance.accent_color, "", "空串 = 未自定义"); + assert_eq!(appearance.accent_or_default(), DEFAULT_ACCENT); +} + +#[test] +fn reading_a_configured_document_reflects_each_key() { + let document = doc(json!({ + "appearance": { + "theme_mode": "dark", + "bg_image_enabled": true, + "bg_image": "D:/wall/paper.png", + "bg_fill": "tile", + "bg_dim": 0.6, + "panel_alpha": 0.8, + "glass": true, + "accent_color": "#ff5722", + } + })); + let appearance = Appearance::read(&document); + assert_eq!(appearance.theme_mode, "dark"); + assert!(appearance.bg_image_active()); + assert_eq!(appearance.bg_image, "D:/wall/paper.png"); + assert_eq!(appearance.bg_fill, BgFill::Tile); + assert!((appearance.bg_dim - 0.6).abs() < 1e-9); + assert!((appearance.panel_alpha - 0.8).abs() < 1e-9); + assert!(appearance.glass); + assert_eq!(appearance.accent_color, "#FF5722", "读入即规范化大写"); + assert_eq!(appearance.accent_or_default(), "#FF5722"); +} + +#[test] +fn bad_values_fall_back_instead_of_panicking() { + // 手改坏的配置:枚举拼错 / 开关类型错 / accent 不是 #RRGGBB。 + let document = doc(json!({ + "appearance": { + "bg_fill": "stretch", + "bg_image_enabled": "yes", + "accent_color": "rebeccapurple", + } + })); + let appearance = Appearance::read(&document); + assert_eq!(appearance.bg_fill, BgFill::Cover); + assert!(!appearance.bg_image_enabled); + assert_eq!(appearance.accent_color, ""); + assert_eq!(appearance.accent_or_default(), DEFAULT_ACCENT); +} + +#[test] +fn panel_alpha_is_clamped_to_the_contrast_floor() { + // schema 下限 0.4;读侧再夹一次(挡手改文件绕过 schema 的情况)。 + let document = doc(json!({ "appearance": { "panel_alpha": 0.05 } })); + assert!((Appearance::read(&document).panel_alpha - PANEL_ALPHA_MIN).abs() < 1e-9); +} + +#[test] +fn appearance_keys_are_all_ai_writable_and_registered() { + let schema = launcher_schema(); + // 主题三态是 D17 已定案的最轻量键(silent/Low),其余高级键留审计痕迹(audited/Medium)。 + let audited_keys = [ + "appearance.bg_image_enabled", + "appearance.bg_image", + "appearance.bg_fill", + "appearance.bg_dim", + "appearance.panel_alpha", + "appearance.glass", + "appearance.accent_color", + ]; + for key in audited_keys { + let spec = schema.spec(key).unwrap_or_else(|| panic!("{key} 必须登记")); + assert!(spec.ai.writable, "{key} 必须在 AI 可写白名单内"); + // 纯视觉、可一键复原 → 不要求弹窗确认(High 级留给凭据/数据根/代理)。 + assert!( + !spec.ai.sensitivity.requires_confirmation(), + "{key} 不该要确认" + ); + assert_eq!( + spec.ai.sensitivity.as_str(), + "medium", + "{key} 要留审计痕迹(AI 改过什么可回看)" + ); + } + // 主题三态仍是 silent(D17 已定案的最轻量键)。 + assert_eq!( + schema + .spec("appearance.theme_mode") + .unwrap() + .ai + .sensitivity + .as_str(), + "low" + ); +} + +#[test] +fn schema_registration_shape_is_right() { + assert_eq!(launcher_version().minor, 3, "1.3.0 登记外观键"); + let schema = launcher_schema(); + assert_eq!( + schema.spec("appearance.bg_fill").unwrap().kind, + ValueKind::Enum(&["cover", "contain", "tile"]) + ); + assert!(matches!( + schema.spec("appearance.panel_alpha").unwrap().kind, + ValueKind::Float { min, max } if (min - PANEL_ALPHA_MIN).abs() < 1e-9 && (max - 1.0).abs() < 1e-9 + )); + assert_eq!( + schema.spec("appearance.accent_color").unwrap().kind, + ValueKind::Text { max_len: 9 }, + "#RRGGBB 最多 7 字符,留 9 容错带井号与空白" + ); + // 默认值 = 出厂态 + assert_eq!( + schema.spec("appearance.panel_alpha").unwrap().default, + serde_json::Value::from(1.0) + ); + assert_eq!( + schema.spec("appearance.glass").unwrap().default, + serde_json::Value::from(false) + ); +} + +#[test] +fn write_round_trips_through_the_store() { + let temp = + std::env::temp_dir().join(format!("ull-appearance-{}-{}", std::process::id(), line!())); + std::fs::create_dir_all(temp.join("config")).expect("临时目录"); + let path = temp.join("config/launcher.json"); + let _ = std::fs::remove_file(&path); + + let mut store = unlucky_core::setting::store::SettingStore::load(&path, launcher_schema()); + let mut appearance = Appearance::from_store(&store); + appearance.theme_mode = "dark".to_owned(); + appearance.panel_alpha = 0.8; + appearance.bg_image_enabled = true; + appearance.bg_image = "D:/wall/p.png".to_owned(); + appearance.accent_color = "#0f6cbd".to_owned(); + appearance.write(&mut store).expect("写外观键"); + store.save().expect("落盘"); + + let reloaded = Appearance::from_store(&unlucky_core::setting::store::SettingStore::load( + &path, + launcher_schema(), + )); + assert_eq!(reloaded.theme_mode, "dark"); + assert!((reloaded.panel_alpha - 0.8).abs() < 1e-9); + assert!(reloaded.bg_image_active()); + assert_eq!(reloaded.accent_color, "#0F6CBD"); + + std::fs::remove_dir_all(&temp).ok(); +} + +#[test] +fn hex_helpers_validate_and_convert() { + assert_eq!(normalize_hex(" 0f6cbd "), Some("#0F6CBD".to_owned())); + assert_eq!(normalize_hex("#0F6CBD"), Some("#0F6CBD".to_owned())); + assert_eq!(normalize_hex("#0F6CB"), None); + assert_eq!(normalize_hex("#GGGGGG"), None); + assert_eq!(hex_to_rgb("#0078D4"), Some((0x00, 0x78, 0xD4))); + // WCAG 相对亮度:纯黑 0、纯白 1、Metro 蓝居中偏暗。 + assert!(relative_luminance("#000000").unwrap().abs() < 1e-9); + assert!((relative_luminance("#FFFFFF").unwrap() - 1.0).abs() < 1e-9); + let metro = relative_luminance(DEFAULT_ACCENT).unwrap(); + assert!(metro > 0.05 && metro < 0.3, "Metro 蓝亮度 {metro}"); +} diff --git a/crates/core/tests/setting/atomic_write.rs b/crates/core/tests/setting/atomic_write.rs new file mode 100644 index 0000000..bd39df2 --- /dev/null +++ b/crates/core/tests/setting/atomic_write.rs @@ -0,0 +1,49 @@ +//! 原子写:覆盖语义、父目录创建、不留临时文件、可选读。 + +use unlucky_core::platform::atomic::{read_optional, tmp_path, write_atomic}; + +use crate::common::{dir_entries, TempDir}; + +#[test] +fn writes_and_replaces_in_place() { + let temp = TempDir::new("atomic"); + let path = temp.child("config/launcher.json"); + + write_atomic(&path, b"{\"a\":1}").expect("首次写"); + assert_eq!(temp.read("config/launcher.json"), "{\"a\":1}"); + + write_atomic(&path, b"{\"a\":2}").expect("覆盖写"); + assert_eq!(temp.read("config/launcher.json"), "{\"a\":2}"); + + // 目录里只该有目标文件,临时文件必须被改名掉。 + assert_eq!(dir_entries(&temp.child("config")), vec!["launcher.json"]); +} + +#[test] +fn read_optional_distinguishes_missing_from_failure() { + let temp = TempDir::new("atomic-read"); + let path = temp.child("nope.json"); + assert!(read_optional(&path).expect("读缺失文件").is_none()); + + temp.write("here.json", "内容"); + let bytes = read_optional(&temp.child("here.json")) + .expect("读文件") + .expect("有内容"); + assert_eq!(bytes, "内容".as_bytes()); +} + +#[test] +fn tmp_name_stays_in_same_directory() { + let temp = TempDir::new("atomic-tmp"); + let path = temp.child("config/profiles.json"); + let tmp = tmp_path(&path); + assert_eq!( + tmp.parent(), + path.parent(), + "临时文件必须同目录(rename 才原子)" + ); + assert_ne!(tmp, path); + let first = tmp_path(&path); + let second = tmp_path(&path); + assert_ne!(first, second, "并发写要能拿到不同的临时名"); +} diff --git a/crates/core/tests/setting/common.rs b/crates/core/tests/setting/common.rs new file mode 100644 index 0000000..4975416 --- /dev/null +++ b/crates/core/tests/setting/common.rs @@ -0,0 +1,81 @@ +//! 测试公用小工具(临时目录 + 文件读写)。 +//! +//! 不引 `tempfile`:M11 的依赖面只有 serde/serde_json,测试自己管临时目录。 + +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +/// 进程内唯一的临时目录,`Drop` 时递归删除。 +pub struct TempDir { + path: PathBuf, +} + +impl TempDir { + /// 建一个临时目录。 + pub fn new(label: &str) -> Self { + let index = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "ull-setting-{label}-{}-{index}", + std::process::id() + )); + std::fs::remove_dir_all(&path).ok(); + std::fs::create_dir_all(&path).expect("创建临时目录"); + Self { path } + } + + /// 根路径。 + pub fn path(&self) -> &Path { + &self.path + } + + /// 拼接子路径(不创建)。 + pub fn child(&self, name: &str) -> PathBuf { + self.path.join(name) + } + + /// 写一个文件(自动建父目录)。 + pub fn write(&self, name: &str, contents: &str) -> PathBuf { + let path = self.child(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("创建父目录"); + } + std::fs::write(&path, contents).expect("写文件"); + path + } + + /// 读一个文件。 + pub fn read(&self, name: &str) -> String { + std::fs::read_to_string(self.child(name)).expect("读文件") + } + + /// 子路径是否存在。 + pub fn exists(&self, name: &str) -> bool { + self.child(name).exists() + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.path).ok(); + } +} + +/// 目录里的文件名(排序后返回,便于断言没留下临时文件)。 +pub fn dir_entries(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .expect("读目录") + .map(|entry| { + entry + .expect("目录项") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect(); + names.sort(); + names +} diff --git a/crates/core/tests/setting/debounce.rs b/crates/core/tests/setting/debounce.rs new file mode 100644 index 0000000..be91d1c --- /dev/null +++ b/crates/core/tests/setting/debounce.rs @@ -0,0 +1,128 @@ +//! 保存通道:200ms 去抖、同路径覆盖、攒批落盘、后台线程 flush/关停。 + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use unlucky_core::setting::error::SettingResult; +use unlucky_core::setting::save::{CoalescingWriter, DebounceQueue, JobSink, DEFAULT_DEBOUNCE}; +use unlucky_core::setting::save_worker::SaveWorker; + +use crate::common::TempDir; + +/// 记录下来的 (路径, 内容) 列表。 +type Written = Vec<(PathBuf, Vec)>; + +#[derive(Clone, Default)] +struct RecordingSink { + written: Arc>, +} + +impl RecordingSink { + fn entries(&self) -> Vec<(PathBuf, Vec)> { + self.written.lock().expect("锁").clone() + } +} + +impl JobSink for RecordingSink { + fn write(&self, path: &Path, payload: &[u8]) -> SettingResult<()> { + self.written + .lock() + .expect("锁") + .push((path.to_path_buf(), payload.to_vec())); + Ok(()) + } +} + +#[test] +fn default_debounce_is_200ms() { + assert_eq!(DEFAULT_DEBOUNCE, Duration::from_millis(200)); +} + +#[test] +fn waits_for_the_window_before_writing() { + let start = Instant::now(); + let mut queue = DebounceQueue::::new(DEFAULT_DEBOUNCE); + assert!(queue.is_empty()); + + queue.enqueue("a".to_owned(), b"1".to_vec(), start); + queue.enqueue("b".to_owned(), b"2".to_vec(), start); + assert_eq!(queue.len(), 2); + assert!(queue.contains(&"a".to_owned())); + assert_eq!(queue.next_due(), Some(start + DEFAULT_DEBOUNCE)); + + assert!(queue + .drain_due(start + Duration::from_millis(100)) + .is_empty()); + let due = queue.drain_due(start + DEFAULT_DEBOUNCE); + assert_eq!(due.len(), 2); + assert_eq!(due[0].0, "a", "同一批按键排序,写盘顺序稳定"); + assert!(queue.is_empty()); +} + +#[test] +fn same_key_collapses_and_keeps_first_deadline() { + let start = Instant::now(); + let mut queue = DebounceQueue::::new(DEFAULT_DEBOUNCE); + queue.enqueue("a".to_owned(), b"1".to_vec(), start); + queue.enqueue( + "a".to_owned(), + b"2".to_vec(), + start + Duration::from_millis(150), + ); + + assert_eq!(queue.len(), 1); + assert_eq!( + queue.next_due(), + Some(start + DEFAULT_DEBOUNCE), + "窗口从首次改动开始算,避免连续改动无限推迟写盘" + ); + assert_eq!( + queue.drain_due(start + DEFAULT_DEBOUNCE), + vec![("a".to_owned(), b"2".to_vec())] + ); +} + +#[test] +fn coalescing_writer_batches_into_one_flush() { + let sink = RecordingSink::default(); + let mut writer = CoalescingWriter::new(sink.clone(), DEFAULT_DEBOUNCE); + let start = Instant::now(); + + writer.queue("/tmp/a.json", b"1".to_vec(), start); + writer.queue( + "/tmp/a.json", + b"2".to_vec(), + start + Duration::from_millis(50), + ); + writer.queue("/tmp/b.json", b"3".to_vec(), start); + assert_eq!(writer.pending_len(), 2); + assert!(!writer.is_empty()); + + assert_eq!(writer.flush_due(start).expect("未到窗口"), 0); + assert_eq!( + writer.flush_due(start + DEFAULT_DEBOUNCE).expect("到窗口"), + 2 + ); + assert!(writer.is_empty()); + assert_eq!(writer.flush_all().expect("空队列兜底"), 0); + + let written = sink.entries(); + assert_eq!(written.len(), 2); + assert_eq!(written[0].0, PathBuf::from("/tmp/a.json")); + assert_eq!(written[0].1, b"2".to_vec(), "只写最新内容"); +} + +#[test] +fn save_worker_flushes_and_shuts_down() { + let temp = TempDir::new("save-worker"); + let target = temp.child("config/launcher.json"); + let mut worker = SaveWorker::spawn(Duration::from_millis(30)).expect("起线程"); + + worker.queue(&target, b"{\"a\":1}").expect("入队"); + worker.queue(&target, b"{\"a\":2}").expect("入队"); + worker.flush().expect("同步落盘"); + + assert_eq!(temp.read("config/launcher.json"), "{\"a\":2}"); + worker.shutdown().expect("关停"); +} diff --git a/crates/core/tests/setting/deprecated_keys.rs b/crates/core/tests/setting/deprecated_keys.rs new file mode 100644 index 0000000..69845a7 --- /dev/null +++ b/crates/core/tests/setting/deprecated_keys.rs @@ -0,0 +1,142 @@ +//! 键废弃机制:`ui.theme` → `appearance.theme_mode`(tower 键冲突裁决)。 +//! +//! 规则:**读兼容**(旧值按 `THEME_ALIASES` 映射,新键已在则新键优先)+ **写永远走新键** +//! (`set` 旧键直接报错,落盘不含旧键,AI 白名单与工具 JSON Schema 也不含旧键)。 + +use serde_json::{json, Value}; +use unlucky_core::setting::document::{LoadProblem, SettingDocument}; +use unlucky_core::setting::keys::{launcher_schema, LEGACY_THEMES, THEME_ALIASES, THEME_MODES}; +use unlucky_core::setting::schema::{SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY}; +use unlucky_core::setting::store::SettingStore; + +use crate::common::TempDir; + +fn legacy_file(raw_ui: Value) -> Value { + json!({ + SCHEMA_KEY: SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 0, 0)).url(), + "ui": raw_ui + }) +} + +fn deprecated_problem(problems: &[LoadProblem]) -> Option<&'static str> { + problems.iter().find_map(|problem| match problem { + LoadProblem::DeprecatedKey { + key, replaced_by, .. + } if key == "ui.theme" => Some(*replaced_by), + _ => None, + }) +} + +#[test] +fn legacy_theme_values_are_translated_on_read() { + assert_eq!(THEME_MODES.len(), 3); + assert!(THEME_MODES.contains(&"auto") && THEME_MODES.contains(&"light")); + assert!(THEME_MODES.contains(&"dark")); + assert_eq!( + LEGACY_THEMES.len(), + THEME_ALIASES.len(), + "三个旧值都要有映射" + ); + + for (legacy, expected) in [ + ("system", "auto"), + ("metro_light", "light"), + ("metro_dark", "dark"), + ] { + let (doc, problems) = + SettingDocument::load(&launcher_schema(), &legacy_file(json!({ "theme": legacy }))); + + assert_eq!( + doc.effective_str("appearance.theme_mode"), + Some(expected), + "旧值 {legacy} 应映射成 {expected}" + ); + assert_eq!( + doc.legacy_value("ui.theme").and_then(Value::as_str), + Some(legacy), + "旧键原文要留着给 UI 提示/回滚" + ); + assert_eq!(deprecated_problem(&problems), Some("appearance.theme_mode")); + assert!(!doc.is_dirty(), "读兼容不算用户改动"); + } +} + +#[test] +fn new_key_wins_when_both_present() { + let raw = json!({ + SCHEMA_KEY: SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 0, 0)).url(), + "ui": { "theme": "metro_dark" }, + "appearance": { "theme_mode": "light" } + }); + let (doc, _) = SettingDocument::load(&launcher_schema(), &raw); + assert_eq!(doc.effective_str("appearance.theme_mode"), Some("light")); +} + +#[test] +fn unmapped_legacy_value_is_reported() { + let (doc, problems) = + SettingDocument::load(&launcher_schema(), &legacy_file(json!({ "theme": "neon" }))); + assert_eq!(doc.effective_str("appearance.theme_mode"), Some("neon")); + assert!( + problems.iter().any(|problem| matches!( + problem, + LoadProblem::InvalidValue { key, .. } if key == "appearance.theme_mode" + )), + "映射不过去的旧值要报出来: {problems:?}" + ); +} + +#[test] +fn writing_the_deprecated_key_is_refused() { + let (mut doc, _) = SettingDocument::load(&launcher_schema(), &json!({})); + let err = doc + .set("ui.theme", json!("metro_dark")) + .expect_err("旧键禁止写"); + assert!(err.to_string().contains("appearance.theme_mode"), "{err}"); + assert!(!doc.is_dirty()); +} + +#[test] +fn save_drops_the_old_key_and_writes_the_new_one() { + let temp = TempDir::new("deprecated-save"); + temp.write( + "config/launcher.json", + &legacy_file(json!({ "theme": "metro_dark", "language": "en_us" })).to_string(), + ); + + let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); + assert_eq!( + store.effective("appearance.theme_mode"), + Some(&json!("dark")) + ); + + // 随便改一个别的键触发一次保存:旧键消失、新键(映射值)落盘。 + store.set("ui.scale", json!(1.25)).expect("改缩放"); + store.save().expect("保存"); + + let saved: Value = serde_json::from_str(&temp.read("config/launcher.json")).expect("JSON 合法"); + assert_eq!(saved["appearance"]["theme_mode"], json!("dark")); + assert!( + saved["ui"].get("theme").is_none(), + "废弃键不再落盘: {saved}" + ); + assert_eq!(saved["ui"]["language"], json!("en_us")); +} + +#[test] +fn whitelist_and_tool_schema_exclude_the_old_key() { + let schema = launcher_schema(); + assert_eq!(schema.deprecated_keys(), vec!["ui.theme"]); + assert_eq!( + schema.replacement_of("ui.theme"), + Some("appearance.theme_mode") + ); + assert_eq!(schema.replacement_of("appearance.theme_mode"), None); + assert!(!schema.accepts_ai_write("ui.theme"), "废弃键不许 AI 写"); + assert!(!schema.ai_writable_keys().contains(&"ui.theme")); + + let projected = schema.to_json_schema(); + let properties = projected["properties"].as_object().expect("properties"); + assert!(properties.contains_key("appearance.theme_mode")); + assert!(!properties.contains_key("ui.theme")); +} diff --git a/crates/core/tests/setting/dir_migration.rs b/crates/core/tests/setting/dir_migration.rs new file mode 100644 index 0000000..8a0d0c0 --- /dev/null +++ b/crates/core/tests/setting/dir_migration.rs @@ -0,0 +1,185 @@ +//! 数据目录迁移:计划校验、逐分区搬动、收据 + 指针、失败逆序回滚。 + +use std::path::{Path, PathBuf}; + +use unlucky_core::platform::data_dir::{DataMode, ResolvedData}; +use unlucky_core::platform::dir_migration::{self, DirMigrationOptions}; +use unlucky_core::platform::error::PlatformError; +use unlucky_core::platform::layout::{LauncherLayout, Partition}; +use unlucky_core::platform::receipt::{self, ReceiptKind}; +use unlucky_core::platform::redirect; + +use crate::common::TempDir; + +fn resolved(home: &Path) -> ResolvedData { + ResolvedData { + mode: DataMode::Custom, + launcher_home: home.to_path_buf(), + game_home: home.join(".minecraft"), + redirected_from: None, + } +} + +fn seed(home: &Path) { + let layout = LauncherLayout::new(home.to_path_buf()); + layout.ensure().expect("建四分区"); + std::fs::create_dir_all(layout.auth_data()).expect("auth 目录"); + std::fs::create_dir_all(layout.sha1_cache("ab")).expect("缓存分片"); + std::fs::write(layout.launcher_config(), b"{\"x\":1}").expect("写配置"); + std::fs::write(layout.auth_data().join("token.json"), b"{}").expect("写凭据"); + std::fs::write(layout.sha1_cache("ab").join("hash"), b"payload").expect("写缓存"); + std::fs::write(layout.logs().join("launcher.log"), b"log").expect("写日志"); +} + +#[test] +fn plan_orders_partitions_and_skips_missing_ones() { + let temp = TempDir::new("dir-migration-plan"); + seed(&temp.child("old/.ULL")); + let from = resolved(&temp.child("old/.ULL")); + let to = resolved(&temp.child("new/.ULL")); + + let plan = dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect("计划"); + let partitions: Vec = plan.steps.iter().map(|step| step.partition).collect(); + assert_eq!( + partitions, + vec![ + Partition::Config, + Partition::Data, + Partition::Logs, + Partition::Cache + ], + "配置最先、缓存最后" + ); + + let cache_off = DirMigrationOptions { + migrate_cache: false, + }; + let plan = dir_migration::plan(&from, &to, &cache_off).expect("计划"); + assert_eq!(plan.steps.len(), 3); + assert!(plan + .steps + .iter() + .all(|step| step.partition != Partition::Cache)); +} + +#[test] +fn execute_moves_everything_and_leaves_a_pointer() { + let temp = TempDir::new("dir-migration-run"); + let old_home = temp.child("old/.ULL"); + let new_home = temp.child("new/.ULL"); + seed(&old_home); + let from = resolved(&old_home); + let to = resolved(&new_home); + + let plan = dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect("计划"); + let outcome = dir_migration::execute(&plan, 1_700_000_000_000).expect("执行"); + + assert_eq!( + outcome.moved, + vec![ + Partition::Config, + Partition::Data, + Partition::Logs, + Partition::Cache + ] + ); + + let moved = LauncherLayout::new(new_home.clone()); + assert!(moved.launcher_config().is_file(), "配置要搬过去"); + assert!(moved.auth_data().join("token.json").is_file()); + assert!(moved.logs().join("launcher.log").is_file()); + assert!(moved.sha1_cache("ab").join("hash").is_file()); + assert!(!LauncherLayout::new(old_home.clone()).config().exists()); + + let stored = receipt::read(&outcome.receipt_path) + .expect("读收据") + .expect("有收据"); + assert_eq!(stored.kind, ReceiptKind::DataDirMove); + assert_eq!(stored.source, old_home); + assert_eq!(stored.target, new_home); + assert_eq!(stored.applied_at_ms, 1_700_000_000_000); + + let pointer = redirect::read(&old_home) + .expect("读指针") + .expect("旧目录要留指针"); + assert_eq!(pointer.launcher_home, new_home); + assert_eq!(pointer.mode, DataMode::Custom); + assert_eq!(outcome.pointer_path, old_home.join("redirect.json")); +} + +#[test] +fn refuses_same_source_and_target() { + let temp = TempDir::new("dir-migration-same"); + seed(&temp.child("only/.ULL")); + let home = resolved(&temp.child("only/.ULL")); + let err = dir_migration::plan(&home, &home, &DirMigrationOptions::default()) + .expect_err("源目标相同要拒绝"); + assert!( + matches!(err, PlatformError::MigrationRefused { .. }), + "{err}" + ); +} + +#[test] +fn refuses_missing_source_and_non_empty_target() { + let temp = TempDir::new("dir-migration-refuse"); + let from = resolved(&temp.child("gone/.ULL")); + let to = resolved(&temp.child("new/.ULL")); + assert!(matches!( + dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect_err("源不存在"), + PlatformError::MigrationRefused { .. } + )); + + seed(&temp.child("old/.ULL")); + let from = resolved(&temp.child("old/.ULL")); + std::fs::create_dir_all(to.launcher_home.clone()).expect("建目标"); + std::fs::write(to.launcher_home.join("something"), b"x").expect("占位"); + let err = dir_migration::plan(&from, &to, &DirMigrationOptions::default()) + .expect_err("目标非空要拒绝"); + assert!(err.to_string().contains("已有数据"), "{err}"); +} + +#[test] +fn rolls_back_moved_partitions_when_a_later_step_fails() { + let temp = TempDir::new("dir-migration-rollback"); + let old_home = temp.child("old/.ULL"); + let new_home = temp.child("new/.ULL"); + seed(&old_home); + let from = resolved(&old_home); + let to = resolved(&new_home); + + let plan = dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect("计划"); + // 计划之后在目标侧造障碍:data 位置先占成普通文件 → 搬 data 必失败。 + std::fs::create_dir_all(&new_home).expect("建目标根"); + std::fs::write(new_home.join("data"), b"obstruction").expect("造障碍"); + + let err = dir_migration::execute(&plan, 42).expect_err("应当失败"); + assert!( + matches!(err, PlatformError::MigrationRefused { .. }), + "{err}" + ); + + let old = LauncherLayout::new(old_home); + assert!(old.launcher_config().is_file(), "config 必须被回滚搬回来"); + assert!(old.auth_data().join("token.json").is_file()); + assert!(!old.redirect_path().exists(), "失败不该留指针"); + assert!(!new_home.join("config").exists(), "目标侧不该残留已搬分区"); + assert!(!new_home.join("data").is_dir(), "障碍文件保持原样"); +} + +#[test] +fn receipt_anti_replay_uses_source_target_signature() { + let temp = TempDir::new("dir-migration-receipt"); + let receipt_path: PathBuf = temp.child("config/migration-receipt.json"); + let receipt = receipt::MigrationReceipt::data_dir_move( + PathBuf::from("/old/.ULL"), + PathBuf::from("/new/.ULL"), + 1_700_000_000_000, + ); + receipt::write(&receipt_path, &receipt).expect("写收据"); + let loaded = receipt::read(&receipt_path).expect("读").expect("有"); + assert_eq!( + receipt::decide(Some(&loaded), &receipt.source_fingerprint), + receipt::ReplayDecision::SkipSameSource + ); +} diff --git a/crates/core/tests/setting/dirs.rs b/crates/core/tests/setting/dirs.rs new file mode 100644 index 0000000..8579a0a --- /dev/null +++ b/crates/core/tests/setting/dirs.rs @@ -0,0 +1,205 @@ +//! 三模式数据目录解析:Portable / Standard / Custom + 平台落点 + 高级覆盖 + redirect 跟随。 + +use std::path::PathBuf; + +use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, ResolvedData, Resolver}; +use unlucky_core::platform::error::PlatformError; +use unlucky_core::platform::paths::{standard_game_home, standard_launcher_home, MapEnv, Platform}; +use unlucky_core::platform::redirect::{self, RedirectPointer, REDIRECT_SCHEMA_VERSION}; + +use crate::common::TempDir; + +#[test] +fn portable_puts_dot_ull_and_minecraft_beside_exe() { + let env = MapEnv::new().with("APPDATA", "/roaming"); + let resolver = Resolver::with_env(Platform::Windows, Some(PathBuf::from("/games/ULL")), &env); + assert_eq!(resolver.default_mode(), DataMode::Portable); + + let resolved = resolver.resolve(&DataDirRequest::default()).expect("解析"); + assert_eq!(resolved.mode, DataMode::Portable); + assert_eq!(resolved.launcher_home, PathBuf::from("/games/ULL/.ULL")); + assert_eq!(resolved.game_home, PathBuf::from("/games/ULL/.minecraft")); + assert!(resolved.redirected_from.is_none()); +} + +#[test] +fn portable_without_exe_dir_errors() { + let env = MapEnv::new(); + let resolver = Resolver::with_env(Platform::Linux, None, &env); + let err = resolver + .resolve(&DataDirRequest::default()) + .expect_err("应当报错"); + assert!(matches!(err, PlatformError::NoExecutableDir)); +} + +#[test] +fn standard_uses_per_os_locations() { + let windows = MapEnv::new().with("APPDATA", "/roaming"); + assert_eq!( + standard_launcher_home(Platform::Windows, &windows), + Some(PathBuf::from("/roaming/ULL")) + ); + assert_eq!( + standard_game_home(Platform::Windows, &windows), + Some(PathBuf::from("/roaming/.minecraft")) + ); + + let mac = MapEnv::new().with("HOME", "/Users/u"); + assert_eq!( + standard_launcher_home(Platform::MacOs, &mac), + Some(PathBuf::from("/Users/u/Library/Application Support/ULL")) + ); + assert_eq!( + standard_game_home(Platform::MacOs, &mac), + Some(PathBuf::from( + "/Users/u/Library/Application Support/minecraft" + )) + ); + + // XDG 规范要求绝对路径;测试用 temp_dir(两种平台都算绝对路径)。 + let xdg_root = std::env::temp_dir().join("ull-xdg"); + let xdg_text = xdg_root.to_string_lossy().into_owned(); + let linux = MapEnv::new() + .with("HOME", "/home/u") + .with("XDG_DATA_HOME", &xdg_text); + assert_eq!( + standard_launcher_home(Platform::Linux, &linux), + Some(xdg_root.join("ull")) + ); + assert_eq!( + standard_game_home(Platform::Linux, &linux), + Some(PathBuf::from("/home/u/.minecraft")) + ); + + // 相对 XDG_DATA_HOME 不合法 → 回落 ~/.local/share/ull。 + let relative_xdg = MapEnv::new() + .with("HOME", "/home/u") + .with("XDG_DATA_HOME", "relative/xdg"); + assert_eq!( + standard_launcher_home(Platform::Linux, &relative_xdg), + Some(PathBuf::from("/home/u/.local/share/ull")) + ); +} + +#[test] +fn standard_falls_back_when_env_missing() { + let env = MapEnv::new() + .with("HOME", "/home/u") + .with("USERPROFILE", "/home/u"); + assert_eq!( + standard_launcher_home(Platform::Linux, &env), + Some(PathBuf::from("/home/u/.local/share/ull")) + ); + assert_eq!( + standard_launcher_home(Platform::Windows, &env), + Some(PathBuf::from("/home/u/AppData/Roaming/ULL")) + ); + + let empty = MapEnv::new(); + assert_eq!(standard_launcher_home(Platform::Windows, &empty), None); +} + +#[test] +fn standard_mode_resolution_and_macos_default() { + let env = MapEnv::new().with("HOME", "/Users/u"); + let resolver = Resolver::with_env(Platform::MacOs, None, &env); + assert_eq!(resolver.default_mode(), DataMode::Standard); + + let request = DataDirRequest { + mode: DataMode::Standard, + ..DataDirRequest::default() + }; + let resolved = resolver.resolve(&request).expect("解析"); + assert_eq!( + resolved.launcher_home, + PathBuf::from("/Users/u/Library/Application Support/ULL") + ); + assert_eq!( + resolved.game_home, + PathBuf::from("/Users/u/Library/Application Support/minecraft") + ); +} + +#[test] +fn custom_requires_absolute_existing_root_choice() { + let env = MapEnv::new(); + let resolver = Resolver::with_env(Platform::Windows, Some(PathBuf::from("/games")), &env); + let root = std::env::temp_dir().join("ull-custom-root"); + + let ok = DataDirRequest { + mode: DataMode::Custom, + custom_root: Some(root.clone()), + ..DataDirRequest::default() + }; + let resolved = resolver.resolve(&ok).expect("解析"); + assert_eq!(resolved.launcher_home, root.join(".ULL")); + assert_eq!(resolved.game_home, root.join(".minecraft")); + + let relative = DataDirRequest { + mode: DataMode::Custom, + custom_root: Some(PathBuf::from("relative/dir")), + ..DataDirRequest::default() + }; + assert!(matches!( + resolver.resolve(&relative).expect_err("相对路径应报错"), + PlatformError::InvalidCustomRoot { .. } + )); + + let missing = DataDirRequest { + mode: DataMode::Custom, + custom_root: None, + ..DataDirRequest::default() + }; + assert!(resolver.resolve(&missing).is_err()); +} + +#[test] +fn advanced_overrides_split_launcher_and_game_home() { + let env = MapEnv::new(); + let resolver = Resolver::with_env(Platform::Windows, Some(PathBuf::from("/games")), &env); + let request = DataDirRequest { + launcher_home: Some(PathBuf::from("/config/ULL")), + game_home: Some(PathBuf::from("/bigdisk/.minecraft")), + ..DataDirRequest::default() + }; + let resolved = resolver.resolve(&request).expect("解析"); + assert_eq!(resolved.launcher_home, PathBuf::from("/config/ULL")); + assert_eq!(resolved.game_home, PathBuf::from("/bigdisk/.minecraft")); + assert!(!resolved.same_target(&ResolvedData { + mode: DataMode::Portable, + launcher_home: PathBuf::from("/other"), + game_home: PathBuf::from("/bigdisk/.minecraft"), + redirected_from: None, + })); +} + +#[test] +fn effective_resolution_follows_redirect_pointer() { + let temp = TempDir::new("data-dir-redirect"); + let exe_dir = temp.child("portable"); + let moved_to = temp.child("after-move"); + std::fs::create_dir_all(&exe_dir).expect("建目录"); + + let pointer = RedirectPointer { + schema_version: REDIRECT_SCHEMA_VERSION, + mode: DataMode::Custom, + launcher_home: moved_to.clone(), + game_home: moved_to.join(".minecraft"), + migrated_at_ms: 1_700_000_000_000, + }; + redirect::write(&exe_dir.join(".ULL"), &pointer).expect("写指针"); + + let env = MapEnv::new(); + let resolver = Resolver::with_env(Platform::Windows, Some(exe_dir.clone()), &env); + let resolved = resolver + .resolve_default(&DataDirRequest::default()) + .expect("解析"); + + assert_eq!(resolved.launcher_home, moved_to); + assert_eq!(resolved.mode, DataMode::Portable); + assert_eq!( + resolved.redirected_from, + Some(exe_dir.join(".ULL")), + "旧目录要留在记录里,便于 UI 提示「数据已迁走」" + ); +} diff --git a/crates/core/tests/setting/document.rs b/crates/core/tests/setting/document.rs new file mode 100644 index 0000000..6a0b391 --- /dev/null +++ b/crates/core/tests/setting/document.rs @@ -0,0 +1,220 @@ +//! 文档模型:脏字段、未知字段保留、默认值不落盘、嵌套点路径、坏值容错、`$schema` 保留。 + +use serde_json::{json, Value}; +use unlucky_core::setting::document::{LoadProblem, RenderMode, SettingDocument}; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::schema::{SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_VERSION_KEY}; + +fn schema_url(version: SchemaVersion) -> String { + SchemaRef::new(unlucky_core::setting::schema::SchemaId::LAUNCHER, version).url() +} + +fn loaded(raw: Value) -> (SettingDocument, Vec) { + SettingDocument::load(&launcher_schema(), &raw) +} + +#[test] +fn reads_nested_keys_and_keeps_unknown_subtrees() { + let raw = json!({ + SCHEMA_KEY: schema_url(launcher_version()), + SCHEMA_VERSION_KEY: 1, + "download": { "concurrency": 16 }, + "ui": { "language": "en_us", "unknown_ui": true }, + "future_section": { "nested": { "flag": true } } + }); + + let (doc, problems) = loaded(raw); + assert_eq!(doc.effective_i64("download.concurrency"), Some(16)); + assert_eq!(doc.effective_str("ui.language"), Some("en_us")); + + let unknown: Vec<&str> = doc.unknown_keys().collect(); + assert!(unknown.contains(&"ui.unknown_ui"), "{unknown:?}"); + assert!( + unknown.contains(&"future_section.nested.flag"), + "未知键清单是叶子粒度: {unknown:?}" + ); + + let reported: Vec<&str> = problems + .iter() + .filter_map(|problem| match problem { + LoadProblem::UnknownKey { key } => Some(key.as_str()), + _ => None, + }) + .collect(); + assert!(reported.contains(&"ui.unknown_ui"), "{problems:?}"); + assert!( + reported.contains(&"future_section"), + "陌生子树按最上层节点报一次,不做逐叶展开: {problems:?}" + ); + + let rendered = doc.to_value(); + assert_eq!(rendered[SCHEMA_VERSION_KEY], json!(1)); + assert_eq!( + rendered["future_section"]["nested"]["flag"], + json!(true), + "未知字段必须原样回写" + ); + assert_eq!(rendered["ui"]["unknown_ui"], json!(true)); +} + +#[test] +fn defaults_never_reach_the_file() { + let (doc, _) = loaded(json!({ SCHEMA_KEY: schema_url(launcher_version()) })); + // 生效值有默认,但落盘视图里没有这些键。 + assert_eq!(doc.effective_i64("download.concurrency"), Some(8)); + assert_eq!(doc.effective_bool("ai.enabled"), Some(true)); + assert!(doc.get("download.concurrency").is_none()); + let rendered = doc.to_value(); + assert!(rendered.get("download").is_none(), "{rendered}"); + // 无默认值的路径键 → None。 + assert!(doc.effective_str("java.selected_path").is_none()); +} + +#[test] +fn tracks_dirty_keys_and_renders_patches() { + let raw = json!({ + SCHEMA_KEY: schema_url(launcher_version()), + "download": { "concurrency": 8, "retry_limit": 2 } + }); + let (mut doc, _) = loaded(raw); + assert!(!doc.is_dirty()); + + doc.set("download.concurrency", json!(32)).expect("写"); + doc.set("appearance.theme_mode", json!("dark")).expect("写"); + + let mut dirty: Vec<&str> = doc.dirty_keys().collect(); + dirty.sort_unstable(); + assert_eq!(dirty, vec!["appearance.theme_mode", "download.concurrency"]); + + let patch = doc.dirty_patch(); + assert_eq!(patch["download"]["concurrency"], json!(32)); + assert_eq!(patch["appearance"]["theme_mode"], json!("dark")); + assert!( + patch["download"].get("retry_limit").is_none(), + "非脏字段不进补丁" + ); + + let dirty_view = doc.render(RenderMode::DirtyOnly); + assert_eq!(dirty_view["download"]["concurrency"], json!(32)); + assert!(dirty_view["download"].get("retry_limit").is_none()); + + doc.mark_clean(); + assert!(!doc.is_dirty()); + assert_eq!(doc.dirty_patch(), json!({})); +} + +#[test] +fn invalid_values_are_preserved_with_a_problem() { + let raw = json!({ + SCHEMA_KEY: schema_url(launcher_version()), + "download": { "concurrency": 9999, "retry_limit": "many" } + }); + let (doc, problems) = loaded(raw); + assert_eq!( + doc.get("download.concurrency").and_then(Value::as_i64), + Some(9999), + "坏值原样保留(用户能看见自己写坏了什么)" + ); + let invalid: Vec<&str> = problems + .iter() + .filter_map(|problem| match problem { + LoadProblem::InvalidValue { key, .. } => Some(key.as_str()), + _ => None, + }) + .collect(); + assert!(invalid.contains(&"download.concurrency"), "{problems:?}"); + assert!(invalid.contains(&"download.retry_limit"), "{problems:?}"); +} + +#[test] +fn schema_version_mirror_mismatch_is_reported_but_schema_wins() { + let raw = json!({ + SCHEMA_KEY: schema_url(launcher_version()), + SCHEMA_VERSION_KEY: 7 + }); + let (doc, problems) = loaded(raw); + assert_eq!(doc.version(), launcher_version()); + assert!(problems.iter().any(|problem| matches!( + problem, + LoadProblem::SchemaVersionMismatch { + declared: 7, + expected: 1 + } + ))); +} + +#[test] +fn newer_patch_preserves_original_schema_string() { + // 同 minor、更高 patch(launcher schema 当前 1.3.0)。 + let newer = SchemaVersion::new(1, 3, 7); + let url = schema_url(newer); + let (doc, _) = loaded(json!({ SCHEMA_KEY: url.clone() })); + assert_eq!( + doc.preserve_schema(), + Some(url.as_str()), + "同 minor 更高 patch 要保留原 $schema" + ); + assert_eq!(doc.to_value()[SCHEMA_KEY], json!(url)); + assert_eq!(doc.version(), newer); +} + +#[test] +fn wrong_shape_for_known_prefix_is_reported() { + let raw = json!({ + SCHEMA_KEY: schema_url(launcher_version()), + "download": "oops" + }); + let (doc, problems) = loaded(raw); + assert!( + problems.iter().any(|problem| matches!( + problem, + LoadProblem::InvalidShape { key, .. } if key == "download" + )), + "{problems:?}" + ); + assert!(doc.get("download").is_none()); + // 原始坏结构不进 values,也不会被当成未知键回写。 + assert!(doc.to_value().get("download").is_none()); +} + +#[test] +fn non_object_json_yields_problem_and_empty_doc() { + let (doc, problems) = loaded(json!([1, 2, 3])); + assert!(!problems.is_empty()); + assert_eq!(doc.effective_i64("download.concurrency"), Some(8)); +} + +#[test] +fn set_rejects_unknown_keys_and_out_of_range_values() { + let (mut doc, _) = loaded(json!({})); + assert!(doc.set("nope.key", json!(1)).is_err()); + assert!(doc.set("download.concurrency", json!(0)).is_err()); + assert!(doc + .set("appearance.theme_mode", json!("solarized")) + .is_err()); + assert!(!doc.is_dirty(), "校验失败不能标脏"); +} + +#[test] +fn unknown_schema_prefix_reports_problem_and_keeps_values() { + let raw = json!({ + SCHEMA_KEY: "https://unlucky-launcher.local/schema/launcher/1.2.0.json", + "core": { "data_mode": "standard" }, + }); + let (doc, problems) = loaded(raw); + // 值照读(旧实现按「没有声明」整份装默认值)。 + assert_eq!(doc.effective_str("core.data_mode"), Some("standard")); + let found = problems.iter().find_map(|problem| match problem { + LoadProblem::UnknownSchema { url } => Some(url.as_str()), + _ => None, + }); + assert_eq!( + found, + Some("https://unlucky-launcher.local/schema/launcher/1.2.0.json"), + "{problems:?}" + ); +} + +fn launcher_version() -> SchemaVersion { + unlucky_core::setting::keys::launcher_version() +} diff --git a/crates/core/tests/setting/isolation_keys.rs b/crates/core/tests/setting/isolation_keys.rs new file mode 100644 index 0000000..5efe2d2 --- /dev/null +++ b/crates/core/tests/setting/isolation_keys.rs @@ -0,0 +1,250 @@ +//! D20 隔离键的 schema 契约:全局 `instances.mode`、实例 `isolation`、`lastModeChange`、 +//! 恒共享层不可配、旧配置缺键即默认值(不需要迁移步骤)。 + +use serde_json::json; +use unlucky_core::game::install::instance::{InstanceConfig, Isolation, OverrideKey}; +use unlucky_core::game::isolation::{observe_mode, IsolationMode}; +use unlucky_core::game::GameLayout; +use unlucky_core::setting::document::{LoadProblem, RenderMode, SettingDocument}; +use unlucky_core::setting::instance_keys::{ + instance_schema, instance_version, ISOLATION_OVERRIDES, ISOLATION_RESOLVED, OVERRIDE_KEYS, +}; +use unlucky_core::setting::keys::{launcher_schema, launcher_version, INSTANCE_MODES}; +use unlucky_core::setting::schema::{SchemaId, SchemaVersion}; +use unlucky_core::setting::spec::{Sensitivity, ValueKind}; +use unlucky_core::setting::store::SettingStore; + +use super::common::TempDir; + +#[test] +fn global_mode_key_defaults_to_isolated() { + let schema = launcher_schema(); + let spec = schema.spec("instances.mode").expect("instances.mode 键"); + assert_eq!(spec.default, json!("isolated"), "D20 默认隔离"); + assert_eq!(spec.kind, ValueKind::Enum(INSTANCE_MODES)); + assert_eq!( + spec.ai.sensitivity, + Sensitivity::High, + "切档会改变存档可见性,AI 改必须弹窗确认" + ); + assert!(schema.validate("instances.mode", &json!("shared")).is_ok()); + assert!(schema + .validate("instances.mode", &json!("isolated")) + .is_ok()); + assert!(schema + .validate("instances.mode", &json!("per_version")) + .is_err()); + assert!(schema.validate("instances.mode", &json!("custom")).is_err()); +} + +#[test] +fn shared_layers_are_not_configurable() { + let schema = launcher_schema(); + for key in ["shared.versions", "shared.assets", "shared.libraries"] { + assert!( + !schema.is_known(key), + "{key} 是恒共享层,不该出现在可配键表里" + ); + } +} + +#[test] +fn instance_isolation_defaults_to_inherit() { + let schema = instance_schema(); + let spec = schema.spec("isolation").expect("isolation 键"); + assert_eq!(spec.default, json!("inherit")); + assert_eq!(spec.kind, ValueKind::Enum(ISOLATION_OVERRIDES)); + for value in ["inherit", "isolated", "shared", "custom"] { + assert!( + schema.validate("isolation", &json!(value)).is_ok(), + "{value}" + ); + } + assert!(schema.validate("isolation", &json!("per_version")).is_err()); +} + +#[test] +fn inherit_never_reaches_the_file() { + // 默认值不落盘:空文档渲染出来只应该有 $schema / schemaVersion。 + let schema = instance_schema(); + let (document, problems) = SettingDocument::load(&schema, &json!({})); + assert!(problems.is_empty(), "{problems:?}"); + let rendered = document.render(RenderMode::Full); + assert!(rendered.get("isolation").is_none(), "{rendered}"); + assert!(rendered.get("lastModeChange").is_none(), "{rendered}"); + let dirty = document.render(RenderMode::DirtyOnly); + assert!(dirty.get("isolation").is_none(), "{dirty}"); +} + +#[test] +fn last_mode_change_keys_are_bookkeeping_only() { + let schema = instance_schema(); + for key in [ + "lastModeChange.at", + "lastModeChange.from", + "lastModeChange.to", + "lastModeChange.probe.isolatedSide", + "lastModeChange.probe.sharedSide", + "lastModeChange.warned", + ] { + assert!(schema.is_known(key), "{key} 必须登记"); + assert!(!schema.accepts_ai_write(key), "{key} 不许 AI 写"); + } + assert!(schema + .validate("lastModeChange.probe.isolatedSide", &json!(12)) + .is_ok()); + assert!(schema + .validate("lastModeChange.probe.isolatedSide", &json!(-1)) + .is_err()); + assert!(schema + .validate("lastModeChange.warned", &json!(true)) + .is_ok()); + assert!(schema.validate("lastModeChange.warned", &json!(1)).is_err()); + assert!(schema + .validate("lastModeChange.from", &json!("shared")) + .is_ok()); + assert!(schema + .validate("lastModeChange.from", &json!("inherit")) + .is_err()); + assert_eq!(ISOLATION_RESOLVED, ["isolated", "shared", "custom"]); +} + +#[test] +fn overrides_key_is_an_enum_whitelist() { + let schema = instance_schema(); + assert!(schema.validate("overrides", &json!([])).is_ok()); + assert!(schema + .validate("overrides", &json!(["maxMemory", "runningDirectory"])) + .is_ok()); + assert!( + schema + .validate("overrides", &json!(["max_memory"])) + .is_err(), + "旧 snake_case 拼写不许过(拼错会静默失效)" + ); + assert!(schema.validate("overrides", &json!([1])).is_err()); + assert_eq!(OVERRIDE_KEYS, ["maxMemory", "javaType", "runningDirectory"]); +} + +#[test] +fn schema_versions_register_the_new_keys_without_migration() { + // 1.3.0 登记 D17 的「外观(高级)」键(1.2.0 是 D23 的 JDK 落点/优先级键)。 + assert_eq!(launcher_version(), SchemaVersion::new(1, 3, 0)); + assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0)); + assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER); + // major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。 + assert_eq!(launcher_version().major, 1); + assert_eq!(instance_version().major, 1); +} + +#[test] +fn old_launcher_file_without_new_keys_loads_with_defaults() { + let temp = std::env::temp_dir().join(format!( + "ull-setting-d20-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + std::fs::create_dir_all(&temp).expect("临时目录"); + let path = temp.join("launcher.json"); + // 旧版本写的文件:只有 $schema 1.0.0 和一个老键,没有 instances.mode。 + std::fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "$schema": "https://schemas.unlucky-launcher.dev/ull/launcher/1.0.0", + "schemaVersion": 1, + "ui": { "scale": 1.25 } + })) + .expect("序列化"), + ) + .expect("写文件"); + + let store = SettingStore::load(&path, launcher_schema()); + assert!(store.access().can_read(), "1.0.0 文件必须可读"); + assert!(store.problems().is_empty(), "{:?}", store.problems()); + assert_eq!( + store.doc().effective_str("instances.mode"), + Some("isolated"), + "缺键取 D20 默认值" + ); + assert_eq!(store.doc().effective_f64("ui.scale"), Some(1.25)); + let _ = std::fs::remove_dir_all(&temp); +} + +#[test] +fn setting_lists_agree_with_the_game_enums() { + // schema 白名单与 game 侧枚举必须一一对应(两处字面量漂移就是静默不生效)。 + let mut modes: Vec<&str> = [IsolationMode::Isolated, IsolationMode::Shared] + .iter() + .map(|mode| mode.as_str()) + .collect(); + modes.sort_unstable(); + let mut declared = INSTANCE_MODES.to_vec(); + declared.sort_unstable(); + assert_eq!(modes, declared); + + for value in ISOLATION_OVERRIDES + .iter() + .filter(|value| **value != "inherit") + { + assert!( + Isolation::parse(value).is_some(), + "{value} 在 game 侧解析不出来" + ); + assert_eq!(Isolation::parse(value).map(Isolation::as_str), Some(*value)); + } + for value in ISOLATION_RESOLVED { + assert!(Isolation::parse(value).is_some(), "{value}"); + } +} + +#[test] +fn writer_output_loads_without_unknown_or_invalid_keys() { + // 回归 P2-1(review r1):写入者(InstanceConfig + observe_mode)产出的键必须被 schema 全认。 + // 之前 `lastModeChange.warned` 没登记,document 层会把真实文件标成 UnknownKey—— + // 这正是「schema 只做写入者白名单视图」这条契约的裂缝。 + let temp = TempDir::new("d20-writer-shape"); + let game = GameLayout::new(temp.child("game")); + let mut config = InstanceConfig::new("11111111-2222-4333-8444-555555555555", "1.20.1"); + config.isolation = Some(Isolation::Custom); + config.game_dir = Some(temp.child("elsewhere")); + config.overrides = vec![OverrideKey::MaxMemory]; + observe_mode( + &mut config, + &game, + "2026-10-30T00:00:00Z", + Isolation::Custom, + ) + .expect("观察"); + + let raw: serde_json::Value = + serde_json::from_str(&config.to_json_string().expect("序列化")).expect("合法 JSON"); + // 写入者确实输出了 warned(P2-1 的前提)。 + assert_eq!(raw["lastModeChange"]["warned"], json!(true)); + + let (document, problems) = SettingDocument::load(&instance_schema(), &raw); + let mut unknown: Vec = Vec::new(); + let mut invalid: Vec = Vec::new(); + for problem in &problems { + match problem { + LoadProblem::UnknownKey { key } => unknown.push(key.clone()), + LoadProblem::InvalidValue { key, reason } => invalid.push(format!("{key}: {reason}")), + _ => {} + } + } + assert!(unknown.is_empty(), "写入者的键没登记进 schema:{unknown:?}"); + assert!( + invalid.is_empty(), + "写入者的值过不了 schema 校验:{invalid:?}" + ); + assert_eq!( + document.get("lastModeChange.warned"), + Some(&json!(true)), + "记账键要能读回" + ); + // 落盘视图不能把它当「未知键」重复输出。 + let rendered = document.render(RenderMode::Full); + assert_eq!(rendered["lastModeChange"]["warned"], json!(true)); + assert_eq!(rendered["overrides"], json!(["maxMemory"])); +} diff --git a/crates/core/tests/setting/java_install_keys.rs b/crates/core/tests/setting/java_install_keys.rs new file mode 100644 index 0000000..f5d6cfb --- /dev/null +++ b/crates/core/tests/setting/java_install_keys.rs @@ -0,0 +1,119 @@ +//! D23 新增的两个设置键:`java.installRoot` / `java.vendorPriority`。 +//! +//! 三件事要说清: +//! 1. 两个键都登记在 launcher schema 里(`ull config get` 能看到、AI 白名单受策略约束); +//! 2. `java.vendorPriority` 的允许值集合与 `game::java::vendor::VENDOR_IDS` **同源** +//! (setting 层不依赖 game 层,靠这条契约测试固定,避免两处漂移); +//! 3. schema 版本推到 1.2.0 但 major 仍为 1 → 不需要迁移步骤(默认值不落盘)。 + +use serde_json::json; +use unlucky_core::game::java::vendor::VENDOR_IDS; +use unlucky_core::setting::keys::{ + launcher_schema, launcher_version, JAVA_INSTALL_ROOT_UNSET, JDK_VENDORS, +}; +use unlucky_core::setting::schema::{SchemaId, SchemaRef, SchemaVersion}; +use unlucky_core::setting::store::SettingStore; + +#[test] +fn both_d23_keys_are_registered_with_their_defaults() { + let schema = launcher_schema(); + assert_eq!( + schema.default_value("java.installRoot"), + Some(&json!(JAVA_INSTALL_ROOT_UNSET)) + ); + assert_eq!( + schema.default_value("java.vendorPriority"), + Some(&json!([])) + ); + // 空串/空数组都有明确语义:installRoot 空 = 按数据目录模式派生; + // vendorPriority 空 = 按地区默认序(国内 Temurin→Oracle→MS→Zulu)。 + assert!(schema.spec("java.installRoot").is_some()); + assert!(schema.spec("java.vendorPriority").is_some()); +} + +#[test] +fn vendor_priority_whitelist_matches_the_vendor_module() { + assert_eq!(JDK_VENDORS, VENDOR_IDS, "两处必须同一份集合"); + let schema = launcher_schema(); + assert!(schema + .validate("java.vendorPriority", &json!(["oracle"])) + .is_ok()); + assert!(schema + .validate("java.vendorPriority", &json!(["temurin", "zulu"])) + .is_ok()); + assert!(schema + .validate("java.vendorPriority", &json!(["graalvm"])) + .is_err()); + assert!(schema + .validate("java.vendorPriority", &json!([1, 2])) + .is_err()); + assert!(schema + .validate("java.vendorPriority", &json!("microsoft")) + .is_err()); +} + +#[test] +fn install_root_accepts_a_path_and_keeps_empty_as_unset() { + let schema = launcher_schema(); + assert!(schema + .validate("java.installRoot", &json!("D:/jdk")) + .is_ok()); + // 空串就是默认值,语义是「没设覆盖键」(不是错误):所以类型是 text 而不是 path + // (Path 类型不接受空串,而这里需要一个合法的「未设置」值)。 + assert!(schema.validate("java.installRoot", &json!("")).is_ok()); + assert!(schema.validate("java.installRoot", &json!(5)).is_err()); +} + +#[test] +fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() { + assert_eq!(launcher_version(), SchemaVersion::new(1, 3, 0)); + assert_eq!( + launcher_version().major, + 1, + "major 不变 → 迁移链(按 major 走)不需要新步骤" + ); +} + +#[test] +fn an_old_config_file_without_the_new_keys_still_loads() { + let dir = std::env::temp_dir().join(format!( + "ull-java-keys-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()) + )); + std::fs::create_dir_all(&dir).expect("建临时目录"); + let path = dir.join("launcher.json"); + // 1.1.0 的文件(真实 $schema 前缀):没有 java.installRoot / java.vendorPriority, + // 且 core.data_mode 故意写非默认值 standard —— 证明值是「读出来的」不是「默认值撞上的」。 + let old_url = SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 1, 0)).url(); + std::fs::write( + &path, + format!(r#"{{"$schema":"{old_url}","schemaVersion":1,"core":{{"data_mode":"standard"}}}}"#), + ) + .expect("写旧配置"); + + let store = SettingStore::load(&path, launcher_schema()); + assert_eq!( + store.doc().effective_str("core.data_mode"), + Some("standard"), + "旧文件的值必须照读(不是碰巧等于默认值)" + ); + // 缺键取默认值,不报错、不写盘(「默认值不落盘」)。 + assert_eq!( + store.doc().effective_str("java.installRoot"), + Some(JAVA_INSTALL_ROOT_UNSET) + ); + assert_eq!( + store.effective("java.vendorPriority"), + Some(&json!([])), + "缺键 → 空数组 → 按地区默认序" + ); + assert!( + store.problems().is_empty(), + "只缺新键不算问题:{:?}", + store.problems() + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/core/tests/setting/layout.rs b/crates/core/tests/setting/layout.rs new file mode 100644 index 0000000..897099b --- /dev/null +++ b/crates/core/tests/setting/layout.rs @@ -0,0 +1,128 @@ +//! 四分区布局、路径拼接、实例 id 白名单、可搬运路径字符串。 + +use std::path::PathBuf; + +use unlucky_core::platform::layout::{ + is_valid_instance_id, LauncherLayout, Partition, CONFIG_DIR, GAME_HOME_DIR, INSTANCES_DIR, + LAUNCHER_CONFIG_FILE, LAUNCHER_HOME_DIR, MIGRATION_RECEIPT_FILE, PROFILES_CONFIG_FILE, + REDIRECT_FILE, +}; + +use crate::common::TempDir; + +#[test] +fn home_dir_names_match_design() { + assert_eq!(LAUNCHER_HOME_DIR, ".ULL"); + assert_eq!(GAME_HOME_DIR, ".minecraft"); + assert_eq!(CONFIG_DIR, "config"); + assert_eq!(INSTANCES_DIR, "instances"); + assert_eq!(LAUNCHER_CONFIG_FILE, "launcher.json"); + assert_eq!(PROFILES_CONFIG_FILE, "profiles.json"); + assert_eq!(REDIRECT_FILE, "redirect.json"); + assert_eq!(MIGRATION_RECEIPT_FILE, "migration-receipt.json"); +} + +#[test] +fn exposes_four_partitions() { + let layout = LauncherLayout::new("/home/u/.ULL"); + assert_eq!(layout.home(), std::path::Path::new("/home/u/.ULL")); + assert_eq!(layout.config(), PathBuf::from("/home/u/.ULL/config")); + assert_eq!(layout.data(), PathBuf::from("/home/u/.ULL/data")); + assert_eq!(layout.cache(), PathBuf::from("/home/u/.ULL/cache")); + assert_eq!(layout.logs(), PathBuf::from("/home/u/.ULL/logs")); + + assert_eq!(Partition::ALL.len(), 4); + assert_eq!(Partition::Config.dir_name(), "config"); + assert!(Partition::Cache.is_disposable()); + assert!(!Partition::Config.is_disposable()); +} + +#[test] +fn builds_config_and_cache_paths() { + let layout = LauncherLayout::new("/home/u/.ULL"); + assert_eq!( + layout.launcher_config(), + PathBuf::from("/home/u/.ULL/config/launcher.json") + ); + assert_eq!( + layout.profiles_config(), + PathBuf::from("/home/u/.ULL/config/profiles.json") + ); + assert_eq!( + layout.sha1_cache("ab"), + PathBuf::from("/home/u/.ULL/cache/sha1/ab") + ); + assert_eq!( + layout.etag_cache(), + PathBuf::from("/home/u/.ULL/cache/etag.json") + ); + assert_eq!( + layout.meta_cache(), + PathBuf::from("/home/u/.ULL/cache/meta") + ); + assert_eq!( + layout.java_managed(), + PathBuf::from("/home/u/.ULL/data/java-managed") + ); + assert_eq!(layout.auth_data(), PathBuf::from("/home/u/.ULL/data/auth")); + assert_eq!( + layout.agent_sessions(), + PathBuf::from("/home/u/.ULL/data/agent/sessions") + ); + assert_eq!( + layout.redirect_path(), + PathBuf::from("/home/u/.ULL/redirect.json") + ); + assert_eq!( + layout.migration_receipt(), + PathBuf::from("/home/u/.ULL/config/migration-receipt.json") + ); +} + +#[test] +fn instance_config_rejects_path_traversal() { + let layout = LauncherLayout::new("/home/u/.ULL"); + let id = "6f9619ff-8b86-d011-b42d-00c04fc964ff"; + assert!(is_valid_instance_id(id)); + assert_eq!( + layout.instance_config(id).expect("合法 id"), + PathBuf::from(format!("/home/u/.ULL/config/instances/{id}.json")) + ); + assert!(layout.instance_config("../escape").is_err()); + assert!(layout.instance_config("").is_err()); + assert!(layout.instance_config("a/b").is_err()); +} + +#[test] +fn ensure_creates_all_partitions() { + let temp = TempDir::new("layout"); + let layout = LauncherLayout::new(temp.child(".ULL")); + layout.ensure().expect("建目录"); + for partition in Partition::ALL { + assert!( + layout.partition(partition).is_dir(), + "{}", + partition.as_str() + ); + } +} + +#[test] +fn portable_path_strings_keep_absolute_and_normalise_relative() { + use unlucky_core::platform::paths::{from_portable_string, to_portable_string}; + + assert_eq!( + to_portable_string(std::path::Path::new(".minecraft")), + ".minecraft" + ); + assert_eq!( + to_portable_string(std::path::Path::new("versions\\1.20.1")), + "versions/1.20.1" + ); + let absolute = std::env::temp_dir(); + assert_eq!(to_portable_string(&absolute), absolute.to_string_lossy()); + assert_eq!( + from_portable_string("versions/1.20.1"), + PathBuf::from("versions/1.20.1") + ); +} diff --git a/crates/core/tests/setting/main.rs b/crates/core/tests/setting/main.rs new file mode 100644 index 0000000..4f54b61 --- /dev/null +++ b/crates/core/tests/setting/main.rs @@ -0,0 +1,21 @@ +//! M12(core::setting + platform)集成测试入口。 +//! +//! cargo 把 `tests//main.rs` 识别为独立的 test target(target 名 = `setting`), +//! 因此本目录下每个子模块就是一个测试主题,避免单文件超过 400 行。 + +mod appearance_keys; +mod atomic_write; +mod common; +mod debounce; +mod deprecated_keys; +mod dir_migration; +mod dirs; +mod document; +mod isolation_keys; +mod java_install_keys; +mod layout; +mod migration_chain; +mod redirect; +mod schema_compat; +mod spec_ai; +mod store; diff --git a/crates/core/tests/setting/migration_chain.rs b/crates/core/tests/setting/migration_chain.rs new file mode 100644 index 0000000..3b03401 --- /dev/null +++ b/crates/core/tests/setting/migration_chain.rs @@ -0,0 +1,295 @@ +//! 迁移链:N→N+1 顺序校验、真实迁移跑通、收据闸门防重放、store 接线。 + +use std::path::PathBuf; + +use serde_json::{json, Map, Value}; +use unlucky_core::platform::receipt::{ + decide, fingerprint_bytes, fingerprint_file, read as read_receipt, MigrationReceipt, + ReceiptKind, ReplayDecision, +}; +use unlucky_core::setting::catalog::SettingSchema; +use unlucky_core::setting::migrate::{ + MigrationChain, MigrationGate, MigrationReport, SettingMigration, +}; +use unlucky_core::setting::schema::{ + SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_VERSION_KEY, +}; +use unlucky_core::setting::spec::{AiPolicy, SettingSpec}; +use unlucky_core::setting::store::SettingStore; + +use crate::common::TempDir; + +/// 1.0.0 → 2.0.0:`download.concurrency` 改名 `download.threads`。 +struct RenameThreads; + +impl SettingMigration for RenameThreads { + fn from(&self) -> SchemaVersion { + SchemaVersion::new(1, 0, 0) + } + + fn to(&self) -> SchemaVersion { + SchemaVersion::new(2, 0, 0) + } + + fn describe(&self) -> &'static str { + "download.concurrency → download.threads" + } + + fn apply(&self, root: &mut Map) -> Result<(), String> { + let Some(download) = root.get_mut("download").and_then(Value::as_object_mut) else { + return Ok(()); + }; + if let Some(value) = download.remove("concurrency") { + download.insert("threads".to_owned(), value); + } + Ok(()) + } +} + +/// 2.0.0 → 3.0.0:补一个镜像偏好默认值。 +struct AddMirrorPreference; + +impl SettingMigration for AddMirrorPreference { + fn from(&self) -> SchemaVersion { + SchemaVersion::new(2, 0, 0) + } + + fn to(&self) -> SchemaVersion { + SchemaVersion::new(3, 0, 0) + } + + fn describe(&self) -> &'static str { + "补齐 download.mirror_preference" + } + + fn apply(&self, root: &mut Map) -> Result<(), String> { + let Some(download) = root.get_mut("download").and_then(Value::as_object_mut) else { + return Err("缺少 download 对象".to_owned()); + }; + download + .entry("mirror_preference".to_owned()) + .or_insert_with(|| json!("auto")); + Ok(()) + } +} + +fn two_step_chain() -> MigrationChain { + let mut chain = MigrationChain::new(); + chain.push(Box::new(RenameThreads)).expect("第一环"); + chain.push(Box::new(AddMirrorPreference)).expect("第二环"); + chain +} + +#[test] +fn chain_requires_contiguous_major_steps() { + let mut chain = MigrationChain::new(); + chain + .push(Box::new(AddMirrorPreference)) + .expect("首环随意起点"); + assert_eq!(chain.len(), 1); + + let err = chain.push(Box::new(RenameThreads)).expect_err("逆序要报错"); + assert!(err.to_string().contains("顺序错误"), "{err}"); + assert_eq!(chain.latest(), Some(SchemaVersion::new(3, 0, 0))); + assert_eq!(chain.describe().len(), 1); + assert!(!chain.is_empty()); +} + +#[test] +fn duplicate_and_skipping_steps_are_rejected() { + let mut chain = MigrationChain::new(); + chain.push(Box::new(RenameThreads)).expect("首环"); + assert!(chain.push(Box::new(RenameThreads)).is_err(), "重复起始版本"); + assert!(chain.push(Box::new(AddMirrorPreference)).is_ok()); + + struct SkipMajor; + impl SettingMigration for SkipMajor { + fn from(&self) -> SchemaVersion { + SchemaVersion::new(3, 0, 0) + } + fn to(&self) -> SchemaVersion { + SchemaVersion::new(5, 0, 0) + } + fn describe(&self) -> &'static str { + "跳版本(非法)" + } + fn apply(&self, _root: &mut Map) -> Result<(), String> { + Ok(()) + } + } + let err = chain + .push(Box::new(SkipMajor)) + .expect_err("跨两个 major 要报错"); + assert!(err.to_string().contains("major"), "{err}"); +} + +#[test] +fn migrates_sequentially_and_bumps_schema_version() { + let chain = two_step_chain(); + let mut root = json!({ "download": { "concurrency": 32 } }) + .as_object() + .cloned() + .expect("对象"); + root.insert(SCHEMA_VERSION_KEY.to_owned(), json!(1)); + + let report = chain + .migrate(SchemaVersion::new(1, 0, 0), &mut root) + .expect("迁移"); + assert_eq!( + report, + MigrationReport { + from: SchemaVersion::new(1, 0, 0), + to: SchemaVersion::new(3, 0, 0), + applied: vec![ + "download.concurrency → download.threads", + "补齐 download.mirror_preference", + ], + } + ); + assert!(!report.is_noop()); + assert_eq!(root["download"]["threads"], json!(32)); + assert_eq!(root["download"]["mirror_preference"], json!("auto")); + assert!(root["download"].get("concurrency").is_none()); + assert_eq!(root[SCHEMA_VERSION_KEY], json!(3)); +} + +#[test] +fn unknown_start_version_breaks_the_chain() { + let chain = two_step_chain(); + let mut root = Map::new(); + let err = chain + .migrate(SchemaVersion::new(9, 0, 0), &mut root) + .expect_err("没有 9.0.0 起始步"); + assert!(err.to_string().contains("链里没有"), "{err}"); + assert!(chain.plan_from(SchemaVersion::new(2, 0, 0)).is_ok()); +} + +#[test] +fn failing_step_reports_migration_error() { + let mut chain = MigrationChain::new(); + chain.push(Box::new(RenameThreads)).expect("第一环"); + chain.push(Box::new(AddMirrorPreference)).expect("第二环"); + + let mut root = json!({ "download": 3 }).as_object().cloned().expect("对象"); + let err = chain + .migrate(SchemaVersion::new(1, 0, 0), &mut root) + .expect_err("download 不是对象"); + assert!(err.to_string().contains("迁移"), "{err}"); +} + +#[test] +fn receipt_gate_prevents_replay() { + let temp = TempDir::new("migration-gate"); + let source = temp.write("legacy/hmcl.json", "{\"x\":1}"); + let target = temp.child("config/launcher.json"); + let fingerprint = fingerprint_file(&source).expect("算指纹").expect("文件在"); + + let receipt_path = temp.child("config/migration-receipt.json"); + let gate = MigrationGate::new(&receipt_path, 1_700_000_000_000); + assert_eq!(gate.now_ms(), 1_700_000_000_000); + assert!(gate.should_run(&fingerprint).expect("无收据")); + + let receipt = + MigrationReceipt::legacy_config(source, target, fingerprint.clone(), gate.now_ms()); + gate.record(&receipt).expect("写收据"); + assert!(!gate.should_run(&fingerprint).expect("同源指纹应跳过")); + assert_eq!( + gate.evaluate(&fingerprint).expect("评估"), + ReplayDecision::SkipSameSource + ); + assert_eq!( + gate.evaluate("其他指纹").expect("评估"), + ReplayDecision::ReplayChangedSource + ); + + let stored = read_receipt(&receipt_path) + .expect("读收据") + .expect("有收据"); + assert_eq!(stored.kind, ReceiptKind::LegacyConfig); + assert_eq!(stored.source_fingerprint, fingerprint); +} + +#[test] +fn receipt_decisions_and_fingerprints() { + let receipt = MigrationReceipt::legacy_config( + PathBuf::from("/a"), + PathBuf::from("/b"), + fingerprint_bytes(b"abc"), + 7, + ); + assert_eq!(decide(None, "x"), ReplayDecision::ReplayNoReceipt); + assert_eq!( + decide(Some(&receipt), &fingerprint_bytes(b"abc")), + ReplayDecision::SkipSameSource + ); + assert_eq!( + decide(Some(&receipt), "changed"), + ReplayDecision::ReplayChangedSource + ); + assert_eq!(ReplayDecision::SkipSameSource.as_str(), "skip_same_source"); + assert!(!ReplayDecision::SkipSameSource.should_replay()); + assert_eq!(fingerprint_bytes(b"abc").len(), 64); + assert_ne!( + unlucky_core::platform::receipt::fingerprint_data_dir_move( + &PathBuf::from("/a"), + &PathBuf::from("/b") + ), + unlucky_core::platform::receipt::fingerprint_data_dir_move( + &PathBuf::from("/a"), + &PathBuf::from("/c") + ) + ); +} + +fn schema_v2() -> SettingSchema { + SettingSchema::new(SchemaId::LAUNCHER, SchemaVersion::new(2, 0, 0)).with(SettingSpec::int( + "download.threads", + 8, + (1, 256), + AiPolicy::silent(), + "下载并发数(2.0.0 起的键名)", + )) +} + +#[test] +fn store_applies_chain_then_saves_new_version() { + let temp = TempDir::new("migration-store"); + let old_url = SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 0, 0)).url(); + temp.write( + "config/launcher.json", + &json!({ + SCHEMA_KEY: old_url, + SCHEMA_VERSION_KEY: 1, + "download": { "concurrency": 32 } + }) + .to_string(), + ); + + let mut store = SettingStore::load(temp.child("config/launcher.json"), schema_v2()); + assert_eq!(store.doc().version(), SchemaVersion::new(1, 0, 0)); + assert!(!store.is_dirty(), "加载不是改动"); + + let mut chain = MigrationChain::new(); + chain.push(Box::new(RenameThreads)).expect("一环足矣"); + let report = store.apply_chain(&chain).expect("迁移").expect("有迁移"); + assert_eq!(report.to, SchemaVersion::new(2, 0, 0)); + assert_eq!(store.doc().version(), SchemaVersion::new(2, 0, 0)); + assert_eq!( + store.effective("download.threads"), + Some(&json!(32)), + "值要跟着键名一起搬" + ); + assert!(store.is_dirty(), "迁移结果必须回写"); + + store.save().expect("保存"); + let saved: Value = serde_json::from_str(&temp.read("config/launcher.json")).expect("JSON"); + assert_eq!(saved[SCHEMA_VERSION_KEY], json!(2)); + assert!(saved[SCHEMA_KEY] + .as_str() + .expect("$schema") + .ends_with("launcher/2.0.0")); + assert_eq!(saved["download"]["threads"], json!(32)); + + let mut up_to_date = SettingStore::load(temp.child("config/launcher.json"), schema_v2()); + assert!(up_to_date.apply_chain(&chain).expect("已最新").is_none()); +} diff --git a/crates/core/tests/setting/redirect.rs b/crates/core/tests/setting/redirect.rs new file mode 100644 index 0000000..1b30023 --- /dev/null +++ b/crates/core/tests/setting/redirect.rs @@ -0,0 +1,88 @@ +//! `redirect.json`:写/读/跟随/成环/损坏/版本更高。 + +use std::path::PathBuf; + +use unlucky_core::platform::data_dir::DataMode; +use unlucky_core::platform::error::PlatformError; +use unlucky_core::platform::redirect::{ + follow, read, redirect_path, remove, write, RedirectPointer, REDIRECT_SCHEMA_VERSION, +}; + +use crate::common::TempDir; + +fn pointer(target: &str) -> RedirectPointer { + RedirectPointer { + schema_version: REDIRECT_SCHEMA_VERSION, + mode: DataMode::Standard, + launcher_home: PathBuf::from(target), + game_home: PathBuf::from(target).join(".minecraft"), + migrated_at_ms: 1_700_000_000_000, + } +} + +#[test] +fn round_trips_pointer_file() { + let temp = TempDir::new("redirect"); + let origin = temp.child(".ULL"); + assert_eq!(read(&origin).expect("读空目录"), None); + + let written = write(&origin, &pointer("/elsewhere/.ULL")).expect("写指针"); + assert_eq!(written, redirect_path(&origin)); + assert!(origin.join("redirect.json").is_file()); + + let loaded = read(&origin).expect("读指针").expect("有指针"); + assert_eq!(loaded, pointer("/elsewhere/.ULL")); + assert!(remove(&origin).expect("删指针")); + assert!(!remove(&origin).expect("再删一次")); +} + +#[test] +fn follows_chain_to_final_target() { + let temp = TempDir::new("redirect-chain"); + let first = temp.child("a"); + let second = temp.child("b"); + let third = temp.child("c"); + + write(&first, &pointer(&second.to_string_lossy())).expect("写 a"); + write(&second, &pointer(&third.to_string_lossy())).expect("写 b"); + + let followed = follow(&first, 8).expect("跟随").expect("有指针"); + assert_eq!(followed.hops, 2); + assert_eq!(followed.pointer.launcher_home, third); +} + +#[test] +fn detects_redirect_cycle() { + let temp = TempDir::new("redirect-cycle"); + let first = temp.child("a"); + let second = temp.child("b"); + write(&first, &pointer(&second.to_string_lossy())).expect("写 a"); + write(&second, &pointer(&first.to_string_lossy())).expect("写 b"); + + let err = follow(&first, 8).expect_err("应当成环报错"); + assert!(matches!(err, PlatformError::RedirectCycle { .. }), "{err}"); +} + +#[test] +fn rejects_corrupt_and_newer_pointer() { + let temp = TempDir::new("redirect-corrupt"); + let dir = temp.child(".ULL"); + std::fs::create_dir_all(&dir).expect("建目录"); + std::fs::write(dir.join("redirect.json"), b"{not json").expect("写坏文件"); + assert!(matches!( + read(&dir).expect_err("坏文件应报错"), + PlatformError::RedirectCorrupt { .. } + )); + + let mut newer = pointer("/newer/.ULL"); + newer.schema_version = REDIRECT_SCHEMA_VERSION + 1; + std::fs::write( + dir.join("redirect.json"), + serde_json::to_vec(&newer).expect("序列化"), + ) + .expect("写文件"); + assert!(matches!( + read(&dir).expect_err("新版本指针应报错"), + PlatformError::RedirectCorrupt { .. } + )); +} diff --git a/crates/core/tests/setting/schema_compat.rs b/crates/core/tests/setting/schema_compat.rs new file mode 100644 index 0000000..96fdcfe --- /dev/null +++ b/crates/core/tests/setting/schema_compat.rs @@ -0,0 +1,120 @@ +//! schema 版本标识、8 态兼容判定、三档访问权映射。 + +use serde_json::json; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::schema::{ + FileAccess, SchemaCompat, SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY, +}; + +fn expected() -> SchemaRef { + SchemaRef::new(SchemaId::LAUNCHER, SchemaVersion::new(1, 2, 3)) +} + +fn compat_of(version: SchemaVersion) -> SchemaCompat { + let url = SchemaRef::new(SchemaId::LAUNCHER, version).url(); + SchemaCompat::check(&json!({ SCHEMA_KEY: url }), expected()) +} + +#[test] +fn version_parsing_and_ordering() { + let version = SchemaVersion::parse("1.2.3").expect("解析"); + assert_eq!(version, SchemaVersion::new(1, 2, 3)); + assert_eq!(version.to_string(), "1.2.3"); + assert_eq!(version.next_major(), SchemaVersion::new(2, 0, 0)); + assert!(SchemaVersion::parse("1.2").is_none()); + assert!(SchemaVersion::parse("1.2.3.4").is_none()); + assert!(SchemaVersion::new(1, 2, 3) < SchemaVersion::new(1, 3, 0)); +} + +#[test] +fn schema_url_round_trip() { + let reference = launcher_schema().schema_ref(); + let url = reference.url(); + assert!( + url.ends_with(&format!("launcher/{}", reference.version)), + "{url}" + ); + assert_eq!(SchemaRef::parse_url(&url), Some(reference)); + assert!(SchemaRef::parse_url("https://example.com/x").is_none()); + assert!(SchemaRef::parse_url("garbage").is_none()); + assert_eq!(SchemaId::parse("instance"), Some(SchemaId::INSTANCE)); + assert!(SchemaId::parse("nope").is_none()); +} + +#[test] +fn same_or_older_versions_are_read_write() { + assert_eq!( + compat_of(SchemaVersion::new(1, 2, 3)), + SchemaCompat::ReadWrite + ); + assert_eq!( + compat_of(SchemaVersion::new(1, 2, 2)), + SchemaCompat::ReadWrite + ); + assert_eq!( + compat_of(SchemaVersion::new(1, 0, 0)), + SchemaCompat::ReadWrite + ); + assert_eq!( + compat_of(SchemaVersion::new(0, 9, 0)), + SchemaCompat::ReadWrite + ); +} + +#[test] +fn newer_patch_keeps_original_schema() { + let compat = compat_of(SchemaVersion::new(1, 2, 4)); + assert_eq!(compat, SchemaCompat::ReadWritePreserveSchema); + assert!(compat.preserve_schema()); + assert_eq!(compat.access(), FileAccess::ReadWrite); +} + +#[test] +fn newer_minor_is_read_only() { + let compat = compat_of(SchemaVersion::new(1, 3, 0)); + assert_eq!(compat, SchemaCompat::ReadOnlyPreserveSchema); + assert!(compat.preserve_schema()); + assert_eq!(compat.access(), FileAccess::ReadOnly); + assert!(!compat.access().can_save()); + assert!(compat.access().blocks_editing()); +} + +#[test] +fn newer_major_is_unreadable() { + let compat = compat_of(SchemaVersion::new(2, 0, 0)); + assert_eq!(compat, SchemaCompat::UnsupportedMajor); + assert!(!compat.readable()); + assert_eq!(compat.access(), FileAccess::Unreadable); + assert!(!compat.access().can_read()); +} + +#[test] +fn other_ids_and_broken_urls_are_rejected() { + let instance_url = SchemaRef::new(SchemaId::INSTANCE, SchemaVersion::new(1, 0, 0)).url(); + assert_eq!( + SchemaCompat::check(&json!({ SCHEMA_KEY: instance_url }), expected()), + SchemaCompat::UnexpectedId + ); + assert_eq!( + SchemaCompat::check(&json!({ SCHEMA_KEY: "not-a-url" }), expected()), + SchemaCompat::Invalid + ); + assert_eq!( + SchemaCompat::check(&json!({ "schemaVersion": 1 }), expected()), + SchemaCompat::Missing + ); + assert_eq!(SchemaCompat::Unparseable.access(), FileAccess::Unreadable); +} + +#[test] +fn access_three_tiers() { + assert!(FileAccess::ReadWrite.can_read() && FileAccess::ReadWrite.can_save()); + assert!(FileAccess::ReadOnly.can_read() && !FileAccess::ReadOnly.can_save()); + assert!(!FileAccess::Unreadable.can_read()); + assert!(FileAccess::Unreadable.blocks_editing()); + assert_eq!(FileAccess::ReadOnly.as_str(), "read_only"); + assert_eq!( + SchemaCompat::ReadOnlyPreserveSchema.as_str(), + "read_only_preserve_schema" + ); +} diff --git a/crates/core/tests/setting/spec_ai.rs b/crates/core/tests/setting/spec_ai.rs new file mode 100644 index 0000000..92c2d19 --- /dev/null +++ b/crates/core/tests/setting/spec_ai.rs @@ -0,0 +1,178 @@ +//! 键元数据:AI 可写白名单、敏感分级、类型/范围校验、工具 JSON Schema。 + +use serde_json::json; +use unlucky_core::setting::catalog::SettingSchema; +use unlucky_core::setting::instance_keys::{instance_schema, profiles_schema}; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::schema::{SchemaId, SchemaVersion}; +use unlucky_core::setting::spec::{AiPolicy, Sensitivity, SettingSpec, ValueKind}; + +#[test] +fn ai_whitelist_excludes_credentials() { + let schema = launcher_schema(); + let writable = schema.ai_writable_keys(); + assert!(writable.contains(&"download.concurrency")); + assert!(writable.contains(&"appearance.theme_mode")); + assert!(!writable.contains(&"ai.api_key"), "凭据永不可写(D9)"); + + assert!(schema.accepts_ai_write("java.selected_path")); + assert!(!schema.accepts_ai_write("ai.api_key")); + assert!(!schema.accepts_ai_write("not.a.key")); +} + +#[test] +fn sensitivity_grades_are_flagged() { + let schema = launcher_schema(); + assert_eq!( + schema.sensitivity("core.data_mode"), + Some(Sensitivity::High) + ); + assert_eq!( + schema.sensitivity("download.concurrency"), + Some(Sensitivity::Low) + ); + assert_eq!(schema.sensitivity("ai.model"), Some(Sensitivity::Medium)); + assert_eq!(schema.sensitivity("ai.api_key"), Some(Sensitivity::High)); + assert!(Sensitivity::High.requires_confirmation()); + assert!(!Sensitivity::Low.requires_confirmation()); + assert_eq!(Sensitivity::Medium.as_str(), "medium"); +} + +#[test] +fn validates_types_ranges_and_enums() { + let schema = launcher_schema(); + assert!(schema.validate("download.concurrency", &json!(8)).is_ok()); + assert!(schema.validate("download.concurrency", &json!(0)).is_err()); + assert!(schema + .validate("download.concurrency", &json!(257)) + .is_err()); + assert!(schema + .validate("download.concurrency", &json!("8")) + .is_err()); + assert!(schema + .validate("appearance.theme_mode", &json!("dark")) + .is_ok()); + assert!(schema + .validate("appearance.theme_mode", &json!("solarized")) + .is_err()); + // 废弃键仍然「读兼容合法」——旧文件里的值不该被判成非法。 + assert!(schema.validate("ui.theme", &json!("metro_dark")).is_ok()); + assert!(schema.validate("ui.theme", &json!("auto")).is_err()); + assert!(schema.validate("ui.scale", &json!(1.5)).is_ok()); + assert!(schema.validate("ui.scale", &json!(9.0)).is_err()); + + let err = schema.validate("nope.key", &json!(1)).expect_err("未知键"); + assert!(err.to_string().contains("nope.key")); +} + +#[test] +fn value_kinds_cover_all_variants() { + assert!(ValueKind::Int { min: 1, max: 2 }.check(&json!(2)).is_ok()); + assert!(ValueKind::Int { min: 1, max: 2 }.check(&json!(3)).is_err()); + assert!(ValueKind::Float { min: 0.0, max: 1.0 } + .check(&json!(0.5)) + .is_ok()); + assert!(ValueKind::Text { max_len: 2 }.check(&json!("ab")).is_ok()); + assert!(ValueKind::Text { max_len: 2 }.check(&json!("abc")).is_err()); + assert!(ValueKind::Path.check(&json!(" ")).is_err()); + assert!(ValueKind::Path + .check(&json!("C:/java/bin/java.exe")) + .is_ok()); + assert!(ValueKind::TextList.check(&json!(["a", "b"])).is_ok()); + assert!(ValueKind::TextList.check(&json!([1])).is_err()); + assert!(ValueKind::Any.check(&json!({"whatever": 1})).is_ok()); +} + +#[test] +fn json_schema_projection_feeds_tool_specs() { + let schema = launcher_schema(); + let projected = schema.to_json_schema(); + assert_eq!(projected["type"], json!("object")); + assert_eq!(projected["additionalProperties"], json!(false)); + + let properties = projected["properties"].as_object().expect("properties"); + assert!(properties.contains_key("download.concurrency")); + assert!(!properties.contains_key("ai.api_key"), "锁定键不进工具参数"); + assert_eq!(properties["download.concurrency"]["type"], json!("integer")); + assert_eq!(properties["download.concurrency"]["default"], json!(8)); + assert_eq!( + properties["download.concurrency"]["x-ai-sensitivity"], + json!("low") + ); + assert_eq!(properties["appearance.theme_mode"]["type"], json!("string")); +} + +#[test] +fn top_level_keys_and_defaults() { + let schema = launcher_schema(); + let tops = schema.top_level_keys(); + for expected in ["core", "download", "java", "ai", "ui", "log"] { + assert!(tops.contains(expected), "缺少顶层键组 {expected}"); + } + assert_eq!( + schema.default_value("download.concurrency"), + Some(&json!(8)) + ); + assert_eq!( + schema.default_value("java.selected_path"), + Some(&json!(null)) + ); + assert!(schema.spec("download.concurrency").is_some()); + assert!(schema.has_prefix(&["download"])); + assert!(!schema.has_prefix(&["nope"])); +} + +#[test] +fn instance_and_profiles_schemas_are_usable() { + let instance = instance_schema(); + assert_eq!(instance.id(), SchemaId::INSTANCE); + // D20:实例键表统一成 camelCase 扁平形态(与 game::install::instance 的 serde 逐键一致)。 + assert!(instance.is_known("versionId")); + assert!(instance.is_known("isolation")); + assert!(instance.is_known("lastModeChange.probe.isolatedSide")); + assert!(instance.accepts_ai_write("isolation")); + assert!( + !instance.accepts_ai_write("lastModeChange.at"), + "切换记录由启动器写,AI 不许改" + ); + assert!(instance.validate("isolation", &json!("isolated")).is_ok()); + assert!(instance.validate("isolation", &json!("inherit")).is_ok()); + assert!(instance.validate("isolation", &json!("maybe")).is_err()); + assert!( + instance + .validate("isolation", &json!("per_version")) + .is_err(), + "旧字面量由 game 侧读兼容,schema 白名单只认新值" + ); + + let profiles = profiles_schema(); + assert_eq!(profiles.id(), SchemaId::PROFILES); + assert_eq!( + profiles.spec("profiles").expect("profiles 键").kind, + ValueKind::Any + ); + assert!( + !profiles.accepts_ai_write("profiles"), + "列表本体不许 AI 整体改写" + ); + assert!(profiles.accepts_ai_write("selected_profile_id")); +} + +#[test] +#[should_panic(expected = "重复的配置键")] +fn duplicate_key_is_a_table_bug() { + let schema = SettingSchema::new(SchemaId::LAUNCHER, SchemaVersion::new(9, 0, 0)) + .with(SettingSpec::bool( + "dup.key", + true, + AiPolicy::silent(), + "第一次", + )) + .with(SettingSpec::bool( + "dup.key", + false, + AiPolicy::silent(), + "第二次", + )); + assert_eq!(schema.specs().len(), 2); +} diff --git a/crates/core/tests/setting/store.rs b/crates/core/tests/setting/store.rs new file mode 100644 index 0000000..f4f312b --- /dev/null +++ b/crates/core/tests/setting/store.rs @@ -0,0 +1,260 @@ +//! 配置文件的加载降级、只写脏字段、坏文件备份、只读/不可读三档行为。 + +use serde_json::{json, Value}; +use unlucky_core::setting::document::LoadProblem; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::schema::{ + FileAccess, SchemaCompat, SchemaId, SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_VERSION_KEY, +}; +use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason}; + +use crate::common::TempDir; + +fn schema_url(version: SchemaVersion) -> String { + SchemaRef::new(SchemaId::LAUNCHER, version).url() +} + +fn read_json(temp: &TempDir, name: &str) -> Value { + serde_json::from_str(&temp.read(name)).expect("配置文件是合法 JSON") +} + +#[test] +fn first_run_starts_writable_with_defaults() { + let temp = TempDir::new("store-new"); + let path = temp.child("config/launcher.json"); + let store = SettingStore::load(&path, launcher_schema()); + + assert_eq!(store.access(), FileAccess::ReadWrite); + assert_eq!(store.compat(), SchemaCompat::Missing); + assert!(store.problems().is_empty()); + assert_eq!( + store.effective("download.concurrency"), + Some(&json!(8)), + "首次运行用默认值" + ); + assert!(!path.exists(), "只读加载不该创建文件"); +} + +#[test] +fn saves_only_when_dirty_and_writes_schema_markers() { + let temp = TempDir::new("store-save"); + let path = temp.child("config/launcher.json"); + let mut store = SettingStore::load(&path, launcher_schema()); + + assert_eq!( + store.save().expect("空保存"), + SaveOutcome::Skipped(SkipReason::NotDirty) + ); + + store + .set("download.concurrency", json!(16)) + .expect("改配置"); + let outcome = store.save().expect("保存"); + assert!(outcome.wrote()); + + let raw = read_json(&temp, "config/launcher.json"); + assert_eq!(raw["download"]["concurrency"], json!(16)); + assert_eq!(raw[SCHEMA_VERSION_KEY], json!(1)); + assert!( + raw[SCHEMA_KEY] + .as_str() + .expect("$schema") + .ends_with(&format!("launcher/{}", launcher_schema().version())), + "保存写当前 schema 版本" + ); + assert!(raw.get("ai").is_none(), "默认值不进文件"); + assert!(!store.is_dirty(), "保存后清脏"); + + let reloaded = SettingStore::load(&path, launcher_schema()); + assert_eq!(reloaded.effective("download.concurrency"), Some(&json!(16))); +} + +#[test] +fn preserves_unknown_keys_across_save() { + let temp = TempDir::new("store-unknown"); + let raw = json!({ + SCHEMA_KEY: schema_url(SchemaVersion::new(1, 0, 0)), + SCHEMA_VERSION_KEY: 1, + "download": { "concurrency": 4 }, + "from_the_future": { "flag": true } + }); + temp.write("config/launcher.json", &raw.to_string()); + + let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); + assert!( + store.problems().iter().any( + |problem| matches!(problem, LoadProblem::UnknownKey { key } if key == "from_the_future") + ), + "陌生顶层子树按最上层节点报一次: {:?}", + store.problems() + ); + + store + .set("download.concurrency", json!(12)) + .expect("改配置"); + store.save().expect("保存"); + + let after = read_json(&temp, "config/launcher.json"); + assert_eq!(after["download"]["concurrency"], json!(12)); + assert_eq!(after["from_the_future"]["flag"], json!(true)); +} + +#[test] +fn corrupt_file_is_backed_up_before_overwrite() { + let temp = TempDir::new("store-corrupt"); + temp.write("config/launcher.json", "{ definitely not json"); + + let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); + assert_eq!(store.compat(), SchemaCompat::Unparseable); + assert_eq!( + store.access(), + FileAccess::ReadWrite, + "坏文件要能修,否则用户被永久卡住" + ); + assert_eq!(store.effective("download.concurrency"), Some(&json!(8))); + + store.set("ui.language", json!("en_us")).expect("改配置"); + store.save().expect("保存"); + + assert!(temp.exists("config/launcher.json.1"), "坏文件先备份成 .1"); + let raw = read_json(&temp, "config/launcher.json"); + assert_eq!(raw["ui"]["language"], json!("en_us")); +} + +#[test] +fn newer_minor_is_read_only_and_keeps_its_schema() { + let temp = TempDir::new("store-readonly"); + // 比当前 schema(1.3.0)更高的 minor:只读且保留原 $schema。 + let url = schema_url(SchemaVersion::new(1, 4, 0)); + temp.write( + "config/launcher.json", + &json!({ SCHEMA_KEY: url, "download": { "concurrency": 32 } }).to_string(), + ); + + let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); + assert_eq!(store.compat(), SchemaCompat::ReadOnlyPreserveSchema); + assert_eq!(store.access(), FileAccess::ReadOnly); + assert_eq!( + store.effective("download.concurrency"), + Some(&json!(32)), + "只读档的值照读" + ); + assert!(store.set("download.concurrency", json!(1)).is_err()); + assert_eq!( + store.save().expect("只读保存"), + SaveOutcome::Skipped(SkipReason::ReadOnly) + ); + + store.save_forced().expect("用户确认强写"); + assert_eq!(store.access(), FileAccess::ReadWrite); + let raw = read_json(&temp, "config/launcher.json"); + assert!( + raw[SCHEMA_KEY] + .as_str() + .expect("$schema") + .ends_with("launcher/1.4.0"), + "更 minor 的文件强写后也要保留原 $schema" + ); +} + +#[test] +fn higher_major_is_unreadable_until_forced() { + let temp = TempDir::new("store-unreadable"); + let url = schema_url(SchemaVersion::new(2, 0, 0)); + temp.write( + "config/launcher.json", + &json!({ SCHEMA_KEY: url, "brand_new": { "thing": 1 } }).to_string(), + ); + + let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); + assert_eq!(store.compat(), SchemaCompat::UnsupportedMajor); + assert_eq!(store.access(), FileAccess::Unreadable); + assert!( + store.set("appearance.theme_mode", json!("dark")).is_err(), + "不可读档全程不写" + ); + assert_eq!( + store.save().expect("不可读保存"), + SaveOutcome::Skipped(SkipReason::Unreadable) + ); + + store.save_forced().expect("用户确认强写"); + assert!(temp.exists("config/launcher.json.1")); + assert_eq!(store.access(), FileAccess::ReadWrite); +} + +#[test] +fn reload_discards_in_memory_changes() { + let temp = TempDir::new("store-reload"); + let path = temp.write( + "config/launcher.json", + &json!({ SCHEMA_KEY: schema_url(SchemaVersion::new(1, 0, 0)), "appearance": { "theme_mode": "light" } }) + .to_string(), + ); + + let mut store = SettingStore::load(&path, launcher_schema()); + store + .set("appearance.theme_mode", json!("dark")) + .expect("改配置"); + assert!(store.is_dirty()); + store.reload(); + assert!(!store.is_dirty()); + assert_eq!( + store.effective("appearance.theme_mode"), + Some(&json!("light")) + ); +} + +#[test] +fn backup_paths_increment() { + let temp = TempDir::new("store-backup"); + temp.write("config/launcher.json.1", "old"); + temp.write("config/launcher.json.2", "older"); + let store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); + let next = store.next_backup_path(); + assert!(next.to_string_lossy().ends_with(".3"), "{next:?}"); +} + +/// finding 20261030-worker-java2-bug-schema-launcher-json-loadproblem: +/// `$schema` 前缀写错(手写/脚本生成的配置最常见的错)曾让整份文件静默回落默认值。 +/// M40 起:值照读 + `UnknownSchema` 告警 + 文件只读(不悄悄改写我们看不懂的文件)。 +#[test] +fn unknown_schema_prefix_warns_but_keeps_file_values() { + let temp = TempDir::new("store-unknown-schema"); + let url = "https://unlucky-launcher.local/schema/launcher/1.2.0.json"; + temp.write( + "config/launcher.json", + &json!({ + SCHEMA_KEY: url, + "core": { "data_mode": "standard" }, + "download": { "concurrency": 32 }, + }) + .to_string(), + ); + + let mut store = SettingStore::load(temp.child("config/launcher.json"), launcher_schema()); + assert_eq!(store.compat(), SchemaCompat::Invalid); + assert_eq!(store.access(), FileAccess::ReadOnly); + assert_eq!( + store.effective("download.concurrency"), + Some(&json!(32)), + "认不出 $schema 也要读文件里的值" + ); + assert!( + store.problems().iter().any( + |problem| matches!(problem, LoadProblem::UnknownSchema { url: found } if found == url) + ), + "{:?}", + store.problems() + ); + assert!( + store.problems()[0].to_string().contains("$schema"), + "{}", + store.problems()[0] + ); + assert!(store.set("download.concurrency", json!(1)).is_err()); + assert_eq!( + store.save().expect("只读保存"), + SaveOutcome::Skipped(SkipReason::ReadOnly) + ); +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..e9b0a59 --- /dev/null +++ b/deny.toml @@ -0,0 +1,54 @@ +# cargo-deny 配置(D15:Apache-2.0 主干,GPL 依赖不得进主干)。 +# CI 侧的安装与执行见 .github/workflows/guards.yml(licenses job)。 +# 参考:https://embarkstudios.github.io/cargo-deny/ · 决策:DevDocs/PlanDoc/00-decision-log.md D15 + +[graph] +all-features = true + +[advisories] +version = 2 +yanked = "deny" +# 注意:cargo-deny 0.20 移除了 severity-threshold(见 PR #611),严重度改由 +# [advisories] 的 ignore 清单 + 后续安全轮次单独把关;这里只做「yanked 依赖禁止」。 + +[licenses] +version = 2 +# 白名单:Apache-2.0 / MIT / BSD / Zlib 系(用户要求)+ 实际依赖树需要的宽松许可。 +# GPL / AGPL / LGPL / SSPL 一律不在列 → 直接 fail,不进主干。 +# +# 补充项及来源(均为宽松、与 Apache-2.0 兼容,不改变「拒绝 copyleft」的立场): +# - BSL-1.0(Boost):clipboard-win / error-code(winit/arboard 的 Windows 依赖) +# - Unlicense:ksni(Slint 系统托盘) +# - CDLA-Permissive-2.0:webpki-roots(Mozilla CA 根证书数据) +# - Unicode-3.0:unicode-ident;CC0-1.0 / MIT-0 / ISC:生态常见宽松项 +confidence-threshold = 0.92 +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "MIT", + "MIT-0", + "BSD-2-Clause", + "BSD-3-Clause", + "Zlib", + "BSL-1.0", + "ISC", + "CC0-1.0", + "Unlicense", + "Unicode-3.0", + "CDLA-Permissive-2.0", + # Slint(D2)走 Royalty-Free 2.0 轨道:其 crates 的 SPDX 表达为 + # "GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0", + # 允许多选一即通过;这里显式放行 RF 分支,GPL 分支仍是拒绝的。 + # 主义务:关于页放 AboutSlint 部件(见 NOTICE 与 DevDocs/PlanDoc/01c-native-gui-options.md §8)。 + "LicenseRef-Slint-Royalty-free-2.0", +] + +[bans] +multiple-versions = "warn" +wildcards = "deny" +highlight = "all" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..ab71de3 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,6 @@ +[toolchain] +# 全仓统一 stable,不在仓库内钉死具体小版本(CI 与本地同样走 stable 解析)。 +# 各平台(Win10+ / macOS / Linux)共用本文件,不按平台另起工具链。 +channel = "stable" +profile = "minimal" +components = ["rustfmt", "clippy"]