From 24f8a417c8ade316f32b8166ddba76af0d7f5f86 Mon Sep 17 00:00:00 2001 From: Lhy723 <873904524@qq.com> Date: Sat, 1 Aug 2026 14:47:12 +0800 Subject: [PATCH 1/3] test: reduce macOS test window usage --- MMCL.xcodeproj/project.pbxproj | 12 +++++++- MMCL/MMCLApp.swift | 21 +++++++++++++ MMCLTests/MMCLTests.swift | 38 ------------------------ MMCLUITests/MMCLUITests.swift | 26 +++------------- MMCLUITests/MMCLUITestsLaunchTests.swift | 35 ---------------------- 5 files changed, 36 insertions(+), 96 deletions(-) delete mode 100644 MMCLTests/MMCLTests.swift delete mode 100644 MMCLUITests/MMCLUITestsLaunchTests.swift diff --git a/MMCL.xcodeproj/project.pbxproj b/MMCL.xcodeproj/project.pbxproj index b128340..6a2c2ed 100644 --- a/MMCL.xcodeproj/project.pbxproj +++ b/MMCL.xcodeproj/project.pbxproj @@ -171,7 +171,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2650; - LastUpgradeCheck = 2650; + LastUpgradeCheck = 2700; TargetAttributes = { 8B55B82F2FC6E1110082C5F0 = { CreatedOnToolsVersion = 26.5; @@ -303,6 +303,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FJ7T78T44N; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -328,6 +329,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -367,6 +369,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FJ7T78T44N; ENABLE_NS_ASSERTIONS = NO; @@ -385,6 +388,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; @@ -397,6 +401,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = FJ7T78T44N; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -430,6 +435,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = FJ7T78T44N; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -461,6 +467,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = FJ7T78T44N; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.5; @@ -482,6 +489,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = FJ7T78T44N; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.5; @@ -502,6 +510,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = FJ7T78T44N; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -521,6 +530,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = FJ7T78T44N; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; diff --git a/MMCL/MMCLApp.swift b/MMCL/MMCLApp.swift index 87b3bd7..717bf87 100644 --- a/MMCL/MMCLApp.swift +++ b/MMCL/MMCLApp.swift @@ -1,7 +1,9 @@ import SwiftUI +import AppKit @main struct MMCLApp: App { + @NSApplicationDelegateAdaptor(MMCLTestAwareAppDelegate.self) private var appDelegate @StateObject private var store = LauncherStore() var body: some Scene { @@ -30,6 +32,25 @@ struct MMCLApp: App { } } +private final class MMCLTestAwareAppDelegate: NSObject, NSApplicationDelegate { + private var isHeadlessUnitTestHost: Bool { + let isXCTestProcess = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + let isUITestLaunch = ProcessInfo.processInfo.arguments.contains("--mmcl-ui-testing") + return isXCTestProcess && !isUITestLaunch + } + + func applicationShouldOpenUntitledFile(_ sender: NSApplication) -> Bool { + !isHeadlessUnitTestHost + } + + func applicationDidFinishLaunching(_ notification: Notification) { + guard isHeadlessUnitTestHost else { return } + + NSApp.setActivationPolicy(.prohibited) + NSApp.windows.forEach { $0.orderOut(nil) } + } +} + private struct ConditionalColorScheme: ViewModifier { let scheme: AppColorScheme diff --git a/MMCLTests/MMCLTests.swift b/MMCLTests/MMCLTests.swift deleted file mode 100644 index 2fc52f7..0000000 --- a/MMCLTests/MMCLTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// MMCLTests.swift -// MMCLTests -// -// Created by 星音 on 2026/5/27. -// - -import XCTest -@testable import MMCL - -final class MMCLTests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. - // XCTest Documentation - // https://developer.apple.com/documentation/xctest - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - -} diff --git a/MMCLUITests/MMCLUITests.swift b/MMCLUITests/MMCLUITests.swift index 4c94c40..62b204b 100644 --- a/MMCLUITests/MMCLUITests.swift +++ b/MMCLUITests/MMCLUITests.swift @@ -10,34 +10,16 @@ import XCTest final class MMCLUITests: XCTestCase { override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. } @MainActor - func testExample() throws { - // UI tests must launch the application that they test. + func testAppLaunchesInForeground() throws { let app = XCUIApplication() + app.launchArguments.append("--mmcl-ui-testing") app.launch() + defer { app.terminate() } - // Use XCTAssert and related functions to verify your tests produce the correct results. - // XCUIAutomation Documentation - // https://developer.apple.com/documentation/xcuiautomation - } - - @MainActor - func testLaunchPerformance() throws { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } + XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) } } diff --git a/MMCLUITests/MMCLUITestsLaunchTests.swift b/MMCLUITests/MMCLUITestsLaunchTests.swift deleted file mode 100644 index 2e44879..0000000 --- a/MMCLUITests/MMCLUITestsLaunchTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// MMCLUITestsLaunchTests.swift -// MMCLUITests -// -// Created by 星音 on 2026/5/27. -// - -import XCTest - -final class MMCLUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - @MainActor - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - // XCUIAutomation Documentation - // https://developer.apple.com/documentation/xcuiautomation - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -} From f08a4849852b2e844401c790705f7365300b4165 Mon Sep 17 00:00:00 2001 From: Lhy723 <873904524@qq.com> Date: Sat, 1 Aug 2026 15:12:53 +0800 Subject: [PATCH 2/3] Fix Swift concurrency warnings --- MMCL/Services/LauncherServices.swift | 42 +++++---- MMCL/Stores/LauncherStore.swift | 87 +++++++++++-------- MMCL/Views/ModrinthProjectDetailView.swift | 2 +- .../download/DownloadResourceSearchView.swift | 2 +- 4 files changed, 79 insertions(+), 54 deletions(-) diff --git a/MMCL/Services/LauncherServices.swift b/MMCL/Services/LauncherServices.swift index b91fa63..e5e44b3 100644 --- a/MMCL/Services/LauncherServices.swift +++ b/MMCL/Services/LauncherServices.swift @@ -293,10 +293,7 @@ final class DownloadService: NSObject, DownloadServicing, URLSessionDownloadDele let task: URLSessionDownloadTask lock.lock() - let resumeData = resumeDataMap[job.id] - if let resumeData { - resumeDataMap.removeValue(forKey: job.id) - } + let resumeData = resumeDataMap.removeValue(forKey: job.id) lock.unlock() if let resumeData { task = session.downloadTask(withResumeData: resumeData) @@ -1927,6 +1924,19 @@ protocol ServerListServicing { func serverListFileURL(for instance: LauncherInstance) -> URL } +private struct ServerPingCompletionGate: @unchecked Sendable { + private let semaphore: DispatchSemaphore + + init() { + semaphore = DispatchSemaphore(value: 1) + } + + nonisolated func run(_ action: @Sendable () -> Void) { + guard semaphore.wait(timeout: .now()) == .success else { return } + action() + } +} + struct ServerListService: ServerListServicing { let applicationSupportDirectory: URL @@ -1965,7 +1975,13 @@ struct ServerListService: ServerListServicing { return await withCheckedContinuation { continuation in let startTime = Date() - var didResume = false + let completionGate = ServerPingCompletionGate() + let finish: @Sendable (ServerInfo.ServerPingResult?) -> Void = { result in + completionGate.run { + connection.cancel() + continuation.resume(returning: result) + } + } connection.stateUpdateHandler = { state in switch state { @@ -1998,16 +2014,12 @@ struct ServerListService: ServerListServicing { versionName: "未知", pingMs: elapsed ) - connection.cancel() - continuation.resume(returning: result) + finish(result) }) case .failed: - continuation.resume(returning: nil) + finish(nil) case .cancelled: - if !didResume { - didResume = true - continuation.resume(returning: nil) - } + finish(nil) default: break } @@ -2017,11 +2029,7 @@ struct ServerListService: ServerListServicing { // Timeout after 5 seconds DispatchQueue.global().asyncAfter(deadline: .now() + 5) { - if !didResume { - didResume = true - connection.cancel() - continuation.resume(returning: nil) - } + finish(nil) } } } diff --git a/MMCL/Stores/LauncherStore.swift b/MMCL/Stores/LauncherStore.swift index 012c001..37f3c13 100644 --- a/MMCL/Stores/LauncherStore.swift +++ b/MMCL/Stores/LauncherStore.swift @@ -157,51 +157,68 @@ final class LauncherStore: ObservableObject { 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(), + launchService: LaunchServicing? = nil, + downloadService: DownloadServicing? = nil, + versionService: VersionManifestServicing? = nil, + javaRuntimeService: JavaRuntimeServicing? = nil, + instanceService: InstanceServicing? = nil, + fabricService: FabricServicing? = nil, + quiltService: QuiltServicing? = nil, + forgeService: ForgeServicing? = nil, + neoForgeService: NeoForgeServicing? = nil, + modrinthService: ModrinthServicing? = nil, + curseForgeService: CurseForgeServicing? = nil, + authService: AuthServicing? = nil, + diagnosticService: DiagnosticServicing? = nil, + skinService: SkinServicing? = nil, + serverListService: ServerListServicing? = nil, accountPersistence: AccountPersistence? = nil ) { + let resolvedLaunchService = launchService ?? LaunchService() + let resolvedDownloadService = downloadService ?? DownloadService() + let resolvedVersionService = versionService ?? VersionManifestService() + let resolvedJavaRuntimeService = javaRuntimeService ?? JavaRuntimeService() + let resolvedInstanceService = instanceService ?? InstanceService() + let resolvedFabricService = fabricService ?? FabricService() + let resolvedQuiltService = quiltService ?? QuiltService() + let resolvedForgeService = forgeService ?? ForgeService() + let resolvedNeoForgeService = neoForgeService ?? NeoForgeService() + let resolvedModrinthService = modrinthService ?? ModrinthService() + let resolvedCurseForgeService = curseForgeService ?? CurseForgeService() + let resolvedAuthService = authService ?? AuthService() + let resolvedDiagnosticService = diagnosticService ?? DiagnosticService() + let resolvedSkinService = skinService ?? SkinService() + let resolvedServerListService = serverListService ?? ServerListService() let resolvedAccountPersistence = accountPersistence ?? AccountPersistence() + let resolvedInstances: [LauncherInstance] if instances.isEmpty { - self.instances = (try? instanceService.loadAllInstances()) ?? [] + resolvedInstances = (try? resolvedInstanceService.loadAllInstances()) ?? [] } else { - self.instances = instances + resolvedInstances = instances } + self.instances = resolvedInstances 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.launchService = resolvedLaunchService + self.downloadService = resolvedDownloadService + self.versionService = resolvedVersionService + self.javaRuntimeService = resolvedJavaRuntimeService + self.instanceService = resolvedInstanceService + self.fabricService = resolvedFabricService + self.quiltService = resolvedQuiltService + self.forgeService = resolvedForgeService + self.neoForgeService = resolvedNeoForgeService + self.modrinthService = resolvedModrinthService + self.curseForgeService = resolvedCurseForgeService + self.authService = resolvedAuthService + self.diagnosticService = resolvedDiagnosticService + self.skinService = resolvedSkinService + self.serverListService = resolvedServerListService self.accountPersistence = resolvedAccountPersistence self.selectedJavaRuntimeID = javaRuntimes.first?.id self.selectedSection = .launcher @@ -209,10 +226,10 @@ final class LauncherStore: ObservableObject { // Restore last selected instance if let savedID = UserDefaults.standard.string(forKey: "lastSelectedInstanceID"), let uuid = UUID(uuidString: savedID), - instances.contains(where: { $0.id == uuid }) { + resolvedInstances.contains(where: { $0.id == uuid }) { self.launcherSelectedInstanceID = uuid } else { - self.launcherSelectedInstanceID = instances.first?.id + self.launcherSelectedInstanceID = resolvedInstances.first?.id } // Load persisted accounts; create default offline if none @@ -441,7 +458,7 @@ final class LauncherStore: ObservableObject { } } - func loadLogContent(for instance: LauncherInstance) -> String { + nonisolated func loadLogContent(for instance: LauncherInstance) -> String { let logURL = instance.rootDirectory .appendingPathComponent("logs", isDirectory: true) .appendingPathComponent("latest.log") diff --git a/MMCL/Views/ModrinthProjectDetailView.swift b/MMCL/Views/ModrinthProjectDetailView.swift index c396a2a..6a62d96 100644 --- a/MMCL/Views/ModrinthProjectDetailView.swift +++ b/MMCL/Views/ModrinthProjectDetailView.swift @@ -54,7 +54,7 @@ struct ModrinthProjectDetailView: View { .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)) { + _ = withAnimation(.mmclSpring(response: 0.4, dampingFraction: 0.85, scale: store.animationDurationScale)) { visibleIDs.insert(version.id) } } diff --git a/MMCL/Views/download/DownloadResourceSearchView.swift b/MMCL/Views/download/DownloadResourceSearchView.swift index 998441f..d5cb4b7 100644 --- a/MMCL/Views/download/DownloadResourceSearchView.swift +++ b/MMCL/Views/download/DownloadResourceSearchView.swift @@ -364,7 +364,7 @@ struct DownloadResourceSearchView: View { .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)) { + _ = withAnimation(.mmclSpring(response: 0.5, dampingFraction: 0.85, scale: store.animationDurationScale)) { visibleIDs.insert(item.id) } } From 258318f7c6d963de2e730c8a7ba26fee39a33f26 Mon Sep 17 00:00:00 2001 From: Lhy723 <873904524@qq.com> Date: Sat, 1 Aug 2026 16:00:59 +0800 Subject: [PATCH 3/3] fix: address update, CI, and NeoForge issues --- .github/workflows/ci.yml | 2 +- CLAUDE.md | 2 +- MMCL/Models/LauncherModels.swift | 193 +++++++++++++++++- MMCL/Stores/LauncherStore.swift | 46 +++-- MMCL/Views/InstanceDetailView.swift | 4 +- MMCL/Views/InstanceSettingsView.swift | 2 + MMCL/Views/ModrinthProjectDetailView.swift | 13 +- .../download/DownloadResourceSearchView.swift | 5 + MMCLTests/LauncherModelTests.swift | 37 ++++ MMCLTests/LauncherStoreTests.swift | 32 +++ 10 files changed, 301 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3baaf12..4b95945 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,4 +41,4 @@ jobs: - name: SwiftLint run: | brew install swiftlint - swiftlint lint --reporter github-actions-logging || true + swiftlint lint --reporter github-actions-logging diff --git a/CLAUDE.md b/CLAUDE.md index ce0d66b..303bd0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ MMCL (Melody Minecraft Launcher) — macOS Minecraft launcher. SwiftUI app with - 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) +- Block icons: Grass (release), CommandBlock (snapshot), CobbleStone (old), Anvil (Forge), NeoForge, Fabric, Egg (Quilt) - Animations use `Animation.mmclSpring()` with configurable duration scale ## Testing diff --git a/MMCL/Models/LauncherModels.swift b/MMCL/Models/LauncherModels.swift index d19cf11..483787b 100644 --- a/MMCL/Models/LauncherModels.swift +++ b/MMCL/Models/LauncherModels.swift @@ -2,13 +2,203 @@ import Combine import Foundation import SwiftUI +struct SemanticVersion: Comparable, Equatable, CustomStringConvertible { + let major: Int + let minor: Int + let patch: Int + + private let prereleaseIdentifiers: [String] + private let buildMetadataIdentifiers: [String] + + init?(_ rawValue: String) { + var normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return nil } + if normalized.first == "v" || normalized.first == "V" { + normalized.removeFirst() + } + guard !normalized.isEmpty else { return nil } + + let versionAndBuild = normalized.split( + separator: "+", + maxSplits: 1, + omittingEmptySubsequences: false + ) + guard versionAndBuild.count <= 2 else { return nil } + + let coreAndPrerelease = versionAndBuild[0].split( + separator: "-", + maxSplits: 1, + omittingEmptySubsequences: false + ) + guard coreAndPrerelease.count <= 2 else { return nil } + + let core = coreAndPrerelease[0].split(separator: ".", omittingEmptySubsequences: false) + guard core.count == 3, + let major = Self.parseCoreComponent(core[0]), + let minor = Self.parseCoreComponent(core[1]), + let patch = Self.parseCoreComponent(core[2]) else { + return nil + } + + let prereleaseIdentifiers: [String] + if coreAndPrerelease.count == 2 { + guard let parsed = Self.parseIdentifiers( + coreAndPrerelease[1], + rejectLeadingZeros: true + ) else { + return nil + } + prereleaseIdentifiers = parsed + } else { + prereleaseIdentifiers = [] + } + + let buildMetadataIdentifiers: [String] + if versionAndBuild.count == 2 { + guard let parsed = Self.parseIdentifiers( + versionAndBuild[1], + rejectLeadingZeros: false + ) else { + return nil + } + buildMetadataIdentifiers = parsed + } else { + buildMetadataIdentifiers = [] + } + + self.major = major + self.minor = minor + self.patch = patch + self.prereleaseIdentifiers = prereleaseIdentifiers + self.buildMetadataIdentifiers = buildMetadataIdentifiers + } + + static func isNewerVersion(_ latest: String, than current: String) -> Bool { + guard let latest = SemanticVersion(latest), + let current = SemanticVersion(current) else { + return false + } + return latest > current + } + + var description: String { + var value = "\(major).\(minor).\(patch)" + if !prereleaseIdentifiers.isEmpty { + value += "-" + prereleaseIdentifiers.joined(separator: ".") + } + if !buildMetadataIdentifiers.isEmpty { + value += "+" + buildMetadataIdentifiers.joined(separator: ".") + } + return value + } + + static func == (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool { + lhs.major == rhs.major && + lhs.minor == rhs.minor && + lhs.patch == rhs.patch && + lhs.prereleaseIdentifiers == rhs.prereleaseIdentifiers + } + + static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } + if lhs.patch != rhs.patch { return lhs.patch < rhs.patch } + + switch (lhs.prereleaseIdentifiers.isEmpty, rhs.prereleaseIdentifiers.isEmpty) { + case (true, true): + return false + case (true, false): + return false + case (false, true): + return true + case (false, false): + break + } + + for (left, right) in zip(lhs.prereleaseIdentifiers, rhs.prereleaseIdentifiers) { + guard left != right else { continue } + + let leftIsNumeric = Self.isNumericIdentifier(left) + let rightIsNumeric = Self.isNumericIdentifier(right) + if leftIsNumeric && rightIsNumeric { + if left.count != right.count { + return left.count < right.count + } + return left < right + } + if leftIsNumeric != rightIsNumeric { + return leftIsNumeric + } + return left < right + } + + return lhs.prereleaseIdentifiers.count < rhs.prereleaseIdentifiers.count + } + + private static func parseCoreComponent(_ value: Substring) -> Int? { + guard isNumericIdentifier(value), + value.count == 1 || value.first != "0" else { + return nil + } + return Int(value) + } + + private static func parseIdentifiers( + _ value: Substring, + rejectLeadingZeros: Bool + ) -> [String]? { + let identifiers = value.split(separator: ".", omittingEmptySubsequences: false) + guard identifiers.allSatisfy({ isValidIdentifier($0) }) else { return nil } + if rejectLeadingZeros { + guard identifiers.allSatisfy({ + !isNumericIdentifier($0) || $0.count == 1 || $0.first != "0" + }) else { + return nil + } + } + return identifiers.map(String.init) + } + + private static func isNumericIdentifier(_ value: Substring) -> Bool { + !value.isEmpty && value.utf8.allSatisfy { byte in + byte >= 48 && byte <= 57 + } + } + + private static func isNumericIdentifier(_ value: String) -> Bool { + !value.isEmpty && value.utf8.allSatisfy { byte in + byte >= 48 && byte <= 57 + } + } + + private static func isValidIdentifier(_ value: Substring) -> Bool { + !value.isEmpty && value.utf8.allSatisfy { byte in + (byte >= 48 && byte <= 57) || + (byte >= 65 && byte <= 90) || + (byte >= 97 && byte <= 122) || + byte == 45 + } + } +} + enum GameLoader: String, Codable, CaseIterable, Identifiable { case vanilla = "Vanilla" case fabric = "Fabric" case quilt = "Quilt" case forge = "Forge" + case neoForge = "NeoForge" var id: String { rawValue } + + var modrinthLoaderName: String? { + switch self { + case .vanilla: return nil + case .fabric: return "fabric" + case .quilt: return "quilt" + case .forge: return "forge" + case .neoForge: return "neoforge" + } + } } enum VersionIsolation: String, Codable, CaseIterable, Identifiable { @@ -23,7 +213,7 @@ enum VersionIsolation: String, Codable, CaseIterable, Identifiable { var helpText: String { switch self { case .off: return "所有版本共享存档、Mod、资源包" - case .moddableVersions: return "Forge/Fabric 等互相独立,原版共享" + case .moddableVersions: return "Forge/Fabric/NeoForge 等互相独立,原版共享" case .snapshots: return "快照与发布版、远古版本等隔离" case .moddableAndSnapshots: return "同时隔离可安装 Mod 版本与非正式版" case .all: return "不同版本的存档、Mod、资源包均不互通" @@ -193,6 +383,7 @@ struct LauncherInstance: Identifiable, Codable, Equatable { var blockIcon: String { switch loader { case .forge: return "Anvil" + case .neoForge: return "NeoForge" case .fabric: return "Fabric" case .quilt: return "Egg" case .vanilla: diff --git a/MMCL/Stores/LauncherStore.swift b/MMCL/Stores/LauncherStore.swift index 37f3c13..91f9af5 100644 --- a/MMCL/Stores/LauncherStore.swift +++ b/MMCL/Stores/LauncherStore.swift @@ -1262,27 +1262,35 @@ extension LauncherStore { 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.isEmpty && 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 - } + guard let latest = SemanticVersion(tagName), + let current = SemanticVersion(currentVersion), + latest > current else { + latestVersion = nil + updateAvailable = false + updateDownloadURL = nil + return + } - diagnostics.insert( - DiagnosticReport(title: "发现新版本", severity: .info, summary: "最新版本 \(version),当前版本 \(currentVersion)。", suggestedActions: ["点击「下载更新」获取最新版本"]), - at: 0 - ) + let version = latest.description + latestVersion = version + updateAvailable = true + updateDownloadURL = nil + + // 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 } diff --git a/MMCL/Views/InstanceDetailView.swift b/MMCL/Views/InstanceDetailView.swift index 2508686..23f5532 100644 --- a/MMCL/Views/InstanceDetailView.swift +++ b/MMCL/Views/InstanceDetailView.swift @@ -223,7 +223,7 @@ struct InstanceDetailView: View { } // Loader-specific actions - if instance.loader == .fabric || instance.loader == .quilt || instance.loader == .forge { + if instance.loader == .fabric || instance.loader == .quilt || instance.loader == .forge || instance.loader == .neoForge { Divider() HStack(spacing: 12) { if instance.loader == .fabric { @@ -243,6 +243,8 @@ struct InstanceDetailView: View { Label("安装 Forge", systemImage: "hammer") } .buttonStyle(.bordered) + } + if instance.loader == .neoForge { Button { Task { await store.installNeoForgeLoader(for: instance) } } label: { Label("安装 NeoForge", systemImage: "hammer.fill") } diff --git a/MMCL/Views/InstanceSettingsView.swift b/MMCL/Views/InstanceSettingsView.swift index f77591a..32277b8 100644 --- a/MMCL/Views/InstanceSettingsView.swift +++ b/MMCL/Views/InstanceSettingsView.swift @@ -182,6 +182,8 @@ struct InstanceSettingsView: View { Button { Task { await store.installForgeLoader(for: instance) } } label: { Label("安装 Forge", systemImage: "hammer") } + } + if instance.loader == .neoForge { Button { Task { await store.installNeoForgeLoader(for: instance) } } label: { Label("安装 NeoForge", systemImage: "hammer.fill") } diff --git a/MMCL/Views/ModrinthProjectDetailView.swift b/MMCL/Views/ModrinthProjectDetailView.swift index 6a62d96..2b1c8de 100644 --- a/MMCL/Views/ModrinthProjectDetailView.swift +++ b/MMCL/Views/ModrinthProjectDetailView.swift @@ -89,9 +89,7 @@ struct ModrinthProjectDetailView: View { private func loadVersions() async { isLoading = true do { - let loaderFilter: String? = store.selectedInstance.flatMap { instance in - loaderName(for: instance.loader) - } + let loaderFilter = store.selectedInstance?.loader.modrinthLoaderName versions = try await store.modrinthService.fetchVersions( projectID: project.id, gameVersion: store.selectedInstance?.gameVersion, @@ -102,15 +100,6 @@ struct ModrinthProjectDetailView: View { } 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 { diff --git a/MMCL/Views/download/DownloadResourceSearchView.swift b/MMCL/Views/download/DownloadResourceSearchView.swift index d5cb4b7..5c0bb42 100644 --- a/MMCL/Views/download/DownloadResourceSearchView.swift +++ b/MMCL/Views/download/DownloadResourceSearchView.swift @@ -560,6 +560,11 @@ struct DownloadResourceSearchView: View { if let categoryFacet = selectedCategory.modrinthFacet { facets.append([categoryFacet]) } + if let selectedLoader, + let loader = GameLoader(rawValue: selectedLoader), + let loaderFacet = loader.modrinthLoaderName { + facets.append(["categories:\(loaderFacet)"]) + } 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 diff --git a/MMCLTests/LauncherModelTests.swift b/MMCLTests/LauncherModelTests.swift index cbb300f..84db882 100644 --- a/MMCLTests/LauncherModelTests.swift +++ b/MMCLTests/LauncherModelTests.swift @@ -2,6 +2,43 @@ import XCTest @testable import MMCL final class LauncherModelTests: XCTestCase { + func testNeoForgeIsFirstClassGameLoader() throws { + XCTAssertTrue(GameLoader.allCases.contains(.neoForge)) + XCTAssertEqual(GameLoader(rawValue: "NeoForge"), .neoForge) + XCTAssertEqual(GameLoader.neoForge.modrinthLoaderName, "neoforge") + + let root = URL(fileURLWithPath: "/Users/example/Library/Application Support/MMCL/Instances/neoforge") + let instance = LauncherInstance( + name: "NeoForge 生存", + gameVersion: "1.21.5", + loader: .neoForge, + rootDirectory: root + ) + + XCTAssertEqual(instance.subtitle, "1.21.5 · NeoForge") + XCTAssertEqual(instance.blockIcon, "NeoForge") + + let data = try JSONEncoder.mmcl.encode(instance) + let decoded = try JSONDecoder.mmcl.decode(LauncherInstance.self, from: data) + XCTAssertEqual(decoded.loader, .neoForge) + } + + func testContentProjectRoundTripsNeoForgeLoader() throws { + let project = ContentProject( + id: "neoforge-project", + title: "NeoForge Mod", + type: .mod, + source: "Modrinth", + gameVersions: ["1.21.5"], + loaders: [.neoForge] + ) + + let data = try JSONEncoder.mmcl.encode(project) + let decoded = try JSONDecoder.mmcl.decode(ContentProject.self, from: data) + + XCTAssertEqual(decoded.loaders, [.neoForge]) + } + func testLauncherInstanceRoundTripsThroughJSON() throws { let root = URL(fileURLWithPath: "/Users/example/Library/Application Support/MMCL/Instances/vanilla") let instance = LauncherInstance( diff --git a/MMCLTests/LauncherStoreTests.swift b/MMCLTests/LauncherStoreTests.swift index 1f4865b..518eef0 100644 --- a/MMCLTests/LauncherStoreTests.swift +++ b/MMCLTests/LauncherStoreTests.swift @@ -1,6 +1,38 @@ import XCTest @testable import MMCL +@MainActor +final class SemanticVersionTests: XCTestCase { + func testSemanticVersionComparesNumericComponents() { + XCTAssertTrue(SemanticVersion("0.10.0")! > SemanticVersion("0.9.0")!) + XCTAssertTrue(SemanticVersion("0.2.0")! > SemanticVersion("0.1.9")!) + } + + func testSemanticVersionOrdersPrereleaseIdentifiers() { + XCTAssertTrue(SemanticVersion("1.0.0-alpha")! < SemanticVersion("1.0.0-beta")!) + XCTAssertTrue(SemanticVersion("1.0.0-beta.1")! < SemanticVersion("1.0.0-beta.2")!) + XCTAssertTrue(SemanticVersion("1.0.0-rc.1")! < SemanticVersion("1.0.0")!) + } + + func testNewerVersionRejectsDowngradesAndInvalidVersions() { + XCTAssertTrue(SemanticVersion.isNewerVersion("v0.10.0", than: "0.9.0")) + XCTAssertTrue(SemanticVersion.isNewerVersion("0.2.0", than: "0.1.9")) + XCTAssertFalse(SemanticVersion.isNewerVersion("0.9.0", than: "0.10.0")) + XCTAssertFalse(SemanticVersion.isNewerVersion("1.0.0", than: "1.0.0")) + XCTAssertFalse(SemanticVersion.isNewerVersion("1.0.0-rc.1", than: "1.0.0")) + XCTAssertFalse(SemanticVersion.isNewerVersion("not-a-version", than: "0.1.0")) + XCTAssertFalse(SemanticVersion.isNewerVersion("vv1.1.0", than: "1.0.0")) + } + + func testSemanticVersionIgnoresBuildMetadataForPrecedence() { + let first = SemanticVersion("1.0.0+build.1") + let second = SemanticVersion("1.0.0+build.2") + + XCTAssertEqual(first, second) + XCTAssertFalse(SemanticVersion.isNewerVersion("1.0.0+build.2", than: "1.0.0+build.1")) + } +} + final class LauncherStoreTests: XCTestCase { @MainActor func testStoreBuildsLaunchPreviewForSelectedInstanceAndJava() {