Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,4 @@ jobs:
- name: SwiftLint
run: |
brew install swiftlint
swiftlint lint --reporter github-actions-logging || true
swiftlint lint --reporter github-actions-logging
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ MMCL (Melody Minecraft Launcher) — macOS Minecraft launcher. SwiftUI app with
- Microsoft auth uses device code flow (browser-based OAuth)
- Mod management: toggle by renaming `.jar` ↔ `.jar.disabled`
- Instance status verified against actual files on disk at startup
- Block icons: Grass (release), CommandBlock (snapshot), CobbleStone (old), Anvil (Forge), Fabric, Egg (Quilt)
- Block icons: Grass (release), CommandBlock (snapshot), CobbleStone (old), Anvil (Forge), NeoForge, Fabric, Egg (Quilt)
- Animations use `Animation.mmclSpring()` with configurable duration scale

## Testing
Expand Down
12 changes: 11 additions & 1 deletion MMCL.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2650;
LastUpgradeCheck = 2650;
LastUpgradeCheck = 2700;
TargetAttributes = {
8B55B82F2FC6E1110082C5F0 = {
CreatedOnToolsVersion = 26.5;
Expand Down Expand Up @@ -303,6 +303,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = FJ7T78T44N;
ENABLE_STRICT_OBJC_MSGSEND = YES;
Expand All @@ -328,6 +329,7 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
Expand Down Expand Up @@ -367,6 +369,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = FJ7T78T44N;
ENABLE_NS_ASSERTIONS = NO;
Expand All @@ -385,6 +388,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
Expand All @@ -397,6 +401,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = FJ7T78T44N;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
Expand Down Expand Up @@ -430,6 +435,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = FJ7T78T44N;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
Expand Down Expand Up @@ -461,6 +467,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = FJ7T78T44N;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.5;
Expand All @@ -482,6 +489,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = FJ7T78T44N;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.5;
Expand All @@ -502,6 +510,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = FJ7T78T44N;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
Expand All @@ -521,6 +530,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = FJ7T78T44N;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
Expand Down
21 changes: 21 additions & 0 deletions MMCL/MMCLApp.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import SwiftUI
import AppKit

@main
struct MMCLApp: App {
@NSApplicationDelegateAdaptor(MMCLTestAwareAppDelegate.self) private var appDelegate
@StateObject private var store = LauncherStore()

var body: some Scene {
Expand Down Expand Up @@ -30,6 +32,25 @@ struct MMCLApp: App {
}
}

private final class MMCLTestAwareAppDelegate: NSObject, NSApplicationDelegate {
private var isHeadlessUnitTestHost: Bool {
let isXCTestProcess = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
let isUITestLaunch = ProcessInfo.processInfo.arguments.contains("--mmcl-ui-testing")
return isXCTestProcess && !isUITestLaunch
}

func applicationShouldOpenUntitledFile(_ sender: NSApplication) -> Bool {
!isHeadlessUnitTestHost
}

func applicationDidFinishLaunching(_ notification: Notification) {
guard isHeadlessUnitTestHost else { return }

NSApp.setActivationPolicy(.prohibited)
NSApp.windows.forEach { $0.orderOut(nil) }
}
}

private struct ConditionalColorScheme: ViewModifier {
let scheme: AppColorScheme

Expand Down
193 changes: 192 additions & 1 deletion MMCL/Models/LauncherModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,203 @@ import Combine
import Foundation
import SwiftUI

struct SemanticVersion: Comparable, Equatable, CustomStringConvertible {
let major: Int
let minor: Int
let patch: Int

private let prereleaseIdentifiers: [String]
private let buildMetadataIdentifiers: [String]

init?(_ rawValue: String) {
var normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return nil }
if normalized.first == "v" || normalized.first == "V" {
normalized.removeFirst()
}
guard !normalized.isEmpty else { return nil }

let versionAndBuild = normalized.split(
separator: "+",
maxSplits: 1,
omittingEmptySubsequences: false
)
guard versionAndBuild.count <= 2 else { return nil }

let coreAndPrerelease = versionAndBuild[0].split(
separator: "-",
maxSplits: 1,
omittingEmptySubsequences: false
)
guard coreAndPrerelease.count <= 2 else { return nil }

let core = coreAndPrerelease[0].split(separator: ".", omittingEmptySubsequences: false)
guard core.count == 3,
let major = Self.parseCoreComponent(core[0]),
let minor = Self.parseCoreComponent(core[1]),
let patch = Self.parseCoreComponent(core[2]) else {
return nil
}

let prereleaseIdentifiers: [String]
if coreAndPrerelease.count == 2 {
guard let parsed = Self.parseIdentifiers(
coreAndPrerelease[1],
rejectLeadingZeros: true
) else {
return nil
}
prereleaseIdentifiers = parsed
} else {
prereleaseIdentifiers = []
}

let buildMetadataIdentifiers: [String]
if versionAndBuild.count == 2 {
guard let parsed = Self.parseIdentifiers(
versionAndBuild[1],
rejectLeadingZeros: false
) else {
return nil
}
buildMetadataIdentifiers = parsed
} else {
buildMetadataIdentifiers = []
}

self.major = major
self.minor = minor
self.patch = patch
self.prereleaseIdentifiers = prereleaseIdentifiers
self.buildMetadataIdentifiers = buildMetadataIdentifiers
}

static func isNewerVersion(_ latest: String, than current: String) -> Bool {
guard let latest = SemanticVersion(latest),
let current = SemanticVersion(current) else {
return false
}
return latest > current
}

var description: String {
var value = "\(major).\(minor).\(patch)"
if !prereleaseIdentifiers.isEmpty {
value += "-" + prereleaseIdentifiers.joined(separator: ".")
}
if !buildMetadataIdentifiers.isEmpty {
value += "+" + buildMetadataIdentifiers.joined(separator: ".")
}
return value
}

static func == (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool {
lhs.major == rhs.major &&
lhs.minor == rhs.minor &&
lhs.patch == rhs.patch &&
lhs.prereleaseIdentifiers == rhs.prereleaseIdentifiers
}

static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool {
if lhs.major != rhs.major { return lhs.major < rhs.major }
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
if lhs.patch != rhs.patch { return lhs.patch < rhs.patch }

switch (lhs.prereleaseIdentifiers.isEmpty, rhs.prereleaseIdentifiers.isEmpty) {
case (true, true):
return false
case (true, false):
return false
case (false, true):
return true
case (false, false):
break
}

for (left, right) in zip(lhs.prereleaseIdentifiers, rhs.prereleaseIdentifiers) {
guard left != right else { continue }

let leftIsNumeric = Self.isNumericIdentifier(left)
let rightIsNumeric = Self.isNumericIdentifier(right)
if leftIsNumeric && rightIsNumeric {
if left.count != right.count {
return left.count < right.count
}
return left < right
}
if leftIsNumeric != rightIsNumeric {
return leftIsNumeric
}
return left < right
}

return lhs.prereleaseIdentifiers.count < rhs.prereleaseIdentifiers.count
}

private static func parseCoreComponent(_ value: Substring) -> Int? {
guard isNumericIdentifier(value),
value.count == 1 || value.first != "0" else {
return nil
}
return Int(value)
}

private static func parseIdentifiers(
_ value: Substring,
rejectLeadingZeros: Bool
) -> [String]? {
let identifiers = value.split(separator: ".", omittingEmptySubsequences: false)
guard identifiers.allSatisfy({ isValidIdentifier($0) }) else { return nil }
if rejectLeadingZeros {
guard identifiers.allSatisfy({
!isNumericIdentifier($0) || $0.count == 1 || $0.first != "0"
}) else {
return nil
}
}
return identifiers.map(String.init)
}

private static func isNumericIdentifier(_ value: Substring) -> Bool {
!value.isEmpty && value.utf8.allSatisfy { byte in
byte >= 48 && byte <= 57
}
}

private static func isNumericIdentifier(_ value: String) -> Bool {
!value.isEmpty && value.utf8.allSatisfy { byte in
byte >= 48 && byte <= 57
}
}

private static func isValidIdentifier(_ value: Substring) -> Bool {
!value.isEmpty && value.utf8.allSatisfy { byte in
(byte >= 48 && byte <= 57) ||
(byte >= 65 && byte <= 90) ||
(byte >= 97 && byte <= 122) ||
byte == 45
}
}
}

enum GameLoader: String, Codable, CaseIterable, Identifiable {
case vanilla = "Vanilla"
case fabric = "Fabric"
case quilt = "Quilt"
case forge = "Forge"
case neoForge = "NeoForge"

var id: String { rawValue }

var modrinthLoaderName: String? {
switch self {
case .vanilla: return nil
case .fabric: return "fabric"
case .quilt: return "quilt"
case .forge: return "forge"
case .neoForge: return "neoforge"
}
}
}

enum VersionIsolation: String, Codable, CaseIterable, Identifiable {
Expand All @@ -23,7 +213,7 @@ enum VersionIsolation: String, Codable, CaseIterable, Identifiable {
var helpText: String {
switch self {
case .off: return "所有版本共享存档、Mod、资源包"
case .moddableVersions: return "Forge/Fabric 等互相独立,原版共享"
case .moddableVersions: return "Forge/Fabric/NeoForge 等互相独立,原版共享"
case .snapshots: return "快照与发布版、远古版本等隔离"
case .moddableAndSnapshots: return "同时隔离可安装 Mod 版本与非正式版"
case .all: return "不同版本的存档、Mod、资源包均不互通"
Expand Down Expand Up @@ -193,6 +383,7 @@ struct LauncherInstance: Identifiable, Codable, Equatable {
var blockIcon: String {
switch loader {
case .forge: return "Anvil"
case .neoForge: return "NeoForge"
case .fabric: return "Fabric"
case .quilt: return "Egg"
case .vanilla:
Expand Down
Loading
Loading