Skip to content
Open
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
7 changes: 6 additions & 1 deletion Mythic/Utilities/Game/Game.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import Foundation
import OSLog
import AppKit

@Observable class Game: Codable, Identifiable {
@Observable class Game: Codable, Identifiable, @unchecked Sendable {
@MainActor static let operationManager: GameOperationManager = .shared

let id: String
Expand Down Expand Up @@ -51,6 +51,7 @@ import AppKit

var launchArguments: [String] = []
final var isFavourited: Bool = false
final var collections: Set<String> = .init()
final var lastLaunched: Date?

// override in subclass
Expand Down Expand Up @@ -78,6 +79,7 @@ import AppKit
self._containerURL = try container.decodeIfPresent(URL.self, forKey: ._containerURL)
self.launchArguments = try container.decode([String].self, forKey: .launchArguments)
self.isFavourited = try container.decode(Bool.self, forKey: .isFavourited)
self.collections = try container.decodeIfPresent(Set<String>.self, forKey: .collections) ?? .init()
self.lastLaunched = try container.decodeIfPresent(Date.self, forKey: .lastLaunched)
}

Expand Down Expand Up @@ -218,6 +220,7 @@ extension Game {
// swiftlint:enable identifier_name
case launchArguments,
isFavourited,
collections,
lastLaunched
}

Expand All @@ -233,6 +236,7 @@ extension Game {
try container.encodeIfPresent(_containerURL, forKey: ._containerURL)
try container.encode(launchArguments, forKey: .launchArguments)
try container.encode(isFavourited, forKey: .isFavourited)
try container.encode(collections, forKey: .collections)
try container.encodeIfPresent(lastLaunched, forKey: .lastLaunched)
}
}
Expand All @@ -252,6 +256,7 @@ extension Game: Mergeable {
.init(\Game._containerURL, forCodingKey: ._containerURL, strategy: { $0 ?? $1 }),
.init(\Game.launchArguments, forCodingKey: .launchArguments, strategy: { Array(Set($0 + $1)) }),
.init(\Game.isFavourited, forCodingKey: .isFavourited, strategy: { $0 || $1 }),
.init(\Game.collections, forCodingKey: .collections, strategy: { $0.union($1) }),
AnyMergeRule(\Game.lastLaunched, forCodingKey: .lastLaunched) { current, new in
guard current != nil || new != nil else { return current }
return max(current ?? .distantPast, new ?? .distantPast)
Expand Down
13 changes: 13 additions & 0 deletions Mythic/Utilities/GameDataStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ import OSLog
}
}

var collectionNames: [String] {
Array(Set(library.flatMap(\.collections))).sorted()
}

func persistLibrary() {
try? UserDefaults.standard.encodeAndSet(library.map({ AnyGame($0) }), forKey: "games")
}

func persist(_ game: Game) {
library.update(with: game)
persistLibrary()
}

