diff --git a/PCL.Mac.Core/Task/MyTask.swift b/PCL.Mac.Core/Task/MyTask.swift index ddd803d..7a8caf4 100644 --- a/PCL.Mac.Core/Task/MyTask.swift +++ b/PCL.Mac.Core/Task/MyTask.swift @@ -31,12 +31,7 @@ public class MyTask: ObservableObject, Identifiable { private let model: Model private var cancellables: [AnyCancellable] = [] - /// 创建一个任务。 - /// - Parameters: - /// - name: 任务名。 - /// - model: 任务模型,用于在子任务间共享数据。 - /// - subTasks: 该任务的子任务列表。 - public init(name: String, model: Model, _ subTasks: SubTask...) { + private init(name: String, model: Model, _ subTasks: [SubTask]) { self.name = name self.model = model self.subTasks = subTasks @@ -47,6 +42,15 @@ public class MyTask: ObservableObject, Identifiable { } } + /// 创建一个任务。 + /// - Parameters: + /// - name: 任务名。 + /// - model: 任务模型,用于在子任务间共享数据。 + /// - subTasks: 该任务的子任务列表。 + public convenience init(name: String, model: Model, _ subTasks: SubTask...) { + self.init(name: name, model: model, subTasks) + } + /// 开始按顺序执行任务。 /// 执行时,会按 `ordinal` 将 `subTasks` 分组,`ordinal` 越小的越先执行。 public func start() async throws { @@ -137,6 +141,16 @@ public class MyTask: ObservableObject, Identifiable { } } +extension MyTask where Model == EmptyModel { + /// 当任务不需要共享模型数据时的便捷构造函数。 + /// - Parameters: + /// - name: 任务名。 + /// - subTasks: 子任务列表。 + public convenience init(name: String, _ subTasks: SubTask...) { + self.init(name: name, model: EmptyModel(), subTasks) + } +} + public enum SubTaskState { case waiting, executing, finished, failed } diff --git a/PCL.Mac.Core/Utils/Architecture.swift b/PCL.Mac.Core/Utils/Architecture.swift index 5298e9d..64e1130 100644 --- a/PCL.Mac.Core/Utils/Architecture.swift +++ b/PCL.Mac.Core/Utils/Architecture.swift @@ -64,4 +64,12 @@ public enum Architecture: String { default: return .unknown } } + + public static func systemArchitecture() -> Architecture { + #if arch(arm64) + return .arm64 + #elseif arch(x86_64) + return .x64 + #endif + } } diff --git a/PCL.Mac.Core/Utils/FileUtils.swift b/PCL.Mac.Core/Utils/FileUtils.swift index 795ec13..1d32817 100644 --- a/PCL.Mac.Core/Utils/FileUtils.swift +++ b/PCL.Mac.Core/Utils/FileUtils.swift @@ -28,4 +28,8 @@ public enum FileUtils { let digest: Insecure.SHA1.Digest = hasher.finalize() return digest.map { String(format: "%02x", $0) }.joined() } + + public static func isExecutable(at url: URL) -> Bool { + return Architecture.architecture(of: url) != .unknown + } } diff --git a/PCL.Mac.Core/Utils/Requests.swift b/PCL.Mac.Core/Utils/Requests.swift index 0ccb547..3f327d1 100644 --- a/PCL.Mac.Core/Utils/Requests.swift +++ b/PCL.Mac.Core/Utils/Requests.swift @@ -80,6 +80,7 @@ public enum Requests { var request: URLRequest = .init(url: url) request.httpMethod = method request.allHTTPHeaderFields = headers + request.setValue("PCL-Mac/0.1.1", forHTTPHeaderField: "User-Agent") if noCache { request.cachePolicy = .reloadIgnoringLocalCacheData } diff --git a/PCL.Mac.Core/Utils/URLConstants.swift b/PCL.Mac.Core/Utils/URLConstants.swift index 33e27ae..be0b544 100644 --- a/PCL.Mac.Core/Utils/URLConstants.swift +++ b/PCL.Mac.Core/Utils/URLConstants.swift @@ -17,6 +17,7 @@ public struct URLConstants { public static let cacheURL: URL = applicationSupportURL.appending(path: "Caches") public static let temperatureURL: URL = applicationSupportURL.appending(path: "Temp") public static let authlibInjectorURL: URL = applicationSupportURL.appending(path: "authlib-injector.jar") + public static let easyTierURL: URL = applicationSupportURL.appending(path: "EasyTier") public static func createDirectories() { let fileManager: FileManager = .default @@ -24,5 +25,6 @@ public struct URLConstants { try? fileManager.createDirectory(at: logsDirectoryURL, withIntermediateDirectories: true) try? fileManager.createDirectory(at: cacheURL, withIntermediateDirectories: true) try? fileManager.createDirectory(at: temperatureURL, withIntermediateDirectories: true) + try? fileManager.createDirectory(at: easyTierURL, withIntermediateDirectories: true) } } diff --git a/PCL.Mac.xcodeproj/project.pbxproj b/PCL.Mac.xcodeproj/project.pbxproj index 1aeff21..352756f 100644 --- a/PCL.Mac.xcodeproj/project.pbxproj +++ b/PCL.Mac.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ EC114A4A2ED05F070027201F /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = EC114A492ED05F070027201F /* SwiftyJSON */; }; + EC3785B02F2F55650008B483 /* SwiftScaffolding in Frameworks */ = {isa = PBXBuildFile; productRef = EC3785AF2F2F55650008B483 /* SwiftScaffolding */; }; + EC971F4B2F2CB23E006CFFF0 /* SwiftScaffolding in Frameworks */ = {isa = PBXBuildFile; productRef = EC971F4A2F2CB23E006CFFF0 /* SwiftScaffolding */; }; EC996B442EBEE80F0094E524 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EC996B3C2EBEE80F0094E524 /* Core.framework */; }; EC996B462EBEE80F0094E524 /* Core.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = EC996B3C2EBEE80F0094E524 /* Core.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; ECE995B32ED6BE2E00E86582 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = ECE995B22ED6BE2E00E86582 /* ZIPFoundation */; }; @@ -116,6 +118,8 @@ buildActionMask = 2147483647; files = ( ECF707982ED8291F001F0C71 /* SwiftyJSON in Frameworks */, + EC3785B02F2F55650008B483 /* SwiftScaffolding in Frameworks */, + EC971F4B2F2CB23E006CFFF0 /* SwiftScaffolding in Frameworks */, EC996B442EBEE80F0094E524 /* Core.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -237,6 +241,8 @@ name = PCL.Mac; packageProductDependencies = ( EC114A472ED05F020027201F /* SwiftyJSON */, + EC971F4A2F2CB23E006CFFF0 /* SwiftScaffolding */, + EC3785AF2F2F55650008B483 /* SwiftScaffolding */, ); productName = PCL.Mac; productReference = ECC26BC32EBEDD67002B7E3E /* PCL.Mac.app */; @@ -278,6 +284,7 @@ packageReferences = ( EC114A452ED05EF50027201F /* XCRemoteSwiftPackageReference "SwiftyJSON" */, ECE995B12ED6BE2E00E86582 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, + EC3785AE2F2F55650008B483 /* XCRemoteSwiftPackageReference "SwiftScaffolding" */, ); preferredProjectObjectVersion = 77; productRefGroup = ECC26BC42EBEDD67002B7E3E /* Products */; @@ -701,6 +708,14 @@ minimumVersion = 5.0.2; }; }; + EC3785AE2F2F55650008B483 /* XCRemoteSwiftPackageReference "SwiftScaffolding" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/CeciliaStudio/SwiftScaffolding"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.1; + }; + }; ECE995B12ED6BE2E00E86582 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/weichsel/ZIPFoundation"; @@ -727,6 +742,15 @@ package = EC114A452ED05EF50027201F /* XCRemoteSwiftPackageReference "SwiftyJSON" */; productName = SwiftyJSON; }; + EC3785AF2F2F55650008B483 /* SwiftScaffolding */ = { + isa = XCSwiftPackageProductDependency; + package = EC3785AE2F2F55650008B483 /* XCRemoteSwiftPackageReference "SwiftScaffolding" */; + productName = SwiftScaffolding; + }; + EC971F4A2F2CB23E006CFFF0 /* SwiftScaffolding */ = { + isa = XCSwiftPackageProductDependency; + productName = SwiftScaffolding; + }; ECE995B22ED6BE2E00E86582 /* ZIPFoundation */ = { isa = XCSwiftPackageProductDependency; package = ECE995B12ED6BE2E00E86582 /* XCRemoteSwiftPackageReference "ZIPFoundation" */; diff --git a/PCL.Mac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/PCL.Mac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 984df89..7daab49 100644 --- a/PCL.Mac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/PCL.Mac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "974e6a03944ddb1a0ca4a4977d5d8785167317d6a24e3cfddf77d56c0be5e7eb", + "originHash" : "2fad4fe31d0bc6aac0651a603871e613b3b11132766eb751c8fe692307376431", "pins" : [ + { + "identity" : "swiftscaffolding", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CeciliaStudio/SwiftScaffolding", + "state" : { + "revision" : "b4a7924992802ff988dd8c7c4c3b480da6b4d726", + "version" : "0.1.1" + } + }, { "identity" : "swiftyjson", "kind" : "remoteSourceControl", diff --git a/PCL.Mac/App/AppDelegate.swift b/PCL.Mac/App/AppDelegate.swift index 73c6f70..876d195 100644 --- a/PCL.Mac/App/AppDelegate.swift +++ b/PCL.Mac/App/AppDelegate.swift @@ -8,24 +8,29 @@ import Foundation import AppKit import Core +import SwiftScaffolding class AppDelegate: NSObject, NSApplicationDelegate { private var window: AppWindow! - private func executeTask(_ name: String, _ start: @escaping () throws -> Void) { + private func executeTask(_ name: String, silent: Bool = false, _ start: @escaping () throws -> Void) { do { try start() - log("\(name)成功") + if !silent { + log("\(name)成功") + } } catch { err("\(name)失败:\(error.localizedDescription)") } } - private func executeAsyncTask(_ name: String, _ start: @escaping () async throws -> Void) { + private func executeAsyncTask(_ name: String, silent: Bool = false, _ start: @escaping () async throws -> Void) { Task { do { try await start() - log("\(name)成功") + if !silent { + log("\(name)成功") + } } catch { err("\(name)失败:\(error.localizedDescription)") } @@ -36,6 +41,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { URLConstants.createDirectories() LogManager.shared.enableLogging() log("正在启动 PCL.Mac.Refactor \(Metadata.appVersion)") + executeTask("开启 SwiftScaffolding 日志", silent: true) { + try SwiftScaffolding.Logger.enableLogging(url: URLConstants.logsDirectoryURL.appending(path: "swift-scaffolding.log")) + } _ = LauncherConfig.shared executeTask("加载版本缓存") { try VersionCache.load() @@ -81,5 +89,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { executeTask("保存启动器配置") { try LauncherConfig.save() } + EasyTierManager.shared.easyTier.terminate() } } diff --git a/PCL.Mac/App/AppRouter.swift b/PCL.Mac/App/AppRouter.swift index 9e934d3..10b8c59 100644 --- a/PCL.Mac/App/AppRouter.swift +++ b/PCL.Mac/App/AppRouter.swift @@ -18,6 +18,9 @@ enum AppRoute: Identifiable, Hashable, Equatable { // 下载页面的子页面 case minecraftDownload, downloadPage2, downloadPage3 + // 联机页面的子页面 + case multiplayerSub, multiplayerSettings + // 更多页面的子页面 case about @@ -54,6 +57,10 @@ class AppRouter: ObservableObject { InstanceListPage(repository: repository) case .noInstanceRepository: NoInstanceRepositoryPage() + case .multiplayerSub: + MultiplayerPage() + case .multiplayerSettings: + MultiplayerSettingsPage() case .about: AboutPage() default: @@ -67,6 +74,7 @@ class AppRouter: ObservableObject { case .launch: LaunchSidebar() case .instanceList, .noInstanceRepository: InstanceListSidebar() case .minecraftDownload, .downloadPage2, .downloadPage3: DownloadSidebar() + case .multiplayer, .multiplayerSub, .multiplayerSettings: MultiplayerSidebar() case .more, .about: MoreSidebar() case .tasks: TasksSidebar() default: EmptySidebar() @@ -107,6 +115,7 @@ class AppRouter: ObservableObject { // 各根页面的默认子页面 if newRoot == .download { append(.minecraftDownload) } if newRoot == .more { append(.about) } + if newRoot == .multiplayer { append(.multiplayerSub) } } func append(_ route: AppRoute) { diff --git a/PCL.Mac/App/AppWindow.swift b/PCL.Mac/App/AppWindow.swift index 331f325..a164c7b 100644 --- a/PCL.Mac/App/AppWindow.swift +++ b/PCL.Mac/App/AppWindow.swift @@ -29,6 +29,7 @@ class AppWindow: NSWindow { .environmentObject(InstanceViewModel()) .environmentObject(MinecraftDownloadPageViewModel()) .environmentObject(InstanceListViewModel()) + .environmentObject(MultiplayerViewModel()) ) self.setFrameAutosaveName("AppWindow") diff --git a/PCL.Mac/App/LauncherConfig.swift b/PCL.Mac/App/LauncherConfig.swift index 4bd35bb..773646b 100644 --- a/PCL.Mac/App/LauncherConfig.swift +++ b/PCL.Mac/App/LauncherConfig.swift @@ -35,6 +35,7 @@ class LauncherConfig: Codable { public var currentInstance: String? public var accounts: [Account] = [] public var currentAccountId: UUID? + public var multiplayerDisclaimerAgreed: Bool = false public init() {} @@ -58,6 +59,7 @@ class LauncherConfig: Codable { self.currentAccountId = accounts[0].id } } + self.multiplayerDisclaimerAgreed = try container.decodeIfPresent(Bool.self, forKey: .multiplayerDisclaimerAgreed) ?? false } public func encode(to encoder: any Encoder) throws { @@ -67,6 +69,7 @@ class LauncherConfig: Codable { try container.encode(currentInstance, forKey: .currentInstance) try container.encode(accounts.map(AccountWrapper.init(_:)), forKey: .accounts) try container.encode(currentAccountId, forKey: .currentAccountId) + try container.encode(multiplayerDisclaimerAgreed, forKey: .multiplayerDisclaimerAgreed) } public static func save(_ config: LauncherConfig = .shared, to url: URL = URLConstants.configURL) throws { @@ -80,5 +83,6 @@ class LauncherConfig: Codable { case currentInstance case accounts case currentAccountId + case multiplayerDisclaimerAgreed } } diff --git a/PCL.Mac/Assets.xcassets/Icons/IconCopy.imageset/Contents.json b/PCL.Mac/Assets.xcassets/Icons/IconCopy.imageset/Contents.json new file mode 100644 index 0000000..ac4e4af --- /dev/null +++ b/PCL.Mac/Assets.xcassets/Icons/IconCopy.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "IconCopy.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/PCL.Mac/Assets.xcassets/Icons/IconCopy.imageset/IconCopy.svg b/PCL.Mac/Assets.xcassets/Icons/IconCopy.imageset/IconCopy.svg new file mode 100644 index 0000000..929796c --- /dev/null +++ b/PCL.Mac/Assets.xcassets/Icons/IconCopy.imageset/IconCopy.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/PCL.Mac/Assets.xcassets/Icons/IconExit.imageset/Contents.json b/PCL.Mac/Assets.xcassets/Icons/IconExit.imageset/Contents.json new file mode 100644 index 0000000..19debb7 --- /dev/null +++ b/PCL.Mac/Assets.xcassets/Icons/IconExit.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "IconExit.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/PCL.Mac/Assets.xcassets/Icons/IconExit.imageset/IconExit.svg b/PCL.Mac/Assets.xcassets/Icons/IconExit.imageset/IconExit.svg new file mode 100644 index 0000000..57173ff --- /dev/null +++ b/PCL.Mac/Assets.xcassets/Icons/IconExit.imageset/IconExit.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/PCL.Mac/Components/MyCard.swift b/PCL.Mac/Components/MyCard.swift index 1937292..174099c 100644 --- a/PCL.Mac/Components/MyCard.swift +++ b/PCL.Mac/Components/MyCard.swift @@ -24,12 +24,14 @@ struct MyCard: View { private let title: String private let foldable: Bool private let titled: Bool + private let limitHeight: Bool private let content: () -> Content - init(_ title: String, foldable: Bool = true, titled: Bool = true, @ViewBuilder _ content: @escaping () -> Content) { + init(_ title: String, foldable: Bool = true, titled: Bool = true, limitHeight: Bool = true, @ViewBuilder _ content: @escaping () -> Content) { self.title = title self.foldable = foldable && titled self.titled = titled + self.limitHeight = limitHeight self.content = content } @@ -93,7 +95,8 @@ struct MyCard: View { } } } - .frame(height: internalContentHeight, alignment: .top) + .frame(height: limitHeight ? internalContentHeight : nil, alignment: .top) + .frame(maxHeight: limitHeight ? nil : .infinity) .clipped() .opacity(showContent ? 1 : 0) } diff --git a/PCL.Mac/Managers/EasyTierManager.swift b/PCL.Mac/Managers/EasyTierManager.swift new file mode 100644 index 0000000..9e41918 --- /dev/null +++ b/PCL.Mac/Managers/EasyTierManager.swift @@ -0,0 +1,146 @@ +// +// EasyTierManager.swift +// PCL.Mac +// +// Created by 温迪 on 2026/1/15. +// + +import Foundation +import SwiftScaffolding +import Core +import SwiftyJSON + +class EasyTierManager { + public static let shared: EasyTierManager = .init() + + public let easyTier: EasyTier + + private let coreURL: URL = URLConstants.easyTierURL.appending(path: "easytier-core") + private let cliURL: URL = URLConstants.easyTierURL.appending(path: "easytier-cli") + private let logURL: URL = URLConstants.logsDirectoryURL.appending(path: "easytier.log") + + private let downloadItems: [ComponentType: DownloadItem] + + private var isEasyTierInstalled: Bool? + + private init() { + if Architecture.systemArchitecture() == .arm64 { + self.downloadItems = [ + .cli: .init( + url: URL(string: "https://gitee.com/yizhimcqiu/easytier-mirror/releases/download/v2.5.0/easytier-cli-macos-aarch64")!, + destination: cliURL, + sha1: "6fced91a4aeb4c9d1776704a6d00438331408056" + ), + .core: .init( + url: URL(string: "https://gitee.com/yizhimcqiu/easytier-mirror/releases/download/v2.5.0/easytier-core-macos-aarch64")!, + destination: coreURL, + sha1: "bcc229e65d2652e538efd59ea88e21a7e6ff2375" + ) + ] + } else { + self.downloadItems = [ + .cli: .init( + url: URL(string: "https://gitee.com/yizhimcqiu/easytier-mirror/releases/download/v2.5.0/easytier-cli-macos-x86_64")!, + destination: cliURL, + sha1: "4dd10266baa8b70b64a953da78632e2d0d581ca9" + ), + .core: .init( + url: URL(string: "https://gitee.com/yizhimcqiu/easytier-mirror/releases/download/v2.5.0/easytier-core-macos-x86_64")!, + destination: coreURL, + sha1: "3185b21c0f3085e313d89fa32a32b7c1013ff1de" + ) + ] + } + + self.easyTier = .init( + coreURL: coreURL, + cliURL: cliURL, + logURL: logURL, + .p2pOnly, + .peer(address: "tcp://public.easytier.top:11010"), + .peer(address: "tcp://public2.easytier.cn:54321") + ) + } + + /// 判断是否已经安装 EasyTier。 + public func isInstalled(refresh: Bool = false) -> Bool { + if let isEasyTierInstalled, !refresh { + return isEasyTierInstalled + } + let installed: Bool = autoreleasepool { checkSingle(type: .cli) && checkSingle(type: .core) } + isEasyTierInstalled = installed + return installed + } + + /// 如果没有安装 EasyTier,提示用户安装。 + /// - Returns: 是否未安装 EasyTier。 + public func hintInstall() async -> Bool { + if isInstalled() { return false } + log("用户未安装 EasyTier") + if await MessageBoxManager.shared.showText( + title: "错误", + content: "你需要安装 EasyTier 才能使用这个功能!", + level: .error, + .init(id: 1, label: "安装", type: .highlight), + .init(id: 0, label: "取消", type: .normal) + ) == 1 { + let task: MyTask = makeInstallTask() + await MainActor.run { + TaskManager.shared.execute(task: task) + AppRouter.shared.append(.tasks) + } + } + return true + } + + /// 删除 EasyTier。 + public func delete() { + if !isInstalled() { + hint("你还没有安装 EasyTier!", type: .critical) + return + } + do { + for url in downloadItems.values.map(\.destination) { + try FileManager.default.removeItem(at: url) + } + hint("删除成功!", type: .finish) + } catch { + hint("删除 EasyTier 失败:\(error.localizedDescription)", type: .critical) + } + _ = isInstalled(refresh: true) + } + + /// 创建一个安装任务。 + public func makeInstallTask() -> MyTask { + let cliDownloadItem: DownloadItem = downloadItems[.cli]! + let coreDownloadItem: DownloadItem = downloadItems[.core]! + return .init( + name: "安装 EasyTier", + .init(0, "下载 easytier-cli") { task, _ in + try await SingleFileDownloader.download(cliDownloadItem, replaceMethod: .skip, progressHandler: task.setProgress(_:)) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliDownloadItem.destination.path) + }, + .init(0, "下载 easytier-core") { task, _ in + try await SingleFileDownloader.download(coreDownloadItem, replaceMethod: .skip, progressHandler: task.setProgress(_:)) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: coreDownloadItem.destination.path) + }, + .init(1, "__completion", display: false) { task, _ in + EasyTierManager.shared.isEasyTierInstalled = true + if AppRouter.shared.getLast() == .tasks { + await MainActor.run { + AppRouter.shared.removeLast() + } + } + } + ) + } + + public enum ComponentType { + case cli, core + } + + private func checkSingle(type: ComponentType) -> Bool { + let item: DownloadItem = downloadItems[type]! + return FileUtils.isExecutable(at: item.destination) && (try? FileUtils.sha1(of: item.destination)) == item.sha1 + } +} diff --git a/PCL.Mac/App/MessageBoxManager.swift b/PCL.Mac/Managers/MessageBoxManager.swift similarity index 100% rename from PCL.Mac/App/MessageBoxManager.swift rename to PCL.Mac/Managers/MessageBoxManager.swift diff --git a/PCL.Mac/ViewModels/MultiplayerViewModel.swift b/PCL.Mac/ViewModels/MultiplayerViewModel.swift new file mode 100644 index 0000000..2ab3a4b --- /dev/null +++ b/PCL.Mac/ViewModels/MultiplayerViewModel.swift @@ -0,0 +1,276 @@ +// +// MultiplayerViewModel.swift +// PCL.Mac +// +// Created by 温迪 on 2026/1/15. +// + +import Foundation +import SwiftScaffolding +import Core +import Combine +import AppKit +import CryptoKit + +class MultiplayerViewModel: ObservableObject { + @MainActor @Published public var state: State = .ready + @MainActor @Published public private(set) var room: Room? + + private var server: ScaffoldingServer? + private var client: ScaffoldingClient? + private var serverCheckTask: Task? + private var heartbeatTask: Task? + private let vendor: String = "PCL.Mac \(Metadata.appVersion), SwiftScaffolding 0.1.1, EasyTier v2.5.0" + + /// 创建并启动一个 Scaffolding 联机中心。 + /// - Parameter serverPort: Minecraft 服务器的端口。 + /// - Returns: 房间邀请码。 + @MainActor + public func startHost(serverPort: UInt16) { + do { + guard state == .ready else { + err("启动联机中心失败:错误的状态:\(state)") + throw Error.invalidState + } + guard server == nil else { + err("启动联机中心失败:似乎已有一个联机中心正在运行") + throw Error.invalidState + } + state = .creatingRoom + let code: String = RoomCode.generate() + let playerName: String = AccountViewModel().currentAccount?.profile.name ?? "Steve" + let server: ScaffoldingServer = .init( + easyTier: EasyTierManager.shared.easyTier, + roomCode: code, + playerName: playerName, + vendor: vendor, + serverPort: serverPort + ) + registerCustomProtocols(to: server) + Task.detached { + do { + _ = try await server.startListener() + try server.createRoom(terminationHandler: { [weak self] process in + guard let self else { return } + Task { @MainActor in + self.handleEasyTierExit(process) + } + }) + await MainActor.run { + self.server = server + self.state = .hostReady + self.room = server.room + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(server.roomCode, forType: .string) + } + self.serverCheckTask = Task.detached { + while !Task.isCancelled { + try await Task.sleep(seconds: 5) + guard await Scaffolding.checkMinecraftServer(on: serverPort, timeout: 5) else { + log("局域网世界验活失败") + await self.stopHost() + _ = await MessageBoxManager.shared.showText(title: "房间已关闭", content: "局域网世界已关闭,房间已自动关闭。") + break + } + } + } + log("启动联机中心成功,房间码:\(server.roomCode)") + } catch { + err("启动联机中心失败:\(error.localizedDescription)") + await self.showErrorAsync(title: "启动联机中心失败", body: error.localizedDescription) + await MainActor.run { + self.stopHost() + } + } + } + } catch { + err("启动联机中心失败:\(error.localizedDescription)") + showError(title: "启动联机中心失败", body: error.localizedDescription) + stopHost() + } + } + + /// 关闭联机中心。 + @MainActor + public func stopHost() { + serverCheckTask?.cancel() + serverCheckTask = nil + room = nil + server?.stop() + server = nil + state = .ready + log("关闭联机中心成功") + } + + /// 创建客户端并加入房间。 + /// - Parameters: + /// - roomCode: 房间码。 + /// - playerName: 房客玩家名。 + @MainActor + public func join(roomCode: String) { + let playerName: String = AccountViewModel().currentAccount?.profile.name ?? "Steve" + let client: ScaffoldingClient = .init( + easyTier: EasyTierManager.shared.easyTier, + playerName: playerName, + vendor: vendor + ) + state = .joiningRoom + Task.detached { + do { + try await client.connect(to: roomCode, terminationHandler: { [weak self] process in + guard let self else { return } + Task { @MainActor in + self.handleEasyTierExit(process) + } + }) + + self.heartbeatTask = Task { [weak client] in + while !Task.isCancelled { + guard let client else { return } + try await self.heartbeat(client) + } + } + await MainActor.run { + self.client = client + self.state = .memberReady + self.room = client.room + NSPasteboard.general.clearContents() + NSPasteboard.general.setString("127.0.0.1:\(client.room.serverPort)", forType: .string) + } + log("加入房间成功,本地端口:\(client.room.serverPort)") + } catch { + err("加入房间失败:\(error.localizedDescription)") + await self.showErrorAsync(title: "加入房间失败", body: error.localizedDescription) + await self.leave() + } + } + } + + /// 退出房间。 + @MainActor + public func leave() { + heartbeatTask?.cancel() + heartbeatTask = nil + room = nil + client?.stop() + client = nil + state = .ready + log("退出房间成功") + } + + public func roomCode() -> String? { + return server?.roomCode + } + + @MainActor + private func showError(title: String, body: String) { + Task { + await showErrorAsync(title: title, body: body) + } + } + + private func showErrorAsync(title: String, body: String) async { + _ = await MessageBoxManager.shared.showText( + title: title, + content: body + "\n若要反馈此问题,请向对方发送完整日志,而不是发送关于此页面的图片。", + level: .error + ) + } + + private func switchState(to state: State) async { + await MainActor.run { + self.state = state + } + } + + @MainActor + private func handleEasyTierExit(_ process: Process) { + guard state == .hostReady || state == .memberReady else { + return + } + if [128 + SIGTERM, 128 + SIGKILL].contains(process.terminationStatus) { + log("用户手动退出了 EasyTier 进程") + showError(title: "错误", body: "无法继续联机:EasyTier 进程被杀死。") + } else { + err("EasyTier 进程意外退出") + showError(title: "错误", body: "无法继续联机:EasyTier 发生崩溃。") + } + state = .ready + // 此时客户端/服务端已经完成了清理,可以直接丢弃引用 + client = nil + server = nil + } + + private func heartbeat(_ client: ScaffoldingClient) async throws { + try await Task.sleep(seconds: 5) + do { + try Task.checkCancellation() + try await client.heartbeat() + } catch is CancellationError { + } catch RoomError.roomClosed { + _ = await MessageBoxManager.shared.showText( + title: "房间已被关闭", + content: "房间连接中断,可能是由于房间被关闭或网络不稳定" + ) + await leave() + } catch { + log("发送心跳包失败:\(error.localizedDescription)") + await showError(title: "发生未知错误", body: "同步数据失败:\(error.localizedDescription)") + await leave() + } + } + + private func registerCustomProtocols(to server: ScaffoldingServer) { + server.handler.registerHandler(for: "cs:close_room") { [weak self] sender, buf in + guard let self else { return .init(status: 0, data: Data()) } + guard buf.data.count > 1 + 64 else { + throw SimpleError("Request body too short") + } + let publicKey = try! Curve25519.Signing.PublicKey( + rawRepresentation: Data(base64Encoded: "jIT9qh1/37/budNx6tyP7bYZe59I+MGFVG1BKybg/KU=")! + ) + let message: String = try buf.readString(buf.readUInt8()) + let signature: Data = try buf.readData(length: 64) + + guard publicKey.isValidSignature(signature, for: message.data(using: .utf8)!) else { + throw SimpleError("Signature validation failed") + } + + let parts: [String] = message.split(separator: "\0").map(String.init) + let formatter: ISO8601DateFormatter = .init() + guard parts.count == 4, + parts[0] == "close_room", + parts[2] == server.roomCode, + let date: Date = formatter.date(from: parts[3]), + Date.now.timeIntervalSince(date) < 15 else { + throw SimpleError("Message validation failed") + } + Task { + await self.stopHost() + _ = await MessageBoxManager.shared.showText( + title: "房间被管理员强制关闭", + content: "房间被强制关闭。\n原因:\(parts[1])" + ) + } + return .init(status: 0, data: Data()) + } + } + + public enum State: Equatable { + case ready + case creatingRoom, hostReady + case joiningRoom, memberReady + } + + public enum Error: LocalizedError { + case invalidState + case startServerFailed(message: String) + + public var errorDescription: String? { + switch self { + case .invalidState: "错误的状态。" + case .startServerFailed(let message): message + } + } + } +} diff --git a/PCL.Mac/Views/Multiplayer/MultiplayerPage.swift b/PCL.Mac/Views/Multiplayer/MultiplayerPage.swift new file mode 100644 index 0000000..207cc3c --- /dev/null +++ b/PCL.Mac/Views/Multiplayer/MultiplayerPage.swift @@ -0,0 +1,253 @@ +// +// MultiplayerPage.swift +// PCL.Mac +// +// Created by 温迪 on 2026/1/15. +// + +import SwiftUI +import SwiftScaffolding +import Core + +struct MultiplayerPage: View { + @EnvironmentObject private var viewModel: MultiplayerViewModel + @StateObject private var loadingViewModel: MyLoadingViewModel = .init(text: "创建房间中") + @State private var isEasyTierInstalled: Bool = true + + var body: some View { + CardContainer { + switch viewModel.state { + case .ready: + if isEasyTierInstalled { + readyBody + } else { + installEasyTierBody + } + case .creatingRoom, .joiningRoom: + MyLoading(viewModel: loadingViewModel) + case .hostReady, .memberReady: + multiplayerReadyView + } + } + .onAppear { + isEasyTierInstalled = EasyTierManager.shared.isInstalled() + } + .onChange(of: viewModel.state) { newValue in + if newValue == .creatingRoom { + loadingViewModel.text = "创建房间中" + } else if newValue == .joiningRoom { + loadingViewModel.text = "加入房间中" + } + } + } + + private var installEasyTierBody: some View { + MyCard("安装 EasyTier", foldable: false) { + MyListItem(.init(image: .init(named: "DownloadPageIcon"), imageSize: 28, name: "安装 EasyTier", description: "联机功能使用 EasyTier 实现,所以你需要先安装 EasyTier 才能进行联机!")) + .onTapGesture { + Task { + try await checkDisclaimer() + let task: MyTask = EasyTierManager.shared.makeInstallTask() + await MainActor.run { + TaskManager.shared.execute(task: task) + AppRouter.shared.append(.tasks) + } + } + } + } + } + + private var readyBody: some View { + MyCard("开始联机", foldable: false) { + VStack(spacing: 0) { + MyListItem(.init(image: .init(named: "MultiplayerPageIcon"), imageSize: 28, name: "创建房间", description: "使用局域网世界创建房间,并邀请好友加入!")) + .onTapGesture { + Task { + if await EasyTierManager.shared.hintInstall() { + return + } + try await checkDisclaimer() + guard await MessageBoxManager.shared.showText( + title: "开启房间", + content: "请按照以下步骤操作:\n 1. 进入世界,按下 ESC\n 2. 点击 “对局域网开放” > “创建局域网世界”\n 3. 回到启动器,点击 “确定” 并输入聊天栏中的端口号", + .init(id: 0, label: "取消", type: .normal), + .init(id: 1, label: "确定", type: .highlight) + ) == 1 else { return } + guard let rawPort: String = await MessageBoxManager.shared.showInput(title: "输入端口号") else { + return + } + guard let port: UInt16 = .init(rawPort), await Scaffolding.checkMinecraftServer(on: port, timeout: 1) else { + hint("无效的端口号!", type: .critical) + return + } + viewModel.startHost(serverPort: port) + } + } + MyListItem(.init(image: .init(named: "IconAdd"), imageSize: 28, name: "加入房间", description: "通过房主分享的房间码,加入游戏世界!")) + .onTapGesture { + Task { + if await EasyTierManager.shared.hintInstall() { + return + } + try await checkDisclaimer() + if let type: AccountWrapper.AccountType = AccountViewModel().currentAccount?.type(), + type == .offline { + guard await MessageBoxManager.shared.showText( + title: "警告", + content: "你正在使用离线账号,可能会导致无法加入游戏!\n如果房主安装了 LAN Server Properties 等模组,可以忽略此警告。\n如果你拥有正版账号,请返回主页面并切换为正版账号。\n\n如果出现了“无效会话”等错误,请不要反馈给他人!", + level: .error, + .init(id: 0, label: "取消", type: .normal), + .init(id: 1, label: "继续", type: .red) + ) == 1 else { return } + } + + if let roomCode: String = await MessageBoxManager.shared.showInput(title: "输入房间码", placeholder: "U/XXXX-XXXX-XXXX-XXXX") { + if RoomCode.isValid(code: roomCode) { + viewModel.join(roomCode: roomCode) + } else { + hint("错误的邀请码格式!", type: .critical) + } + } + } + } + } + } + } + + @ViewBuilder + private var multiplayerReadyView: some View { + if let room = viewModel.room { + VStack(spacing: 20) { + MyCard("提示", foldable: false) { + VStack(alignment: .leading) { + if viewModel.state == .hostReady, let roomCode = viewModel.roomCode() { + MyText("房间码:\(roomCode)(已自动复制)") + MyText("你的好友可以通过这个房间码来加入房间!") + } else { + MyText("本地地址:127.0.0.1:\(room.serverPort)(已自动复制)") + MyText("你可以在游戏中点击 “多人游戏” > “直接连接”,然后输入这个地址来加入游戏!") + } + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + HStack(alignment: .top, spacing: 20) { + MyCard("操作", foldable: false, limitHeight: false) { + VStack(spacing: 0) { + if viewModel.state == .hostReady, let roomCode = viewModel.roomCode() { + ActionView("IconCopy", "复制房间码") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(roomCode, forType: .string) + hint("复制成功!", type: .finish) + } + ActionView("IconExit", "关闭房间", color: .red) { + Task { + if room.members.count > 1 { + if await MessageBoxManager.shared.showText( + title: "警告", + content: "你确定要关闭房间吗?\n这会让除了你以外的所有玩家退出游戏!", + level: .error, + .init(id: 1, label: "是", type: .red), + .init(id: 0, label: "否", type: .normal) + ) != 1 { return } + } + viewModel.stopHost() + } + } + } else { + ActionView("IconCopy", "复制地址") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString("127.0.0.1:\(room.serverPort)", forType: .string) + hint("复制成功!", type: .finish) + } + ActionView("IconExit", "退出房间", color: .red) { + viewModel.leave() + } + } + Spacer() + } + .fixedSize(horizontal: true, vertical: false) + .frame(maxHeight: .infinity) + } + .fixedSize(horizontal: true, vertical: false) + PlayerListView(room: room) + .frame(maxHeight: .infinity) + } + } + } + } + + private func checkDisclaimer() async throws { + if LauncherConfig.shared.multiplayerDisclaimerAgreed { return } + if await MessageBoxManager.shared.showText( + title: "免责声明", + content: "在多人联机过程中,您须严格遵守所在国家和地区的相关法律法规。因违法使用本功能导致的后果将由用户自行承担。\n\n点击“同意”即表示您已阅读并同意上述全部内容。", + level: .info, + .init(id: 0, label: "不同意", type: .red), + .init(id: 1, label: "同意", type: .highlight) + ) == 0 { + AppRouter.shared.setRoot(.launch) + throw SimpleError("用户未同意免责声明") + } + LauncherConfig.shared.multiplayerDisclaimerAgreed = true + } +} + +private struct PlayerListView: View { + @ObservedObject private var room: Room + + init(room: Room) { + self.room = room + } + + var body: some View { + MyCard("玩家列表", foldable: false, limitHeight: false) { + LazyVStack(spacing: 0) { + ForEach(room.members, id: \.machineId) { member in + MyListItem(.init(name: member.name, description: "[\(member.kind.localizedName)] \(member.vendor)")) + } + Spacer(minLength: 0) + } + } + } +} + +private struct ActionView: View { + private let imageName: String + private let text: String + private let color: Color + private let onClick: () -> Void + + init(_ imageName: String, _ text: String, color: Color = .color1, onClick: @escaping () -> Void) { + self.imageName = imageName + self.text = text + self.color = color + self.onClick = onClick + } + + var body: some View { + MyListItem { + HStack(spacing: 7) { + Image(imageName) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .foregroundStyle(color) + MyText(text, color: color) + Spacer(minLength: 0) + } + .padding(2) + } + .frame(height: 27) + .onTapGesture(perform: onClick) + } +} + +extension Member.Kind { + var localizedName: String { + switch self { + case .host: "房主" + case .guest: "成员" + } + } +} diff --git a/PCL.Mac/Views/Multiplayer/MultiplayerSettingsPage.swift b/PCL.Mac/Views/Multiplayer/MultiplayerSettingsPage.swift new file mode 100644 index 0000000..4fa3689 --- /dev/null +++ b/PCL.Mac/Views/Multiplayer/MultiplayerSettingsPage.swift @@ -0,0 +1,25 @@ +// +// MultiplayerSettingsPage.swift +// PCL.Mac +// +// Created by 温迪 on 2026/2/1. +// + +import SwiftUI + +struct MultiplayerSettingsPage: View { + var body: some View { + CardContainer { + MyCard("EasyTier 设置", foldable: false) { + HStack { + MyButton("删除 EasyTier", type: .red) { + EasyTierManager.shared.delete() + } + .frame(width: 130) + Spacer() + } + .frame(height: 32) + } + } + } +} diff --git a/PCL.Mac/Views/Multiplayer/MultiplayerSidebar.swift b/PCL.Mac/Views/Multiplayer/MultiplayerSidebar.swift new file mode 100644 index 0000000..2297e1e --- /dev/null +++ b/PCL.Mac/Views/Multiplayer/MultiplayerSidebar.swift @@ -0,0 +1,22 @@ +// +// MultiplayerSidebar.swift +// PCL.Mac +// +// Created by 温迪 on 2026/1/15. +// + +import SwiftUI + +struct MultiplayerSidebar: Sidebar { + let width: CGFloat = 150 + + var body: some View { + VStack { + MyNavigationList( + .init(.multiplayerSub, "MultiplayerPageIcon", "联机"), + .init(.multiplayerSettings, "SettingsPageIcon", "设置") + ) + Spacer() + } + } +} diff --git a/PCL.Mac/Views/Tasks/TasksPage.swift b/PCL.Mac/Views/Tasks/TasksPage.swift index 821c84c..6b4ae3d 100644 --- a/PCL.Mac/Views/Tasks/TasksPage.swift +++ b/PCL.Mac/Views/Tasks/TasksPage.swift @@ -29,7 +29,6 @@ struct TasksPage: View { taskManager.execute(task: task) } .frame(width: 100) - Spacer() } .frame(height: 40) } diff --git a/Resources/licenses/EasyTier.LICENSE.txt b/Resources/licenses/EasyTier.LICENSE.txt new file mode 100644 index 0000000..0a04128 --- /dev/null +++ b/Resources/licenses/EasyTier.LICENSE.txt @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/Resources/licenses/SwiftScaffolding.LICENSE.txt b/Resources/licenses/SwiftScaffolding.LICENSE.txt new file mode 100644 index 0000000..50d0b7e --- /dev/null +++ b/Resources/licenses/SwiftScaffolding.LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 CeciliaStudio + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Resources/licenses/SwiftyJSON.LICENSE b/Resources/licenses/SwiftyJSON.LICENSE.txt similarity index 100% rename from Resources/licenses/SwiftyJSON.LICENSE rename to Resources/licenses/SwiftyJSON.LICENSE.txt diff --git a/Resources/licenses/ZIPFoundation.LICENSE b/Resources/licenses/ZIPFoundation.LICENSE.txt similarity index 100% rename from Resources/licenses/ZIPFoundation.LICENSE rename to Resources/licenses/ZIPFoundation.LICENSE.txt