diff --git a/PCL.Mac.Core/Minecraft/Launch/MinecraftLauncher.swift b/PCL.Mac.Core/Minecraft/Launch/MinecraftLauncher.swift index 51e7253..e165031 100644 --- a/PCL.Mac.Core/Minecraft/Launch/MinecraftLauncher.swift +++ b/PCL.Mac.Core/Minecraft/Launch/MinecraftLauncher.swift @@ -30,7 +30,7 @@ public class MinecraftLauncher { "library_directory": librariesURL.path, "auth_player_name": options.profile.name, - "version_name": manifest.id, + "version_name": options.runningDirectory.lastPathComponent, "game_directory": runningDirectory.path, "assets_root": librariesURL.deletingLastPathComponent().appending(path: "assets").path, "assets_index_name": manifest.assetIndex.id, @@ -54,21 +54,14 @@ public class MinecraftLauncher { arguments.append(contentsOf: manifest.jvmArguments.flatMap { $0.rules.allSatisfy { $0.test(with: options) } ? $0.value : [] }) arguments.append(manifest.mainClass) arguments.append(contentsOf: manifest.gameArguments.flatMap { $0.rules.allSatisfy { $0.test(with: options) } ? $0.value : [] }) - arguments = arguments.map(replaceWithValue(_:)) + arguments = arguments.map { Utils.replace($0, withValues: values) } process.arguments = arguments - // accessToken 打码 - // arguments 不会再被使用了,可以直接修改 - if let accessTokenIndex: Int = arguments.firstIndex(of: "--accessToken"), - accessTokenIndex + 1 < arguments.count { - arguments[accessTokenIndex + 1] = "🥚" - } - let pipe: Pipe = .init() process.standardOutput = pipe process.standardError = pipe - log("正在使用以下参数启动 Minecraft:\(arguments)") + log("正在使用以下参数启动 Minecraft:\(arguments.map { $0 == options.accessToken ? "🥚" : $0 })") try process.run() Self.gameLogQueue.async { FileManager.default.createFile(atPath: self.logURL.path, contents: nil) @@ -92,20 +85,12 @@ public class MinecraftLauncher { private func buildClasspath() -> String { var urls: [URL] = [] - for library in manifest.libraries { - if library.isRulesSatisfied, let artifact = library.artifact { + for library in manifest.getLibraries() { + if let artifact = library.artifact { urls.append(librariesURL.appending(path: artifact.path)) } } urls.append(runningDirectory.appending(path: "\(runningDirectory.lastPathComponent).jar")) return urls.map(\.path).joined(separator: ":") } - - private func replaceWithValue(_ string: String) -> String { - var s: String = string - for key in values.keys { - s = s.replacingOccurrences(of: "${\(key)}", with: values[key]!) - } - return s - } } diff --git a/PCL.Mac.Core/Minecraft/MinecraftInstance.swift b/PCL.Mac.Core/Minecraft/MinecraftInstance.swift index 9a968bd..a253ed2 100644 --- a/PCL.Mac.Core/Minecraft/MinecraftInstance.swift +++ b/PCL.Mac.Core/Minecraft/MinecraftInstance.swift @@ -8,12 +8,13 @@ import Foundation import SwiftyJSON -public class MinecraftInstance { +public class MinecraftInstance: Equatable { private static let configFileName: String = ".clconfig.json" public let runningDirectory: URL public let version: MinecraftVersion public let manifest: ClientManifest public let config: Config + public let modLoader: ModLoader? public var name: String { runningDirectory.lastPathComponent } public var manifestURL: URL { runningDirectory.appending(path: "\(name).json") } @@ -26,11 +27,13 @@ public class MinecraftInstance { /// - version: 实例的 Minecraft 版本。 /// - manifest: 客户端清单。 /// - config: 实例配置。 - public init(runningDirectory: URL, version: MinecraftVersion, manifest: ClientManifest, config: Config) { + /// - modLoader: 实例安装的模组加载器。 + public init(runningDirectory: URL, version: MinecraftVersion, manifest: ClientManifest, config: Config, modLoader: ModLoader?) { self.runningDirectory = runningDirectory self.version = version self.manifest = manifest self.config = config + self.modLoader = modLoader VersionCache.add(version: version, for: self) if config.javaURL == nil { setJava(url: searchJava().map(\.executableURL)) @@ -114,16 +117,13 @@ public class MinecraftInstance { /// - version: (可选)缓存的版本号。 /// - Returns: 实例对象。 public static func load(from runningDirectory: URL) throws -> MinecraftInstance { + if FileManager.default.fileExists(atPath: runningDirectory.appending(path: ".incomplete").path) { + throw MinecraftError.incomplete + } // 加载客户端清单 let manifestURL: URL = runningDirectory.appending(path: "\(runningDirectory.lastPathComponent).json") guard FileManager.default.fileExists(atPath: manifestURL.path) else { throw MinecraftError.missingManifest } - let manifest: ClientManifest - do { - manifest = try JSONDecoder.shared.decode(ClientManifest.self, from: Data(contentsOf: manifestURL)) - } catch { - err("加载客户端清单失败:\(error)") - throw MinecraftError.unknownManifestFormat - } + let (manifest, modLoader): (ClientManifest, ModLoader?) = try ClientManifest.load(at: manifestURL) // 获取版本 let version: MinecraftVersion if let cachedVersion = VersionCache.version(of: manifestURL) { @@ -135,6 +135,8 @@ public class MinecraftInstance { let json: JSON = try? JSON(data: ArchiveUtils.getEntry(url: jarURL, path: "version.json")) { log("成功解析 version.json") version = .init(json["id"].stringValue) + } else if let clVersion: String = manifest.version { + version = .init(clVersion) } else { warn("\(jarURL.lastPathComponent)!/version.json 不存在或解析失败,使用客户端清单中的 id 作为版本号") version = .init(manifest.id) @@ -156,11 +158,15 @@ public class MinecraftInstance { runningDirectory: runningDirectory, version: version, manifest: manifest, - config: config ?? .init() + config: config ?? .init(), + modLoader: modLoader ) return instance } + public static func == (lhs: MinecraftInstance, rhs: MinecraftInstance) -> Bool { + lhs.runningDirectory == rhs.runningDirectory + } public class Config: Codable { public var jvmHeapSize: UInt64 diff --git a/PCL.Mac.Core/Minecraft/MinecraftRepository.swift b/PCL.Mac.Core/Minecraft/MinecraftRepository.swift index b692d38..eddeeb7 100644 --- a/PCL.Mac.Core/Minecraft/MinecraftRepository.swift +++ b/PCL.Mac.Core/Minecraft/MinecraftRepository.swift @@ -73,6 +73,15 @@ public class MinecraftRepository: ObservableObject, Codable, Hashable, Equatable err("加载实例失败:不支持的客户端清单格式。") errorInstances.append(.init(name: content.lastPathComponent, message: "不支持的客户端清单格式。")) continue + } catch MinecraftError.incomplete { + log("实例未完成安装,正在尝试自动删除") + do { + try FileManager.default.removeItem(at: content) + } catch { + err("删除失败:\(error.localizedDescription)") + errorInstances.append(.init(name: content.lastPathComponent, message: "该实例未完成安装,且自动删除失败。")) + } + continue } catch { err("加载实例失败:\(error.localizedDescription)") continue diff --git a/PCL.Mac.Core/Minecraft/ModLoader.swift b/PCL.Mac.Core/Minecraft/ModLoader.swift new file mode 100644 index 0000000..ad1c5ce --- /dev/null +++ b/PCL.Mac.Core/Minecraft/ModLoader.swift @@ -0,0 +1,19 @@ +// +// ModLoader.swift +// PCL.Mac +// +// Created by 温迪 on 2026/2/11. +// + +import Foundation + +public enum ModLoader: Int, CustomStringConvertible { + case fabric, forge + + public var description: String { + switch self { + case .fabric: "Fabric" + case .forge: "Forge" + } + } +} diff --git a/PCL.Mac.Core/Models/ClientManifest.swift b/PCL.Mac.Core/Models/ClientManifest.swift index 5235092..03e1509 100644 --- a/PCL.Mac.Core/Models/ClientManifest.swift +++ b/PCL.Mac.Core/Models/ClientManifest.swift @@ -10,10 +10,12 @@ import SwiftyJSON /// https://zh.minecraft.wiki/w/客户端清单文件格式 public class ClientManifest: Decodable { + private static let oldVersionFlag: String = "-Dorg.ceciliastudio.cl.OldVersionFlag=1" + public let gameArguments: [Argument] public let jvmArguments: [Argument] - public let assetIndex: AssetIndex - public let downloads: Downloads + public let assetIndex: AssetIndex! + public let downloads: Downloads! public let id: String public let javaVersion: JavaVersion public let libraries: [Library] @@ -23,10 +25,14 @@ public class ClientManifest: Decodable { public let inheritsFrom: String? + // 非标准字段 + public let version: String? + private enum CodingKeys: String, CodingKey { case arguments, assetIndex, downloads, id, javaVersion, libraries, logging, mainClass, type case minecraftArguments case inheritsFrom + case version } private enum ArgumentsCodingKeys: String, CodingKey { @@ -36,15 +42,16 @@ public class ClientManifest: Decodable { public init( gameArguments: [Argument], jvmArguments: [Argument], - assetIndex: AssetIndex, - downloads: Downloads, + assetIndex: AssetIndex?, + downloads: Downloads?, id: String, javaVersion: JavaVersion, libraries: [Library], logging: Logging, mainClass: String, type: String, - inheritsFrom: String? + inheritsFrom: String?, + version: String? ) { self.gameArguments = gameArguments self.jvmArguments = jvmArguments @@ -57,6 +64,7 @@ public class ClientManifest: Decodable { self.mainClass = mainClass self.type = type self.inheritsFrom = inheritsFrom + self.version = version } public required init(from decoder: any Decoder) throws { @@ -64,6 +72,7 @@ public class ClientManifest: Decodable { if container.contains(.minecraftArguments) { // 1.12- self.gameArguments = try container.decode(String.self, forKey: .minecraftArguments).split(separator: " ").map { .init(value: [String($0)], rules: []) } self.jvmArguments = [ + Self.oldVersionFlag, "-XX:+UnlockExperimentalVMOptions", "-XX:+UseG1GC", "-XX:-UseAdaptiveSizePolicy", "-XX:-OmitStackTraceInFastThrow", "-Djava.library.path=${natives_directory}", "-Dorg.lwjgl.system.SharedLibraryExtractPath=${natives_directory}", @@ -73,15 +82,15 @@ public class ClientManifest: Decodable { ].map { .init(value: [$0], rules: []) } } else { let argumentsContainer = try container.nestedContainer(keyedBy: ArgumentsCodingKeys.self, forKey: .arguments) - self.gameArguments = try argumentsContainer.decode([Argument].self, forKey: .game) - self.jvmArguments = try argumentsContainer.decode([Argument].self, forKey: .jvm) + self.gameArguments = try argumentsContainer.decodeIfPresent([Argument].self, forKey: .game) ?? [] + self.jvmArguments = try argumentsContainer.decodeIfPresent([Argument].self, forKey: .jvm) ?? [] } - self.assetIndex = try container.decode(AssetIndex.self, forKey: .assetIndex) - self.downloads = try container.decode(Downloads.self, forKey: .downloads) + self.assetIndex = try container.decodeIfPresent(AssetIndex.self, forKey: .assetIndex) + self.downloads = try container.decodeIfPresent(Downloads.self, forKey: .downloads) self.id = try container.decode(String.self, forKey: .id) self.javaVersion = try container.decodeIfPresent(JavaVersion.self, forKey: .javaVersion) ?? .init(component: "jre-legacy", majorVersion: 8) self.libraries = try container.decode([Library].self, forKey: .libraries) - self.logging = try container.decodeIfPresent(Logging.self, forKey: .logging) ?? .init( + self.logging = (try? container.decodeIfPresent(Logging.self, forKey: .logging)) ?? .init( argument: "-Dlog4j.configurationFile=${path}", file: .init( id: "client-1.12.xml", @@ -93,6 +102,7 @@ public class ClientManifest: Decodable { self.mainClass = try container.decode(String.self, forKey: .mainClass) self.type = try container.decode(String.self, forKey: .type) self.inheritsFrom = try container.decodeIfPresent(String.self, forKey: .inheritsFrom) + self.version = try container.decodeIfPresent(String.self, forKey: .version) } public class Argument: Decodable { @@ -125,21 +135,33 @@ public class ClientManifest: Decodable { } } - public class Artifact: Decodable { + public class Artifact: Codable { public let path: String public let sha1: String? public let size: Int? - public let url: URL + public let url: URL? - public init(path: String, sha1: String?, size: Int?, url: URL) { + public init(path: String, sha1: String?, size: Int?, url: URL?) { self.path = path self.sha1 = sha1 self.size = size self.url = url } + + public required init(from decoder: any Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: ClientManifest.Artifact.CodingKeys.self) + self.path = try container.decode(String.self, forKey: ClientManifest.Artifact.CodingKeys.path) + self.sha1 = try container.decodeIfPresent(String.self, forKey: ClientManifest.Artifact.CodingKeys.sha1) + self.size = try container.decodeIfPresent(Int.self, forKey: ClientManifest.Artifact.CodingKeys.size) + self.url = try? container.decodeIfPresent(URL.self, forKey: ClientManifest.Artifact.CodingKeys.url) + } + + public func downloadItem(destinationDirectory: URL) -> DownloadItem? { + return url.map { DownloadItem(url: $0, destination: destinationDirectory.appending(path: path), sha1: sha1) } + } } - public class AssetIndex: Decodable { + public class AssetIndex: Codable { public let id: String public let sha1: String public let size: Int @@ -173,7 +195,7 @@ public class ClientManifest: Decodable { public let isNativesLibrary: Bool private enum CodingKeys: String, CodingKey { - case name, downloads, natives, rules + case name, downloads, natives, rules, url, sha1, size } private enum DownloadsCodingKeys: String, CodingKey { @@ -200,19 +222,24 @@ public class ClientManifest: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) self.name = try container.decode(String.self, forKey: .name) self.isNativesLibrary = container.contains(.natives) - let downloadsContainer = try? container.nestedContainer(keyedBy: DownloadsCodingKeys.self, forKey: .downloads) - if !isNativesLibrary { - self.artifact = try downloadsContainer.unwrap("该支持库没有 artifact。").decode(Artifact.self, forKey: .artifact) - } else { - let natives: [String: String] = try container.decode([String: String].self, forKey: .natives) - if let key = natives["osx"] { - let classifiers: [String: Artifact] = try downloadsContainer.unwrap().decode([String: Artifact].self, forKey: .classifiers) + self.rules = try container.decodeIfPresent([Rule].self, forKey: .rules) ?? [] + getArtifact: if let downloadsContainer = try? container.nestedContainer(keyedBy: DownloadsCodingKeys.self, forKey: .downloads) { + if isNativesLibrary { + let natives: [String: String] = try container.decode([String: String].self, forKey: .natives) + guard let key: String = natives["osx"] else { + self.artifact = nil + break getArtifact + } + let classifiers: [String: Artifact] = try downloadsContainer.decode([String: Artifact].self, forKey: .classifiers) self.artifact = try classifiers[key].unwrap() } else { - self.artifact = nil + self.artifact = try downloadsContainer.decode(Artifact.self, forKey: .artifact) } + } else { + let url: URL = try container.decodeIfPresent(URL.self, forKey: .url) ?? .init(string: "https://libraries.minecraft.net")! + let path: String = MavenCoordinateUtils.path(of: name) + self.artifact = .init(path: path, sha1: nil, size: nil, url: url.appending(path: path)) } - self.rules = try container.decodeIfPresent([Rule].self, forKey: .rules) ?? [] } } @@ -328,6 +355,115 @@ public class ClientManifest: Decodable { /// 创建一个新清单,继承本清单的所有属性,并使用指定的 libraries。 public func setLibraries(to libraries: [Library]) -> ClientManifest { - return .init(gameArguments: gameArguments, jvmArguments: jvmArguments, assetIndex: assetIndex, downloads: downloads, id: id, javaVersion: javaVersion, libraries: libraries, logging: logging, mainClass: mainClass, type: type, inheritsFrom: inheritsFrom) + return .init(gameArguments: gameArguments, jvmArguments: jvmArguments, assetIndex: assetIndex, downloads: downloads, id: id, javaVersion: javaVersion, libraries: libraries, logging: logging, mainClass: mainClass, type: type, inheritsFrom: inheritsFrom, version: version) + } +} + + +// MARK: - Modded 实例处理 + +public extension ClientManifest { + func merge(to baseManifest: ClientManifest) -> ClientManifest { + let isOldVersion: Bool = baseManifest.jvmArguments.contains { $0.value.contains(Self.oldVersionFlag) } + var librarySet: Set = [] + let libraries: [Library] = (libraries + baseManifest.libraries) + .filter { $0.isRulesSatisfied && librarySet.insert(.init(from: $0)).inserted } + librarySet.removeAll() + + return .init( + gameArguments: (isOldVersion ? [] : baseManifest.gameArguments) + gameArguments, + jvmArguments: (isOldVersion ? [] : baseManifest.jvmArguments) + jvmArguments, + assetIndex: baseManifest.assetIndex, + downloads: baseManifest.downloads, + id: id, + javaVersion: baseManifest.javaVersion, + libraries: libraries, + logging: baseManifest.logging, + mainClass: mainClass, + type: baseManifest.type, + inheritsFrom: nil, + version: version + ) + } + + private struct HashableLibrary: Hashable { + private let groupId: String + private let artifactId: String + private let classifier: String? + private let isNativesLibrary: Bool + + public init(from library: Library) { + self.groupId = library.groupId + self.artifactId = library.artifactId + self.classifier = library.classifier + self.isNativesLibrary = library.isNativesLibrary + } + } + + enum LoadError: LocalizedError { + case fileNotFound + case formatError + case missingParentManifest + case failedToRead(underlying: Error) + + public var errorDescription: String? { + switch self { + case .fileNotFound: + "客户端清单文件不存在。" + case .formatError: + "客户端清单格式错误。" + case .missingParentManifest: + "未找到客户端清单的父清单。" + case .failedToRead(let underlying): + "读取客户端清单失败:\(underlying.localizedDescription)" + } + } + } + + /// 从磁盘加载 `ClientManifest`。 + /// - Parameters: + /// - url: 客户端清单文件 `URL`。 + /// - loadParent: 是否加载父清单(`inhertsFrom`)。如果此参数为 `false`,且清单中包含 `inheritsFrom` 键,会抛出 `LoadError.missingParentManifest` 错误。 + /// - Returns: 一个 `ClientManifest`。 + /// - Throws: `LoadError` + static func load(at url: URL, loadParent: Bool = true) throws -> (ClientManifest, ModLoader?) { + guard FileManager.default.fileExists(atPath: url.path) else { throw LoadError.fileNotFound } + let data: Data + do { + data = try .init(contentsOf: url) + } catch { + throw LoadError.failedToRead(underlying: error) + } + + let modLoader: ModLoader? + guard let str: String = .init(data: data, encoding: .utf8) else { throw LoadError.formatError } + if str.contains("neoforge") { + modLoader = nil // unsupported + } else if str.contains("forge") { + modLoader = .forge + } else if str.contains("fabric") { + modLoader = .fabric + } else { + modLoader = nil + } + + let manifest: ClientManifest = try .load(from: data) + if let inheritsFrom: String = manifest.inheritsFrom { + guard loadParent else { throw LoadError.missingParentManifest } + let parentURL: URL = url.deletingLastPathComponent().appending(path: ".parent/\(inheritsFrom).json") + guard FileManager.default.fileExists(atPath: parentURL.path) else { throw LoadError.missingParentManifest } + let parentManifest: ClientManifest = try .load(at: parentURL, loadParent: false).0 + return (manifest.merge(to: parentManifest), modLoader) + } + return (manifest, modLoader) + } + + static func load(from data: Data) throws -> ClientManifest { + do { + return try JSONDecoder.shared.decode(ClientManifest.self, from: data) + } catch let error as DecodingError { + err("解析失败:\(error)") + throw LoadError.formatError + } } } diff --git a/PCL.Mac.Core/Models/ForgeInstallProfile.swift b/PCL.Mac.Core/Models/ForgeInstallProfile.swift new file mode 100644 index 0000000..ef92e7a --- /dev/null +++ b/PCL.Mac.Core/Models/ForgeInstallProfile.swift @@ -0,0 +1,66 @@ +// +// ForgeInstallProfile.swift +// PCL.Mac +// +// Created by AnemoFlower on 2026/2/16. +// + +import Foundation + +public struct ForgeInstallProfile: Codable { + public let data: [String: DataEntry] + public let processors: [Processor] + public let libraries: [Library] + public let clientManifestPath: String + + private enum CodingKeys: String, CodingKey { + case data + case processors + case libraries + case clientManifestPath = "json" + } + + public enum Side: String, Codable { + case client, server + } + + public struct DataEntry: Codable { + public let client: String + public let server: String + } + + public struct Processor: Codable { + public let sides: [Side]? + public let jar: String + public let classpath: [String] + public let args: [String] + public let outputs: [String: String]? + } + + public struct Library: Codable { + public let name: String + public let artifact: ClientManifest.Artifact + + private enum CodingKeys: String, CodingKey { + case name, downloads + } + + private enum DownloadsCodingKeys: String, CodingKey { + case artifact + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.name = try container.decode(String.self, forKey: .name) + self.artifact = try container.nestedContainer(keyedBy: DownloadsCodingKeys.self, forKey: .downloads) + .decode(ClientManifest.Artifact.self, forKey: .artifact) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + var downloadsContainer = container.nestedContainer(keyedBy: DownloadsCodingKeys.self, forKey: .downloads) + try downloadsContainer.encode(artifact, forKey: .artifact) + } + } +} diff --git a/PCL.Mac.Core/Models/VersionManifest.swift b/PCL.Mac.Core/Models/VersionManifest.swift index a9b82ac..13b2271 100644 --- a/PCL.Mac.Core/Models/VersionManifest.swift +++ b/PCL.Mac.Core/Models/VersionManifest.swift @@ -26,7 +26,7 @@ public struct VersionManifest: Decodable { self.versions = try container.decode([Version].self, forKey: .versions) } - public struct Version: Decodable { + public struct Version: Decodable, Hashable { public let id: String public let type: MinecraftVersion.VersionType public let url: URL diff --git a/PCL.Mac.Core/Services/ForgeInstallService.swift b/PCL.Mac.Core/Services/ForgeInstallService.swift new file mode 100644 index 0000000..6e6f8f0 --- /dev/null +++ b/PCL.Mac.Core/Services/ForgeInstallService.swift @@ -0,0 +1,218 @@ +// +// ForgeInstallService.swift +// PCL.Mac +// +// Created by 温迪 on 2026/2/15. +// + +import Foundation +import ZIPFoundation +import SwiftyJSON + +public class ForgeInstallService { + public init( + minecraftVersion: MinecraftVersion, + version: String, + repository: MinecraftRepository, + manifest: ClientManifest, + runningDirectory: URL + ) { + self.minecraftVersion = minecraftVersion + self.version = version + self.repository = repository + self.manifest = manifest + self.runningDirectory = runningDirectory + self.tempDirectory = URLConstants.tempURL.appending(path: "forge-install-\(UUID().uuidString.lowercased())") + try? FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + } + + deinit { + if FileManager.default.fileExists(atPath: tempDirectory.path) { + do { + try FileManager.default.removeItem(at: tempDirectory) + } catch { + err("删除临时目录失败:\(error.localizedDescription)") + } + } + } + + private let minecraftVersion: MinecraftVersion + private let version: String + private let repository: MinecraftRepository + private let manifest: ClientManifest + private let runningDirectory: URL + private let tempDirectory: URL + private var installProfile: ForgeInstallProfile! + private var values: [String: String]! + + private lazy var installerURL: URL = tempDirectory.appending(path: "installer") + private lazy var librariesURL: URL = repository.librariesURL + + /// 下载安装器及其所需文件。 + /// - Parameter progressHandler: 进度回调。 + public func downloadFiles(progressHandler: @MainActor @escaping (Double) -> Void) async throws { + let progressHandler: ConcurrentProgressHandler = .init(totalHandler: progressHandler) + progressHandler.startCalculate() + guard try await downloadInstaller(progressHandler: progressHandler.handler(withMultiplier: 0.3)) else { + await progressHandler.stopCalculate() + return + } + try copyLibraries() + try await downloadInstallerDependencies(progressHandler: progressHandler.handler(withMultiplier: 0.7)) + self.values = makeValueDict() + await progressHandler.stopCalculate() + } + + /// 执行安装器。 + /// - Parameter progressHandler: 进度回调。 + public func executeProcessors(progressHandler: @MainActor @escaping (Double) -> Void) async throws { + guard let installProfile else { + await progressHandler(1) + return + } + let processors: [ForgeInstallProfile.Processor] = installProfile.processors.filter { $0.sides?.contains(.client) ?? true } + var progress: Double = 0 + let progressStep: Double = 1.0 / Double(processors.count) + for processor in processors { + if processor.args.contains("DOWNLOAD_MOJMAPS") { + guard let destination: URL = values["MOJMAPS"].map(URL.init(fileURLWithPath:)) else { + throw SimpleError("下载混淆表失败:未找到混淆表下载项。") + } + try await downloadMojmaps(to: destination) + } else { + try executeProcessor(processor) + } + progress += progressStep + await progressHandler(progress) + } + await progressHandler(1) + } + + /// 下载安装器本体并解析。 + /// - Returns: 是否是新版本安装器,且需要继续执行后续步骤。 + private func downloadInstaller(progressHandler: @MainActor @escaping (Double) -> Void) async throws -> Bool { + let destination: URL = tempDirectory.appending(path: "installer.jar") + let url: URL = .init(string: "https://bmclapi2.bangbang93.com/forge/download?mcversion=\(minecraftVersion)&version=\(version)&category=installer&format=jar")! + try await SingleFileDownloader.download(url: url, destination: destination, sha1: nil, replaceMethod: .skip, progressHandler: progressHandler) + _ = try FileManager.default.unzipItem(at: destination, to: installerURL) + + // 处理客户端清单 + let manifestURL: URL = runningDirectory.appending(path: "\(runningDirectory.lastPathComponent).json") + let parentURL: URL = runningDirectory.appending(path: ".parent/\(minecraftVersion).json") + if !FileManager.default.fileExists(atPath: parentURL.path) { + try FileManager.default.createDirectory(at: runningDirectory.appending(path: ".parent"), withIntermediateDirectories: true) + try FileManager.default.moveItem(at: manifestURL, to: parentURL) + } + if FileManager.default.fileExists(atPath: manifestURL.path) { + try FileManager.default.removeItem(at: manifestURL) + } + // 此时 manifestURL 上没有文件 + + let profileURL: URL = installerURL.appending(path: "install_profile.json") + let data: Data = try .init(contentsOf: profileURL) + let json: JSON = try .init(data: data) + if json["install"].exists() { // 旧版本安装器,只需拷贝一个文件即可完成安装 + let forgeURL: URL = installerURL.appending(path: json["install"]["filePath"].stringValue) + let forgeDestination: URL = repository.librariesURL.appending(path: MavenCoordinateUtils.path(of: json["install"]["path"].stringValue)) + if !FileManager.default.fileExists(atPath: forgeDestination.path) { + try FileManager.default.createDirectory(at: forgeDestination.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.copyItem(at: forgeURL, to: forgeDestination) + } + + try json["versionInfo"].rawData().write(to: manifestURL) + return false + } else { + self.installProfile = try JSONDecoder.shared.decode(ForgeInstallProfile.self, from: data) + try FileManager.default.moveItem(at: installerURL.appending(path: "version.json"), to: manifestURL) + return true + } + } + + private func makeValueDict() -> [String: String] { + let values: [String: String] = [ + "SIDE": "client", + "INSTALLER": tempDirectory.appending(path: "installer.jar").path, + "MINECRAFT_JAR": runningDirectory.appending(path: "\(runningDirectory.lastPathComponent).jar").path, + "MINECRAFT_VERSION": minecraftVersion.id, + "ROOT": repository.url.path, + "LIBRARY_DIR": librariesURL.path + ].merging(installProfile.data.mapValues { parseValue($0.client) }, uniquingKeysWith: { _, value in value }) + + return values + } + + + private func parseValue(_ value: String) -> String { + if value.starts(with: "/") { + return installerURL.appending(path: value).path + } else if value.starts(with: "[") && value.hasSuffix("]") { + let path: String = MavenCoordinateUtils.path(of: String(value.dropFirst().dropLast())) + return librariesURL.appending(path: path).path + } else if value.starts(with: "'") && value.hasSuffix("'") { + return String(value.dropFirst().dropLast()) + } else { + return value + } + } + + private func copyLibraries() throws { + let sourceDirectory: URL = installerURL.appending(path: "maven") + guard FileManager.default.fileExists(atPath: sourceDirectory.path) else { + return + } + let baseComponents: [String] = sourceDirectory.pathComponents + + guard let enumerator: FileManager.DirectoryEnumerator = FileManager.default.enumerator( + at: sourceDirectory, + includingPropertiesForKeys: [.isRegularFileKey] + ) else { + throw SimpleError("创建 enumerator 失败。") + } + + for case let url as URL in enumerator { + guard let isRegularFile: Bool = try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile, + isRegularFile else { + continue + } + let components: [String] = url.pathComponents + guard components.starts(with: baseComponents) else { continue } + let relativePath: String = components.dropFirst(baseComponents.count).joined(separator: "/") + let destination: URL = repository.librariesURL.appending(path: relativePath) + if FileManager.default.fileExists(atPath: destination.path) { continue } + try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.moveItem(at: url, to: destination) + } + } + + /// 下载安装器所需的依赖项。 + private func downloadInstallerDependencies(progressHandler: @MainActor @escaping (Double) -> Void) async throws { + let libraries: [ForgeInstallProfile.Library] = installProfile.libraries + let downloadItems: [DownloadItem] = libraries.compactMap { $0.artifact.downloadItem(destinationDirectory: librariesURL) } + try await MultiFileDownloader(items: downloadItems, concurrentLimit: 64, replaceMethod: .replace, progressHandler: progressHandler).start() + } + + private func parseMavenCoord(coord: String) -> String { + return librariesURL.appending(path: MavenCoordinateUtils.path(of: coord)).path + } + + private func executeProcessor(_ processor: ForgeInstallProfile.Processor) throws { + let classpath: String = (processor.classpath + [processor.jar]).map(parseMavenCoord(coord:)).joined(separator: ":") + let mainClass: String = try JarUtils.mainClass(of: librariesURL.appending(path: MavenCoordinateUtils.path(of: processor.jar))) + let arguments: [String] = ["-cp", classpath, mainClass] + processor.args.map { Utils.replace(parseValue($0), withValues: values, withDollarPrefix: false) } + let process: Process = .init() + process.arguments = arguments + process.executableURL = URL(fileURLWithPath: "/usr/bin/java") + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { + throw SimpleError("Forge 安装器 \(processor.jar) 执行失败。") + } + } + + private func downloadMojmaps(to destination: URL) async throws { + guard let url: URL = manifest.downloads.clientMappings?.url else { + throw SimpleError("下载混淆表失败:未找到混淆表下载项。") + } + try await SingleFileDownloader.download(url: url, destination: destination, sha1: nil, replaceMethod: .skip) + } +} diff --git a/PCL.Mac.Core/Services/MicrosoftAuthService.swift b/PCL.Mac.Core/Services/MicrosoftAuthService.swift index 388a2bc..bb8d9f7 100644 --- a/PCL.Mac.Core/Services/MicrosoftAuthService.swift +++ b/PCL.Mac.Core/Services/MicrosoftAuthService.swift @@ -142,11 +142,12 @@ public class MicrosoftAuthService { } private func post(_ url: URLConvertible, _ body: [String: Any], encodeMethod: Requests.EncodeMethod = .json) async throws -> JSON { - let json: JSON = try await Requests.post(url, body: body, using: encodeMethod).json() + let response = try await Requests.post(url, body: body, using: encodeMethod) + let json: JSON = try response.json() if let error: String = json["error"].string, error != "authorization_pending" && error != "slow_down" { let description: String = json["error_description"].string ?? json["errorMessage"].stringValue - err("调用 API 失败:\(error),错误描述:\(description)") + err("调用 API 失败:\(response.statusCode) \(error),错误描述:\(description)") throw Error.apiError(description: description) } return json diff --git a/PCL.Mac.Core/Task/MinecraftInstallTask.swift b/PCL.Mac.Core/Task/MinecraftInstallTask.swift index 82c8d3d..d71005d 100644 --- a/PCL.Mac.Core/Task/MinecraftInstallTask.swift +++ b/PCL.Mac.Core/Task/MinecraftInstallTask.swift @@ -18,12 +18,14 @@ public enum MinecraftInstallTask { /// - name: 实例名。 /// - version: Minecraft 版本。 /// - minecraftDirectory: 实例所在的 Minecraft 目录。 + /// - modLoader: 需要附加的模组加载器。 /// - completion: 任务完成回调,会在主线程执行。 /// - Returns: 实例安装任务。 public static func create( name: String, version: MinecraftVersion, repository: MinecraftRepository, + modLoader: Loader?, completion: ((MinecraftInstance) -> Void)? = nil ) -> MyTask { let model: Model = .init( @@ -31,8 +33,11 @@ public enum MinecraftInstallTask { version: version, repository: repository ) - return .init( - name: "\(name) 安装", model: model, + var subTasks: [SubTask] = [ + .init(0, "__pre", display: false) { _, model in + try FileManager.default.createDirectory(at: model.runningDirectory, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: model.runningDirectory.appending(path: ".incomplete").path, contents: nil) + }, .init(0, "下载客户端 JSON 文件") { task, model in guard let versionManifest = CoreState.versionManifest else { err("CoreState.versionManifest 为空") @@ -45,11 +50,10 @@ public enum MinecraftInstallTask { progressHandler: task.setProgress(_:) ) model.manifest = manifest - model.mappedManifest = NativesMapper.map(manifest) }, .init(1, "下载资源索引文件") { task, model in let assetIndex: AssetIndex = try await downloadAssetIndex( - assetIndex: model.mappedManifest.assetIndex, + assetIndex: model.manifest.assetIndex, repository: model.repository, progressHandler: task.setProgress(_:) ) @@ -57,26 +61,39 @@ public enum MinecraftInstallTask { }, .init(2, "下载客户端本体") { task, model in try await downloadClient( - clientDownload: model.mappedManifest.downloads.client, + clientDownload: model.manifest.downloads.client, runningDirectory: model.runningDirectory, progressHandler: task.setProgress(_:) ) }, - .init(2, "下载散列资源文件") { task, model in + + // Forge / NeoForge + + .init(5, "__modify_manifest", display: false) { task, model in + let manifestURL: URL = model.runningDirectory.appending(path: "\(model.name).json") + if var dict: [String: Any] = try JSON(data: Data(contentsOf: manifestURL)).dictionaryObject { + dict["id"] = model.name + dict["version"] = model.version.id + let manifestData: Data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys]) + try manifestData.write(to: manifestURL, options: .atomic) + } + model.mappedManifest = NativesMapper.map(model.manifest) + }, + .init(6, "下载散列资源文件") { task, model in try await downloadAssets( assetIndex: model.assetIndex, repository: model.repository, progressHandler: task.setProgress(_:) ) }, - .init(2, "下载依赖库文件") { task, model in + .init(6, "下载依赖库文件") { task, model in try await downloadLibraries( manifest: model.mappedManifest, repository: model.repository, progressHandler: task.setProgress(_:) ) }, - .init(3, "解压本地库文件", display: version < .init("1.19.1")) { task, model in + .init(7, "解压本地库文件", display: version < .init("1.19.1")) { task, model in try await extractNatives( manifest: model.mappedManifest, runningDirectory: model.runningDirectory, @@ -84,19 +101,61 @@ public enum MinecraftInstallTask { progressHandler: task.setProgress(_:) ) }, - .init(4, "__completion", display: false) { _, _ in + .init(8, "__completion", display: false) { _, _ in let instance: MinecraftInstance = .init( runningDirectory: repository.versionsURL.appending(path: name), version: version, manifest: model.manifest, - config: .init() + config: .init(), + modLoader: modLoader?.type ) repository.instances?.append(instance) + try? FileManager.default.removeItem(at: model.runningDirectory.appending(path: ".incomplete")) await MainActor.run { completion?(instance) } } - ) + ] + + if let modLoader { + switch modLoader.type { + case .fabric: + subTasks.insert( + .init(3, "安装 Fabric Loader") { task, model in + let manifest: ClientManifest = try await downloadFabricManifest( + version: version, + repository: repository, + runningDirectory: model.runningDirectory, + loaderVersion: modLoader.version, + progressHandler: task.setProgress(_:) + ) + model.manifest = manifest.merge(to: model.manifest) + }, + at: 3 + ) + + case .forge: + subTasks.insert( + .init(3, "下载 Forge 安装器文件") { task, model in + let service: ForgeInstallService = .init(minecraftVersion: model.version, version: modLoader.version, repository: model.repository, manifest: model.manifest, runningDirectory: model.runningDirectory) + model.forgeInstallService = service + try await service.downloadFiles(progressHandler: task.setProgress(_:)) + }, + at: 3 + ) + subTasks.insert( + .init(4, "执行 Forge 安装器") { task, model in + try await model.forgeInstallService!.executeProcessors(progressHandler: task.setProgress(_:)) + model.manifest = try .load(at: model.runningDirectory.appending(path: "\(model.name).json")).0 + }, + at: 4 + ) + } + } + + return .init(name: "\(name) 安装", model: model, subTasks) { _ in + try? FileManager.default.removeItem(at: repository.versionsURL.appending(path: name)) + } } /// 补全实例资源文件。 @@ -109,38 +168,43 @@ public enum MinecraftInstallTask { repository: MinecraftRepository, progressHandler: @MainActor @escaping (Double) -> Void ) async throws { - var progress: [Double] = Array(repeating: 0, count: 5) { - didSet { - progressHandler(progress[0] * 0.15 + progress[1] * 0.05 + progress[2] * 0.5 + progress[3] * 0.25 + progress[4] * 0.05) - } + let progressHandler: ConcurrentProgressHandler = .init(totalHandler: progressHandler) + progressHandler.startCalculate(interval: 0.1) + + if let downloads = manifest.downloads { + try await downloadClient( + clientDownload: downloads.client, + runningDirectory: runningDirectory, + progressHandler: progressHandler.handler(withMultiplier: 0.15) + ) + } else { + warn("manifest.downloads 为空") + await progressHandler.handler(withMultiplier: 0.15)(1) } - try await downloadClient( - clientDownload: manifest.downloads.client, - runningDirectory: runningDirectory, - progressHandler: { progress[0] = $0 } - ) let assetIndex: AssetIndex = try await downloadAssetIndex( assetIndex: manifest.assetIndex, repository: repository, - progressHandler: { progress[1] = $0 } + progressHandler: progressHandler.handler(withMultiplier: 0.05) ) try await downloadAssets( assetIndex: assetIndex, repository: repository, - progressHandler: { progress[2] = $0 } + progressHandler: progressHandler.handler(withMultiplier: 0.5) ) try await downloadLibraries( manifest: manifest, repository: repository, - progressHandler: { progress[3] = $0 } + progressHandler: progressHandler.handler(withMultiplier: 0.25) ) try await extractNatives( manifest: manifest, runningDirectory: runningDirectory, repository: repository, - progressHandler: { progress[4] = $0 } + progressHandler: progressHandler.handler(withMultiplier: 0.05) ) + + await progressHandler.stopCalculate() } private static func downloadClientManifest( @@ -219,7 +283,7 @@ public enum MinecraftInstallTask { ) async throws { let items: [DownloadItem] = (manifest.getLibraries() + manifest.getNatives()) .compactMap(\.artifact) - .map { DownloadItem(url: $0.url, destination: repository.librariesURL.appending(path: $0.path), sha1: $0.sha1) } + .compactMap { $0.downloadItem(destinationDirectory: repository.librariesURL) } try await MultiFileDownloader(items: items, concurrentLimit: 64, replaceMethod: .skip, progressHandler: progressHandler).start() } @@ -270,6 +334,8 @@ public enum MinecraftInstallTask { public var mappedManifest: ClientManifest! public var assetIndex: AssetIndex! + public var forgeInstallService: ForgeInstallService? + public init(name: String, version: MinecraftVersion, repository: MinecraftRepository) { self.name = name self.version = version @@ -277,4 +343,44 @@ public enum MinecraftInstallTask { self.repository = repository } } + + public struct Loader { + public let type: ModLoader + public let version: String + + public init(type: ModLoader, version: String) { + self.type = type + self.version = version + } + } +} + +// MARK: - Fabric 安装 +extension MinecraftInstallTask { + private static func downloadFabricManifest( + version: MinecraftVersion, + repository: MinecraftRepository, + runningDirectory: URL, + loaderVersion: String, + progressHandler: @MainActor @escaping (Double) -> Void + ) async throws -> ClientManifest { + let manifestURL: URL = runningDirectory.appending(path: "\(runningDirectory.lastPathComponent).json") + let parentURL: URL = runningDirectory.appending(path: ".parent/\(version).json") + if !FileManager.default.fileExists(atPath: parentURL.path) { + try FileManager.default.createDirectory(at: runningDirectory.appending(path: ".parent"), withIntermediateDirectories: true) + try FileManager.default.moveItem(at: manifestURL, to: parentURL) + } + if FileManager.default.fileExists(atPath: manifestURL.path) { + try FileManager.default.removeItem(at: manifestURL) + } + let url: URL = .init(string: "https://meta.fabricmc.net/v2/versions/loader/\(version)/\(loaderVersion)/profile/json")! + try await SingleFileDownloader.download( + url: url, + destination: manifestURL, + sha1: nil, + replaceMethod: .throw, + progressHandler: progressHandler + ) + return try JSONDecoder.shared.decode(ClientManifest.self, from: Data(contentsOf: manifestURL)) + } } diff --git a/PCL.Mac.Core/Task/MyTask.swift b/PCL.Mac.Core/Task/MyTask.swift index 01df409..0a6769a 100644 --- a/PCL.Mac.Core/Task/MyTask.swift +++ b/PCL.Mac.Core/Task/MyTask.swift @@ -31,12 +31,14 @@ public class MyTask: ObservableObject, Identifiable { public let name: String public let subTasks: [SubTask] private let model: Model + private let failureHandler: ((Error) -> Void)? private var cancellables: [AnyCancellable] = [] - private init(name: String, model: Model, _ subTasks: [SubTask]) { + public init(name: String, model: Model, _ subTasks: [SubTask], failureHandler: ((Error) -> Void)? = nil) { self.name = name self.model = model self.subTasks = subTasks + self.failureHandler = failureHandler cancellables = subTasks.map { subTask in subTask.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() @@ -49,8 +51,9 @@ public class MyTask: ObservableObject, Identifiable { /// - name: 任务名。 /// - model: 任务模型,用于在子任务间共享数据。 /// - subTasks: 该任务的子任务列表。 - public convenience init(name: String, model: Model, _ subTasks: SubTask...) { - self.init(name: name, model: model, subTasks) + /// - failureHandler: 任务失败回调。 + public convenience init(name: String, model: Model, _ subTasks: SubTask..., failureHandler: ((Error) -> Void)? = nil) { + self.init(name: name, model: model, subTasks, failureHandler: failureHandler) } /// 开始按顺序执行任务。 @@ -90,6 +93,11 @@ public class MyTask: ObservableObject, Identifiable { try await execute(taskList: subTaskList) } catch let error as CancellationError { log("任务 \(name) 被中断") + failureHandler?(error) + throw error + } catch { + err("任务 \(name) 执行失败:\(error.localizedDescription)") + failureHandler?(error) throw error } } @@ -182,8 +190,9 @@ extension MyTask where Model == EmptyModel { /// - Parameters: /// - name: 任务名。 /// - subTasks: 子任务列表。 - public convenience init(name: String, _ subTasks: SubTask...) { - self.init(name: name, model: EmptyModel(), subTasks) + /// - failureHandler: 任务失败回调。 + public convenience init(name: String, _ subTasks: SubTask..., failureHandler: ((Error) -> Void)? = nil) { + self.init(name: name, model: EmptyModel(), subTasks, failureHandler: failureHandler) } } diff --git a/PCL.Mac.Core/Utils/ConcurrentProgressHandler.swift b/PCL.Mac.Core/Utils/ConcurrentProgressHandler.swift new file mode 100644 index 0000000..d3e93c7 --- /dev/null +++ b/PCL.Mac.Core/Utils/ConcurrentProgressHandler.swift @@ -0,0 +1,71 @@ +// +// ConcurrentProgressHandler.swift +// PCL.Mac +// +// Created by AnemoFlower on 2026/2/16. +// + +import Foundation + +public class ConcurrentProgressHandler { + private let totalHandler: @MainActor (Double) -> Void + @MainActor private var progressMap: [UUID: Progress] = [:] + private var calculateTask: Task? + + public init(totalHandler: @MainActor @escaping (Double) -> Void) { + self.totalHandler = totalHandler + } + + deinit { + calculateTask?.cancel() + calculateTask = nil + } + + /// 创建一个新的进度处理器。 + /// - Parameter multiplier: 该处理器的倍率。 + @MainActor + public func handler(withMultiplier multiplier: Double) -> (@MainActor (Double) -> Void) { + let id: UUID = .init() + progressMap[id] = .init(multiplier: multiplier) + let handler: (@MainActor (Double) -> Void) = { [weak self] progress in + self?.progressMap[id]?.currentProgress = progress + } + return handler + } + + /// 开始计算并同步进度。 + /// - Parameter interval: 计算间隔,默认为 0.1s。 + public func startCalculate(interval: Double = 0.1) { + if calculateTask != nil { return } + calculateTask = Task.detached { [weak self] in + while !Task.isCancelled { + guard let self else { return } + await self.totalHandler(await self.calculateProgress()) + try await Task.sleep(seconds: interval) + } + } + } + + /// 停止计算并最后一次同步进度。 + @MainActor + public func stopCalculate() { + calculateTask?.cancel() + calculateTask = nil + totalHandler(1) + } + + @MainActor + private func calculateProgress() -> Double { + min(max(progressMap.values.reduce(0) { $0 + $1.currentProgress * $1.multiplier }, 0), 1) + } + + private class Progress { + public let multiplier: Double + public var currentProgress: Double + + public init(multiplier: Double) { + self.multiplier = multiplier + self.currentProgress = 0 + } + } +} diff --git a/PCL.Mac.Core/Utils/Errors.swift b/PCL.Mac.Core/Utils/Errors.swift index e5d5d38..6b224d8 100644 --- a/PCL.Mac.Core/Utils/Errors.swift +++ b/PCL.Mac.Core/Utils/Errors.swift @@ -36,6 +36,7 @@ public enum TaskError: Error, Equatable { public enum MinecraftError: LocalizedError { case missingManifest case unknownManifestFormat + case incomplete public var errorDescription: String? { switch self { @@ -43,6 +44,8 @@ public enum MinecraftError: LocalizedError { "未找到客户端清单文件。" case .unknownManifestFormat: "未知的客户端清单格式,可能是由外部安装的实例。" + case .incomplete: + "这个实例还未完成安装进程。" } } } diff --git a/PCL.Mac.Core/Utils/JarUtils.swift b/PCL.Mac.Core/Utils/JarUtils.swift new file mode 100644 index 0000000..78e5021 --- /dev/null +++ b/PCL.Mac.Core/Utils/JarUtils.swift @@ -0,0 +1,41 @@ +// +// JarUtils.swift +// PCL.Mac +// +// Created by AnemoFlower on 2026/2/17. +// + +import Foundation +import ZIPFoundation + +public enum JarUtils { + public static func mainClass(of jarURL: URL) throws -> String { + let archive: Archive = try Archive(url: jarURL, accessMode: .read) + guard let manifestEntry: Entry = archive["META-INF/MANIFEST.MF"] else { + throw Error.missingManifest + } + var dataBuffer: Data = .init() + _ = try archive.extract(manifestEntry, consumer: { (chunk) in + dataBuffer.append(chunk) + }) + guard let manifest: String = .init(data: dataBuffer, encoding: .utf8) else { + throw Error.failedToDecodeManifest + } + + let lines: [String] = manifest.components(separatedBy: .newlines) + for line in lines { + if line.starts(with: "Main-Class: ") { + return String(line.dropFirst("Main-Class: ".count)) + } + } + throw Error.mainClassNotFound + } + + public enum Error: LocalizedError { + case missingManifest + case failedToDecodeManifest + case mainClassNotFound + + public var errorDescription: String? { "获取主类失败。" } + } +} diff --git a/PCL.Mac.Core/Utils/MavenCoordinateUtils.swift b/PCL.Mac.Core/Utils/MavenCoordinateUtils.swift index 4d22217..dea0c60 100644 --- a/PCL.Mac.Core/Utils/MavenCoordinateUtils.swift +++ b/PCL.Mac.Core/Utils/MavenCoordinateUtils.swift @@ -13,22 +13,29 @@ public enum MavenCoordinateUtils { public var artifactId: String public var version: String public var classifier: String? + public var packaging: String public var name: String { "\(groupId):\(artifactId):\(version)" + (classifier != nil ? ":\(classifier!)" : "") } - public init(groupId: String, artifactId: String, version: String, classifier: String?) { + public init(groupId: String, artifactId: String, version: String, classifier: String?, packaging: String = "jar") { self.groupId = groupId self.artifactId = artifactId self.version = version self.classifier = classifier + self.packaging = packaging } public static func parse(coord: String) -> MavenCoordinate { - let parts: [String] = coord.split(separator: ":").map(String.init) + let extensionParts: [String] = coord.split(separator: "@").map(String.init) + + let parts: [String] = extensionParts[0].split(separator: ":").map(String.init) let groupId: String = parts.count >= 1 ? parts[0] : "" let artifactId: String = parts.count >= 2 ? parts[1] : "" let version: String = parts.count >= 3 ? parts[2] : "" let classifier: String? = parts.count >= 4 ? parts[3] : nil - return .init(groupId: groupId, artifactId: artifactId, version: version, classifier: classifier) + + let packaging: String = extensionParts.count == 2 ? extensionParts[1] : "jar" + + return .init(groupId: groupId, artifactId: artifactId, version: version, classifier: classifier, packaging: packaging) } } @@ -36,8 +43,8 @@ public enum MavenCoordinateUtils { let parsed: MavenCoordinate = .parse(coord: coord) let path: String = "\(parsed.groupId.replacingOccurrences(of: ".", with: "/"))/\(parsed.artifactId)/\(parsed.version)/" if let classifier = parsed.classifier { - return path + "\(parsed.artifactId)-\(parsed.version)-\(classifier).jar" + return path + "\(parsed.artifactId)-\(parsed.version)-\(classifier).\(parsed.packaging)" } - return path + "\(parsed.artifactId)-\(parsed.version).jar" + return path + "\(parsed.artifactId)-\(parsed.version).\(parsed.packaging)" } } diff --git a/PCL.Mac.Core/Utils/Utils.swift b/PCL.Mac.Core/Utils/Utils.swift new file mode 100644 index 0000000..27c4ec2 --- /dev/null +++ b/PCL.Mac.Core/Utils/Utils.swift @@ -0,0 +1,18 @@ +// +// Utils.swift +// PCL.Mac +// +// Created by AnemoFlower on 2026/2/17. +// + +import Foundation + +public enum Utils { + public static func replace(_ string: String, withValues values: [String: String], withDollarPrefix: Bool = true) -> String { + var s: String = string + for key in values.keys { + s = s.replacingOccurrences(of: (withDollarPrefix ? "$" : "") + "{\(key)}", with: values[key]!) + } + return s + } +} diff --git a/PCL.Mac.xcodeproj/project.pbxproj b/PCL.Mac.xcodeproj/project.pbxproj index 87543f7..d99d880 100644 --- a/PCL.Mac.xcodeproj/project.pbxproj +++ b/PCL.Mac.xcodeproj/project.pbxproj @@ -611,7 +611,6 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = PCL.Mac/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = PCL.Mac; - INFOPLIST_KEY_GCSupportsGameMode = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( @@ -642,7 +641,6 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = PCL.Mac/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = PCL.Mac; - INFOPLIST_KEY_GCSupportsGameMode = YES; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/PCL.Mac/App/AppRouter.swift b/PCL.Mac/App/AppRouter.swift index 2ae083c..3e16596 100644 --- a/PCL.Mac/App/AppRouter.swift +++ b/PCL.Mac/App/AppRouter.swift @@ -19,7 +19,7 @@ enum AppRoute: Identifiable, Hashable, Equatable { case instanceConfig(id: String) // 下载页面的子页面 - case minecraftDownload, downloadPage2, downloadPage3 + case minecraftDownload, minecraftInstallOptions(version: VersionManifest.Version), downloadPage2, downloadPage3 // 联机页面的子页面 case multiplayerSub, multiplayerSettings @@ -50,6 +50,8 @@ class AppRouter: ObservableObject { LaunchPage() case .minecraftDownload: MinecraftDownloadPage() + case .minecraftInstallOptions(let version): + MinecraftInstallOptionsPage(version: version) case .downloadPage2: DownloadPage2() case .downloadPage3: @@ -93,6 +95,7 @@ class AppRouter: ObservableObject { case .tasks: return true case .instanceList, .noInstanceRepository: return true case .instanceSettings, .instanceConfig: return true + case .minecraftInstallOptions: return true default: return false } } @@ -103,6 +106,7 @@ class AppRouter: ObservableObject { case .tasks: "任务列表" case .instanceList, .noInstanceRepository: "实例列表" case .instanceSettings(let id), .instanceConfig(let id): "实例设置 - \(id)" + case .minecraftInstallOptions(let version): "游戏安装 - \(version.id)" default: "错误:当前页面没有标题,请报告此问题!" } } diff --git a/PCL.Mac/App/AppWindow.swift b/PCL.Mac/App/AppWindow.swift index a7050f2..760ec42 100644 --- a/PCL.Mac/App/AppWindow.swift +++ b/PCL.Mac/App/AppWindow.swift @@ -27,7 +27,7 @@ class AppWindow: NSWindow { rootView: ContentView() .ignoresSafeArea(.container, edges: .top) .frame(minWidth: 1000, minHeight: 550) - .environmentObject(InstanceViewModel()) + .environmentObject(InstanceManager.shared) .environmentObject(MinecraftDownloadPageViewModel()) .environmentObject(InstanceListViewModel()) .environmentObject(MultiplayerViewModel()) diff --git a/PCL.Mac/Assets.xcassets/VersionIcons/Fabric.imageset/Contents.json b/PCL.Mac/Assets.xcassets/VersionIcons/Fabric.imageset/Contents.json new file mode 100644 index 0000000..05bc550 --- /dev/null +++ b/PCL.Mac/Assets.xcassets/VersionIcons/Fabric.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Fabric.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PCL.Mac/Assets.xcassets/VersionIcons/Fabric.imageset/Fabric.png b/PCL.Mac/Assets.xcassets/VersionIcons/Fabric.imageset/Fabric.png new file mode 100644 index 0000000..e70888d Binary files /dev/null and b/PCL.Mac/Assets.xcassets/VersionIcons/Fabric.imageset/Fabric.png differ diff --git a/PCL.Mac/Assets.xcassets/VersionIcons/Forge.imageset/Contents.json b/PCL.Mac/Assets.xcassets/VersionIcons/Forge.imageset/Contents.json new file mode 100644 index 0000000..06d7aeb --- /dev/null +++ b/PCL.Mac/Assets.xcassets/VersionIcons/Forge.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Forge.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PCL.Mac/Assets.xcassets/VersionIcons/Forge.imageset/Forge.png b/PCL.Mac/Assets.xcassets/VersionIcons/Forge.imageset/Forge.png new file mode 100644 index 0000000..ab0b74e Binary files /dev/null and b/PCL.Mac/Assets.xcassets/VersionIcons/Forge.imageset/Forge.png differ diff --git a/PCL.Mac/Components/MyCard.swift b/PCL.Mac/Components/MyCard.swift index c073c79..14aa8d4 100644 --- a/PCL.Mac/Components/MyCard.swift +++ b/PCL.Mac/Components/MyCard.swift @@ -8,7 +8,9 @@ import SwiftUI struct MyCard: View { - @Environment(\.cardIndex) private var index: Int? + @Environment(\.cardIndex) private var index: Int + @Environment(\.disableCardAppearAnimation) private var disableCardAppearAnimation: Bool + @Environment(\.disableHoverAnimation) private var disableHoverAnimation: Bool /// 带动画 @State private var appeared: Bool = false /// 无动画,在 `appeared` 动画结束后变更 @@ -23,14 +25,25 @@ struct MyCard: View { @State private var lastClick: Date = .distantPast private let title: String private let foldable: Bool + private let initialFolded: Bool? private let titled: Bool private let limitHeight: Bool private let padding: CGFloat private let content: () -> Content - init(_ title: String, foldable: Bool = true, titled: Bool = true, limitHeight: Bool = true, padding: CGFloat = 18, @ViewBuilder _ content: @escaping () -> Content) { + /// 创建一个卡片视图。 + /// - Parameters: + /// - title: 卡片的标题。在 `titled` 为 `false` 时,该参数会被忽略。 + /// - foldable: 卡片是否可被折叠。当 `folded` 未被指定时,卡片默认不会被折叠。 + /// - folded: 卡片的初始折叠状态。 + /// - titled: 卡片是否拥有标题栏。当 `folded` 未被指定时,卡片默认不会被折叠。 + /// - limitHeight: 是否限制卡片高度。若该参数为 `false`,请手动设置卡片高度。 + /// - padding: 卡片的内边距。 + /// - content: 卡片内容。 + init(_ title: String, foldable: Bool = true, folded: Bool? = nil, titled: Bool = true, limitHeight: Bool = true, padding: CGFloat = 18, @ViewBuilder _ content: @escaping () -> Content) { self.title = title self.foldable = foldable && titled + self.initialFolded = folded self.titled = titled self.limitHeight = limitHeight self.padding = padding @@ -53,7 +66,7 @@ struct MyCard: View { } } } - .foregroundStyle(appearFinished && hovered ? Color.color2 : .color1) + .foregroundStyle(appearFinished && !disableHoverAnimation && hovered ? Color.color2 : .color1) .frame(height: titled ? 12 : 0) .frame(maxWidth: .infinity) .padding(titled ? 12 : padding / 2) @@ -67,6 +80,9 @@ struct MyCard: View { folded = false showContent = true withAnimation(.linear(duration: 0.2)) { + internalContentHeight = min(1000, contentHeight) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { internalContentHeight = contentHeight } } else { @@ -75,7 +91,7 @@ struct MyCard: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { showContent = false } - internalContentHeight = min(2000, contentHeight) // 控制回弹上限 + internalContentHeight = min(1000, contentHeight) // 控制回弹上限 withAnimation(.spring(response: 0.35)) { internalContentHeight = 0 } @@ -84,6 +100,7 @@ struct MyCard: View { VStack { content() } + .disableHoverAnimation(!appearFinished) .padding(EdgeInsets(top: 0, leading: padding, bottom: padding, trailing: padding)) .background { GeometryReader { proxy in @@ -113,40 +130,32 @@ struct MyCard: View { .offset(y: appeared ? 0 : -25) .opacity(appeared ? 1 : 0) .animation(.easeInOut(duration: 0.2), value: hovered) - .animation(.spring(response: 0.4, dampingFraction: 0.5), value: appeared) .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(index ?? 0) * 0.04) { + if disableCardAppearAnimation { appeared = true - } - DispatchQueue.main.asyncAfter(deadline: .now() + Double(index ?? 0) * 0.04 + 0.4) { appearFinished = true + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.04) { + withAnimation(.spring(response: 0.4, dampingFraction: 0.5)) { appeared = true } + } + DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.04 + 0.4) { + appearFinished = true + } } - if !foldable || !titled { - folded = false - showContent = true - internalContentHeight = contentHeight + + if let initialFolded { + folded = initialFolded + } else { + if !foldable || !titled { + folded = false + showContent = true + internalContentHeight = contentHeight + } } } } } -private struct CardIndexKey: EnvironmentKey { - static let defaultValue: Int? = nil -} - -extension EnvironmentValues { - var cardIndex: Int? { - get { self[CardIndexKey.self] } - set { self[CardIndexKey.self] = newValue } - } -} - -extension View { - func cardIndex(_ index: Int) -> some View { - environment(\.cardIndex, index) - } -} - #Preview { MyCard("卡片测试") { ZStack { diff --git a/PCL.Mac/Components/MyExtraTextButton.swift b/PCL.Mac/Components/MyExtraTextButton.swift new file mode 100644 index 0000000..4f2743b --- /dev/null +++ b/PCL.Mac/Components/MyExtraTextButton.swift @@ -0,0 +1,53 @@ +// +// MyExtraTextButton.swift +// PCL.Mac +// +// Created by 温迪 on 2026/2/12. +// + +import SwiftUI + +struct MyExtraTextButton: View { + @State private var hovered: Bool = false + @State private var pressed: Bool = false + private let image: String + private let imageSize: CGFloat + private let text: String + private let action: () -> Void + + init(image: String, imageSize: CGFloat, text: String, action: @escaping () -> Void) { + self.image = image + self.imageSize = imageSize + self.text = text + self.action = action + } + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 1000) + .fill(hovered ? Color.color4 : .color3) + HStack(spacing: 12) { + Image(image) + .resizable() + .scaledToFit() + .foregroundStyle(.white) + .frame(width: imageSize, height: imageSize) + MyText(text, size: 16, color: .white) + } + .padding() + } + .fixedSize(horizontal: true, vertical: true) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in pressed = true } + .onEnded { _ in + pressed = false + action() + } + ) + .onHover { hovered = $0 } + .scaleEffect(pressed ? 0.85 : 1, anchor: .center) + .animation(.linear(duration: 0.15), value: hovered) + .animation(.easeOut(duration: 0.15), value: pressed) + } +} diff --git a/PCL.Mac/Components/MyList.swift b/PCL.Mac/Components/MyList.swift index dce3f2b..c1f6188 100644 --- a/PCL.Mac/Components/MyList.swift +++ b/PCL.Mac/Components/MyList.swift @@ -9,26 +9,26 @@ import SwiftUI struct MyList: View { private let items: [ListItem] - private let onSelect: ((Int) -> Void)? + private let onSelect: ((Int?) -> Void)? @State private var selected: Int? - init(items: [ListItem], onSelect: ((Int) -> Void)? = nil) { + init(_ items: [ListItem], onSelect: ((Int?) -> Void)? = nil) { self.items = items self.onSelect = onSelect } - init(_ items: ListItem..., onSelect: ((Int) -> Void)? = nil) { + init(_ items: ListItem..., onSelect: ((Int?) -> Void)? = nil) { self.items = items self.onSelect = onSelect } var body: some View { - VStack(spacing: 0) { + LazyVStack(spacing: 0) { ForEach(0..: View { if selected { RightRoundedRectangle(cornerRadius: 2) .fill(Color.color3) - .frame(width: 4, height: 20) + .frame(width: 4, height: 24) .offset(x: -4) } HStack { diff --git a/PCL.Mac/Components/MyTextField.swift b/PCL.Mac/Components/MyTextField.swift index 14a39eb..8c49dd6 100644 --- a/PCL.Mac/Components/MyTextField.swift +++ b/PCL.Mac/Components/MyTextField.swift @@ -8,52 +8,27 @@ import SwiftUI struct MyTextField: View { - @State private var text: String + @Binding private var text: String @State private var hovered: Bool = false @FocusState private var focused: Bool private let placeholder: String - private let immediately: Bool - private let onSubmit: ((String) -> Void)? - init(initial: String = "", placeholder: String = "", immediately: Bool = false, onSubmit: ((String) -> Void)? = nil) { - self.text = initial + init(text: Binding, placeholder: String = "") { + self._text = text self.placeholder = placeholder - self.immediately = immediately - self.onSubmit = onSubmit - } - - init(initial: T? = nil, placeholder: String = "", parse: @escaping (String) -> T?, onSubmit: @escaping (T) -> Void) { - self.text = initial.map(String.init) ?? "" - self.placeholder = placeholder - self.immediately = false - self.onSubmit = { text in - guard let value: T = parse(text) else { - hint("数字格式不正确!", type: .critical) - return - } - onSubmit(value) - } - } - - init(initial: Int? = nil, placeholder: String = "", onSubmit: @escaping (Int) -> Void) { - self.init(initial: initial, placeholder: placeholder, parse: { .init($0) }, onSubmit: onSubmit) } var body: some View { ZStack(alignment: .leading) { - TextField("", text: $text) + TextField("", text: _text) .textFieldStyle(.plain) .focused($focused) .padding(4) .foregroundStyle(Color.color1) .background(backgroundColor) .onSubmit { - onSubmit?(text) focused = false } - .onChange(of: text) { newValue in - if immediately { onSubmit?(newValue) } - } RoundedRectangle(cornerRadius: 3) .stroke(foregroundColor, lineWidth: 1) .padding(.top, 1) @@ -86,11 +61,3 @@ struct MyTextField: View { return .white.opacity(0.5) } } - -#Preview { - MyTextField(initial: nil, placeholder: "请输入文本") { (value: Int) in - print(value) - } - .padding() - .background(.white) -} diff --git a/PCL.Mac/Components/MyTip.swift b/PCL.Mac/Components/MyTip.swift new file mode 100644 index 0000000..0132312 --- /dev/null +++ b/PCL.Mac/Components/MyTip.swift @@ -0,0 +1,74 @@ +// +// MyTip.swift +// PCL.Mac +// +// Created by 温迪 on 2026/2/13. +// + +import SwiftUI + + +struct MyTip: View { + private let text: String + private let theme: Theme + + init(text: String, theme: Theme) { + self.text = text + self.theme = theme + } + + var body: some View { + HStack(spacing: 0) { + Rectangle() + .fill(theme.borderColor) + .frame(width: 3) + MyText(text, color: theme.foregroundColor) + .padding(.horizontal, 12) + .padding(.vertical, 9) + Spacer(minLength: 0) + } + .background(theme.backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: 2)) + .fixedSize(horizontal: false, vertical: true) + } + + enum Theme { + case blue, red, yellow + + var borderColor: Color { + let hex: UInt = switch self { + case .blue: 0x1172D4 + case .red: 0xD82929 + case .yellow: 0xF57A00 + } + return .init(hex) + } + + var backgroundColor: Color { + let hex: UInt = switch self { + case .blue: 0xD9ECFF + case .red: 0xFFDDDF + case .yellow: 0xFFEBD7 + } + return .init(hex) + } + + var foregroundColor: Color { + let hex: UInt = switch self { + case .blue: 0x0F64B8 + case .red: 0xBF0B0B + case .yellow: 0xD86C00 + } + return .init(hex) + } + } +} + +#Preview { + VStack { + MyTip(text: "Test", theme: .red) + MyTip(text: "Test", theme: .yellow) + MyTip(text: "Test", theme: .blue) + } + .padding() +} diff --git a/PCL.Mac/Extensions/Environments.swift b/PCL.Mac/Extensions/Environments.swift new file mode 100644 index 0000000..204c40f --- /dev/null +++ b/PCL.Mac/Extensions/Environments.swift @@ -0,0 +1,51 @@ +// +// Environments.swift +// PCL.Mac +// +// Created by 温迪 on 2026/2/13. +// + +import SwiftUI + +private struct CardIndexKey: EnvironmentKey { + public static let defaultValue: Int = 0 +} + +private struct DisableCardAppearAnimationKey: EnvironmentKey { + public static let defaultValue: Bool = false +} + +private struct DisableHoverAnimationKey: EnvironmentKey { + public static let defaultValue: Bool = false +} + +extension EnvironmentValues { + var cardIndex: Int { + get { self[CardIndexKey.self] } + set { self[CardIndexKey.self] = newValue } + } + + var disableCardAppearAnimation: Bool { + get { self[DisableCardAppearAnimationKey.self] } + set { self[DisableCardAppearAnimationKey.self] = newValue } + } + + var disableHoverAnimation: Bool { + get { self[DisableHoverAnimationKey.self] } + set { self[DisableHoverAnimationKey.self] = newValue } + } +} + +extension View { + func cardIndex(_ index: Int) -> some View { + environment(\.cardIndex, index) + } + + func disableCardAppearAnimation(_ disabled: Bool = true) -> some View { + environment(\.disableCardAppearAnimation, disabled) + } + + func disableHoverAnimation(_ disabled: Bool = true) -> some View { + environment(\.disableHoverAnimation, disabled) + } +} diff --git a/PCL.Mac/Extensions/Frontend.swift b/PCL.Mac/Extensions/Frontend.swift index 61f7f64..c75f1bf 100644 --- a/PCL.Mac/Extensions/Frontend.swift +++ b/PCL.Mac/Extensions/Frontend.swift @@ -36,7 +36,7 @@ extension SubTaskState { case .waiting: "TaskWaiting" case .executing: "" case .finished: "TaskFinished" - case .failed: "" + case .failed: "TaskWaiting" } } } diff --git a/PCL.Mac/ViewModels/InstanceViewModel.swift b/PCL.Mac/ViewModels/InstanceManager.swift similarity index 96% rename from PCL.Mac/ViewModels/InstanceViewModel.swift rename to PCL.Mac/ViewModels/InstanceManager.swift index 05c49df..a105bc5 100644 --- a/PCL.Mac/ViewModels/InstanceViewModel.swift +++ b/PCL.Mac/ViewModels/InstanceManager.swift @@ -1,5 +1,5 @@ // -// InstanceViewModel.swift +// InstanceManager.swift // PCL.Mac // // Created by 温迪 on 2025/12/30. @@ -8,13 +8,14 @@ import SwiftUI import Core -class InstanceViewModel: ObservableObject { +class InstanceManager: ObservableObject { + public static let shared: InstanceManager = .init() @Published public var repositories: [MinecraftRepository] @Published public var currentRepository: MinecraftRepository? @Published public var currentInstance: MinecraftInstance? @Published public var reloadErrorMessage: String? - public init() { + private init() { self.repositories = LauncherConfig.shared.minecraftRepositories if let currentRepository: Int = LauncherConfig.shared.currentRepository { self.currentRepository = LauncherConfig.shared.minecraftRepositories[currentRepository] @@ -111,7 +112,7 @@ class InstanceViewModel: ObservableObject { } /// 启动游戏。 - /// + /// /// - Parameters: /// - instance: 目标游戏实例。 /// - account: 使用的账号。 diff --git a/PCL.Mac/ViewModels/MinecraftInstallOptionsViewModel.swift b/PCL.Mac/ViewModels/MinecraftInstallOptionsViewModel.swift new file mode 100644 index 0000000..0089f8c --- /dev/null +++ b/PCL.Mac/ViewModels/MinecraftInstallOptionsViewModel.swift @@ -0,0 +1,64 @@ +// +// MinecraftInstallOptionsViewModel.swift +// PCL.Mac +// +// Created by 温迪 on 2026/2/13. +// + +import Foundation +import Core + +class MinecraftInstallOptionsViewModel: ObservableObject { + @Published public var name: String { didSet { checkName() } } + @Published public var loader: MinecraftInstallTask.Loader? { + willSet { lastLoader = loader?.type } + didSet { + if let lastLoader, loader == nil { + if name == "\(version.id)-\(lastLoader)" { + name = version.id + return + } + } else if let loader, lastLoader == nil { + if name == version.id { + name = "\(version.id)-\(loader.type)" + return + } + } else if let loader, let lastLoader { + if name == "\(version.id)-\(lastLoader)" { + name = "\(version.id)-\(loader.type)" + return + } + } + checkName() + } + } + @Published public var errorMessage: String? + public let version: VersionManifest.Version + private var lastLoader: ModLoader? + + init(version: VersionManifest.Version) { + self.version = version + self.name = version.id + checkName() + } + + private func checkName() { + if name.isEmpty { + errorMessage = "实例名不能为空!" + return + } + let invalidCharacters: [Character] = [ + ":", ";", "/", "\\" + ] + if invalidCharacters.contains(where: name.contains(_:)) { + errorMessage = "实例名包含特殊字符!" + return + } + if let repository: MinecraftRepository = InstanceManager.shared.currentRepository, + FileManager.default.fileExists(atPath: repository.versionsURL.appending(path: name).path) { + errorMessage = "当前实例名已被使用!" + return + } + errorMessage = nil + } +} diff --git a/PCL.Mac/Views/Download/MinecraftDownloadPage.swift b/PCL.Mac/Views/Download/MinecraftDownloadPage.swift index f81638a..e8eb2db 100644 --- a/PCL.Mac/Views/Download/MinecraftDownloadPage.swift +++ b/PCL.Mac/Views/Download/MinecraftDownloadPage.swift @@ -70,7 +70,7 @@ struct MinecraftDownloadPage: View { } private struct VersionView: View { - @EnvironmentObject private var viewModel: InstanceViewModel + @EnvironmentObject private var viewModel: InstanceManager private static let dateFormatter: DateFormatter = { let formatter: DateFormatter = .init() @@ -88,20 +88,12 @@ private struct VersionView: View { var body: some View { MyListItem(.init(image: version.type.icon, name: version.id, description: prefix + Self.dateFormatter.string(from: version.releaseTime))) .onTapGesture { - guard let repository = viewModel.currentRepository else { + guard viewModel.currentRepository != nil else { warn("试图安装 \(version.id),但没有设置游戏仓库") hint("请先添加一个游戏目录!", type: .critical) return } - let id: String = version.id - let version: MinecraftVersion = .init(version.id) - TaskManager.shared.execute(task: MinecraftInstallTask.create(name: id, version: version, repository: repository) { instance in - viewModel.switchInstance(to: instance, repository) - if AppRouter.shared.getLast() == .tasks { - AppRouter.shared.removeLast() - } - }) - AppRouter.shared.append(.tasks) + AppRouter.shared.append(.minecraftInstallOptions(version: version)) } } } diff --git a/PCL.Mac/Views/Download/MinecraftInstallOptionsPage.swift b/PCL.Mac/Views/Download/MinecraftInstallOptionsPage.swift new file mode 100644 index 0000000..7687037 --- /dev/null +++ b/PCL.Mac/Views/Download/MinecraftInstallOptionsPage.swift @@ -0,0 +1,194 @@ +// +// MinecraftInstallOptionsPage.swift +// PCL.Mac +// +// Created by 温迪 on 2026/2/11. +// + +import SwiftUI +import Core + +struct MinecraftInstallOptionsPage: View { + @StateObject private var viewModel: MinecraftInstallOptionsViewModel + @EnvironmentObject private var instanceVM: InstanceManager + + init(version: VersionManifest.Version) { + self._viewModel = .init(wrappedValue: .init(version: version)) + } + + var body: some View { + CardContainer { + VStack { + MyCard("", titled: false, limitHeight: false) { + HStack { + Image(icon) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .padding(.trailing, 12) + VStack(alignment: .leading) { + if let errorMessage = viewModel.errorMessage { + MyText(errorMessage, color: .red) + } + MyTextField(text: $viewModel.name) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 15) + VStack(spacing: 6) { + ModLoaderCard(.fabric, viewModel.version.id, $viewModel.loader) + .cardIndex(1) + ModLoaderCard(.forge, viewModel.version.id, $viewModel.loader) + .cardIndex(2) + } + Spacer() + } + .padding(EdgeInsets(top: 10, leading: 25, bottom: 25, trailing: 25)) + } + .overlay(alignment: .bottom) { + MyExtraTextButton(image: "DownloadPageIcon", imageSize: 20, text: "开始下载") { + if let errorMessage = viewModel.errorMessage { + hint(errorMessage, type: .critical) + return + } + guard let repository = instanceVM.currentRepository else { + warn("试图安装 \(viewModel.version),但没有设置游戏仓库") + hint("请先添加一个游戏目录!", type: .critical) + return + } + let version: MinecraftVersion = .init(viewModel.version.id) + TaskManager.shared.execute(task: MinecraftInstallTask.create(name: viewModel.name, version: version, repository: repository, modLoader: viewModel.loader) { instance in + instanceVM.switchInstance(to: instance, repository) + if AppRouter.shared.getLast() == .tasks { + AppRouter.shared.removeLast() + if case .minecraftInstallOptions = AppRouter.shared.getLast() { + AppRouter.shared.removeLast() + } + } + }) + AppRouter.shared.append(.tasks) + } + .padding() + } + .animation(.spring(duration: 0.2), value: viewModel.errorMessage) + } + + private var icon: String { + if let loader = viewModel.loader { + return switch loader.type { + case .fabric: "Fabric" + case .forge: "Forge" + } + } else { + return viewModel.version.type == .snapshot ? "Dirt" : "GrassBlock" + } + } +} + +private struct ModLoaderCard: View { + @Binding private var currentLoader: MinecraftInstallTask.Loader? + @State private var versions: [Version]? + @State private var loadState: LoadState = .loading + private let type: ModLoader + private let minecraftVersion: String + + init(_ type: ModLoader, _ minecraftVersion: String, _ currentLoader: Binding) { + self.type = type + self.minecraftVersion = minecraftVersion + self._currentLoader = currentLoader + } + + var body: some View { + MyCard("", titled: false, limitHeight: false, padding: 0) { + ZStack(alignment: .topLeading) { + MyCard(type.description, foldable: loadState == .finished, folded: true) { + if let versions { + MyList(versions.map { ListItem(image: iconName, name: $0.id, description: $0.beta ? "测试版" : "稳定版") }) { index in + if let index { + currentLoader = MinecraftInstallTask.Loader(type: type, version: versions[index].id) + } else { + currentLoader = nil + } + } + } + } + .disableCardAppearAnimation() + HStack(spacing: 7) { + if let currentLoader, currentLoader.type == type { + Image(iconName) + .resizable() + .scaledToFit() + .frame(width: 18) + MyText(currentLoader.version, color: .colorGray1) + } else { + MyText(loadState.description, color: .colorGray4) + } + } + .padding(.leading, 300) + .padding(.top, 10) + .allowsHitTesting(false) + } + } + .task(id: type) { + await loadVersions() + } + } + + private var iconName: String { + switch type { + case .fabric: "Fabric" + case .forge: "Forge" + } + } + + private func loadVersions() async { + do { + let versions: [Version] = switch type { + case .fabric: + try await Requests.get("https://meta.fabricmc.net/v2/versions/loader/\(minecraftVersion)").json().arrayValue + .map { .init(id: $0["loader"]["version"].stringValue) } + case .forge: + try await Requests.get("https://bmclapi2.bangbang93.com/forge/minecraft/\(minecraftVersion)").json().arrayValue + .map { .init(id: $0["version"].stringValue) } + } + await MainActor.run { + self.versions = versions.sorted { $0.id.compare($1.id, options: .numeric) == .orderedDescending } + loadState = versions.isEmpty ? .noUsableVersion : .finished + } + } catch { + err("加载 \(type) 版本列表失败:\(error.localizedDescription)") + await MainActor.run { + loadState = .error(message: error.localizedDescription) + } + } + } + + private enum LoadState: Equatable, CustomStringConvertible { + case loading + case noUsableVersion + case error(message: String) + case finished + + var description: String { + switch self { + case .loading: "加载中" + case .noUsableVersion: "无可用版本" + case .error(let message): "加载失败:\(message)" + case .finished: "可以添加" + } + } + } + + private struct Version { + public let id: String + public let beta: Bool + + public init(id: String) { + self.id = id + // 稳定版判断逻辑:https://github.com/PCL-Community/PCL2-CE/blob/45773cb9c69e677a3ae334c3d1f55f08468d623a/Plain%20Craft%20Launcher%202/Modules/Minecraft/ModDownload.vb#L1047 + self.beta = id.contains("alpha") + } + } +} diff --git a/PCL.Mac/Views/Launch/InstanceList/InstanceListPage.swift b/PCL.Mac/Views/Launch/InstanceList/InstanceListPage.swift index f548557..79f75c7 100644 --- a/PCL.Mac/Views/Launch/InstanceList/InstanceListPage.swift +++ b/PCL.Mac/Views/Launch/InstanceList/InstanceListPage.swift @@ -9,7 +9,7 @@ import SwiftUI import Core struct InstanceListPage: View { - @EnvironmentObject private var instanceViewModel: InstanceViewModel + @EnvironmentObject private var instanceViewModel: InstanceManager @EnvironmentObject private var viewModel: InstanceListViewModel @ObservedObject private var repository: MinecraftRepository @@ -21,7 +21,7 @@ struct InstanceListPage: View { VStack { if let instances = repository.instances { CardContainer { - if let errorInstances = repository.errorInstances { + if let errorInstances = repository.errorInstances, !errorInstances.isEmpty { MyCard("错误的实例") { VStack(spacing: 0) { ForEach(errorInstances, id: \.name) { instance in @@ -30,18 +30,20 @@ struct InstanceListPage: View { } } } - MyCard("常规实例") { - VStack(spacing: 0) { - ForEach(instances.sorted(by: { $0.version > $1.version }), id: \.name) { instance in - InstanceView(instance: instance) - .onTapGesture { - instanceViewModel.switchInstance(to: instance, repository) - AppRouter.shared.removeLast() - } - } + let moddedInstances: [MinecraftInstance] = instances.filter { $0.modLoader != nil } + if !moddedInstances.isEmpty { + MyCard("可安装 Mod") { + instanceList(moddedInstances) + } + .cardIndex(1) + } + let vanillaInstances: [MinecraftInstance] = instances.filter { !moddedInstances.contains($0) } + if !vanillaInstances.isEmpty { + MyCard("常规实例") { + instanceList(vanillaInstances) } + .cardIndex(moddedInstances.isEmpty ? 1 : 2) } - .cardIndex(1) } } else { MyLoading(viewModel: viewModel.loadingViewModel) @@ -53,18 +55,44 @@ struct InstanceListPage: View { } .id(repository.url) } + + private func compareInstance(lhs: MinecraftInstance, rhs: MinecraftInstance) -> Bool { + if lhs.modLoader == rhs.modLoader { + return lhs.version > rhs.version + } + return (lhs.modLoader?.rawValue ?? -1) > (rhs.modLoader?.rawValue ?? -1) + } + + @ViewBuilder + private func instanceList(_ instances: [MinecraftInstance]) -> some View { + VStack(spacing: 0) { + ForEach(instances.sorted(by: compareInstance(lhs:rhs:)), id: \.name) { instance in + InstanceView(instance: instance) + .onTapGesture { + instanceViewModel.switchInstance(to: instance, repository) + AppRouter.shared.removeLast() + } + } + } + } } private struct InstanceView: View { private let name: String private let version: MinecraftVersion + private let iconName: String init(instance: MinecraftInstance) { self.name = instance.name self.version = instance.version + if let modLoader = instance.modLoader { + self.iconName = modLoader.description + } else { + self.iconName = "GrassBlock" + } } var body: some View { - MyListItem(.init(image: "GrassBlock", name: name, description: version.id)) + MyListItem(.init(image: iconName, name: name, description: version.id)) } } diff --git a/PCL.Mac/Views/Launch/InstanceList/InstanceListSidebar.swift b/PCL.Mac/Views/Launch/InstanceList/InstanceListSidebar.swift index c8b08e4..e0a4cbe 100644 --- a/PCL.Mac/Views/Launch/InstanceList/InstanceListSidebar.swift +++ b/PCL.Mac/Views/Launch/InstanceList/InstanceListSidebar.swift @@ -9,7 +9,7 @@ import SwiftUI import Core struct InstanceListSidebar: Sidebar { - @EnvironmentObject private var instanceViewModel: InstanceViewModel + @EnvironmentObject private var instanceViewModel: InstanceManager @EnvironmentObject private var viewModel: InstanceListViewModel let width: CGFloat = 300 diff --git a/PCL.Mac/Views/Launch/InstanceSettings/InstanceConfigPage.swift b/PCL.Mac/Views/Launch/InstanceSettings/InstanceConfigPage.swift index 3996f16..55817c7 100644 --- a/PCL.Mac/Views/Launch/InstanceSettings/InstanceConfigPage.swift +++ b/PCL.Mac/Views/Launch/InstanceSettings/InstanceConfigPage.swift @@ -9,9 +9,12 @@ import SwiftUI import Core struct InstanceConfigPage: View { - @EnvironmentObject private var instanceVM: InstanceViewModel + @EnvironmentObject private var instanceVM: InstanceManager @StateObject private var loadingVM: MyLoadingViewModel = .init(text: "加载中") @State private var instance: MinecraftInstance? + + @State private var jvmHeapSize: String = "" + private let id: String init(id: String) { @@ -34,6 +37,7 @@ struct InstanceConfigPage: View { let instance: MinecraftInstance = try instanceVM.loadInstance(id) await MainActor.run { self.instance = instance + self.jvmHeapSize = instance.config.jvmHeapSize.description } } catch { await MainActor.run { @@ -48,9 +52,10 @@ struct InstanceConfigPage: View { MyCard("JVM 设置", foldable: false) { VStack { configLine(label: "内存分配") { - MyTextField(initial: instance.config.jvmHeapSize, parse: { .init($0) }) { value in - instance.setJVMHeapSize(value) - } + MyTextField(text: $jvmHeapSize) + .onChange(of: jvmHeapSize) { newValue in + if let jvmHeapSize: UInt64 = .init(newValue) { instance.setJVMHeapSize(jvmHeapSize) } + } MyText("MB") } } diff --git a/PCL.Mac/Views/Launch/LaunchPage.swift b/PCL.Mac/Views/Launch/LaunchPage.swift index dedf072..c828eef 100644 --- a/PCL.Mac/Views/Launch/LaunchPage.swift +++ b/PCL.Mac/Views/Launch/LaunchPage.swift @@ -33,7 +33,7 @@ struct LaunchPage: View { MyButton("红色按钮", subLabel: "但是两行文本", type: .red) {} } .frame(height: 60) - MyList(items: listItems) + MyList(listItems) } } MyCard("不可折叠的卡片", foldable: false) { diff --git a/PCL.Mac/Views/Launch/LaunchSidebar.swift b/PCL.Mac/Views/Launch/LaunchSidebar.swift index 9a03db0..fb1aae1 100644 --- a/PCL.Mac/Views/Launch/LaunchSidebar.swift +++ b/PCL.Mac/Views/Launch/LaunchSidebar.swift @@ -9,7 +9,7 @@ import SwiftUI import Core struct LaunchSidebar: Sidebar { - @EnvironmentObject private var instanceViewModel: InstanceViewModel + @EnvironmentObject private var instanceViewModel: InstanceManager @ObservedObject private var launchManager: MinecraftLaunchManager = .shared @StateObject private var accountViewModel: AccountViewModel = .init() @State private var showingAccountEditor: Bool = false diff --git a/PCL.Mac/Views/MessageBoxView.swift b/PCL.Mac/Views/MessageBoxView.swift index 055dea7..13cb435 100644 --- a/PCL.Mac/Views/MessageBoxView.swift +++ b/PCL.Mac/Views/MessageBoxView.swift @@ -67,11 +67,9 @@ struct MessageBoxView: View { case .text(let text): MyText(text) case .list(let items): - MyList(items: items, onSelect: { self.selectedItemIndex = $0 }) - case .input(let initial, let placeholder): - MyTextField(initial: initial ?? "", placeholder: placeholder ?? "", immediately: true) { newValue in - inputText = newValue - } + MyList(items, onSelect: { self.selectedItemIndex = $0 }) + case .input(_, let placeholder): + MyTextField(text: $inputText, placeholder: placeholder ?? "") } } }