diff --git a/Mythic.xcodeproj/project.pbxproj b/Mythic.xcodeproj/project.pbxproj index 92402c1..6ae89ca 100644 --- a/Mythic.xcodeproj/project.pbxproj +++ b/Mythic.xcodeproj/project.pbxproj @@ -66,8 +66,12 @@ 6A2935682BFCFAFD0035CE4B /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 6A2934F22BFCFAFD0035CE4B /* Credits.rtf */; }; 6A29356A2BFCFAFD0035CE4B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 6A2934F42BFCFAFD0035CE4B /* Localizable.xcstrings */; }; 6A29356B2BFCFAFD0035CE4B /* MythicApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A2934F62BFCFAFD0035CE4B /* MythicApp.swift */; }; - 6A302EE22CDE652D00E11458 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 6A302EE12CDE652D00E11458 /* FirebaseAnalytics */; }; - 6A302EE42CDE652D00E11458 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 6A302EE32CDE652D00E11458 /* FirebaseCore */; }; + 6A2960FC2CE0ED0D00917E90 /* EpicWebAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A2960FB2CE0ED0700917E90 /* EpicWebAuthView.swift */; }; + 6A2960FE2CE1017900917E90 /* NSApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A2960FD2CE1017700917E90 /* NSApplication.swift */; }; + 6A2961002CE1033200917E90 /* NSWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A2960FF2CE1033000917E90 /* NSWindow.swift */; }; + 6A2961032CE1DD6200917E90 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 6A2961022CE1DD6200917E90 /* FirebaseAnalytics */; }; + 6A2961052CE1DD6200917E90 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 6A2961042CE1DD6200917E90 /* FirebaseCore */; }; + 6A2961072CE1DD6200917E90 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 6A2961062CE1DD6200917E90 /* FirebaseCrashlytics */; }; 6A34366E2B8D7F1200D35BCA /* Shimmer in Frameworks */ = {isa = PBXBuildFile; productRef = 6A34366D2B8D7F1200D35BCA /* Shimmer */; }; 6A371B592AE7DFBF0054BF7A /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 6A371B582AE7DFBF0054BF7A /* ZIPFoundation */; }; 6A448E0E2CC4A53A001E9F47 /* GameListCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A448E0D2CC4A531001E9F47 /* GameListCard.swift */; }; @@ -82,7 +86,6 @@ 6AAD31152B08693D0035FA69 /* SemanticVersion in Frameworks */ = {isa = PBXBuildFile; productRef = 6AAD31142B08693D0035FA69 /* SemanticVersion */; }; 6AC45E092C1B2FD500ED9F64 /* SettingsFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AC45E082C1B2FC800ED9F64 /* SettingsFormView.swift */; }; 6AC742DD2B9314AB000EA1B2 /* SwordRPC in Frameworks */ = {isa = PBXBuildFile; productRef = 6AC742DC2B9314AB000EA1B2 /* SwordRPC */; }; - 6ACCEC182CD0817D00611BEF /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 6ACCEC172CD0817D00611BEF /* FirebaseCrashlytics */; }; 6ACCEC1B2CD08DEE00611BEF /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6ACCEC1A2CD08DEE00611BEF /* GoogleService-Info.plist */; }; 6AEEFA472CA9174B0025C840 /* WindowBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AEEFA462CA9173C0025C840 /* WindowBlurView.swift */; }; 6AF630D92C077A17001F4E10 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AF630D82C077A17001F4E10 /* UserDefaults.swift */; }; @@ -164,6 +167,9 @@ 6A2934F42BFCFAFD0035CE4B /* Localizable.xcstrings */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 6A2934F52BFCFAFD0035CE4B /* Mythic.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Mythic.entitlements; sourceTree = ""; }; 6A2934F62BFCFAFD0035CE4B /* MythicApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MythicApp.swift; sourceTree = ""; }; + 6A2960FB2CE0ED0700917E90 /* EpicWebAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpicWebAuthView.swift; sourceTree = ""; }; + 6A2960FD2CE1017700917E90 /* NSApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSApplication.swift; sourceTree = ""; }; + 6A2960FF2CE1033000917E90 /* NSWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSWindow.swift; sourceTree = ""; }; 6A448E0D2CC4A531001E9F47 /* GameListCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListCard.swift; sourceTree = ""; }; 6A448E0F2CC4BC50001E9F47 /* GameCardVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCardVM.swift; sourceTree = ""; }; 6A496A722C1AF75600FD637B /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = ""; }; @@ -185,15 +191,15 @@ files = ( 6AC742DD2B9314AB000EA1B2 /* SwordRPC in Frameworks */, 6A7A81162B77093600D19E32 /* ColorfulX in Frameworks */, + 6A2961052CE1DD6200917E90 /* FirebaseCore in Frameworks */, 6AFC02812ABE02970004AB77 /* Sparkle in Frameworks */, 6AFC027E2ABDB5D40004AB77 /* SwiftyJSON in Frameworks */, 6AA1744F2CD5CC290035B081 /* WhatsNewKit in Frameworks */, 6A12FF8E2B73AC4E00AA948C /* Glur in Frameworks */, - 6A302EE22CDE652D00E11458 /* FirebaseAnalytics in Frameworks */, 6A34366E2B8D7F1200D35BCA /* Shimmer in Frameworks */, 6AAD31152B08693D0035FA69 /* SemanticVersion in Frameworks */, - 6ACCEC182CD0817D00611BEF /* FirebaseCrashlytics in Frameworks */, - 6A302EE42CDE652D00E11458 /* FirebaseCore in Frameworks */, + 6A2961072CE1DD6200917E90 /* FirebaseCrashlytics in Frameworks */, + 6A2961032CE1DD6200917E90 /* FirebaseAnalytics in Frameworks */, 6A371B592AE7DFBF0054BF7A /* ZIPFoundation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -323,6 +329,7 @@ 6A2934EC2BFCFAFD0035CE4B /* Unified */ = { isa = PBXGroup; children = ( + 6A2960F92CE0ECEE00917E90 /* Windows */, 6A2934DF2BFCFAFD0035CE4B /* Modules */, 6A91FEC02C2BFB8100D9F153 /* Models */, 6A2934E72BFCFAFD0035CE4B /* Sheets */, @@ -364,6 +371,14 @@ path = Mythic; sourceTree = ""; }; + 6A2960F92CE0ECEE00917E90 /* Windows */ = { + isa = PBXGroup; + children = ( + 6A2960FB2CE0ED0700917E90 /* EpicWebAuthView.swift */, + ); + path = Windows; + sourceTree = ""; + }; 6A91FEC02C2BFB8100D9F153 /* Models */ = { isa = PBXGroup; children = ( @@ -402,6 +417,8 @@ 6AD44DE52C0A29BF00824C06 /* Built-in */ = { isa = PBXGroup; children = ( + 6A2960FF2CE1033000917E90 /* NSWindow.swift */, + 6A2960FD2CE1017700917E90 /* NSApplication.swift */, 6A2934B32BFCFAFD0035CE4B /* Bundle.swift */, 6A2934B42BFCFAFD0035CE4B /* Color.swift */, 6A2934B52BFCFAFD0035CE4B /* Data.swift */, @@ -443,10 +460,10 @@ 6A7A81152B77093600D19E32 /* ColorfulX */, 6A34366D2B8D7F1200D35BCA /* Shimmer */, 6AC742DC2B9314AB000EA1B2 /* SwordRPC */, - 6ACCEC172CD0817D00611BEF /* FirebaseCrashlytics */, 6AA1744E2CD5CC290035B081 /* WhatsNewKit */, - 6A302EE12CDE652D00E11458 /* FirebaseAnalytics */, - 6A302EE32CDE652D00E11458 /* FirebaseCore */, + 6A2961022CE1DD6200917E90 /* FirebaseAnalytics */, + 6A2961042CE1DD6200917E90 /* FirebaseCore */, + 6A2961062CE1DD6200917E90 /* FirebaseCrashlytics */, ); productName = Mythic; productReference = 6AB474952AACBBE900AB9C63 /* Mythic.app */; @@ -485,8 +502,8 @@ 6A7A81142B77093600D19E32 /* XCRemoteSwiftPackageReference "ColorfulX" */, 6A34366C2B8D7F1200D35BCA /* XCRemoteSwiftPackageReference "SwiftUI-Shimmer" */, 6AC742DB2B9314AB000EA1B2 /* XCRemoteSwiftPackageReference "SwordRPC" */, - 6ACCEC162CD0817D00611BEF /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, 6AA1744D2CD5CC290035B081 /* XCRemoteSwiftPackageReference "WhatsNewKit" */, + 6A2961012CE1DD6200917E90 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); productRefGroup = 6AB474962AACBBE900AB9C63 /* Products */; projectDirPath = ""; @@ -567,6 +584,7 @@ files = ( 6A29353A2BFCFAFD0035CE4B /* Task.swift in Sources */, 6A2935592BFCFAFD0035CE4B /* ContainerCreationView.swift in Sources */, + 6A2960FE2CE1017900917E90 /* NSApplication.swift in Sources */, 6A448E102CC4BC55001E9F47 /* GameCardVM.swift in Sources */, 6A2935562BFCFAFD0035CE4B /* GameCard.swift in Sources */, 6A2935622BFCFAFD0035CE4B /* NotImplementedView.swift in Sources */, @@ -611,8 +629,10 @@ 6A29353E2BFCFAFD0035CE4B /* LegendaryInterfaceExt.swift in Sources */, 6A71D3DD2BFD024D00A2C74D /* Auth.swift in Sources */, 6A29354B2BFCFAFD0035CE4B /* LocalImport.swift in Sources */, + 6A2960FC2CE0ED0D00917E90 /* EpicWebAuthView.swift in Sources */, 6A29355C2BFCFAFD0035CE4B /* InstallGameView.swift in Sources */, 6A2935432BFCFAFD0035CE4B /* FileLocations.swift in Sources */, + 6A2961002CE1033200917E90 /* NSWindow.swift in Sources */, 6A9FE1162CDEED7A00C36058 /* WhatsNewCollection.swift in Sources */, 6A2935402BFCFAFD0035CE4B /* LocalGamesExt.swift in Sources */, 6A2935502BFCFAFD0035CE4B /* HomeView.swift in Sources */, @@ -769,7 +789,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3083; + CURRENT_PROJECT_VERSION = 3144; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Mythic/Preview Content\""; @@ -816,7 +836,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3083; + CURRENT_PROJECT_VERSION = 3144; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Mythic/Preview Content\""; @@ -884,6 +904,14 @@ kind = branch; }; }; + 6A2961012CE1DD6200917E90 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 11.4.0; + }; + }; 6A34366C2B8D7F1200D35BCA /* XCRemoteSwiftPackageReference "SwiftUI-Shimmer" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/markiv/SwiftUI-Shimmer"; @@ -932,14 +960,6 @@ kind = branch; }; }; - 6ACCEC162CD0817D00611BEF /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 11.4.0; - }; - }; 6AFC027C2ABDB5D40004AB77 /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SwiftyJSON/SwiftyJSON"; @@ -964,16 +984,21 @@ package = 6A12FF8C2B73AC4E00AA948C /* XCRemoteSwiftPackageReference "Glur" */; productName = Glur; }; - 6A302EE12CDE652D00E11458 /* FirebaseAnalytics */ = { + 6A2961022CE1DD6200917E90 /* FirebaseAnalytics */ = { isa = XCSwiftPackageProductDependency; - package = 6ACCEC162CD0817D00611BEF /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + package = 6A2961012CE1DD6200917E90 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseAnalytics; }; - 6A302EE32CDE652D00E11458 /* FirebaseCore */ = { + 6A2961042CE1DD6200917E90 /* FirebaseCore */ = { isa = XCSwiftPackageProductDependency; - package = 6ACCEC162CD0817D00611BEF /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + package = 6A2961012CE1DD6200917E90 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseCore; }; + 6A2961062CE1DD6200917E90 /* FirebaseCrashlytics */ = { + isa = XCSwiftPackageProductDependency; + package = 6A2961012CE1DD6200917E90 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCrashlytics; + }; 6A34366D2B8D7F1200D35BCA /* Shimmer */ = { isa = XCSwiftPackageProductDependency; package = 6A34366C2B8D7F1200D35BCA /* XCRemoteSwiftPackageReference "SwiftUI-Shimmer" */; @@ -1004,11 +1029,6 @@ package = 6AC742DB2B9314AB000EA1B2 /* XCRemoteSwiftPackageReference "SwordRPC" */; productName = SwordRPC; }; - 6ACCEC172CD0817D00611BEF /* FirebaseCrashlytics */ = { - isa = XCSwiftPackageProductDependency; - package = 6ACCEC162CD0817D00611BEF /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseCrashlytics; - }; 6AFC027D2ABDB5D40004AB77 /* SwiftyJSON */ = { isa = XCSwiftPackageProductDependency; package = 6AFC027C2ABDB5D40004AB77 /* XCRemoteSwiftPackageReference "SwiftyJSON" */; diff --git a/Mythic/AppDelegate.swift b/Mythic/AppDelegate.swift index 3cd01ea..efb31be 100644 --- a/Mythic/AppDelegate.swift +++ b/Mythic/AppDelegate.swift @@ -349,7 +349,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // https://arc.net/l/quote/ extension AppDelegate: UNUserNotificationCenterDelegate {} -extension AppDelegate: SPUUpdaterDelegate {} +extension AppDelegate: SPUUpdaterDelegate {} // FIXME: nonfunctional extension AppDelegate: SwordRPCDelegate { func swordRPCDidConnect(_ rpc: SwordRPC) { diff --git a/Mythic/Localizable.xcstrings b/Mythic/Localizable.xcstrings index 15c16b5..78a1ba3 100644 --- a/Mythic/Localizable.xcstrings +++ b/Mythic/Localizable.xcstrings @@ -38624,6 +38624,9 @@ } } } + }, + "Unable to sign in to Epic." : { + }, "Unable to uninstall \"%@\"." : { "localizations" : { @@ -43377,4 +43380,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/Mythic/MythicApp.swift b/Mythic/MythicApp.swift index f9bcd94..6b7481f 100644 --- a/Mythic/MythicApp.swift +++ b/Mythic/MythicApp.swift @@ -32,40 +32,34 @@ struct MythicApp: App { @State private var bootError: Error? - func toggleTitleBar(_ value: Bool) { - if let window = NSApp.windows.first { - window.titlebarAppearsTransparent = !value - window.isMovableByWindowBackground = !value - window.titleVisibility = value ? .visible : .hidden - window.standardWindowButton(.miniaturizeButton)?.isHidden = !value - window.standardWindowButton(.zoomButton)?.isHidden = !value - } - } - // MARK: - App Body var body: some Scene { Window("Mythic", id: "main") { if isOnboardingPresented { OnboardingR2(fromPhase: onboardingPhase) + .contentTransition(.opacity) .onAppear { - toggleTitleBar(false) - - // Bring to front if let window = NSApp.mainWindow { + window.isImmersive = true + window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } } } else { ContentView() - .transition(.opacity) + .contentTransition(.opacity) .environmentObject(networkMonitor) .environmentObject(sparkleController) .frame(minWidth: 750, minHeight: 390) - .onAppear { toggleTitleBar(true) } + .onAppear { + if let window = NSApp.mainWindow { + window.isImmersive = false + } + } } } - + .handlesExternalEvents(matching: ["open"]) .environment( \.whatsNew, WhatsNewEnvironment( @@ -80,7 +74,6 @@ struct MythicApp: App { whatsNewCollection: self ) ) - .commands { CommandGroup(after: .appInfo) { Button("Check for Updates...", action: sparkleController.updater.checkForUpdates) @@ -92,7 +85,6 @@ struct MythicApp: App { } } .disabled(isOnboardingPresented) - // .keyboardShortcut("O", modifiers: [.command]) } } diff --git a/Mythic/Utilities/Extensions/Built-in/NSApplication.swift b/Mythic/Utilities/Extensions/Built-in/NSApplication.swift new file mode 100644 index 0000000..0ad23e5 --- /dev/null +++ b/Mythic/Utilities/Extensions/Built-in/NSApplication.swift @@ -0,0 +1,14 @@ +// +// NSApplication.swift +// Mythic +// +// Created by Esiayo Alegbe on 11/10/24. +// + +import AppKit + +extension NSApplication { + func window(withID id: String) -> NSWindow? { + return windows.first { $0.identifier?.rawValue == id } + } +} diff --git a/Mythic/Utilities/Extensions/Built-in/NSWindow.swift b/Mythic/Utilities/Extensions/Built-in/NSWindow.swift new file mode 100644 index 0000000..237ec04 --- /dev/null +++ b/Mythic/Utilities/Extensions/Built-in/NSWindow.swift @@ -0,0 +1,36 @@ +// +// NSWindow.swift +// Mythic +// +// Created by Esiayo Alegbe on 11/10/24. +// + +import AppKit + +extension NSWindow { + var isImmersive: Bool { + get { + return self.titlebarAndTextHidden && + self.isMovableByWindowBackground && + self.standardWindowButton(.miniaturizeButton)?.isHidden == true && + self.standardWindowButton(.zoomButton)?.isHidden == true + } + set { + self.titlebarAndTextHidden = newValue + self.isMovableByWindowBackground = newValue + self.standardWindowButton(.miniaturizeButton)?.isHidden = newValue + self.standardWindowButton(.zoomButton)?.isHidden = newValue + } + } + + var titlebarAndTextHidden: Bool { + get { + return self.titlebarAppearsTransparent == true && + self.titleVisibility == .visible + } + set { + self.titlebarAppearsTransparent = newValue + self.titleVisibility = newValue ? .hidden : .visible + } + } +} diff --git a/Mythic/Utilities/Global.swift b/Mythic/Utilities/Global.swift index 8ddaa3d..9c4285a 100644 --- a/Mythic/Utilities/Global.swift +++ b/Mythic/Utilities/Global.swift @@ -26,9 +26,12 @@ let files: FileManager = .default /// A simpler alias of `UserDefaults.standard`. let defaults: UserDefaults = .standard -/// A simpler alias of `workspace`. +/// A simpler alias of `NSWorkspace.shared`. let workspace: NSWorkspace = .shared +/// A simpler alias of `NSApp[lication].shared`. +let sharedApp: NSApplication = .shared + let notifications: UNUserNotificationCenter = .current() let mainLock: NSRecursiveLock = .init() diff --git a/Mythic/Utilities/Legendary/LegendaryInterface.swift b/Mythic/Utilities/Legendary/LegendaryInterface.swift index 2fd76c5..caf80b5 100644 --- a/Mythic/Utilities/Legendary/LegendaryInterface.swift +++ b/Mythic/Utilities/Legendary/LegendaryInterface.swift @@ -188,7 +188,7 @@ final class Legendary { - priority: Whether the game should interrupt currently queued game installations. */ static func install(args: GameOperation.InstallArguments, priority: Bool = false) async throws { - guard signedIn() else { throw NotSignedInError() } + guard signedIn else { throw NotSignedInError() } guard case .epic = args.game.source else { throw IsNotLegendaryError() } // guard args.type != .uninstall else { do {/* Add uninstallation support via dialog */}; return } @@ -257,7 +257,7 @@ final class Legendary { // FIXME: repeating code if output.stdout.contains("Installation requirements check returned the following results:") { if let match = try? Regex(#"Failure: (.*)"#).firstMatch(in: output.stdout) { - let errorDescription = match.last?.substring ?? "Unknown Error" + let errorDescription = match.last?.substring ?? "Unknown Error." stopCommand(identifier: "install") error = InstallationError(errorDescription: .init(errorDescription)) return @@ -265,7 +265,7 @@ final class Legendary { } if let match = try? Regex(#"(ERROR|CRITICAL): (.*)"#).firstMatch(in: output.stderr) { - let errorDescription = match.last?.substring ?? "Unknown Error" + let errorDescription = match.last?.substring ?? "Unknown Error." stopCommand(identifier: "install") error = InstallationError(errorDescription: .init(errorDescription)) return @@ -357,14 +357,24 @@ final class Legendary { } } - static func signIn(authKey: String) async throws -> Bool { - var isLoggedIn = false - - try await command(arguments: ["auth", "--code", authKey], identifier: "signin", waits: true ) { output in - isLoggedIn = (isLoggedIn == true ? true : output.stderr.contains("Successfully logged in as")) + @discardableResult + static func signIn(authKey: String) async throws -> String { + var user: String = .init() + try await command(arguments: ["auth", "--code", authKey], identifier: "signin", waits: true) { output in + if let match = try? Regex(#"Successfully logged in as \"(?[^\"]+)\""#).firstMatch(in: output.stderr), + let username = match["username"]?.substring { + user = .init(username) + } } - return isLoggedIn + guard !user.isEmpty else { throw SignInError() } + return user + } + + static func signOut() async throws { + try await Legendary.command(arguments: ["auth", "--delete"], identifier: "signout") { _ in } + + //guard !signedIn() else { } } /** @@ -483,32 +493,18 @@ final class Legendary { log.notice("Cleared legendary command cache.") } - // MARK: - Who Am I Method - /** - Queries the user that is currently signed into epic games. - This command has no delay. - - - Returns: The user's account information as a `String`. - */ - static func whoAmI() -> String { - let userJSONFileURL = URL(filePath: "\(configLocation)/user.json") - - guard - files.fileExists(atPath: userJSONFileURL.path), - let json = try? JSON(data: Data(contentsOf: userJSONFileURL)) - else { return "Nobody" } + /// Queries for the user that is currently signed into epic games. + static var user: String? { + let json: URL = .init(filePath: "\(configLocation)/user.json") + guard let json = try? JSON(data: .init(contentsOf: json)) else { + return nil + } return String(describing: json["displayName"]) } - // MARK: - Signed In Method - /** - Boolean verifier for the user's epic games signin state. - This command has no delay. - - - Returns: `true` if the user is signed in, otherwise `false`. - */ - static func signedIn() -> Bool { return whoAmI() != "Nobody" } + /// Checks account signin state. + static var signedIn: Bool { return user != nil } // MARK: - Get Installed Games Method /** @@ -518,7 +514,7 @@ final class Legendary { - Throws: A ``NotSignedInError``. */ static func getInstalledGames() throws -> [Mythic.Game] { - guard signedIn() else { throw NotSignedInError() } + guard signedIn else { throw NotSignedInError() } let installedData = URL(filePath: "\(configLocation)/installed.json") let data = try Data(contentsOf: installedData) @@ -534,7 +530,7 @@ final class Legendary { } static func getGamePath(game: Mythic.Game) throws -> String? { // no need to throw if it returns nil - guard signedIn() else { throw NotSignedInError() } + guard signedIn else { throw NotSignedInError() } guard case .epic = game.source else { throw IsNotLegendaryError() } let installed = try JSON(data: Data(contentsOf: URL(filePath: "\(configLocation)/installed.json"))) @@ -549,7 +545,7 @@ final class Legendary { - Returns: An `Array` of ``Game`` objects. */ static func getInstallable() throws -> [Mythic.Game] { - guard signedIn() else { throw NotSignedInError() } + guard signedIn else { throw NotSignedInError() } let metadata = "\(configLocation)/metadata" @@ -661,7 +657,7 @@ final class Legendary { - Returns: A tuple containing the outcome of the check, and which game it's an alias of (is an app\_name). */ static func isAlias(game: String) throws -> (Bool?, of: String?) { - guard signedIn() else { throw NotSignedInError() } + guard signedIn else { throw NotSignedInError() } let aliasesJSONFileURL: URL = URL(filePath: "\(configLocation)/aliases.json") diff --git a/Mythic/Utilities/Legendary/LegendaryInterfaceExt.swift b/Mythic/Utilities/Legendary/LegendaryInterfaceExt.swift index 1465318..1c962c1 100644 --- a/Mythic/Utilities/Legendary/LegendaryInterfaceExt.swift +++ b/Mythic/Utilities/Legendary/LegendaryInterfaceExt.swift @@ -49,7 +49,11 @@ extension Legendary { struct NotSignedInError: LocalizedError { var errorDescription: String? = "You aren't signed in to epic games." } - + + struct SignInError: LocalizedError { + var errorDescription: String? = "Unable to sign into Epic Games." + } + /// Installation error with a message, see ``Legendary.install()`` struct InstallationError: LocalizedError { var errorDescription: String? = "Unable to install game." diff --git a/Mythic/Views/Navigation/AccountsView.swift b/Mythic/Views/Navigation/AccountsView.swift index eecc4f2..1d9362d 100644 --- a/Mythic/Views/Navigation/AccountsView.swift +++ b/Mythic/Views/Navigation/AccountsView.swift @@ -18,10 +18,9 @@ import SwiftUI import SwordRPC struct AccountsView: View { + @ObservedObject private var epicWebAuthViewModel: EpicWebAuthViewModel = .shared @State private var isSignOutConfirmationPresented: Bool = false - @State private var isAuthViewPresented: Bool = false @State private var isHoveringOverDestructiveEpicButton: Bool = false - @State private var signedIn: Bool = false @State private var isHoveringOverDestructiveSteamButton: Bool = false @@ -50,21 +49,21 @@ struct AccountsView: View { } HStack { - Text(signedIn ? "Signed in as \"\(Legendary.whoAmI())\"." : "Not signed in") + Text(Legendary.signedIn ? "Signed in as \"\(Legendary.user ?? "Unknown")\"." : "Not signed in") .font(.bold(.title3)()) Spacer() } } Button { - if signedIn { + if Legendary.signedIn { isSignOutConfirmationPresented = true } else { - isAuthViewPresented = true + epicWebAuthViewModel.showEpicSignInWindow() } } label: { Image(systemName: "person") - .symbolVariant(signedIn ? .slash : .none) + .symbolVariant(Legendary.signedIn ? .slash : .none) .foregroundStyle(isHoveringOverDestructiveEpicButton ? .red : .primary) .padding(5) @@ -72,28 +71,22 @@ struct AccountsView: View { .clipShape(.circle) .onHover { hovering in withAnimation(.easeInOut(duration: 0.1)) { - isHoveringOverDestructiveEpicButton = (hovering && signedIn) + isHoveringOverDestructiveEpicButton = (hovering && Legendary.signedIn) } } - .sheet(isPresented: $isAuthViewPresented, onDismiss: { signedIn = Legendary.signedIn() }, content: { - AuthView(isPresented: $isAuthViewPresented) - }) .alert(isPresented: $isSignOutConfirmationPresented) { Alert( title: .init("Are you sure you want to sign out from Epic?"), - message: .init("This will sign you out of the account \"\(Legendary.whoAmI())\"."), + message: .init("This will sign you out of the account \"\(Legendary.user ?? "Unknown")\"."), primaryButton: .destructive(.init("Sign Out")) { Task.sync(priority: .high) { try? await Legendary.command(arguments: ["auth", "--delete"], identifier: "signout") { _ in } } - - signedIn = Legendary.signedIn() }, secondaryButton: .cancel(.init("Cancel")) ) } } - .task { signedIn = Legendary.signedIn() } .padding() } @@ -135,7 +128,7 @@ struct AccountsView: View { .clipShape(.circle) .onHover { hovering in withAnimation(.easeInOut(duration: 0.1)) { - isHoveringOverDestructiveSteamButton = (hovering && signedIn) + isHoveringOverDestructiveSteamButton = (hovering && Legendary.signedIn) } } } diff --git a/Mythic/Views/Navigation/ContentView.swift b/Mythic/Views/Navigation/ContentView.swift index 2f01a30..8fde37c 100644 --- a/Mythic/Views/Navigation/ContentView.swift +++ b/Mythic/Views/Navigation/ContentView.swift @@ -25,21 +25,14 @@ import WhatsNewKit // MARK: - ContentView Struct struct ContentView: View { - @EnvironmentObject var networkMonitor: NetworkMonitor @EnvironmentObject var sparkle: SparkleController @ObservedObject private var variables: VariableManager = .shared @ObservedObject private var operation: GameOperation = .shared - @State private var isInstallStatusViewPresented: Bool = false - - @State var account: String = Legendary.whoAmI() - @State private var appVersion: String = .init() @State private var buildNumber: Int = 0 - func updateEpicSignin() { account = Legendary.whoAmI() } - // MARK: - Body var body: some View { NavigationSplitView( @@ -90,10 +83,6 @@ struct ContentView: View { Text("Management") } } - - .sheet(isPresented: $isInstallStatusViewPresented) { - InstallStatusView(isPresented: $isInstallStatusViewPresented) - } .listStyle(SidebarListStyle()) .frame(minWidth: 150, idealWidth: 250, maxWidth: 300) .toolbar { diff --git a/Mythic/Views/OnboardingViewR2.swift b/Mythic/Views/OnboardingViewR2.swift index fbe6954..7f79d9a 100644 --- a/Mythic/Views/OnboardingViewR2.swift +++ b/Mythic/Views/OnboardingViewR2.swift @@ -49,10 +49,10 @@ struct OnboardingR2: View { // TODO: ViewModel return } if !forceNext { - if case .signin = allCases[nextIndex], Legendary.signedIn() { + if case .signin = allCases[nextIndex], Legendary.signedIn { nextIndex += 1 } - if case .greetings = allCases[nextIndex], !Legendary.signedIn() { + if case .greetings = allCases[nextIndex], !Legendary.signedIn { nextIndex += 1 } if case .rosettaDisclaimer = allCases[nextIndex], (Rosetta.exists || !workspace.isARM()) { @@ -157,8 +157,10 @@ struct OnboardingR2: View { // TODO: ViewModel Task(priority: .userInitiated) { withAnimation { isSigningIn = true } do { - epicUnsuccessfulSignInAttempt = !(try await Legendary.signIn(authKey: epicSigninAuthKey)) + epicUnsuccessfulSignInAttempt = false + try await Legendary.signIn(authKey: epicSigninAuthKey) } catch { + epicUnsuccessfulSignInAttempt = true errorString = error.localizedDescription } withAnimation { isSigningIn = false } @@ -260,13 +262,13 @@ struct OnboardingR2: View { // TODO: ViewModel isNextButtonDisabled = true } .onChange(of: isSigningIn) { - if !$1, Legendary.signedIn() { // dumb logic, only checks signin status after pressing arrow + if !$1, Legendary.signedIn { // dumb logic, only checks signin status after pressing arrow animateNextPhase() notifications.add( .init(identifier: UUID().uuidString, content: { let content = UNMutableNotificationContent() - content.title = "Signed in as \"\(Legendary.whoAmI())\"." + content.title = "Signed in as \"\(Legendary.user ?? "Unknown")\"." return content }(), trigger: nil) @@ -280,7 +282,7 @@ struct OnboardingR2: View { // TODO: ViewModel isThirdRowPresented: $isThirdRowPresented, otherFirstRow: .init( HStack { - Text("Hey, \(Legendary.whoAmI())!") + Text("Hey, \(Legendary.user ?? "user")!") .font(.bold(.title)()) .scaledToFit() .truncationMode(.tail) diff --git a/Mythic/Views/Unified/Sheets/InstallGameView.swift b/Mythic/Views/Unified/Sheets/InstallGameView.swift index c9386d0..6077365 100644 --- a/Mythic/Views/Unified/Sheets/InstallGameView.swift +++ b/Mythic/Views/Unified/Sheets/InstallGameView.swift @@ -76,7 +76,7 @@ struct InstallViewEvo: View { .alert(isPresented: $isInstallationErrorPresented) { Alert( title: .init("Unable to proceed with installation."), - message: .init(installationError?.localizedDescription ?? "Unknown error."), + message: .init(installationError?.localizedDescription ?? "Unknown Error."), dismissButton: .default(.init("OK")) { isPresented = false } diff --git a/Mythic/Views/Unified/Windows/EpicWebAuthView.swift b/Mythic/Views/Unified/Windows/EpicWebAuthView.swift new file mode 100644 index 0000000..229a832 --- /dev/null +++ b/Mythic/Views/Unified/Windows/EpicWebAuthView.swift @@ -0,0 +1,156 @@ +// +// EpicWebAuthView.swift +// Mythic +// +// Created by Esiayo Alegbe on 11/10/24. +// + +import SwiftUI +import WebKit +import SwiftyJSON +import OSLog + +struct EpicWebAuthView: View { + @ObservedObject var viewModel = EpicWebAuthViewModel.shared + + @State private var attemptingSignIn: Bool = false + @State private var isSigninErrorPresented: Bool = false + @State private var signInError: Error? + + @State private var authKey: String = .init() + + var body: some View { + EpicInterceptorWebView(completion: { authKey = $0 }) + .blur(radius: attemptingSignIn ? 30 : 0) + .onChange(of: authKey) { + guard !$1.isEmpty else { return } + + Task { + do { + attemptingSignIn = true + try await Legendary.signIn(authKey: authKey) + viewModel.closeEpicSignInWindow() + } catch { + self.signInError = error + isSigninErrorPresented = true + } + + withAnimation { + attemptingSignIn = false + } + } + } + .onDisappear { + authKey = .init() + } + .alert(isPresented: $isSigninErrorPresented) { + .init( + title: .init("Unable to sign in to Epic."), + message: .init(signInError?.localizedDescription ?? "Unknown Error."), + primaryButton: .default(.init("OK")), + secondaryButton: .cancel() + ) + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + if attemptingSignIn { + ProgressView() + .controlSize(.small) + } + } + } + } +} + +final class EpicWebAuthViewModel: ObservableObject { + public static let shared: EpicWebAuthViewModel = .init() + private init() {} + + @MainActor + func showEpicSignInWindow() { + guard sharedApp.window(withID: "epic-signin") == nil else { + Logger.app.warning("Epic sign-in window already open") + return + } + + let hostingView: NSHostingView = .init(rootView: EpicWebAuthView()) + + let window = NSWindow( + contentRect: .init(x: 0, y: 0, width: 750, height: 500), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.identifier = .init("epic-signin") + window.contentView = hostingView + + window.titlebarAndTextHidden = true + window.center() + window.makeKeyAndOrderFront(nil) + + sharedApp.activate(ignoringOtherApps: true) + } + + @MainActor + func closeEpicSignInWindow() { + // FIXME: .close() causes stupid crash for unknown reason + sharedApp.window(withID: "epic-signin")?.orderOut(nil) + } +} + +fileprivate struct EpicInterceptorWebView: NSViewRepresentable { + var completion: (String) -> Void + + class Coordinator: NSObject, WKNavigationDelegate { + var parent: EpicInterceptorWebView + @State private var isLoading: Bool = false + + init(parent: EpicInterceptorWebView) { + self.parent = parent + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + isLoading = true + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + isLoading = false + + webView.evaluateJavaScript("document.body.innerText") { result, error in + guard + error == nil, + let content = result as? String, + let data = content.data(using: .utf8), + let json: JSON = try? .init(data: data) + else { + return + } + + if let code = json["authorizationCode"].string { + self.parent.completion(code) + } + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + isLoading = false + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(parent: self) + } + + func makeNSView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + config.websiteDataStore = .nonPersistent() // don't persist user data + + let webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = context.coordinator + return webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) { + nsView.load(URLRequest(url: .init(string: "https://legendary.gl/epiclogin")!)) + } +}