diff --git a/Mythic/Utilities/Game/Game.swift b/Mythic/Utilities/Game/Game.swift index 64fd306f..4df45d42 100644 --- a/Mythic/Utilities/Game/Game.swift +++ b/Mythic/Utilities/Game/Game.swift @@ -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 @@ -51,6 +51,7 @@ import AppKit var launchArguments: [String] = [] final var isFavourited: Bool = false + final var collections: Set = .init() final var lastLaunched: Date? // override in subclass @@ -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.self, forKey: .collections) ?? .init() self.lastLaunched = try container.decodeIfPresent(Date.self, forKey: .lastLaunched) } @@ -218,6 +220,7 @@ extension Game { // swiftlint:enable identifier_name case launchArguments, isFavourited, + collections, lastLaunched } @@ -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) } } @@ -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) diff --git a/Mythic/Utilities/GameDataStore.swift b/Mythic/Utilities/GameDataStore.swift index f35731de..789bc812 100644 --- a/Mythic/Utilities/GameDataStore.swift +++ b/Mythic/Utilities/GameDataStore.swift @@ -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 { diff --git a/Mythic/Utilities/GameManager/EpicGamesGameManager.swift b/Mythic/Utilities/GameManager/EpicGamesGameManager.swift index a929820a..daba739e 100644 --- a/Mythic/Utilities/GameManager/EpicGamesGameManager.swift +++ b/Mythic/Utilities/GameManager/EpicGamesGameManager.swift @@ -58,7 +58,7 @@ 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, @@ -66,7 +66,7 @@ extension EpicGamesGameManager: StorefrontGameManager { 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, diff --git a/Mythic/Utilities/GameManager/Legendary/LegendaryInterface.swift b/Mythic/Utilities/GameManager/Legendary/LegendaryInterface.swift index abecd938..a0b5ccc4 100644 --- a/Mythic/Utilities/GameManager/Legendary/LegendaryInterface.swift +++ b/Mythic/Utilities/GameManager/Legendary/LegendaryInterface.swift @@ -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 { @@ -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: (?\d+\.\d+)% \((?\d+)\/(?\d+)\), Running for (?\d+:\d+:\d+), ETA: (?\d+:\d+:\d+)"#) + let progressRegex: Regex = try! .init(#"Progress: (?\d+(?:\.\d+)?)% \((?\d+)\/(?\d+)\), Running for (?\d+:\d+:\d+), ETA: (?(?:\d+:\d+:\d+|--:--:--|Unknown))"#) // let downloadRegex: Regex = try! .init(#"Downloaded: (?\d+\.\d+) \w+, Written: (?\d+\.\d+) \w+"#) // let cacheRegex: Regex = try! .init(#"Cache usage: (?\d+\.\d+) \w+, active tasks: (?\d+)"#) let downloadSpeedRegex: Regex = try! .init(#"\+ Download\s+- (?[\d.]+) \w+/\w+ \(raw\) / (?[\d.]+) \w+/\w+ \(decompressed\)"#) @@ -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"] @@ -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) @@ -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() @@ -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*\* (?\w+) - (?.+)"#).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*\* (?\w+) - (?.+)"#).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") { @@ -651,8 +652,6 @@ final class Legendary { } */ } - - return nil } } onCancel: { process.interrupt() diff --git a/Mythic/Utilities/GameManager/LocalGameManager.swift b/Mythic/Utilities/GameManager/LocalGameManager.swift index 1f5483ef..b001c15c 100644 --- a/Mythic/Utilities/GameManager/LocalGameManager.swift +++ b/Mythic/Utilities/GameManager/LocalGameManager.swift @@ -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) @@ -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 } diff --git a/Mythic/Utilities/SparkleUpdateController.swift b/Mythic/Utilities/SparkleUpdateController.swift index 5bd0fbd7..10056b88 100644 --- a/Mythic/Utilities/SparkleUpdateController.swift +++ b/Mythic/Utilities/SparkleUpdateController.swift @@ -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 diff --git a/Mythic/Views/Navigation/LibraryView.swift b/Mythic/Views/Navigation/LibraryView.swift index fc7a0d7a..22c9e493 100644 --- a/Mythic/Views/Navigation/LibraryView.swift +++ b/Mythic/Views/Navigation/LibraryView.swift @@ -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) } diff --git a/Mythic/Views/Navigation/SupportView.swift b/Mythic/Views/Navigation/SupportView.swift index cb45653f..79733913 100644 --- a/Mythic/Views/Navigation/SupportView.swift +++ b/Mythic/Views/Navigation/SupportView.swift @@ -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"){ diff --git a/Mythic/Views/Unified/Components/GameCard/GameCard+Extensions.swift b/Mythic/Views/Unified/Components/GameCard/GameCard+Extensions.swift index 36a30c3a..e2152846 100644 --- a/Mythic/Views/Unified/Components/GameCard/GameCard+Extensions.swift +++ b/Mythic/Views/Unified/Components/GameCard/GameCard+Extensions.swift @@ -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) @@ -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) + } } } } diff --git a/Mythic/Views/Unified/Components/GameListView.swift b/Mythic/Views/Unified/Components/GameListView.swift index 33b8813c..b0a530c8 100644 --- a/Mythic/Views/Unified/Components/GameListView.swift +++ b/Mythic/Views/Unified/Components/GameListView.swift @@ -80,6 +80,8 @@ struct GameListView: View { Text("Not Installed") case .favourited: Text("Favourited") + case .collection(let collection): + Text(collection) } } } diff --git a/Mythic/Views/Unified/Components/WebView.swift b/Mythic/Views/Unified/Components/WebView.swift index 6ad4a8e6..8c394330 100644 --- a/Mythic/Views/Unified/Components/WebView.swift +++ b/Mythic/Views/Unified/Components/WebView.swift @@ -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) } diff --git a/Mythic/Views/Unified/Models/GameListViewModel.swift b/Mythic/Views/Unified/Models/GameListViewModel.swift index 21c6d921..41da48df 100644 --- a/Mythic/Views/Unified/Models/GameListViewModel.swift +++ b/Mythic/Views/Unified/Models/GameListViewModel.swift @@ -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 } } @@ -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 } } + } } } @@ -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 @@ -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 } @@ -90,6 +97,7 @@ extension GameListViewModel { case installed case notInstalled case favourited + case collection(String) var id: String { switch self { @@ -103,6 +111,8 @@ extension GameListViewModel { return "notInstalled" case .favourited: return "favourited" + case .collection(let collection): + return "collection_\(collection)" } } } diff --git a/Mythic/Views/Unified/Sheets/GameSettingsView.swift b/Mythic/Views/Unified/Sheets/GameSettingsView.swift index 999ffe4e..88228cd5 100644 --- a/Mythic/Views/Unified/Sheets/GameSettingsView.swift +++ b/Mythic/Views/Unified/Sheets/GameSettingsView.swift @@ -18,6 +18,7 @@ struct GameSettingsView: View { @State private var isMovingFileImporterPresented: Bool = false @State private var typingArgument: String = .init() + @State private var typingCollection: String = .init() @State private var isImageEmpty: Bool = true @@ -135,6 +136,41 @@ struct GameSettingsView: View { } } + // MARK: Collection Modifier + HStack { + VStack(alignment: .leading) { + Text("Collections") + + if !game.collections.isEmpty { + ScrollView(.horizontal) { + HStack { + ForEach(game.collections.sorted(), id: \.self) { collection in + CollectionItem(game: $game, collection: collection) + } + + Spacer() + } + } + .scrollIndicators(.never) + } else { + Text("Add this game to a local Mythic collection.") + .foregroundStyle(.secondary) + } + } + + Spacer() + + TextField("Collection", text: $typingCollection) + .onSubmit(submitCollection) + + if !typingCollection.isEmpty { + Button("", systemImage: "return") { + submitCollection() + } + .buttonStyle(.plain) + } + } + // MARK: File Integrity verification button HStack { VStack(alignment: .leading) { @@ -274,6 +310,18 @@ private extension GameSettingsView { typingArgument = .init() } } + + func submitCollection() { + let cleanedCollection = typingCollection + .trimmingCharacters(in: .illegalCharacters) + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard !cleanedCollection.isEmpty else { return } + + game.collections.insert(cleanedCollection) + GameDataStore.shared.persist(game) + typingCollection = .init() + } } private extension GameSettingsView { @@ -336,6 +384,34 @@ extension GameSettingsView { } } + struct CollectionItem: View { + @Binding var game: Game + var collection: String + + @State var isHoveringOverCollection: Bool = false + + var body: some View { + HStack { + Text(collection) + .foregroundStyle(isHoveringOverCollection ? .red : .secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + } + .background(in: .capsule) + .backgroundStyle(.quinary) + .onHover { hovering in + withAnimation { isHoveringOverCollection = hovering } + } + .onTapGesture { + withAnimation(.easeInOut(duration: 0.3)) { + game.collections.remove(collection) + GameDataStore.shared.persist(game) + } + } + .help("Remove from \"\(collection)\"") + } + } + struct ThumbnailURLChangeView: View { @Binding var game: Game @Binding var isPresented: Bool diff --git a/Mythic/Views/Unified/Windows/EpicWebAuthView.swift b/Mythic/Views/Unified/Windows/EpicWebAuthView.swift index 2ff40c69..61ed0c5e 100644 --- a/Mythic/Views/Unified/Windows/EpicWebAuthView.swift +++ b/Mythic/Views/Unified/Windows/EpicWebAuthView.swift @@ -161,13 +161,24 @@ private struct EpicInterceptorWebView: NSViewRepresentable { let completion: (String) -> Void - class Coordinator: NSObject, WKNavigationDelegate { + class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { let parent: EpicInterceptorWebView init(parent: EpicInterceptorWebView) { self.parent = parent } + func webView(_ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures) -> WKWebView? { + if navigationAction.targetFrame == nil { + webView.load(navigationAction.request) + } + + return nil + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { webView.evaluateJavaScript("document.body.innerText") { result, error in guard error == nil else { @@ -237,6 +248,7 @@ private struct EpicInterceptorWebView: NSViewRepresentable { let webView = WKWebView(frame: .zero, configuration: config) webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator webView.load(URLRequest(url: URL(string: "https://legendary.gl/epiclogin")!)) return webView }