func refreshFromStorefronts(_ storefronts: Game.Storefront...) async throws {
GameListViewModel.shared.isUpdatingLibrary = true
defer {
Expand Down
4 changes: 2 additions & 2 deletions Mythic/Utilities/GameManager/EpicGamesGameManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,15 @@ extension EpicGamesGameManager: StorefrontGameManager {
guard case .epicGames = game.storefront,
let castGame = game as? EpicGamesGame else { throw CocoaError(.coderInvalidValue) }

return try await Task(operation: { try await launch(game: castGame) }).value
return try await launch(game: castGame)
}

@MainActor static func move(game: Game,
to location: URL) async throws -> GameOperation {
guard case .epicGames = game.storefront,
let castGame = game as? EpicGamesGame else { throw CocoaError(.coderInvalidValue) }

return try await Task(operation: { try await move(game: castGame, to: location) }).value
return try await move(game: castGame, to: location)
}

@MainActor static func uninstall(game: Game,
Expand Down
49 changes: 24 additions & 25 deletions Mythic/Utilities/GameManager/Legendary/LegendaryInterface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ final class Legendary {
}

static func handleCLIErrorOutput(fromStandardErrorOutput output: String) throws {
if output.contains("ValueError: No saved credentials") {
throw NotSignedInError()
}

for line in output.split(whereSeparator: \.isNewline) {
if let match = try? Regex(#"(ERROR|CRITICAL): (.*)"#).firstMatch(in: line),
let errorReason = match.last?.substring {
Expand Down Expand Up @@ -103,7 +107,7 @@ final class Legendary {
progress: Progress) {
// these regexes are not dynamic, so there's no reason why they should fail to initialise
// swiftlint:disable force_try
let progressRegex: Regex = try! .init(#"Progress: (?<percentage>\d+\.\d+)% \((?<downloadedObjects>\d+)\/(?<totalObjects>\d+)\), Running for (?<runtime>\d+:\d+:\d+), ETA: (?<eta>\d+:\d+:\d+)"#)
let progressRegex: Regex = try! .init(#"Progress: (?<percentage>\d+(?:\.\d+)?)% \((?<downloadedObjects>\d+)\/(?<totalObjects>\d+)\), Running for (?<runtime>\d+:\d+:\d+), ETA: (?<eta>(?:\d+:\d+:\d+|--:--:--|Unknown))"#)
// let downloadRegex: Regex = try! .init(#"Downloaded: (?<downloaded>\d+\.\d+) \w+, Written: (?<written>\d+\.\d+) \w+"#)
// let cacheRegex: Regex = try! .init(#"Cache usage: (?<usage>\d+\.\d+) \w+, active tasks: (?<activeTasks>\d+)"#)
let downloadSpeedRegex: Regex = try! .init(#"\+ Download\s+- (?<raw>[\d.]+) \w+/\w+ \(raw\) / (?<decompressed>[\d.]+) \w+/\w+ \(decompressed\)"#)
Expand Down Expand Up @@ -410,8 +414,10 @@ final class Legendary {
throw CocoaError(.fileNoSuchFile)
}

let destinationURL = newLocation.appending(path: currentLocation.lastPathComponent)

let operation: GameOperation = .init(game: game, type: .move) { _ in
try FileManager.default.moveItem(at: currentLocation, to: newLocation)
try FileManager.default.moveItem(at: currentLocation, to: destinationURL)

let process: Process = .init()
process.arguments = ["move", game.id, newLocation.path, "--skip-move"]
Expand All @@ -424,7 +430,7 @@ final class Legendary {

try handleCLIErrorOutput(fromStandardErrorPipe: processStandardErrorPipe)

game.installationState = .installed(location: newLocation, platform: platform)
game.installationState = .installed(location: destinationURL, platform: platform)
}

await Game.operationManager.queueOperation(operation)
Expand Down Expand Up @@ -598,7 +604,6 @@ final class Legendary {
}

let arguments: [String] = ["install", game.id, "--platform", matchPlatform(for: platform)]

var installSize: Int64?
var optionalPacks: [String: String] = .init()

Expand All @@ -609,31 +614,27 @@ final class Legendary {

// note that install size and optional packs are mutually exclusive in this context.
try await withTaskCancellationHandler {
try await executeStreamed(process) { chunk in
await transformProcess(process)

for try await chunk in process.runStreamed() {
switch chunk.stream {
case .standardError:
// Handle install size
Task {
// legendary always returns install size in MiB
if let match = try? Regex(#"Install size: (\d+(?:\.\d+)?) MiB"#).firstMatch(in: chunk.output),
let sizeString = match[1].substring,
let sizeValue = Double(sizeString) {
await MainActor.run {
installSize = Int64(Int(sizeValue) * 1_048_576) // MiB ➜ B

process.interrupt()
}
}
try handleCLIErrorOutput(fromStandardErrorOutput: chunk.output)

// Handle install size. legendary always returns install size in MiB.
if let match = try? Regex(#"Install size: (\d+(?:\.\d+)?) MiB"#).firstMatch(in: chunk.output),
let sizeString = match[1].substring,
let sizeValue = Double(sizeString) {
installSize = Int64(Int(sizeValue) * 1_048_576) // MiB ➜ B
process.interrupt()
}

case .standardOutput:
// Handle optional packs
Task { @MainActor in
if let match = try? Regex(#"\s*\* (?<identifier>\w+) - (?<name>.+)"#).firstMatch(in: chunk.output),
let id = match["identifier"]?.substring,
let name = match["name"]?.substring {
optionalPacks[String(id)] = String(name)
}
if let match = try? Regex(#"\s*\* (?<identifier>\w+) - (?<name>.+)"#).firstMatch(in: chunk.output),
let id = match["identifier"]?.substring,
let name = match["name"]?.substring {
optionalPacks[String(id)] = String(name)
}

if chunk.output.contains("Please enter tags of pack(s) to install") {
Expand All @@ -651,8 +652,6 @@ final class Legendary {
}
*/
}

return nil
}
} onCancel: {
process.interrupt()
Expand Down
10 changes: 7 additions & 3 deletions Mythic/Utilities/GameManager/LocalGameManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ class LocalGameManager {

let process: Process = .init()
process.arguments = [location.path] + game.launchArguments
process.currentDirectoryURL = location.deletingLastPathComponent()
process.environment = environment
Wine.transformProcess(process, containerURL: containerURL)

Expand All @@ -111,10 +112,13 @@ class LocalGameManager {
throw CocoaError(.fileNoSuchFile)
}

let operation: GameOperation = .init(game: game, type: .uninstall) { _ in
try FileManager.default.moveItem(at: currentLocation, to: newLocation)
game.installationState = .installed(location: newLocation, platform: platform)
let destinationURL = newLocation.appending(path: currentLocation.lastPathComponent)

let operation: GameOperation = .init(game: game, type: .move) { _ in
try FileManager.default.moveItem(at: currentLocation, to: destinationURL)
game.installationState = .installed(location: destinationURL, platform: platform)
}
Game.operationManager.queueOperation(operation)
return operation
}

Expand Down
2 changes: 1 addition & 1 deletion Mythic/Utilities/SparkleUpdateController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ final class SparkleUpdateController: NSObject, SPUUserDriver, ObservableObject {
if enabled {
backgroundTask = AnyCancellable(
backgroundQueue.schedule(
after: .init(.now()),
after: .init(.now() + .seconds(60 * 60 * 6)),
interval: .seconds(60 * 60 * 6)
) {
Task { @MainActor in
Expand Down
9 changes: 9 additions & 0 deletions Mythic/Views/Navigation/LibraryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ struct LibraryView: View {
Section {
Toggle("Favourited", isOn: searchTokenBinding(for: .favourited))
}

if !gameDataStore.collectionNames.isEmpty {
Section("Collections") {
ForEach(gameDataStore.collectionNames, id: \.self) { collection in
Toggle(collection,
isOn: searchTokenBinding(for: .collection(collection)))
}
}
}
}
.menuIndicator(.hidden)
}
Expand Down
2 changes: 1 addition & 1 deletion Mythic/Views/Navigation/SupportView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ struct SupportView: View {
}
verticalDivider(height: 30)
Button("FAQ"){
openLink(urlString: "https://getmythic.app/faq/")
openLink(urlString: "https://docs.getmythic.app/docs/faq/")
}
verticalDivider(height: 30)
Button("Compatibility List"){
Expand Down
30 changes: 26 additions & 4 deletions Mythic/Views/Unified/Components/GameCard/GameCard+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -317,12 +317,23 @@ extension GameCard {

struct MenuView: View {
@Binding var game: Game
var includesPrimaryAction: Bool = false
@State private var isGameSettingsSheetPresented: Bool = false
@State private var isUninstallSheetPresented: Bool = false

var body: some View {
Group { // annoying, but the only way two sheets'll fit in here
Menu {
if includesPrimaryAction {
Section {
if case .installed = game.installationState {
GameCard.Buttons.Prominent.PlayButton(game: $game, withLabel: true)
} else {
GameCard.Buttons.Prominent.InstallButton(game: $game, withLabel: true)
}
}
}

GameCard.Buttons.SettingsButton(game: $game, withLabel: true, isGameSettingsSheetPresented: $isGameSettingsSheetPresented)
GameCard.Buttons.UpdateButton(game: $game, withLabel: true)
GameCard.Buttons.FavouriteButton(game: $game, withLabel: true)
Expand Down Expand Up @@ -374,11 +385,22 @@ extension GameCard {
if let operation = operationManager.queue.first(where: { $0.isExecuting && $0.game == game }) {
OperationCard.StatusView(operation: .constant(operation), withLabel: withLabel)
} else if case .installed = game.installationState {
Buttons.Prominent.PlayButton(game: $game, withLabel: withLabel)
MenuView(game: $game)
.layoutPriority(1)
ViewThatFits(in: .horizontal) {
HStack(spacing: 8) {
Buttons.Prominent.PlayButton(game: $game, withLabel: withLabel)
MenuView(game: $game)
}
.fixedSize(horizontal: true, vertical: false)

MenuView(game: $game, includesPrimaryAction: true)
.fixedSize(horizontal: true, vertical: false)
}
} else {
Buttons.Prominent.InstallButton(game: $game, withLabel: withLabel)
ViewThatFits(in: .horizontal) {
Buttons.Prominent.InstallButton(game: $game, withLabel: withLabel)
MenuView(game: $game, includesPrimaryAction: true)
.fixedSize(horizontal: true, vertical: false)
}
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions Mythic/Views/Unified/Components/GameListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ struct GameListView: View {
Text("Not Installed")
case .favourited:
Text("Favourited")
case .collection(let collection):
Text(collection)
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion Mythic/Views/Unified/Components/WebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ struct WebView: NSViewRepresentable {

let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: url))
return webView
}

func updateNSView(_ nsView: WKWebView, context: Context) {
if nsView.url != self.url {
if nsView.url == nil || url.scheme == "javascript" {
let request = URLRequest(url: self.url)
nsView.load(request)
}
Expand Down
10 changes: 10 additions & 0 deletions Mythic/Views/Unified/Models/GameListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import OSLog
let platforms: [SearchToken] = searchTokens.compactMap { if case .platform = $0 { $0 } else { nil } }
let storefronts: [SearchToken] = searchTokens.compactMap { if case .storefront = $0 { $0 } else { nil } }
let installations: [SearchToken] = searchTokens.filter { $0 == .installed || $0 == .notInstalled }
let collections: [SearchToken] = searchTokens.compactMap { if case .collection = $0 { $0 } else { nil } }

if platforms.count > 1, let last = platforms.last {
searchTokens.removeAll { if case .platform = $0 { $0 != last } else { false } }
Expand All @@ -31,6 +32,9 @@ import OSLog
if installations.count > 1, let last = installations.last {
searchTokens.removeAll { ($0 == .installed || $0 == .notInstalled) && $0 != last }
}
if collections.count > 1, let last = collections.last {
searchTokens.removeAll { if case .collection = $0 { $0 != last } else { false } }
}
}
}

Expand All @@ -56,6 +60,8 @@ import OSLog
return false
case .favourited:
return game.isFavourited
case .collection(let collection):
return game.collections.contains(collection)
}
}
return matchesText && matchesTokens
Expand All @@ -73,6 +79,7 @@ import OSLog
if !hasStorefront { suggestions.append(contentsOf: Game.Storefront.allCases.map { .storefront($0) }) }
if !hasInstallation { suggestions += [.installed, .notInstalled] }
if !searchTokens.contains(.favourited) { suggestions.append(.favourited) }
suggestions.append(contentsOf: GameDataStore.shared.collectionNames.map { .collection($0) }.filter { !searchTokens.contains($0) })

return suggestions
}
Expand All @@ -90,6 +97,7 @@ extension GameListViewModel {
case installed
case notInstalled
case favourited
case collection(String)

var id: String {
switch self {
Expand All @@ -103,6 +111,8 @@ extension GameListViewModel {
return "notInstalled"
case .favourited:
return "favourited"
case .collection(let collection):
return "collection_\(collection)"
}
}
}
Expand Down
Loading
Loading