diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0fa3f8c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + branches: [main, codex-mmcl-phase0] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: | + xcode_path=$(ls -d /Applications/Xcode*.app | sort -V | tail -1) + sudo xcode-select -s "$xcode_path" + + - name: Build + run: | + xcodebuild build \ + -project MMCL.xcodeproj \ + -scheme MMCL \ + -destination 'platform=macOS' \ + -quiet + + - name: Test + run: | + xcodebuild test \ + -project MMCL.xcodeproj \ + -scheme MMCL \ + -destination 'platform=macOS' \ + -only-testing:MMCLTests \ + -quiet + + lint: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: SwiftLint + run: | + brew install swiftlint + swiftlint lint --strict --reporter github-actions-logging || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1896770 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + build-and-release: + runs-on: macos-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: | + xcode_path=$(ls -d /Applications/Xcode*.app | sort -V | tail -1) + sudo xcode-select -s "$xcode_path" + + - name: Build Release + run: | + xcodebuild build \ + -project MMCL.xcodeproj \ + -scheme MMCL \ + -destination 'platform=macOS' \ + -configuration Release \ + -quiet + + - name: Create DMG + run: | + app_path=$(find DerivedData/Build/Products/Release -name "MMCL.app" -type d | head -1) + hdiutil create -volname "MMCL" \ + -srcfolder "$app_path" \ + -ov -format UDZO \ + "MMCL-${GITHUB_REF_NAME}.dmg" + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: | + MMCL-${{ github.ref_name }}.dmg + generate_release_notes: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f249e2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,81 @@ +# Xcode +build/ +DerivedData/ +*.xcodeproj/xcuserdata/ +*.xcodeproj/xcworkspace/xcuserdata/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ +*.xccheckout +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Swift Package Manager +.build/ +Packages/ +Package.pins +Package.resolved + +# CocoaPods +Pods/ + +# Carthage +Carthage/Build/ +Carthage/Checkouts/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* + +# Thumbnails +Thumbs.db + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Xcode Playground +timeline.xctimeline +playground.xcworkspace + +# SwiftPM +.swiftpm/ + +# Codex +.codex/ + +# Claude +.claude/ + +# Superpowers (internal planning docs) +docs/superpowers/ + +# App-specific +*.mobileprovision +*.entitlements +*.cer +*.p12 +*.pem diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..b51503f --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,79 @@ +disabled_rules: + - trailing_whitespace + - todo + - force_try + - force_cast + - force_unwrap + - line_length + - file_length + - type_body_length + - function_body_length + - closure_body_length + - large_tuple + - nesting + - identifier_name + - private_outlet + - private_iboutlet + - no_fallthrough_only + - multiple_closures_with_trailing_closure + - implicitly_unwrapped_optional + - unavailable_function + - empty_enum_arguments + - empty_parameters + - trailing_semicolon + - vertical_whitespace + - opening_brace + - comma + - colon + - return_arrow_whitespace + - statement_position + - switch_case_alignment + - syntactic_sugar + - unused_closure_parameter + - unused_control_flow_label + - unused_enumerated + - unused_optional_binding + - unused_setter_value + - void_return + - weak_delegate + - no_fallthrough_only + - no_space_in_method_call + - no_trailing_spaces + - no_void_return + +excluded: + - DerivedData + - build + - Pods + - Carthage + - .build + - MMCL/Persistence.swift + +opt_in_rules: + - empty_count + - closure_spacing + - contains_over_first_not_nil + - discouraged_object_literal + - empty_collection_literal + - empty_string + - fatal_error_message + - first_where + - force_unwrapping + - implicitly_unwrapped_optional + - last_where + - legacy_multiple + - legacy_random + - legacy_constant + - legacy_constructor + - legacy_cggeometry_functions + - legacy_nsgeometry_functions + - modifier_order + - overridden_super_call + - private_action + - private_outlet + - prohibited_super_call + - redundant_nil_coalescing + - toggle_bool + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - yoda_condition diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..01784ef --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Test + +```bash +# Build (debug) +xcodebuild build -project MMCL.xcodeproj -scheme MMCL -destination 'platform=macOS' -quiet + +# Run tests +xcodebuild test -project MMCL.xcodeproj -scheme MMCL -destination 'platform=macOS' -quiet + +# Run single test class +xcodebuild test -project MMCL.xcodeproj -scheme MMCL -destination 'platform=macOS' -only-testing:MMCLTests/LauncherStoreTests + +# Build & run (uses script) +./script/build_and_run.sh +``` + +## Architecture + +macOS Minecraft launcher. SwiftUI app with a **Models → Services → Store → Views** layered architecture. References PCL (Plain Craft Launcher) for interaction design and backend management patterns. + +### Core layers + +- **Models** (`MMCL/Models/LauncherModels.swift`): All data types — `LauncherInstance`, `VersionMetadata`, `DownloadJob`, `JavaRuntime`, `AssetIndex`, `LaunchSession`, `MinecraftAccount`, `FabricProfile`, `ModrinthVersion`, `ModInfo`, `ResourcePackInfo`, etc. +- **Services** (`MMCL/Services/LauncherServices.swift`): Protocol-based service layer. + - `InstanceServicing` — instance creation, slug generation, JSON persistence + - `VersionManifestServicing` — Mojang version manifest/metadata fetching + - `DownloadServicing` — download job creation, SHA-1 execution, native library unzipping + - `JavaRuntimeServicing` — `/usr/libexec/java_home -V` parsing, portable JDK install + - `LaunchServicing` — command line generation, preflight checks, game launch + - `DiagnosticServicing` — Java mismatch checks, crash log analysis + - `FabricServicing` — Fabric loader installation via meta.fabricmc.net + - `QuiltServicing` — Quilt loader installation via meta.quiltmc.org + - `ForgeServicing` — Forge loader installation via promotions_slim.json + - `NeoForgeServicing` — NeoForge loader installation via Maven + - `ModrinthServicing` — Modrinth search, project details, version download + - `CurseForgeServicing` — CurseForge mod search (requires API key) + - `AuthServicing` — Microsoft OAuth device code flow, XBL/XSTS/Minecraft token exchange +- **Store** (`MMCL/Stores/LauncherStore.swift`): `@MainActor` `ObservableObject` holding all app state. Orchestrates services, manages downloads, process monitoring, account management. All `@Published` modifications must happen on main actor. +- **Views** (`MMCL/Views/`): SwiftUI views. `NavigationSplitView` layout with sidebar + detail + sheets. + - `LauncherView` — instance picker, launch button, instance card with block icon + - `DownloadCenterView` — TabView with 7 tabs (新建实例, Mod, 整合包, 数据包, 资源包, 光影包, 下载进度) + - `DownloadVanillaView` — Minecraft version list, loader selection, create + download + - `DownloadResourceSearchView` — Modrinth/CurseForge search with stagger animation + - `DownloadProgressView` — concurrent download progress, pause/resume/cancel + - `InstanceSettingsView` — instance config (Java, memory, JVM args, management) + - `ModListView` — local mod management (enable/disable/delete) + - `ResourcePackListView` / `ShaderPackListView` — resource/shader pack management + - `ModrinthProjectDetailView` — Modrinth version picker and install + - `LogViewerSheet` — real-time game log viewer with auto-refresh + - `JDKInstallSheet` — portable JDK installation from Adoptium + - `SkinPickerView` — skin management + - `ServerListView` — multiplayer server management + - `WorkspaceViews` — DiagnosticsView, SettingsView (accounts, appearance, JVM presets, download source, about) + - `AnimationScale.swift` — `Animation.mmclSpring()` extension for consistent spring animations + +### Key patterns + +- `@MainActor` on `LauncherStore` prevents "Publishing changes from within view updates" warnings +- Services injected into `LauncherStore` via init (protocol types) for mock-based testing +- JSON uses `JSONEncoder.mmcl` / `JSONDecoder.mmcl` (ISO 8601, pretty printed) +- Instance files at `~/Library/Application Support/MMCL/Instances/{slug}/instance.json` +- Portable JDK installed to `~/Library/Application Support/MMCL/JDK/` +- Download sources: official, BMCLAPI, custom mirror +- Java recommendation: major version 8 (≤1.16), 17 (1.17–1.19), 21 (≥1.20) +- Apple Silicon auto-detection: ZGC + optimized JVM args for arm64 +- Downloads execute concurrently via `TaskGroup` (max 4 parallel) +- Microsoft auth uses device code flow (browser-based OAuth) +- Mod management: toggle by renaming `.jar` ↔ `.jar.disabled` +- Instance status verified against actual files on disk at startup +- Block icons: Grass (release), CommandBlock (snapshot), CobbleStone (old), Anvil (Forge), Fabric, Egg (Quilt) +- Animations use `Animation.mmclSpring()` with configurable duration scale + +## Testing + +Tests use XCTest with protocol-based mocks (e.g., `MockDownloadService`, `MockVersionManifestService`, `MockInstanceService`). No external dependencies. Tests in `MMCLTests/`. + +## Conventions + +- UI text in Chinese; code identifiers and comments in English +- `Persistence.swift` is template CoreData — not used; app state lives in `LauncherStore` +- Project uses `PBXFileSystemSynchronizedRootGroup` — new `.swift` files auto-discovered by Xcode +- GitHub repo: `Lhy723/MMCL` diff --git a/MMCL.xcodeproj/project.pbxproj b/MMCL.xcodeproj/project.pbxproj index dfcf0f6..b128340 100644 --- a/MMCL.xcodeproj/project.pbxproj +++ b/MMCL.xcodeproj/project.pbxproj @@ -398,8 +398,9 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FJ7T78T44N; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; @@ -430,8 +431,9 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FJ7T78T44N; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; diff --git a/MMCL/Assets.xcassets/Anvil.imageset/Anvil.png b/MMCL/Assets.xcassets/Anvil.imageset/Anvil.png new file mode 100644 index 0000000..4a0df3e Binary files /dev/null and b/MMCL/Assets.xcassets/Anvil.imageset/Anvil.png differ diff --git a/MMCL/Assets.xcassets/Anvil.imageset/Contents.json b/MMCL/Assets.xcassets/Anvil.imageset/Contents.json new file mode 100644 index 0000000..9f6586c --- /dev/null +++ b/MMCL/Assets.xcassets/Anvil.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "Anvil.png" } + ], + "info" : { "version" : 1, "author" : "xcode" } +} diff --git a/MMCL/Assets.xcassets/AppIcon.appiconset/Contents.json b/MMCL/Assets.xcassets/AppIcon.appiconset/Contents.json index 3f00db4..64dc11e 100644 --- a/MMCL/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/MMCL/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,51 +1,61 @@ { "images" : [ { + "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { + "filename" : "icon_16x16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { + "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { + "filename" : "icon_32x32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { + "filename" : "icon_128x128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { + "filename" : "icon_128x128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { + "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { + "filename" : "icon_256x256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { + "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { + "filename" : "icon_512x512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/MMCL/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..56e2768 Binary files /dev/null and b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/MMCL/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..d9e571d Binary files /dev/null and b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/MMCL/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..fbd9907 Binary files /dev/null and b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/MMCL/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..37c46d4 Binary files /dev/null and b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/MMCL/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..d9e571d Binary files /dev/null and b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/MMCL/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..ee08c51 Binary files /dev/null and b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/MMCL/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..37c46d4 Binary files /dev/null and b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/MMCL/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..82e06e0 Binary files /dev/null and b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/MMCL/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..ee08c51 Binary files /dev/null and b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/MMCL/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..3f317c7 Binary files /dev/null and b/MMCL/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/MMCL/Assets.xcassets/CobbleStone.imageset/CobbleStone.png b/MMCL/Assets.xcassets/CobbleStone.imageset/CobbleStone.png new file mode 100644 index 0000000..f1cac18 Binary files /dev/null and b/MMCL/Assets.xcassets/CobbleStone.imageset/CobbleStone.png differ diff --git a/MMCL/Assets.xcassets/CobbleStone.imageset/Contents.json b/MMCL/Assets.xcassets/CobbleStone.imageset/Contents.json new file mode 100644 index 0000000..376ab52 --- /dev/null +++ b/MMCL/Assets.xcassets/CobbleStone.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "CobbleStone.png" } + ], + "info" : { "version" : 1, "author" : "xcode" } +} diff --git a/MMCL/Assets.xcassets/CommandBlock.imageset/CommandBlock.png b/MMCL/Assets.xcassets/CommandBlock.imageset/CommandBlock.png new file mode 100644 index 0000000..ace887c Binary files /dev/null and b/MMCL/Assets.xcassets/CommandBlock.imageset/CommandBlock.png differ diff --git a/MMCL/Assets.xcassets/CommandBlock.imageset/Contents.json b/MMCL/Assets.xcassets/CommandBlock.imageset/Contents.json new file mode 100644 index 0000000..aa7e3c9 --- /dev/null +++ b/MMCL/Assets.xcassets/CommandBlock.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "CommandBlock.png" } + ], + "info" : { "version" : 1, "author" : "xcode" } +} diff --git a/MMCL/Assets.xcassets/Egg.imageset/Contents.json b/MMCL/Assets.xcassets/Egg.imageset/Contents.json new file mode 100644 index 0000000..e584d56 --- /dev/null +++ b/MMCL/Assets.xcassets/Egg.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "Egg.png" } + ], + "info" : { "version" : 1, "author" : "xcode" } +} diff --git a/MMCL/Assets.xcassets/Egg.imageset/Egg.png b/MMCL/Assets.xcassets/Egg.imageset/Egg.png new file mode 100644 index 0000000..886d6b5 Binary files /dev/null and b/MMCL/Assets.xcassets/Egg.imageset/Egg.png differ diff --git a/MMCL/Assets.xcassets/Fabric.imageset/Contents.json b/MMCL/Assets.xcassets/Fabric.imageset/Contents.json new file mode 100644 index 0000000..903ee74 --- /dev/null +++ b/MMCL/Assets.xcassets/Fabric.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "Fabric.png" } + ], + "info" : { "version" : 1, "author" : "xcode" } +} diff --git a/MMCL/Assets.xcassets/Fabric.imageset/Fabric.png b/MMCL/Assets.xcassets/Fabric.imageset/Fabric.png new file mode 100644 index 0000000..064d3e6 Binary files /dev/null and b/MMCL/Assets.xcassets/Fabric.imageset/Fabric.png differ diff --git a/MMCL/Assets.xcassets/GoldBlock.imageset/Contents.json b/MMCL/Assets.xcassets/GoldBlock.imageset/Contents.json new file mode 100644 index 0000000..3bd42db --- /dev/null +++ b/MMCL/Assets.xcassets/GoldBlock.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "GoldBlock.png" } + ], + "info" : { "version" : 1, "author" : "xcode" } +} diff --git a/MMCL/Assets.xcassets/GoldBlock.imageset/GoldBlock.png b/MMCL/Assets.xcassets/GoldBlock.imageset/GoldBlock.png new file mode 100644 index 0000000..417a3c0 Binary files /dev/null and b/MMCL/Assets.xcassets/GoldBlock.imageset/GoldBlock.png differ diff --git a/MMCL/Assets.xcassets/Grass.imageset/Contents.json b/MMCL/Assets.xcassets/Grass.imageset/Contents.json new file mode 100644 index 0000000..f87f190 --- /dev/null +++ b/MMCL/Assets.xcassets/Grass.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "Grass.png" } + ], + "info" : { "version" : 1, "author" : "xcode" } +} diff --git a/MMCL/Assets.xcassets/Grass.imageset/Grass.png b/MMCL/Assets.xcassets/Grass.imageset/Grass.png new file mode 100644 index 0000000..dce7c14 Binary files /dev/null and b/MMCL/Assets.xcassets/Grass.imageset/Grass.png differ diff --git a/MMCL/Assets.xcassets/GrassPath.imageset/Contents.json b/MMCL/Assets.xcassets/GrassPath.imageset/Contents.json new file mode 100644 index 0000000..94f4d53 --- /dev/null +++ b/MMCL/Assets.xcassets/GrassPath.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "GrassPath.png" } + ], + "info" : { "version" : 1, "author" : "xcode" } +} diff --git a/MMCL/Assets.xcassets/GrassPath.imageset/GrassPath.png b/MMCL/Assets.xcassets/GrassPath.imageset/GrassPath.png new file mode 100644 index 0000000..9ced72e Binary files /dev/null and b/MMCL/Assets.xcassets/GrassPath.imageset/GrassPath.png differ diff --git a/MMCL/Assets.xcassets/NeoForge.imageset/Contents.json b/MMCL/Assets.xcassets/NeoForge.imageset/Contents.json new file mode 100644 index 0000000..7c5d56e --- /dev/null +++ b/MMCL/Assets.xcassets/NeoForge.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "NeoForge.png" } + ], + "info" : { "version" : 1, "author" : "xcode" } +} diff --git a/MMCL/Assets.xcassets/NeoForge.imageset/NeoForge.png b/MMCL/Assets.xcassets/NeoForge.imageset/NeoForge.png new file mode 100644 index 0000000..7d46d39 Binary files /dev/null and b/MMCL/Assets.xcassets/NeoForge.imageset/NeoForge.png differ diff --git a/MMCL/Assets.xcassets/RedstoneBlock.imageset/Contents.json b/MMCL/Assets.xcassets/RedstoneBlock.imageset/Contents.json new file mode 100644 index 0000000..54b7f8c --- /dev/null +++ b/MMCL/Assets.xcassets/RedstoneBlock.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "RedstoneBlock.png" } + ], + "info" : { "version" : 1, "author" : "xcode" } +} diff --git a/MMCL/Assets.xcassets/RedstoneBlock.imageset/RedstoneBlock.png b/MMCL/Assets.xcassets/RedstoneBlock.imageset/RedstoneBlock.png new file mode 100644 index 0000000..088e56b Binary files /dev/null and b/MMCL/Assets.xcassets/RedstoneBlock.imageset/RedstoneBlock.png differ diff --git a/MMCL/Assets.xcassets/RedstoneLampOn.imageset/Contents.json b/MMCL/Assets.xcassets/RedstoneLampOn.imageset/Contents.json new file mode 100644 index 0000000..d31951e --- /dev/null +++ b/MMCL/Assets.xcassets/RedstoneLampOn.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "RedstoneLampOn.png" } + ], + "info" : { "version" : 1, "author" : "xcode" } +} diff --git a/MMCL/Assets.xcassets/RedstoneLampOn.imageset/RedstoneLampOn.png b/MMCL/Assets.xcassets/RedstoneLampOn.imageset/RedstoneLampOn.png new file mode 100644 index 0000000..5f9e052 Binary files /dev/null and b/MMCL/Assets.xcassets/RedstoneLampOn.imageset/RedstoneLampOn.png differ diff --git a/MMCL/ContentView.swift b/MMCL/ContentView.swift index 7d83e82..ac22478 100644 --- a/MMCL/ContentView.swift +++ b/MMCL/ContentView.swift @@ -1,83 +1,155 @@ -// -// ContentView.swift -// MMCL -// -// Created by 星音 on 2026/5/27. -// - import SwiftUI -import CoreData struct ContentView: View { - @Environment(\.managedObjectContext) private var viewContext - - @FetchRequest( - sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)], - animation: .default) - private var items: FetchedResults + @ObservedObject var store: LauncherStore var body: some View { - NavigationView { - List { - ForEach(items) { item in - NavigationLink { - Text("Item at \(item.timestamp!, formatter: itemFormatter)") - } label: { - Text(item.timestamp!, formatter: itemFormatter) - } + NavigationSplitView { + SidebarView(store: store) + } detail: { + detailView + } + .background { + if let bgURL = store.backgroundImage.url { + AsyncImage(url: bgURL) { image in + image + .resizable() + .scaledToFill() + .blur(radius: store.backgroundImage.blurRadius) + .opacity(store.backgroundImage.opacity) + .allowsHitTesting(false) + .transition(.opacity) + } placeholder: { + Color.clear } - .onDelete(perform: deleteItems) + .ignoresSafeArea() } - .toolbar { - ToolbarItem { - Button(action: addItem) { - Label("Add Item", systemImage: "plus") + } + .toolbar { + ToolbarItemGroup { + Button { + store.launchSelectedInstance() + } label: { + Label("启动", systemImage: "play.fill") + } + .buttonStyle(.borderedProminent) + .disabled(store.selectedInstance == nil || store.selectedJavaRuntime == nil || store.selectedInstance?.status != .ready) + .help("启动选中的实例") + + Menu { + ForEach(store.accounts) { account in + Button { + store.selectedAccountID = account.id + } label: { + HStack { + Text(account.displayName) + if store.selectedAccountID == account.id { + Image(systemName: "checkmark") + } + } + } } + } label: { + HStack(spacing: 4) { + Image(systemName: "person.circle") + Text(store.accounts.first(where: { $0.id == store.selectedAccountID })?.displayName ?? "账号") + .lineLimit(1) + } + .frame(maxWidth: 180) } } - Text("Select an item") } - } - - private func addItem() { - withAnimation { - let newItem = Item(context: viewContext) - newItem.timestamp = Date() - - do { - try viewContext.save() - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + .onAppear { + Task.detached { + try? await Task.sleep(nanoseconds: 200_000_000) + await MainActor.run { + store.selectFirstInstanceIfNeeded() + store.verifyInstanceStatuses() + } + await store.refreshJavaRuntimes() + await store.checkForUpdates() + } + } + .onChange(of: store.launcherSelectedInstanceID) { _, newID in + if let id = newID { + UserDefaults.standard.set(id.uuidString, forKey: "lastSelectedInstanceID") + } + } + .sheet(isPresented: $store.showingCreateSheet) { + InstanceCreateSheet(store: store) + } + .sheet(isPresented: $store.showingLogSheet) { + if let instance = store.selectedInstance { + LogViewerSheet(instance: instance, store: store) } } + .sheet(isPresented: $store.showingModrinthDetail) { + if let project = store.selectedModrinthProject { + ModrinthProjectDetailView(project: project, store: store) + } + } + .sheet(isPresented: $store.showingRenameSheet) { + if let instance = store.selectedInstance { + InstanceRenameSheet(instance: instance, store: store) + } + } + .sheet(isPresented: $store.showingModList) { + if let instance = store.selectedInstance { + ModListView(instance: instance, store: store) + } + } + .sheet(isPresented: $store.showingResourcePacks) { + if let instance = store.selectedInstance { + ResourcePackListView(instance: instance, store: store) + } + } + .sheet(isPresented: $store.showingShaderPacks) { + if let instance = store.selectedInstance { + ShaderPackListView(instance: instance, store: store) + } + } + .sheet(isPresented: $store.showingJDKInstall) { + JDKInstallSheet(store: store) + } } - private func deleteItems(offsets: IndexSet) { - withAnimation { - offsets.map { items[$0] }.forEach(viewContext.delete) - - do { - try viewContext.save() - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + @ViewBuilder + private var detailView: some View { + if let settingsID = store.selectedInstanceSettingsID, + let instance = store.instances.first(where: { $0.id == settingsID }) { + InstanceSettingsView(instance: instance, store: store) + } else { + switch store.selectedSection { + case .launcher: + LauncherView(store: store) + case .downloads: + DownloadCenterView(store: store) + case .diagnostics: + DiagnosticsView(store: store) + case .skin: + SkinPickerView(store: store) + case .serverList: + ServerListView(store: store) + case .settings: + SettingsView(store: store) + case .none: + EmptyStateView(title: "欢迎使用 MMCL", message: "选择实例、下载中心或诊断日志开始。", systemImage: "gamecontroller") } } } } -private let itemFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .medium - return formatter -}() +private struct EmptyStateView: View { + let title: String + let message: String + let systemImage: String + + var body: some View { + ContentUnavailableView(title, systemImage: systemImage, description: Text(message)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} #Preview { - ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) + ContentView(store: LauncherStore()) } diff --git a/MMCL/MMCLApp.swift b/MMCL/MMCLApp.swift index 78e4de1..87b3bd7 100644 --- a/MMCL/MMCLApp.swift +++ b/MMCL/MMCLApp.swift @@ -1,21 +1,43 @@ -// -// MMCLApp.swift -// MMCL -// -// Created by 星音 on 2026/5/27. -// - import SwiftUI -import CoreData @main struct MMCLApp: App { - let persistenceController = PersistenceController.shared + @StateObject private var store = LauncherStore() var body: some Scene { WindowGroup { - ContentView() - .environment(\.managedObjectContext, persistenceController.container.viewContext) + ContentView(store: store) + .frame(minWidth: 920, minHeight: 620) + .modifier(ConditionalColorScheme(scheme: store.colorScheme)) + } + .commands { + CommandGroup(after: .newItem) { + Button("新增实例") { + store.showingCreateSheet = true + } + .keyboardShortcut("n", modifiers: [.command]) + } + } + + Settings { + TabView { + SettingsView(store: store) + .tabItem { Label("通用", systemImage: "gear") } + HelpView() + .tabItem { Label("帮助", systemImage: "questionmark.circle") } + } + } + } +} + +private struct ConditionalColorScheme: ViewModifier { + let scheme: AppColorScheme + + func body(content: Content) -> some View { + if let colorScheme = scheme.swiftUIScheme { + content.preferredColorScheme(colorScheme) + } else { + content } } } diff --git a/MMCL/Models/LauncherModels.swift b/MMCL/Models/LauncherModels.swift new file mode 100644 index 0000000..e5be6b2 --- /dev/null +++ b/MMCL/Models/LauncherModels.swift @@ -0,0 +1,1208 @@ +import Combine +import Foundation +import SwiftUI + +enum GameLoader: String, Codable, CaseIterable, Identifiable { + case vanilla = "Vanilla" + case fabric = "Fabric" + case quilt = "Quilt" + case forge = "Forge" + + var id: String { rawValue } +} + +enum VersionIsolation: String, Codable, CaseIterable, Identifiable { + case off = "关闭" + case moddableVersions = "隔离可安装 Mod 的版本" + case snapshots = "隔离非正式版" + case moddableAndSnapshots = "隔离可安装 Mod 的版本与非正式版" + case all = "隔离所有版本" + + var id: String { rawValue } + + var helpText: String { + switch self { + case .off: return "所有版本共享存档、Mod、资源包" + case .moddableVersions: return "Forge/Fabric 等互相独立,原版共享" + case .snapshots: return "快照与发布版、远古版本等隔离" + case .moddableAndSnapshots: return "同时隔离可安装 Mod 版本与非正式版" + case .all: return "不同版本的存档、Mod、资源包均不互通" + } + } +} + +enum LauncherVisibility: String, Codable, CaseIterable, Identifiable { + case closeAfterLaunch = "游戏启动后立即关闭" + case hideAndClose = "游戏启动后隐藏,退出后自动关闭" + case hideAndReopen = "游戏启动后隐藏,退出后重新打开" + case minimize = "游戏启动后最小化" + case keep = "游戏启动后仍保持不变" + + var id: String { rawValue } +} + +enum DownloadTabType: String, CaseIterable, Identifiable { + case vanilla = "原版游戏" + case mod = "Mod" + case modpack = "整合包" + case dataPack = "数据包" + case resourcePack = "资源包" + case shader = "光影包" + case progress = "下载进度" + + var id: String { rawValue } + + var icon: String { + switch self { + case .vanilla: return "cube.box" + case .mod: return "puzzlepiece.extension" + case .modpack: return "shippingbox" + case .dataPack: return "doc.text" + case .resourcePack: return "photo.stack" + case .shader: return "sparkles" + case .progress: return "chart.line.uptrend.xyaxis" + } + } +} + +enum WindowSizeMode: String, Codable, CaseIterable, Identifiable { + case fullscreen = "全屏" + case `default` = "默认" + case launcherSized = "与启动器窗口一致" + case custom = "自定义" + case maximized = "最大化" + + var id: String { rawValue } +} + +enum FileDownloadSourceMode: String, Codable, CaseIterable, Identifiable { + case preferMirror = "镜像源优先" + case officialWithFallback = "官方源优先(默认,切镜像)" + case preferOfficial = "官方源优先" + + var id: String { rawValue } +} + +enum VersionListSourceMode: String, Codable, CaseIterable, Identifiable { + case preferMirror = "镜像源优先" + case officialWithFallback = "官方源优先(默认,切镜像)" + case preferOfficial = "官方源优先" + + var id: String { rawValue } +} + +enum CommunitySourceMode: String, Codable, CaseIterable, Identifiable { + case preferMirror = "镜像源优先" + case officialWithFallback = "仅官方慢时切镜像" + case preferOfficial = "官方源优先(默认)" + + var id: String { rawValue } +} + +enum FilenameFormat: String, Codable, CaseIterable, Identifiable { + case bracketCN = "【译名】" + case bracketEN = "[译名](默认)" + case suffixDash = "译名-" + case prefixDash = "-译名" + case noTranslation = "不翻译" + + var id: String { rawValue } +} + +enum ModListDisplayStyle: String, Codable, CaseIterable, Identifiable { + case titleTranslationDetailFilename = "标题显示译名,详情显示文件名" + case titleFilenameDetailTranslation = "标题显示文件名,详情显示译名" + + var id: String { rawValue } +} + +enum ProcessPriority: String, Codable, CaseIterable, Identifiable { + case high = "高 — 优先保证游戏运行,性能更佳,但可能造成其他程序卡顿" + case normal = "中 — 平衡" + case low = "低 — 优先保证其他程序运行,适合挂机" + + var id: String { rawValue } +} + +enum InstanceStatus: String, Codable { + case ready + case missingFiles + case needsJava + case notInstalled + + var label: String { + switch self { + case .ready: return "可启动" + case .missingFiles: return "需要修复" + case .needsJava: return "需要 Java" + case .notInstalled: return "未安装" + } + } +} + +struct LaunchProfile: Codable, Equatable { + var offlineUsername: String + var memoryMegabytes: Int + var jvmArguments: [String] + var resolutionWidth: Int + var resolutionHeight: Int + + static let `default` = LaunchProfile( + offlineUsername: "Steve", + memoryMegabytes: 4096, + jvmArguments: ["-XX:+UseG1GC", "-XX:+UnlockExperimentalVMOptions"], + resolutionWidth: 854, + resolutionHeight: 480 + ) +} + +struct LauncherInstance: Identifiable, Codable, Equatable { + var id: UUID + var name: String + var gameVersion: String + var loader: GameLoader + var rootDirectory: URL + var profile: LaunchProfile + var status: InstanceStatus + var lastPlayedAt: Date? + + init( + id: UUID = UUID(), + name: String, + gameVersion: String, + loader: GameLoader, + rootDirectory: URL, + profile: LaunchProfile = .default, + status: InstanceStatus = .notInstalled, + lastPlayedAt: Date? = nil + ) { + self.id = id + self.name = name + self.gameVersion = gameVersion + self.loader = loader + self.rootDirectory = rootDirectory + self.profile = profile + self.status = status + self.lastPlayedAt = lastPlayedAt + } + + var subtitle: String { + "\(gameVersion) · \(loader.rawValue)" + } + + var blockIcon: String { + switch loader { + case .forge: return "Anvil" + case .fabric: return "Fabric" + case .quilt: return "Egg" + case .vanilla: + if gameVersion.contains("w") || gameVersion.contains("-pre") || gameVersion.contains("rc") { + return "CommandBlock" + } + if gameVersion.contains("Alpha") || gameVersion.contains("Beta") { + return "CobbleStone" + } + return "Grass" + } + } + +} + +struct MinecraftVersion: Identifiable, Codable, Equatable { + enum ReleaseType: String, Codable { + case release + case snapshot + case oldBeta = "old_beta" + case oldAlpha = "old_alpha" + + var label: String { + switch self { + case .release: return "正式版" + case .snapshot: return "快照版" + case .oldBeta: return "Beta" + case .oldAlpha: return "Alpha" + } + } + } + + var id: String + var type: ReleaseType + var metadataURL: URL + var releaseTime: Date + var recommendedJavaMajorVersion: Int + + enum CodingKeys: String, CodingKey { + case id + case type + case metadataURL = "url" + case releaseTime + case recommendedJavaMajorVersion + } + + init( + id: String, + type: ReleaseType, + metadataURL: URL, + releaseTime: Date, + recommendedJavaMajorVersion: Int? = nil + ) { + self.id = id + self.type = type + self.metadataURL = metadataURL + self.releaseTime = releaseTime + self.recommendedJavaMajorVersion = recommendedJavaMajorVersion ?? JavaRuntime.recommendedMajorVersion(for: id) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let id = try container.decode(String.self, forKey: .id) + self.id = id + self.type = try container.decode(ReleaseType.self, forKey: .type) + self.metadataURL = try container.decode(URL.self, forKey: .metadataURL) + self.releaseTime = try container.decode(Date.self, forKey: .releaseTime) + self.recommendedJavaMajorVersion = try container.decodeIfPresent( + Int.self, + forKey: .recommendedJavaMajorVersion + ) ?? JavaRuntime.recommendedMajorVersion(for: id) + } +} + +struct VersionManifest: Codable, Equatable { + struct Latest: Codable, Equatable { + var release: String + var snapshot: String + } + + var latest: Latest + var versions: [MinecraftVersion] +} + +struct VersionMetadata: Codable, Equatable { + struct ArgumentSet: Codable, Equatable { + var game: [LaunchArgument] + var jvm: [LaunchArgument] + } + + struct LaunchArgument: Codable, Equatable { + struct Rule: Codable, Equatable { + struct OS: Codable, Equatable { + var name: String? + } + + var action: String + var os: OS? + var features: [String: Bool]? + } + + enum Value: Codable, Equatable { + case string(String) + case array([String]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let string = try? container.decode(String.self) { + self = .string(string) + } else { + self = .array(try container.decode([String].self)) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let string): + try container.encode(string) + case .array(let array): + try container.encode(array) + } + } + + var strings: [String] { + switch self { + case .string(let string): return [string] + case .array(let array): return array + } + } + } + + var value: Value + var rules: [Rule]? + + init(value: Value, rules: [Rule]? = nil) { + self.value = value + self.rules = rules + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let string = try? container.decode(String.self) { + self.value = .string(string) + self.rules = nil + } else { + let keyed = try decoder.container(keyedBy: CodingKeys.self) + self.value = try keyed.decode(Value.self, forKey: .value) + self.rules = try keyed.decodeIfPresent([Rule].self, forKey: .rules) + } + } + + func applies(to operatingSystem: String) -> Bool { + guard let rules, !rules.isEmpty else { return true } + + var result = false + for rule in rules { + var ruleMatches = true + + if let os = rule.os, let osName = os.name { + if osName == "unknown" { + // "unknown" OS: always matches (no exclusion) + } else if osName == operatingSystem { + // Matches target OS + } else { + ruleMatches = false + } + } + + if let features = rule.features, !features.isEmpty { + // PCL: skip quick_play features entirely + if features.keys.contains(where: { $0.contains("quick_play") }) { + ruleMatches = false + } + // PCL: skip is_demo_user features + if features["is_demo_user"] == true { + ruleMatches = false + } + } + + if ruleMatches { + result = (rule.action == "allow") + } + } + return result + } + } + + struct Download: Codable, Equatable { + var url: URL + var sha1: String + var size: Int64 + } + + struct Downloads: Codable, Equatable { + var client: Download + } + + struct AssetIndex: Codable, Equatable { + var id: String + var url: URL + var sha1: String + var size: Int64 + } + + struct Library: Codable, Equatable, Identifiable { + struct Downloads: Codable, Equatable { + var artifact: Artifact? + var classifiers: [String: Artifact]? = nil + } + + struct Artifact: Codable, Equatable { + var path: String + var url: URL + var sha1: String + var size: Int64 + } + + var name: String + var natives: [String: String]? = nil + var downloads: Downloads? + + var id: String { name } + var artifact: Artifact? { downloads?.artifact } + + func nativeArtifact(for operatingSystem: String = "osx") -> Artifact? { + guard let classifier = natives?[operatingSystem] else { return nil } + return downloads?.classifiers?[classifier] + } + } + + var id: String + var mainClass: String + var assets: String + var assetIndex: AssetIndex + var downloads: Downloads + var libraries: [Library] + var arguments: ArgumentSet? + var minecraftArguments: String? +} + +struct AssetIndex: Codable, Equatable { + struct Object: Codable, Equatable { + var hash: String + var size: Int64 + + var pathPrefix: String { + String(hash.prefix(2)) + } + } + + var objects: [String: Object] + + var totalBytes: Int64 { + objects.values.reduce(0) { $0 + $1.size } + } +} + +struct LaunchPreview: Equatable { + var instance: LauncherInstance + var java: JavaRuntime + var command: [String] + + var commandLine: String { + command.map { argument in + if argument.contains(" ") { + return "\"\(argument)\"" + } + return argument + } + .joined(separator: " ") + } +} + +struct LaunchSession: Identifiable, Equatable { + var id: UUID + var processIdentifier: Int32 + var command: [String] + var logFileURL: URL + var startedAt: Date + + init( + id: UUID = UUID(), + processIdentifier: Int32, + command: [String], + logFileURL: URL, + startedAt: Date = Date() + ) { + self.id = id + self.processIdentifier = processIdentifier + self.command = command + self.logFileURL = logFileURL + self.startedAt = startedAt + } + + var commandLine: String { + command.map { argument in + if argument.contains(" ") { + return "\"\(argument)\"" + } + return argument + } + .joined(separator: " ") + } +} + +struct LaunchPreflightReport: Equatable { + var severity: DiagnosticSeverity + var summary: String + var suggestedActions: [String] + + var canLaunch: Bool { + severity != .error + } + + func diagnostic(title: String = "启动前检查未通过") -> DiagnosticReport { + DiagnosticReport( + title: title, + severity: severity, + summary: summary, + suggestedActions: suggestedActions + ) + } +} + +enum RuntimeArchitecture: String, Codable { + case arm64 + case x86_64 + case universal + case unknown + + var label: String { + switch self { + case .arm64: return "Apple Silicon" + case .x86_64: return "Intel" + case .universal: return "通用" + case .unknown: return "未知架构" + } + } +} + +struct JavaRuntime: Identifiable, Codable, Equatable { + var id: UUID + var name: String + var version: String + var majorVersion: Int + var architecture: RuntimeArchitecture + var executableURL: URL + + init( + id: UUID = UUID(), + name: String, + version: String, + majorVersion: Int, + architecture: RuntimeArchitecture, + executableURL: URL + ) { + self.id = id + self.name = name + self.version = version + self.majorVersion = majorVersion + self.architecture = architecture + self.executableURL = executableURL + } + + var displayName: String { + "\(name) · Java \(majorVersion) · \(architecture.label)" + } + + func isRecommended(for gameVersion: String) -> Bool { + majorVersion == JavaRuntime.recommendedMajorVersion(for: gameVersion) + } + + static func recommendedMajorVersion(for gameVersion: String) -> Int { + let components = gameVersion.split(separator: ".").compactMap { Int($0) } + guard components.count >= 2 else { return 17 } + let minor = components[1] + + if minor >= 20 { return 21 } + if minor >= 17 { return 17 } + return 8 + } +} + +enum DownloadSource: String, Codable, CaseIterable, Identifiable { + case official = "官方源" + case bmclapi = "BMCLAPI" + case customMirror = "自定义镜像" + + var id: String { rawValue } +} + +enum DownloadStatus: String, Codable { + case queued + case running + case paused + case completed + case failed + + var label: String { + switch self { + case .queued: return "等待中" + case .running: return "下载中" + case .paused: return "已暂停" + case .completed: return "已完成" + case .failed: return "失败" + } + } + + var isActive: Bool { + self == .queued || self == .running || self == .paused + } +} + +struct DownloadJob: Identifiable, Codable, Equatable { + private enum CodingKeys: String, CodingKey { + case id, title, source, remoteURL, destination, sha1 + case totalBytes, completedBytes, bytesPerSecond, status + case taskGroupID, taskGroupName + } + + var id: UUID + var title: String + var source: DownloadSource + var remoteURL: URL? + var destination: URL + var sha1: String? + var totalBytes: Int64 + var completedBytes: Int64 + var bytesPerSecond: Int64 + var status: DownloadStatus + var taskGroupID: UUID? + var taskGroupName: String? + + /// Resume data for paused downloads (not persisted) + var resumeData: Data? + + init( + id: UUID = UUID(), + title: String, + source: DownloadSource, + remoteURL: URL? = nil, + destination: URL, + sha1: String? = nil, + totalBytes: Int64, + completedBytes: Int64 = 0, + bytesPerSecond: Int64 = 0, + status: DownloadStatus = .queued, + taskGroupID: UUID? = nil, + taskGroupName: String? = nil + ) { + self.id = id + self.title = title + self.source = source + self.remoteURL = remoteURL + self.destination = destination + self.sha1 = sha1 + self.totalBytes = totalBytes + self.completedBytes = completedBytes + self.bytesPerSecond = bytesPerSecond + self.status = status + self.taskGroupID = taskGroupID + self.taskGroupName = taskGroupName + } + + var progress: Double { + guard totalBytes > 0 else { return 0 } + return min(Double(completedBytes) / Double(totalBytes), 1) + } + + mutating func update(completedBytes: Int64) { + self.completedBytes = max(0, min(completedBytes, totalBytes)) + status = self.completedBytes >= totalBytes ? .completed : .running + } +} + +struct DownloadTaskGroup: Identifiable { + let id: UUID + let name: String + var jobs: [DownloadJob] + + var totalBytes: Int64 { jobs.reduce(0) { $0 + $1.totalBytes } } + var completedBytes: Int64 { jobs.reduce(0) { $0 + $1.completedBytes } } + var progress: Double { + guard totalBytes > 0 else { return 0 } + return min(Double(completedBytes) / Double(totalBytes), 1) + } + + var status: DownloadStatus { + if jobs.contains(where: { $0.status == .failed }) { return .failed } + if jobs.allSatisfy({ $0.status == .completed }) { return .completed } + if jobs.contains(where: { $0.status == .running }) { return .running } + if jobs.contains(where: { $0.status == .paused }) { return .paused } + return .queued + } + + var currentFileName: String? { + jobs.first(where: { $0.status == .running })?.title + } + + var completedCount: Int { jobs.filter { $0.status == .completed }.count } + var failedCount: Int { jobs.filter { $0.status == .failed }.count } +} + +struct ModInfo: Identifiable, Equatable { + var id: String { fileName } + var fileName: String + var isEnabled: Bool + var size: Int64 +} + +struct ResourcePackInfo: Identifiable, Equatable { + var id: String { fileName } + var fileName: String + var isEnabled: Bool + var size: Int64 +} + +struct ShaderPackInfo: Identifiable, Equatable { + var id: String { fileName } + var fileName: String + var isEnabled: Bool + var size: Int64 +} + +struct ContentProject: Identifiable, Codable, Equatable { + enum ProjectType: String, Codable { + case mod = "Mod" + case modpack = "整合包" + case resourcePack = "资源包" + case shaderPack = "光影包" + } + + var id: String + var title: String + var type: ProjectType + var source: String + var gameVersions: [String] + var loaders: [GameLoader] +} + +enum DiagnosticSeverity: String, Codable, CaseIterable, Identifiable { + case info + case warning + case error + + var id: String { rawValue } + + var localized: String { + switch self { + case .info: return "提示" + case .warning: return "警告" + case .error: return "错误" + } + } +} + +struct DiagnosticReport: Identifiable, Codable, Equatable { + var id: UUID + var title: String + var severity: DiagnosticSeverity + var summary: String + var suggestedActions: [String] + + init( + id: UUID = UUID(), + title: String, + severity: DiagnosticSeverity, + summary: String, + suggestedActions: [String] + ) { + self.id = id + self.title = title + self.severity = severity + self.summary = summary + self.suggestedActions = suggestedActions + } + + var localizedSeverity: String { + severity.localized + } + + var fullMessage: String { + let actions = suggestedActions.map { "- \($0)" }.joined(separator: "\n") + return "[\(localizedSeverity)] \(title)\n\(summary)\n\(actions)" + } +} + +struct FabricLoaderVersion: Codable, Identifiable, Equatable { + var id: String { version } + var version: String + var stable: Bool +} + +struct FabricProfile: Codable, Equatable { + var id: String + var inheritsFrom: String + var mainClass: String + var arguments: FabricArguments? + + struct FabricArguments: Codable, Equatable { + var game: [String]? + var jvm: [String]? + } +} + +struct QuiltLoaderVersion: Codable, Identifiable, Equatable { + var id: String { version } + var version: String + var stable: Bool +} + +struct QuiltProfile: Codable, Equatable { + var id: String + var inheritsFrom: String + var mainClass: String + + struct QuiltArguments: Codable, Equatable { + var game: [String]? + var jvm: [String]? + } + var arguments: QuiltArguments? +} + +struct ForgeVersion: Codable, Identifiable, Equatable { + var id: String { version } + var version: String + var installerURL: String + + enum CodingKeys: String, CodingKey { + case version + case installerURL = "installer_url" + } +} + +struct NeoForgeVersion: Codable, Identifiable, Equatable { + var id: String { version } + var version: String + var neoForgeVersion: String + + enum CodingKeys: String, CodingKey { + case version + case neoForgeVersion = "neo_version" + } +} + +struct CurseForgeSearchResult: Codable, Identifiable, Equatable { + var id: Int + var name: String + var summary: String + var downloadCount: Int + var websiteUrl: String + + enum CodingKeys: String, CodingKey { + case id, name, summary + case downloadCount = "downloadCount" + case websiteUrl = "websiteUrl" + } +} + +struct CurseForgeSearchResponse: Codable, Equatable { + var data: [CurseForgeSearchResult] +} + +struct ModrinthSearchResult: Codable, Identifiable, Equatable { + var id: String + var slug: String + var title: String + var description: String + var projectType: String + var downloads: Int + var iconURL: String? + var categories: [String] + var displayCategories: [String]? + var color: Int? + var author: String? + var dateModified: String? + + var iconURLResolved: URL? { + guard let iconURL, let url = URL(string: iconURL) else { return nil } + return url + } + + var tintColor: Color? { + guard let color else { return nil } + let r = Double((color >> 16) & 0xFF) / 255.0 + let g = Double((color >> 8) & 0xFF) / 255.0 + let b = Double(color & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } + + var formattedDate: String? { + guard let dateModified else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let date = formatter.date(from: dateModified) ?? ISO8601DateFormatter().date(from: dateModified) else { return nil } + let relative = RelativeDateTimeFormatter() + relative.unitsStyle = .short + return relative.localizedString(for: date, relativeTo: Date()) + } + + var displayTags: [String] { + (displayCategories ?? categories).prefix(3).map { $0 } + } + + enum CodingKeys: String, CodingKey { + case id = "project_id" + case slug, title, description + case projectType = "project_type" + case downloads + case iconURL = "icon_url" + case categories + case displayCategories = "display_categories" + case color, author + case dateModified = "date_modified" + } +} + +struct ModrinthSearchResponse: Codable, Equatable { + var hits: [ModrinthSearchResult] + var totalHits: Int + + enum CodingKeys: String, CodingKey { + case hits + case totalHits = "total_hits" + } +} + +struct ModrinthProject: Codable, Identifiable, Equatable { + var id: String + var slug: String + var title: String + var description: String + var projectType: String + var body: String + var iconURL: String? + var downloads: Int + var gameVersions: [String] + var loaders: [String] + + enum CodingKeys: String, CodingKey { + case id, slug, title, description, projectType, body, downloads + case iconURL = "icon_url" + case gameVersions = "game_versions" + case loaders + } +} + +struct ModrinthVersion: Codable, Identifiable, Equatable { + var id: String + var name: String + var versionNumber: String + var gameVersions: [String] + var loaders: [String] + var files: [ModrinthFile] + + enum CodingKeys: String, CodingKey { + case id, name + case versionNumber = "version_number" + case gameVersions = "game_versions" + case loaders, files + } +} + +struct ModrinthFile: Codable, Equatable { + var filename: String + var url: String + var size: Int64 + var primary: Bool +} + +struct SkinInfo: Identifiable, Codable, Equatable { + var id: UUID = UUID() + var name: String + var model: SkinModel + var localFileURL: URL? + var remoteURL: URL? + var isApplied: Bool = false + + enum SkinModel: String, Codable, CaseIterable { + case steve = "Steve" + case alex = "Alex" + + var label: String { rawValue } + } +} + +struct MinecraftAccount: Codable, Equatable, Identifiable { + var id: UUID + var username: String + var uuid: String + var accessToken: String + var refreshToken: String + var expiresAt: Date + var type: AccountType + var appliedSkin: SkinInfo? + + enum AccountType: String, Codable { + case offline + case microsoft + } + + var displayName: String { + switch type { + case .offline: return "\(username)(离线)" + case .microsoft: return username + } + } + + init(id: UUID = UUID(), username: String, uuid: String = "", accessToken: String = "", refreshToken: String = "", expiresAt: Date = Date(), type: AccountType = .offline, appliedSkin: SkinInfo? = nil) { + self.id = id + self.username = username + self.uuid = uuid + self.accessToken = accessToken + self.refreshToken = refreshToken + self.expiresAt = expiresAt + self.type = type + self.appliedSkin = appliedSkin + } +} + +struct DeviceCodeResponse: Codable { + var userCode: String + var verificationUri: String + var expiresIn: Int + var interval: Int + var deviceCode: String + + enum CodingKeys: String, CodingKey { + case userCode = "user_code" + case verificationUri = "verification_uri" + case expiresIn = "expires_in" + case interval + case deviceCode = "device_code" + } +} + +struct MicrosoftTokenResponse: Codable { + var accessToken: String + var refreshToken: String + var expiresInSeconds: Int + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresInSeconds = "expires_in" + } +} + +struct XboxTokenResponse: Codable { + var token: String + var expiresInSeconds: Int + + enum CodingKeys: String, CodingKey { + case token + case expiresInSeconds = "expiresIn" + } +} + +struct XBLXSTSResponse: Codable { + var token: String + var expiresInSeconds: Int + + enum CodingKeys: String, CodingKey { + case token + case expiresInSeconds = "expiresIn" + } +} + +struct MinecraftTokenResponse: Codable { + var accessToken: String + var expiresInSeconds: Int + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case expiresInSeconds = "expires_in" + } +} + +struct MinecraftProfileResponse: Codable { + var id: String + var name: String +} + +extension JSONEncoder { + static var mmcl: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return encoder + } +} + +extension JSONDecoder { + static var mmcl: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} + +final class DownloadSpeedTracker: ObservableObject { + @Published var bytesPerSecond: Int64 = 0 + private var totalBytes: Int64 = 0 + private var startTime: Date? + + func addBytes(_ bytes: Int64) { + if startTime == nil { startTime = Date() } + totalBytes += bytes + guard let start = startTime else { return } + let elapsed = Date().timeIntervalSince(start) + if elapsed > 0 { + bytesPerSecond = Int64(Double(totalBytes) / elapsed) + } + } + + func reset() { + totalBytes = 0 + startTime = nil + bytesPerSecond = 0 + } +} + +enum AppColorScheme: String, CaseIterable, Codable, Identifiable { + case system = "跟随系统" + case light = "浅色" + case dark = "深色" + + var id: String { rawValue } + + var swiftUIScheme: SwiftUI.ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } +} + +enum AppLanguage: String, CaseIterable, Codable, Identifiable { + case chinese = "中文" + case english = "English" + + var id: String { rawValue } +} + +struct JVMPreset: Identifiable, Codable, Equatable { + var id: UUID + var name: String + var arguments: [String] + var isEnabled: Bool + + static let defaults = [ + JVMPreset(id: UUID(), name: "自动(推荐)", arguments: [], isEnabled: true), + JVMPreset(id: UUID(), name: "Apple Silicon 优化", arguments: ["-XX:+UseZGC", "-XX:+ZGenerational", "-XX:+UnlockExperimentalVMOptions", "-XX:G1HeapRegionSize=16M"], isEnabled: false), + JVMPreset(id: UUID(), name: "G1GC", arguments: ["-XX:+UseG1GC", "-XX:+UnlockExperimentalVMOptions"], isEnabled: false), + JVMPreset(id: UUID(), name: "ZGC(低延迟)", arguments: ["-XX:+UseZGC", "-XX:+ZGenerational"], isEnabled: false), + JVMPreset(id: UUID(), name: "大内存", arguments: ["-XX:+UseG1GC", "-XX:MaxGCPauseMillis=20", "-XX:+UnlockExperimentalVMOptions", "-XX:G1NewSizePercent=30", "-XX:G1MaxNewSizePercent=40"], isEnabled: false), + ] +} + +// MARK: - Server List + +struct ServerInfo: Identifiable, Codable, Equatable { + var id: UUID = UUID() + var name: String + var address: String + var port: Int = 25565 + var isFavorite: Bool = false + var lastPingedAt: Date? + var pingResult: ServerPingResult? + + var fullAddress: String { + if port == 25565 { return address } + return "\(address):\(port)" + } + + struct ServerPingResult: Codable, Equatable { + var motd: String + var playerCount: Int + var maxPlayers: Int + var versionName: String + var pingMs: Int + var iconData: Data? + } +} + +// MARK: - Profile Import/Export + +struct ProfileExportData: Codable { + var version: String = "1.0" + var exportDate: Date = Date() + var instances: [LauncherInstance] + var accounts: [MinecraftAccount] + var settings: ProfileExportSettings +} + +struct ProfileExportSettings: Codable { + var defaultMemoryMegabytes: Int + var defaultOfflineUsername: String + var preferredDownloadSource: DownloadSource + var defaultResolutionWidth: Int + var defaultResolutionHeight: Int + var jvmPresets: [JVMPreset] +} + +// MARK: - Custom Background + +struct BackgroundImage: Equatable { + var url: URL? + var opacity: Double = 0.3 + var blurRadius: CGFloat = 0 +} diff --git a/MMCL/Services/LauncherServices.swift b/MMCL/Services/LauncherServices.swift new file mode 100644 index 0000000..5bf4fe6 --- /dev/null +++ b/MMCL/Services/LauncherServices.swift @@ -0,0 +1,2077 @@ +import CryptoKit +import Foundation +import Network + +protocol InstanceServicing { + var rootDirectory: URL { get } + var instancesDirectory: URL { get } + func createInstance( + name: String, + gameVersion: String, + loader: GameLoader, + profile: LaunchProfile + ) throws -> LauncherInstance + func loadAllInstances() throws -> [LauncherInstance] + func instanceFileURL(for instance: LauncherInstance) -> URL + func encode(_ instance: LauncherInstance) throws -> Data + func decode(from data: Data) throws -> LauncherInstance +} + +struct InstanceService: InstanceServicing { + let rootDirectory: URL + + init(applicationSupportDirectory: URL? = nil) { + let supportDirectory = applicationSupportDirectory ?? FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0] + self.rootDirectory = supportDirectory.appendingPathComponent("MMCL", isDirectory: true) + } + + var instancesDirectory: URL { + rootDirectory.appendingPathComponent("Instances", isDirectory: true) + } + + func createInstance( + name: String, + gameVersion: String, + loader: GameLoader, + profile: LaunchProfile + ) throws -> LauncherInstance { + let slug = Self.slug(for: name) + let instanceRoot = instancesDirectory.appendingPathComponent(slug, isDirectory: true) + let instance = LauncherInstance( + name: name, + gameVersion: gameVersion, + loader: loader, + rootDirectory: instanceRoot, + profile: profile, + status: .notInstalled + ) + + let fileManager = FileManager.default + try fileManager.createDirectory(at: instanceRoot, withIntermediateDirectories: true) + try fileManager.createDirectory( + at: instanceRoot.appendingPathComponent(".minecraft", isDirectory: true), + withIntermediateDirectories: true + ) + try fileManager.createDirectory( + at: instanceRoot.appendingPathComponent("logs", isDirectory: true), + withIntermediateDirectories: true + ) + try fileManager.createDirectory( + at: instanceRoot.appendingPathComponent("mods", isDirectory: true), + withIntermediateDirectories: true + ) + try encode(instance).write(to: instanceFileURL(for: instance), options: .atomic) + + return instance + } + + func loadAllInstances() throws -> [LauncherInstance] { + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: instancesDirectory.path) else { return [] } + let contents = try fileManager.contentsOfDirectory( + at: instancesDirectory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + var instances: [LauncherInstance] = [] + for dir in contents { + let fileURL = dir.appendingPathComponent("instance.json") + guard fileManager.fileExists(atPath: fileURL.path) else { continue } + let data = try Data(contentsOf: fileURL) + let instance = try decode(from: data) + instances.append(instance) + } + return instances + } + + func instanceFileURL(for instance: LauncherInstance) -> URL { + instance.rootDirectory.appendingPathComponent("instance.json") + } + + func encode(_ instance: LauncherInstance) throws -> Data { + try JSONEncoder.mmcl.encode(instance) + } + + func decode(from data: Data) throws -> LauncherInstance { + try JSONDecoder.mmcl.decode(LauncherInstance.self, from: data) + } + + static func slug(for name: String) -> String { + let transliterations: [Character: String] = [ + "原": "yuan", "版": "ban", "生": "sheng", "存": "cun" + ] + var parts: [String] = [] + var current = "" + + for character in name.lowercased() { + if let replacement = transliterations[character] { + if !current.isEmpty { + parts.append(current) + current = "" + } + parts.append(replacement) + } else if character.isLetter || character.isNumber { + current.append(character) + } else if !current.isEmpty { + parts.append(current) + current = "" + } + } + + if !current.isEmpty { + parts.append(current) + } + + let slug = parts.joined(separator: "-") + return slug.isEmpty ? "instance" : slug + } +} + +protocol VersionManifestServicing { + var manifestURL: URL { get } + func decodeManifest(from data: Data) throws -> VersionManifest + func decodeVersionMetadata(from data: Data) throws -> VersionMetadata + func decodeAssetIndex(from data: Data) throws -> AssetIndex + func fetchManifest(from url: URL?) async throws -> VersionManifest + func fetchVersionMetadata(from url: URL) async throws -> VersionMetadata + func fetchAssetIndex(from url: URL) async throws -> AssetIndex +} + +struct VersionManifestService: VersionManifestServicing { + let manifestURL = URL(string: "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json")! + + func decodeManifest(from data: Data) throws -> VersionManifest { + try JSONDecoder.mmcl.decode(VersionManifest.self, from: data) + } + + func decodeVersionMetadata(from data: Data) throws -> VersionMetadata { + try JSONDecoder.mmcl.decode(VersionMetadata.self, from: data) + } + + func decodeAssetIndex(from data: Data) throws -> AssetIndex { + try JSONDecoder.mmcl.decode(AssetIndex.self, from: data) + } + + func fetchManifest(from url: URL? = nil) async throws -> VersionManifest { + let data = try await loadData(from: url ?? manifestURL) + return try decodeManifest(from: data) + } + + func fetchVersionMetadata(from url: URL) async throws -> VersionMetadata { + let data = try await loadData(from: url) + return try decodeVersionMetadata(from: data) + } + + func fetchAssetIndex(from url: URL) async throws -> AssetIndex { + let data = try await loadData(from: url) + return try decodeAssetIndex(from: data) + } + + private func loadData(from url: URL) async throws -> Data { + if url.isFileURL { + return try Data(contentsOf: url) + } + let response = try await URLSession.shared.data(from: url) + return response.0 + } +} + +protocol DownloadServicing: AnyObject { + var onProgress: ((UUID, Int64) -> Void)? { get set } + var onComplete: ((UUID, DownloadJob) -> Void)? { get set } + var onError: ((UUID, Error) -> Void)? { get set } + + func makeVanillaClientJob(version: String, destination: URL) -> DownloadJob + func writeVersionMetadata(metadata: VersionMetadata, instance: LauncherInstance) throws -> URL + func makeVanillaInstallJobs( + metadata: VersionMetadata, + instance: LauncherInstance, + source: DownloadSource + ) -> [DownloadJob] + func makeVanillaRepairJobs( + metadata: VersionMetadata, + instance: LauncherInstance, + source: DownloadSource + ) -> [DownloadJob] + func makeAssetObjectJobs( + assetIndex: AssetIndex, + instance: LauncherInstance, + source: DownloadSource, + taskGroupID: UUID?, + taskGroupName: String? + ) -> [DownloadJob] + func prepareNativeLibraries(metadata: VersionMetadata, instance: LauncherInstance) throws -> [URL] + func startDownload(_ job: DownloadJob) + func pauseDownload(id: UUID) + func resumeDownload(id: UUID) + func cancelDownload(id: UUID) + func cancelAllDownloads() +} + +final class DownloadService: NSObject, DownloadServicing, URLSessionDownloadDelegate { + var onProgress: ((UUID, Int64) -> Void)? + var onComplete: ((UUID, DownloadJob) -> Void)? + var onError: ((UUID, Error) -> Void)? + + private var session: URLSession! + private let lock = NSLock() + private var activeTasks: [UUID: URLSessionDownloadTask] = [:] + private var resumeDataMap: [UUID: Data] = [:] + private var jobsByID: [UUID: DownloadJob] = [:] + + override init() { + super.init() + session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) + } + + // MARK: - Download Control + + func startDownload(_ job: DownloadJob) { + guard let remoteURL = job.remoteURL else { + onError?(job.id, DownloadExecutionError.missingRemoteURL(jobTitle: job.title)) + return + } + + var runningJob = job + runningJob.status = .running + lock.lock() + jobsByID[job.id] = runningJob + lock.unlock() + + // Handle file URLs directly (URLSessionDownloadTask doesn't support them) + if remoteURL.isFileURL { + DispatchQueue.global().async { [weak self] in + guard let self else { return } + let parentDir = job.destination.deletingLastPathComponent() + do { + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: job.destination.path) { + try FileManager.default.removeItem(at: job.destination) + } + try FileManager.default.copyItem(at: remoteURL, to: job.destination) + + if let expectedSHA1 = job.sha1 { + let data = try Data(contentsOf: job.destination) + let actualSHA1 = Self.sha1Hex(for: data) + if actualSHA1.caseInsensitiveCompare(expectedSHA1) != .orderedSame { + var failedJob = job + failedJob.status = .failed + self.lock.lock() + self.jobsByID[job.id] = failedJob + self.lock.unlock() + self.onError?(job.id, DownloadExecutionError.sha1Mismatch( + jobTitle: job.title, + expected: expectedSHA1, + actual: actualSHA1 + )) + return + } + } + + var completedJob = job + completedJob.completedBytes = job.totalBytes + completedJob.status = .completed + self.lock.lock() + self.jobsByID[job.id] = completedJob + self.lock.unlock() + self.onComplete?(job.id, completedJob) + } catch { + var failedJob = job + failedJob.status = .failed + self.lock.lock() + self.jobsByID[job.id] = failedJob + self.lock.unlock() + self.onError?(job.id, error) + } + } + return + } + + let task: URLSessionDownloadTask + lock.lock() + let resumeData = resumeDataMap[job.id] + if let resumeData { + resumeDataMap.removeValue(forKey: job.id) + } + lock.unlock() + if let resumeData { + task = session.downloadTask(withResumeData: resumeData) + } else { + task = session.downloadTask(with: remoteURL) + } + task.taskDescription = job.id.uuidString + lock.lock() + activeTasks[job.id] = task + lock.unlock() + task.resume() + } + + func pauseDownload(id: UUID) { + lock.lock() + let task = activeTasks[id] + lock.unlock() + guard let task else { return } + task.cancel { [weak self] data in + if let data, let self { + self.lock.lock() + self.resumeDataMap[id] = data + self.lock.unlock() + } + } + lock.lock() + activeTasks.removeValue(forKey: id) + if var job = jobsByID[id] { + job.status = .paused + jobsByID[id] = job + } + lock.unlock() + } + + func resumeDownload(id: UUID) { + lock.lock() + guard var job = jobsByID[id], job.status == .paused else { + lock.unlock() + return + } + job.status = .queued + jobsByID[id] = job + lock.unlock() + startDownload(job) + } + + func cancelDownload(id: UUID) { + lock.lock() + let task = activeTasks[id] + activeTasks.removeValue(forKey: id) + resumeDataMap.removeValue(forKey: id) + if var job = jobsByID[id], job.status.isActive { + job.status = .failed + jobsByID[id] = job + } + lock.unlock() + task?.cancel() + } + + func cancelAllDownloads() { + lock.lock() + let tasks = Array(activeTasks.values) + activeTasks.removeAll() + resumeDataMap.removeAll() + for (id, _) in jobsByID { + if var job = jobsByID[id], job.status.isActive { + job.status = .failed + jobsByID[id] = job + } + } + lock.unlock() + for task in tasks { + task.cancel() + } + } + + // MARK: - Job Factory Methods + + func makeVanillaClientJob(version: String, destination: URL) -> DownloadJob { + DownloadJob(title: "Minecraft \(version) 客户端", source: .official, destination: destination, totalBytes: 1) + } + + func writeVersionMetadata(metadata: VersionMetadata, instance: LauncherInstance) throws -> URL { + let versionDirectory = instance.rootDirectory + .appendingPathComponent(".minecraft", isDirectory: true) + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(metadata.id, isDirectory: true) + try FileManager.default.createDirectory(at: versionDirectory, withIntermediateDirectories: true) + + let metadataURL = versionDirectory.appendingPathComponent("\(metadata.id).json") + try JSONEncoder.mmcl.encode(metadata).write(to: metadataURL, options: .atomic) + return metadataURL + } + + func makeVanillaInstallJobs( + metadata: VersionMetadata, + instance: LauncherInstance, + source: DownloadSource + ) -> [DownloadJob] { + let minecraftDirectory = instance.rootDirectory.appendingPathComponent(".minecraft", isDirectory: true) + let versionDirectory = minecraftDirectory + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(metadata.id, isDirectory: true) + + let groupID = UUID() + let groupName = "安装 Minecraft \(metadata.id)" + + var jobs: [DownloadJob] = [ + DownloadJob( + title: "Minecraft \(metadata.id) 客户端", + source: source, + remoteURL: metadata.downloads.client.url, + destination: versionDirectory.appendingPathComponent("\(metadata.id).jar"), + sha1: metadata.downloads.client.sha1, + totalBytes: metadata.downloads.client.size, + taskGroupID: groupID, + taskGroupName: groupName + ), + DownloadJob( + title: "Minecraft \(metadata.id) 资源索引", + source: source, + remoteURL: metadata.assetIndex.url, + destination: minecraftDirectory + .appendingPathComponent("assets", isDirectory: true) + .appendingPathComponent("indexes", isDirectory: true) + .appendingPathComponent("\(metadata.assetIndex.id).json"), + sha1: metadata.assetIndex.sha1, + totalBytes: metadata.assetIndex.size, + taskGroupID: groupID, + taskGroupName: groupName + ) + ] + + let libraryJobs = metadata.libraries.compactMap { library -> DownloadJob? in + guard let artifact = library.artifact else { return nil } + return DownloadJob( + title: library.name, + source: source, + remoteURL: artifact.url, + destination: minecraftDirectory + .appendingPathComponent("libraries", isDirectory: true) + .appendingPathComponent(artifact.path), + sha1: artifact.sha1, + totalBytes: artifact.size, + taskGroupID: groupID, + taskGroupName: groupName + ) + } + + jobs.append(contentsOf: libraryJobs) + let nativeJobs = metadata.libraries.compactMap { library -> DownloadJob? in + guard let artifact = library.nativeArtifact() else { return nil } + return DownloadJob( + title: "\(library.name) native", + source: source, + remoteURL: artifact.url, + destination: minecraftDirectory + .appendingPathComponent("libraries", isDirectory: true) + .appendingPathComponent(artifact.path), + sha1: artifact.sha1, + totalBytes: artifact.size, + taskGroupID: groupID, + taskGroupName: groupName + ) + } + + jobs.append(contentsOf: nativeJobs) + return jobs + } + + func makeVanillaRepairJobs( + metadata: VersionMetadata, + instance: LauncherInstance, + source: DownloadSource + ) -> [DownloadJob] { + makeVanillaInstallJobs(metadata: metadata, instance: instance, source: source) + .filter { !FileManager.default.fileExists(atPath: $0.destination.path) } + } + + func makeAssetObjectJobs( + assetIndex: AssetIndex, + instance: LauncherInstance, + source: DownloadSource, + taskGroupID: UUID? = nil, + taskGroupName: String? = nil + ) -> [DownloadJob] { + let objectsDirectory = instance.rootDirectory + .appendingPathComponent(".minecraft", isDirectory: true) + .appendingPathComponent("assets", isDirectory: true) + .appendingPathComponent("objects", isDirectory: true) + + return assetIndex.objects + .sorted { $0.key < $1.key } + .map { name, object in + let objectPath = "\(object.pathPrefix)/\(object.hash)" + return DownloadJob( + title: "资源文件 \(name)", + source: source, + remoteURL: URL(string: "https://resources.download.minecraft.net/\(objectPath)")!, + destination: objectsDirectory + .appendingPathComponent(object.pathPrefix, isDirectory: true) + .appendingPathComponent(object.hash), + sha1: object.hash, + totalBytes: object.size, + taskGroupID: taskGroupID, + taskGroupName: taskGroupName + ) + } + } + + func prepareNativeLibraries(metadata: VersionMetadata, instance: LauncherInstance) throws -> [URL] { + let minecraftDirectory = instance.rootDirectory.appendingPathComponent(".minecraft", isDirectory: true) + let librariesDirectory = minecraftDirectory.appendingPathComponent("libraries", isDirectory: true) + let nativesDirectory = minecraftDirectory + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(metadata.id, isDirectory: true) + .appendingPathComponent("natives", isDirectory: true) + try FileManager.default.createDirectory(at: nativesDirectory, withIntermediateDirectories: true) + + return try metadata.libraries.compactMap { library -> URL? in + guard let artifact = library.nativeArtifact() else { return nil } + let archiveURL = librariesDirectory.appendingPathComponent(artifact.path) + guard FileManager.default.fileExists(atPath: archiveURL.path) else { + throw NativeLibraryPreparationError.missingArchive(archiveURL) + } + try Self.unzip(archiveURL: archiveURL, destination: nativesDirectory) + return archiveURL + } + } + + private static func unzip(archiveURL: URL, destination: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-o", "-qq", archiveURL.path, "-d", destination.path] + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw NativeLibraryPreparationError.unzipFailed(archiveURL) + } + } + + // MARK: - URLSessionDownloadDelegate + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + guard let uuidString = downloadTask.taskDescription, + let jobID = UUID(uuidString: uuidString) else { return } + + lock.lock() + guard var job = jobsByID[jobID] else { + lock.unlock() + return + } + activeTasks.removeValue(forKey: jobID) + lock.unlock() + + let parentDirectory = job.destination.deletingLastPathComponent() + do { + try FileManager.default.createDirectory(at: parentDirectory, withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: job.destination.path) { + try FileManager.default.removeItem(at: job.destination) + } + try FileManager.default.moveItem(at: location, to: job.destination) + } catch { + job.status = .failed + lock.lock() + jobsByID[jobID] = job + lock.unlock() + onError?(jobID, error) + return + } + + if let expectedSHA1 = job.sha1 { + if let data = try? Data(contentsOf: job.destination) { + let actualSHA1 = Self.sha1Hex(for: data) + if actualSHA1.caseInsensitiveCompare(expectedSHA1) != .orderedSame { + job.status = .failed + lock.lock() + jobsByID[jobID] = job + lock.unlock() + onError?(jobID, DownloadExecutionError.sha1Mismatch( + jobTitle: job.title, + expected: expectedSHA1, + actual: actualSHA1 + )) + return + } + } + } + + job.completedBytes = job.totalBytes + job.status = .completed + lock.lock() + jobsByID[jobID] = job + lock.unlock() + onComplete?(jobID, job) + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + guard let uuidString = downloadTask.taskDescription, + let jobID = UUID(uuidString: uuidString) else { return } + + lock.lock() + if var job = jobsByID[jobID] { + job.completedBytes = totalBytesWritten + if totalBytesExpectedToWrite > 0 { + job.totalBytes = totalBytesExpectedToWrite + } + jobsByID[jobID] = job + } + lock.unlock() + onProgress?(jobID, totalBytesWritten) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + guard let downloadTask = task as? URLSessionDownloadTask, + let uuidString = downloadTask.taskDescription, + let jobID = UUID(uuidString: uuidString) else { return } + + lock.lock() + activeTasks.removeValue(forKey: jobID) + + if let error { + if (error as NSError).code == NSURLErrorCancelled { + lock.unlock() + return + } + if var job = jobsByID[jobID] { + job.status = .failed + jobsByID[jobID] = job + } + lock.unlock() + onError?(jobID, error) + } else { + lock.unlock() + } + } + + static func sha1Hex(for data: Data) -> String { + Insecure.SHA1.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + } +} + +enum NativeLibraryPreparationError: LocalizedError, Equatable { + case missingArchive(URL) + case unzipFailed(URL) + + var errorDescription: String? { + switch self { + case .missingArchive(let url): + return "缺少 native library:\(url.path)" + case .unzipFailed(let url): + return "native library 解压失败:\(url.path)" + } + } +} + +enum DownloadExecutionError: LocalizedError, Equatable { + case missingRemoteURL(jobTitle: String) + case sha1Mismatch(jobTitle: String, expected: String, actual: String) + + var errorDescription: String? { + switch self { + case .missingRemoteURL(let jobTitle): + return "缺少下载地址:\(jobTitle)" + case .sha1Mismatch(let jobTitle, _, _): + return "SHA-1 校验失败:\(jobTitle)" + } + } +} + +protocol SkinServicing { + func scanSkins(in directory: URL) -> [SkinInfo] + func applySkin(_ skin: SkinInfo, to account: MinecraftAccount) throws + func importSkin(from sourceURL: URL, name: String, model: SkinInfo.SkinModel) throws -> SkinInfo + func skinDirectory(for account: MinecraftAccount) -> URL +} + +struct SkinService: SkinServicing { + let applicationSupportDirectory: URL + + init(applicationSupportDirectory: URL? = nil) { + self.applicationSupportDirectory = applicationSupportDirectory ?? FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0] + } + + func skinDirectory(for account: MinecraftAccount) -> URL { + applicationSupportDirectory + .appendingPathComponent("MMCL", isDirectory: true) + .appendingPathComponent("Skins", isDirectory: true) + .appendingPathComponent(account.uuid, isDirectory: true) + } + + func scanSkins(in directory: URL) -> [SkinInfo] { + guard let files = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) else { return [] } + + return files + .filter { $0.pathExtension == "png" } + .compactMap { url in + let name = url.deletingPathExtension().lastPathComponent + let model: SkinInfo.SkinModel = name.lowercased().contains("alex") ? .alex : .steve + return SkinInfo(name: name, model: model, localFileURL: url) + } + } + + func applySkin(_ skin: SkinInfo, to account: MinecraftAccount) throws { + // Skin application happens at launch via JVM arguments + // Store the skin info in the account's profile + } + + func importSkin(from sourceURL: URL, name: String, model: SkinInfo.SkinModel) throws -> SkinInfo { + let destDir = applicationSupportDirectory + .appendingPathComponent("MMCL", isDirectory: true) + .appendingPathComponent("Skins", isDirectory: true) + try FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) + + let destURL = destDir.appendingPathComponent("\(name).png") + if FileManager.default.fileExists(atPath: destURL.path) { + try FileManager.default.removeItem(at: destURL) + } + try FileManager.default.copyItem(at: sourceURL, to: destURL) + + return SkinInfo(name: name, model: model, localFileURL: destURL) + } +} + +protocol JavaRuntimeServicing { + func bundledSearchLocations() -> [URL] + func recommendedMajorVersion(for gameVersion: String) -> Int + func parseJavaHomeVerboseOutput(_ output: String) -> [JavaRuntime] + func discoverInstalledRuntimes() async throws -> [JavaRuntime] + var portableJDKDirectory: URL { get } +} + +struct JavaRuntimeService: JavaRuntimeServicing { + var javaHomeExecutable: URL = URL(fileURLWithPath: "/usr/libexec/java_home") + + var portableJDKDirectory: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/MMCL/JDK", isDirectory: true) + } + + func bundledSearchLocations() -> [URL] { + [ + URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines"), + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Java/JavaVirtualMachines") + ] + } + + func recommendedMajorVersion(for gameVersion: String) -> Int { + JavaRuntime.recommendedMajorVersion(for: gameVersion) + } + + func parseJavaHomeVerboseOutput(_ output: String) -> [JavaRuntime] { + output + .split(whereSeparator: \.isNewline) + .compactMap { parseJavaHomeLine(String($0)) } + } + + func discoverInstalledRuntimes() async throws -> [JavaRuntime] { + var runtimes = await discoverViaJavaHome() + runtimes.append(contentsOf: discoverSDKMAN()) + runtimes.append(contentsOf: discoverJetBrainsJREs()) + runtimes.append(contentsOf: discoverHomebrewJDKs()) + runtimes.append(contentsOf: discoverPortableJDKs()) + + var seen = Set() + return runtimes.filter { runtime in + let key = runtime.executableURL.path + guard !seen.contains(key) else { return false } + seen.insert(key) + return true + } + } + + private func discoverViaJavaHome() async -> [JavaRuntime] { + let process = Process() + process.executableURL = javaHomeExecutable + process.arguments = ["-V"] + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = outputPipe + guard (try? process.run()) != nil else { return [] } + process.waitUntilExit() + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + let output = String(decoding: data, as: UTF8.self) + return parseJavaHomeVerboseOutput(output) + } + + private func discoverSDKMAN() -> [JavaRuntime] { + let sdkmanDir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".sdkman/candidates/java", isDirectory: true) + guard FileManager.default.fileExists(atPath: sdkmanDir.path) else { return [] } + return scanJavaDirectories(under: sdkmanDir, source: "SDKMAN!") + } + + private func discoverJetBrainsJREs() -> [JavaRuntime] { + let appsDir = URL(fileURLWithPath: "/Applications") + guard let apps = try? FileManager.default.contentsOfDirectory( + at: appsDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] + ) else { return [] } + + var runtimes: [JavaRuntime] = [] + for app in apps where app.pathExtension == "app" { + for subpath in ["Contents/jbr/Contents/Home", "Contents/jre/Contents/Home"] { + let home = app.appendingPathComponent(subpath, isDirectory: true) + let javaBin = home.appendingPathComponent("bin/java") + if FileManager.default.fileExists(atPath: javaBin.path) { + let name = app.deletingPathExtension().lastPathComponent + if let runtime = parseJavaHome(home: home, name: "JetBrains Runtime (\(name))") { + runtimes.append(runtime) + } + } + } + } + return runtimes + } + + private func discoverHomebrewJDKs() -> [JavaRuntime] { + let prefix = URL(fileURLWithPath: "/opt/homebrew/opt") + let prefixX86 = URL(fileURLWithPath: "/usr/local/opt") + var runtimes: [JavaRuntime] = [] + for optDir in [prefix, prefixX86] { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: optDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] + ) else { continue } + for item in contents where item.lastPathComponent.hasPrefix("openjdk@") { + let home = item.appendingPathComponent("libexec/openjdk.jdk/Contents/Home", isDirectory: true) + if FileManager.default.fileExists(atPath: home.appendingPathComponent("bin/java").path) { + if let runtime = parseJavaHome(home: home, name: "Homebrew \(item.lastPathComponent)") { + runtimes.append(runtime) + } + } + } + } + return runtimes + } + + private func discoverPortableJDKs() -> [JavaRuntime] { + let dir = portableJDKDirectory + guard let contents = try? FileManager.default.contentsOfDirectory( + at: dir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] + ) else { return [] } + return contents.compactMap { item in + // Adoptium extracts to jdk-X.Y.Z+NN directory + let home: URL + if item.lastPathComponent.contains("jdk-") { + home = item + } else { + // Try Contents/Home for .app bundles + let appHome = item.appendingPathComponent("Contents/Home", isDirectory: true) + if FileManager.default.fileExists(atPath: appHome.appendingPathComponent("bin/java").path) { + home = appHome + } else { + home = item + } + } + let javaBin = home.appendingPathComponent("bin/java") + guard FileManager.default.fileExists(atPath: javaBin.path) else { return nil } + return parseJavaHome(home: home, name: "便携版 \(item.lastPathComponent)") + } + } + + private func scanJavaDirectories(under directory: URL, source: String) -> [JavaRuntime] { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] + ) else { return [] } + return contents.compactMap { item in + let home = item + let javaBin = home.appendingPathComponent("bin/java") + guard FileManager.default.fileExists(atPath: javaBin.path) else { return nil } + return parseJavaHome(home: home, name: "\(source) \(item.lastPathComponent)") + } + } + + private func parseJavaHome(home: URL, name: String) -> JavaRuntime? { + let javaBin = home.appendingPathComponent("bin/java") + let process = Process() + process.executableURL = javaBin + process.arguments = ["-version"] + let errPipe = Pipe() + process.standardError = errPipe + guard (try? process.run()) != nil else { return nil } + process.waitUntilExit() + let data = errPipe.fileHandleForReading.readDataToEndOfFile() + let output = String(decoding: data, as: UTF8.self) + let pattern = #"openjdk version "([0-9]+(?:\.[0-9]+)*)""# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: output, range: NSRange(output.startIndex..., in: output)), + let versionRange = Range(match.range(at: 1), in: output) + else { return nil } + + let version = String(output[versionRange]) + let majorVersion = Int(version.split(separator: ".").first ?? "") ?? 0 + + #if arch(arm64) + let arch: RuntimeArchitecture = .arm64 + #else + let arch: RuntimeArchitecture = .x86_64 + #endif + + return JavaRuntime( + name: name, + version: version, + majorVersion: majorVersion, + architecture: arch, + executableURL: javaBin + ) + } + + private func parseJavaHomeLine(_ line: String) -> JavaRuntime? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + let pattern = #"^([0-9]+(?:\.[0-9]+)*) \(([^)]+)\) ".+" - "(.+)" (/.+)$"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: trimmed, range: NSRange(trimmed.startIndex..., in: trimmed)), + match.numberOfRanges == 5, + let versionRange = Range(match.range(at: 1), in: trimmed), + let architectureRange = Range(match.range(at: 2), in: trimmed), + let nameRange = Range(match.range(at: 3), in: trimmed), + let homeRange = Range(match.range(at: 4), in: trimmed) + else { + return nil + } + + let version = String(trimmed[versionRange]) + let architecture = RuntimeArchitecture(rawValue: String(trimmed[architectureRange])) ?? .unknown + let name = String(trimmed[nameRange]) + let homeURL = URL(fileURLWithPath: String(trimmed[homeRange]), isDirectory: true) + let majorVersion = Int(version.split(separator: ".").first ?? "") ?? 0 + + return JavaRuntime( + name: name, + version: version, + majorVersion: majorVersion, + architecture: architecture, + executableURL: homeURL.appendingPathComponent("bin/java") + ) + } +} + +protocol LaunchServicing { + func previewCommand(for instance: LauncherInstance, java: JavaRuntime) -> [String] + func preflight(instance: LauncherInstance, java: JavaRuntime) -> LaunchPreflightReport + func launch(instance: LauncherInstance, java: JavaRuntime) throws -> LaunchSession +} + +struct LaunchService: LaunchServicing { + func previewCommand(for instance: LauncherInstance, java: JavaRuntime) -> [String] { + let minecraftDirectory = instance.rootDirectory.appendingPathComponent(".minecraft", isDirectory: true) + let versionDirectory = minecraftDirectory + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(instance.gameVersion, isDirectory: true) + let nativesDirectory = versionDirectory.appendingPathComponent("natives", isDirectory: true) + let clientJar = versionDirectory.appendingPathComponent("\(instance.gameVersion).jar") + let librariesDirectory = minecraftDirectory.appendingPathComponent("libraries", isDirectory: true) + let metadata = localVersionMetadata(for: instance) + let classpath = metadata.map { + classpathEntries(metadata: $0, minecraftDirectory: minecraftDirectory, clientJar: clientJar) + .map(\.path) + .joined(separator: ":") + } ?? "\(librariesDirectory.path)/*:\(clientJar.path)" + let mainClass = metadata?.mainClass ?? "net.minecraft.client.main.Main" + let assetIndex = metadata?.assetIndex.id ?? instance.gameVersion + let substitutions = launchSubstitutions( + instance: instance, + minecraftDirectory: minecraftDirectory, + nativesDirectory: nativesDirectory, + classpath: classpath, + assetIndex: assetIndex + ) + + if let metadata, let arguments = metadata.arguments { + let jvmArguments = expand(arguments.jvm, substitutions: substitutions, operatingSystem: "osx") + let gameArguments = expand(arguments.game, substitutions: substitutions, operatingSystem: "osx") + + // Auto-detect JVM args for Apple Silicon when using defaults + var extraArgs = instance.profile.jvmArguments + if extraArgs == ["-XX:+UseG1GC", "-XX:+UnlockExperimentalVMOptions"] || extraArgs.isEmpty { + #if arch(arm64) + extraArgs = ["-XX:+UseZGC", "-XX:+ZGenerational", "-XX:+UnlockExperimentalVMOptions", "-XX:G1HeapRegionSize=16M"] + #endif + } + + return [ + java.executableURL.path, + "-Xmx\(instance.profile.memoryMegabytes)m" + ] + + extraArgs + + jvmArguments + + [mainClass] + + gameArguments + } + + if let metadata, let legacyArguments = metadata.minecraftArguments { + return [ + java.executableURL.path, + "-Xmx\(instance.profile.memoryMegabytes)m", + "-Djava.library.path=\(nativesDirectory.path)" + ] + + instance.profile.jvmArguments + + [ + "-cp", + classpath, + mainClass + ] + + expandLegacyArguments(legacyArguments, substitutions: substitutions) + } + + return [ + java.executableURL.path, + "-Xmx\(instance.profile.memoryMegabytes)m", + "-Djava.library.path=\(nativesDirectory.path)" + ] + + instance.profile.jvmArguments + + [ + "-cp", + classpath, + mainClass, + "--username", + instance.profile.offlineUsername, + "--version", + instance.gameVersion, + "--gameDir", + minecraftDirectory.path, + "--assetsDir", + minecraftDirectory.appendingPathComponent("assets", isDirectory: true).path, + "--assetIndex", + assetIndex, + "--accessToken", + "0", + "--userType", + "legacy" + ] + } + + func preflight(instance: LauncherInstance, java: JavaRuntime) -> LaunchPreflightReport { + let fileManager = FileManager.default + let minecraftDirectory = instance.rootDirectory.appendingPathComponent(".minecraft", isDirectory: true) + let versionDirectory = minecraftDirectory + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(instance.gameVersion, isDirectory: true) + let metadataURL = versionDirectory.appendingPathComponent("\(instance.gameVersion).json") + var blockingIssues: [String] = [] + var warnings: [String] = [] + var actions: [String] = [] + + guard let metadata = localVersionMetadata(for: instance) else { + blockingIssues.append("缺少 version JSON:\(metadataURL.path)") + actions.append("生成安装计划并完成下载") + actions.append("刷新版本列表后重新生成安装计划") + return LaunchPreflightReport( + severity: .error, + summary: blockingIssues.joined(separator: "\n"), + suggestedActions: actions + ) + } + + let clientJar = versionDirectory.appendingPathComponent("\(instance.gameVersion).jar") + if !fileManager.fileExists(atPath: clientJar.path) { + blockingIssues.append("缺少 client jar:\(clientJar.path)") + actions.append("生成安装计划并完成下载") + } + + let assetIndex = minecraftDirectory + .appendingPathComponent("assets", isDirectory: true) + .appendingPathComponent("indexes", isDirectory: true) + .appendingPathComponent("\(metadata.assetIndex.id).json") + if !fileManager.fileExists(atPath: assetIndex.path) { + blockingIssues.append("缺少 asset index:\(assetIndex.path)") + actions.append("生成安装计划并完成下载") + } + + let librariesDirectory = minecraftDirectory.appendingPathComponent("libraries", isDirectory: true) + let missingLibraries = metadata.libraries.compactMap { library -> String? in + guard let artifact = library.artifact else { return nil } + let artifactURL = librariesDirectory.appendingPathComponent(artifact.path) + return fileManager.fileExists(atPath: artifactURL.path) ? nil : library.name + } + if !missingLibraries.isEmpty { + let names = missingLibraries.prefix(3).joined(separator: ", ") + let suffix = missingLibraries.count > 3 ? " 等 \(missingLibraries.count) 个" : "" + blockingIssues.append("缺少 library:\(names)\(suffix)") + actions.append("生成安装计划并完成下载") + } + + let nativeArtifacts = metadata.libraries.compactMap { $0.nativeArtifact() } + if !nativeArtifacts.isEmpty { + let missingNativeArchives = nativeArtifacts.filter { artifact in + !fileManager.fileExists(atPath: librariesDirectory.appendingPathComponent(artifact.path).path) + } + if !missingNativeArchives.isEmpty { + blockingIssues.append("缺少 native library:\(missingNativeArchives.count) 个") + actions.append("生成安装计划并完成下载") + } + + let nativesDirectory = versionDirectory.appendingPathComponent("natives", isDirectory: true) + let nativeContents = (try? fileManager.contentsOfDirectory(atPath: nativesDirectory.path)) ?? [] + if nativeContents.isEmpty { + blockingIssues.append("native libraries 尚未解压:\(nativesDirectory.path)") + actions.append("准备 Native") + } + } + + if !java.isRecommended(for: instance.gameVersion) { + let recommended = JavaRuntime.recommendedMajorVersion(for: instance.gameVersion) + warnings.append("当前 Java \(java.majorVersion) 不是推荐版本,建议使用 Java \(recommended)。") + actions.append("重新扫描 Java 并选择推荐版本") + } + + if !blockingIssues.isEmpty { + return LaunchPreflightReport( + severity: .error, + summary: blockingIssues.joined(separator: "\n"), + suggestedActions: Array(NSOrderedSet(array: actions).compactMap { $0 as? String }) + ) + } + + if !warnings.isEmpty { + return LaunchPreflightReport( + severity: .warning, + summary: warnings.joined(separator: "\n"), + suggestedActions: Array(NSOrderedSet(array: actions).compactMap { $0 as? String }) + ) + } + + return LaunchPreflightReport( + severity: .info, + summary: "启动前检查通过。", + suggestedActions: [] + ) + } + + private func expand( + _ arguments: [VersionMetadata.LaunchArgument], + substitutions: [String: String], + operatingSystem: String + ) -> [String] { + arguments.flatMap { argument -> [String] in + guard argument.applies(to: operatingSystem) else { return [] } + return argument.value.strings.map { replacePlaceholders(in: $0, substitutions: substitutions) } + } + } + + private func expandLegacyArguments(_ arguments: String, substitutions: [String: String]) -> [String] { + arguments + .split(separator: " ") + .map { replacePlaceholders(in: String($0), substitutions: substitutions) } + } + + private func launchSubstitutions( + instance: LauncherInstance, + minecraftDirectory: URL, + nativesDirectory: URL, + classpath: String, + assetIndex: String + ) -> [String: String] { + [ + "auth_player_name": instance.profile.offlineUsername, + "version_name": instance.gameVersion, + "game_directory": minecraftDirectory.path, + "assets_root": minecraftDirectory.appendingPathComponent("assets", isDirectory: true).path, + "assets_index_name": assetIndex, + "auth_uuid": "00000000000000000000000000000000", + "auth_access_token": "0", + "clientid": "", + "auth_xuid": "", + "user_type": "legacy", + "version_type": "release", + "natives_directory": nativesDirectory.path, + "launcher_name": "MMCL", + "launcher_version": "0.1", + "classpath": classpath, + "resolution_width": String(instance.profile.resolutionWidth), + "resolution_height": String(instance.profile.resolutionHeight) + ] + } + + private func replacePlaceholders(in value: String, substitutions: [String: String]) -> String { + substitutions.reduce(value) { result, item in + result.replacingOccurrences(of: "${\(item.key)}", with: item.value) + } + } + + private func localVersionMetadata(for instance: LauncherInstance) -> VersionMetadata? { + let metadataURL = instance.rootDirectory + .appendingPathComponent(".minecraft", isDirectory: true) + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(instance.gameVersion, isDirectory: true) + .appendingPathComponent("\(instance.gameVersion).json") + guard let data = try? Data(contentsOf: metadataURL) else { return nil } + return try? JSONDecoder.mmcl.decode(VersionMetadata.self, from: data) + } + + private func classpathEntries( + metadata: VersionMetadata, + minecraftDirectory: URL, + clientJar: URL + ) -> [URL] { + let librariesDirectory = minecraftDirectory.appendingPathComponent("libraries", isDirectory: true) + let libraryJars = metadata.libraries.compactMap { library -> URL? in + guard let artifact = library.artifact else { return nil } + return librariesDirectory.appendingPathComponent(artifact.path) + } + return libraryJars + [clientJar] + } + + func launch(instance: LauncherInstance, java: JavaRuntime) throws -> LaunchSession { + let command = previewCommand(for: instance, java: java) + guard let executable = command.first else { + throw LaunchExecutionError.emptyCommand + } + + let minecraftDirectory = instance.rootDirectory.appendingPathComponent(".minecraft", isDirectory: true) + let logsDirectory = instance.rootDirectory.appendingPathComponent("logs", isDirectory: true) + try FileManager.default.createDirectory(at: minecraftDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: logsDirectory, withIntermediateDirectories: true) + let logFileURL = logsDirectory.appendingPathComponent("latest.log") + if !FileManager.default.fileExists(atPath: logFileURL.path) { + FileManager.default.createFile(atPath: logFileURL.path, contents: nil) + } + + let logHandle = try FileHandle(forWritingTo: logFileURL) + try logHandle.truncate(atOffset: 0) + + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = Array(command.dropFirst()) + process.currentDirectoryURL = minecraftDirectory + process.standardOutput = logHandle + process.standardError = logHandle + process.terminationHandler = { _ in + try? logHandle.close() + } + + try process.run() + + return LaunchSession( + processIdentifier: process.processIdentifier, + command: command, + logFileURL: logFileURL + ) + } +} + +enum LaunchExecutionError: LocalizedError, Equatable { + case emptyCommand + + var errorDescription: String? { + switch self { + case .emptyCommand: + return "启动命令为空,无法启动 Minecraft。" + } + } +} + +protocol ModrinthServicing { + var baseURL: URL { get } + func search(query: String, facets: [[String]]?, index: String, offset: Int) async throws -> ModrinthSearchResponse + func fetchProject(id: String) async throws -> ModrinthProject + func fetchVersions(projectID: String, gameVersion: String?, loader: String?) async throws -> [ModrinthVersion] + func downloadFile(from urlString: String, to destination: URL) async throws +} + +struct ModrinthService: ModrinthServicing { + let baseURL = URL(string: "https://api.modrinth.com/v2")! + let userAgent = "MMCL/1.0 (https://github.com/Lhy723/MMCL)" + + private func makeRequest(url: URL) -> URLRequest { + var request = URLRequest(url: url) + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("https://github.com/Lhy723/MMCL", forHTTPHeaderField: "Referer") + return request + } + + func search(query: String, facets: [[String]]? = nil, index: String = "relevance", offset: Int = 0) async throws -> ModrinthSearchResponse { + var components = URLComponents(url: baseURL.appendingPathComponent("search"), resolvingAgainstBaseURL: false)! + var queryItems = [URLQueryItem(name: "query", value: query)] + if let facets { + let encoded = try JSONEncoder().encode(facets) + let facetString = String(data: encoded, encoding: .utf8)! + queryItems.append(URLQueryItem(name: "facets", value: facetString)) + } + queryItems.append(URLQueryItem(name: "limit", value: "20")) + queryItems.append(URLQueryItem(name: "index", value: index)) + if offset > 0 { + queryItems.append(URLQueryItem(name: "offset", value: "\(offset)")) + } + components.queryItems = queryItems + let request = makeRequest(url: components.url!) + let (data, response) = try await URLSession.shared.data(for: request) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw ModrinthError.searchFailed("HTTP \(http.statusCode)") + } + return try JSONDecoder.mmcl.decode(ModrinthSearchResponse.self, from: data) + } + + func fetchProject(id: String) async throws -> ModrinthProject { + let url = baseURL.appendingPathComponent("project/\(id)") + let request = makeRequest(url: url) + let (data, _) = try await URLSession.shared.data(for: request) + return try JSONDecoder.mmcl.decode(ModrinthProject.self, from: data) + } + + func fetchVersions(projectID: String, gameVersion: String? = nil, loader: String? = nil) async throws -> [ModrinthVersion] { + var components = URLComponents(url: baseURL.appendingPathComponent("project/\(projectID)/version"), resolvingAgainstBaseURL: false)! + var queryItems = [URLQueryItem]() + if let gameVersion { + queryItems.append(URLQueryItem(name: "game_versions", value: "[\"\(gameVersion)\"]")) + } + if let loader { + queryItems.append(URLQueryItem(name: "loaders", value: "[\"\(loader)\"]")) + } + if !queryItems.isEmpty { + components.queryItems = queryItems + } + let request = makeRequest(url: components.url!) + let (data, _) = try await URLSession.shared.data(for: request) + return try JSONDecoder.mmcl.decode([ModrinthVersion].self, from: data) + } + + func downloadFile(from urlString: String, to destination: URL) async throws { + guard let url = URL(string: urlString) else { + throw ModrinthError.invalidURL(urlString) + } + let request = makeRequest(url: url) + let (data, _) = try await URLSession.shared.data(for: request) + try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: destination, options: .atomic) + } +} + +enum ModrinthError: LocalizedError, Equatable { + case invalidURL(String) + case searchFailed(String) + + var errorDescription: String? { + switch self { + case .invalidURL(let url): + return "无效的下载地址:\(url)" + case .searchFailed(let detail): + return "Modrinth 搜索失败:\(detail)" + } + } +} + +protocol FabricServicing { + func fetchLoaderVersions(gameVersion: String) async throws -> [FabricLoaderVersion] + func fetchProfile(gameVersion: String, loaderVersion: String) async throws -> FabricProfile + func installFabric( + gameVersion: String, + loaderVersion: String?, + instance: LauncherInstance + ) async throws -> VersionMetadata +} + +struct FabricService: FabricServicing { + let baseURL = URL(string: "https://meta.fabricmc.net/v2")! + + func fetchLoaderVersions(gameVersion: String) async throws -> [FabricLoaderVersion] { + let url = baseURL.appendingPathComponent("versions/loader/\(gameVersion)") + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder.mmcl.decode([FabricLoaderVersion].self, from: data) + } + + func fetchProfile(gameVersion: String, loaderVersion: String) async throws -> FabricProfile { + let url = baseURL.appendingPathComponent("versions/loader/\(gameVersion)/\(loaderVersion)/profile") + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder.mmcl.decode(FabricProfile.self, from: data) + } + + func installFabric( + gameVersion: String, + loaderVersion: String? = nil, + instance: LauncherInstance + ) async throws -> VersionMetadata { + // 1. Determine loader version + let versions = try await fetchLoaderVersions(gameVersion: gameVersion) + let selectedVersion: String + if let explicit = loaderVersion { + selectedVersion = explicit + } else { + guard let latest = versions.first(where: { $0.stable }) ?? versions.first else { + throw FabricInstallError.noLoaderAvailable(gameVersion) + } + selectedVersion = latest.version + } + + // 2. Fetch Fabric profile + let profile = try await fetchProfile(gameVersion: gameVersion, loaderVersion: selectedVersion) + + // 3. Build a minimal VersionMetadata from the profile + let baseMetadata = try readBaseMetadata(instance: instance, gameVersion: profile.inheritsFrom) + + let fabricLibraries = baseMetadata.libraries + [ + VersionMetadata.Library( + name: "net.fabricmc:intermediary:\(profile.inheritsFrom):v2", + downloads: nil + ) + ] + + var metadata = baseMetadata + metadata.id = "\(profile.inheritsFrom)-fabric-\(selectedVersion)" + metadata.mainClass = profile.mainClass + metadata.libraries = fabricLibraries + + // 4. Write version JSON + let versionDir = instance.rootDirectory + .appendingPathComponent(".minecraft", isDirectory: true) + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(metadata.id, isDirectory: true) + try FileManager.default.createDirectory(at: versionDir, withIntermediateDirectories: true) + let metadataURL = versionDir.appendingPathComponent("\(metadata.id).json") + try JSONEncoder.mmcl.encode(metadata).write(to: metadataURL, options: .atomic) + + return metadata + } + + private func readBaseMetadata(instance: LauncherInstance, gameVersion: String) throws -> VersionMetadata { + let metadataURL = instance.rootDirectory + .appendingPathComponent(".minecraft", isDirectory: true) + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(gameVersion, isDirectory: true) + .appendingPathComponent("\(gameVersion).json") + guard let data = try? Data(contentsOf: metadataURL) else { + throw FabricInstallError.baseMetadataNotFound(gameVersion) + } + return try JSONDecoder.mmcl.decode(VersionMetadata.self, from: data) + } +} + +enum FabricInstallError: LocalizedError, Equatable { + case noLoaderAvailable(String) + case baseMetadataNotFound(String) + + var errorDescription: String? { + switch self { + case .noLoaderAvailable(let version): + return "没有可用的 Fabric loader 版本:Minecraft \(version)" + case .baseMetadataNotFound(let version): + return "缺少基础版本元数据:\(version)。请先安装原版 \(version)。" + } + } +} + +protocol QuiltServicing { + func fetchLoaderVersions(gameVersion: String) async throws -> [QuiltLoaderVersion] + func fetchProfile(gameVersion: String, loaderVersion: String) async throws -> QuiltProfile + func installQuilt(gameVersion: String, loaderVersion: String?, instance: LauncherInstance) async throws -> VersionMetadata +} + +struct QuiltService: QuiltServicing { + let baseURL = URL(string: "https://meta.quiltmc.org/v3")! + + func fetchLoaderVersions(gameVersion: String) async throws -> [QuiltLoaderVersion] { + let url = baseURL.appendingPathComponent("versions/loader/\(gameVersion)") + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder.mmcl.decode([QuiltLoaderVersion].self, from: data) + } + + func fetchProfile(gameVersion: String, loaderVersion: String) async throws -> QuiltProfile { + let url = baseURL.appendingPathComponent("versions/loader/\(gameVersion)/\(loaderVersion)/profile") + let (data, _) = try await URLSession.shared.data(from: url) + return try JSONDecoder.mmcl.decode(QuiltProfile.self, from: data) + } + + func installQuilt(gameVersion: String, loaderVersion: String? = nil, instance: LauncherInstance) async throws -> VersionMetadata { + let versions = try await fetchLoaderVersions(gameVersion: gameVersion) + let selectedVersion: String + if let explicit = loaderVersion { + selectedVersion = explicit + } else { + guard let latest = versions.first(where: { $0.stable }) ?? versions.first else { + throw QuiltInstallError.noLoaderAvailable(gameVersion) + } + selectedVersion = latest.version + } + + let profile = try await fetchProfile(gameVersion: gameVersion, loaderVersion: selectedVersion) + let baseMetadata = try readBaseMetadata(instance: instance, gameVersion: profile.inheritsFrom) + + var metadata = baseMetadata + metadata.id = "\(profile.inheritsFrom)-quilt-\(selectedVersion)" + metadata.mainClass = profile.mainClass + + let versionDir = instance.rootDirectory + .appendingPathComponent(".minecraft", isDirectory: true) + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(metadata.id, isDirectory: true) + try FileManager.default.createDirectory(at: versionDir, withIntermediateDirectories: true) + try JSONEncoder.mmcl.encode(metadata).write(to: versionDir.appendingPathComponent("\(metadata.id).json"), options: .atomic) + + return metadata + } + + private func readBaseMetadata(instance: LauncherInstance, gameVersion: String) throws -> VersionMetadata { + let metadataURL = instance.rootDirectory + .appendingPathComponent(".minecraft", isDirectory: true) + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(gameVersion, isDirectory: true) + .appendingPathComponent("\(gameVersion).json") + guard let data = try? Data(contentsOf: metadataURL) else { + throw QuiltInstallError.baseMetadataNotFound(gameVersion) + } + return try JSONDecoder.mmcl.decode(VersionMetadata.self, from: data) + } +} + +enum QuiltInstallError: LocalizedError, Equatable { + case noLoaderAvailable(String) + case baseMetadataNotFound(String) + + var errorDescription: String? { + switch self { + case .noLoaderAvailable(let v): return "没有可用的 Quilt loader 版本:Minecraft \(v)" + case .baseMetadataNotFound(let v): return "缺少基础版本元数据:\(v)。请先安装原版 \(v)。" + } + } +} + +protocol ForgeServicing { + func fetchVersions(gameVersion: String) async throws -> [ForgeVersion] + func installForge(gameVersion: String, forgeVersion: String?, instance: LauncherInstance) async throws -> VersionMetadata +} + +struct ForgeService: ForgeServicing { + let baseURL = URL(string: "https://files.minecraftforge.net/net/minecraftforge/forge")! + + func fetchVersions(gameVersion: String) async throws -> [ForgeVersion] { + let url = URL(string: "https://files.minecraftforge.net/net/minecraftforge/forge/\(gameVersion)/promotions_slim.json")! + let (data, _) = try await URLSession.shared.data(from: url) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let promo = json?["promos"] as? [String: String] ?? [:] + return promo.compactMap { key, value in + guard key.hasSuffix("-latest") || key.hasSuffix("-recommended") else { return nil } + let mcVersion = key.replacingOccurrences(of: "-latest", with: "").replacingOccurrences(of: "-recommended", with: "") + guard mcVersion == gameVersion else { return nil } + return ForgeVersion( + version: value, + installerURL: "https://files.minecraftforge.net/net/minecraftforge/forge/\(gameVersion)-\(value)/forge-\(gameVersion)-\(value)-installer.jar" + ) + } + } + + func installForge(gameVersion: String, forgeVersion: String? = nil, instance: LauncherInstance) async throws -> VersionMetadata { + let versions = try await fetchVersions(gameVersion: gameVersion) + guard let selected = versions.first else { + throw ForgeInstallError.noVersionAvailable(gameVersion) + } + + let baseMetadata = try readBaseMetadata(instance: instance, gameVersion: gameVersion) + var metadata = baseMetadata + metadata.id = "\(gameVersion)-forge-\(selected.version)" + + let versionDir = instance.rootDirectory + .appendingPathComponent(".minecraft", isDirectory: true) + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(metadata.id, isDirectory: true) + try FileManager.default.createDirectory(at: versionDir, withIntermediateDirectories: true) + try JSONEncoder.mmcl.encode(metadata).write(to: versionDir.appendingPathComponent("\(metadata.id).json"), options: .atomic) + + return metadata + } + + private func readBaseMetadata(instance: LauncherInstance, gameVersion: String) throws -> VersionMetadata { + let metadataURL = instance.rootDirectory + .appendingPathComponent(".minecraft", isDirectory: true) + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(gameVersion, isDirectory: true) + .appendingPathComponent("\(gameVersion).json") + guard let data = try? Data(contentsOf: metadataURL) else { + throw ForgeInstallError.baseMetadataNotFound(gameVersion) + } + return try JSONDecoder.mmcl.decode(VersionMetadata.self, from: data) + } +} + +enum ForgeInstallError: LocalizedError, Equatable { + case noVersionAvailable(String) + case baseMetadataNotFound(String) + + var errorDescription: String? { + switch self { + case .noVersionAvailable(let v): return "没有可用的 Forge 版本:Minecraft \(v)" + case .baseMetadataNotFound(let v): return "缺少基础版本元数据:\(v)。请先安装原版 \(v)。" + } + } +} + +protocol NeoForgeServicing { + func fetchVersions(gameVersion: String) async throws -> [NeoForgeVersion] + func installNeoForge(gameVersion: String, version: String?, instance: LauncherInstance) async throws -> VersionMetadata +} + +struct NeoForgeService: NeoForgeServicing { + let baseURL = URL(string: "https://maven.neoforged.net/releases/net/neoforged/neoforge")! + + func fetchVersions(gameVersion: String) async throws -> [NeoForgeVersion] { + let url = URL(string: "https://maven.neoforged.net/api/maven/versions/releases/net/neoforged/neoforge")! + let (data, _) = try await URLSession.shared.data(from: url) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let versions = json?["versions"] as? [String] ?? [] + return versions + .filter { $0.hasPrefix(gameVersion + ".") } + .map { NeoForgeVersion(version: $0, neoForgeVersion: $0.replacingOccurrences(of: "\(gameVersion).", with: "")) } + } + + func installNeoForge(gameVersion: String, version: String? = nil, instance: LauncherInstance) async throws -> VersionMetadata { + let versions = try await fetchVersions(gameVersion: gameVersion) + guard let selected = versions.first else { + throw NeoForgeInstallError.noVersionAvailable(gameVersion) + } + + let baseMetadata = try readBaseMetadata(instance: instance, gameVersion: gameVersion) + var metadata = baseMetadata + metadata.id = "\(gameVersion)-neoforge-\(selected.version)" + + let versionDir = instance.rootDirectory + .appendingPathComponent(".minecraft", isDirectory: true) + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(metadata.id, isDirectory: true) + try FileManager.default.createDirectory(at: versionDir, withIntermediateDirectories: true) + try JSONEncoder.mmcl.encode(metadata).write(to: versionDir.appendingPathComponent("\(metadata.id).json"), options: .atomic) + + return metadata + } + + private func readBaseMetadata(instance: LauncherInstance, gameVersion: String) throws -> VersionMetadata { + let metadataURL = instance.rootDirectory + .appendingPathComponent(".minecraft", isDirectory: true) + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(gameVersion, isDirectory: true) + .appendingPathComponent("\(gameVersion).json") + guard let data = try? Data(contentsOf: metadataURL) else { + throw NeoForgeInstallError.baseMetadataNotFound(gameVersion) + } + return try JSONDecoder.mmcl.decode(VersionMetadata.self, from: data) + } +} + +enum NeoForgeInstallError: LocalizedError, Equatable { + case noVersionAvailable(String) + case baseMetadataNotFound(String) + + var errorDescription: String? { + switch self { + case .noVersionAvailable(let v): return "没有可用的 NeoForge 版本:Minecraft \(v)" + case .baseMetadataNotFound(let v): return "缺少基础版本元数据:\(v)。请先安装原版 \(v)。" + } + } +} + +protocol DiagnosticServicing { + func javaMismatch(instance: LauncherInstance, runtime: JavaRuntime) -> DiagnosticReport? + func analyzeLatestCrash(instance: LauncherInstance) -> DiagnosticReport? +} + +struct DiagnosticService: DiagnosticServicing { + func javaMismatch(instance: LauncherInstance, runtime: JavaRuntime) -> DiagnosticReport? { + guard !runtime.isRecommended(for: instance.gameVersion) else { return nil } + let required = JavaRuntime.recommendedMajorVersion(for: instance.gameVersion) + return DiagnosticReport( + title: "Java 版本过低", + severity: .warning, + summary: "实例 \(instance.name) 推荐使用 Java \(required),当前选择的是 Java \(runtime.majorVersion)。", + suggestedActions: ["安装 Java \(required) 或更高版本", "在实例设置中重新选择 Java 运行时"] + ) + } + + func analyzeLatestCrash(instance: LauncherInstance) -> DiagnosticReport? { + let logURL = instance.rootDirectory.appendingPathComponent("logs/latest.log") + guard let data = try? Data(contentsOf: logURL), + let content = String(data: data, encoding: .utf8) else { + return nil + } + + let lines = content.components(separatedBy: .newlines) + var crashLines: [String] = [] + var inCrash = false + + for line in lines { + if line.contains("---- Minecraft Crash Report ----") || line.contains("java.lang.") && line.contains("Exception") { + inCrash = true + } + if inCrash { + crashLines.append(line) + if crashLines.count > 50 { break } + } + } + + guard !crashLines.isEmpty else { return nil } + + let crashContent = crashLines.joined(separator: "\n") + let summary: String + if crashContent.contains("OutOfMemoryError") { + summary = "内存不足。建议增加分配内存。" + } else if crashContent.contains("ClassNotFound") || crashContent.contains("NoClassDefFoundError") { + summary = "缺少依赖类。可能是 mod 版本不兼容或 loader 安装不完整。" + } else if crashContent.contains("NoSuchMethod") { + summary = "方法不存在。可能是 mod 与游戏版本不兼容。" + } else { + summary = "游戏崩溃,前 50 行日志已捕获。" + } + + return DiagnosticReport( + title: "游戏崩溃", + severity: .error, + summary: summary, + suggestedActions: ["检查 mod 兼容性", "尝试移除最近安装的 mod", "查看完整崩溃日志"] + ) + } +} + +protocol AuthServicing { + func startDeviceCodeFlow() async throws -> DeviceCodeResponse + func pollForToken(deviceCode: String, interval: Int) async throws -> MicrosoftTokenResponse + func exchangeForXBLToken(accessToken: String) async throws -> XboxTokenResponse + func exchangeForXSTSToken(xblToken: String) async throws -> XBLXSTSResponse + func exchangeForMinecraftToken(xstsToken: String) async throws -> MinecraftTokenResponse + func fetchMinecraftProfile(accessToken: String) async throws -> MinecraftProfileResponse + func refreshMicrosoftToken(refreshToken: String) async throws -> MicrosoftTokenResponse +} + +struct AuthService: AuthServicing { + let clientID = "16d660be-3984-44b0-a834-44be4a89d609" + + func startDeviceCodeFlow() async throws -> DeviceCodeResponse { + let url = URL(string: "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = "client_id=\(clientID)&scope=XboxLive.signin offline_access".data(using: .utf8) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + let (data, _) = try await URLSession.shared.data(for: request) + return try JSONDecoder().decode(DeviceCodeResponse.self, from: data) + } + + func pollForToken(deviceCode: String, interval: Int = 5) async throws -> MicrosoftTokenResponse { + let url = URL(string: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = "client_id=\(clientID)&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=\(deviceCode)".data(using: .utf8) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + while true { + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = response as! HTTPURLResponse + + if httpResponse.statusCode == 200 { + return try JSONDecoder().decode(MicrosoftTokenResponse.self, from: data) + } + + let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] + let error = json?["error"] ?? "" + + if error == "authorization_pending" { + try await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) + continue + } else if error == "authorization_declined" { + throw AuthError.userDeclined + } else if error == "expired_token" { + throw AuthError.codeExpired + } else { + throw AuthError.tokenExchangeFailed(json?["error_description"] ?? error) + } + } + } + + func exchangeForXBLToken(accessToken: String) async throws -> XboxTokenResponse { + let url = URL(string: "https://user.auth.xboxlive.com/user/authenticate")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + let body: [String: Any] = [ + "Properties": [ + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": accessToken + ], + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let (data, _) = try await URLSession.shared.data(for: request) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let token = json["Token"] as! String + let expiresIn = (json["IssueAfter"] as? Int) ?? 3600 + return XboxTokenResponse(token: token, expiresInSeconds: expiresIn) + } + + func exchangeForXSTSToken(xblToken: String) async throws -> XBLXSTSResponse { + let url = URL(string: "https://xsts.auth.xboxlive.com/xsts/authorize")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + let body: [String: Any] = [ + "Properties": [ + "SandboxId": "RETAIL", + "UserTokens": [xblToken] + ], + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT" + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let (data, _) = try await URLSession.shared.data(for: request) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + if let token = json["Token"] as? String { + return XBLXSTSResponse(token: token, expiresInSeconds: 3600) + } + let error = json["XErr"] as? Int ?? 0 + throw AuthError.xstsAuthFailed(error) + } + + func exchangeForMinecraftToken(xstsToken: String) async throws -> MinecraftTokenResponse { + let url = URL(string: "https://api.minecraftservices.com/authentication/login_with_xbox")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + let body = ["identityToken": "XBL3.0 x=\(xstsToken)"] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let (data, _) = try await URLSession.shared.data(for: request) + return try JSONDecoder().decode(MinecraftTokenResponse.self, from: data) + } + + func fetchMinecraftProfile(accessToken: String) async throws -> MinecraftProfileResponse { + let url = URL(string: "https://api.minecraftservices.com/minecraft/profile")! + var request = URLRequest(url: url) + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + let (data, response) = try await URLSession.shared.data(for: request) + let httpResponse = response as! HTTPURLResponse + guard httpResponse.statusCode == 200 else { + throw AuthError.noMinecraftProfile + } + return try JSONDecoder().decode(MinecraftProfileResponse.self, from: data) + } + + func refreshMicrosoftToken(refreshToken: String) async throws -> MicrosoftTokenResponse { + let url = URL(string: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = "client_id=\(clientID)&grant_type=refresh_token&refresh_token=\(refreshToken)&scope=XboxLive.signin offline_access".data(using: .utf8) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + let (data, _) = try await URLSession.shared.data(for: request) + return try JSONDecoder().decode(MicrosoftTokenResponse.self, from: data) + } +} + +// MARK: - Profile Export/Import + +protocol ProfileExportServicing { + func exportProfile( + instances: [LauncherInstance], + accounts: [MinecraftAccount], + settings: ProfileExportSettings + ) throws -> Data + func importProfile(from data: Data) throws -> ProfileExportData + func saveExport(_ data: Data, to url: URL) throws + func loadExport(from url: URL) throws -> Data +} + +struct ProfileExportService: ProfileExportServicing { + func exportProfile( + instances: [LauncherInstance], + accounts: [MinecraftAccount], + settings: ProfileExportSettings + ) throws -> Data { + let export = ProfileExportData( + instances: instances, + accounts: accounts, + settings: settings + ) + return try JSONEncoder.mmcl.encode(export) + } + + func importProfile(from data: Data) throws -> ProfileExportData { + try JSONDecoder.mmcl.decode(ProfileExportData.self, from: data) + } + + func saveExport(_ data: Data, to url: URL) throws { + try data.write(to: url, options: .atomic) + } + + func loadExport(from url: URL) throws -> Data { + try Data(contentsOf: url) + } +} + +// MARK: - Server List + +protocol ServerListServicing { + func loadServers(from url: URL) -> [ServerInfo] + func saveServers(_ servers: [ServerInfo], to url: URL) throws + func pingServer(address: String, port: Int) async -> ServerInfo.ServerPingResult? + func serverListFileURL(for instance: LauncherInstance) -> URL +} + +struct ServerListService: ServerListServicing { + let applicationSupportDirectory: URL + + init(applicationSupportDirectory: URL? = nil) { + self.applicationSupportDirectory = applicationSupportDirectory ?? FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0] + } + + func serverListFileURL(for instance: LauncherInstance) -> URL { + instance.rootDirectory + .appendingPathComponent(".minecraft", isDirectory: true) + .appendingPathComponent("servers.json") + } + + func loadServers(from url: URL) -> [ServerInfo] { + guard let data = try? Data(contentsOf: url), + let servers = try? JSONDecoder.mmcl.decode([ServerInfo].self, from: data) else { + return [] + } + return servers + } + + func saveServers(_ servers: [ServerInfo], to url: URL) throws { + let data = try JSONEncoder.mmcl.encode(servers) + let parentDir = url.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + } + + func pingServer(address: String, port: Int) async -> ServerInfo.ServerPingResult? { + let host = NWEndpoint.Host(address) + let portObj = NWEndpoint.Port(rawValue: UInt16(port))! + let connection = NWConnection(host: host, port: portObj, using: .tcp) + + return await withCheckedContinuation { continuation in + let startTime = Date() + var didResume = false + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + let elapsed = Int(Date().timeIntervalSince(startTime) * 1000) + // Simple ping - send a basic Minecraft server list ping + var packet = Data() + // Packet ID: 0x00 (Handshake) + packet.append(0x01) // length + packet.append(0x00) // packet id + // Protocol version + packet.append(contentsOf: [0xFF, 0x05]) // varint 762 (1.19.4) + // Server address + let addrData = address.utf8 + packet.append(UInt8(addrData.count)) + packet.append(contentsOf: addrData) + // Server port + packet.append(UInt8(port >> 8)) + packet.append(UInt8(port & 0xFF)) + // Next state: 1 (status) + packet.append(0x01) + + connection.send(content: packet, completion: .contentProcessed { _ in + // For a real implementation, we'd parse the response + // For now, return a basic result indicating the server is reachable + let result = ServerInfo.ServerPingResult( + motd: "服务器可达", + playerCount: 0, + maxPlayers: 0, + versionName: "未知", + pingMs: elapsed + ) + connection.cancel() + continuation.resume(returning: result) + }) + case .failed: + continuation.resume(returning: nil) + case .cancelled: + if !didResume { + didResume = true + continuation.resume(returning: nil) + } + default: + break + } + } + + connection.start(queue: .global()) + + // Timeout after 5 seconds + DispatchQueue.global().asyncAfter(deadline: .now() + 5) { + if !didResume { + didResume = true + connection.cancel() + continuation.resume(returning: nil) + } + } + } + } +} + +protocol CurseForgeServicing { + func search(query: String, classId: Int?, gameVersion: String?, apiKey: String) async throws -> [CurseForgeSearchResult] +} + +struct CurseForgeService: CurseForgeServicing { + let baseURL = URL(string: "https://api.curseforge.com")! + let userAgent = "MMCL/1.0 (https://github.com/Lhy723/MMCL)" + + func search(query: String, classId: Int? = nil, gameVersion: String? = nil, apiKey: String) async throws -> [CurseForgeSearchResult] { + var components = URLComponents(url: baseURL.appendingPathComponent("/v1/mods/search"), resolvingAgainstBaseURL: false)! + var queryItems = [ + URLQueryItem(name: "gameId", value: "432"), + URLQueryItem(name: "searchFilter", value: query), + URLQueryItem(name: "pageSize", value: "20") + ] + if let classId { + queryItems.append(URLQueryItem(name: "classId", value: "\(classId)")) + } + if let gv = gameVersion { + queryItems.append(URLQueryItem(name: "gameVersion", value: gv)) + } + components.queryItems = queryItems + var request = URLRequest(url: components.url!) + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + let (data, urlResponse) = try await URLSession.shared.data(for: request) + if let http = urlResponse as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw CurseForgeError.searchFailed("HTTP \(http.statusCode)") + } + let response = try JSONDecoder().decode(CurseForgeSearchResponse.self, from: data) + return response.data + } +} + +enum CurseForgeError: LocalizedError, Equatable { + case searchFailed(String) + + var errorDescription: String? { + switch self { + case .searchFailed(let detail): + return "CurseForge 搜索失败:\(detail)" + } + } +} + +enum AuthError: LocalizedError, Equatable { + case userDeclined + case codeExpired + case tokenExchangeFailed(String) + case xstsAuthFailed(Int) + case noMinecraftProfile + + var errorDescription: String? { + switch self { + case .userDeclined: return "登录已被拒绝。" + case .codeExpired: return "设备代码已过期,请重试。" + case .tokenExchangeFailed(let desc): return "令牌交换失败:\(desc)" + case .xstsAuthFailed(let code): return "XSTS 认证失败(错误码 \(code))。" + case .noMinecraftProfile: return "此账号没有 Minecraft Profile。请确认已购买游戏。" + } + } +} diff --git a/MMCL/Stores/LauncherStore.swift b/MMCL/Stores/LauncherStore.swift new file mode 100644 index 0000000..ac61913 --- /dev/null +++ b/MMCL/Stores/LauncherStore.swift @@ -0,0 +1,2063 @@ +import Combine +import Foundation +import AppKit + +@MainActor +final class LauncherStore: ObservableObject { + enum Section: Hashable { + case launcher + case downloads + case diagnostics + case skin + case serverList + case settings + } + + @Published var instances: [LauncherInstance] + @Published var downloadJobs: [DownloadJob] + @Published var featuredProjects: [ContentProject] + @Published var diagnostics: [DiagnosticReport] + @Published var javaRuntimes: [JavaRuntime] + @Published var availableVersions: [MinecraftVersion] + @Published var selectedSection: Section? + @Published var launcherSelectedInstanceID: LauncherInstance.ID? + @Published var selectedDownloadSource: DownloadSource + @Published var selectedDownloadTab: DownloadTabType = .vanilla + @Published var selectedJavaRuntimeID: JavaRuntime.ID? + @Published var currentLaunchSession: LaunchSession? + @Published var plannedVersionMetadata: VersionMetadata? + @Published var plannedInstanceID: LauncherInstance.ID? + + @Published var defaultMemoryMegabytes: Int = 4096 + @Published var defaultOfflineUsername: String = "Steve" + @Published var defaultResolutionWidth: Int = 854 + @Published var defaultResolutionHeight: Int = 480 + @Published var isScanningJava: Bool = false + @Published var isInstallingJDK = false + @Published var jdkInstallProgress: Double = 0 + @Published var preferredDownloadSource: DownloadSource = .bmclapi + + // Download & community settings + @Published var fileDownloadSourceMode: FileDownloadSourceMode = .officialWithFallback + @Published var versionListSourceMode: VersionListSourceMode = .officialWithFallback + @Published var maxDownloadThreads: Int = 64 + @Published var downloadSpeedLimit: Int = 0 // 0 = unlimited, KB/s + @Published var communitySourceMode: CommunitySourceMode = .preferOfficial + @Published var curseForgeApiKey: String = "" + @Published var filenameFormat: FilenameFormat = .bracketEN + @Published var modListDisplayStyle: ModListDisplayStyle = .titleTranslationDetailFilename + + // Launch settings + @Published var versionIsolation: VersionIsolation = .moddableAndSnapshots + @Published var gameWindowTitle: String = "" + @Published var customInfo: String = "" + @Published var launcherVisibility: LauncherVisibility = .keep + @Published var processPriority: ProcessPriority = .normal + @Published var windowSizeMode: WindowSizeMode = .default + @Published var gameArguments: String = "" + @Published var preLaunchCommand: String = "" + @Published var useHighPerformanceGPU: Bool = false + @Published var memoryAutoConfig: Bool = true + @Published var customJavaPath: String = "" + @Published var showingCreateSheet: Bool = false + @Published var showingLogSheet: Bool = false + @Published var showingRenameSheet: Bool = false + @Published var showingModList: Bool = false + @Published var showingResourcePacks: Bool = false + @Published var showingShaderPacks: Bool = false + @Published var selectedInstanceSettingsID: LauncherInstance.ID? + @Published var showingJDKInstall: Bool = false + @Published var availableSkins: [SkinInfo] = [] + @Published var serverList: [ServerInfo] = [] + + @Published var accounts: [MinecraftAccount] = [] + @Published var selectedAccountID: MinecraftAccount.ID? + @Published var isLoggingIn = false + @Published var deviceCodeMessage: String = "" + + @Published var modrinthSearchResults: [ModrinthSearchResult] = [] + @Published var modrinthSearchQuery: String = "" + @Published var showingModrinthDetail: Bool = false + @Published var selectedModrinthProject: ModrinthSearchResult? + + @Published var curseForgeResults: [CurseForgeSearchResult] = [] + @Published var curseForgeSearchQuery: String = "" + + var portableJDKDirectory: URL { javaRuntimeService.portableJDKDirectory } + + let githubRepoURL = "https://github.com/Lhy723/MMCL" + + func openGitHubRepo() { + if let url = URL(string: githubRepoURL) { + NSWorkspace.shared.open(url) + } + } + + let currentVersion = "0.1.0" + @Published var latestVersion: String? + @Published var updateAvailable = false + @Published var updateDownloadURL: URL? + @Published var isDownloadingUpdate: Bool = false + + @Published var colorScheme: AppColorScheme = .system + @Published var appLanguage: AppLanguage = .chinese + @Published var jvmPresets: [JVMPreset] = JVMPreset.defaults + @Published var backgroundImage: BackgroundImage = BackgroundImage() + @Published var animationDurationScale: Double = 1.0 + + let speedTracker = DownloadSpeedTracker() + private var queuedDownloadIDs: [UUID] = [] + private var activeDownloadCount: Int = 0 + + var taskGroups: [DownloadTaskGroup] { + let grouped = Dictionary(grouping: downloadJobs) { $0.taskGroupID ?? $0.id } + return grouped.map { key, jobs in + DownloadTaskGroup( + id: key, + name: jobs.first?.taskGroupName ?? jobs.first?.title ?? "未知任务", + jobs: jobs + ) + } + .sorted { a, b in + let order: (DownloadStatus) -> Int = { s in + switch s { + case .running: return 0 + case .paused: return 1 + case .queued: return 2 + case .failed: return 3 + case .completed: return 4 + } + } + return order(a.status) < order(b.status) + } + } + + private let launchService: LaunchServicing + private let downloadService: DownloadServicing + private let versionService: VersionManifestServicing + private let javaRuntimeService: JavaRuntimeServicing + private let instanceService: InstanceServicing + private let fabricService: FabricServicing + private let quiltService: QuiltServicing + private let forgeService: ForgeServicing + private let neoForgeService: NeoForgeServicing + let modrinthService: ModrinthServicing + let curseForgeService: CurseForgeServicing + private let authService: AuthServicing + private let diagnosticService: DiagnosticServicing + private let skinService: SkinServicing + private let serverListService: ServerListServicing + + init( + instances: [LauncherInstance] = [], + downloadJobs: [DownloadJob] = [], + featuredProjects: [ContentProject] = [], + diagnostics: [DiagnosticReport] = [], + selectedDownloadSource: DownloadSource = .bmclapi, + javaRuntimes: [JavaRuntime] = [], + availableVersions: [MinecraftVersion] = [], + launchService: LaunchServicing = LaunchService(), + downloadService: DownloadServicing = DownloadService(), + versionService: VersionManifestServicing = VersionManifestService(), + javaRuntimeService: JavaRuntimeServicing = JavaRuntimeService(), + instanceService: InstanceServicing = InstanceService(), + fabricService: FabricServicing = FabricService(), + quiltService: QuiltServicing = QuiltService(), + forgeService: ForgeServicing = ForgeService(), + neoForgeService: NeoForgeServicing = NeoForgeService(), + modrinthService: ModrinthServicing = ModrinthService(), + curseForgeService: CurseForgeServicing = CurseForgeService(), + authService: AuthServicing = AuthService(), + diagnosticService: DiagnosticServicing = DiagnosticService(), + skinService: SkinServicing = SkinService(), + serverListService: ServerListServicing = ServerListService() + ) { + if instances.isEmpty { + self.instances = (try? instanceService.loadAllInstances()) ?? [] + } else { + self.instances = instances + } + self.downloadJobs = downloadJobs + self.featuredProjects = featuredProjects + self.diagnostics = diagnostics + self.selectedDownloadSource = selectedDownloadSource + self.javaRuntimes = javaRuntimes + self.availableVersions = availableVersions + self.launchService = launchService + self.downloadService = downloadService + self.versionService = versionService + self.javaRuntimeService = javaRuntimeService + self.instanceService = instanceService + self.fabricService = fabricService + self.quiltService = quiltService + self.forgeService = forgeService + self.neoForgeService = neoForgeService + self.modrinthService = modrinthService + self.curseForgeService = curseForgeService + self.authService = authService + self.diagnosticService = diagnosticService + self.skinService = skinService + self.serverListService = serverListService + self.selectedJavaRuntimeID = javaRuntimes.first?.id + self.selectedSection = .launcher + + // Restore last selected instance + if let savedID = UserDefaults.standard.string(forKey: "lastSelectedInstanceID"), + let uuid = UUID(uuidString: savedID), + instances.contains(where: { $0.id == uuid }) { + self.launcherSelectedInstanceID = uuid + } else { + self.launcherSelectedInstanceID = instances.first?.id + } + + // Load persisted accounts; create default offline if none + let loaded = Self.loadAccountsFromDisk() + if loaded.isEmpty { + let defaultAccount = MinecraftAccount(username: defaultOfflineUsername, type: .offline) + self.accounts = [defaultAccount] + self.selectedAccountID = defaultAccount.id + Self.saveAccountsToDisk([defaultAccount]) + } else { + self.accounts = loaded + self.selectedAccountID = loaded.first?.id + } + } + + var selectedInstance: LauncherInstance? { + guard let id = launcherSelectedInstanceID else { return nil } + return instances.first { $0.id == id } + } + + func verifyInstanceStatuses() { + let fm = FileManager.default + for index in instances.indices { + let mcDir = instances[index].rootDirectory.appendingPathComponent(".minecraft") + let versionsDir = mcDir.appendingPathComponent("versions") + let hasMinecraftDir = fm.fileExists(atPath: mcDir.path) + let hasVersionsDir = fm.fileExists(atPath: versionsDir.path) + + let correctStatus: InstanceStatus + if hasMinecraftDir && hasVersionsDir { + correctStatus = .ready + } else { + correctStatus = .notInstalled + } + + if instances[index].status != correctStatus { + instances[index].status = correctStatus + persistInstance(at: index) + } + } + } + + func selectFirstInstanceIfNeeded() { + if selectedSection == nil { + selectedSection = .launcher + launcherSelectedInstanceID = instances.first?.id + } + } + + func startMicrosoftLogin() async { + isLoggingIn = true + do { + let deviceCode = try await authService.startDeviceCodeFlow() + deviceCodeMessage = "请在浏览器中打开 \(deviceCode.verificationUri),输入代码:\(deviceCode.userCode)" + let token = try await authService.pollForToken(deviceCode: deviceCode.deviceCode, interval: deviceCode.interval) + + let xblToken = try await authService.exchangeForXBLToken(accessToken: token.accessToken) + let xstsToken = try await authService.exchangeForXSTSToken(xblToken: xblToken.token) + let mcToken = try await authService.exchangeForMinecraftToken(xstsToken: xstsToken.token) + let profile = try await authService.fetchMinecraftProfile(accessToken: mcToken.accessToken) + + let account = MinecraftAccount( + username: profile.name, + uuid: profile.id, + accessToken: mcToken.accessToken, + refreshToken: token.refreshToken, + expiresAt: Date().addingTimeInterval(TimeInterval(mcToken.expiresInSeconds)), + type: .microsoft + ) + + if !accounts.contains(where: { $0.uuid == account.uuid }) { + accounts.append(account) + } + selectedAccountID = account.id + Self.saveAccountsToDisk(accounts) + isLoggingIn = false + deviceCodeMessage = "" + diagnostics.insert( + DiagnosticReport( + title: "登录成功", + severity: .info, + summary: "已登录 Microsoft 账号 \(profile.name)。", + suggestedActions: ["启动游戏将使用在线模式"] + ), + at: 0 + ) + } catch { + isLoggingIn = false + deviceCodeMessage = "" + diagnostics.insert( + DiagnosticReport( + title: "登录失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["检查网络连接", "确认 Microsoft 账号已购买 Minecraft"] + ), + at: 0 + ) + } + } + + func addOfflineAccount(username: String) { + let account = MinecraftAccount(username: username, type: .offline) + accounts.append(account) + selectedAccountID = account.id + Self.saveAccountsToDisk(accounts) + } + + func deleteAccount(_ account: MinecraftAccount) { + accounts.removeAll { $0.id == account.id } + if selectedAccountID == account.id { + selectedAccountID = accounts.first?.id + } + Self.saveAccountsToDisk(accounts) + } + + func updateAccountUsername(_ account: MinecraftAccount, newUsername: String) { + guard let index = accounts.firstIndex(where: { $0.id == account.id }) else { return } + accounts[index].username = newUsername + Self.saveAccountsToDisk(accounts) + } + + // MARK: - Account Persistence + + private static var accountsDirectoryURL: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("MMCL", isDirectory: true) + } + + private static var accountsFileURL: URL { + accountsDirectoryURL.appendingPathComponent("accounts.json") + } + + static func loadAccountsFromDisk() -> [MinecraftAccount] { + let url = accountsFileURL + guard let data = try? Data(contentsOf: url) else { return [] } + return (try? JSONDecoder.mmcl.decode([MinecraftAccount].self, from: data)) ?? [] + } + + static func saveAccountsToDisk(_ accounts: [MinecraftAccount]) { + let url = accountsFileURL + let dir = accountsDirectoryURL + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + if let data = try? JSONEncoder.mmcl.encode(accounts) { + try? data.write(to: url, options: .atomic) + } + } + + var selectedAccount: MinecraftAccount? { + guard let selectedAccountID else { return accounts.first } + return accounts.first { $0.id == selectedAccountID } ?? accounts.first + } + + func createInstance( + name: String, + gameVersion: String, + loader: GameLoader, + memory: Int? = nil, + username: String? = nil, + jvmArgs: [String] = [] + ) { + let profile = LaunchProfile( + offlineUsername: username ?? defaultOfflineUsername, + memoryMegabytes: memory ?? defaultMemoryMegabytes, + jvmArguments: jvmArgs, + resolutionWidth: defaultResolutionWidth, + resolutionHeight: defaultResolutionHeight + ) + do { + let instance = try instanceService.createInstance( + name: name, + gameVersion: gameVersion, + loader: loader, + profile: profile + ) + instances.append(instance) + selectedSection = .launcher + launcherSelectedInstanceID = instance.id + showingCreateSheet = false + diagnostics.insert( + DiagnosticReport( + title: "实例已创建", + severity: .info, + summary: "\(name) 已创建,游戏版本 \(gameVersion),加载器 \(loader.rawValue)。", + suggestedActions: ["生成安装计划并下载游戏文件"] + ), + at: 0 + ) + } catch { + diagnostics.insert( + DiagnosticReport( + title: "实例创建失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["检查磁盘空间和目录权限"] + ), + at: 0 + ) + } + } + + func loadLogContent(for instance: LauncherInstance) -> String { + let logURL = instance.rootDirectory + .appendingPathComponent("logs", isDirectory: true) + .appendingPathComponent("latest.log") + guard let data = try? Data(contentsOf: logURL) else { + return "日志文件不存在:\(logURL.path)" + } + return String(decoding: data, as: UTF8.self) + } + + func deleteInstance(_ instance: LauncherInstance) { + let fileManager = FileManager.default + if fileManager.fileExists(atPath: instance.rootDirectory.path) { + do { + try fileManager.removeItem(at: instance.rootDirectory) + } catch { + diagnostics.insert( + DiagnosticReport( + title: "删除实例目录失败", + severity: .warning, + summary: "\(instance.name):\(error.localizedDescription)", + suggestedActions: ["检查文件权限", "手动删除目录"] + ), + at: 0 + ) + } + } + instances.removeAll { $0.id == instance.id } + if launcherSelectedInstanceID == instance.id { + launcherSelectedInstanceID = instances.first?.id + } + diagnostics.insert( + DiagnosticReport( + title: "实例已删除", + severity: .info, + summary: "\(instance.name) 已移除。", + suggestedActions: [] + ), + at: 0 + ) + } + + func renameInstance(_ instance: LauncherInstance, to newName: String) { + guard !newName.trimmingCharacters(in: .whitespaces).isEmpty else { return } + guard let index = instances.firstIndex(where: { $0.id == instance.id }) else { return } + instances[index].name = newName + persistInstance(at: index) + } + + func saveInstanceProfile(_ instance: LauncherInstance, profile: LaunchProfile) { + guard let index = instances.firstIndex(where: { $0.id == instance.id }) else { return } + instances[index].profile = profile + persistInstance(at: index) + } + + private func persistInstance(at index: Int) { + do { + let data = try instanceService.encode(instances[index]) + try data.write(to: instanceService.instanceFileURL(for: instances[index]), options: .atomic) + } catch { + diagnostics.insert( + DiagnosticReport(title: "保存失败", severity: .error, summary: error.localizedDescription, suggestedActions: ["检查文件权限"]), + at: 0 + ) + } + } + + func copyInstance(_ instance: LauncherInstance) { + let newName = "\(instance.name)(副本)" + let profile = LaunchProfile( + offlineUsername: instance.profile.offlineUsername, + memoryMegabytes: instance.profile.memoryMegabytes, + jvmArguments: instance.profile.jvmArguments, + resolutionWidth: instance.profile.resolutionWidth, + resolutionHeight: instance.profile.resolutionHeight + ) + do { + let copy = try instanceService.createInstance( + name: newName, + gameVersion: instance.gameVersion, + loader: instance.loader, + profile: profile + ) + instances.append(copy) + selectedSection = .launcher + launcherSelectedInstanceID = copy.id + diagnostics.insert( + DiagnosticReport(title: "实例已复制", severity: .info, summary: "\(instance.name) 已复制为 \(newName)。", suggestedActions: []), + at: 0 + ) + } catch { + diagnostics.insert( + DiagnosticReport(title: "复制失败", severity: .error, summary: error.localizedDescription, suggestedActions: []), + at: 0 + ) + } + } + + func scanInstalledMods(for instance: LauncherInstance) -> [ModInfo] { + let modsDir = instance.rootDirectory.appendingPathComponent("mods", isDirectory: true) + guard let files = try? FileManager.default.contentsOfDirectory(at: modsDir, includingPropertiesForKeys: [.fileSizeKey]) else { + return [] + } + return files + .filter { $0.pathExtension == "jar" || $0.pathExtension == "disabled" } + .map { url in + let isEnabled = url.pathExtension == "jar" + let actualURL = isEnabled ? url : url.deletingPathExtension() + let size = (try? actualURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + let name = actualURL.lastPathComponent + return ModInfo(fileName: name, isEnabled: isEnabled, size: Int64(size)) + } + .sorted { $0.fileName < $1.fileName } + } + + func toggleMod(for instance: LauncherInstance, mod: ModInfo) { + let modsDir = instance.rootDirectory.appendingPathComponent("mods", isDirectory: true) + let currentURL = modsDir.appendingPathComponent(mod.fileName + (mod.isEnabled ? ".jar" : ".jar.disabled")) + let newURL = modsDir.appendingPathComponent(mod.fileName + (mod.isEnabled ? ".jar.disabled" : ".jar")) + try? FileManager.default.moveItem(at: currentURL, to: newURL) + } + + func deleteMod(for instance: LauncherInstance, mod: ModInfo) { + let modsDir = instance.rootDirectory.appendingPathComponent("mods", isDirectory: true) + let fileName = mod.fileName + (mod.isEnabled ? ".jar" : ".jar.disabled") + try? FileManager.default.removeItem(at: modsDir.appendingPathComponent(fileName)) + } + + func scanResourcePacks(for instance: LauncherInstance) -> [ResourcePackInfo] { + let dir = instance.rootDirectory.appendingPathComponent(".minecraft/resourcepacks", isDirectory: true) + guard let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.fileSizeKey]) else { return [] } + return files + .filter { $0.pathExtension == "zip" } + .map { url in + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + return ResourcePackInfo(fileName: url.lastPathComponent, isEnabled: true, size: Int64(size)) + } + .sorted { $0.fileName < $1.fileName } + } + + func deleteResourcePack(for instance: LauncherInstance, pack: ResourcePackInfo) { + let dir = instance.rootDirectory.appendingPathComponent(".minecraft/resourcepacks", isDirectory: true) + try? FileManager.default.removeItem(at: dir.appendingPathComponent(pack.fileName)) + } + + func scanShaderPacks(for instance: LauncherInstance) -> [ShaderPackInfo] { + let dir = instance.rootDirectory.appendingPathComponent(".minecraft/shaderpacks", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + guard let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.fileSizeKey]) else { return [] } + return files + .filter { $0.pathExtension == "zip" || $0.pathExtension == "jar" } + .map { url in + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + return ShaderPackInfo(fileName: url.lastPathComponent, isEnabled: true, size: Int64(size)) + } + .sorted { $0.fileName < $1.fileName } + } + + func deleteShaderPack(for instance: LauncherInstance, pack: ShaderPackInfo) { + let dir = instance.rootDirectory.appendingPathComponent(".minecraft/shaderpacks", isDirectory: true) + try? FileManager.default.removeItem(at: dir.appendingPathComponent(pack.fileName)) + } + + // MARK: - Skin Management + + func scanSkinsForAccount(_ account: MinecraftAccount) { + let dir = skinService.skinDirectory(for: account) + availableSkins = skinService.scanSkins(in: dir) + } + + func importSkinFromPicker(sourceURL: URL, name: String, model: SkinInfo.SkinModel) { + do { + let skin = try skinService.importSkin(from: sourceURL, name: name, model: model) + availableSkins.append(skin) + diagnostics.insert( + DiagnosticReport( + title: "皮肤已导入", + severity: .info, + summary: "皮肤 \(name) 已导入到皮肤库。", + suggestedActions: [] + ), + at: 0 + ) + } catch { + diagnostics.insert( + DiagnosticReport( + title: "皮肤导入失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["检查文件格式是否为 PNG"] + ), + at: 0 + ) + } + } + + func applySkin(_ skin: SkinInfo) { + guard var account = selectedAccount else { return } + account.appliedSkin = skin + if let index = accounts.firstIndex(where: { $0.id == account.id }) { + accounts[index] = account + } + diagnostics.insert( + DiagnosticReport( + title: "皮肤已应用", + severity: .info, + summary: "皮肤 \(skin.name) 将在下次启动时生效。", + suggestedActions: [] + ), + at: 0 + ) + } +} + +// MARK: - Server List + +extension LauncherStore { + func loadServerList(for instance: LauncherInstance) { + let url = serverListService.serverListFileURL(for: instance) + serverList = serverListService.loadServers(from: url) + } + + func saveServerList(for instance: LauncherInstance) { + let url = serverListService.serverListFileURL(for: instance) + do { + try serverListService.saveServers(serverList, to: url) + } catch { + diagnostics.insert( + DiagnosticReport( + title: "服务器列表保存失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: [] + ), + at: 0 + ) + } + } + + func addServer(name: String, address: String, port: Int = 25565, for instance: LauncherInstance) { + let server = ServerInfo(name: name, address: address, port: port) + serverList.append(server) + saveServerList(for: instance) + } + + func updateServer(_ server: ServerInfo, for instance: LauncherInstance) { + if let index = serverList.firstIndex(where: { $0.id == server.id }) { + serverList[index] = server + saveServerList(for: instance) + } + } + + func deleteServer(_ server: ServerInfo, for instance: LauncherInstance) { + serverList.removeAll { $0.id == server.id } + saveServerList(for: instance) + } + + func toggleServerFavorite(_ server: ServerInfo, for instance: LauncherInstance) { + if let index = serverList.firstIndex(where: { $0.id == server.id }) { + serverList[index].isFavorite.toggle() + saveServerList(for: instance) + } + } + + func pingServer(_ server: ServerInfo) { + Task { + if let result = await serverListService.pingServer( + address: server.address, + port: server.port + ) { + if let index = serverList.firstIndex(where: { $0.id == server.id }) { + serverList[index].pingResult = result + serverList[index].lastPingedAt = Date() + } + } else { + if let index = serverList.firstIndex(where: { $0.id == server.id }) { + serverList[index].pingResult = nil + } + } + } + } + + func pingAllServers() { + for server in serverList { + pingServer(server) + } + } +} + +extension LauncherStore { + var selectedJavaRuntime: JavaRuntime? { + guard let selectedJavaRuntimeID else { return javaRuntimes.first } + return javaRuntimes.first { $0.id == selectedJavaRuntimeID } ?? javaRuntimes.first + } + + func launchPreviewForSelectedInstance() -> LaunchPreview? { + guard let selectedInstance, let selectedJavaRuntime else { return nil } + return LaunchPreview( + instance: selectedInstance, + java: selectedJavaRuntime, + command: launchService.previewCommand(for: selectedInstance, java: selectedJavaRuntime) + ) + } + + func launchSelectedInstance() { + guard let selectedInstance else { + diagnostics.insert( + DiagnosticReport( + title: "未选择实例", + severity: .error, + summary: "需要先选择实例才能启动 Minecraft。", + suggestedActions: ["从侧边栏选择一个实例"] + ), + at: 0 + ) + return + } + + guard let selectedJavaRuntime else { + diagnostics.insert( + DiagnosticReport( + title: "缺少 Java 运行时", + severity: .error, + summary: "没有可用于启动 \(selectedInstance.name) 的 Java。", + suggestedActions: ["点击重新扫描 Java", "安装推荐版本 Java \(javaRuntimeService.recommendedMajorVersion(for: selectedInstance.gameVersion))"] + ), + at: 0 + ) + return + } + + let preflightReport = launchService.preflight(instance: selectedInstance, java: selectedJavaRuntime) + guard preflightReport.canLaunch else { + updateInstanceStatus(selectedInstance.id, status: .missingFiles) + diagnostics.insert(preflightReport.diagnostic(), at: 0) + return + } + + if preflightReport.severity == .warning { + diagnostics.insert( + preflightReport.diagnostic(title: "启动前检查有提醒"), + at: 0 + ) + } + + do { + let session = try launchService.launch(instance: selectedInstance, java: selectedJavaRuntime) + currentLaunchSession = session + monitorLaunchSession() + diagnostics.insert( + DiagnosticReport( + title: "Minecraft 已启动", + severity: .info, + summary: "进程 \(session.processIdentifier) 已启动,日志写入 \(session.logFileURL.path)。", + suggestedActions: ["打开日志查看启动输出", "如果游戏窗口未出现,检查诊断日志"] + ), + at: 0 + ) + } catch { + diagnostics.insert( + DiagnosticReport( + title: "启动失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["检查 Java 路径是否存在", "确认实例文件已经下载完整"] + ), + at: 0 + ) + } + } + + func inspectSelectedInstance() { + guard let selectedInstance else { + diagnostics.insert( + DiagnosticReport( + title: "未选择实例", + severity: .error, + summary: "需要先选择实例才能检查启动环境。", + suggestedActions: ["从侧边栏选择一个实例"] + ), + at: 0 + ) + return + } + + guard let selectedJavaRuntime else { + updateInstanceStatus(selectedInstance.id, status: .needsJava) + diagnostics.insert( + DiagnosticReport( + title: "实例需要 Java", + severity: .error, + summary: "没有可用于检查 \(selectedInstance.name) 的 Java。", + suggestedActions: ["点击重新扫描 Java", "安装推荐版本 Java \(javaRuntimeService.recommendedMajorVersion(for: selectedInstance.gameVersion))"] + ), + at: 0 + ) + return + } + + let report = launchService.preflight(instance: selectedInstance, java: selectedJavaRuntime) + let title: String + switch report.severity { + case .info: + title = "实例可启动" + updateInstanceStatus(selectedInstance.id, status: .ready) + case .warning: + title = "实例有启动提醒" + case .error: + title = "实例需要修复" + updateInstanceStatus(selectedInstance.id, status: .missingFiles) + } + diagnostics.insert(report.diagnostic(title: title), at: 0) + } + + func repairSelectedInstance() async { + guard let selectedInstance else { + diagnostics.insert( + DiagnosticReport( + title: "未选择实例", + severity: .error, + summary: "需要先选择实例才能生成修复任务。", + suggestedActions: ["从侧边栏选择一个实例"] + ), + at: 0 + ) + return + } + + do { + let metadata = try await repairMetadata(for: selectedInstance) + plannedVersionMetadata = metadata + plannedInstanceID = selectedInstance.id + _ = try downloadService.writeVersionMetadata(metadata: metadata, instance: selectedInstance) + + let jobs = downloadService.makeVanillaRepairJobs( + metadata: metadata, + instance: selectedInstance, + source: selectedDownloadSource + ) + downloadJobs = jobs + updateInstanceStatus(selectedInstance.id, status: jobs.isEmpty ? .ready : .missingFiles) + diagnostics.insert( + DiagnosticReport( + title: jobs.isEmpty ? "实例文件已完整" : "已生成修复任务", + severity: jobs.isEmpty ? .info : .warning, + summary: jobs.isEmpty ? "\(selectedInstance.name) 没有发现需要重新下载的核心文件。" : "已为 \(selectedInstance.name) 生成 \(jobs.count) 个缺失文件下载任务。", + suggestedActions: jobs.isEmpty ? ["准备 Native 后启动"] : ["打开下载中心执行修复任务", "下载完成后准备 Native"] + ), + at: 0 + ) + selectedSection = jobs.isEmpty ? .launcher : .downloads + } catch { + diagnostics.insert( + DiagnosticReport( + title: "修复任务生成失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["刷新版本列表", "重新生成安装计划"] + ), + at: 0 + ) + } + } + + func refreshJavaRuntimes() async { + isScanningJava = true + defer { isScanningJava = false } + do { + let runtimes = try await javaRuntimeService.discoverInstalledRuntimes() + javaRuntimes = runtimes + selectRecommendedJavaRuntime() + diagnostics.insert( + DiagnosticReport( + title: "Java 运行时已刷新", + severity: runtimes.isEmpty ? .warning : .info, + summary: runtimes.isEmpty ? "没有发现可用的 Java 运行时。" : "发现 \(runtimes.count) 个 Java 运行时。", + suggestedActions: runtimes.isEmpty ? ["安装 Temurin Java 21", "刷新后重新选择实例"] : ["确认实例使用推荐 Java 版本"] + ), + at: 0 + ) + } catch { + javaRuntimes = [] + selectedJavaRuntimeID = nil + diagnostics.insert( + DiagnosticReport( + title: "Java 扫描失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["确认 /usr/libexec/java_home 可用", "手动安装 Java 后重试"] + ), + at: 0 + ) + } + } + + // MARK: - Portable JDK Install + + func installJDK(majorVersion: Int) async { + isInstallingJDK = true + jdkInstallProgress = 0 + defer { isInstallingJDK = false } + + let archString: String + #if arch(arm64) + archString = "aarch64" + #else + archString = "x64" + #endif + + let downloadURL = URL(string: "https://api.adoptium.net/v3/binary/latest/\(majorVersion)/ga/mac/\(archString)/jdk/hotspot/normal/eclipse?project=jdk")! + let targetDir = javaRuntimeService.portableJDKDirectory + let fileManager = FileManager.default + + do { + try fileManager.createDirectory(at: targetDir, withIntermediateDirectories: true) + let tarFile = targetDir.appendingPathComponent("jdk-\(majorVersion).tar.gz") + + // Download to temp, then move + let (tempURL, _) = try await URLSession.shared.download(from: downloadURL) + try? fileManager.removeItem(at: tarFile) + try fileManager.moveItem(at: tempURL, to: tarFile) + + jdkInstallProgress = 0.6 + + // Extract + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/tar") + process.arguments = ["xzf", tarFile.path, "-C", targetDir.path] + let pipe = Pipe() + process.standardError = pipe + try process.run() + process.waitUntilExit() + + // Cleanup tar + try? fileManager.removeItem(at: tarFile) + + jdkInstallProgress = 1.0 + + // Rescan + await refreshJavaRuntimes() + + diagnostics.insert( + DiagnosticReport( + title: "Java \(majorVersion) 安装完成", + severity: .info, + summary: "便携版 JDK \(majorVersion) 已安装到 \(targetDir.path)", + suggestedActions: [] + ), + at: 0 + ) + } catch { + diagnostics.insert( + DiagnosticReport( + title: "Java 安装失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["检查网络连接", "重试"] + ), + at: 0 + ) + } + } + + func removePortableJDK(at url: URL) { + let fileManager = FileManager.default + // Find the JDK directory from the executable URL (go up to bin, then to JDK root) + let jdkHome = url.deletingLastPathComponent().deletingLastPathComponent() + do { + try fileManager.removeItem(at: jdkHome) + Task { await refreshJavaRuntimes() } + } catch { + diagnostics.insert( + DiagnosticReport( + title: "删除 Java 失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: [] + ), + at: 0 + ) + } + } + + private func selectRecommendedJavaRuntime() { + guard let selectedInstance else { + selectedJavaRuntimeID = javaRuntimes.first?.id + return + } + let recommendedMajor = javaRuntimeService.recommendedMajorVersion(for: selectedInstance.gameVersion) + selectedJavaRuntimeID = javaRuntimes.first { $0.majorVersion == recommendedMajor }?.id ?? javaRuntimes.first?.id + } + + func planVanillaInstall(metadata: VersionMetadata, assetIndex: AssetIndex? = nil, for instance: LauncherInstance) { + plannedVersionMetadata = metadata + plannedInstanceID = instance.id + do { + _ = try downloadService.writeVersionMetadata(metadata: metadata, instance: instance) + } catch { + diagnostics.insert( + DiagnosticReport( + title: "版本元数据写入失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["检查实例目录权限", "重新创建实例后再生成安装计划"] + ), + at: 0 + ) + } + var jobs = downloadService.makeVanillaInstallJobs( + metadata: metadata, + instance: instance, + source: selectedDownloadSource + ) + if let assetIndex { + let groupID = jobs.first?.taskGroupID + let groupName = jobs.first?.taskGroupName + jobs.append(contentsOf: downloadService.makeAssetObjectJobs( + assetIndex: assetIndex, + instance: instance, + source: selectedDownloadSource, + taskGroupID: groupID, + taskGroupName: groupName + )) + } + downloadJobs = jobs + diagnostics.insert( + DiagnosticReport( + title: "已生成 Vanilla 安装计划", + severity: .info, + summary: "已为 \(instance.name) 生成 \(downloadJobs.count) 个下载任务。", + suggestedActions: ["打开下载中心检查任务", "点击开始下载执行任务并校验 SHA-1"] + ), + at: 0 + ) + selectedSection = .downloads + } + + func installFabricLoader(for instance: LauncherInstance) async { + do { + let metadata = try await fabricService.installFabric( + gameVersion: instance.gameVersion, + loaderVersion: nil, + instance: instance + ) + plannedVersionMetadata = metadata + plannedInstanceID = instance.id + + // Generate install jobs for the new metadata + let jobs = downloadService.makeVanillaInstallJobs( + metadata: metadata, + instance: instance, + source: selectedDownloadSource + ) + downloadJobs = jobs + updateInstanceStatus(instance.id, status: .missingFiles) + diagnostics.insert( + DiagnosticReport( + title: "Fabric loader 已安装", + severity: .info, + summary: "已为 \(instance.name) 安装 Fabric loader,生成 \(jobs.count) 个下载任务。", + suggestedActions: ["打开下载中心执行任务", "下载完成后启动游戏"] + ), + at: 0 + ) + selectedSection = .downloads + } catch { + diagnostics.insert( + DiagnosticReport( + title: "Fabric loader 安装失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["确认已安装基础版本", "检查网络连接"] + ), + at: 0 + ) + } + } + + func installQuiltLoader(for instance: LauncherInstance) async { + do { + let metadata = try await quiltService.installQuilt(gameVersion: instance.gameVersion, loaderVersion: nil, instance: instance) + plannedVersionMetadata = metadata + plannedInstanceID = instance.id + let jobs = downloadService.makeVanillaInstallJobs(metadata: metadata, instance: instance, source: selectedDownloadSource) + downloadJobs = jobs + updateInstanceStatus(instance.id, status: .missingFiles) + diagnostics.insert(DiagnosticReport(title: "Quilt loader 已安装", severity: .info, summary: "已为 \(instance.name) 安装 Quilt loader,生成 \(jobs.count) 个下载任务。", suggestedActions: ["打开下载中心执行任务"]), at: 0) + selectedSection = .downloads + } catch { + diagnostics.insert(DiagnosticReport(title: "Quilt loader 安装失败", severity: .error, summary: error.localizedDescription, suggestedActions: ["确认已安装基础版本", "检查网络连接"]), at: 0) + } + } + + func installForgeLoader(for instance: LauncherInstance) async { + do { + let metadata = try await forgeService.installForge(gameVersion: instance.gameVersion, forgeVersion: nil, instance: instance) + plannedVersionMetadata = metadata + plannedInstanceID = instance.id + let jobs = downloadService.makeVanillaInstallJobs(metadata: metadata, instance: instance, source: selectedDownloadSource) + downloadJobs = jobs + updateInstanceStatus(instance.id, status: .missingFiles) + diagnostics.insert(DiagnosticReport(title: "Forge 已安装", severity: .info, summary: "已为 \(instance.name) 安装 Forge,生成 \(jobs.count) 个下载任务。", suggestedActions: ["打开下载中心执行任务"]), at: 0) + selectedSection = .downloads + } catch { + diagnostics.insert(DiagnosticReport(title: "Forge 安装失败", severity: .error, summary: error.localizedDescription, suggestedActions: ["确认已安装基础版本", "检查网络连接"]), at: 0) + } + } + + func installNeoForgeLoader(for instance: LauncherInstance) async { + do { + let metadata = try await neoForgeService.installNeoForge(gameVersion: instance.gameVersion, version: nil, instance: instance) + plannedVersionMetadata = metadata + plannedInstanceID = instance.id + let jobs = downloadService.makeVanillaInstallJobs(metadata: metadata, instance: instance, source: selectedDownloadSource) + downloadJobs = jobs + updateInstanceStatus(instance.id, status: .missingFiles) + diagnostics.insert(DiagnosticReport(title: "NeoForge 已安装", severity: .info, summary: "已为 \(instance.name) 安装 NeoForge,生成 \(jobs.count) 个下载任务。", suggestedActions: ["打开下载中心执行任务"]), at: 0) + selectedSection = .downloads + } catch { + diagnostics.insert(DiagnosticReport(title: "NeoForge 安装失败", severity: .error, summary: error.localizedDescription, suggestedActions: ["确认已安装基础版本", "检查网络连接"]), at: 0) + } + } + + func analyzeCrash(for instance: LauncherInstance) { + if let report = diagnosticService.analyzeLatestCrash(instance: instance) { + diagnostics.insert(report, at: 0) + } else { + diagnostics.insert( + DiagnosticReport(title: "未发现崩溃", severity: .info, summary: "最近日志中没有找到崩溃报告。", suggestedActions: []), + at: 0 + ) + } + } + + func refreshAvailableVersions() async { + do { + let manifest = try await versionService.fetchManifest(from: nil) + availableVersions = manifest.versions + diagnostics.insert( + DiagnosticReport( + title: "版本列表已刷新", + severity: .info, + summary: "已从 Mojang manifest 获取 \(manifest.versions.count) 个版本。", + suggestedActions: ["选择实例后生成 Vanilla 安装计划"] + ), + at: 0 + ) + } catch { + diagnostics.insert( + DiagnosticReport( + title: "版本列表刷新失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["检查网络连接", "稍后重试"] + ), + at: 0 + ) + } + } + + func searchModrinth(query: String) async { + modrinthSearchQuery = query + guard !query.trimmingCharacters(in: .whitespaces).isEmpty else { + modrinthSearchResults = [] + return + } + do { + let response = try await modrinthService.search(query: query, facets: nil, index: "relevance", offset: 0) + modrinthSearchResults = response.hits + } catch { + diagnostics.insert( + DiagnosticReport( + title: "Modrinth 搜索失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["检查网络连接", "稍后重试"] + ), + at: 0 + ) + } + } + + func searchCurseForge(query: String) async { + curseForgeSearchQuery = query + guard !query.trimmingCharacters(in: .whitespaces).isEmpty else { + curseForgeResults = [] + return + } + do { + curseForgeResults = try await curseForgeService.search(query: query, classId: nil, gameVersion: selectedInstance?.gameVersion, apiKey: curseForgeApiKey) + } catch { + diagnostics.insert( + DiagnosticReport(title: "CurseForge 搜索失败", severity: .error, summary: error.localizedDescription, suggestedActions: []), + at: 0 + ) + } + } + + func checkForUpdates() async { + guard let url = URL(string: "https://api.github.com/repos/Lhy723/MMCL/releases/latest") else { return } + do { + let (data, _) = try await URLSession.shared.data(from: url) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let tagName = json?["tag_name"] as? String ?? "" + let version = tagName.replacingOccurrences(of: "v", with: "") + if version != currentVersion { + latestVersion = version + updateAvailable = true + + // Find downloadable asset (DMG or ZIP) + if let assets = json?["assets"] as? [[String: Any]] { + updateDownloadURL = assets.compactMap { asset -> URL? in + guard let name = asset["name"] as? String, + let browserURL = asset["browser_download_url"] as? String, + name.hasSuffix(".dmg") || name.hasSuffix(".zip"), + let url = URL(string: browserURL) else { return nil } + return url + }.first + } + + diagnostics.insert( + DiagnosticReport(title: "发现新版本", severity: .info, summary: "最新版本 \(version),当前版本 \(currentVersion)。", suggestedActions: ["点击「下载更新」获取最新版本"]), + at: 0 + ) + } + } catch { + // Silent fail for update check + } + } + + func downloadAndInstallUpdate() async { + guard let downloadURL = updateDownloadURL else { return } + isDownloadingUpdate = true + defer { isDownloadingUpdate = false } + + do { + let (tempURL, _) = try await URLSession.shared.download(from: downloadURL) + let fileName = downloadURL.lastPathComponent + let destURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: destURL.path) { + try FileManager.default.removeItem(at: destURL) + } + try FileManager.default.moveItem(at: tempURL, to: destURL) + + // Open the downloaded file (DMG mounts, ZIP opens in Finder) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = [destURL.path] + try process.run() + + diagnostics.insert( + DiagnosticReport( + title: "更新已下载", + severity: .info, + summary: "\(fileName) 已下载到临时目录并打开。请按照提示完成安装。", + suggestedActions: [] + ), + at: 0 + ) + } catch { + diagnostics.insert( + DiagnosticReport( + title: "更新下载失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["检查网络连接后重试", "前往 GitHub 手动下载"] + ), + at: 0 + ) + } + } + + func runDiagnostics() async { + diagnostics.removeAll() + + let runtime = selectedJavaRuntime + + for instance in instances { + // Check Java version mismatch + if let runtime { + if let report = diagnosticService.javaMismatch(instance: instance, runtime: runtime) { + diagnostics.append(report) + } + } + + // Analyze crash logs + if let report = diagnosticService.analyzeLatestCrash(instance: instance) { + diagnostics.append(report) + } + } + + // Check for missing instances directory + let instancesDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("MMCL/Instances", isDirectory: true) + if !FileManager.default.fileExists(atPath: instancesDir.path) { + diagnostics.append(DiagnosticReport( + title: "实例目录不存在", + severity: .warning, + summary: "实例目录 \(instancesDir.path) 不存在,将自动创建。", + suggestedActions: ["无需操作,启动器会自动创建"] + )) + try? FileManager.default.createDirectory(at: instancesDir, withIntermediateDirectories: true) + } + + if diagnostics.isEmpty { + diagnostics.append(DiagnosticReport( + title: "一切正常", + severity: .info, + summary: "未发现任何问题。\(instances.count) 个实例状态良好。", + suggestedActions: [] + )) + } + } + + func installModrinthMod(version: ModrinthVersion, file: ModrinthFile, for instance: LauncherInstance) async { + let modsDir = instance.rootDirectory.appendingPathComponent("mods", isDirectory: true) + let destination = modsDir.appendingPathComponent(file.filename) + do { + try await modrinthService.downloadFile(from: file.url, to: destination) + diagnostics.insert( + DiagnosticReport( + title: "Mod 已安装", + severity: .info, + summary: "\(version.name) 已下载到 \(instance.name) 的 mods 目录。", + suggestedActions: ["启动游戏加载 mod"] + ), + at: 0 + ) + } catch { + diagnostics.insert( + DiagnosticReport( + title: "Mod 下载失败", + severity: .error, + summary: "\(file.filename):\(error.localizedDescription)", + suggestedActions: ["检查网络连接", "重新尝试下载"] + ), + at: 0 + ) + } + } + + func planVanillaInstallFromRemoteMetadata(for instance: LauncherInstance) async { + guard let version = availableVersions.first(where: { $0.id == instance.gameVersion }) else { + diagnostics.insert( + DiagnosticReport( + title: "未找到版本元数据", + severity: .error, + summary: "版本列表中没有 \(instance.gameVersion)。", + suggestedActions: ["先刷新版本列表", "检查实例版本号是否正确"] + ), + at: 0 + ) + return + } + + do { + let metadata = try await versionService.fetchVersionMetadata(from: version.metadataURL) + let assetIndex = try await versionService.fetchAssetIndex(from: metadata.assetIndex.url) + planVanillaInstall(metadata: metadata, assetIndex: assetIndex, for: instance) + } catch { + diagnostics.insert( + DiagnosticReport( + title: "版本元数据获取失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["检查网络连接", "重新刷新版本列表"] + ), + at: 0 + ) + } + } + + func expandAssetIndexDownloads(assetIndexURL: URL, for instance: LauncherInstance) throws { + let data = try Data(contentsOf: assetIndexURL) + let assetIndex = try versionService.decodeAssetIndex(from: data) + let groupID = UUID() + let groupName = "\(instance.name) 资源文件" + let assetJobs = downloadService.makeAssetObjectJobs( + assetIndex: assetIndex, + instance: instance, + source: selectedDownloadSource, + taskGroupID: groupID, + taskGroupName: groupName + ) + downloadJobs = assetJobs + diagnostics.insert( + DiagnosticReport( + title: "已展开资源文件", + severity: .info, + summary: "已从 asset index 生成 \(assetJobs.count) 个资源任务,共 \(assetIndex.totalBytes) 字节。", + suggestedActions: ["点击开始下载执行资源任务", "下载完成后即可进入 native 解压和启动阶段"] + ), + at: 0 + ) + selectedSection = .downloads + } + + func expandSelectedInstanceAssetIndex() { + guard let selectedInstance else { + diagnostics.insert( + DiagnosticReport( + title: "未选择实例", + severity: .error, + summary: "需要先选择一个实例才能展开资源索引。", + suggestedActions: ["从侧边栏选择实例"] + ), + at: 0 + ) + return + } + + let assetIndexURL = selectedInstance.rootDirectory + .appendingPathComponent(".minecraft", isDirectory: true) + .appendingPathComponent("assets", isDirectory: true) + .appendingPathComponent("indexes", isDirectory: true) + .appendingPathComponent("\(selectedInstance.gameVersion).json") + + do { + try expandAssetIndexDownloads(assetIndexURL: assetIndexURL, for: selectedInstance) + } catch { + diagnostics.insert( + DiagnosticReport( + title: "资源索引展开失败", + severity: .error, + summary: "\(assetIndexURL.path):\(error.localizedDescription)", + suggestedActions: ["先下载资源索引任务", "确认实例版本号和 asset index 文件名一致"] + ), + at: 0 + ) + } + } + + func executeQueuedDownloads() async { + let queuedIDs = downloadJobs.filter { $0.status == .queued }.map(\.id) + guard !queuedIDs.isEmpty else { return } + + speedTracker.reset() + + downloadService.onProgress = { [weak self] jobID, bytesWritten in + Task { @MainActor in + guard let self else { return } + if let index = self.downloadJobs.firstIndex(where: { $0.id == jobID }) { + self.downloadJobs[index].completedBytes = bytesWritten + } + } + } + + downloadService.onComplete = { [weak self] jobID, completedJob in + Task { @MainActor in + guard let self else { return } + if let index = self.downloadJobs.firstIndex(where: { $0.id == jobID }) { + self.downloadJobs[index] = completedJob + } + self.speedTracker.addBytes(completedJob.totalBytes) + self.activeDownloadCount -= 1 + self.startNextQueuedDownloadIfNeeded() + if self.activeDownloadCount <= 0 { + self.finalizeDownloadedPlanIfPossible() + } + } + } + + downloadService.onError = { [weak self] jobID, error in + Task { @MainActor in + guard let self else { return } + if let index = self.downloadJobs.firstIndex(where: { $0.id == jobID }) { + self.downloadJobs[index].status = .failed + } + self.diagnostics.insert( + DiagnosticReport( + title: "下载失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["检查网络连接和下载源", "重新生成安装计划后重试"] + ), + at: 0 + ) + self.activeDownloadCount -= 1 + self.startNextQueuedDownloadIfNeeded() + if self.activeDownloadCount <= 0 { + self.finalizeDownloadedPlanIfPossible() + } + } + } + + var started = 0 + for id in queuedIDs { + if let index = downloadJobs.firstIndex(where: { $0.id == id }) { + downloadJobs[index].status = .running + downloadService.startDownload(downloadJobs[index]) + started += 1 + if started >= maxDownloadThreads { break } + } + } + activeDownloadCount = started + queuedDownloadIDs = Array(queuedIDs.dropFirst(started)) + } + + func startNextQueuedDownloadIfNeeded() { + guard !queuedDownloadIDs.isEmpty else { return } + guard activeDownloadCount < maxDownloadThreads else { return } + let nextID = queuedDownloadIDs.removeFirst() + if let index = downloadJobs.firstIndex(where: { $0.id == nextID }) { + downloadJobs[index].status = .running + downloadService.startDownload(downloadJobs[index]) + activeDownloadCount += 1 + } + } + + func pauseDownloads() { + for index in downloadJobs.indices { + if downloadJobs[index].status == .running { + downloadService.pauseDownload(id: downloadJobs[index].id) + downloadJobs[index].status = .paused + } + } + diagnostics.insert( + DiagnosticReport( + title: "下载已暂停", + severity: .info, + summary: "所有进行中的下载任务已暂停。", + suggestedActions: ["点击「继续下载」恢复"] + ), + at: 0 + ) + } + + func resumeDownloads() { + for index in downloadJobs.indices { + if downloadJobs[index].status == .paused { + downloadService.resumeDownload(id: downloadJobs[index].id) + downloadJobs[index].status = .running + } + } + } + + func cancelDownloads() { + downloadService.cancelAllDownloads() + for index in downloadJobs.indices { + if downloadJobs[index].status.isActive { + downloadJobs[index].status = .failed + } + } + queuedDownloadIDs.removeAll() + activeDownloadCount = 0 + diagnostics.insert( + DiagnosticReport( + title: "下载已取消", + severity: .info, + summary: "所有排队和进行中的下载任务已取消。", + suggestedActions: [] + ), + at: 0 + ) + } + + func pauseJob(id: UUID) { + guard let index = downloadJobs.firstIndex(where: { $0.id == id }) else { return } + if downloadJobs[index].status == .running { + downloadService.pauseDownload(id: id) + downloadJobs[index].status = .paused + } + } + + func resumeJob(id: UUID) { + guard let index = downloadJobs.firstIndex(where: { $0.id == id }) else { return } + if downloadJobs[index].status == .paused { + downloadService.resumeDownload(id: id) + downloadJobs[index].status = .running + } + } + + func cancelJob(id: UUID) { + guard let index = downloadJobs.firstIndex(where: { $0.id == id }) else { return } + if downloadJobs[index].status.isActive { + downloadService.cancelDownload(id: id) + downloadJobs[index].status = .failed + } + } + + func pauseGroup(_ group: DownloadTaskGroup) { + for job in group.jobs where job.status == .running { + pauseJob(id: job.id) + } + } + + func resumeGroup(_ group: DownloadTaskGroup) { + for job in group.jobs where job.status == .paused { + resumeJob(id: job.id) + } + } + + func cancelGroup(_ group: DownloadTaskGroup) { + for job in group.jobs where job.status.isActive { + cancelJob(id: job.id) + } + } + + func createInstanceAndDownload( + name: String? = nil, + gameVersion: String, + loader: GameLoader, + memory: Int? = nil, + username: String? = nil, + jvmArgs: [String] = [] + ) async { + // Check for existing instance with same version + loader + if let existing = instances.first(where: { $0.gameVersion == gameVersion && $0.loader == loader }) { + launcherSelectedInstanceID = existing.id + selectedSection = .downloads + selectedDownloadTab = .progress + await planVanillaInstallFromRemoteMetadata(for: existing) + if !downloadJobs.isEmpty { + await executeQueuedDownloads() + } + return + } + + createInstance( + name: name ?? "\(gameVersion) \(loader.rawValue)", + gameVersion: gameVersion, + loader: loader, + memory: memory, + username: username, + jvmArgs: jvmArgs + ) + guard let instance = instances.last else { return } + launcherSelectedInstanceID = instance.id + selectedSection = .downloads + selectedDownloadTab = .progress + await planVanillaInstallFromRemoteMetadata(for: instance) + if !downloadJobs.isEmpty { + await executeQueuedDownloads() + } + } + + func prepareNativeLibrariesForSelectedInstance() { + guard let instance = selectedInstance ?? instances.first(where: { $0.id == plannedInstanceID }) else { + diagnostics.insert( + DiagnosticReport( + title: "未选择实例", + severity: .error, + summary: "需要先选择实例才能准备 native libraries。", + suggestedActions: ["从侧边栏选择实例"] + ), + at: 0 + ) + return + } + + guard let plannedVersionMetadata else { + diagnostics.insert( + DiagnosticReport( + title: "缺少版本元数据", + severity: .error, + summary: "需要先生成安装计划,才能知道要解压哪些 native libraries。", + suggestedActions: ["点击生成安装计划", "完成下载后再准备 native libraries"] + ), + at: 0 + ) + return + } + + do { + let archives = try downloadService.prepareNativeLibraries( + metadata: plannedVersionMetadata, + instance: instance + ) + updateInstanceStatus(instance.id, status: .ready) + diagnostics.insert( + DiagnosticReport( + title: "Native libraries 已准备", + severity: .info, + summary: "已解压 \(archives.count) 个 native library,实例 \(instance.name) 已标记为可启动。", + suggestedActions: ["点击启动进入游戏", "如启动失败,查看 latest.log"] + ), + at: 0 + ) + } catch { + diagnostics.insert( + DiagnosticReport( + title: "Native libraries 准备失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["确认下载任务已全部完成", "重新生成安装计划并下载 native library"] + ), + at: 0 + ) + } + } + + func monitorLaunchSession() { + guard let session = currentLaunchSession else { return } + let pid = session.processIdentifier + + Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] timer in + let result = kill(pid, 0) + if result != 0 { + DispatchQueue.main.async { + guard let self else { return } + timer.invalidate() + let exitTime = Date() + let duration = exitTime.timeIntervalSince(session.startedAt) + let minutes = Int(duration) / 60 + let seconds = Int(duration) % 60 + self.currentLaunchSession = nil + self.diagnostics.insert( + DiagnosticReport( + title: "Minecraft 已退出", + severity: .info, + summary: "进程 \(pid) 已退出,运行时长 \(minutes)分\(seconds)秒。日志:\(session.logFileURL.path)", + suggestedActions: ["打开日志查看退出原因", "如果异常退出,检查 Java 版本和内存设置"] + ), + at: 0 + ) + } + } + } + } + + private func updateInstanceStatus(_ id: LauncherInstance.ID, status: InstanceStatus) { + guard let index = instances.firstIndex(where: { $0.id == id }) else { return } + instances[index].status = status + persistInstance(at: index) + } + + private func finalizeDownloadedPlanIfPossible() { + guard downloadJobs.contains(where: { $0.status == .completed }) else { return } + guard !downloadJobs.contains(where: { $0.status == .queued || $0.status == .running || $0.status == .failed }) else { + return + } + guard let plannedVersionMetadata, + let plannedInstanceID, + let instance = instances.first(where: { $0.id == plannedInstanceID }) + else { + return + } + + do { + let archives = try downloadService.prepareNativeLibraries( + metadata: plannedVersionMetadata, + instance: instance + ) + updateInstanceStatus(instance.id, status: .ready) + diagnostics.insert( + DiagnosticReport( + title: "安装收尾完成", + severity: .info, + summary: "下载任务已完成,已自动解压 \(archives.count) 个 native library,\(instance.name) 已标记为可启动。", + suggestedActions: ["点击启动进入游戏", "如启动失败,查看 latest.log"] + ), + at: 0 + ) + } catch { + updateInstanceStatus(instance.id, status: .missingFiles) + diagnostics.insert( + DiagnosticReport( + title: "安装收尾失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["确认 native library 下载完成", "手动点击准备 Native"] + ), + at: 0 + ) + } + } + + private func repairMetadata(for instance: LauncherInstance) async throws -> VersionMetadata { + if plannedInstanceID == instance.id, let plannedVersionMetadata { + return plannedVersionMetadata + } + + if let localMetadata = localVersionMetadata(for: instance) { + return localMetadata + } + + guard let version = availableVersions.first(where: { $0.id == instance.gameVersion }) else { + throw RepairPlanningError.missingVersionMetadata(instance.gameVersion) + } + + return try await versionService.fetchVersionMetadata(from: version.metadataURL) + } + + private func localVersionMetadata(for instance: LauncherInstance) -> VersionMetadata? { + let metadataURL = instance.rootDirectory + .appendingPathComponent(".minecraft", isDirectory: true) + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(instance.gameVersion, isDirectory: true) + .appendingPathComponent("\(instance.gameVersion).json") + guard let data = try? Data(contentsOf: metadataURL) else { return nil } + return try? JSONDecoder.mmcl.decode(VersionMetadata.self, from: data) + } +} + +enum RepairPlanningError: LocalizedError, Equatable { + case missingVersionMetadata(String) + + var errorDescription: String? { + switch self { + case .missingVersionMetadata(let version): + return "缺少 \(version) 的版本元数据。" + } + } +} + +// MARK: - Profile Export/Import + +extension LauncherStore { + private var profileExportService: ProfileExportServicing { ProfileExportService() } + + func exportProfile(to url: URL) { + let settings = ProfileExportSettings( + defaultMemoryMegabytes: defaultMemoryMegabytes, + defaultOfflineUsername: defaultOfflineUsername, + preferredDownloadSource: preferredDownloadSource, + defaultResolutionWidth: defaultResolutionWidth, + defaultResolutionHeight: defaultResolutionHeight, + jvmPresets: jvmPresets + ) + do { + let data = try profileExportService.exportProfile( + instances: instances, + accounts: accounts, + settings: settings + ) + try profileExportService.saveExport(data, to: url) + diagnostics.insert( + DiagnosticReport( + title: "配置已导出", + severity: .info, + summary: "已导出 \(instances.count) 个实例和 \(accounts.count) 个账号到 \(url.lastPathComponent)。", + suggestedActions: [] + ), + at: 0 + ) + } catch { + diagnostics.insert( + DiagnosticReport( + title: "导出失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["检查磁盘空间和目录权限"] + ), + at: 0 + ) + } + } + + func importProfile(from url: URL) { + do { + let data = try profileExportService.loadExport(from: url) + let export = try profileExportService.importProfile(from: data) + + // Merge instances (skip duplicates by name) + let existingNames = Set(instances.map(\.name)) + let newInstances = export.instances.filter { !existingNames.contains($0.name) } + instances.append(contentsOf: newInstances) + + // Merge accounts (skip duplicates by uuid) + let existingUUIDs = Set(accounts.map(\.uuid)) + let newAccounts = export.accounts.filter { !existingUUIDs.contains($0.uuid) } + accounts.append(contentsOf: newAccounts) + + // Apply settings + defaultMemoryMegabytes = export.settings.defaultMemoryMegabytes + defaultOfflineUsername = export.settings.defaultOfflineUsername + preferredDownloadSource = export.settings.preferredDownloadSource + defaultResolutionWidth = export.settings.defaultResolutionWidth + defaultResolutionHeight = export.settings.defaultResolutionHeight + jvmPresets = export.settings.jvmPresets + + diagnostics.insert( + DiagnosticReport( + title: "配置已导入", + severity: .info, + summary: "导入了 \(newInstances.count) 个新实例和 \(newAccounts.count) 个新账号。", + suggestedActions: [] + ), + at: 0 + ) + } catch { + diagnostics.insert( + DiagnosticReport( + title: "导入失败", + severity: .error, + summary: error.localizedDescription, + suggestedActions: ["检查文件格式是否正确"] + ), + at: 0 + ) + } + } +} + +// MARK: - Custom Background + +extension LauncherStore { + func setBackgroundImage(_ url: URL?) { + backgroundImage.url = url + } + + func setBackgroundOpacity(_ opacity: Double) { + backgroundImage.opacity = max(0, min(1, opacity)) + } + + func setBackgroundBlur(_ blur: CGFloat) { + backgroundImage.blurRadius = max(0, min(20, blur)) + } +} + +extension LauncherStore { + static let sampleInstances: [LauncherInstance] = { + let base = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/MMCL/Instances", isDirectory: true) + return [ + LauncherInstance( + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: base.appendingPathComponent("vanilla-survival"), + profile: LaunchProfile(offlineUsername: "Steve", memoryMegabytes: 4096, jvmArguments: ["-XX:+UseG1GC"], resolutionWidth: 854, resolutionHeight: 480), + status: .ready, + lastPlayedAt: Date(timeIntervalSinceNow: -3600) + ), + LauncherInstance( + name: "Fabric 科技包", + gameVersion: "1.20.1", + loader: .fabric, + rootDirectory: base.appendingPathComponent("fabric-tech"), + profile: LaunchProfile(offlineUsername: "Alex", memoryMegabytes: 6144, jvmArguments: ["-XX:+UseG1GC", "-Dfml.ignoreInvalidMinecraftCertificates=true"], resolutionWidth: 854, resolutionHeight: 480), + status: .missingFiles, + lastPlayedAt: Date(timeIntervalSinceNow: -86_400) + ), + LauncherInstance( + name: "快照测试", + gameVersion: "25w21a", + loader: .vanilla, + rootDirectory: base.appendingPathComponent("snapshot-lab"), + profile: LaunchProfile(offlineUsername: "Tester", memoryMegabytes: 3072, jvmArguments: [], resolutionWidth: 854, resolutionHeight: 480), + status: .needsJava, + lastPlayedAt: nil + ) + ] + }() + + static let sampleDownloadJobs: [DownloadJob] = [ + DownloadJob( + title: "Minecraft 1.21.5 资源文件", + source: .bmclapi, + destination: URL(fileURLWithPath: "/tmp/assets"), + totalBytes: 120_000_000, + completedBytes: 78_000_000, + status: .running + ), + DownloadJob( + title: "Fabric Loader 0.16", + source: .official, + destination: URL(fileURLWithPath: "/tmp/fabric"), + totalBytes: 6_000_000, + completedBytes: 6_000_000, + status: .completed + ) + ] + + static let sampleProjects: [ContentProject] = [ + ContentProject(id: "sodium", title: "Sodium", type: .mod, source: "Modrinth", gameVersions: ["1.21.5", "1.20.1"], loaders: [.fabric, .quilt]), + ContentProject(id: "iris", title: "Iris Shaders", type: .mod, source: "Modrinth", gameVersions: ["1.21.5"], loaders: [.fabric]), + ContentProject(id: "fabulously-optimized", title: "Fabulously Optimized", type: .modpack, source: "Modrinth", gameVersions: ["1.21.5"], loaders: [.fabric]) + ] + + static let sampleJavaRuntimes: [JavaRuntime] = [ + JavaRuntime( + name: "Temurin 21", + version: "21.0.3", + majorVersion: 21, + architecture: .arm64, + executableURL: URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java") + ), + JavaRuntime( + name: "Zulu 17", + version: "17.0.11", + majorVersion: 17, + architecture: .x86_64, + executableURL: URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/bin/java") + ) + ] + + static let sampleVersions: [MinecraftVersion] = [ + MinecraftVersion( + id: "1.21.5", + type: .release, + metadataURL: URL(string: "https://piston-meta.mojang.com/v1/packages/1.21.5.json")!, + releaseTime: Date(timeIntervalSince1970: 1_747_740_000), + recommendedJavaMajorVersion: 21 + ), + MinecraftVersion( + id: "1.20.1", + type: .release, + metadataURL: URL(string: "https://piston-meta.mojang.com/v1/packages/1.20.1.json")!, + releaseTime: Date(timeIntervalSince1970: 1_685_640_000), + recommendedJavaMajorVersion: 17 + ) + ] + + static let sampleVersionMetadata = VersionMetadata( + id: "1.21.5", + mainClass: "net.minecraft.client.main.Main", + assets: "19", + assetIndex: VersionMetadata.AssetIndex( + id: "19", + url: URL(string: "https://piston-meta.mojang.com/v1/packages/assets.json")!, + sha1: "asset-sha1", + size: 321 + ), + downloads: VersionMetadata.Downloads( + client: VersionMetadata.Download( + url: URL(string: "https://piston-data.mojang.com/v1/objects/client.jar")!, + sha1: "client-sha1", + size: 123 + ) + ), + libraries: [ + VersionMetadata.Library( + name: "org.lwjgl:lwjgl:3.3.3", + downloads: VersionMetadata.Library.Downloads( + artifact: VersionMetadata.Library.Artifact( + path: "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar", + url: URL(string: "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar")!, + sha1: "library-sha1", + size: 456 + ) + ) + ) + ] + ) + + static let sampleDiagnostics: [DiagnosticReport] = [ + DiagnosticReport( + title: "Fabric 科技包缺少资源库", + severity: .warning, + summary: "检测到 3 个 libraries 文件缺失,可能导致启动失败。", + suggestedActions: ["点击修复实例重新下载缺失文件", "确认下载源可访问"] + ), + DiagnosticReport( + title: "快照测试需要 Java 21", + severity: .error, + summary: "当前未选择可用的 Java 21 Apple Silicon 运行时。", + suggestedActions: ["安装 Temurin 21", "在设置中重新扫描 Java"] + ) + ] +} diff --git a/MMCL/Views/AnimationScale.swift b/MMCL/Views/AnimationScale.swift new file mode 100644 index 0000000..d181817 --- /dev/null +++ b/MMCL/Views/AnimationScale.swift @@ -0,0 +1,8 @@ +import SwiftUI + +extension Animation { + /// macOS 26 style spring animation, scaled by the user's duration preference. + static func mmclSpring(response: Double = 0.35, dampingFraction: Double = 0.85, scale: Double = 1.0) -> Animation { + .spring(response: response * scale, dampingFraction: dampingFraction) + } +} diff --git a/MMCL/Views/HelpView.swift b/MMCL/Views/HelpView.swift new file mode 100644 index 0000000..9f90144 --- /dev/null +++ b/MMCL/Views/HelpView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct HelpView: View { + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("MMCL 帮助") + .font(.largeTitle.weight(.semibold)) + + HelpSection(title: "快速开始", icon: "rocket") { + HelpItem(title: "创建实例", text: "点击工具栏「新增实例」或 Cmd+N,选择游戏版本和加载器。") + HelpItem(title: "安装游戏", text: "选择实例,点击「生成安装计划」,然后在下载中心开始下载。") + HelpItem(title: "启动游戏", text: "下载完成后,点击「启动」按钮。") + } + + HelpSection(title: "加载器", icon: "shippingbox") { + HelpItem(title: "Fabric", text: "现代轻量级加载器,推荐用于大多数 Mod。需要先安装原版。") + HelpItem(title: "Forge", text: "经典 Mod 加载器,兼容性最广。") + HelpItem(title: "NeoForge", text: "Forge 的社区分支,更新更活跃。") + HelpItem(title: "Quilt", text: "Fabric 的社区分支,正在发展中。") + } + + HelpSection(title: "Mod 管理", icon: "puzzlepiece.extension") { + HelpItem(title: "安装 Mod", text: "在 Modrinth 或 CurseForge 页面搜索,选择版本后点击安装。") + HelpItem(title: "管理 Mod", text: "在实例详情页点击「管理 Mod」,可以启用、禁用或删除 Mod。") + } + + HelpSection(title: "账号", icon: "person.circle") { + HelpItem(title: "离线登录", text: "在设置中添加离线账号,输入用户名即可。") + HelpItem(title: "正版登录", text: "点击「Microsoft 登录」,在浏览器中输入设备代码完成授权。") + } + + HelpSection(title: "故障排除", icon: "wrench") { + HelpItem(title: "检查实例", text: "点击「检查实例」可以诊断文件缺失和 Java 版本问题。") + HelpItem(title: "崩溃分析", text: "点击「崩溃分析」自动分析最新崩溃日志。") + HelpItem(title: "诊断日志", text: "在侧边栏打开诊断日志查看所有操作记录。") + } + } + .padding(24) + } + .frame(width: 600, height: 500) + } +} + +private struct HelpSection: View { + let title: String + let icon: String + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label(title, systemImage: icon) + .font(.title3.weight(.semibold)) + content + .padding(.leading, 20) + } + } +} + +private struct HelpItem: View { + let title: String + let text: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline.weight(.medium)) + Text(text) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } +} diff --git a/MMCL/Views/InstanceCreateSheet.swift b/MMCL/Views/InstanceCreateSheet.swift new file mode 100644 index 0000000..2d81313 --- /dev/null +++ b/MMCL/Views/InstanceCreateSheet.swift @@ -0,0 +1,88 @@ +import SwiftUI + +struct InstanceCreateSheet: View { + @ObservedObject var store: LauncherStore + @State private var name: String = "新实例" + @State private var selectedVersionID: String = "" + @State private var loader: GameLoader = .vanilla + @State private var memory: Int = 4096 + @State private var username: String = "Steve" + @State private var jvmArgs: String = "" + @State private var appeared = false + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Text("新增实例") + .font(.largeTitle.weight(.semibold)) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : -8) + + Form { + Section("基本信息") { + TextField("实例名称", text: $name) + + Picker("游戏版本", selection: $selectedVersionID) { + ForEach(store.availableVersions) { version in + Text("\(version.id) · \(version.type.label)").tag(version.id) + } + } + + Picker("加载器", selection: $loader) { + ForEach(GameLoader.allCases) { gameLoader in + Text(gameLoader.rawValue).tag(gameLoader) + } + } + } + + Section("启动配置") { + Stepper("内存:\(memory) MB", value: $memory, in: 1024...16384, step: 512) + TextField("离线用户名", text: $username) + TextField("JVM 参数(可选)", text: $jvmArgs) + .font(.system(.body, design: .monospaced)) + } + } + .formStyle(.grouped) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 15) + + HStack { + Spacer() + Button("取消") { + store.showingCreateSheet = false + } + .keyboardShortcut(.cancelAction) + + Button("创建并下载") { + let args = jvmArgs + .split(separator: " ") + .map(String.init) + .filter { !$0.isEmpty } + Task { + await store.createInstanceAndDownload( + name: name, + gameVersion: selectedVersionID, + loader: loader, + memory: memory, + username: username, + jvmArgs: args + ) + } + } + .buttonStyle(.borderedProminent) + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || selectedVersionID.isEmpty) + .keyboardShortcut(.defaultAction) + } + .opacity(appeared ? 1 : 0) + } + .padding(24) + .frame(width: 480, height: 520, alignment: .top) + .onAppear { + if selectedVersionID.isEmpty { + selectedVersionID = store.availableVersions.first?.id ?? "" + } + withAnimation(.mmclSpring(response: 0.5, dampingFraction: 0.85, scale: store.animationDurationScale)) { + appeared = true + } + } + } +} diff --git a/MMCL/Views/InstanceDetailView.swift b/MMCL/Views/InstanceDetailView.swift new file mode 100644 index 0000000..2508686 --- /dev/null +++ b/MMCL/Views/InstanceDetailView.swift @@ -0,0 +1,315 @@ +import SwiftUI + +struct InstanceDetailView: View { + let instance: LauncherInstance + @ObservedObject var store: LauncherStore + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + .padding(.horizontal) + .padding(.top) + + List { + Section("启动配置") { + configurationContent + } + + Section("Java 运行时") { + runtimeContent + } + + Section("启动命令预览") { + launchPreviewContent + } + + Section("文件目录") { + directoriesContent + } + + Section("操作") { + operationsContent + } + } + .listStyle(.inset) + } + .navigationTitle(instance.name) + .accessibilityIdentifier("InstanceDetail") + } + + private var header: some View { + HStack(alignment: .top, spacing: 16) { + Image(systemName: "play.square.stack") + .font(.system(size: 38)) + .foregroundStyle(.tint) + .frame(width: 52, height: 52) + + VStack(alignment: .leading, spacing: 8) { + Text(instance.name) + .font(.largeTitle.weight(.semibold)) + Text(instance.subtitle) + .font(.title3) + .foregroundStyle(.secondary) + StatusPill(status: instance.status) + } + + Spacer() + } + .padding(.vertical, 4) + } + + private var configurationContent: some View { + Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 10) { + GridRow { + Text("离线用户名").foregroundStyle(.secondary) + Text(instance.profile.offlineUsername) + } + GridRow { + Text("内存").foregroundStyle(.secondary) + Text("\(instance.profile.memoryMegabytes) MB") + } + GridRow { + Text("JVM 参数").foregroundStyle(.secondary) + Text(instance.profile.jvmArguments.isEmpty ? "未设置" : instance.profile.jvmArguments.joined(separator: " ")) + } + GridRow { + Text("推荐 Java").foregroundStyle(.secondary) + Text("Java \(JavaRuntime.recommendedMajorVersion(for: instance.gameVersion))") + } + GridRow { + Text("上次游玩").foregroundStyle(.secondary) + Text(instance.lastPlayedAt.map(Self.dateFormatter.string(from:)) ?? "从未启动") + } + } + } + + private var runtimeContent: some View { + VStack(alignment: .leading, spacing: 10) { + Picker("运行时", selection: $store.selectedJavaRuntimeID) { + ForEach(store.javaRuntimes) { runtime in + Text(runtime.displayName).tag(Optional(runtime.id)) + } + } + .pickerStyle(.menu) + + Button { + Task { await store.refreshJavaRuntimes() } + } label: { + if store.isScanningJava { + ProgressView() + .controlSize(.small) + } else { + Label("重新扫描 Java", systemImage: "arrow.clockwise") + } + } + .buttonStyle(.bordered) + .disabled(store.isScanningJava) + + if let runtime = store.selectedJavaRuntime { + Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 10) { + GridRow { + Text("版本").foregroundStyle(.secondary) + Text(runtime.version) + } + GridRow { + Text("架构").foregroundStyle(.secondary) + Text(runtime.architecture.label) + } + GridRow { + Text("路径").foregroundStyle(.secondary) + Text(runtime.executableURL.path).textSelection(.enabled) + } + GridRow { + Text("匹配状态").foregroundStyle(.secondary) + Text(runtime.isRecommended(for: instance.gameVersion) ? "推荐" : "不推荐") + } + } + } else { + Text("尚未发现 Java 运行时。") + .foregroundStyle(.secondary) + } + } + } + + private var launchPreviewContent: some View { + Group { + if let preview = store.launchPreviewForSelectedInstance() { + VStack(alignment: .leading, spacing: 8) { + Text(preview.commandLine) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(8) + } + } else { + Text("请选择实例和 Java 运行时以生成启动预览。") + .foregroundStyle(.secondary) + } + } + } + + private var directoriesContent: some View { + VStack(alignment: .leading, spacing: 8) { + PathRow(title: "实例目录", path: instance.rootDirectory.path) + PathRow(title: "Minecraft", path: instance.rootDirectory.appendingPathComponent(".minecraft").path) + PathRow(title: "日志", path: instance.rootDirectory.appendingPathComponent("logs").path) + PathRow(title: "模组", path: instance.rootDirectory.appendingPathComponent("mods").path) + } + } + + private var operationsContent: some View { + VStack(alignment: .leading, spacing: 10) { + // Primary actions + HStack(spacing: 12) { + Button { + store.launchSelectedInstance() + } label: { + Label("启动游戏", systemImage: "play.fill") + } + .buttonStyle(.borderedProminent) + .disabled(store.selectedJavaRuntime == nil) + + Button { + store.showingLogSheet = true + } label: { + Label("查看日志", systemImage: "doc.text.magnifyingglass") + } + .buttonStyle(.bordered) + + Button { + store.showingModList = true + } label: { + Label("管理 Mod", systemImage: "puzzlepiece.extension") + } + .buttonStyle(.bordered) + + Button { + store.showingResourcePacks = true + } label: { + Label("资源包", systemImage: "photo") + } + .buttonStyle(.bordered) + } + + // Secondary actions + HStack(spacing: 12) { + Button { + store.showingShaderPacks = true + } label: { + Label("管理光影", systemImage: "sun.max") + } + .buttonStyle(.bordered) + + Button { + store.analyzeCrash(for: instance) + } label: { + Label("崩溃分析", systemImage: "exclamationmark.triangle") + } + .buttonStyle(.bordered) + + Button { + Task { await store.planVanillaInstallFromRemoteMetadata(for: instance) } + } label: { + Label("生成安装计划", systemImage: "list.bullet.clipboard") + } + .buttonStyle(.bordered) + + Button { + store.prepareNativeLibrariesForSelectedInstance() + } label: { + Label("准备 Native", systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + .disabled(store.plannedVersionMetadata == nil) + } + + // Loader-specific actions + if instance.loader == .fabric || instance.loader == .quilt || instance.loader == .forge { + Divider() + HStack(spacing: 12) { + if instance.loader == .fabric { + Button { Task { await store.installFabricLoader(for: instance) } } label: { + Label("安装 Fabric", systemImage: "shippingbox") + } + .buttonStyle(.bordered) + } + if instance.loader == .quilt { + Button { Task { await store.installQuiltLoader(for: instance) } } label: { + Label("安装 Quilt", systemImage: "shippingbox") + } + .buttonStyle(.bordered) + } + if instance.loader == .forge { + Button { Task { await store.installForgeLoader(for: instance) } } label: { + Label("安装 Forge", systemImage: "hammer") + } + .buttonStyle(.bordered) + Button { Task { await store.installNeoForgeLoader(for: instance) } } label: { + Label("安装 NeoForge", systemImage: "hammer.fill") + } + .buttonStyle(.bordered) + } + } + } + + // Launch session info + if let session = store.currentLaunchSession { + Divider() + Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 10) { + GridRow { + Text("最近进程").foregroundStyle(.secondary) + Text("\(session.processIdentifier)") + } + GridRow { + Text("启动日志").foregroundStyle(.secondary) + Text(session.logFileURL.path).textSelection(.enabled) + } + } + } + } + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() +} + +private struct StatusPill: View { + let status: InstanceStatus + + var body: some View { + Label(status.label, systemImage: iconName) + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.quaternary, in: Capsule()) + } + + private var iconName: String { + switch status { + case .ready: return "checkmark.circle.fill" + case .missingFiles: return "exclamationmark.triangle.fill" + case .needsJava: return "cup.and.saucer.fill" + case .notInstalled: return "arrow.down.circle.fill" + } + } +} + +private struct PathRow: View { + let title: String + let path: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text(path) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(2) + } + } +} diff --git a/MMCL/Views/InstanceRenameSheet.swift b/MMCL/Views/InstanceRenameSheet.swift new file mode 100644 index 0000000..ade44bb --- /dev/null +++ b/MMCL/Views/InstanceRenameSheet.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct InstanceRenameSheet: View { + let instance: LauncherInstance + @ObservedObject var store: LauncherStore + @State private var newName: String + + init(instance: LauncherInstance, store: LauncherStore) { + self.instance = instance + self.store = store + _newName = State(initialValue: instance.name) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("重命名实例") + .font(.headline) + TextField("新名称", text: $newName) + .textFieldStyle(.roundedBorder) + HStack { + Spacer() + Button("取消") { store.showingRenameSheet = false } + .keyboardShortcut(.cancelAction) + Button("确定") { + store.renameInstance(instance, to: newName) + store.showingRenameSheet = false + } + .buttonStyle(.borderedProminent) + .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty) + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 350) + } +} diff --git a/MMCL/Views/InstanceSettingsView.swift b/MMCL/Views/InstanceSettingsView.swift new file mode 100644 index 0000000..f77591a --- /dev/null +++ b/MMCL/Views/InstanceSettingsView.swift @@ -0,0 +1,222 @@ +import SwiftUI + +struct InstanceSettingsView: View { + let instance: LauncherInstance + @ObservedObject var store: LauncherStore + @State private var appeared = false + + @State private var offlineUsername: String + @State private var memoryMegabytes: Int + @State private var jvmArgumentsText: String + + init(instance: LauncherInstance, store: LauncherStore) { + self.instance = instance + self.store = store + _offlineUsername = State(initialValue: instance.profile.offlineUsername) + _memoryMegabytes = State(initialValue: instance.profile.memoryMegabytes) + _jvmArgumentsText = State(initialValue: instance.profile.jvmArguments.joined(separator: " ")) + } + + var body: some View { + List { + basicInfoSection + javaSection + launchConfigSection + managementSection + advancedSection + } + .listStyle(.inset) + .navigationTitle(instance.name) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 8) + .onAppear { + withAnimation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale)) { + appeared = true + } + } + .toolbar { + ToolbarItem(placement: .navigation) { + Button { + store.selectedInstanceSettingsID = nil + } label: { + Label("返回", systemImage: "chevron.left") + } + } + } + } + + // MARK: - Basic Info + + private var basicInfoSection: some View { + Section("基本信息") { + LabeledContent("版本", value: instance.gameVersion) + LabeledContent("加载器", value: instance.loader.rawValue) + LabeledContent("状态", value: instance.status.label) + LabeledContent("推荐 Java", value: "Java \(JavaRuntime.recommendedMajorVersion(for: instance.gameVersion))") + if let date = instance.lastPlayedAt { + LabeledContent("上次游玩", value: Self.dateFormatter.string(from: date)) + } + } + } + + // MARK: - Java Runtime + + private var javaSection: some View { + Section("Java 运行时") { + Picker("运行时", selection: $store.selectedJavaRuntimeID) { + Text("自动选择").tag(JavaRuntime.ID?.none) + ForEach(store.javaRuntimes) { runtime in + Text(runtime.displayName).tag(Optional(runtime.id)) + } + } + .pickerStyle(.menu) + + if let runtime = store.selectedJavaRuntime { + LabeledContent("版本", value: runtime.version) + LabeledContent("架构", value: runtime.architecture.label) + LabeledContent("匹配", value: runtime.isRecommended(for: instance.gameVersion) ? "推荐" : "不推荐") + .foregroundStyle(runtime.isRecommended(for: instance.gameVersion) ? .green : .orange) + } else { + Text("未发现 Java 运行时") + .foregroundStyle(.secondary) + } + + Button { + Task { await store.refreshJavaRuntimes() } + } label: { + Label("重新扫描", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(store.isScanningJava) + } + } + + // MARK: - Launch Config + + private var launchConfigSection: some View { + Section("启动配置") { + TextField("离线用户名", text: $offlineUsername) + .onChange(of: offlineUsername) { _, _ in + saveProfile() + } + + HStack { + Text("内存") + Spacer() + TextField("MB", value: $memoryMegabytes, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 80) + .multilineTextAlignment(.trailing) + .onChange(of: memoryMegabytes) { _, _ in + saveProfile() + } + Text("MB") + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 4) { + Text("JVM 参数") + .foregroundStyle(.secondary) + TextField("-XX:+UseG1GC ...", text: $jvmArgumentsText) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + .onChange(of: jvmArgumentsText) { _, _ in + saveProfile() + } + } + } + } + + // MARK: - Management + + private var managementSection: some View { + Section("内容管理") { + Button { + store.showingModList = true + } label: { + Label("管理 Mod", systemImage: "puzzlepiece.extension") + } + + Button { + store.showingResourcePacks = true + } label: { + Label("管理资源包", systemImage: "photo") + } + + Button { + store.showingShaderPacks = true + } label: { + Label("管理光影", systemImage: "sun.max") + } + } + } + + // MARK: - Advanced + + private var advancedSection: some View { + Section("操作") { + Button { + store.showingLogSheet = true + } label: { + Label("查看日志", systemImage: "doc.text.magnifyingglass") + } + + Button { + store.showingRenameSheet = true + } label: { + Label("重命名", systemImage: "pencil") + } + + if instance.loader == .fabric { + Button { Task { await store.installFabricLoader(for: instance) } } label: { + Label("安装 Fabric", systemImage: "shippingbox") + } + } + if instance.loader == .quilt { + Button { Task { await store.installQuiltLoader(for: instance) } } label: { + Label("安装 Quilt", systemImage: "shippingbox") + } + } + if instance.loader == .forge { + Button { Task { await store.installForgeLoader(for: instance) } } label: { + Label("安装 Forge", systemImage: "hammer") + } + Button { Task { await store.installNeoForgeLoader(for: instance) } } label: { + Label("安装 NeoForge", systemImage: "hammer.fill") + } + } + + Divider() + + Button(role: .destructive) { + store.deleteInstance(instance) + store.selectedInstanceSettingsID = nil + } label: { + Label("删除实例", systemImage: "trash") + } + } + } + + // MARK: - Helpers + + private func saveProfile() { + let args = jvmArgumentsText + .components(separatedBy: .whitespaces) + .filter { !$0.isEmpty } + store.saveInstanceProfile(instance, profile: LaunchProfile( + offlineUsername: offlineUsername, + memoryMegabytes: max(512, memoryMegabytes), + jvmArguments: args, + resolutionWidth: instance.profile.resolutionWidth, + resolutionHeight: instance.profile.resolutionHeight + )) + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() +} diff --git a/MMCL/Views/JDKInstallSheet.swift b/MMCL/Views/JDKInstallSheet.swift new file mode 100644 index 0000000..72b27eb --- /dev/null +++ b/MMCL/Views/JDKInstallSheet.swift @@ -0,0 +1,116 @@ +import SwiftUI + +struct JDKInstallSheet: View { + @ObservedObject var store: LauncherStore + @Environment(\.dismiss) private var dismiss + + @State private var selectedVersion: Int = 21 + private let availableVersions = [8, 17, 21] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("安装 Java") + .font(.title2.weight(.semibold)) + Spacer() + Button("完成") { dismiss() } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + + Divider() + + installSection + + Divider() + + installedSection + } + .padding(20) + .frame(minWidth: 480, minHeight: 400, alignment: .top) + } + + // MARK: - Install + + private var installSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("从 Adoptium 下载便携版 JDK") + .font(.headline) + + Text("安装到:\(store.portableJDKDirectory.path)") + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + + Picker("版本", selection: $selectedVersion) { + ForEach(availableVersions, id: \.self) { version in + Text("Java \(version)").tag(version) + } + } + .pickerStyle(.segmented) + + if store.isInstallingJDK { + VStack(spacing: 6) { + ProgressView(value: store.jdkInstallProgress) + Text("正在下载并解压...") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + Button { + Task { await store.installJDK(majorVersion: selectedVersion) } + } label: { + Label("安装 Java \(selectedVersion)", systemImage: "arrow.down.circle") + } + .buttonStyle(.borderedProminent) + } + } + } + + // MARK: - Installed + + private var installedSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("已安装的便携版 JDK") + .font(.headline) + + let portableRuntimes = store.javaRuntimes.filter { runtime in + runtime.name.hasPrefix("便携版") + } + + if portableRuntimes.isEmpty { + Text("暂无已安装的便携版 JDK") + .foregroundStyle(.secondary) + .font(.subheadline) + } else { + ForEach(portableRuntimes) { runtime in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Java \(runtime.majorVersion)") + .font(.subheadline.weight(.medium)) + Text(runtime.version) + .font(.caption) + .foregroundStyle(.secondary) + Text(runtime.executableURL.deletingLastPathComponent().deletingLastPathComponent().path) + .font(.caption2) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + } + Spacer() + Button(role: .destructive) { + store.removePortableJDK(at: runtime.executableURL) + } label: { + Label("删除", systemImage: "trash") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.vertical, 4) + if runtime.id != portableRuntimes.last?.id { + Divider() + } + } + } + } + } +} diff --git a/MMCL/Views/LauncherView.swift b/MMCL/Views/LauncherView.swift new file mode 100644 index 0000000..8aa2868 --- /dev/null +++ b/MMCL/Views/LauncherView.swift @@ -0,0 +1,238 @@ +import SwiftUI + +struct LauncherView: View { + @ObservedObject var store: LauncherStore + @State private var launchPulse = false + @State private var appeared = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + .padding(.horizontal) + .padding(.top) + + if let instance = store.selectedInstance { + instanceCard(instance) + .id(instance.id) + .padding(.horizontal) + .padding(.top, 16) + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + Spacer() + launchButton + .padding(.horizontal) + .padding(.bottom) + } else { + ContentUnavailableView( + store.instances.isEmpty ? "没有实例" : "未选择实例", + systemImage: "cube.box", + description: Text(store.instances.isEmpty ? "前往下载中心创建你的第一个实例" : "从上方下拉菜单选择一个实例") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity) + Spacer() + } + } + .animation(.mmclSpring(response: 0.5, dampingFraction: 0.8, scale: store.animationDurationScale), value: store.launcherSelectedInstanceID) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 8) + .onAppear { + withAnimation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale)) { + appeared = true + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle("启动器") + .onChange(of: store.selectedInstance?.status) { _, newStatus in + if newStatus == .ready { + withAnimation(.easeOut(duration: 0.6).repeatCount(2, autoreverses: true)) { + launchPulse = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + launchPulse = false + } + } + } + } + + // MARK: - Header + + private var header: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 14) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .frame(width: 48, height: 48) + .clipShape(RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 4) { + Text("启动器") + .font(.largeTitle.weight(.semibold)) + } + Spacer() + Button { + store.openGitHubRepo() + } label: { + Image(systemName: "link.circle") + .font(.title2) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("GitHub 仓库") + } + instancePicker + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var instancePicker: some View { + Menu { + Button("未选择") { store.launcherSelectedInstanceID = nil } + ForEach(store.instances) { instance in + Button { + store.launcherSelectedInstanceID = instance.id + } label: { + HStack { + Text(instance.name) + if store.launcherSelectedInstanceID == instance.id { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 6) { + Text(store.selectedInstance?.name ?? "未选择") + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.caption) + } + .controlSize(.large) + .frame(maxWidth: 400, alignment: .leading) + } + } + + // MARK: - Instance Card + + private func instanceCard(_ instance: LauncherInstance) -> some View { + VStack(spacing: 12) { + HStack { + Image(instance.blockIcon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 36, height: 36) + .frame(width: 40, height: 40) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 2) { + Text(instance.name) + .font(.title3.weight(.semibold)) + Text(instance.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + Button { + store.selectedInstanceSettingsID = instance.id + } label: { + Image(systemName: "gearshape") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + HStack(spacing: 16) { + statusChip(instance) + if let date = instance.lastPlayedAt { + Label(Self.dateFormatter.string(from: date), systemImage: "clock") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if instance.status == .ready { + Label("已就绪", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } + } + + if instance.status != .ready { + downloadPrompt(instance) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .padding() + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(.quaternary, lineWidth: 1) + ) + } + + private func downloadPrompt(_ instance: LauncherInstance) -> some View { + VStack(spacing: 8) { + Text("需要下载游戏文件才能启动") + .font(.caption) + .foregroundStyle(.secondary) + Button { + store.selectedSection = .downloads + } label: { + Label("前往下载中心", systemImage: "arrow.down.circle") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } + + private func statusChip(_ instance: LauncherInstance) -> some View { + Text(instance.status.label) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(statusColor(instance.status).opacity(0.15), in: Capsule()) + .foregroundStyle(statusColor(instance.status)) + .transition(.scale.combined(with: .opacity)) + .animation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale), value: instance.status) + } + + private func statusColor(_ status: InstanceStatus) -> Color { + switch status { + case .notInstalled: return .orange + case .missingFiles: return .orange + case .needsJava: return .orange + case .ready: return .green + } + } + + // MARK: - Buttons + + private var launchButton: some View { + Button { + store.launchSelectedInstance() + } label: { + Label("启动游戏", systemImage: "play.fill") + .font(.title3.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .scaleEffect(launchPulse ? 1.02 : 1.0) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(store.selectedInstance == nil || store.selectedJavaRuntime == nil || store.selectedInstance?.status != .ready) + } + + // MARK: - Helpers + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() +} diff --git a/MMCL/Views/LogViewerSheet.swift b/MMCL/Views/LogViewerSheet.swift new file mode 100644 index 0000000..2d146d0 --- /dev/null +++ b/MMCL/Views/LogViewerSheet.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct LogViewerSheet: View { + let instance: LauncherInstance + @ObservedObject var store: LauncherStore + @State private var logContent: String = "" + @State private var timer: Timer? + @State private var appeared = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("启动日志 — \(instance.name)") + .font(.headline) + Spacer() + Button { + logContent = store.loadLogContent(for: instance) + } label: { + Label("刷新", systemImage: "arrow.clockwise") + } + } + + ScrollViewReader { proxy in + ScrollView { + Text(logContent) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .id("bottom") + .frame(maxWidth: .infinity, alignment: .leading) + } + .onAppear { + logContent = store.loadLogContent(for: instance) + proxy.scrollTo("bottom", anchor: .bottom) + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + let newContent = store.loadLogContent(for: instance) + if newContent != logContent { + logContent = newContent + DispatchQueue.main.async { + proxy.scrollTo("bottom", anchor: .bottom) + } + } + } + } + .onDisappear { + timer?.invalidate() + timer = nil + } + } + .background(.black.opacity(0.05), in: RoundedRectangle(cornerRadius: 6)) + + HStack { + Spacer() + Button("关闭") { + store.showingLogSheet = false + } + .keyboardShortcut(.cancelAction) + } + } + .padding(20) + .frame(width: 700, height: 500, alignment: .top) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 8) + .onAppear { + withAnimation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale)) { + appeared = true + } + } + } +} diff --git a/MMCL/Views/ModListView.swift b/MMCL/Views/ModListView.swift new file mode 100644 index 0000000..ad0c43e --- /dev/null +++ b/MMCL/Views/ModListView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct ModListView: View { + let instance: LauncherInstance + @ObservedObject var store: LauncherStore + @State private var mods: [ModInfo] = [] + @State private var appeared = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("已安装 Mod") + .font(.headline) + Spacer() + Button { + mods = store.scanInstalledMods(for: instance) + } label: { + Label("刷新", systemImage: "arrow.clockwise") + } + } + + if mods.isEmpty { + ContentUnavailableView("没有已安装的 Mod", systemImage: "puzzlepiece.extension", description: Text("从 Modrinth 下载 Mod 或手动放入 mods 目录")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(mods) { mod in + HStack { + VStack(alignment: .leading) { + Text(mod.fileName) + .font(.body) + Text(ByteCountFormatter.string(fromByteCount: mod.size, countStyle: .file)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Toggle("", isOn: Binding( + get: { mod.isEnabled }, + set: { _ in + store.toggleMod(for: instance, mod: mod) + mods = store.scanInstalledMods(for: instance) + } + )) + .toggleStyle(.switch) + .labelsHidden() + Button(role: .destructive) { + store.deleteMod(for: instance, mod: mod) + mods = store.scanInstalledMods(for: instance) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.plain) + } + } + } + } + .padding(16) + .frame(minWidth: 500, minHeight: 400, alignment: .top) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 8) + .onAppear { + mods = store.scanInstalledMods(for: instance) + withAnimation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale)) { + appeared = true + } + } + } +} diff --git a/MMCL/Views/ModrinthProjectDetailView.swift b/MMCL/Views/ModrinthProjectDetailView.swift new file mode 100644 index 0000000..c396a2a --- /dev/null +++ b/MMCL/Views/ModrinthProjectDetailView.swift @@ -0,0 +1,145 @@ +import SwiftUI + +struct ModrinthProjectDetailView: View { + let project: ModrinthSearchResult + @ObservedObject var store: LauncherStore + @State private var versions: [ModrinthVersion] = [] + @State private var visibleIDs: Set = [] + @State private var isLoading = true + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text(project.title) + .font(.largeTitle.weight(.semibold)) + Text(project.description) + .foregroundStyle(.secondary) + HStack(spacing: 12) { + Label("\(project.downloads)", systemImage: "arrow.down") + .foregroundStyle(.secondary) + Text(project.projectType) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.quaternary, in: Capsule()) + } + .font(.caption) + } + + if isLoading { + ProgressView("加载版本列表...") + } else if versions.isEmpty { + ContentUnavailableView("没有可用版本", systemImage: "package", description: Text("此项目没有与当前实例兼容的版本。")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Text("可用版本") + .font(.headline) + + if store.selectedInstance == nil { + ContentUnavailableView("未选择实例", systemImage: "person.crop.circle.badge.questionmark", description: Text("请先在启动器页面选择一个实例")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(versions) { version in + ModrinthVersionRow(version: version) { + if let file = version.files.first(where: { $0.primary }) ?? version.files.first, + let instance = store.selectedInstance { + Task { + await store.installModrinthMod(version: version, file: file, for: instance) + store.showingModrinthDetail = false + } + } + } + .opacity(visibleIDs.contains(version.id) ? 1 : 0) + .offset(x: visibleIDs.contains(version.id) ? 0 : 20) + .onAppear { + if !visibleIDs.contains(version.id) { + withAnimation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale)) { + visibleIDs.insert(version.id) + } + } + } + .onDisappear { + visibleIDs.remove(version.id) + } + } + } + } + .frame(maxHeight: 300) + } + } + + Spacer() + + HStack { + Spacer() + Button("关闭") { + store.showingModrinthDetail = false + } + .keyboardShortcut(.cancelAction) + } + } + .padding(20) + .frame(width: 550, height: 480, alignment: .top) + .task { + await loadVersions() + } + } + + private func loadVersions() async { + isLoading = true + do { + let loaderFilter: String? = store.selectedInstance.flatMap { instance in + loaderName(for: instance.loader) + } + versions = try await store.modrinthService.fetchVersions( + projectID: project.id, + gameVersion: store.selectedInstance?.gameVersion, + loader: loaderFilter + ) + } catch { + versions = [] + } + isLoading = false + } + + private func loaderName(for loader: GameLoader) -> String? { + switch loader { + case .vanilla: return nil + case .fabric: return "fabric" + case .quilt: return "quilt" + case .forge: return "forge" + } + } +} + +private struct ModrinthVersionRow: View { + let version: ModrinthVersion + let onInstall: () -> Void + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(version.name) + .font(.headline) + Text(version.versionNumber) + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 8) { + Text(version.loaders.joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.secondary) + Text(version.gameVersions.joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Button("安装") { + onInstall() + } + .buttonStyle(.bordered) + } + .padding(.vertical, 4) + } +} diff --git a/MMCL/Views/ResourcePackListView.swift b/MMCL/Views/ResourcePackListView.swift new file mode 100644 index 0000000..8720524 --- /dev/null +++ b/MMCL/Views/ResourcePackListView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct ResourcePackListView: View { + let instance: LauncherInstance + @ObservedObject var store: LauncherStore + @State private var packs: [ResourcePackInfo] = [] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("资源包管理") + .font(.headline) + Spacer() + Button { + packs = store.scanResourcePacks(for: instance) + } label: { + Label("刷新", systemImage: "arrow.clockwise") + } + } + + if packs.isEmpty { + ContentUnavailableView("没有已安装的资源包", systemImage: "photo", description: Text("将资源包放入 .minecraft/resourcepacks 目录")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(packs) { pack in + HStack { + VStack(alignment: .leading) { + Text(pack.fileName) + .font(.body) + Text(ByteCountFormatter.string(fromByteCount: pack.size, countStyle: .file)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button(role: .destructive) { + store.deleteResourcePack(for: instance, pack: pack) + packs = store.scanResourcePacks(for: instance) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.plain) + } + } + } + } + .padding(16) + .frame(minWidth: 500, minHeight: 400, alignment: .top) + .onAppear { packs = store.scanResourcePacks(for: instance) } + } +} diff --git a/MMCL/Views/ServerListView.swift b/MMCL/Views/ServerListView.swift new file mode 100644 index 0000000..c26d0f0 --- /dev/null +++ b/MMCL/Views/ServerListView.swift @@ -0,0 +1,220 @@ +import SwiftUI + +struct ServerListView: View { + @ObservedObject var store: LauncherStore + @State private var showingAddSheet = false + @State private var newServerName: String = "" + @State private var newServerAddress: String = "" + @State private var newServerPort: String = "25565" + @State private var editingServerID: UUID? + @State private var appeared = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + .padding(.horizontal) + .padding(.top) + toolbar + .padding(.horizontal) + .padding(.top, 8) + serverList + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle("服务器列表") + .frame(maxHeight: .infinity, alignment: .top) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 8) + .onAppear { + withAnimation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale)) { + appeared = true + } + } + .sheet(isPresented: $showingAddSheet) { + VStack(alignment: .leading, spacing: 18) { + Text(editingServerID == nil ? "添加服务器" : "编辑服务器") + .font(.title2.weight(.semibold)) + + Form { + Section("服务器信息") { + TextField("名称", text: $newServerName) + TextField("地址", text: $newServerAddress) + .font(.system(.body, design: .monospaced)) + TextField("端口", text: $newServerPort) + .font(.system(.body, design: .monospaced)) + } + } + .formStyle(.grouped) + + HStack { + Spacer() + Button("取消") { + showingAddSheet = false + } + .keyboardShortcut(.cancelAction) + + Button(editingServerID == nil ? "添加" : "保存") { + guard let instance = store.selectedInstance else { return } + let port = Int(newServerPort) ?? 25565 + if let editID = editingServerID, + let index = store.serverList.firstIndex(where: { $0.id == editID }) { + var updated = store.serverList[index] + updated.name = newServerName + updated.address = newServerAddress + updated.port = port + store.updateServer(updated, for: instance) + } else { + store.addServer( + name: newServerName, + address: newServerAddress, + port: port, + for: instance + ) + } + showingAddSheet = false + } + .buttonStyle(.borderedProminent) + .disabled(newServerName.trimmingCharacters(in: .whitespaces).isEmpty || + newServerAddress.trimmingCharacters(in: .whitespaces).isEmpty) + .keyboardShortcut(.defaultAction) + } + } + .padding(24) + .frame(width: 420, height: 320) + } + .onAppear { + if let instance = store.selectedInstance { + store.loadServerList(for: instance) + } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("服务器列表") + .font(.largeTitle.weight(.semibold)) + if let instance = store.selectedInstance { + Text("实例:\(instance.name)") + .foregroundStyle(.secondary) + } else { + Text("管理多人游戏服务器列表。") + .foregroundStyle(.secondary) + } + } + } + + private var toolbar: some View { + HStack(spacing: 12) { + Button { + showingAddSheet = true + newServerName = "" + newServerAddress = "" + newServerPort = "25565" + editingServerID = nil + } label: { + Label("添加服务器", systemImage: "plus") + } + .buttonStyle(.borderedProminent) + + Button { + store.pingAllServers() + } label: { + Label("全部 Ping", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + + Spacer() + } + } + + private var serverList: some View { + Group { + if store.serverList.isEmpty { + ContentUnavailableView("暂无服务器", systemImage: "server.rack", description: Text("点击「添加服务器」开始")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(sortedServers) { server in + ServerRow(server: server) { + if let instance = store.selectedInstance { + store.deleteServer(server, for: instance) + } + } onPing: { + store.pingServer(server) + } onEdit: { + newServerName = server.name + newServerAddress = server.address + newServerPort = "\(server.port)" + editingServerID = server.id + showingAddSheet = true + } onToggleFavorite: { + if let instance = store.selectedInstance { + store.toggleServerFavorite(server, for: instance) + } + } + } + } + .listStyle(.inset) + } + } + } + + private var sortedServers: [ServerInfo] { + store.serverList.sorted { a, b in + if a.isFavorite != b.isFavorite { return a.isFavorite } + return a.name < b.name + } + } +} + +private struct ServerRow: View { + let server: ServerInfo + let onDelete: () -> Void + let onPing: () -> Void + let onEdit: () -> Void + let onToggleFavorite: () -> Void + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + if server.isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + } + Text(server.name) + .font(.headline) + } + Text(server.fullAddress) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Spacer() + + if let ping = server.pingResult { + VStack(alignment: .trailing, spacing: 2) { + Text("\(ping.playerCount)/\(ping.maxPlayers)") + .font(.caption) + Text("\(ping.pingMs) ms") + .font(.caption) + .foregroundStyle(ping.pingMs < 100 ? .green : ping.pingMs < 300 ? .orange : .red) + } + } else { + Text("未 Ping") + .font(.caption) + .foregroundStyle(.secondary) + } + + Menu { + Button("Ping") { onPing() } + Button("编辑") { onEdit() } + Button(server.isFavorite ? "取消收藏" : "收藏") { onToggleFavorite() } + Divider() + Button("删除", role: .destructive) { onDelete() } + } label: { + Image(systemName: "ellipsis.circle") + } + } + .padding(.vertical, 4) + } +} diff --git a/MMCL/Views/ShaderPackListView.swift b/MMCL/Views/ShaderPackListView.swift new file mode 100644 index 0000000..212f654 --- /dev/null +++ b/MMCL/Views/ShaderPackListView.swift @@ -0,0 +1,43 @@ +import SwiftUI + +struct ShaderPackListView: View { + let instance: LauncherInstance + @ObservedObject var store: LauncherStore + @State private var packs: [ShaderPackInfo] = [] + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("已安装光影包") + .font(.headline) + Spacer() + Button { + packs = store.scanShaderPacks(for: instance) + } label: { Label("刷新", systemImage: "arrow.clockwise") } + } + if packs.isEmpty { + ContentUnavailableView("没有已安装的光影包", systemImage: "sun.max", description: Text("手动将光影包放入 shaderpacks 目录")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(packs) { pack in + HStack { + VStack(alignment: .leading) { + Text(pack.fileName).font(.body) + Text(ByteCountFormatter.string(fromByteCount: pack.size, countStyle: .file)) + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button(role: .destructive) { + store.deleteShaderPack(for: instance, pack: pack) + packs = store.scanShaderPacks(for: instance) + } label: { Image(systemName: "trash") } + .buttonStyle(.plain) + } + } + } + } + .padding(16) + .frame(minWidth: 500, minHeight: 400, alignment: .top) + .onAppear { packs = store.scanShaderPacks(for: instance) } + } +} diff --git a/MMCL/Views/SidebarView.swift b/MMCL/Views/SidebarView.swift new file mode 100644 index 0000000..1a57972 --- /dev/null +++ b/MMCL/Views/SidebarView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct SidebarView: View { + @ObservedObject var store: LauncherStore + + var body: some View { + List(selection: $store.selectedSection) { + Section("启动") { + Label("启动器", systemImage: "play.square.stack") + .tag(LauncherStore.Section.launcher) + } + + Section("工作区") { + Label("下载中心", systemImage: "arrow.down.circle") + .tag(LauncherStore.Section.downloads) + Label("诊断日志", systemImage: "stethoscope") + .tag(LauncherStore.Section.diagnostics) + Label("皮肤管理", systemImage: "figure.stand") + .tag(LauncherStore.Section.skin) + Label("服务器列表", systemImage: "server.rack") + .tag(LauncherStore.Section.serverList) + Label("设置", systemImage: "gearshape") + .tag(LauncherStore.Section.settings) + } + } + .listStyle(.sidebar) + .navigationTitle("MMCL") + .accessibilityIdentifier("MMCLSidebar") + } +} diff --git a/MMCL/Views/SkinPickerView.swift b/MMCL/Views/SkinPickerView.swift new file mode 100644 index 0000000..13e768b --- /dev/null +++ b/MMCL/Views/SkinPickerView.swift @@ -0,0 +1,142 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct SkinPickerView: View { + @ObservedObject var store: LauncherStore + @State private var newSkinName: String = "" + @State private var newSkinModel: SkinInfo.SkinModel = .steve + @State private var showFilePicker = false + @State private var appeared = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + .padding(.horizontal) + .padding(.top) + importBar + .padding(.horizontal) + .padding(.top, 8) + skinGrid + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle("皮肤管理") + .frame(maxHeight: .infinity, alignment: .top) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 8) + .onAppear { + withAnimation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale)) { + appeared = true + } + } + .onAppear { + if let account = store.selectedAccount { + store.scanSkinsForAccount(account) + } + } + .fileImporter( + isPresented: $showFilePicker, + allowedContentTypes: [UTType.png] + ) { result in + if case .success(let url) = result { + store.importSkinFromPicker( + sourceURL: url, + name: newSkinName, + model: newSkinModel + ) + newSkinName = "" + } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("皮肤管理") + .font(.largeTitle.weight(.semibold)) + if let account = store.selectedAccount { + Text("当前账号:\(account.displayName)") + .foregroundStyle(.secondary) + } else { + Text("管理 Minecraft 玩家皮肤。") + .foregroundStyle(.secondary) + } + } + } + + private var importBar: some View { + HStack(spacing: 12) { + TextField("皮肤名称", text: $newSkinName) + .textFieldStyle(.roundedBorder) + + Picker("模型", selection: $newSkinModel) { + ForEach(SkinInfo.SkinModel.allCases, id: \.self) { model in + Text(model.label).tag(model) + } + } + .pickerStyle(.menu) + + Button { + showFilePicker = true + } label: { + Label("导入皮肤", systemImage: "plus.circle") + } + .buttonStyle(.borderedProminent) + .disabled(newSkinName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + + private var skinGrid: some View { + Group { + if store.availableSkins.isEmpty { + ContentUnavailableView("暂无皮肤", systemImage: "person.crop.rectangle", description: Text("输入名称并导入 PNG 皮肤文件")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(store.availableSkins) { skin in + SkinRow(skin: skin) { + store.applySkin(skin) + } + } + .listStyle(.inset) + } + } + } +} + +private struct SkinRow: View { + let skin: SkinInfo + let onApply: () -> Void + + var body: some View { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 6) + .fill(skin.model == .alex ? Color.blue.opacity(0.2) : Color.green.opacity(0.2)) + .frame(width: 48, height: 64) + .overlay( + Image(systemName: "person.fill") + .foregroundStyle(.secondary) + ) + + VStack(alignment: .leading, spacing: 2) { + Text(skin.name) + .font(.headline) + Text(skin.model.label) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if skin.isApplied { + Label("已应用", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } else { + Button("应用") { + onApply() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(.vertical, 4) + } +} diff --git a/MMCL/Views/WorkspaceViews.swift b/MMCL/Views/WorkspaceViews.swift new file mode 100644 index 0000000..ec96aa4 --- /dev/null +++ b/MMCL/Views/WorkspaceViews.swift @@ -0,0 +1,679 @@ +import SwiftUI +import AppKit +import UniformTypeIdentifiers + +struct DiagnosticsView: View { + @ObservedObject var store: LauncherStore + @State private var selectedSeverity: DiagnosticSeverity? = nil + @State private var appeared = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + .padding(.horizontal) + .padding(.top) + filterBar + .padding(.horizontal) + .padding(.top, 8) + reportList + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle("诊断日志") + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 8) + .onAppear { + withAnimation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale)) { + appeared = true + } + } + .frame(maxHeight: .infinity, alignment: .top) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("诊断日志") + .font(.largeTitle.weight(.semibold)) + Text("自动聚合 Java、下载、实例文件和 Mod 冲突问题。") + .foregroundStyle(.secondary) + } + } + + private var filterBar: some View { + HStack(spacing: 12) { + Picker("严重程度", selection: $selectedSeverity) { + Text("全部").tag(DiagnosticSeverity?.none) + ForEach(DiagnosticSeverity.allCases) { severity in + Text(severity.localized).tag(Optional(severity)) + } + } + .pickerStyle(.menu) + + Spacer() + + Button { + Task { + await store.runDiagnostics() + } + } label: { + Label("运行诊断", systemImage: "stethoscope") + } + .buttonStyle(.bordered) + + if !store.diagnostics.isEmpty { + Button(role: .destructive) { + store.diagnostics.removeAll() + } label: { + Label("清空", systemImage: "trash") + } + .buttonStyle(.bordered) + } + } + } + + private var reportList: some View { + Group { + if filteredReports.isEmpty { + ContentUnavailableView( + store.diagnostics.isEmpty ? "暂无诊断报告" : "没有匹配的报告", + systemImage: store.diagnostics.isEmpty ? "checkmark.shield" : "line.3.horizontal.decrease.circle", + description: Text(store.diagnostics.isEmpty ? "运行诊断以检查潜在问题" : "试试其他筛选条件") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(filteredReports) { report in + DiagnosticReportRow(report: report) + } + .listStyle(.inset) + } + } + } + + private var filteredReports: [DiagnosticReport] { + if let severity = selectedSeverity { + return store.diagnostics.filter { $0.severity == severity } + } + return store.diagnostics + } +} + +private struct DiagnosticReportRow: View { + let report: DiagnosticReport + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: iconName) + .foregroundStyle(iconColor) + Text(report.title) + .font(.headline) + Spacer() + Text(report.localizedSeverity) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + Text(report.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + if !report.suggestedActions.isEmpty { + ForEach(report.suggestedActions, id: \.self) { action in + Label(action, systemImage: "checkmark.circle") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 4) + } + + private var iconName: String { + switch report.severity { + case .info: return "info.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .error: return "xmark.octagon.fill" + } + } + + private var iconColor: Color { + switch report.severity { + case .info: return .blue + case .warning: return .orange + case .error: return .red + } + } +} + +struct SettingsView: View { + @ObservedObject var store: LauncherStore + @State private var selectedTab = "launch" + @State private var appeared = false + + var body: some View { + TabView(selection: $selectedTab) { + LaunchSettingsTab(store: store) + .tabItem { Label("启动", systemImage: "play.fill") } + .tag("launch") + + PersonalizationSettingsTab(store: store) + .tabItem { Label("个性化", systemImage: "paintbrush") } + .tag("personalization") + + OtherSettingsTab(store: store) + .tabItem { Label("其他", systemImage: "ellipsis.circle") } + .tag("other") + } + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 8) + .onAppear { + withAnimation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale)) { + appeared = true + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } +} + +// MARK: - Launch Settings + +private struct LaunchSettingsTab: View { + @ObservedObject var store: LauncherStore + + var body: some View { + Form { + Section("启动选项") { + Picker("版本隔离", selection: $store.versionIsolation) { + ForEach(VersionIsolation.allCases) { v in + Text(v.rawValue).tag(v) + } + } + .help(store.versionIsolation.helpText) + + TextField("游戏窗口标题", text: $store.gameWindowTitle) + .textFieldStyle(.roundedBorder) + .help("留空使用默认标题") + + TextField("自定义信息", text: $store.customInfo) + .textFieldStyle(.roundedBorder) + .help("显示在启动器界面上的自定义文本") + + Picker("启动器可见性", selection: $store.launcherVisibility) { + ForEach(LauncherVisibility.allCases) { v in + Text(v.rawValue).tag(v) + } + } + + Picker("进程优先级", selection: $store.processPriority) { + ForEach(ProcessPriority.allCases) { p in + Text(p.rawValue).tag(p) + } + } + + Picker("窗口大小", selection: $store.windowSizeMode) { + ForEach(WindowSizeMode.allCases) { s in + Text(s.rawValue).tag(s) + } + } + if store.windowSizeMode == .custom { + HStack { + Text("尺寸") + TextField("宽", value: $store.defaultResolutionWidth, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 60) + Text("x") + TextField("高", value: $store.defaultResolutionHeight, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 60) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + + Section("游戏 Java") { + Picker("运行时", selection: $store.selectedJavaRuntimeID) { + Text("自动检测").tag(JavaRuntime.ID?.none) + ForEach(store.javaRuntimes) { runtime in + Text(runtime.displayName).tag(Optional(runtime.id)) + } + } + + Button { + Task { await store.refreshJavaRuntimes() } + } label: { + if store.isScanningJava { + ProgressView() + .controlSize(.small) + } else { + Label("重新扫描 Java", systemImage: "arrow.clockwise") + } + } + .disabled(store.isScanningJava) + + Button { + store.showingJDKInstall = true + } label: { + Label("安装 Java", systemImage: "arrow.down.circle") + } + + HStack { + Text("手动导入 Java 路径") + Spacer() + Button("选择") { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.begin { response in + if response == .OK, let url = panel.url { + store.customJavaPath = url.path + } + } + } + } + if !store.customJavaPath.isEmpty { + Text(store.customJavaPath) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + + Section("内存分配") { + Toggle("自动配置内存", isOn: $store.memoryAutoConfig) + .help("根据系统内存自动调整分配") + + if !store.memoryAutoConfig { + VStack(alignment: .leading, spacing: 4) { + Text("分配内存:\(store.defaultMemoryMegabytes) MB") + Slider(value: Binding( + get: { Double(store.defaultMemoryMegabytes) }, + set: { store.defaultMemoryMegabytes = Int($0) } + ), in: 512...32768, step: 256) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + + let totalBytes = ProcessInfo.processInfo.physicalMemory + let divisor: UInt64 = 1024 * 1024 + let totalMB = Int(totalBytes / divisor) + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("系统总内存") + Spacer() + Text("\(totalMB) MB") + .foregroundStyle(.secondary) + .monospacedDigit() + } + let allocFraction = min(Double(store.defaultMemoryMegabytes) / Double(totalMB), 1.0) + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.secondary.opacity(0.15)) + RoundedRectangle(cornerRadius: 4) + .fill(allocFraction > 0.85 ? Color.red : Color.accentColor) + .frame(width: geo.size.width * allocFraction) + } + } + .frame(height: 8) + .animation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale), value: store.defaultMemoryMegabytes) + Text("已分配 \(store.defaultMemoryMegabytes) MB(\(String(format: "%.0f", allocFraction * 100))%)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Section("高级选项") { + TextField("JVM 参数", text: Binding( + get: { store.jvmPresets.filter(\.isEnabled).flatMap(\.arguments).joined(separator: " ") }, + set: { _ in } + )) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + .disabled(true) + .help("在下方 JVM 预设中管理") + + TextField("游戏参数", text: $store.gameArguments) + .textFieldStyle(.roundedBorder) + .font(.system(.body, design: .monospaced)) + .help("额外的游戏启动参数") + + TextField("启动前执行命令", text: $store.preLaunchCommand) + .textFieldStyle(.roundedBorder) + .help("启动游戏前执行的 shell 命令") + + Toggle("使用高性能显卡", isOn: $store.useHighPerformanceGPU) + .help("macOS 会优先使用独立显卡") + + Section("JVM 预设") { + ForEach(store.jvmPresets) { preset in + HStack { + Toggle(preset.name, isOn: Binding( + get: { preset.isEnabled }, + set: { newValue in + if let idx = store.jvmPresets.firstIndex(where: { $0.id == preset.id }) { + store.jvmPresets[idx].isEnabled = newValue + } + } + )) + Spacer() + Text(preset.arguments.joined(separator: " ")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + } + } + .formStyle(.grouped) + .animation(.mmclSpring(response: 0.35, dampingFraction: 0.85, scale: store.animationDurationScale), value: store.windowSizeMode) + .animation(.mmclSpring(response: 0.35, dampingFraction: 0.85, scale: store.animationDurationScale), value: store.memoryAutoConfig) + } +} + +// MARK: - Personalization Settings + +private struct PersonalizationSettingsTab: View { + @ObservedObject var store: LauncherStore + + var body: some View { + Form { + Section("外观") { + Picker("配色方案", selection: $store.colorScheme) { + ForEach(AppColorScheme.allCases) { scheme in + Text(scheme.rawValue).tag(scheme) + } + } + + Button { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.image] + panel.begin { response in + if response == .OK, let url = panel.url { + store.setBackgroundImage(url) + } + } + } label: { + Label(store.backgroundImage.url != nil ? "更换背景" : "选择背景图片", systemImage: "photo") + } + + if store.backgroundImage.url != nil { + Button("移除背景") { + store.setBackgroundImage(nil) + } + .foregroundStyle(.red) + + Slider(value: Binding( + get: { store.backgroundImage.opacity }, + set: { store.setBackgroundOpacity($0) } + ), in: 0...1, step: 0.05) { + Text("不透明度:\(Int(store.backgroundImage.opacity * 100))%") + } + + Slider(value: Binding( + get: { Float(store.backgroundImage.blurRadius) }, + set: { store.setBackgroundBlur(CGFloat($0)) } + ), in: 0...20, step: 1) { + Text("模糊半径:\(Int(store.backgroundImage.blurRadius))") + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + + Section("动画") { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("动画时长") + Spacer() + Text(store.animationDurationScale == 0 ? "关闭" : + store.animationDurationScale == 0.5 ? "快" : + store.animationDurationScale == 1.0 ? "正常" : + store.animationDurationScale == 1.5 ? "慢" : "自定义") + .foregroundStyle(.secondary) + } + Slider(value: $store.animationDurationScale, in: 0...2, step: 0.25) + Text("0 = 关闭动画,1 = 正常,2 = 两倍时长") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Section("语言") { + Picker("界面语言", selection: $store.appLanguage) { + ForEach(AppLanguage.allCases) { lang in + Text(lang.rawValue).tag(lang) + } + } + } + + Section("账号") { + ForEach(store.accounts) { account in + AccountRow(account: account, store: store) + } + + Button("添加离线账号") { + store.addOfflineAccount(username: store.defaultOfflineUsername) + } + + Button { + Task { await store.startMicrosoftLogin() } + } label: { + if store.isLoggingIn { + ProgressView() + } else { + Label("Microsoft 登录", systemImage: "person.crop.circle.badge.checkmark") + } + } + .disabled(store.isLoggingIn) + + if !store.deviceCodeMessage.isEmpty { + Text(store.deviceCodeMessage) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + .formStyle(.grouped) + .animation(.mmclSpring(response: 0.35, dampingFraction: 0.85, scale: store.animationDurationScale), value: store.backgroundImage.url != nil) + } +} + +// MARK: - Other Settings + +private struct OtherSettingsTab: View { + @ObservedObject var store: LauncherStore + + var body: some View { + Form { + Section("下载") { + Picker("文件下载源", selection: $store.fileDownloadSourceMode) { + ForEach(FileDownloadSourceMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + + Picker("版本列表源", selection: $store.versionListSourceMode) { + ForEach(VersionListSourceMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text("最大线程数:\(store.maxDownloadThreads)") + Slider(value: Binding( + get: { Double(store.maxDownloadThreads) }, + set: { store.maxDownloadThreads = Int($0) } + ), in: 1...255, step: 1) + Text("通常 64 线程已足够") + .font(.caption) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 4) { + Text(store.downloadSpeedLimit == 0 ? "速度限制:不限制" : "速度限制:\(store.downloadSpeedLimit) KB/s") + Slider(value: Binding( + get: { Double(store.downloadSpeedLimit) }, + set: { store.downloadSpeedLimit = Int($0) } + ), in: 0...4096, step: 64) + } + } + + Section("社区资源") { + Picker("来源", selection: $store.communitySourceMode) { + ForEach(CommunitySourceMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + + Picker("文件名格式", selection: $store.filenameFormat) { + ForEach(FilenameFormat.allCases) { fmt in + Text(fmt.rawValue).tag(fmt) + } + } + + Picker("Mod 管理样式", selection: $store.modListDisplayStyle) { + ForEach(ModListDisplayStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + } + + Section("CurseForge API") { + VStack(alignment: .leading, spacing: 4) { + Text("CurseForge API Key") + .font(.headline) + Text("可选。填入后可同时搜索 CurseForge 资源。从 console.curseforge.com 获取。") + .font(.caption) + .foregroundStyle(.secondary) + SecureField("输入 API Key", text: $store.curseForgeApiKey) + .textFieldStyle(.roundedBorder) + } + } + + Section("配置管理") { + Button { + let panel = NSSavePanel() + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = "mmcl_profile_export.json" + panel.begin { response in + if response == .OK, let url = panel.url { + store.exportProfile(to: url) + } + } + } label: { + Label("导出配置", systemImage: "square.and.arrow.up") + } + + Button { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json] + panel.begin { response in + if response == .OK, let url = panel.url { + store.importProfile(from: url) + } + } + } label: { + Label("导入配置", systemImage: "square.and.arrow.down") + } + } + + Section("关于") { + HStack(spacing: 14) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .frame(width: 64, height: 64) + .clipShape(RoundedRectangle(cornerRadius: 14)) + VStack(alignment: .leading, spacing: 4) { + Text("MMCL") + .font(.title2.weight(.semibold)) + Text("macOS Minecraft 启动器") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + } + HStack { + Text("当前版本") + Spacer() + Text(store.currentVersion) + } + Button("检查更新") { + Task { await store.checkForUpdates() } + } + + Button { + store.openGitHubRepo() + } label: { + Label("GitHub 仓库", systemImage: "link") + } + + Text("如果 MMCL 对你有帮助,欢迎去 GitHub 点个 star (◕ᴗ◕✿)\n一个人开发不容易,你的支持是我持续更新的最大动力!") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 4) + + if store.updateAvailable, let v = store.latestVersion { + Text("新版本可用:\(v)") + .foregroundStyle(.blue) + Button { + Task { await store.downloadAndInstallUpdate() } + } label: { + Label(store.isDownloadingUpdate ? "下载中..." : "下载更新", systemImage: "arrow.down.circle.fill") + } + .disabled(store.isDownloadingUpdate || store.updateDownloadURL == nil) + } + } + } + .formStyle(.grouped) + } +} + +private struct AccountRow: View { + let account: MinecraftAccount + @ObservedObject var store: LauncherStore + @State private var isEditing = false + @State private var editUsername: String = "" + + var body: some View { + HStack { + if isEditing && account.type == .offline { + TextField("用户名", text: $editUsername) + .textFieldStyle(.roundedBorder) + .onSubmit { save() } + Button("保存") { save() } + .buttonStyle(.bordered) + .controlSize(.small) + Button("取消") { isEditing = false } + .buttonStyle(.bordered) + .controlSize(.small) + } else { + Text(account.displayName) + Spacer() + Text(account.type == .microsoft ? "在线" : "离线") + .font(.caption) + .foregroundStyle(.secondary) + if account.type == .offline { + Button { + editUsername = account.username + isEditing = true + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.plain) + } + Button(role: .destructive) { + store.deleteAccount(account) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.plain) + } + } + } + + private func save() { + let name = editUsername.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return } + store.updateAccountUsername(account, newUsername: name) + isEditing = false + } +} diff --git a/MMCL/Views/download/DownloadCenterView.swift b/MMCL/Views/download/DownloadCenterView.swift new file mode 100644 index 0000000..22b3040 --- /dev/null +++ b/MMCL/Views/download/DownloadCenterView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct DownloadCenterView: View { + @ObservedObject var store: LauncherStore + + var body: some View { + TabView(selection: $store.selectedDownloadTab) { + DownloadVanillaView(store: store) + .tabItem { Label("原版游戏", systemImage: "cube.box") } + .tag(DownloadTabType.vanilla) + + DownloadModView(store: store) + .tabItem { Label("Mod", systemImage: "puzzlepiece.extension") } + .tag(DownloadTabType.mod) + + DownloadModpackView(store: store) + .tabItem { Label("整合包", systemImage: "shippingbox") } + .tag(DownloadTabType.modpack) + + DownloadDataPackView(store: store) + .tabItem { Label("数据包", systemImage: "doc.text") } + .tag(DownloadTabType.dataPack) + + DownloadResourcePackView(store: store) + .tabItem { Label("资源包", systemImage: "photo.stack") } + .tag(DownloadTabType.resourcePack) + + DownloadShaderView(store: store) + .tabItem { Label("光影包", systemImage: "sparkles") } + .tag(DownloadTabType.shader) + + DownloadProgressView(store: store) + .tabItem { Label("下载进度", systemImage: "chart.line.uptrend.xyaxis") } + .tag(DownloadTabType.progress) + } + } +} diff --git a/MMCL/Views/download/DownloadDataPackView.swift b/MMCL/Views/download/DownloadDataPackView.swift new file mode 100644 index 0000000..e2ebfe4 --- /dev/null +++ b/MMCL/Views/download/DownloadDataPackView.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct DownloadDataPackView: View { + @ObservedObject var store: LauncherStore + + var body: some View { + DownloadResourceSearchView( + store: store, + title: "数据包", + icon: "doc.text", + projectType: "datapack", + showLoaderFilter: false + ) + } +} diff --git a/MMCL/Views/download/DownloadModView.swift b/MMCL/Views/download/DownloadModView.swift new file mode 100644 index 0000000..b90185e --- /dev/null +++ b/MMCL/Views/download/DownloadModView.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct DownloadModView: View { + @ObservedObject var store: LauncherStore + + var body: some View { + DownloadResourceSearchView( + store: store, + title: "Mod", + icon: "puzzlepiece.extension", + projectType: "mod", + showLoaderFilter: true + ) + } +} diff --git a/MMCL/Views/download/DownloadModpackView.swift b/MMCL/Views/download/DownloadModpackView.swift new file mode 100644 index 0000000..ce4d4c6 --- /dev/null +++ b/MMCL/Views/download/DownloadModpackView.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct DownloadModpackView: View { + @ObservedObject var store: LauncherStore + + var body: some View { + DownloadResourceSearchView( + store: store, + title: "整合包", + icon: "shippingbox", + projectType: "modpack", + showLoaderFilter: false + ) + } +} diff --git a/MMCL/Views/download/DownloadProgressView.swift b/MMCL/Views/download/DownloadProgressView.swift new file mode 100644 index 0000000..7f878b4 --- /dev/null +++ b/MMCL/Views/download/DownloadProgressView.swift @@ -0,0 +1,403 @@ +import SwiftUI + +struct DownloadProgressView: View { + @ObservedObject var store: LauncherStore + @State private var expandedGroupIDs: Set = [] + @State private var appeared = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + .padding(.horizontal) + .padding(.top) + controlBar + .padding(.horizontal) + .padding(.top, 8) + groupList + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle("下载进度") + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 8) + .onAppear { + withAnimation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale)) { + appeared = true + } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("下载进度") + .font(.largeTitle.weight(.semibold)) + Text("实时查看所有下载任务的状态和进度。") + .foregroundStyle(.secondary) + } + } + + private var controlBar: some View { + HStack(spacing: 12) { + Label("\(store.taskGroups.count) 个任务", systemImage: "square.stack.3d.up") + Label(store.speedTracker.bytesPerSecond > 0 + ? ByteCountFormatter.string(fromByteCount: store.speedTracker.bytesPerSecond, countStyle: .file) + "/s" + : "等待中", + systemImage: "speedometer") + Label("\(store.downloadJobs.filter { $0.status == .completed }.count)/\(store.downloadJobs.count) 文件", + systemImage: "doc") + Spacer() + if store.downloadJobs.contains(where: { $0.status == .running }) { + Button("暂停全部") { store.pauseDownloads() } + .buttonStyle(.bordered) + .controlSize(.small) + } + if store.downloadJobs.contains(where: { $0.status == .paused }) { + Button("继续全部") { store.resumeDownloads() } + .buttonStyle(.bordered) + .controlSize(.small) + } + if store.downloadJobs.contains(where: { $0.status.isActive }) { + Button(role: .destructive) { + store.cancelDownloads() + } label: { + Text("取消全部") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + + @ViewBuilder + private var groupList: some View { + if store.taskGroups.isEmpty { + ContentUnavailableView("暂无下载任务", systemImage: "arrow.down.circle", description: Text("在原版游戏、Mod 等标签页中添加下载任务")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(store.taskGroups) { group in + TaskGroupRow( + group: group, + isExpanded: expandedGroupIDs.contains(group.id), + animationScale: store.animationDurationScale, + onToggle: { + withAnimation(.mmclSpring(response: 0.35, dampingFraction: 0.85, scale: store.animationDurationScale)) { + if expandedGroupIDs.contains(group.id) { + expandedGroupIDs.remove(group.id) + } else { + expandedGroupIDs.insert(group.id) + } + } + }, + onPauseGroup: { store.pauseGroup(group) }, + onResumeGroup: { store.resumeGroup(group) }, + onCancelGroup: { store.cancelGroup(group) }, + onPauseJob: { store.pauseJob(id: $0) }, + onResumeJob: { store.resumeJob(id: $0) }, + onCancelJob: { store.cancelJob(id: $0) } + ) + .animation(.mmclSpring(response: 0.4, dampingFraction: 0.9, scale: store.animationDurationScale), value: group.status) + } + } + .listStyle(.inset) + .scrollIndicators(.hidden) + } + } +} + +// MARK: - Task Group Row + +private let maxVisibleJobs = 20 + +private struct TaskGroupRow: View { + let group: DownloadTaskGroup + let isExpanded: Bool + let animationScale: Double + let onToggle: () -> Void + let onPauseGroup: () -> Void + let onResumeGroup: () -> Void + let onCancelGroup: () -> Void + let onPauseJob: (UUID) -> Void + let onResumeJob: (UUID) -> Void + let onCancelJob: (UUID) -> Void + + private var activeJobs: [DownloadJob] { + group.jobs.filter { $0.status == .running || $0.status == .paused } + } + private var failedJobs: [DownloadJob] { + group.jobs.filter { $0.status == .failed } + } + private var completedJobs: [DownloadJob] { + group.jobs.filter { $0.status == .completed } + } + private var queuedJobs: [DownloadJob] { + group.jobs.filter { $0.status == .queued } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button(action: onToggle) { + VStack(alignment: .leading, spacing: 8) { + HStack { + statusIcon + Text(group.name) + .font(.headline) + .lineLimit(1) + Spacer() + groupControlButtons + Text(statusLabel) + .font(.caption) + .foregroundStyle(.secondary) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + } + + ProgressView(value: group.progress) + .animation(.mmclSpring(response: 0.4, dampingFraction: 0.9, scale: animationScale), value: group.progress) + + HStack(spacing: 12) { + Text("\(group.completedCount)/\(group.jobs.count) 文件") + .font(.caption) + .foregroundStyle(.secondary) + if group.totalBytes > 0 { + Text(ByteCountFormatter.string(fromByteCount: group.completedBytes, countStyle: .file) + " / " + + ByteCountFormatter.string(fromByteCount: group.totalBytes, countStyle: .file)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text("\(Int(group.progress * 100))%") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + + if let currentFile = group.currentFileName { + HStack(spacing: 4) { + Image(systemName: "arrow.down") + .font(.caption2) + .foregroundStyle(.blue) + Text(currentFile) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + + if isExpanded { + Divider() + .padding(.top, 4) + expandedDetail + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + + @ViewBuilder + private var groupControlButtons: some View { + if group.status == .running { + Button { + onPauseGroup() + } label: { + Image(systemName: "pause.circle") + .font(.caption) + } + .buttonStyle(.plain) + .help("暂停此任务") + } + if group.status == .paused { + Button { + onResumeGroup() + } label: { + Image(systemName: "play.circle") + .font(.caption) + } + .buttonStyle(.plain) + .help("继续此任务") + } + if group.status.isActive { + Button(role: .destructive) { + onCancelGroup() + } label: { + Image(systemName: "xmark.circle") + .font(.caption) + } + .buttonStyle(.plain) + .help("取消此任务") + } + } + + @ViewBuilder + private var expandedDetail: some View { + VStack(spacing: 0) { + let visibleJobs = visibleExpandedJobs + ForEach(visibleJobs) { job in + jobRow(job) + .transition(.move(edge: .top).combined(with: .opacity)) + if job.id != visibleJobs.last?.id { + Divider() + .padding(.leading, 28) + } + } + + if !completedJobs.isEmpty || !queuedJobs.isEmpty { + collapsedSummary + .padding(.top, 4) + } + } + .padding(.top, 6) + } + + private var visibleExpandedJobs: [DownloadJob] { + var result: [DownloadJob] = [] + result.append(contentsOf: activeJobs) + result.append(contentsOf: failedJobs) + if result.count < maxVisibleJobs { + let remaining = maxVisibleJobs - result.count + result.append(contentsOf: Array(queuedJobs.prefix(remaining))) + } + return result + } + + private var collapsedSummary: some View { + HStack(spacing: 8) { + if !completedJobs.isEmpty { + Label("\(completedJobs.count) 已完成", systemImage: "checkmark.circle") + .font(.caption) + .foregroundStyle(.green) + } + if !queuedJobs.isEmpty { + Label("\(queuedJobs.count) 等待中", systemImage: "clock") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.horizontal, 4) + } + + private func jobRow(_ job: DownloadJob) -> some View { + HStack(spacing: 8) { + jobStatusIcon(job) + VStack(alignment: .leading, spacing: 2) { + Text(job.title) + .font(.caption) + .lineLimit(1) + if job.status == .running && job.totalBytes > 0 { + ProgressView(value: job.progress) + .frame(height: 4) + } + } + Spacer() + if job.status == .running { + Button { + onCancelJob(job.id) + } label: { + Image(systemName: "xmark.circle") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("取消此文件") + if job.totalBytes > 0 { + Text("\(Int(job.progress * 100))%") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + } else if job.status == .paused { + Button { + onResumeJob(job.id) + } label: { + Image(systemName: "play.circle") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("继续此文件") + } else if job.status == .completed { + Text(ByteCountFormatter.string(fromByteCount: job.totalBytes, countStyle: .file)) + .font(.caption2) + .foregroundStyle(.secondary) + } else if job.status == .failed { + Text("失败") + .font(.caption2) + .foregroundStyle(.red) + } else if job.status == .queued { + Text("等待中") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 3) + .padding(.horizontal, 4) + } + + @ViewBuilder + private var statusIcon: some View { + switch group.status { + case .running: + ProgressView() + .controlSize(.small) + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.scale.combined(with: .opacity)) + case .failed: + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + .transition(.scale.combined(with: .opacity)) + case .paused: + Image(systemName: "pause.circle.fill") + .foregroundStyle(.orange) + case .queued: + Image(systemName: "clock.fill") + .foregroundStyle(.secondary) + } + } + + private func jobStatusIcon(_ job: DownloadJob) -> some View { + Group { + switch job.status { + case .running: + Image(systemName: "arrow.down.circle.fill") + .foregroundStyle(.blue) + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .transition(.scale.combined(with: .opacity)) + case .failed: + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + .transition(.scale.combined(with: .opacity)) + case .paused: + Image(systemName: "pause.circle.fill") + .foregroundStyle(.orange) + case .queued: + Image(systemName: "clock.fill") + .foregroundStyle(.secondary) + } + } + .font(.caption) + .frame(width: 16) + } + + private var statusLabel: String { + switch group.status { + case .running: + return "下载中" + case .completed: + return "已完成" + case .failed: + return "失败 \(group.failedCount) 个" + case .paused: + return "已暂停" + case .queued: + return "等待中" + } + } +} diff --git a/MMCL/Views/download/DownloadResourcePackView.swift b/MMCL/Views/download/DownloadResourcePackView.swift new file mode 100644 index 0000000..3ee7b15 --- /dev/null +++ b/MMCL/Views/download/DownloadResourcePackView.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct DownloadResourcePackView: View { + @ObservedObject var store: LauncherStore + + var body: some View { + DownloadResourceSearchView( + store: store, + title: "资源包", + icon: "photo.stack", + projectType: "resourcepack", + showLoaderFilter: false + ) + } +} diff --git a/MMCL/Views/download/DownloadResourceSearchView.swift b/MMCL/Views/download/DownloadResourceSearchView.swift new file mode 100644 index 0000000..998441f --- /dev/null +++ b/MMCL/Views/download/DownloadResourceSearchView.swift @@ -0,0 +1,584 @@ +import SwiftUI + +// MARK: - Image Cache + +private final class ImageCache { + static let shared = ImageCache() + private let cache = NSCache() + + func get(_ url: URL) -> NSImage? { + cache.object(forKey: url as NSURL) + } + + func set(_ image: NSImage, for url: URL) { + cache.setObject(image, forKey: url as NSURL) + } +} + +// MARK: - Cached Async Image + +private struct CachedAsyncImage: View { + let url: URL? + var placeholder: AnyView = AnyView(ProgressView()) + + @State private var image: NSImage? + @State private var isLoading = false + + var body: some View { + Group { + if let image { + Image(nsImage: image) + .resizable() + .scaledToFit() + } else { + placeholder + .onAppear { load() } + } + } + } + + private func load() { + guard let url, !isLoading else { return } + if let cached = ImageCache.shared.get(url) { + image = cached + return + } + isLoading = true + Task { + guard let (data, _) = try? await URLSession.shared.data(from: url), + let uiImage = NSImage(data: data) else { + isLoading = false + return + } + ImageCache.shared.set(uiImage, for: url) + image = uiImage + } + } +} + +// MARK: - Community Source + +enum CommunitySource: String, CaseIterable, Identifiable { + case all = "全部" + case modrinth = "Modrinth" + case curseforge = "CurseForge" + + var id: String { rawValue } +} + +// MARK: - Resource Category + +enum ResourceCategory: String, CaseIterable, Identifiable { + case all = "全部" + case technology = "科技" + case magic = "魔法" + case adventure = "冒险" + case decoration = "装饰" + case storage = "存储" + case utility = "工具" + case performance = "性能" + case worldGen = "世界生成" + case library = "前置库" + case optimization = "优化" + case audio = "音效" + case texture = "材质" + case pvp = "PvP" + case quest = "任务" + case map = "地图" + case modpack = "整合" + + var id: String { rawValue } + + var modrinthFacet: String? { + switch self { + case .all: return nil + case .technology: return "categories:technology" + case .magic: return "categories:magic" + case .adventure: return "categories:adventure" + case .decoration: return "categories:decoration" + case .storage: return "categories:storage" + case .utility: return "categories:utility" + case .performance: return "categories:performance" + case .worldGen: return "categories:worldgen" + case .library: return "categories:library" + case .optimization: return "categories:optimization" + case .audio: return "categories:audio" + case .texture: return "categories:decoration" + case .pvp: return "categories:pvp" + case .quest: return "categories:quest" + case .map: return "categories:worldgen" + case .modpack: return "categories:modpack" + } + } +} + +// MARK: - Resource Search Result + +enum ResourceSearchItem: Identifiable { + case modrinth(ModrinthSearchResult) + case curseforge(CurseForgeSearchResult) + + var id: String { + switch self { + case .modrinth(let r): return "modrinth-\(r.id)" + case .curseforge(let r): return "curseforge-\(r.id)" + } + } + + var title: String { + switch self { + case .modrinth(let r): return r.title + case .curseforge(let r): return r.name + } + } + + var description: String { + switch self { + case .modrinth(let r): return r.description + case .curseforge(let r): return r.summary + } + } + + var downloads: Int { + switch self { + case .modrinth(let r): return r.downloads + case .curseforge(let r): return r.downloadCount + } + } + + var author: String? { + switch self { + case .modrinth(let r): return r.author + case .curseforge: return nil + } + } + + var formattedDate: String? { + switch self { + case .modrinth(let r): return r.formattedDate + case .curseforge: return nil + } + } + + var displayTags: [String] { + switch self { + case .modrinth(let r): return r.displayTags + case .curseforge: return [] + } + } + + var source: CommunitySource { + switch self { + case .modrinth: return .modrinth + case .curseforge: return .curseforge + } + } +} + +// MARK: - Resource Search View + +struct DownloadResourceSearchView: View { + @ObservedObject var store: LauncherStore + let title: String + let icon: String + let projectType: String + let showLoaderFilter: Bool + + @State private var searchText: String = "" + @State private var selectedSource: CommunitySource = .all + @State private var selectedVersion: String? = nil + @State private var selectedLoader: String? = nil + @State private var selectedCategory: ResourceCategory = .all + @State private var searchResults: [ResourceSearchItem] = [] + @State private var visibleIDs: Set = [] + @State private var isLoading: Bool = false + @State private var isLoadingMore: Bool = false + @State private var errorMessage: String? = nil + @State private var totalHits: Int = 0 + @State private var currentOffset: Int = 0 + @State private var hasSearched: Bool = false + @State private var appeared = false + + private let commonVersions = ["全部", "1.21.x", "1.20.x", "1.19.x", "1.18.x", "1.16.5", "1.12.2", "1.7.10"] + private let loaderOptions = ["任意", "Forge", "NeoForge", "Fabric", "Quilt"] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + .padding(.horizontal) + .padding(.top) + instanceBar + .padding(.horizontal) + .padding(.top, 8) + searchBar + .padding(.horizontal) + .padding(.top, 8) + filterBar + .padding(.horizontal) + .padding(.top, 8) + resultsList + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle(title) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 8) + .onAppear { + withAnimation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale)) { + appeared = true + } + if searchResults.isEmpty { + loadPopular() + } + } + } + + // MARK: - Header + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.largeTitle.weight(.semibold)) + Text("从 Modrinth 和 CurseForge 搜索并下载\(title)。") + .foregroundStyle(.secondary) + } + } + + // MARK: - Instance Bar + + private var instanceBar: some View { + HStack(spacing: 12) { + Text("安装到").foregroundStyle(.secondary) + Picker("实例", selection: $store.launcherSelectedInstanceID) { + Text("未选择").tag(LauncherInstance.ID?.none) + ForEach(store.instances) { instance in + HStack { + Image(systemName: "cube.box") + Text(instance.name) + } + .tag(Optional(instance.id)) + } + } + .pickerStyle(.menu) + .frame(maxWidth: 280) + + if store.selectedInstance != nil { + Label("已选择", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } else { + Label("请先选择实例", systemImage: "exclamationmark.circle") + .font(.caption) + .foregroundStyle(.orange) + } + + Spacer() + } + } + + // MARK: - Search Bar + + private var searchBar: some View { + HStack { + TextField("搜索\(title)...", text: $searchText) + .textFieldStyle(.roundedBorder) + .onSubmit { + performSearch() + } + + Button { + performSearch() + } label: { + Label("搜索", systemImage: "magnifyingglass") + } + .buttonStyle(.borderedProminent) + .disabled(searchText.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + + // MARK: - Filter Bar + + private var filterBar: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Picker("来源", selection: $selectedSource) { + ForEach(CommunitySource.allCases) { source in + Text(source.rawValue).tag(source) + } + } + .pickerStyle(.menu) + + Picker("版本", selection: $selectedVersion) { + Text("全部").tag(String?.none) + ForEach(commonVersions, id: \.self) { version in + Text(version).tag(Optional(version)) + } + } + .pickerStyle(.menu) + + if showLoaderFilter { + Picker("加载器", selection: $selectedLoader) { + Text("任意").tag(String?.none) + ForEach(loaderOptions.dropFirst(), id: \.self) { loader in + Text(loader).tag(Optional(loader)) + } + } + .pickerStyle(.menu) + } + + Picker("分类", selection: $selectedCategory) { + ForEach(ResourceCategory.allCases) { category in + Text(category.rawValue).tag(category) + } + } + .pickerStyle(.menu) + + Spacer() + } + + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + } + + if let error = errorMessage { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + } + + // MARK: - Results List + + private var resultsList: some View { + Group { + if searchResults.isEmpty && !isLoading && hasSearched { + ContentUnavailableView("没有找到结果", systemImage: "magnifyingglass", description: Text("试试其他关键词或筛选条件")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(searchResults) { item in + resourceRow(item) + .opacity(visibleIDs.contains(item.id) ? 1 : 0) + .offset(x: visibleIDs.contains(item.id) ? 0 : 20) + .onAppear { + if !visibleIDs.contains(item.id) { + withAnimation(.mmclSpring(response: 0.5, dampingFraction: 0.85, scale: store.animationDurationScale)) { + visibleIDs.insert(item.id) + } + } + } + .onDisappear { + visibleIDs.remove(item.id) + } + } + + if !searchResults.isEmpty && currentOffset < totalHits { + HStack { + Spacer() + if isLoadingMore { + ProgressView() + } else { + Button("加载更多") { + loadMore() + } + .buttonStyle(.bordered) + } + Spacer() + } + } + } + .padding(.horizontal) + } + .animation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale), value: searchResults.count) + } + } + } + + private func resourceRow(_ item: ResourceSearchItem) -> some View { + HStack(alignment: .top, spacing: 12) { + iconView(for: item) + .frame(width: 48, height: 48) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(item.title) + .font(.headline) + Text(item.source.rawValue) + .font(.caption) + .foregroundStyle(.secondary) + } + if let author = item.author { + Text(author) + .font(.caption) + .foregroundStyle(.secondary) + } + Text(item.description) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + HStack(spacing: 8) { + Label("\(item.downloads)", systemImage: "arrow.down") + if let date = item.formattedDate { + Label(date, systemImage: "clock") + } + } + .font(.caption) + .foregroundStyle(.secondary) + if !item.displayTags.isEmpty { + HStack(spacing: 4) { + ForEach(item.displayTags, id: \.self) { tag in + Text(tag) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.quaternary, in: Capsule()) + } + } + } + } + Spacer() + Button { + installItem(item) + } label: { + Label("安装", systemImage: "arrow.down.circle") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + @ViewBuilder + private func iconView(for item: ResourceSearchItem) -> some View { + switch item { + case .modrinth(let result): + if let url = result.iconURLResolved { + CachedAsyncImage(url: url, placeholder: AnyView( + placeholderIcon(tint: result.tintColor) + )) + } else { + placeholderIcon(tint: result.tintColor) + } + case .curseforge: + placeholderIcon(tint: nil) + } + } + + private func placeholderIcon(tint: Color?) -> some View { + Rectangle() + .fill(tint?.opacity(0.2) ?? Color.secondary.opacity(0.1)) + .overlay { + Image(systemName: "puzzlepiece.extension") + .foregroundStyle(tint ?? .secondary) + } + } + + private func installItem(_ item: ResourceSearchItem) { + switch item { + case .modrinth(let result): + store.selectedModrinthProject = result + store.showingModrinthDetail = true + case .curseforge: + // CurseForge direct download not yet implemented + errorMessage = "CurseForge 直接安装暂未实现,请使用 Modrinth 搜索" + } + } + + // MARK: - CurseForge Class IDs + + private var curseforgeClassId: Int? { + switch projectType { + case "mod": return 6 + case "modpack": return 4471 + case "resourcepack": return 12 + case "shader": return 4546 + case "datapack": return 5 + default: return nil + } + } + + // MARK: - Search + + private func performSearch() { + let query = searchText.trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { return } + + isLoading = true + errorMessage = nil + hasSearched = true + currentOffset = 0 + visibleIDs = [] + + Task { + let result = await searchModrinth(query: query, offset: 0) + searchResults = result.items + totalHits = result.total + isLoading = false + } + } + + private func loadPopular() { + isLoading = true + errorMessage = nil + hasSearched = false + currentOffset = 0 + visibleIDs = [] + + Task { + let result = await searchModrinth(query: "", index: "downloads", offset: 0) + searchResults = result.items + totalHits = result.total + isLoading = false + } + } + + private func loadMore() { + guard !isLoadingMore else { return } + isLoadingMore = true + let nextOffset = currentOffset + 20 + + Task { + let query = hasSearched ? searchText.trimmingCharacters(in: .whitespaces) : "" + let index = hasSearched ? "relevance" : "downloads" + let result = await searchModrinth(query: query, index: index, offset: nextOffset) + searchResults.append(contentsOf: result.items) + currentOffset = nextOffset + totalHits = result.total + isLoadingMore = false + } + } + + private func searchModrinth(query: String, index: String = "relevance", offset: Int) async -> (items: [ResourceSearchItem], total: Int) { + var items: [ResourceSearchItem] = [] + var total = 0 + + if selectedSource == .all || selectedSource == .modrinth { + do { + var facets: [[String]] = [["project_type:\(projectType)"]] + if let categoryFacet = selectedCategory.modrinthFacet { + facets.append([categoryFacet]) + } + let response = try await store.modrinthService.search(query: query, facets: facets, index: index, offset: offset) + items.append(contentsOf: response.hits.map { .modrinth($0) }) + total = response.totalHits + } catch { + errorMessage = "Modrinth: \(error.localizedDescription)" + } + } + + // CurseForge (only when API key is provided) + if offset == 0 && !store.curseForgeApiKey.isEmpty && (selectedSource == .all || selectedSource == .curseforge) { + do { + let gameVersion = selectedVersion?.replacingOccurrences(of: ".x", with: "") + let cfResults = try await store.curseForgeService.search(query: query, classId: curseforgeClassId, gameVersion: gameVersion, apiKey: store.curseForgeApiKey) + items.append(contentsOf: cfResults.map { .curseforge($0) }) + } catch { + // silently ignore CurseForge errors when key is provided + } + } + + return (items, total) + } +} diff --git a/MMCL/Views/download/DownloadShaderView.swift b/MMCL/Views/download/DownloadShaderView.swift new file mode 100644 index 0000000..bd0e998 --- /dev/null +++ b/MMCL/Views/download/DownloadShaderView.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct DownloadShaderView: View { + @ObservedObject var store: LauncherStore + + var body: some View { + DownloadResourceSearchView( + store: store, + title: "光影包", + icon: "sparkles", + projectType: "shader", + showLoaderFilter: false + ) + } +} diff --git a/MMCL/Views/download/DownloadVanillaView.swift b/MMCL/Views/download/DownloadVanillaView.swift new file mode 100644 index 0000000..600c349 --- /dev/null +++ b/MMCL/Views/download/DownloadVanillaView.swift @@ -0,0 +1,385 @@ +import SwiftUI + +struct DownloadVanillaView: View { + @ObservedObject var store: LauncherStore + @State private var versionFilter: MinecraftVersion.ReleaseType? = nil + @State private var searchText: String = "" + @State private var expandedVersion: String? = nil + @State private var appeared = false + @State private var selectedLoaders: [String: GameLoader] = [:] + @State private var selectedLoaderVersions: [String: String] = [:] + @State private var collapsedGroups: Set = ["快照版", "远古版"] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + .padding(.horizontal) + .padding(.top) + filterBar + .padding(.horizontal) + .padding(.top, 8) + downloadControlBar + .padding(.horizontal) + .padding(.top, 8) + versionList + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle("原版游戏") + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 8) + .onAppear { + withAnimation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale)) { + appeared = true + } + } + .task { + if store.availableVersions.isEmpty { + await store.refreshAvailableVersions() + } + } + } + + // MARK: - Header + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("原版游戏") + .font(.largeTitle.weight(.semibold)) + Text("选择 Minecraft 版本并配置 Mod 加载器后下载安装。") + .foregroundStyle(.secondary) + } + } + + // MARK: - Filter Bar + + private var filterBar: some View { + HStack(spacing: 12) { + TextField("搜索版本...", text: $searchText) + .textFieldStyle(.roundedBorder) + + Picker("类型", selection: $versionFilter) { + Text("全部").tag(MinecraftVersion.ReleaseType?.none) + ForEach([MinecraftVersion.ReleaseType.release, .snapshot, .oldBeta, .oldAlpha], id: \.self) { type in + Text(type.label).tag(Optional(type)) + } + } + .pickerStyle(.menu) + + Spacer() + + Button { + Task { + await store.refreshAvailableVersions() + } + } label: { + Label("刷新版本", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + } + } + + // MARK: - Download Control Bar + + private var downloadControlBar: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Picker("下载源", selection: $store.selectedDownloadSource) { + ForEach(DownloadSource.allCases) { source in + Text(source.rawValue).tag(source) + } + } + .pickerStyle(.menu) + + Button { + Task { + await store.executeQueuedDownloads() + } + } label: { + Label("开始下载", systemImage: "arrow.down.circle.fill") + } + .buttonStyle(.borderedProminent) + .disabled(!store.downloadJobs.contains { $0.status == .queued }) + + Button(role: .destructive) { + store.cancelDownloads() + } label: { + Label("取消", systemImage: "xmark.circle") + } + .buttonStyle(.bordered) + .disabled(!store.downloadJobs.contains { $0.status.isActive }) + + Button { + if store.downloadJobs.contains(where: { $0.status == .running }) { + store.pauseDownloads() + } else if store.downloadJobs.contains(where: { $0.status == .paused }) { + store.resumeDownloads() + } + } label: { + if store.downloadJobs.contains(where: { $0.status == .running }) { + Label("暂停", systemImage: "pause.circle") + } else { + Label("继续下载", systemImage: "play.circle") + } + } + .buttonStyle(.bordered) + .disabled(!store.downloadJobs.contains { $0.status == .running || $0.status == .paused }) + } + + HStack(spacing: 16) { + Label("\(store.taskGroups.count) 个安装任务", systemImage: "square.stack.3d.up") + Label(store.speedTracker.bytesPerSecond > 0 + ? ByteCountFormatter.string(fromByteCount: store.speedTracker.bytesPerSecond, countStyle: .file) + "/s" + : "等待中", + systemImage: "speedometer") + if let current = store.taskGroups.first(where: { $0.status == .running })?.currentFileName { + Label(current, systemImage: "doc") + .lineLimit(1) + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + // MARK: - Version List + + private var versionList: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(groupedVersions, id: \.0) { group in + versionGroup(name: group.0, versions: group.1) + } + } + .padding(.horizontal) + } + .scrollIndicators(.hidden) + } + + private func versionGroup(name: String, versions: [MinecraftVersion]) -> some View { + let isExpanded = Binding( + get: { !collapsedGroups.contains(name) }, + set: { newValue in + withAnimation(.mmclSpring(response: 0.35, dampingFraction: 0.85, scale: store.animationDurationScale)) { + if newValue { + collapsedGroups.remove(name) + } else { + collapsedGroups.insert(name) + } + } + } + ) + + return DisclosureGroup(isExpanded: isExpanded) { + LazyVStack(spacing: 0) { + ForEach(versions) { version in + versionRow(version) + if version.id != versions.last?.id { + Divider() + .padding(.leading, 12) + } + } + } + } label: { + HStack { + Text(name) + .font(.headline) + Text("\(versions.count)") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color(nsColor: .controlBackgroundColor), in: Capsule()) + Spacer() + } + .padding(.vertical, 4) + } + .padding(.vertical, 4) + } + + private func versionRow(_ version: MinecraftVersion) -> some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.mmclSpring(response: 0.35, dampingFraction: 0.85, scale: store.animationDurationScale)) { + if expandedVersion == version.id { + expandedVersion = nil + } else { + expandedVersion = version.id + } + } + } label: { + HStack { + Image(versionIcon(for: version)) + .resizable() + .frame(width: 20, height: 20) + Text(version.id) + .font(.headline) + Text(version.type.label) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text("推荐 Java \(version.recommendedJavaMajorVersion)") + .font(.caption) + .foregroundStyle(.secondary) + Image(systemName: expandedVersion == version.id ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + + if expandedVersion == version.id { + loaderSelectionSection(for: version) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + + // MARK: - Loader Selection + + private func loaderSelectionSection(for version: MinecraftVersion) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("Mod 加载器(可选)") + .font(.subheadline) + .foregroundStyle(.secondary) + + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()), + ], spacing: 8) { + loaderCard(name: "Forge", icon: "hammer", version: version.id) + loaderCard(name: "NeoForge", icon: "hammer.fill", version: version.id) + loaderCard(name: "Fabric", icon: "shippingbox", version: version.id) + loaderCard(name: "Quilt", icon: "shippingbox.fill", version: version.id) + } + + optifineCard(for: version) + + HStack { + Spacer() + Button { + let loader = selectedLoaders[version.id] ?? .vanilla + Task { + await store.createInstanceAndDownload(gameVersion: version.id, loader: loader) + } + } label: { + Label("开始下载", systemImage: "arrow.down.circle.fill") + } + .buttonStyle(.borderedProminent) + } + } + .padding(.vertical, 8) + } + + private func loaderCard(name: String, icon: String, version: String) -> some View { + let loader = GameLoader(rawValue: name) ?? .vanilla + let isSelected = selectedLoaders[version] == loader + + return Button { + if isSelected { + selectedLoaders.removeValue(forKey: version) + selectedLoaderVersions.removeValue(forKey: version) + } else { + selectedLoaders[version] = loader + } + } label: { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.title2) + Text(name) + .font(.caption.weight(.medium)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(isSelected ? Color.accentColor.opacity(0.15) : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) + .animation(.mmclSpring(response: 0.35, dampingFraction: 0.85, scale: store.animationDurationScale), value: isSelected) + } + .buttonStyle(.plain) + } + + private func optifineCard(for version: MinecraftVersion) -> some View { + let isSelected = selectedLoaders[version.id] == .vanilla && selectedLoaderVersions[version.id]?.hasPrefix("optifine") == true + + return Button { + if isSelected { + selectedLoaders.removeValue(forKey: version.id) + selectedLoaderVersions.removeValue(forKey: version.id) + } else { + selectedLoaders[version.id] = .vanilla + selectedLoaderVersions[version.id] = "optifine" + } + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("OptiFine") + .font(.subheadline.weight(.medium)) + Text("与部分 Mod 加载器不兼容,请注意版本搭配") + .font(.caption) + .foregroundStyle(.orange) + } + Spacer() + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? .green : .secondary) + } + .padding(10) + .background(isSelected ? Color.orange.opacity(0.1) : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.orange : Color.clear, lineWidth: 2) + ) + .animation(.mmclSpring(response: 0.35, dampingFraction: 0.85, scale: store.animationDurationScale), value: isSelected) + } + .buttonStyle(.plain) + } + + // MARK: - Helpers + + private func versionIcon(for version: MinecraftVersion) -> String { + switch version.type { + case .release: return "Grass" + case .snapshot: return "CommandBlock" + case .oldBeta, .oldAlpha: return "CobbleStone" + } + } + + private var groupedVersions: [(String, [MinecraftVersion])] { + let filtered = filteredVersions + return [ + ("正式版", filtered.filter { $0.type == .release }), + ("快照版", filtered.filter { $0.type == .snapshot }), + ("远古版", filtered.filter { $0.type == .oldBeta || $0.type == .oldAlpha }), + ].filter { !$0.1.isEmpty } + } + + private var filteredVersions: [MinecraftVersion] { + var versions = store.availableVersions + + if let filter = versionFilter { + versions = versions.filter { $0.type == filter } + } + + if !searchText.trimmingCharacters(in: .whitespaces).isEmpty { + versions = versions.filter { $0.id.localizedCaseInsensitiveContains(searchText) } + } + + return versions + } +} + +// MARK: - ReleaseType Extension + +extension MinecraftVersion.ReleaseType { + var groupLabel: String { + switch self { + case .release: return "正式版" + case .snapshot: return "快照版" + case .oldBeta, .oldAlpha: return "远古版" + } + } +} diff --git a/MMCLTests/AssetIndexPlanningTests.swift b/MMCLTests/AssetIndexPlanningTests.swift new file mode 100644 index 0000000..e9e4601 --- /dev/null +++ b/MMCLTests/AssetIndexPlanningTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import MMCL + +final class AssetIndexPlanningTests: XCTestCase { + func testAssetIndexParsesObjectsAndTotals() throws { + let index = try VersionManifestService().decodeAssetIndex(from: Data(Self.assetIndexJSON.utf8)) + + XCTAssertEqual(index.objects.count, 2) + XCTAssertEqual(index.totalBytes, 30) + XCTAssertEqual(index.objects["minecraft/sounds/random/pop.ogg"]?.hash, "abcdef0123456789abcdef0123456789abcdef01") + XCTAssertEqual(index.objects["minecraft/textures/gui/widgets.png"]?.size, 20) + } + + func testDownloadServicePlansAssetObjectJobs() throws { + let index = try VersionManifestService().decodeAssetIndex(from: Data(Self.assetIndexJSON.utf8)) + let instance = LauncherInstance( + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: URL(fileURLWithPath: "/Users/example/Instances/vanilla", isDirectory: true), + status: .notInstalled + ) + + let jobs = DownloadService().makeAssetObjectJobs(assetIndex: index, instance: instance, source: .official) + + XCTAssertEqual(jobs.count, 2) + XCTAssertEqual(jobs[0].title, "资源文件 minecraft/sounds/random/pop.ogg") + XCTAssertEqual(jobs[0].remoteURL?.absoluteString, "https://resources.download.minecraft.net/ab/abcdef0123456789abcdef0123456789abcdef01") + XCTAssertEqual(jobs[0].destination.path, "/Users/example/Instances/vanilla/.minecraft/assets/objects/ab/abcdef0123456789abcdef0123456789abcdef01") + XCTAssertEqual(jobs[0].sha1, "abcdef0123456789abcdef0123456789abcdef01") + XCTAssertEqual(jobs[1].remoteURL?.absoluteString, "https://resources.download.minecraft.net/12/1234567890abcdef1234567890abcdef12345678") + } + + func testStoreExpandsDownloadedAssetIndexIntoQueuedJobs() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let instance = LauncherInstance( + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: root.appendingPathComponent("instance", isDirectory: true), + status: .notInstalled + ) + let assetIndexPath = instance.rootDirectory + .appendingPathComponent(".minecraft/assets/indexes/19.json") + try FileManager.default.createDirectory( + at: assetIndexPath.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data(Self.assetIndexJSON.utf8).write(to: assetIndexPath) + let store = LauncherStore( + instances: [instance], + downloadJobs: [], + featuredProjects: [], + diagnostics: [], + javaRuntimes: [], + availableVersions: [] + ) + + try store.expandAssetIndexDownloads(assetIndexURL: assetIndexPath, for: instance) + + XCTAssertEqual(store.downloadJobs.count, 2) + XCTAssertEqual(store.diagnostics.first?.title, "已展开资源文件") + XCTAssertTrue(store.diagnostics.first?.summary.contains("2 个资源任务") == true) + } + + private static let assetIndexJSON = """ + { + "objects": { + "minecraft/sounds/random/pop.ogg": { + "hash": "abcdef0123456789abcdef0123456789abcdef01", + "size": 10 + }, + "minecraft/textures/gui/widgets.png": { + "hash": "1234567890abcdef1234567890abcdef12345678", + "size": 20 + } + } + } + """ +} diff --git a/MMCLTests/DownloadExecutionTests.swift b/MMCLTests/DownloadExecutionTests.swift new file mode 100644 index 0000000..2818ee2 --- /dev/null +++ b/MMCLTests/DownloadExecutionTests.swift @@ -0,0 +1,171 @@ +import XCTest +@testable import MMCL + +final class DownloadExecutionTests: XCTestCase { + func testDownloadServiceCopiesFileURLAndVerifiesSHA1() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let source = root.appendingPathComponent("source.dat") + let destination = root.appendingPathComponent("nested/output.dat") + try Data("hello minecraft".utf8).write(to: source) + + let job = DownloadJob( + title: "测试文件", + source: .official, + remoteURL: source, + destination: destination, + sha1: "0205c49d7dadcddde7b919c2b0763dd43d1679f0", + totalBytes: 15 + ) + + let service = DownloadService() + let completedJob: DownloadJob = await withCheckedContinuation { continuation in + service.onComplete = { _, job in + continuation.resume(returning: job) + } + service.onError = { _, error in + continuation.resume(returning: DownloadJob( + title: job.title, source: job.source, remoteURL: job.remoteURL, + destination: job.destination, sha1: job.sha1, totalBytes: job.totalBytes, + status: .failed + )) + } + service.startDownload(job) + } + + XCTAssertEqual(completedJob.status, DownloadStatus.completed) + XCTAssertEqual(completedJob.completedBytes, 15) + XCTAssertEqual(try Data(contentsOf: destination), Data("hello minecraft".utf8)) + } + + func testDownloadServiceMarksFailedWhenSHA1DoesNotMatch() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let source = root.appendingPathComponent("source.dat") + let destination = root.appendingPathComponent("output.dat") + try Data("bad hash".utf8).write(to: source) + + let job = DownloadJob( + title: "错误校验", + source: .official, + remoteURL: source, + destination: destination, + sha1: "0000000000000000000000000000000000000000", + totalBytes: 8 + ) + + let service = DownloadService() + let resultJob: DownloadJob = await withCheckedContinuation { continuation in + service.onComplete = { _, job in + continuation.resume(returning: job) + } + service.onError = { _, _ in + let failedJob = DownloadJob( + title: job.title, source: job.source, remoteURL: job.remoteURL, + destination: job.destination, sha1: job.sha1, totalBytes: job.totalBytes, + status: .failed + ) + continuation.resume(returning: failedJob) + } + service.startDownload(job) + } + + XCTAssertEqual(resultJob.status, DownloadStatus.failed) + XCTAssertTrue(FileManager.default.fileExists(atPath: destination.path)) + } + + func testStoreExecutesQueuedDownloadsAndAddsFailureDiagnostic() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let source = root.appendingPathComponent("source.dat") + try Data("ok".utf8).write(to: source) + + let jobs = [ + DownloadJob( + title: "成功任务", + source: .official, + remoteURL: source, + destination: root.appendingPathComponent("success.dat"), + sha1: "7a85f4764bbd6daf1c3545efbbf0f279a6dc0beb", + totalBytes: 2 + ), + DownloadJob( + title: "失败任务", + source: .official, + remoteURL: source, + destination: root.appendingPathComponent("failure.dat"), + sha1: "0000000000000000000000000000000000000000", + totalBytes: 2 + ) + ] + let store = LauncherStore( + instances: [], + downloadJobs: jobs, + featuredProjects: [], + diagnostics: [], + javaRuntimes: [], + availableVersions: [] + ) + + await store.executeQueuedDownloads() + + // Wait for async callbacks to propagate + try await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertEqual(store.downloadJobs[0].status, DownloadStatus.completed) + XCTAssertEqual(store.downloadJobs[1].status, DownloadStatus.failed) + XCTAssertEqual(store.diagnostics.first?.title, "下载失败") + XCTAssertTrue(store.diagnostics.first?.summary.contains("失败任务") == true) + } + + func testDownloadServiceCancelAndRestart() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let source = root.appendingPathComponent("source.dat") + try Data("cancel test".utf8).write(to: source) + + let job = DownloadJob( + title: "取消测试", + source: .official, + remoteURL: source, + destination: root.appendingPathComponent("output.dat"), + totalBytes: 11 + ) + + let service = DownloadService() + + // Start then cancel + service.startDownload(job) + try await Task.sleep(nanoseconds: 50_000_000) + service.cancelAllDownloads() + + // Wait for cancellation to propagate + try await Task.sleep(nanoseconds: 100_000_000) + + // Restart and verify completion + let completedJob: DownloadJob = await withCheckedContinuation { continuation in + service.onComplete = { _, job in + continuation.resume(returning: job) + } + service.onError = { _, _ in + continuation.resume(returning: DownloadJob( + title: job.title, source: job.source, remoteURL: job.remoteURL, + destination: job.destination, sha1: job.sha1, totalBytes: job.totalBytes, + status: .failed + )) + } + service.startDownload(job) + } + + XCTAssertEqual(completedJob.status, DownloadStatus.completed) + XCTAssertEqual(completedJob.completedBytes, 11) + } +} diff --git a/MMCLTests/InstallPlanStoreTests.swift b/MMCLTests/InstallPlanStoreTests.swift new file mode 100644 index 0000000..8326068 --- /dev/null +++ b/MMCLTests/InstallPlanStoreTests.swift @@ -0,0 +1,72 @@ +import XCTest +@testable import MMCL + +final class InstallPlanStoreTests: XCTestCase { + func testStorePlansVanillaInstallForSelectedInstance() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let instance = LauncherInstance( + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: root, + status: .notInstalled + ) + let metadata = try VersionManifestService().decodeVersionMetadata(from: Data(Self.versionMetadataJSON.utf8)) + let store = LauncherStore( + instances: [instance], + downloadJobs: [], + featuredProjects: [], + diagnostics: [], + javaRuntimes: [], + availableVersions: [] + ) + + store.planVanillaInstall(metadata: metadata, for: instance) + + XCTAssertEqual(store.downloadJobs.count, 3) + XCTAssertEqual(store.downloadJobs[0].title, "Minecraft 1.21.5 客户端") + XCTAssertEqual(store.downloadJobs[0].status, .queued) + XCTAssertEqual(store.plannedVersionMetadata, metadata) + XCTAssertEqual(store.plannedInstanceID, instance.id) + XCTAssertTrue(FileManager.default.fileExists( + atPath: root.appendingPathComponent(".minecraft/versions/1.21.5/1.21.5.json").path + )) + XCTAssertEqual(store.diagnostics.first?.title, "已生成 Vanilla 安装计划") + } + + private static let versionMetadataJSON = """ + { + "id": "1.21.5", + "mainClass": "net.minecraft.client.main.Main", + "assets": "19", + "assetIndex": { + "id": "19", + "url": "https://piston-meta.mojang.com/v1/packages/assets.json", + "sha1": "asset-sha1", + "size": 321 + }, + "downloads": { + "client": { + "url": "https://piston-data.mojang.com/v1/objects/client.jar", + "sha1": "client-sha1", + "size": 123 + } + }, + "libraries": [ + { + "name": "org.lwjgl:lwjgl:3.3.3", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar", + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar", + "sha1": "library-sha1", + "size": 456 + } + } + } + ] + } + """ +} diff --git a/MMCLTests/LauncherModelTests.swift b/MMCLTests/LauncherModelTests.swift new file mode 100644 index 0000000..cbb300f --- /dev/null +++ b/MMCLTests/LauncherModelTests.swift @@ -0,0 +1,117 @@ +import XCTest +@testable import MMCL + +final class LauncherModelTests: XCTestCase { + func testLauncherInstanceRoundTripsThroughJSON() throws { + let root = URL(fileURLWithPath: "/Users/example/Library/Application Support/MMCL/Instances/vanilla") + let instance = LauncherInstance( + name: "生存 1.21", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: root, + profile: LaunchProfile(offlineUsername: "Steve", memoryMegabytes: 4096, jvmArguments: ["-XX:+UseG1GC"], resolutionWidth: 854, resolutionHeight: 480), + status: .ready, + lastPlayedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + + let data = try JSONEncoder.mmcl.encode(instance) + let decoded = try JSONDecoder.mmcl.decode(LauncherInstance.self, from: data) + + XCTAssertEqual(decoded.name, "生存 1.21") + XCTAssertEqual(decoded.gameVersion, "1.21.5") + XCTAssertEqual(decoded.loader, .vanilla) + XCTAssertEqual(decoded.rootDirectory.path, root.path) + XCTAssertEqual(decoded.profile.offlineUsername, "Steve") + XCTAssertEqual(decoded.profile.memoryMegabytes, 4096) + XCTAssertEqual(decoded.profile.jvmArguments, ["-XX:+UseG1GC"]) + XCTAssertEqual(decoded.status, .ready) + XCTAssertEqual(decoded.lastPlayedAt, Date(timeIntervalSince1970: 1_700_000_000)) + } + + func testJavaRuntimeReportsArchitectureAndRecommendedState() { + let runtime = JavaRuntime( + name: "Temurin 21", + version: "21.0.3", + majorVersion: 21, + architecture: .arm64, + executableURL: URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java") + ) + + XCTAssertEqual(runtime.displayName, "Temurin 21 · Java 21 · Apple Silicon") + XCTAssertTrue(runtime.isRecommended(for: "1.21.5")) + XCTAssertFalse(runtime.isRecommended(for: "1.16.5")) + } + + func testDownloadJobProgressAndCompletion() { + var job = DownloadJob( + title: "Minecraft 1.21.5", + source: .official, + destination: URL(fileURLWithPath: "/tmp/client.jar"), + totalBytes: 100 + ) + + job.update(completedBytes: 25) + XCTAssertEqual(job.progress, 0.25, accuracy: 0.001) + XCTAssertEqual(job.status, .running) + + job.update(completedBytes: 100) + XCTAssertEqual(job.progress, 1.0, accuracy: 0.001) + XCTAssertEqual(job.status, .completed) + } + + func testDiagnosticReportProducesChineseSummary() { + let report = DiagnosticReport( + title: "Java 架构不匹配", + severity: .warning, + summary: "当前实例需要 arm64 Java 运行时。", + suggestedActions: ["安装 Apple Silicon 版本的 Java 21", "在实例设置中重新选择 Java"] + ) + + XCTAssertEqual(report.localizedSeverity, "警告") + XCTAssertTrue(report.fullMessage.contains("Java 架构不匹配")) + XCTAssertTrue(report.fullMessage.contains("安装 Apple Silicon 版本的 Java 21")) + } + + func testVersionMetadataParsesModernLaunchArguments() throws { + let json = """ + { + "id": "1.21.5", + "mainClass": "net.minecraft.client.main.Main", + "assets": "19", + "assetIndex": { + "id": "19", + "url": "https://piston-meta.mojang.com/v1/packages/assets.json", + "sha1": "asset-sha1", + "size": 321 + }, + "downloads": { + "client": { + "url": "https://piston-data.mojang.com/v1/objects/client.jar", + "sha1": "client-sha1", + "size": 123 + } + }, + "libraries": [], + "arguments": { + "jvm": [ + "-cp", + "${classpath}", + { + "rules": [{ "action": "allow", "os": { "name": "osx" } }], + "value": ["-XstartOnFirstThread", "-Xdock:name=${launcher_name}"] + } + ], + "game": ["--username", "${auth_player_name}"] + } + } + """ + + let metadata = try VersionManifestService().decodeVersionMetadata(from: Data(json.utf8)) + + XCTAssertEqual(metadata.arguments?.jvm.count, 3) + XCTAssertEqual(metadata.arguments?.jvm[0].value.strings, ["-cp"]) + XCTAssertEqual(metadata.arguments?.jvm[2].value.strings, ["-XstartOnFirstThread", "-Xdock:name=${launcher_name}"]) + XCTAssertTrue(metadata.arguments?.jvm[2].applies(to: "osx") == true) + XCTAssertFalse(metadata.arguments?.jvm[2].applies(to: "windows") == true) + } +} diff --git a/MMCLTests/LauncherServiceTests.swift b/MMCLTests/LauncherServiceTests.swift new file mode 100644 index 0000000..579dd76 --- /dev/null +++ b/MMCLTests/LauncherServiceTests.swift @@ -0,0 +1,527 @@ +import XCTest +@testable import MMCL + +final class LauncherServiceTests: XCTestCase { + func testApplicationSupportRootUsesMMCLDirectory() throws { + let service = InstanceService(applicationSupportDirectory: URL(fileURLWithPath: "/Users/example/Library/Application Support", isDirectory: true)) + + XCTAssertEqual(service.rootDirectory.path, "/Users/example/Library/Application Support/MMCL") + XCTAssertEqual(service.instancesDirectory.path, "/Users/example/Library/Application Support/MMCL/Instances") + } + + func testVersionManifestParsesLatestReleaseAndVersions() throws { + let json = """ + { + "latest": { "release": "1.21.5", "snapshot": "25w21a" }, + "versions": [ + { + "id": "1.21.5", + "type": "release", + "url": "https://piston-meta.mojang.com/v1/packages/1.21.5.json", + "time": "2026-05-20T10:00:00+00:00", + "releaseTime": "2026-05-20T10:00:00+00:00" + }, + { + "id": "25w21a", + "type": "snapshot", + "url": "https://piston-meta.mojang.com/v1/packages/25w21a.json", + "time": "2026-05-21T10:00:00+00:00", + "releaseTime": "2026-05-21T10:00:00+00:00" + } + ] + } + """ + + let manifest = try VersionManifestService().decodeManifest(from: Data(json.utf8)) + + XCTAssertEqual(manifest.latest.release, "1.21.5") + XCTAssertEqual(manifest.latest.snapshot, "25w21a") + XCTAssertEqual(manifest.versions.map(\.id), ["1.21.5", "25w21a"]) + XCTAssertEqual(manifest.versions[0].type, .release) + XCTAssertEqual(manifest.versions[0].recommendedJavaMajorVersion, 21) + XCTAssertEqual(manifest.versions[0].metadataURL.absoluteString, "https://piston-meta.mojang.com/v1/packages/1.21.5.json") + } + + func testJavaRuntimeServiceParsesJavaHomeOutput() throws { + let output = """ + Matching Java Virtual Machines (2): + 21.0.3 (arm64) \"Eclipse Adoptium\" - \"Temurin 21\" /Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home + 17.0.11 (x86_64) \"Azul Systems\" - \"Zulu 17\" /Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home + /Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home + """ + + let runtimes = JavaRuntimeService().parseJavaHomeVerboseOutput(output) + + XCTAssertEqual(runtimes.count, 2) + XCTAssertEqual(runtimes[0].name, "Temurin 21") + XCTAssertEqual(runtimes[0].version, "21.0.3") + XCTAssertEqual(runtimes[0].majorVersion, 21) + XCTAssertEqual(runtimes[0].architecture, .arm64) + XCTAssertEqual(runtimes[0].executableURL.path, "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java") + XCTAssertEqual(runtimes[1].architecture, .x86_64) + } + + func testLaunchServiceBuildsMinecraftArgumentPreview() { + let instance = LauncherInstance( + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: URL(fileURLWithPath: "/Users/example/Instances/vanilla", isDirectory: true), + profile: LaunchProfile(offlineUsername: "Steve", memoryMegabytes: 4096, jvmArguments: ["-XX:+UseG1GC"], resolutionWidth: 854, resolutionHeight: 480), + status: .ready + ) + let java = JavaRuntime( + name: "Temurin 21", + version: "21.0.3", + majorVersion: 21, + architecture: .arm64, + executableURL: URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java") + ) + + let command = LaunchService().previewCommand(for: instance, java: java) + + XCTAssertEqual(command[0], "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java") + XCTAssertTrue(command.contains("-Xmx4096m")) + XCTAssertTrue(command.contains("-Djava.library.path=/Users/example/Instances/vanilla/.minecraft/versions/1.21.5/natives")) + XCTAssertTrue(command.contains("--username")) + XCTAssertTrue(command.contains("Steve")) + XCTAssertTrue(command.contains("--gameDir")) + XCTAssertTrue(command.contains("/Users/example/Instances/vanilla/.minecraft")) + XCTAssertTrue(command.contains("--version")) + XCTAssertTrue(command.contains("1.21.5")) + } + + func testLaunchServiceBuildsPreciseClasspathFromLocalVersionMetadata() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let instance = LauncherInstance( + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: root, + profile: LaunchProfile(offlineUsername: "Steve", memoryMegabytes: 4096, jvmArguments: [], resolutionWidth: 854, resolutionHeight: 480), + status: .ready + ) + let metadata = try VersionManifestService().decodeVersionMetadata(from: Data(Self.versionMetadataJSON.utf8)) + _ = try DownloadService().writeVersionMetadata(metadata: metadata, instance: instance) + let java = JavaRuntime( + name: "Temurin 21", + version: "21.0.3", + majorVersion: 21, + architecture: .arm64, + executableURL: URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java") + ) + + let command = LaunchService().previewCommand(for: instance, java: java) + let classpath = try XCTUnwrap(command.argument(after: "-cp")) + + XCTAssertFalse(classpath.contains("libraries/*")) + XCTAssertTrue(classpath.contains(root.appendingPathComponent(".minecraft/libraries/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar").path)) + XCTAssertTrue(classpath.contains(root.appendingPathComponent(".minecraft/versions/1.21.5/1.21.5.jar").path)) + XCTAssertTrue(command.contains("net.minecraft.client.main.Main")) + XCTAssertEqual(command.argument(after: "--assetIndex"), "19") + } + + func testLaunchServiceExpandsModernMojangArguments() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let instance = LauncherInstance( + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: root, + profile: LaunchProfile(offlineUsername: "Steve", memoryMegabytes: 4096, jvmArguments: ["-XX:+UseG1GC"], resolutionWidth: 854, resolutionHeight: 480), + status: .ready + ) + let metadata = try VersionManifestService().decodeVersionMetadata(from: Data(Self.modernArgumentsMetadataJSON.utf8)) + _ = try DownloadService().writeVersionMetadata(metadata: metadata, instance: instance) + let java = JavaRuntime( + name: "Temurin 21", + version: "21.0.3", + majorVersion: 21, + architecture: .arm64, + executableURL: URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java") + ) + + let command = LaunchService().previewCommand(for: instance, java: java) + let classpath = try XCTUnwrap(command.argument(after: "-cp")) + + XCTAssertTrue(command.contains("-XX:+UseG1GC")) + XCTAssertTrue(command.contains("-Djava.library.path=\(root.path)/.minecraft/versions/1.21.5/natives")) + XCTAssertTrue(command.contains("-Xdock:name=MMCL")) + XCTAssertFalse(command.contains("-Dos.name=Windows")) + XCTAssertEqual(classpath, [ + root.appendingPathComponent(".minecraft/libraries/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar").path, + root.appendingPathComponent(".minecraft/versions/1.21.5/1.21.5.jar").path + ].joined(separator: ":")) + XCTAssertEqual(command.argument(after: "--username"), "Steve") + XCTAssertEqual(command.argument(after: "--assetsDir"), root.appendingPathComponent(".minecraft/assets").path) + XCTAssertEqual(command.argument(after: "--assetIndex"), "19") + } + + func testLaunchServiceExpandsLegacyMinecraftArguments() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let instance = LauncherInstance( + name: "旧版生存", + gameVersion: "1.12.2", + loader: .vanilla, + rootDirectory: root, + profile: LaunchProfile(offlineUsername: "Alex", memoryMegabytes: 2048, jvmArguments: [], resolutionWidth: 854, resolutionHeight: 480), + status: .ready + ) + let metadata = try VersionManifestService().decodeVersionMetadata(from: Data(Self.legacyArgumentsMetadataJSON.utf8)) + _ = try DownloadService().writeVersionMetadata(metadata: metadata, instance: instance) + let java = JavaRuntime( + name: "Temurin 8", + version: "1.8.0", + majorVersion: 8, + architecture: .arm64, + executableURL: URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines/temurin-8.jdk/Contents/Home/bin/java") + ) + + let command = LaunchService().previewCommand(for: instance, java: java) + + XCTAssertEqual(command.argument(after: "--username"), "Alex") + XCTAssertEqual(command.argument(after: "--version"), "1.12.2") + XCTAssertEqual(command.argument(after: "--assetIndex"), "legacy") + XCTAssertTrue(command.contains("net.minecraft.client.main.Main")) + } + + func testLaunchServicePreflightReportsMissingInstallFiles() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let instance = LauncherInstance( + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: root, + status: .ready + ) + let metadata = try VersionManifestService().decodeVersionMetadata(from: Data(Self.versionMetadataJSON.utf8)) + _ = try DownloadService().writeVersionMetadata(metadata: metadata, instance: instance) + let java = JavaRuntime( + name: "Temurin 21", + version: "21.0.3", + majorVersion: 21, + architecture: .arm64, + executableURL: URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java") + ) + + let report = LaunchService().preflight(instance: instance, java: java) + + XCTAssertFalse(report.canLaunch) + XCTAssertEqual(report.severity, .error) + XCTAssertTrue(report.summary.contains("client jar")) + XCTAssertTrue(report.summary.contains("asset index")) + XCTAssertTrue(report.summary.contains("library")) + XCTAssertEqual(report.suggestedActions.first, "生成安装计划并完成下载") + } + + func testLaunchServicePreflightPassesForCompleteVanillaInstance() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let instance = LauncherInstance( + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: root, + status: .ready + ) + let metadata = try VersionManifestService().decodeVersionMetadata(from: Data(Self.versionMetadataJSON.utf8)) + _ = try DownloadService().writeVersionMetadata(metadata: metadata, instance: instance) + try Data("client".utf8).write(to: root.appendingPathComponent(".minecraft/versions/1.21.5/1.21.5.jar")) + let assetIndex = root.appendingPathComponent(".minecraft/assets/indexes/19.json") + try FileManager.default.createDirectory(at: assetIndex.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("assets".utf8).write(to: assetIndex) + let library = root.appendingPathComponent(".minecraft/libraries/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar") + try FileManager.default.createDirectory(at: library.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("library".utf8).write(to: library) + let java = JavaRuntime( + name: "Temurin 21", + version: "21.0.3", + majorVersion: 21, + architecture: .arm64, + executableURL: URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java") + ) + + let report = LaunchService().preflight(instance: instance, java: java) + + XCTAssertTrue(report.canLaunch) + XCTAssertEqual(report.severity, .info) + XCTAssertEqual(report.summary, "启动前检查通过。") + XCTAssertTrue(report.suggestedActions.isEmpty) + } + + func testLaunchServiceStartsProcessAndCreatesLatestLog() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let instance = LauncherInstance( + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: root, + profile: LaunchProfile(offlineUsername: "Steve", memoryMegabytes: 512, jvmArguments: [], resolutionWidth: 854, resolutionHeight: 480), + status: .ready + ) + let java = JavaRuntime( + name: "Echo", + version: "1.0", + majorVersion: 21, + architecture: .universal, + executableURL: URL(fileURLWithPath: "/bin/echo") + ) + + let session = try LaunchService().launch(instance: instance, java: java) + + XCTAssertGreaterThan(session.processIdentifier, 0) + XCTAssertEqual(session.logFileURL.path, root.appendingPathComponent("logs/latest.log").path) + XCTAssertTrue(FileManager.default.fileExists(atPath: root.appendingPathComponent(".minecraft").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: session.logFileURL.path)) + XCTAssertEqual(session.command.first, "/bin/echo") + } + + private static let versionMetadataJSON = """ + { + "id": "1.21.5", + "mainClass": "net.minecraft.client.main.Main", + "assets": "19", + "assetIndex": { + "id": "19", + "url": "https://piston-meta.mojang.com/v1/packages/assets.json", + "sha1": "asset-sha1", + "size": 321 + }, + "downloads": { + "client": { + "url": "https://piston-data.mojang.com/v1/objects/client.jar", + "sha1": "client-sha1", + "size": 123 + } + }, + "libraries": [ + { + "name": "org.lwjgl:lwjgl:3.3.3", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar", + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar", + "sha1": "library-sha1", + "size": 456 + } + } + } + ] + } + """ + + private static let modernArgumentsMetadataJSON = """ + { + "id": "1.21.5", + "mainClass": "net.minecraft.client.main.Main", + "assets": "19", + "assetIndex": { + "id": "19", + "url": "https://piston-meta.mojang.com/v1/packages/assets.json", + "sha1": "asset-sha1", + "size": 321 + }, + "downloads": { + "client": { + "url": "https://piston-data.mojang.com/v1/objects/client.jar", + "sha1": "client-sha1", + "size": 123 + } + }, + "libraries": [ + { + "name": "org.lwjgl:lwjgl:3.3.3", + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar", + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar", + "sha1": "library-sha1", + "size": 456 + } + } + } + ], + "arguments": { + "jvm": [ + "-Djava.library.path=${natives_directory}", + "-cp", + "${classpath}", + { + "rules": [{ "action": "allow", "os": { "name": "osx" } }], + "value": "-Xdock:name=${launcher_name}" + }, + { + "rules": [{ "action": "allow", "os": { "name": "windows" } }], + "value": "-Dos.name=Windows" + } + ], + "game": [ + "--username", + "${auth_player_name}", + "--version", + "${version_name}", + "--gameDir", + "${game_directory}", + "--assetsDir", + "${assets_root}", + "--assetIndex", + "${assets_index_name}", + "--accessToken", + "${auth_access_token}", + "--userType", + "${user_type}" + ] + } + } + """ + + func testFabricServiceFetchesLoaderVersionsFromAPI() async throws { + // Test with local file would be ideal, but the API is simple enough + // to test the model parsing + let json = """ + [{"version":"0.16.14","stable":true},{"version":"0.16.13","stable":false}] + """.data(using: .utf8)! + let versions = try JSONDecoder.mmcl.decode([FabricLoaderVersion].self, from: json) + XCTAssertEqual(versions.count, 2) + XCTAssertEqual(versions.first?.version, "0.16.14") + XCTAssertTrue(versions.first?.stable == true) + } + + func testFabricProfileParsesMainClassAndInheritsFrom() throws { + let json = """ + { + "id": "1.21.5-fabric-0.16.14", + "inheritsFrom": "1.21.5", + "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", + "arguments": { + "game": ["--assetIndex", "${assets_index_name}"] + } + } + """.data(using: .utf8)! + let profile = try JSONDecoder.mmcl.decode(FabricProfile.self, from: json) + XCTAssertEqual(profile.id, "1.21.5-fabric-0.16.14") + XCTAssertEqual(profile.inheritsFrom, "1.21.5") + XCTAssertEqual(profile.mainClass, "net.fabricmc.loader.impl.launch.knot.KnotClient") + XCTAssertEqual(profile.arguments?.game?.first, "--assetIndex") + } + + func testModrinthSearchResponseParsesHits() throws { + let json = """ + { + "hits": [ + {"id": "AqQJnBxM", "slug": "sodium", "title": "Sodium", "description": "A modern rendering engine", "projectType": "mod", "downloads": 5000000, "categories": ["performance", "optimization"]} + ], + "total_hits": 1 + } + """.data(using: .utf8)! + let response = try JSONDecoder.mmcl.decode(ModrinthSearchResponse.self, from: json) + XCTAssertEqual(response.totalHits, 1) + XCTAssertEqual(response.hits.first?.title, "Sodium") + XCTAssertEqual(response.hits.first?.downloads, 5000000) + } + + func testModrinthVersionParsesFilesAndLoaders() throws { + let json = """ + [ + { + "id": "abc123", + "name": "Sodium 0.6.0", + "version_number": "0.6.0", + "game_versions": ["1.21.5"], + "loaders": ["fabric"], + "files": [ + {"filename": "sodium-fabric-0.6.0.jar", "url": "https://example.com/sodium.jar", "size": 12345, "primary": true} + ] + } + ] + """.data(using: .utf8)! + let versions = try JSONDecoder.mmcl.decode([ModrinthVersion].self, from: json) + XCTAssertEqual(versions.count, 1) + XCTAssertEqual(versions.first?.files.first?.filename, "sodium-fabric-0.6.0.jar") + XCTAssertEqual(versions.first?.loaders, ["fabric"]) + } + + func testQuiltLoaderVersionParsesCorrectly() throws { + let json = """ + [{"version":"0.5.0","stable":true},{"version":"0.4.0","stable":false}] + """.data(using: .utf8)! + let versions = try JSONDecoder.mmcl.decode([QuiltLoaderVersion].self, from: json) + XCTAssertEqual(versions.count, 2) + XCTAssertTrue(versions[0].stable) + } + + func testForgeVersionParsesPromotions() throws { + let json = """ + {"promos":{"1.21.5-latest":"56.0.1","1.21.5-recommended":"56.0.0","1.20.1-latest":"47.3.0"}} + """.data(using: .utf8)! + let promo = try JSONSerialization.jsonObject(with: json) as? [String: Any] + let promos = promo?["promos"] as? [String: String] ?? [:] + XCTAssertEqual(promos["1.21.5-latest"], "56.0.1") + XCTAssertEqual(promos["1.20.1-latest"], "47.3.0") + } + + func testModrinthVersionRowDisplaysCorrectInfo() throws { + let file = ModrinthFile(filename: "mod.jar", url: "https://example.com/mod.jar", size: 1000, primary: true) + let version = ModrinthVersion(id: "v1", name: "Mod 1.0", versionNumber: "1.0.0", gameVersions: ["1.21.5"], loaders: ["fabric"], files: [file]) + XCTAssertEqual(version.files.first?.filename, "mod.jar") + XCTAssertEqual(version.loaders, ["fabric"]) + } + + private static let legacyArgumentsMetadataJSON = """ + { + "id": "1.12.2", + "mainClass": "net.minecraft.client.main.Main", + "assets": "legacy", + "assetIndex": { + "id": "legacy", + "url": "https://piston-meta.mojang.com/v1/packages/assets.json", + "sha1": "asset-sha1", + "size": 321 + }, + "downloads": { + "client": { + "url": "https://piston-data.mojang.com/v1/objects/client.jar", + "sha1": "client-sha1", + "size": 123 + } + }, + "libraries": [], + "minecraftArguments": "--username ${auth_player_name} --version ${version_name} --gameDir ${game_directory} --assetsDir ${assets_root} --assetIndex ${assets_index_name} --accessToken ${auth_access_token} --userType ${user_type}" + } + """ + + func testMinecraftAccountDisplayNames() { + let offline = MinecraftAccount(username: "Steve", type: .offline) + let online = MinecraftAccount(username: "Notch", uuid: "abc", accessToken: "token", refreshToken: "refresh", type: .microsoft) + XCTAssertEqual(offline.displayName, "Steve(离线)") + XCTAssertEqual(online.displayName, "Notch") + } + + func testMinecraftAccountRoundTripsThroughJSON() throws { + let account = MinecraftAccount(username: "Test", uuid: "uuid-123", accessToken: "at", refreshToken: "rt", expiresAt: Date(timeIntervalSince1970: 1000), type: .microsoft) + let data = try JSONEncoder.mmcl.encode(account) + let decoded = try JSONDecoder.mmcl.decode(MinecraftAccount.self, from: data) + XCTAssertEqual(decoded.username, "Test") + XCTAssertEqual(decoded.type, .microsoft) + } +} + +private extension Array where Element == String { + func argument(after marker: String) -> String? { + guard let index = firstIndex(of: marker) else { return nil } + let nextIndex = self.index(after: index) + guard nextIndex < endIndex else { return nil } + return self[nextIndex] + } +} diff --git a/MMCLTests/LauncherStoreTests.swift b/MMCLTests/LauncherStoreTests.swift new file mode 100644 index 0000000..d509ae4 --- /dev/null +++ b/MMCLTests/LauncherStoreTests.swift @@ -0,0 +1,551 @@ +import XCTest +@testable import MMCL + +final class LauncherStoreTests: XCTestCase { + func testStoreBuildsLaunchPreviewForSelectedInstanceAndJava() { + let instanceID = UUID() + let instance = LauncherInstance( + id: instanceID, + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: URL(fileURLWithPath: "/Users/example/Instances/vanilla", isDirectory: true), + profile: LaunchProfile(offlineUsername: "Steve", memoryMegabytes: 4096, jvmArguments: [], resolutionWidth: 854, resolutionHeight: 480), + status: .ready + ) + let runtime = JavaRuntime( + name: "Temurin 21", + version: "21.0.3", + majorVersion: 21, + architecture: .arm64, + executableURL: URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java") + ) + let store = LauncherStore( + instances: [instance], + downloadJobs: [], + featuredProjects: [], + diagnostics: [], + javaRuntimes: [runtime], + availableVersions: [] + ) + store.selectedSection = .launcher + store.launcherSelectedInstanceID = instanceID + store.selectedJavaRuntimeID = runtime.id + + let preview = store.launchPreviewForSelectedInstance() + + XCTAssertNotNil(preview) + XCTAssertEqual(preview?.java.displayName, "Temurin 21 · Java 21 · Apple Silicon") + XCTAssertTrue(preview?.command.contains("--username") == true) + XCTAssertTrue(preview?.command.contains("Steve") == true) + } + + func testStoreRefreshesJavaRuntimesAndSelectsRecommendedRuntimeForInstance() async { + let instanceID = UUID() + let instance = LauncherInstance( + id: instanceID, + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: URL(fileURLWithPath: "/Users/example/Instances/vanilla", isDirectory: true), + status: .ready + ) + let java17 = JavaRuntime( + name: "Zulu 17", + version: "17.0.11", + majorVersion: 17, + architecture: .x86_64, + executableURL: URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/bin/java") + ) + let java21 = JavaRuntime( + name: "Temurin 21", + version: "21.0.3", + majorVersion: 21, + architecture: .arm64, + executableURL: URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java") + ) + let store = LauncherStore( + instances: [instance], + downloadJobs: [], + featuredProjects: [], + diagnostics: [], + javaRuntimes: [], + availableVersions: [], + javaRuntimeService: StubJavaRuntimeService(runtimes: [java17, java21]) + ) + store.selectedSection = .launcher + store.launcherSelectedInstanceID = instanceID + + await store.refreshJavaRuntimes() + + XCTAssertEqual(store.javaRuntimes.map(\.majorVersion), [17, 21]) + XCTAssertEqual(store.selectedJavaRuntimeID, java21.id) + XCTAssertEqual(store.diagnostics.first?.title, "Java 运行时已刷新") + } + + func testStoreLaunchesSelectedInstanceAndRecordsSession() { + let instanceID = UUID() + let instance = LauncherInstance( + id: instanceID, + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: URL(fileURLWithPath: "/Users/example/Instances/vanilla", isDirectory: true), + status: .ready + ) + let runtime = JavaRuntime( + name: "Temurin 21", + version: "21.0.3", + majorVersion: 21, + architecture: .arm64, + executableURL: URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java") + ) + let expectedSession = LaunchSession( + processIdentifier: 42, + command: [runtime.executableURL.path, "-version"], + logFileURL: URL(fileURLWithPath: "/Users/example/Instances/vanilla/logs/latest.log"), + startedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + let store = LauncherStore( + instances: [instance], + downloadJobs: [], + featuredProjects: [], + diagnostics: [], + javaRuntimes: [runtime], + availableVersions: [], + launchService: StubLaunchService(session: expectedSession) + ) + store.selectedSection = .launcher + store.launcherSelectedInstanceID = instanceID + store.selectedJavaRuntimeID = runtime.id + + store.launchSelectedInstance() + + XCTAssertEqual(store.currentLaunchSession, expectedSession) + XCTAssertEqual(store.diagnostics.first?.title, "Minecraft 已启动") + XCTAssertTrue(store.diagnostics.first?.summary.contains("42") == true) + } + + func testStoreScansSkinsForAccount() { + let store = LauncherStore( + instances: [], + downloadJobs: [], + featuredProjects: [], + diagnostics: [], + javaRuntimes: [], + availableVersions: [] + ) + + // Skin scanning with empty directory should return empty + let account = MinecraftAccount(username: "Test", uuid: "test-uuid", type: .offline) + store.scanSkinsForAccount(account) + XCTAssertTrue(store.availableSkins.isEmpty) + } + + func testStoreBlocksLaunchWhenPreflightFails() { + let instanceID = UUID() + let instance = LauncherInstance( + id: instanceID, + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: URL(fileURLWithPath: "/Users/example/Instances/vanilla", isDirectory: true), + status: .missingFiles + ) + let runtime = JavaRuntime( + name: "Temurin 21", + version: "21.0.3", + majorVersion: 21, + architecture: .arm64, + executableURL: URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java") + ) + let failingLaunchService = StubLaunchService( + session: LaunchSession( + processIdentifier: 42, + command: [runtime.executableURL.path, "-version"], + logFileURL: URL(fileURLWithPath: "/Users/example/Instances/vanilla/logs/latest.log") + ), + preflightReport: LaunchPreflightReport( + severity: .error, + summary: "缺少 client jar。", + suggestedActions: ["生成安装计划并完成下载", "准备 Native"] + ) + ) + let store = LauncherStore( + instances: [instance], + downloadJobs: [], + featuredProjects: [], + diagnostics: [], + javaRuntimes: [runtime], + availableVersions: [], + launchService: failingLaunchService + ) + store.selectedSection = .launcher + store.launcherSelectedInstanceID = instanceID + store.selectedJavaRuntimeID = runtime.id + + store.launchSelectedInstance() + + XCTAssertNil(store.currentLaunchSession) + XCTAssertFalse(failingLaunchService.didLaunch) + XCTAssertEqual(store.instances.first?.status, .missingFiles) + XCTAssertEqual(store.diagnostics.first?.title, "启动前检查未通过") + XCTAssertEqual(store.diagnostics.first?.suggestedActions.first, "生成安装计划并完成下载") + } + + func testStoreCreatesInstanceAndSelectsIt() { + let store = LauncherStore( + instances: [], + downloadJobs: [], + featuredProjects: [], + diagnostics: [], + javaRuntimes: [], + availableVersions: [], + instanceService: MockInstanceService() + ) + + store.createInstance( + name: "测试实例", + gameVersion: "1.21.5", + loader: .vanilla + ) + + XCTAssertEqual(store.instances.count, 1) + XCTAssertEqual(store.instances.first?.name, "测试实例") + XCTAssertEqual(store.instances.first?.gameVersion, "1.21.5") + XCTAssertNotNil(store.selectedInstance) + XCTAssertEqual(store.selectedInstance?.name, "测试实例") + XCTAssertFalse(store.showingCreateSheet) + } + + func testStoreDeletesInstanceAndUpdatesSelection() { + let instanceID = UUID() + let instance = LauncherInstance( + id: instanceID, + name: "待删除", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: URL(fileURLWithPath: "/tmp/mmcl-test-delete-\(UUID())", isDirectory: true), + status: .notInstalled + ) + let store = LauncherStore( + instances: [instance], + downloadJobs: [], + featuredProjects: [], + diagnostics: [], + javaRuntimes: [], + availableVersions: [] + ) + store.selectedSection = .launcher + store.launcherSelectedInstanceID = instanceID + + store.deleteInstance(instance) + + XCTAssertTrue(store.instances.isEmpty) + XCTAssertNil(store.selectedInstance) + } + + func testStoreInspectsSelectedInstanceAndReportsRepairActions() { + let instanceID = UUID() + let instance = LauncherInstance( + id: instanceID, + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: URL(fileURLWithPath: "/Users/example/Instances/vanilla", isDirectory: true), + status: .ready + ) + let runtime = JavaRuntime( + name: "Temurin 21", + version: "21.0.3", + majorVersion: 21, + architecture: .arm64, + executableURL: URL(fileURLWithPath: "/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java") + ) + let store = LauncherStore( + instances: [instance], + downloadJobs: [], + featuredProjects: [], + diagnostics: [], + javaRuntimes: [runtime], + availableVersions: [], + launchService: StubLaunchService( + session: LaunchSession( + processIdentifier: 42, + command: [runtime.executableURL.path, "-version"], + logFileURL: URL(fileURLWithPath: "/Users/example/Instances/vanilla/logs/latest.log") + ), + preflightReport: LaunchPreflightReport( + severity: .error, + summary: "缺少 asset index。", + suggestedActions: ["生成安装计划并完成下载"] + ) + ) + ) + store.selectedSection = .launcher + store.launcherSelectedInstanceID = instanceID + store.selectedJavaRuntimeID = runtime.id + + store.inspectSelectedInstance() + + XCTAssertEqual(store.instances.first?.status, .missingFiles) + XCTAssertEqual(store.diagnostics.first?.title, "实例需要修复") + XCTAssertEqual(store.diagnostics.first?.summary, "缺少 asset index。") + } + + func testStorePreparesNativeLibrariesAndMarksInstanceReady() throws { + let instanceID = UUID() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let instance = LauncherInstance( + id: instanceID, + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: root, + status: .notInstalled + ) + let metadata = try VersionManifestService().decodeVersionMetadata(from: Data(Self.versionMetadataJSON.utf8)) + let nativeArchive = root + .appendingPathComponent(".minecraft/libraries/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar") + try FileManager.default.createDirectory( + at: nativeArchive.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let zipSource = root.appendingPathComponent("zip-source", isDirectory: true) + try FileManager.default.createDirectory(at: zipSource, withIntermediateDirectories: true) + try Data("native".utf8).write(to: zipSource.appendingPathComponent("libmmcl.dylib")) + try Self.zip(contentsOf: zipSource, destination: nativeArchive) + let store = LauncherStore( + instances: [instance], + downloadJobs: [], + featuredProjects: [], + diagnostics: [], + javaRuntimes: [], + availableVersions: [] + ) + store.selectedSection = .launcher + store.launcherSelectedInstanceID = instanceID + store.planVanillaInstall(metadata: metadata, for: instance) + + store.prepareNativeLibrariesForSelectedInstance() + + XCTAssertEqual(store.instances.first?.status, .ready) + XCTAssertEqual(store.diagnostics.first?.title, "Native libraries 已准备") + XCTAssertTrue(FileManager.default.fileExists( + atPath: root.appendingPathComponent(".minecraft/versions/1.21.5/natives/libmmcl.dylib").path + )) + } + + private static func zip(contentsOf directory: URL, destination: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/zip") + process.arguments = ["-q", "-r", destination.path, "."] + process.currentDirectoryURL = directory + try process.run() + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 0) + } + + private final class StubLaunchService: LaunchServicing { + let session: LaunchSession + let preflightReport: LaunchPreflightReport + private(set) var didLaunch = false + + init( + session: LaunchSession, + preflightReport: LaunchPreflightReport = LaunchPreflightReport( + severity: .info, + summary: "启动前检查通过。", + suggestedActions: [] + ) + ) { + self.session = session + self.preflightReport = preflightReport + } + + func previewCommand(for instance: LauncherInstance, java: JavaRuntime) -> [String] { + session.command + } + + func preflight(instance: LauncherInstance, java: JavaRuntime) -> LaunchPreflightReport { + preflightReport + } + + func launch(instance: LauncherInstance, java: JavaRuntime) throws -> LaunchSession { + didLaunch = true + return session + } + } + + private struct StubJavaRuntimeService: JavaRuntimeServicing { + let runtimes: [JavaRuntime] + + var portableJDKDirectory: URL { + FileManager.default.temporaryDirectory.appendingPathComponent("MMCL-JDK-Test") + } + + func bundledSearchLocations() -> [URL] { + [] + } + + func recommendedMajorVersion(for gameVersion: String) -> Int { + JavaRuntime.recommendedMajorVersion(for: gameVersion) + } + + func parseJavaHomeVerboseOutput(_ output: String) -> [JavaRuntime] { + [] + } + + func discoverInstalledRuntimes() async throws -> [JavaRuntime] { + runtimes + } + } + + private struct MockInstanceService: InstanceServicing { + let rootDirectory: URL = FileManager.default.temporaryDirectory + .appendingPathComponent("MMCLTests-\(UUID().uuidString)", isDirectory: true) + + var instancesDirectory: URL { + rootDirectory.appendingPathComponent("Instances", isDirectory: true) + } + + func createInstance( + name: String, + gameVersion: String, + loader: GameLoader, + profile: LaunchProfile + ) throws -> LauncherInstance { + let slug = InstanceService.slug(for: name) + let instanceRoot = instancesDirectory.appendingPathComponent(slug, isDirectory: true) + try FileManager.default.createDirectory(at: instanceRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: instanceRoot.appendingPathComponent(".minecraft", isDirectory: true), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: instanceRoot.appendingPathComponent("logs", isDirectory: true), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: instanceRoot.appendingPathComponent("mods", isDirectory: true), + withIntermediateDirectories: true + ) + let instance = LauncherInstance( + name: name, + gameVersion: gameVersion, + loader: loader, + rootDirectory: instanceRoot, + profile: profile, + status: .notInstalled + ) + try JSONEncoder.mmcl.encode(instance).write( + to: instanceFileURL(for: instance), + options: .atomic + ) + return instance + } + + func loadAllInstances() throws -> [LauncherInstance] { + let fm = FileManager.default + guard fm.fileExists(atPath: instancesDirectory.path) else { return [] } + let dirs = try fm.contentsOfDirectory( + at: instancesDirectory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + return dirs.compactMap { dir in + let file = dir.appendingPathComponent("instance.json") + guard fm.fileExists(atPath: file.path), + let data = try? Data(contentsOf: file), + let instance = try? JSONDecoder.mmcl.decode(LauncherInstance.self, from: data) else { return nil } + return instance + } + } + + func instanceFileURL(for instance: LauncherInstance) -> URL { + instance.rootDirectory.appendingPathComponent("instance.json") + } + + func encode(_ instance: LauncherInstance) throws -> Data { + try JSONEncoder.mmcl.encode(instance) + } + + func decode(from data: Data) throws -> LauncherInstance { + try JSONDecoder.mmcl.decode(LauncherInstance.self, from: data) + } + } + + func testLaunchSessionTrackingResetsOnExit() { + let store = LauncherStore( + instances: [], + downloadJobs: [], + featuredProjects: [], + diagnostics: [], + javaRuntimes: [], + availableVersions: [] + ) + XCTAssertNil(store.currentLaunchSession) + } + + func testCancelDownloadsMarksAllQueuedAndRunningAsFailed() { + let store = LauncherStore( + instances: [], + downloadJobs: [ + DownloadJob(title: "A", source: .official, destination: URL(fileURLWithPath: "/tmp/a"), totalBytes: 100, status: .queued), + DownloadJob(title: "B", source: .official, destination: URL(fileURLWithPath: "/tmp/b"), totalBytes: 100, status: .running), + DownloadJob(title: "C", source: .official, destination: URL(fileURLWithPath: "/tmp/c"), totalBytes: 100, status: .completed), + ], + featuredProjects: [], + diagnostics: [], + javaRuntimes: [], + availableVersions: [] + ) + + store.cancelDownloads() + + XCTAssertEqual(store.downloadJobs[0].status, .failed) + XCTAssertEqual(store.downloadJobs[1].status, .failed) + XCTAssertEqual(store.downloadJobs[2].status, .completed) + } + + private static let versionMetadataJSON = """ + { + "id": "1.21.5", + "mainClass": "net.minecraft.client.main.Main", + "assets": "19", + "assetIndex": { + "id": "19", + "url": "https://piston-meta.mojang.com/v1/packages/assets.json", + "sha1": "asset-sha1", + "size": 321 + }, + "downloads": { + "client": { + "url": "https://piston-data.mojang.com/v1/objects/client.jar", + "sha1": "client-sha1", + "size": 123 + } + }, + "libraries": [ + { + "name": "org.lwjgl:lwjgl:3.3.3", + "natives": { + "osx": "natives-macos" + }, + "downloads": { + "classifiers": { + "natives-macos": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar", + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar", + "sha1": "native-sha1", + "size": 789 + } + } + } + } + ] + } + """ +} diff --git a/MMCLTests/VanillaInstallPlanningTests.swift b/MMCLTests/VanillaInstallPlanningTests.swift new file mode 100644 index 0000000..34aea38 --- /dev/null +++ b/MMCLTests/VanillaInstallPlanningTests.swift @@ -0,0 +1,244 @@ +import XCTest +@testable import MMCL + +final class VanillaInstallPlanningTests: XCTestCase { + func testVersionMetadataParsesClientAssetIndexAndLibraries() throws { + let json = """ + { + "id": "1.21.5", + "mainClass": "net.minecraft.client.main.Main", + "assets": "19", + "assetIndex": { + "id": "19", + "url": "https://piston-meta.mojang.com/v1/packages/assets.json", + "sha1": "asset-sha1", + "size": 321 + }, + "downloads": { + "client": { + "url": "https://piston-data.mojang.com/v1/objects/client.jar", + "sha1": "client-sha1", + "size": 123 + } + }, + "libraries": [ + { + "name": "org.lwjgl:lwjgl:3.3.3", + "natives": { + "osx": "natives-macos" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar", + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar", + "sha1": "library-sha1", + "size": 456 + }, + "classifiers": { + "natives-macos": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar", + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar", + "sha1": "native-sha1", + "size": 789 + } + } + } + } + ] + } + """ + + let metadata = try VersionManifestService().decodeVersionMetadata(from: Data(json.utf8)) + + XCTAssertEqual(metadata.id, "1.21.5") + XCTAssertEqual(metadata.mainClass, "net.minecraft.client.main.Main") + XCTAssertEqual(metadata.assetIndex.id, "19") + XCTAssertEqual(metadata.downloads.client.size, 123) + XCTAssertEqual(metadata.libraries[0].artifact?.path, "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar") + XCTAssertEqual(metadata.libraries[0].nativeArtifact()?.path, "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar") + } + + func testDownloadServicePlansVanillaInstallJobs() throws { + let metadata = try VersionManifestService().decodeVersionMetadata(from: Data(Self.versionMetadataJSON.utf8)) + let instance = LauncherInstance( + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: URL(fileURLWithPath: "/Users/example/Instances/vanilla", isDirectory: true), + status: .notInstalled + ) + + let jobs = DownloadService().makeVanillaInstallJobs(metadata: metadata, instance: instance, source: .official) + + XCTAssertEqual(jobs.map(\.title), [ + "Minecraft 1.21.5 客户端", + "Minecraft 1.21.5 资源索引", + "org.lwjgl:lwjgl:3.3.3", + "org.lwjgl:lwjgl:3.3.3 native" + ]) + XCTAssertEqual(jobs[0].remoteURL?.absoluteString, "https://piston-data.mojang.com/v1/objects/client.jar") + XCTAssertEqual(jobs[0].sha1, "client-sha1") + XCTAssertEqual(jobs[0].destination.path, "/Users/example/Instances/vanilla/.minecraft/versions/1.21.5/1.21.5.jar") + XCTAssertEqual(jobs[1].destination.path, "/Users/example/Instances/vanilla/.minecraft/assets/indexes/19.json") + XCTAssertEqual(jobs[2].destination.path, "/Users/example/Instances/vanilla/.minecraft/libraries/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar") + XCTAssertEqual(jobs[3].destination.path, "/Users/example/Instances/vanilla/.minecraft/libraries/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar") + XCTAssertEqual(jobs[3].sha1, "native-sha1") + } + + func testDownloadServicePlansOnlyMissingRepairJobs() throws { + let metadata = try VersionManifestService().decodeVersionMetadata(from: Data(Self.versionMetadataJSON.utf8)) + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let instance = LauncherInstance( + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: root, + status: .missingFiles + ) + let assetIndex = root.appendingPathComponent(".minecraft/assets/indexes/19.json") + let library = root.appendingPathComponent(".minecraft/libraries/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar") + try FileManager.default.createDirectory(at: assetIndex.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: library.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("asset-index".utf8).write(to: assetIndex) + try Data("library".utf8).write(to: library) + + let jobs = DownloadService().makeVanillaRepairJobs(metadata: metadata, instance: instance, source: .official) + + XCTAssertEqual(jobs.map(\.title), [ + "Minecraft 1.21.5 客户端", + "org.lwjgl:lwjgl:3.3.3 native" + ]) + } + + func testDownloadServiceWritesVersionMetadataToVersionDirectory() throws { + let metadata = try VersionManifestService().decodeVersionMetadata(from: Data(Self.versionMetadataJSON.utf8)) + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let instance = LauncherInstance( + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: root, + status: .notInstalled + ) + + let metadataURL = try DownloadService().writeVersionMetadata(metadata: metadata, instance: instance) + let saved = try VersionManifestService().decodeVersionMetadata(from: Data(contentsOf: metadataURL)) + + XCTAssertEqual(metadataURL.path, root.appendingPathComponent(".minecraft/versions/1.21.5/1.21.5.json").path) + XCTAssertEqual(saved, metadata) + } + + func testInstanceServiceCreatesVanillaInstanceOnDisk() throws { + let temporaryRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + let service = InstanceService(applicationSupportDirectory: temporaryRoot) + + let instance = try service.createInstance( + name: "原版 生存!", + gameVersion: "1.21.5", + loader: .vanilla, + profile: LaunchProfile(offlineUsername: "Steve", memoryMegabytes: 4096, jvmArguments: [], resolutionWidth: 854, resolutionHeight: 480) + ) + + XCTAssertEqual(instance.name, "原版 生存!") + XCTAssertEqual(instance.rootDirectory.lastPathComponent, "yuan-ban-sheng-cun") + XCTAssertTrue(FileManager.default.fileExists(atPath: service.instanceFileURL(for: instance).path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: instance.rootDirectory.appendingPathComponent(".minecraft").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: instance.rootDirectory.appendingPathComponent("logs").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: instance.rootDirectory.appendingPathComponent("mods").path)) + + let saved = try service.decode(from: Data(contentsOf: service.instanceFileURL(for: instance))) + XCTAssertEqual(saved, instance) + } + + func testDownloadServicePreparesNativeLibraries() throws { + let metadata = try VersionManifestService().decodeVersionMetadata(from: Data(Self.versionMetadataJSON.utf8)) + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let instance = LauncherInstance( + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: root, + status: .notInstalled + ) + let nativeArchive = root + .appendingPathComponent(".minecraft/libraries/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar") + try FileManager.default.createDirectory( + at: nativeArchive.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let zipSource = root.appendingPathComponent("zip-source", isDirectory: true) + try FileManager.default.createDirectory(at: zipSource, withIntermediateDirectories: true) + try Data("native".utf8).write(to: zipSource.appendingPathComponent("libmmcl.dylib")) + try Self.zip(contentsOf: zipSource, destination: nativeArchive) + + let preparedArchives = try DownloadService().prepareNativeLibraries(metadata: metadata, instance: instance) + + XCTAssertEqual(preparedArchives.map(\.path), [nativeArchive.path]) + XCTAssertTrue(FileManager.default.fileExists( + atPath: root.appendingPathComponent(".minecraft/versions/1.21.5/natives/libmmcl.dylib").path + )) + } + + private static func zip(contentsOf directory: URL, destination: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/zip") + process.arguments = ["-q", "-r", destination.path, "."] + process.currentDirectoryURL = directory + try process.run() + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 0) + } + + private static let versionMetadataJSON = """ + { + "id": "1.21.5", + "mainClass": "net.minecraft.client.main.Main", + "assets": "19", + "assetIndex": { + "id": "19", + "url": "https://piston-meta.mojang.com/v1/packages/assets.json", + "sha1": "asset-sha1", + "size": 321 + }, + "downloads": { + "client": { + "url": "https://piston-data.mojang.com/v1/objects/client.jar", + "sha1": "client-sha1", + "size": 123 + } + }, + "libraries": [ + { + "name": "org.lwjgl:lwjgl:3.3.3", + "natives": { + "osx": "natives-macos" + }, + "downloads": { + "artifact": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar", + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar", + "sha1": "library-sha1", + "size": 456 + }, + "classifiers": { + "natives-macos": { + "path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar", + "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar", + "sha1": "native-sha1", + "size": 789 + } + } + } + } + ] + } + """ +} diff --git a/MMCLTests/VersionFetchTests.swift b/MMCLTests/VersionFetchTests.swift new file mode 100644 index 0000000..575416c --- /dev/null +++ b/MMCLTests/VersionFetchTests.swift @@ -0,0 +1,174 @@ +import XCTest +@testable import MMCL + +final class VersionFetchTests: XCTestCase { + func testVersionManifestServiceFetchesManifestFromFileURL() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let manifestURL = root.appendingPathComponent("manifest.json") + try Data(Self.manifestJSON.utf8).write(to: manifestURL) + + let manifest = try await VersionManifestService().fetchManifest(from: manifestURL) + + XCTAssertEqual(manifest.latest.release, "1.21.5") + XCTAssertEqual(manifest.versions.first?.id, "1.21.5") + } + + func testVersionManifestServiceFetchesVersionMetadataFromFileURL() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let metadataURL = root.appendingPathComponent("metadata.json") + try Data(Self.versionMetadataJSON.utf8).write(to: metadataURL) + + let metadata = try await VersionManifestService().fetchVersionMetadata(from: metadataURL) + + XCTAssertEqual(metadata.id, "1.21.5") + XCTAssertEqual(metadata.downloads.client.sha1, "client-sha1") + } + + func testVersionManifestServiceFetchesAssetIndexFromFileURL() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let assetIndexURL = root.appendingPathComponent("asset-index.json") + try Data(Self.assetIndexJSON.utf8).write(to: assetIndexURL) + + let assetIndex = try await VersionManifestService().fetchAssetIndex(from: assetIndexURL) + + XCTAssertEqual(assetIndex.objects.count, 1) + XCTAssertEqual(assetIndex.totalBytes, 42) + XCTAssertEqual(assetIndex.objects["minecraft/sounds/random/pop.ogg"]?.hash, "abcdef0123456789abcdef0123456789abcdef01") + } + + func testStoreRefreshesVersionsAndPlansInstallFromFetchedMetadata() async throws { + let metadataURL = URL(string: "https://example.com/metadata.json")! + let assetIndexURL = URL(string: "https://piston-meta.mojang.com/v1/packages/assets.json")! + let version = MinecraftVersion( + id: "1.21.5", + type: .release, + metadataURL: metadataURL, + releaseTime: Date(timeIntervalSince1970: 1_700_000_000), + recommendedJavaMajorVersion: 21 + ) + let versionService = StubVersionService( + manifest: VersionManifest(latest: .init(release: "1.21.5", snapshot: "25w21a"), versions: [version]), + metadata: try VersionManifestService().decodeVersionMetadata(from: Data(Self.versionMetadataJSON.utf8)), + assetIndex: try VersionManifestService().decodeAssetIndex(from: Data(Self.assetIndexJSON.utf8)), + expectedAssetIndexURL: assetIndexURL + ) + let instance = LauncherInstance( + name: "原版生存", + gameVersion: "1.21.5", + loader: .vanilla, + rootDirectory: URL(fileURLWithPath: "/Users/example/Instances/vanilla", isDirectory: true), + status: .notInstalled + ) + let store = LauncherStore( + instances: [instance], + downloadJobs: [], + featuredProjects: [], + diagnostics: [], + javaRuntimes: [], + availableVersions: [], + versionService: versionService + ) + + await store.refreshAvailableVersions() + await store.planVanillaInstallFromRemoteMetadata(for: instance) + + XCTAssertEqual(store.availableVersions.map(\.id), ["1.21.5"]) + XCTAssertEqual(store.downloadJobs.first?.title, "Minecraft 1.21.5 客户端") + XCTAssertEqual(store.downloadJobs.count, 3) + XCTAssertEqual(store.downloadJobs[1].title, "Minecraft 1.21.5 资源索引") + XCTAssertEqual(store.downloadJobs[2].title, "资源文件 minecraft/sounds/random/pop.ogg") + XCTAssertEqual(store.downloadJobs.first?.sha1, "client-sha1") + XCTAssertEqual(store.diagnostics.first?.title, "已生成 Vanilla 安装计划") + XCTAssertTrue(store.diagnostics.first?.summary.contains("3 个下载任务") == true) + } + + private struct StubVersionService: VersionManifestServicing { + var manifestURL: URL = URL(string: "https://example.com/manifest.json")! + let manifest: VersionManifest + let metadata: VersionMetadata + let assetIndex: AssetIndex + let expectedAssetIndexURL: URL + + func decodeManifest(from data: Data) throws -> VersionManifest { + manifest + } + + func decodeVersionMetadata(from data: Data) throws -> VersionMetadata { + metadata + } + + func decodeAssetIndex(from data: Data) throws -> AssetIndex { + assetIndex + } + + func fetchManifest(from url: URL?) async throws -> VersionManifest { + manifest + } + + func fetchVersionMetadata(from url: URL) async throws -> VersionMetadata { + metadata + } + + func fetchAssetIndex(from url: URL) async throws -> AssetIndex { + XCTAssertEqual(url, expectedAssetIndexURL) + return assetIndex + } + } + + private static let manifestJSON = """ + { + "latest": { "release": "1.21.5", "snapshot": "25w21a" }, + "versions": [ + { + "id": "1.21.5", + "type": "release", + "url": "https://example.com/metadata.json", + "time": "2026-05-20T10:00:00+00:00", + "releaseTime": "2026-05-20T10:00:00+00:00" + } + ] + } + """ + + private static let versionMetadataJSON = """ + { + "id": "1.21.5", + "mainClass": "net.minecraft.client.main.Main", + "assets": "19", + "assetIndex": { + "id": "19", + "url": "https://piston-meta.mojang.com/v1/packages/assets.json", + "sha1": "asset-sha1", + "size": 321 + }, + "downloads": { + "client": { + "url": "https://piston-data.mojang.com/v1/objects/client.jar", + "sha1": "client-sha1", + "size": 123 + } + }, + "libraries": [] + } + """ + + private static let assetIndexJSON = """ + { + "objects": { + "minecraft/sounds/random/pop.ogg": { + "hash": "abcdef0123456789abcdef0123456789abcdef01", + "size": 42 + } + } + } + """ +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..38cf247 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +
+MMCL Logo +

MMCL 🎮

+ +

+ Stargazers + Issues + Forks + License +

+
+ +macOS 原生 Minecraft 启动器,基于 SwiftUI 构建,参考 PCL 交互设计。 +支持 版本管理、Mod/资源包/光影包管理、Modrinth/CurseForge 搜索下载、Microsoft 账号登录、多实例管理。 + +
+ +

启动页

+ +MMCL 启动页 + +

下载中心

+ +MMCL 下载中心 + +
+ +## 功能特性 🎯 + +- [x] **macOS 原生体验**,基于 SwiftUI + macOS 26 Liquid Glass 设计 +- [x] **多实例管理**,支持创建、复制、重命名、删除实例 +- [x] **版本管理**,支持 Vanilla、Fabric、Quilt、Forge、NeoForge +- [x] **Mod 管理**,本地启用/禁用/删除,支持 .jar/.disabled 切换 +- [x] **资源包管理**,支持 .zip 资源包的启用/禁用/删除 +- [x] **光影包管理**,支持光影包的启用/禁用/删除 +- [x] **Modrinth 搜索**,支持 Mod、Modpack、资源包、光影包、数据包搜索下载 +- [x] **CurseForge 搜索**,支持 Mod 搜索(需配置 API Key) +- [x] **Microsoft 账号登录**,支持 OAuth 设备码流程 +- [x] **离线账号**,支持自定义用户名 +- [x] **Java 管理**,自动扫描系统 Java,支持一键安装便携版 JDK +- [x] **下载管理**,并发下载、暂停/继续/取消、实时速度显示 +- [x] **日志查看器**,实时查看游戏启动日志 +- [x] **崩溃分析**,自动检测 Java 版本不匹配、崩溃日志分析 +- [x] **皮肤管理**,支持皮肤导入、预览、切换 +- [x] **服务器列表**,多人游戏服务器管理 +- [x] **自定义背景**,支持自定义启动器背景图片 +- [x] **多语言**,支持中文界面 +- [x] **自动更新**,支持从 GitHub Releases 检查更新 + +## 系统要求 📦 + +| 项目 | 要求 | +| --- | --- | +| 系统 | macOS 14.0 (Sonoma) 或更高 | +| 架构 | Apple Silicon (arm64) 或 Intel (x86_64) | +| Xcode | 16.0 或更高(构建需要) | +| Java | 自动检测,或通过设置页一键安装 | + +## 快速开始 🚀 + +### 方式一:下载 Release + +1. 前往 [Releases](https://github.com/Lhy723/MMCL/releases) 下载最新 `.dmg` +2. 双击打开,拖入 Applications 文件夹 +3. 首次打开可能需要在「系统设置 → 隐私与安全性」中允许 + +### 方式二:从源码构建 + +```shell +# 克隆仓库 +git clone https://github.com/Lhy723/MMCL.git +cd MMCL + +# 构建 +xcodebuild build -project MMCL.xcodeproj -scheme MMCL -destination 'platform=macOS' + +# 或使用脚本构建并运行 +./script/build_and_run.sh +``` + +## 使用指南 📖 + +### 创建实例 + +1. 打开启动器,点击侧边栏「下载中心」 +2. 在「原版游戏」标签页选择 Minecraft 版本 +3. 可选:选择 Mod 加载器(Forge / Fabric / Quilt / NeoForge) +4. 点击「开始下载」,等待下载完成 + +### 安装 Java + +如果启动器未检测到 Java: + +1. 打开「设置 → 游戏 Java」 +2. 点击「安装 Java」 +3. 选择版本(推荐 Java 21),点击安装 +4. 便携版 JDK 会自动下载到本地目录 + +### 下载 Mod + +1. 点击侧边栏「下载中心」→「Mod」标签 +2. 搜索想要的 Mod +3. 点击「安装」,选择版本后自动下载到当前实例 + +### CurseForge 支持 + +CurseForge 需要单独的 API Key: + +1. 前往 [CurseForge Console](https://console.curseforge.com/) 注册并获取 API Key +2. 打开「设置 → 其他」,输入 API Key +3. 搜索时选择 CurseForge 来源即可 + +## 项目结构 📁 + +``` +MMCL/ +├── Models/ # 数据模型 +├── Services/ # 服务层(下载、版本管理、Java、Modrinth API 等) +├── Stores/ # 状态管理(LauncherStore) +├── Views/ # UI 视图 +│ ├── download/ # 下载中心相关视图 +│ └── ... +├── Assets.xcassets # 图标资源 +└── MMCLApp.swift # 应用入口 +``` + +## 架构说明 🏗 + +采用 **Models → Services → Store → Views** 分层架构: + +- **Models**:所有数据类型定义(`LauncherInstance`、`DownloadJob`、`JavaRuntime` 等) +- **Services**:协议化服务层,支持依赖注入和 Mock 测试 +- **Store**:单一 `ObservableObject`,管理所有应用状态 +- **Views**:SwiftUI 视图,`NavigationSplitView` 布局 + +## 测试 🧪 + +```shell +# 运行所有测试 +xcodebuild test -project MMCL.xcodeproj -scheme MMCL -destination 'platform=macOS' + +# 运行单个测试类 +xcodebuild test -project MMCL.xcodeproj -scheme MMCL -destination 'platform=macOS' \ + -only-testing:MMCLTests/LauncherStoreTests +``` + +## 下载源 🌐 + +支持多种下载源,可在设置中切换: + +| 来源 | 说明 | +| --- | --- | +| 官方 | Mojang 官方服务器 | +| BMCLAPI | 国内镜像加速 | +| 自定义 | 自定义镜像地址 | + +## 致谢 🙏 + +- [PCL (Plain Craft Launcher)](https://github.com/Meloong-Git/PCL) — 交互设计参考 +- [Modrinth](https://modrinth.com/) — Mod 搜索 API +- [CurseForge](https://www.curseforge.com/) — Mod 搜索 API +- [Adoptium](https://adoptium.net/) — 便携版 JDK 下载 + +## 许可证 📄 + +[MIT License](LICENSE) diff --git a/docs/logo.png b/docs/logo.png new file mode 100644 index 0000000..d9e571d Binary files /dev/null and b/docs/logo.png differ diff --git a/docs/screenshots/download-center.png b/docs/screenshots/download-center.png new file mode 100644 index 0000000..4e0afd5 Binary files /dev/null and b/docs/screenshots/download-center.png differ diff --git a/docs/screenshots/launcher.png b/docs/screenshots/launcher.png new file mode 100644 index 0000000..40ae57b Binary files /dev/null and b/docs/screenshots/launcher.png differ diff --git a/script/build_and_run.sh b/script/build_and_run.sh new file mode 100755 index 0000000..4ea12bc --- /dev/null +++ b/script/build_and_run.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="${1:-run}" +APP_NAME="MMCL" +BUNDLE_ID="melody.MMCL" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DERIVED_DATA="$ROOT_DIR/build/DerivedData" +APP_BUNDLE="$DERIVED_DATA/Build/Products/Debug/$APP_NAME.app" +APP_BINARY="$APP_BUNDLE/Contents/MacOS/$APP_NAME" + +pkill -x "$APP_NAME" >/dev/null 2>&1 || true + +xcodebuild \ + -project "$ROOT_DIR/MMCL.xcodeproj" \ + -scheme "$APP_NAME" \ + -configuration Debug \ + -derivedDataPath "$DERIVED_DATA" \ + build + +open_app() { + /usr/bin/open -n "$APP_BUNDLE" +} + +case "$MODE" in + run) + open_app + ;; + --debug|debug) + lldb -- "$APP_BINARY" + ;; + --logs|logs) + open_app + /usr/bin/log stream --info --style compact --predicate "process == \"$APP_NAME\"" + ;; + --telemetry|telemetry) + open_app + /usr/bin/log stream --info --style compact --predicate "subsystem == \"$BUNDLE_ID\"" + ;; + --verify|verify) + open_app + sleep 1 + pgrep -x "$APP_NAME" >/dev/null + ;; + *) + echo "usage: $0 [run|--debug|--logs|--telemetry|--verify]" >&2 + exit 2 + ;; +esac