From 7f9ea3d78fd03664adc1e25fffa6c271657499ea Mon Sep 17 00:00:00 2001 From: esiayo <41133734+blackxfiied@users.noreply.github.com> Date: Fri, 15 Nov 2024 12:12:18 +0800 Subject: [PATCH] feat: add custom sparkle updater upstreamed from branch `refactor/rewrite`, f41ccb7 Co-authored-by: Josh <36625023+JoshuaBrest@users.noreply.github.com> --- Mythic.xcodeproj/project.pbxproj | 71 +++- Mythic/App/AppDelegate.swift | 7 +- Mythic/App/AppMenuController.swift | 3 +- Mythic/Localizable.xcstrings | 78 +++++ Mythic/Models/AppLoggerModel.swift | 2 +- .../AppSettingsPersistentStateModel.swift | 8 +- .../Models/SparkleUpdateControllerModel.swift | 305 ++++++++++++++++++ Mythic/Views/Components/BundleIconView.swift | 31 ++ .../Components/ColorfulBackgroundView.swift | 1 + Mythic/Views/Components/RichAlertView.swift | 74 +++++ Mythic/Views/ContentView.swift | 3 + .../OnboardingEpicGamesLoginStepView.swift | 245 +++++++++++++- Mythic/Views/Onboarding/OnboardingView.swift | 37 ++- .../SparkleUpdaterCheckingView.swift | 34 ++ .../SparkleUpdaterDownloadingView.swift | 162 ++++++++++ .../SparkleUpdaterExtractingView.swift | 49 +++ .../SparkleUpdaterFinishView.swift | 61 ++++ .../SparkleUpdaterInstallingView.swift | 44 +++ .../SparkleUpdaterPreviewView.swift | 103 ++++++ .../SparkleUpdaterSheetViewModifier.swift | 141 ++++++++ 20 files changed, 1425 insertions(+), 34 deletions(-) create mode 100644 Mythic/Models/SparkleUpdateControllerModel.swift create mode 100644 Mythic/Views/Components/BundleIconView.swift create mode 100644 Mythic/Views/Components/RichAlertView.swift create mode 100644 Mythic/Views/SparkleUpdater/SparkleUpdaterCheckingView.swift create mode 100644 Mythic/Views/SparkleUpdater/SparkleUpdaterDownloadingView.swift create mode 100644 Mythic/Views/SparkleUpdater/SparkleUpdaterExtractingView.swift create mode 100644 Mythic/Views/SparkleUpdater/SparkleUpdaterFinishView.swift create mode 100644 Mythic/Views/SparkleUpdater/SparkleUpdaterInstallingView.swift create mode 100644 Mythic/Views/SparkleUpdater/SparkleUpdaterPreviewView.swift create mode 100644 Mythic/Views/SparkleUpdater/SparkleUpdaterSheetViewModifier.swift diff --git a/Mythic.xcodeproj/project.pbxproj b/Mythic.xcodeproj/project.pbxproj index 9f2d463c..d7ccdc39 100644 --- a/Mythic.xcodeproj/project.pbxproj +++ b/Mythic.xcodeproj/project.pbxproj @@ -98,6 +98,17 @@ 6A496A732C1AF75B00FD637B /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A496A722C1AF75600FD637B /* Game.swift */; }; 6A541C362CE6FB0400AD8A98 /* SetupWindow.xib in Resources */ = {isa = PBXBuildFile; fileRef = 6A541C352CE6FB0400AD8A98 /* SetupWindow.xib */; }; 6A541C382CE6FBC600AD8A98 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 6A541C372CE6FBC600AD8A98 /* MainMenu.xib */; }; + 6A541C3A2CE6FFD900AD8A98 /* SparkleUpdateControllerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A541C392CE6FFD900AD8A98 /* SparkleUpdateControllerModel.swift */; }; + 6A541C3C2CE7001000AD8A98 /* BundleIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A541C3B2CE7001000AD8A98 /* BundleIconView.swift */; }; + 6A541C3E2CE7001300AD8A98 /* RichAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A541C3D2CE7001300AD8A98 /* RichAlertView.swift */; }; + 6A541C472CE700CF00AD8A98 /* SparkleUpdaterSheetViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A541C452CE700CF00AD8A98 /* SparkleUpdaterSheetViewModifier.swift */; }; + 6A541C482CE700CF00AD8A98 /* SparkleUpdaterExtractingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A541C422CE700CF00AD8A98 /* SparkleUpdaterExtractingView.swift */; }; + 6A541C492CE700CF00AD8A98 /* SparkleUpdaterPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A541C402CE700CF00AD8A98 /* SparkleUpdaterPreviewView.swift */; }; + 6A541C4A2CE700CF00AD8A98 /* SparkleUpdaterInstallingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A541C432CE700CF00AD8A98 /* SparkleUpdaterInstallingView.swift */; }; + 6A541C4B2CE700CF00AD8A98 /* SparkleUpdaterCheckingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A541C3F2CE700CF00AD8A98 /* SparkleUpdaterCheckingView.swift */; }; + 6A541C4C2CE700CF00AD8A98 /* SparkleUpdaterFinishView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A541C442CE700CF00AD8A98 /* SparkleUpdaterFinishView.swift */; }; + 6A541C4D2CE700CF00AD8A98 /* SparkleUpdaterDownloadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A541C412CE700CF00AD8A98 /* SparkleUpdaterDownloadingView.swift */; }; + 6A541C502CE7013800AD8A98 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 6A541C4F2CE7013800AD8A98 /* MarkdownUI */; }; 6A71D3D92BFD01AB00A2C74D /* legendary in Resources */ = {isa = PBXBuildFile; fileRef = 6A71D3D82BFD01AB00A2C74D /* legendary */; }; 6A71D3DD2BFD024D00A2C74D /* Auth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A71D3DC2BFD024D00A2C74D /* Auth.swift */; }; 6A7A81162B77093600D19E32 /* ColorfulX in Frameworks */ = {isa = PBXBuildFile; productRef = 6A7A81152B77093600D19E32 /* ColorfulX */; }; @@ -219,6 +230,16 @@ 6A496A722C1AF75600FD637B /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = ""; }; 6A541C352CE6FB0400AD8A98 /* SetupWindow.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SetupWindow.xib; sourceTree = ""; }; 6A541C372CE6FBC600AD8A98 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; + 6A541C392CE6FFD900AD8A98 /* SparkleUpdateControllerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdateControllerModel.swift; sourceTree = ""; }; + 6A541C3B2CE7001000AD8A98 /* BundleIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIconView.swift; sourceTree = ""; }; + 6A541C3D2CE7001300AD8A98 /* RichAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RichAlertView.swift; sourceTree = ""; }; + 6A541C3F2CE700CF00AD8A98 /* SparkleUpdaterCheckingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdaterCheckingView.swift; sourceTree = ""; }; + 6A541C402CE700CF00AD8A98 /* SparkleUpdaterPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdaterPreviewView.swift; sourceTree = ""; }; + 6A541C412CE700CF00AD8A98 /* SparkleUpdaterDownloadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdaterDownloadingView.swift; sourceTree = ""; }; + 6A541C422CE700CF00AD8A98 /* SparkleUpdaterExtractingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdaterExtractingView.swift; sourceTree = ""; }; + 6A541C432CE700CF00AD8A98 /* SparkleUpdaterInstallingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdaterInstallingView.swift; sourceTree = ""; }; + 6A541C442CE700CF00AD8A98 /* SparkleUpdaterFinishView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdaterFinishView.swift; sourceTree = ""; }; + 6A541C452CE700CF00AD8A98 /* SparkleUpdaterSheetViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdaterSheetViewModifier.swift; sourceTree = ""; }; 6A71D3D82BFD01AB00A2C74D /* legendary */ = {isa = PBXFileReference; lastKnownFileType = folder; path = legendary; sourceTree = ""; }; 6A71D3DC2BFD024D00A2C74D /* Auth.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Auth.swift; sourceTree = ""; }; 6A9FE1152CDEED7200C36058 /* WhatsNewCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewCollection.swift; sourceTree = ""; }; @@ -239,6 +260,7 @@ buildActionMask = 2147483647; files = ( 6A22B12A2CE6ECDC0055D837 /* BLAKE3 in Frameworks */, + 6A541C502CE7013800AD8A98 /* MarkdownUI in Frameworks */, 6AC742DD2B9314AB000EA1B2 /* SwordRPC in Frameworks */, 6A7A81162B77093600D19E32 /* ColorfulX in Frameworks */, 6A2961052CE1DD6200917E90 /* FirebaseCore in Frameworks */, @@ -283,6 +305,7 @@ 6A22B12D2CE6ED2F0055D837 /* PersistentState */, 6A22B12E2CE6ED2F0055D837 /* AppLoggerModel.swift */, 6A22B12F2CE6ED2F0055D837 /* EngineInstallerModel.swift */, + 6A541C392CE6FFD900AD8A98 /* SparkleUpdateControllerModel.swift */, ); path = Models; sourceTree = ""; @@ -290,6 +313,7 @@ 6A22B13E2CE6EE280055D837 /* Onboarding */ = { isa = PBXGroup; children = ( + 6A22B13D2CE6EE280055D837 /* OnboardingView.swift */, 6A22B1352CE6EE280055D837 /* OnboardingIntroStepView.swift */, 6A22B1362CE6EE280055D837 /* OnboardingEpicGamesLoginStepView.swift */, 6A22B1372CE6EE280055D837 /* OnboardingEpicGamesWelcomeStepView.swift */, @@ -298,7 +322,6 @@ 6A22B13A2CE6EE280055D837 /* OnboardingEngineTermsStepView.swift */, 6A22B13B2CE6EE280055D837 /* OnboardingEngineConfigStepView.swift */, 6A22B13C2CE6EE280055D837 /* OnboardingEngineInstallationStepView.swift */, - 6A22B13D2CE6EE280055D837 /* OnboardingView.swift */, ); path = Onboarding; sourceTree = ""; @@ -442,6 +465,7 @@ 6A22B13E2CE6EE280055D837 /* Onboarding */, 6A2934D62BFCFAFD0035CE4B /* ContentView.swift */, 6A2C39912CE4EF9600B303AE /* Components */, + 6A541C462CE700CF00AD8A98 /* SparkleUpdater */, 6A2934DA2BFCFAFD0035CE4B /* Navigation */, 6A2934EC2BFCFAFD0035CE4B /* Unified */, 6A2C39932CE4F01C00B303AE /* Legacy */, @@ -483,6 +507,8 @@ isa = PBXGroup; children = ( 6A2C39902CE4EF9600B303AE /* ColorfulBackgroundView.swift */, + 6A541C3B2CE7001000AD8A98 /* BundleIconView.swift */, + 6A541C3D2CE7001300AD8A98 /* RichAlertView.swift */, ); path = Components; sourceTree = ""; @@ -495,6 +521,20 @@ path = Legacy; sourceTree = ""; }; + 6A541C462CE700CF00AD8A98 /* SparkleUpdater */ = { + isa = PBXGroup; + children = ( + 6A541C3F2CE700CF00AD8A98 /* SparkleUpdaterCheckingView.swift */, + 6A541C402CE700CF00AD8A98 /* SparkleUpdaterPreviewView.swift */, + 6A541C412CE700CF00AD8A98 /* SparkleUpdaterDownloadingView.swift */, + 6A541C422CE700CF00AD8A98 /* SparkleUpdaterExtractingView.swift */, + 6A541C432CE700CF00AD8A98 /* SparkleUpdaterInstallingView.swift */, + 6A541C442CE700CF00AD8A98 /* SparkleUpdaterFinishView.swift */, + 6A541C452CE700CF00AD8A98 /* SparkleUpdaterSheetViewModifier.swift */, + ); + path = SparkleUpdater; + sourceTree = ""; + }; 6A91FEC02C2BFB8100D9F153 /* Models */ = { isa = PBXGroup; children = ( @@ -619,6 +659,7 @@ 6A2961042CE1DD6200917E90 /* FirebaseCore */, 6A2961062CE1DD6200917E90 /* FirebaseCrashlytics */, 6A22B1292CE6ECDC0055D837 /* BLAKE3 */, + 6A541C4F2CE7013800AD8A98 /* MarkdownUI */, ); productName = Mythic; productReference = 6AB474952AACBBE900AB9C63 /* Mythic.app */; @@ -660,6 +701,7 @@ 6AA1744D2CD5CC290035B081 /* XCRemoteSwiftPackageReference "WhatsNewKit" */, 6A2961012CE1DD6200917E90 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, 6A22B1282CE6ECDC0055D837 /* XCRemoteSwiftPackageReference "blake3-swift" */, + 6A541C4E2CE7013800AD8A98 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, ); productRefGroup = 6AB474962AACBBE900AB9C63 /* Products */; projectDirPath = ""; @@ -745,6 +787,13 @@ 6A29353A2BFCFAFD0035CE4B /* Task.swift in Sources */, 6A2935592BFCFAFD0035CE4B /* ContainerCreationView.swift in Sources */, 6A2960FE2CE1017900917E90 /* NSApplication.swift in Sources */, + 6A541C472CE700CF00AD8A98 /* SparkleUpdaterSheetViewModifier.swift in Sources */, + 6A541C482CE700CF00AD8A98 /* SparkleUpdaterExtractingView.swift in Sources */, + 6A541C492CE700CF00AD8A98 /* SparkleUpdaterPreviewView.swift in Sources */, + 6A541C4A2CE700CF00AD8A98 /* SparkleUpdaterInstallingView.swift in Sources */, + 6A541C4B2CE700CF00AD8A98 /* SparkleUpdaterCheckingView.swift in Sources */, + 6A541C4C2CE700CF00AD8A98 /* SparkleUpdaterFinishView.swift in Sources */, + 6A541C4D2CE700CF00AD8A98 /* SparkleUpdaterDownloadingView.swift in Sources */, 6A448E102CC4BC55001E9F47 /* GameCardVM.swift in Sources */, 6A2935562BFCFAFD0035CE4B /* GameCard.swift in Sources */, 6A2935622BFCFAFD0035CE4B /* NotImplementedView.swift in Sources */, @@ -793,14 +842,17 @@ 6AF495F42CE4EF1300C251EA /* HubWindowController.swift in Sources */, 6AF495F52CE4EF1300C251EA /* SetupWindowController.swift in Sources */, 6A2935632BFCFAFD0035CE4B /* WebView.swift in Sources */, + 6A541C3E2CE7001300AD8A98 /* RichAlertView.swift in Sources */, 6AAD47152CE6513F00B08564 /* DirectoriesUtility.swift in Sources */, 6A2935582BFCFAFD0035CE4B /* SubscriptedTextView.swift in Sources */, 6A2935542BFCFAFD0035CE4B /* SupportView.swift in Sources */, 6A29354E2BFCFAFD0035CE4B /* ContainersView.swift in Sources */, 6A2935412BFCFAFD0035CE4B /* WineInterface.swift in Sources */, + 6A541C3A2CE6FFD900AD8A98 /* SparkleUpdateControllerModel.swift in Sources */, 6A2935362BFCFAFD0035CE4B /* Color.swift in Sources */, 6A2935512BFCFAFD0035CE4B /* ContentView.swift in Sources */, 6A29353E2BFCFAFD0035CE4B /* LegendaryInterfaceExt.swift in Sources */, + 6A541C3C2CE7001000AD8A98 /* BundleIconView.swift in Sources */, 6A71D3DD2BFD024D00A2C74D /* Auth.swift in Sources */, 6A29354B2BFCFAFD0035CE4B /* LocalImport.swift in Sources */, 6A2960FC2CE0ED0D00917E90 /* EpicWebAuthView.swift in Sources */, @@ -969,7 +1021,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3478; + CURRENT_PROJECT_VERSION = 3480; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Mythic/Preview Content\""; @@ -1017,7 +1069,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3478; + CURRENT_PROJECT_VERSION = 3480; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"Mythic/Preview Content\""; @@ -1118,6 +1170,14 @@ minimumVersion = 0.9.17; }; }; + 6A541C4E2CE7013800AD8A98 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.4.1; + }; + }; 6A7A81142B77093600D19E32 /* XCRemoteSwiftPackageReference "ColorfulX" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Lakr233/ColorfulX"; @@ -1204,6 +1264,11 @@ package = 6A371B572AE7DFBF0054BF7A /* XCRemoteSwiftPackageReference "ZIPFoundation" */; productName = ZIPFoundation; }; + 6A541C4F2CE7013800AD8A98 /* MarkdownUI */ = { + isa = XCSwiftPackageProductDependency; + package = 6A541C4E2CE7013800AD8A98 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; + productName = MarkdownUI; + }; 6A7A81152B77093600D19E32 /* ColorfulX */ = { isa = XCSwiftPackageProductDependency; package = 6A7A81142B77093600D19E32 /* XCRemoteSwiftPackageReference "ColorfulX" */; diff --git a/Mythic/App/AppDelegate.swift b/Mythic/App/AppDelegate.swift index a3e4914f..6f35c19c 100644 --- a/Mythic/App/AppDelegate.swift +++ b/Mythic/App/AppDelegate.swift @@ -8,11 +8,14 @@ import Foundation import AppKit import Combine +import SemanticVersion public class AppDelegate: NSObject, NSApplicationDelegate { public static let shared = AppDelegate() public static let bundleIdentifier = Bundle.main.bundleIdentifier ?? "Mythic" + public static let applicationVersion = SemanticVersion(Bundle.main.infoDictionary?["CFBundleShortVersionString"] + as? String ?? "0.0.0") ?? .init(0, 0, 0) public static let applicationBundleName = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "Application" private let logger = AppLoggerModel(category: AppDelegate.self) @@ -33,9 +36,9 @@ public class AppDelegate: NSObject, NSApplicationDelegate { } /// Listen for events - private func listenEvents() { + @MainActor private func listenEvents() { // Flush old cancellables - let _ = cancellables.map({ $0.cancel() }) + cancellables.forEach({ $0.cancel() }) cancellables.removeAll() // Listen for onboarding state changes diff --git a/Mythic/App/AppMenuController.swift b/Mythic/App/AppMenuController.swift index 66d6bd16..62be1ed8 100644 --- a/Mythic/App/AppMenuController.swift +++ b/Mythic/App/AppMenuController.swift @@ -224,7 +224,8 @@ public class AppMenuController { } @objc private func checkForUpdates() { - // todo + let sparkle = SparkleUpdateControllerModel.shared + sparkle.checkForUpdates(userInitiated: true) } @objc private func restartOnboarding() { diff --git a/Mythic/Localizable.xcstrings b/Mythic/Localizable.xcstrings index 789d70a6..47caeb44 100644 --- a/Mythic/Localizable.xcstrings +++ b/Mythic/Localizable.xcstrings @@ -9234,6 +9234,9 @@ } } } + }, + "common.cancel" : { + }, "common.finish" : { @@ -27193,6 +27196,9 @@ }, "onboardingEpicGamesLoginStepView.description" : { + }, + "onboardingEpicGamesLoginStepView.epicGamesSignin" : { + }, "onboardingEpicGamesLoginStepView.failure.invalidCode" : { @@ -27202,6 +27208,9 @@ }, "onboardingEpicGamesLoginStepView.failure.unknownError" : { + }, + "onboardingEpicGamesLoginStepView.loading" : { + }, "onboardingEpicGamesLoginStepView.title" : { @@ -34474,6 +34483,75 @@ } } } + }, + "sparkleUpdaterCheckingView.description" : { + + }, + "sparkleUpdaterCheckingView.title" : { + + }, + "sparkleUpdaterDownloadingView.bytesDownloaded" : { + + }, + "sparkleUpdaterDownloadingView.bytesOfBytes" : { + + }, + "sparkleUpdaterDownloadingView.bytesOfBytesEta" : { + + }, + "sparkleUpdaterDownloadingView.description" : { + + }, + "sparkleUpdaterDownloadingView.title" : { + + }, + "sparkleUpdaterExtractingView.description" : { + + }, + "sparkleUpdaterExtractingView.title" : { + + }, + "sparkleUpdaterFinishView.description" : { + + }, + "sparkleUpdaterFinishView.relaunchNow" : { + + }, + "sparkleUpdaterFinishView.title" : { + + }, + "sparkleUpdaterFinishView.updateOnClose" : { + + }, + "sparkleUpdaterInstallingView.description" : { + + }, + "sparkleUpdaterInstallingView.title" : { + + }, + "sparkleUpdaterPreviewView.description" : { + + }, + "sparkleUpdaterPreviewView.dismiss" : { + + }, + "sparkleUpdaterPreviewView.noReleaseNotes" : { + + }, + "sparkleUpdaterPreviewView.releaseNotes" : { + + }, + "sparkleUpdaterPreviewView.update" : { + + }, + "sparkleUpdaterSheetViewModifier.error.title" : { + + }, + "sparkleUpdaterSheetViewModifier.noUpdateAvailable.description" : { + + }, + "sparkleUpdaterSheetViewModifier.noUpdateAvailable.title" : { + }, "Stable" : { "comment" : "Within the context of Mythic Engine", diff --git a/Mythic/Models/AppLoggerModel.swift b/Mythic/Models/AppLoggerModel.swift index e925236d..5243e8f9 100644 --- a/Mythic/Models/AppLoggerModel.swift +++ b/Mythic/Models/AppLoggerModel.swift @@ -37,7 +37,7 @@ public struct AppLoggerModel { #if DEBUG private func log(_ message: String, level: LogLevel) { - if level.rawValue <= Self.logLevel.rawValue { return } + if level.rawValue < Self.logLevel.rawValue { return } switch level { case .debug: print("\u{1b}[0;1;34m[🐞DEBUG \(self.category)]\u{1b}[0;34m \(message)\u{1b}[0") diff --git a/Mythic/Models/PersistentState/AppSettingsPersistentStateModel.swift b/Mythic/Models/PersistentState/AppSettingsPersistentStateModel.swift index 1e08b3aa..654a3c3f 100644 --- a/Mythic/Models/PersistentState/AppSettingsPersistentStateModel.swift +++ b/Mythic/Models/PersistentState/AppSettingsPersistentStateModel.swift @@ -9,11 +9,11 @@ import Foundation public struct AppSettingsPersistentStateModel: StorablePersistentStateModel.State { /// Shared instance. - public static let shared: StorablePersistentStateModel.Store = .init() - + @MainActor public static let shared: StorablePersistentStateModel.Store = .init() + public typealias RootType = AppSettings - public static var persistentStateStoreName: String = "AppSettings" - + public static let persistentStateStoreName = "AppSettings" + public static func defaultValue() -> AppSettings { .init() } diff --git a/Mythic/Models/SparkleUpdateControllerModel.swift b/Mythic/Models/SparkleUpdateControllerModel.swift new file mode 100644 index 00000000..45578677 --- /dev/null +++ b/Mythic/Models/SparkleUpdateControllerModel.swift @@ -0,0 +1,305 @@ +// +// SparkleUpdateControllerModel.swift +// Mythic +// + +import Foundation +import Sparkle + +public class SparkleUpdateControllerModel: NSObject, SPUUserDriver, ObservableObject { + /// The shared instance of the Sparkle updater events. + public static let shared = SparkleUpdateControllerModel() + + private let logger = AppLoggerModel(category: SparkleUpdateControllerModel.self) + + /// The Sparkle updater. + private var sparkleUpdater: SPUUpdater? + + /// Choice to update. + public enum UpdateChoice { + case update + case dismiss + } + + /// Download progress. + public struct DownloadProgress { + public let started: Date + public var total: UInt64 + public var completed: UInt64 + } + + /// Extract progress. + public struct ExtractProgress { + public let started: Date + public var progress: Double + } + + /// The state of an sparkle update. + public enum UpdateState { + public var stateType: Int { + switch self { + case .idle: return 0 + case .checkingForUpdates: return 1 + case .updateAvailable: return 2 + case .noUpdateAvailable: return 3 + case .initializingUpdate: return 4 + case .downloadingUpdate: return 5 + case .extractingUpdate: return 6 + case .readyToRelaunch: return 7 + case .installingUpdate: return 8 + case .error: return 9 + } + } + + case idle + case checkingForUpdates(cancel: () -> Void) + case updateAvailable(choice: (UpdateChoice) -> Void, appcast: SUAppcastItem) + case noUpdateAvailable(acknowledge: () -> Void) + case initializingUpdate + case downloadingUpdate(cancel: () -> Void, progress: DownloadProgress) + case extractingUpdate(progress: ExtractProgress) + case readyToRelaunch(acknowledge: (UpdateChoice) -> Void) + case installingUpdate + case error(acknowledge: () -> Void, error: Error) + } + + /// The current state of the update. + @Published public private(set) var state: UpdateState = .idle + /// If the check was initiated by the user. + @Published public private(set) var userInitiatedCheck: Bool = false + + /// Initialize the Sparkle updater. + public override init() { + super.init() + + let updaterController = SPUUpdater( + hostBundle: Bundle.main, + applicationBundle: Bundle.main, + userDriver: self, + delegate: nil + ) + self.sparkleUpdater = updaterController + + updaterController.automaticallyChecksForUpdates = false + updaterController.automaticallyDownloadsUpdates = false + do { try updaterController.start() } catch { + logger.error("Sparkle failed to start: \(error.localizedDescription).") + } + } + + /// Clear any existing update state. + public func clearState() -> Bool { + switch state { + case .idle: + return false + case .checkingForUpdates(let cancel): + cancel() + case .updateAvailable(let choice, _): + choice(.dismiss) + case .noUpdateAvailable(let acknowledge): + acknowledge() + case .downloadingUpdate(let cancel, _): + cancel() + case .extractingUpdate(_): + return false + case .initializingUpdate: + return false + case .readyToRelaunch(let acknowledge): + acknowledge(.dismiss) + case .installingUpdate: + return false + case .error(let acknowledge, _): + acknowledge() + } + + return true + } + + /// Force clear the current state. + public func forceClearState() { + state = .idle + } + + /// Check for updates + /// - Parameter userInitiated: If the check was initiated by the user. + public func checkForUpdates(userInitiated: Bool = false) { + switch state { + case .idle, .noUpdateAvailable(_), .error(_, _): + logger.info("\(userInitiated ? "User-initiated" : "Automatic") update check initiated...") + _ = clearState() + userInitiatedCheck = userInitiated + sparkleUpdater?.checkForUpdates() + default: + logger.info("\(userInitiated ? "User-initiated" : "Automatic") update check ignored due to in-progress update session.") + if userInitiated { + userInitiatedCheck = true + } + } + } + + /// Initialize the settings for the updater. + /// Implementation of `SPUUserDriver` protocol. + public func show(_ request: SPUUpdatePermissionRequest) async -> SUUpdatePermissionResponse { + logger.debug("Update permission request received.") + return .init( + automaticUpdateChecks: false, + sendSystemProfile: false + ) + } + + /// A checking for updates event initiated by the user. + /// Implementation of `SPUUserDriver` protocol. + public func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { + logger.debug("User-initiated update check initiated.") + state = .checkingForUpdates { + cancellation() + } + } + + /// An update is available. + /// Implementation of `SPUUserDriver` protocol. + public func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, + reply: @escaping (SPUUserUpdateChoice) -> Void) { + logger.debug("Update found: \(appcastItem.displayVersionString.isEmpty ? "unknown" : appcastItem.displayVersionString).") + self.state = .updateAvailable(choice: { choice in + switch choice { + case .update: + reply(.install) + self.state = .initializingUpdate + case .dismiss: + reply(.dismiss) + self.state = .idle + } + }, appcast: appcastItem) + } + + /// Never needed. + /// Implementation of `SPUUserDriver` protocol. + public func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { + logger.debug("Release notes received.") + } + + /// Release notes failed to load. + /// Implementation of `SPUUserDriver` protocol. + public func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) { + logger.error("Failed to download release notes: \(error.localizedDescription).") + + state = .error(acknowledge: { + self.state = .idle + }, error: error) + } + + /// No update available. + /// Implementation of `SPUUserDriver` protocol. + public func showUpdateNotFoundWithError(_ error: Error, acknowledgement: @escaping () -> Void) { + logger.debug("No update available.") + state = .noUpdateAvailable(acknowledge: { + acknowledgement() + self.state = .idle + }) + } + + /// An error occurred. + /// Implementation of `SPUUserDriver` protocol. + public func showUpdaterError(_ error: Error, acknowledgement: @escaping () -> Void) { + logger.error("Updater error: \(error.localizedDescription).") + state = .error(acknowledge: { + acknowledgement() + self.state = .idle + }, error: error) + } + + /// An update is downloading. + /// Implementation of `SPUUserDriver` protocol. + public func showDownloadInitiated(cancellation: @escaping () -> Void) { + logger.debug("Update download initiated.") + state = .downloadingUpdate(cancel: { + cancellation() + self.state = .idle + }, progress: .init(started: .init(), total: 0, completed: 0)) + } + + /// An update is downloading. + /// Implementation of `SPUUserDriver` protocol. + public func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { + guard case .downloadingUpdate(let cancel, var progress) = state else { + return + } + + progress.total = expectedContentLength + state = .downloadingUpdate(cancel: cancel, progress: progress) + } + + /// An update is downloading. + /// Implementation of `SPUUserDriver` protocol. + public func showDownloadDidReceiveData(ofLength length: UInt64) { + guard case .downloadingUpdate(let cancel, var progress) = state else { + return + } + + progress.completed += length + state = .downloadingUpdate(cancel: cancel, progress: progress) + } + + /// An update is extracting. + /// Implementation of `SPUUserDriver` protocol. + public func showDownloadDidStartExtractingUpdate() { + logger.debug("Update download complete; extracting...") + state = .extractingUpdate(progress: .init(started: .init(), progress: 0)) + } + + /// An update is extracting. + /// Implementation of `SPUUserDriver` protocol. + public func showExtractionReceivedProgress(_ progress: Double) { + guard case .extractingUpdate(let currentProgress) = state else { + return + } + + state = .extractingUpdate(progress: .init(started: currentProgress.started, progress: progress)) + } + + /// An update is ready to install. + /// Implementation of `SPUUserDriver` protocol. + public func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) { + logger.debug("Update ready to install.") + state = .readyToRelaunch { choice in + switch choice { + case .update: + reply(.install) + self.state = .installingUpdate + case .dismiss: + reply(.dismiss) + self.state = .idle + } + } + } + + /// An update is installing. + /// Implementation of `SPUUserDriver` protocol. + public func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, + retryTerminatingApplication: @escaping () -> Void) { + logger.debug("Update installing...") + state = .installingUpdate + } + + /// Never needed. + /// Implementation of `SPUUserDriver` protocol. + public func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { + acknowledgement() + } + + /// Focus the updater. + /// Implementation of `SPUUserDriver` protocol. + public func showUpdateInFocus() {} + + /// Dismiss the updater. + /// Implementation of `SPUUserDriver` protocol. + public func dismissUpdateInstallation() { + if case .checkingForUpdates(_) = state { + // No updates were found. + state = .noUpdateAvailable { + self.state = .idle + } + } + } +} diff --git a/Mythic/Views/Components/BundleIconView.swift b/Mythic/Views/Components/BundleIconView.swift new file mode 100644 index 00000000..216f725a --- /dev/null +++ b/Mythic/Views/Components/BundleIconView.swift @@ -0,0 +1,31 @@ +// +// BundleIconView.swift +// Mythic +// + +import SwiftUI + +public struct BundleIconView: View { + private var appIconName: String? { + guard let icon = Bundle.main.object(forInfoDictionaryKey: "CFBundleIconName") as? String else { return nil } + + return icon + } + + public var body: some View { + if let appIconName = appIconName, + let icon = NSImage(named: appIconName) { + Image(nsImage: icon) + .resizable() + .scaledToFit() + } else { + Image(systemName: "app.dashed") + .resizable() + .scaledToFit() + } + } +} + +#Preview { + BundleIconView() +} diff --git a/Mythic/Views/Components/ColorfulBackgroundView.swift b/Mythic/Views/Components/ColorfulBackgroundView.swift index 9bc2e696..be685686 100644 --- a/Mythic/Views/Components/ColorfulBackgroundView.swift +++ b/Mythic/Views/Components/ColorfulBackgroundView.swift @@ -19,6 +19,7 @@ struct ColorfulBackgroundView: View { var body: some View { ColorfulView(color: .constant(animationColors), speed: .constant(animationSpeed), noise: .constant(animationNoise)) + .overlay(Color.black.opacity(0.2)) .ignoresSafeArea() } } diff --git a/Mythic/Views/Components/RichAlertView.swift b/Mythic/Views/Components/RichAlertView.swift new file mode 100644 index 00000000..fc85e490 --- /dev/null +++ b/Mythic/Views/Components/RichAlertView.swift @@ -0,0 +1,74 @@ +// +// RichAlertView.swift +// Mythic +// + +import SwiftUI + +public struct RichAlertView< + TitleContent: View, + MessageContent: View, + Content: View, + ButtonsLeadingContent: View, + ButtonTrailingContent: View +>: View { + public let title: (() -> TitleContent) + public let message: (() -> MessageContent) + public let content: (() -> Content) + public let buttonsLeading: (() -> ButtonsLeadingContent) + public let buttonsTrailing: (() -> ButtonTrailingContent) + + public init( + title: @escaping () -> TitleContent, + message: @escaping (() -> MessageContent) = EmptyView.init, + content: @escaping (() -> Content) = EmptyView.init, + buttonsLeft: @escaping (() -> ButtonsLeadingContent) = EmptyView.init, + buttonsRight: @escaping (() -> ButtonTrailingContent) = EmptyView.init + ) { + self.title = title + self.message = message + self.content = content + self.buttonsLeading = buttonsLeft + self.buttonsTrailing = buttonsRight + } + + public var body: some View { + HStack(alignment: .top, spacing: 16) { + BundleIconView() + .frame(width: 56, height: 56) + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + title().bold() + if MessageContent.self != EmptyView.self { + message().foregroundStyle(.secondary) + } + } + if Content.self != EmptyView.self { + content() + } + HStack(spacing: 8) { + if ButtonsLeadingContent.self != EmptyView.self { + buttonsLeading() + } + Spacer() + if ButtonTrailingContent.self != EmptyView.self { + buttonsTrailing() + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .padding(20) + .frame(width: 448) + } +} + +#Preview { + RichAlertView( + title: { Text("Title") }, + message: { Text("Message") }, + content: { Text("Content") }, + buttonsLeft: { Button("Left") {} }, + buttonsRight: { Button("Right") {} } + ) +} diff --git a/Mythic/Views/ContentView.swift b/Mythic/Views/ContentView.swift index 0b490d02..ce3f01c1 100644 --- a/Mythic/Views/ContentView.swift +++ b/Mythic/Views/ContentView.swift @@ -118,6 +118,9 @@ struct ContentView: View { HomeView() } ) + .modifier(SparkleUpdaterSheetViewModifier()) + .frame(minWidth: 768, idealWidth: 896, minHeight: 384, idealHeight: 512) + .whatsNewSheet() .toolbar { /* diff --git a/Mythic/Views/Onboarding/OnboardingEpicGamesLoginStepView.swift b/Mythic/Views/Onboarding/OnboardingEpicGamesLoginStepView.swift index c84a60c5..72d7b149 100644 --- a/Mythic/Views/Onboarding/OnboardingEpicGamesLoginStepView.swift +++ b/Mythic/Views/Onboarding/OnboardingEpicGamesLoginStepView.swift @@ -4,6 +4,7 @@ // import SwiftUI +@preconcurrency import WebKit public struct OnboardingEpicGamesLoginStepView: View { @Binding var canGoNext: Bool @@ -11,8 +12,10 @@ public struct OnboardingEpicGamesLoginStepView: View { @Binding var currentStep: OnboardingView.Step var goNext: () -> Void + @State private var epicLoginPresented = false @State private var authorizationCode: String = "" @State private var signInState: SignInState = .none + @State private var webviewLoading = false @State private var errorShown = false @Environment(\.openURL) var openURL @@ -25,6 +28,149 @@ public struct OnboardingEpicGamesLoginStepView: View { case failure(String) } + private class SignInCordinator: NSObject, WKNavigationDelegate { + private let onAuthorizationCode: (String?) -> Void + private let changeLoadingState: (Bool) -> Void + + init(onAuthorizationCode: @escaping (String?) -> Void, changeLoadingState: @escaping (Bool) -> Void) { + self.onAuthorizationCode = onAuthorizationCode + self.changeLoadingState = changeLoadingState + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + changeLoadingState(true) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation) { + changeLoadingState(false) + } + + private func handlePageNavigation( + with webView: WKWebView, policy: WKNavigationAction, action: @escaping (Bool) -> Void + ) { + guard let url = policy.request.url else { + action(false) + return + } + + let pathComponents = url.pathComponents.filter { !$0.isEmpty } + let expectedPathComponents = ["/", "id", "api", "redirect"] + + var pathMatches = true + if pathComponents.count != expectedPathComponents.count { + pathMatches = false + } + for (index, component) in pathComponents.enumerated() { + if component != expectedPathComponents[index] { + pathMatches = false + break + } + } + + if url.host == "www.epicgames.com" && pathMatches { + struct JSONData: Decodable { + let authorizationCode: String + } + + URLSession.shared.dataTask(with: url) { data, response, error in + guard let data = data, let string = String(data: data, encoding: .utf8) else { + action(false) + return + } + + let decoder = JSONDecoder() + guard let jsonData = string.data(using: .utf8), + let json = try? decoder.decode(JSONData.self, from: jsonData) else { + action(false) + return + } + + self.onAuthorizationCode(json.authorizationCode) + action(true) + }.resume() + return + } + + action(true) + } + + #if compiler(>=6) + public func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @MainActor @escaping (WKNavigationActionPolicy) -> Void + ) { + handlePageNavigation( + with: webView, policy: navigationAction, + action: { result in + decisionHandler(result ? .allow : .cancel) + }) + } + #else + public func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + + handlePageNavigation( + with: webView, policy: navigationAction, + action: { result in + decisionHandler(result ? .allow : .cancel) + }) + } + #endif + } + + private struct SignInView: NSViewRepresentable { + private static let epicGamesRedirectURL = URL(string: "https://legendary.gl/epiclogin") + + private let coordinator: SignInCordinator + + init(onAuthorizationCode: @escaping (String?) -> Void, changeLoadingState: @escaping (Bool) -> Void) { + self.coordinator = SignInCordinator(onAuthorizationCode: onAuthorizationCode, changeLoadingState: changeLoadingState) + } + + func makeNSView(context: Context) -> WKWebView { + let scriptData = """ + (function() { + // Disable zooming + const meta = document.createElement('meta'); + meta.name = 'viewport'; + meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'; + document.getElementsByTagName('head')[0].appendChild(meta); + })(); + """ + let script = WKUserScript(source: scriptData, injectionTime: .atDocumentEnd, forMainFrameOnly: true) + + let config = WKWebViewConfiguration() + config.websiteDataStore = .nonPersistent() + config.userContentController.addUserScript(script) + + let webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = context.coordinator + + let userContentController = WKUserContentController() + userContentController.addUserScript(script) + + webView.configuration.userContentController = userContentController + webView.configuration.websiteDataStore = .nonPersistent() + + guard let url = Self.epicGamesRedirectURL else { return webView } + webView.load(URLRequest(url: url)) + + return webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) { + nsView.navigationDelegate = context.coordinator + } + + func makeCoordinator() -> SignInCordinator { + coordinator + } + } + public var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { @@ -35,13 +181,73 @@ public struct OnboardingEpicGamesLoginStepView: View { .tint(.secondary) } VStack { - Image(systemName: "person.badge.plus") - .resizable() - .scaledToFit() - .frame(width: 96) + if currentStep == .epicGamesSigningIn { + HStack(alignment: .center, spacing: 8) { + ProgressView() + .controlSize(.small) + Text("onboardingEpicGamesLoginStepView.loading") + } + } else { + Image(systemName: "person.badge.plus") + .resizable() + .scaledToFit() + .frame(width: 96) + } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } + .sheet(isPresented: $epicLoginPresented) { + VStack { + HStack { + HStack { + Button("common.cancel") { + currentStep = .epicGamesTerms + } + .buttonStyle(.link) + } + .frame(maxWidth: .infinity, alignment: .leading) + HStack(alignment: .center, spacing: 4) { + if webviewLoading { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + } + Text("onboardingEpicGamesLoginStepView.epicGamesSignin") + .foregroundStyle(.secondary) + .bold() + .multilineTextAlignment(.center) + .lineLimit(1) + .allowsTightening(false) + } + .frame(maxWidth: .infinity, alignment: .center) + Spacer() + .frame(maxWidth: .infinity) + } + .padding(12) + .padding(.bottom, -8) + SignInView(onAuthorizationCode: { code in + if let code = code { + withAnimation { + authorizationCode = code + currentStep = .epicGamesSigningIn + webviewLoading = false + } + } else { + withAnimation { + signInState = .loginFailure + errorShown = true + } + } + }, changeLoadingState: { isLoading in + withAnimation { + webviewLoading = isLoading + } + }) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(width: 640, height: 640) + .interactiveDismissDisabled() + } .alert("onboardingEpicGamesLoginStepView.failure.title", isPresented: $errorShown, actions: { Button("common.okay", role: .cancel) {} }, message: { @@ -55,27 +261,49 @@ public struct OnboardingEpicGamesLoginStepView: View { .multilineTextAlignment(.leading) .onAppear { withAnimation { - canGoNext = false + signInState = .none + canGoNext = true canGoSkip = true + errorShown = false + epicLoginPresented = false } } .onChange(of: errorShown) { if !errorShown { withAnimation { - canGoNext = !authorizationCode.isEmpty canGoSkip = true signInState = .none - currentStep = .epicGamesSignIn + currentStep = .epicGamesTerms } } } .onChange(of: currentStep) { - if currentStep == .epicGamesSigningIn { + switch currentStep { + case .epicGamesTerms: + withAnimation { + signInState = .none + canGoNext = true + canGoSkip = true + errorShown = false + epicLoginPresented = false + } + case .epicGamesSignIn: + withAnimation { + signInState = .none + canGoNext = false + canGoSkip = false + errorShown = false + epicLoginPresented = true + webviewLoading = false + } + case .epicGamesSigningIn: Task(priority: .userInitiated) { withAnimation { signInState = .loading canGoNext = false canGoSkip = false + epicLoginPresented = false + errorShown = false } var signInSuccess: Bool = false @@ -101,6 +329,7 @@ public struct OnboardingEpicGamesLoginStepView: View { } } } + default: () } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) diff --git a/Mythic/Views/Onboarding/OnboardingView.swift b/Mythic/Views/Onboarding/OnboardingView.swift index 79a93b3a..7e288a09 100644 --- a/Mythic/Views/Onboarding/OnboardingView.swift +++ b/Mythic/Views/Onboarding/OnboardingView.swift @@ -6,17 +6,20 @@ import SwiftUI struct OnboardingView: View { + private let logger = AppLoggerModel(category: Self.self) + @State private var currentStep: Step = .intro @State private var completedSteps: Set = [] @State private var skippedSteps: Set = [] @State private var canGoNext: Bool = false @State private var canGoSkip: Bool = false - + @ObservedObject private var appSettings = AppSettingsPersistentStateModel.shared - + public enum Step: Int, Identifiable { var id: Int { self.rawValue } case intro + case epicGamesTerms case epicGamesSignIn case epicGamesSigningIn case epicGamesWelcome @@ -30,6 +33,7 @@ struct OnboardingView: View { static let stepsOrder: [Step] = [ .intro, + .epicGamesTerms, .epicGamesSignIn, .epicGamesSigningIn, .epicGamesWelcome, @@ -41,24 +45,25 @@ struct OnboardingView: View { .engineContainer, .outro ] - + var textContent: LocalizedStringResource { switch self.equivalentStep { case .intro: "onboardingView.steps.welcome" - case .epicGamesSignIn: "onboardingView.steps.epicGameSetup" + case .epicGamesTerms: "onboardingView.steps.epicGameSetup" case .rosettaTerms: "onboardingView.steps.rosettaSetup" case .engineTerms: "onboardingView.steps.engineSetup" case .outro: "onboardingView.steps.finish" default: "common.unknown" } } - + var equivalentStep: Step { switch self { case .intro: .intro - case .epicGamesSignIn: .epicGamesSignIn - case .epicGamesSigningIn: .epicGamesSignIn - case .epicGamesWelcome: .epicGamesSignIn + case .epicGamesTerms: .epicGamesTerms + case .epicGamesSignIn: .epicGamesTerms + case .epicGamesSigningIn: .epicGamesTerms + case .epicGamesWelcome: .epicGamesTerms case .rosettaTerms: .rosettaTerms case .rosettaInstallation: .rosettaTerms case .engineTerms: .engineTerms @@ -68,7 +73,7 @@ struct OnboardingView: View { case .outro: .outro } } - + var nextStep: Step? { guard let index = Self.stepsOrder.firstIndex(of: self) else { return nil } return index + 1 < Self.stepsOrder.count ? Self.stepsOrder[index + 1] : nil @@ -83,12 +88,12 @@ struct OnboardingView: View { return step } } - - private let stepsDisplayOrder: [Step] = [.intro, .epicGamesSignIn, .rosettaTerms, .engineTerms, .outro] + + private let stepsDisplayOrder: [Step] = [.intro, .epicGamesTerms, .rosettaTerms, .engineTerms, .outro] private let skipMap: [Step: [Step]] = [ .rosettaTerms: [.engineTerms] ] - + private struct StepView: View { var step: Step @Binding var currentStep: Step @@ -112,7 +117,7 @@ struct OnboardingView: View { .scaleEffect(currentStep.equivalentStep == step ? 1 : 0.8, anchor: .leading) } } - + private static var moveAndFadeNext: AnyTransition { AnyTransition.asymmetric( insertion: .offset(x: 32).combined(with: .opacity), @@ -144,7 +149,7 @@ struct OnboardingView: View { case .intro: OnboardingIntroStepView(canGoNext: $canGoNext, canGoSkip: $canGoSkip) .transition(Self.moveAndFadeNext) - case .epicGamesSignIn, .epicGamesSigningIn: + case .epicGamesTerms, .epicGamesSignIn, .epicGamesSigningIn: OnboardingEpicGamesLoginStepView(canGoNext: $canGoNext, canGoSkip: $canGoSkip, currentStep: $currentStep, goNext: { goNext() }) @@ -240,10 +245,11 @@ struct OnboardingView: View { return skipStep } - + private func goNext() { guard let nextStep = getNextStep() else { return } currentStep = nextStep + logger.debug("Navigating to \(nextStep.rawValue).") } private func goSkip() { @@ -254,6 +260,7 @@ struct OnboardingView: View { guard let skipStep = getSkipStep() else { return } currentStep = skipStep + logger.debug("Navigating to \(skipStep.rawValue).") } } diff --git a/Mythic/Views/SparkleUpdater/SparkleUpdaterCheckingView.swift b/Mythic/Views/SparkleUpdater/SparkleUpdaterCheckingView.swift new file mode 100644 index 00000000..8927fe32 --- /dev/null +++ b/Mythic/Views/SparkleUpdater/SparkleUpdaterCheckingView.swift @@ -0,0 +1,34 @@ +// +// SparkleUpdaterCheckingView.swift +// Mythic +// + +import SwiftUI + +public struct SparkleUpdaterCheckingView: View { + public let cancel: () -> Void + + public var body: some View { + RichAlertView( + title: { + Text("sparkleUpdaterCheckingView.title") + }, + message: { + Text("sparkleUpdaterCheckingView.description") + }, + content: { + ProgressView() + .progressViewStyle(.linear) + }, + buttonsRight: { + Button("common.cancel") { + cancel() + } + } + ) + } +} + +#Preview { + SparkleUpdaterCheckingView(cancel: {}) +} diff --git a/Mythic/Views/SparkleUpdater/SparkleUpdaterDownloadingView.swift b/Mythic/Views/SparkleUpdater/SparkleUpdaterDownloadingView.swift new file mode 100644 index 00000000..6d097c29 --- /dev/null +++ b/Mythic/Views/SparkleUpdater/SparkleUpdaterDownloadingView.swift @@ -0,0 +1,162 @@ +// +// SparkleUpdaterDownloadingView.swift +// Mythic +// + +import SwiftUI + +public struct SparkleUpdaterDownloadingView: View { + public let cancel: () -> Void + public let downloadStartTimestamp: Date + public var bytesDownloaded: UInt64 + public var bytesTotal: UInt64 + + @State private var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + @State private var timeRemaining: Double? + @State private var downloaded: UInt64 = 0 + + public init(cancel: @escaping () -> Void, downloadStartTimestamp: Date, bytesDownloaded: UInt64, bytesTotal: UInt64) { + self.cancel = cancel + self.downloadStartTimestamp = downloadStartTimestamp + self.bytesDownloaded = bytesDownloaded + self.bytesTotal = bytesTotal + } + + public var body: some View { + // HStack(alignment: .top, spacing: 16) { + // BundleIconView() + // .frame(width: 64, height: 64) + // VStack(alignment: .leading, spacing: 8) { + // VStack(alignment: .leading, spacing: 4) { + // Text("sparkleUpdaterDownloadingView.title") + // .bold() + // Text("sparkleUpdaterDownloadingView.description") + // .foregroundStyle(.secondary) + // } + // VStack(alignment: .leading, spacing: 2) { + // if bytesTotal != 0 { + // ProgressView(value: Double(bytesDownloaded), total: Double(bytesTotal)) + // .progressViewStyle(.linear) + // if let timeRemaining = timeRemaining { + // Text(String(format: String(localized: "sparkleUpdaterDownloadingView.bytesOfBytesEta"), + // formatBytes(downloaded), + // formatBytes(bytesTotal), + // formatTimeRemaining(timeRemaining))) + // } else { + // Text(String(format: String(localized: "sparkleUpdaterDownloadingView.bytesOfBytes"), + // formatBytes(downloaded), + // formatBytes(bytesTotal))) + // } + // } else { + // ProgressView() + // .progressViewStyle(.linear) + // Text(String(format: String(localized: "sparkleUpdaterDownloadingView.bytesDownloaded"), + // formatBytes(downloaded))) + // } + // } + // HStack(spacing: 8) { + // Spacer() + // Button("common.cancel") { + // cancel() + // } + // } + // } + // .frame(maxWidth: .infinity, maxHeight: .infinity) + // } + // .padding(20) + // .frame(width: 512) + // .onReceive(timer) { _ in + // withAnimation { + // downloaded = UInt64(bytesDownloaded) + + // let timeElapsed = Date().timeIntervalSince(downloadStartTimestamp) + + // // This could be more accurate if we used a window, but this is good enough + // if timeElapsed > 6 && bytesDownloaded > 0 { + // let bytesRemaining = Double(bytesTotal - bytesDownloaded) + // let bytesPerSecond = Double(bytesDownloaded) / timeElapsed + // timeRemaining = bytesRemaining / bytesPerSecond + // } else { + // timeRemaining = nil + // } + // } + // } + + RichAlertView( + title: { + Text("sparkleUpdaterDownloadingView.title") + }, + message: { + Text("sparkleUpdaterDownloadingView.description") + }, + content: { + VStack(alignment: .leading, spacing: 2) { + if bytesTotal != 0 { + ProgressView(value: Double(bytesDownloaded), total: Double(bytesTotal)) + .progressViewStyle(.linear) + if let timeRemaining = timeRemaining { + Text(String(format: String(localized: "sparkleUpdaterDownloadingView.bytesOfBytesEta"), + formatBytes(downloaded), + formatBytes(bytesTotal), + formatTimeRemaining(timeRemaining))) + .foregroundStyle(.secondary) + .font(.system(.caption, design: .monospaced)) + } else { + Text(String(format: String(localized: "sparkleUpdaterDownloadingView.bytesOfBytes"), + formatBytes(downloaded), + formatBytes(bytesTotal))) + .foregroundStyle(.secondary) + .font(.system(.caption, design: .monospaced)) + } + } else { + ProgressView() + .progressViewStyle(.linear) + Text(String(format: String(localized: "sparkleUpdaterDownloadingView.bytesDownloaded"), + formatBytes(downloaded))) + .foregroundStyle(.secondary) + .font(.system(.caption, design: .monospaced)) + } + } + }, + buttonsRight: { + Button("common.cancel") { + cancel() + } + } + ) + .onReceive(timer) { _ in + withAnimation { + downloaded = bytesDownloaded + + let timeElapsed = Date().timeIntervalSince(downloadStartTimestamp) + + // This could be more accurate if we used a window, but this is good enough + if timeElapsed > 6 && bytesDownloaded > 0 { + let bytesRemaining = Double(bytesTotal - bytesDownloaded) + let bytesPerSecond = Double(bytesDownloaded) / timeElapsed + timeRemaining = bytesRemaining / bytesPerSecond + } else { + timeRemaining = nil + } + } + } + } + + private func formatTimeRemaining(_ time: Double) -> String { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .abbreviated + formatter.allowedUnits = [.hour, .minute, .second] + return formatter.string(from: time) ?? "" + } + + private func formatBytes(_ bytes: UInt64) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.zeroPadsFractionDigits = true + return formatter.string(fromByteCount: Int64(bytes)) + } +} + +#Preview { + SparkleUpdaterDownloadingView(cancel: {}, downloadStartTimestamp: Date(), bytesDownloaded: 0, bytesTotal: 0) +} diff --git a/Mythic/Views/SparkleUpdater/SparkleUpdaterExtractingView.swift b/Mythic/Views/SparkleUpdater/SparkleUpdaterExtractingView.swift new file mode 100644 index 00000000..f5560724 --- /dev/null +++ b/Mythic/Views/SparkleUpdater/SparkleUpdaterExtractingView.swift @@ -0,0 +1,49 @@ +// +// SparkleUpdaterExtractingView.swift +// Mythic +// + +import SwiftUI + +public struct SparkleUpdaterExtractingView: View { + public var progress: Double + + @State private var timeRemaining: Double? + @State private var downloaded: Double = 0 + + public var body: some View { + // HStack(alignment: .top, spacing: 16) { + // BundleIconView() + // .frame(width: 64, height: 64) + // VStack(alignment: .leading, spacing: 8) { + // VStack(alignment: .leading, spacing: 4) { + // Text("sparkleUpdaterExtractingView.title") + // .bold() + // Text("sparkleUpdaterExtractingView.description") + // .foregroundStyle(.secondary) + // } + // ProgressView(value: progress, total: 100) + // .progressViewStyle(.linear) + // } + // .frame(maxWidth: .infinity, maxHeight: .infinity) + // } + // .padding(20) + // .frame(width: 512) + RichAlertView( + title: { + Text("sparkleUpdaterExtractingView.title") + }, + message: { + Text("sparkleUpdaterExtractingView.description") + }, + content: { + ProgressView(value: progress * 1000, total: 1000) + .progressViewStyle(.linear) + } + ) + } +} + +#Preview { + SparkleUpdaterExtractingView(progress: 0.5) +} diff --git a/Mythic/Views/SparkleUpdater/SparkleUpdaterFinishView.swift b/Mythic/Views/SparkleUpdater/SparkleUpdaterFinishView.swift new file mode 100644 index 00000000..5aa4980e --- /dev/null +++ b/Mythic/Views/SparkleUpdater/SparkleUpdaterFinishView.swift @@ -0,0 +1,61 @@ +// +// SparkleUpdaterFinishView.swift +// Mythic +// + +import SwiftUI + +public struct SparkleUpdaterFinishView: View { + public let dismiss: (Bool) -> () + + public var body: some View { + // HStack(alignment: .top, spacing: 16) { + // BundleIconView() + // .frame(width: 64, height: 64) + // VStack(alignment: .leading, spacing: 8) { + // VStack(alignment: .leading, spacing: 4) { + // Text("sparkleUpdaterFinishView.title") + // .bold() + // Text("sparkleUpdaterFinishView.description") + // .foregroundStyle(.secondary) + // } + // HStack(spacing: 8) { + // Spacer() + // Button("sparkleUpdaterFinishView.updateOnClose") { + // dismiss(false) + // } + // Button("sparkleUpdaterFinishView.relaunchNow") { + // dismiss(true) + // } + // .buttonStyle(.borderedProminent) + // } + // } + // .frame(maxWidth: .infinity, maxHeight: .infinity) + // } + // .padding(20) + // .frame(width: 512) + RichAlertView( + title: { + Text("sparkleUpdaterFinishView.title") + }, + message: { + Text("sparkleUpdaterFinishView.description") + }, + buttonsRight: { + HStack(spacing: 8) { + Button("sparkleUpdaterFinishView.updateOnClose") { + dismiss(false) + } + Button("sparkleUpdaterFinishView.relaunchNow") { + dismiss(true) + } + .buttonStyle(.borderedProminent) + } + } + ) + } +} + +#Preview { + SparkleUpdaterFinishView(dismiss: { _ in }) +} diff --git a/Mythic/Views/SparkleUpdater/SparkleUpdaterInstallingView.swift b/Mythic/Views/SparkleUpdater/SparkleUpdaterInstallingView.swift new file mode 100644 index 00000000..8438f326 --- /dev/null +++ b/Mythic/Views/SparkleUpdater/SparkleUpdaterInstallingView.swift @@ -0,0 +1,44 @@ +// +// SparkleUpdaterInstallingView.swift +// Mythic +// + +import SwiftUI + +public struct SparkleUpdaterInstallingView: View { + public var body: some View { + // HStack(alignment: .top, spacing: 16) { + // BundleIconView() + // .frame(width: 64, height: 64) + // VStack(alignment: .leading, spacing: 8) { + // VStack(alignment: .leading, spacing: 4) { + // Text("sparkleUpdaterInstallingView.title") + // .bold() + // Text("sparkleUpdaterInstallingView.description") + // .foregroundStyle(.secondary) + // } + // ProgressView() + // .progressViewStyle(.linear) + // } + // .frame(maxWidth: .infinity, maxHeight: .infinity) + // } + // .padding(20) + // .frame(width: 512) + RichAlertView( + title: { + Text("sparkleUpdaterInstallingView.title") + }, + message: { + Text("sparkleUpdaterInstallingView.description") + }, + content: { + ProgressView() + .progressViewStyle(.linear) + } + ) + } +} + +#Preview { + SparkleUpdaterInstallingView() +} diff --git a/Mythic/Views/SparkleUpdater/SparkleUpdaterPreviewView.swift b/Mythic/Views/SparkleUpdater/SparkleUpdaterPreviewView.swift new file mode 100644 index 00000000..b758759f --- /dev/null +++ b/Mythic/Views/SparkleUpdater/SparkleUpdaterPreviewView.swift @@ -0,0 +1,103 @@ +// +// SparkleUpdaterPreviewView.swift +// Mythic +// + +import SwiftUI +import Sparkle +import MarkdownUI + +public struct SparkleUpdaterPreviewView: View { + public let appcast: SUAppcastItem + public let choice: (SparkleUpdateControllerModel.UpdateChoice) -> Void + + public var body: some View { + HStack(spacing: 0) { + VStack(alignment: .center, spacing: 32) { + VStack(spacing: 16) { + VStack(spacing: 16) { + BundleIconView() + .shadow(radius: 16) + .frame(width: 64, height: 64) + VStack(spacing: 2) { + Text(AppDelegate.applicationBundleName) + .font(.title2) + .bold() + Text(String(format: "v%@ (%@)", + appcast.displayVersionString.isEmpty ? "0.0.0" : appcast.displayVersionString, + appcast.versionString.isEmpty ? "0" : appcast.versionString)) + .font(.caption) + .opacity(0.6) + } + } + + Text(String(format: String(localized: "sparkleUpdaterPreviewView.description"), + String(format: "v%@", AppDelegate.applicationVersion.description))) + .font(.callout) + .multilineTextAlignment(.center) + .opacity(0.6) + } + + VStack(spacing: 8) { + Button { + choice(.update) + } label: { + Text("sparkleUpdaterPreviewView.update") + .padding(6) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + if !appcast.isCriticalUpdate { + Button { + choice(.dismiss) + } label: { + Text("sparkleUpdaterPreviewView.dismiss") + .padding(6) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + } + } + } + .padding(24) + .frame(width: 256, height: nil, alignment: .center) + .frame(maxHeight: .infinity) + .background(ColorfulBackgroundView()) + .foregroundStyle(.white) + if let itemDescription = appcast.itemDescription, !itemDescription.isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text("sparkleUpdaterPreviewView.releaseNotes") + .bold() + .font(.title2) + Markdown { + itemDescription + } + } + .multilineTextAlignment(.leading) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + VStack(alignment: .center, spacing: 16) { + Image(systemName: "pc") + .resizable() + .scaledToFit() + .frame(width: 96, height: 96) + Text("sparkleUpdaterPreviewView.noReleaseNotes") + } + .multilineTextAlignment(.center) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .foregroundStyle(.secondary) + } + } + .frame(width: 668, height: 384) + } +} + +#Preview { + SparkleUpdaterPreviewView(appcast: .empty(), choice: { _ in }) +} diff --git a/Mythic/Views/SparkleUpdater/SparkleUpdaterSheetViewModifier.swift b/Mythic/Views/SparkleUpdater/SparkleUpdaterSheetViewModifier.swift new file mode 100644 index 00000000..7608f5c1 --- /dev/null +++ b/Mythic/Views/SparkleUpdater/SparkleUpdaterSheetViewModifier.swift @@ -0,0 +1,141 @@ +// +// SparkleUpdaterSheetViewModifier.swift +// Mythic +// + +import SwiftUI +import Sparkle + +public struct SparkleUpdaterSheetViewModifier: ViewModifier { + @ObservedObject private var updateController = SparkleUpdateControllerModel.shared + @State private var checkingForUpdatesSheetPresented = false + @State private var noUpdateAvailableAlertPresented = false + @State private var updateAvailableSheetPresented = false + @State private var downloadSheetPresented = false + @State private var extractSheetPresented = false + @State private var installSheetPresented = false + @State private var restartSheetPresented = false + @State private var errorSheetPresented = false + + private var updateAvailableAppcast: SUAppcastItem { + if case .updateAvailable(_, let appcast) = updateController.state { + return appcast + } + return .empty() + } + private var downloadProgress: (started: Date, total: UInt64, completed: UInt64) { + if case .downloadingUpdate(_, let progress) = updateController.state { + return (progress.started, progress.total, progress.completed) + } + return (.init(), 0, 0) + } + private var extractProgress: (started: Date, progress: Double) { + if case .extractingUpdate(let progress) = updateController.state { + return (progress.started, progress.progress) + } + return (.init(), 0) + } + + public func body(content: Content) -> some View { + content + .sheet(isPresented: $checkingForUpdatesSheetPresented) { + SparkleUpdaterCheckingView(cancel: { + if case .checkingForUpdates(let cancel) = updateController.state { + cancel() + } + }) + } + .alert("sparkleUpdaterSheetViewModifier.noUpdateAvailable.title", + isPresented: $noUpdateAvailableAlertPresented, + actions: { + Button("common.okay", role: .cancel) {} + }, message: { + Text(String(format: String(localized: "sparkleUpdaterSheetViewModifier.noUpdateAvailable.description"), + AppDelegate.applicationBundleName, + "v" + AppDelegate.applicationVersion.description)) + }) + .sheet(isPresented: $updateAvailableSheetPresented) { + SparkleUpdaterPreviewView(appcast: updateAvailableAppcast, choice: { choiceValue in + if case .updateAvailable(let choice, _) = updateController.state { + choice(choiceValue) + } + }) + } + .sheet(isPresented: $downloadSheetPresented) { + SparkleUpdaterDownloadingView(cancel: { + if case .downloadingUpdate(let cancel, _) = updateController.state { + cancel() + } + }, downloadStartTimestamp: downloadProgress.started, bytesDownloaded: downloadProgress.completed, bytesTotal: downloadProgress.total) + } + .sheet(isPresented: $extractSheetPresented) { + SparkleUpdaterExtractingView(progress: extractProgress.progress) + } + .sheet(isPresented: $installSheetPresented) { + SparkleUpdaterInstallingView() + } + .sheet(isPresented: $restartSheetPresented) { + SparkleUpdaterFinishView(dismiss: { relaunch in + if case .readyToRelaunch(let acknowledge) = updateController.state { + acknowledge(relaunch ? .update : .dismiss) + } + }) + } + .alert("sparkleUpdaterSheetViewModifier.error.title", + isPresented: $errorSheetPresented, + actions: { + Button("common.okay", role: .cancel) {} + }, message: { + if case .error(_, let error) = updateController.state { + Text(error.localizedDescription) + } + }) + .onChange(of: noUpdateAvailableAlertPresented) { + if case .noUpdateAvailable(let acknowledge) = updateController.state, + !noUpdateAvailableAlertPresented { + acknowledge() + } + } + .onChange(of: errorSheetPresented) { + if case .error(let acknowledge, _) = updateController.state, + !errorSheetPresented { + acknowledge() + } + } + .onChange(of: updateController.state.stateType) { + // Hide all + checkingForUpdatesSheetPresented = false + noUpdateAvailableAlertPresented = false + updateAvailableSheetPresented = false + downloadSheetPresented = false + extractSheetPresented = false + installSheetPresented = false + restartSheetPresented = false + + if !updateController.userInitiatedCheck { return } + + + switch updateController.state { + case .checkingForUpdates: + checkingForUpdatesSheetPresented = true + case .noUpdateAvailable: + noUpdateAvailableAlertPresented = true + case .updateAvailable: + updateAvailableSheetPresented = true + case .initializingUpdate, .downloadingUpdate: + downloadSheetPresented = true + case .extractingUpdate: + extractSheetPresented = true + case .readyToRelaunch: + restartSheetPresented = true + case .installingUpdate: + installSheetPresented = true + case .error: + errorSheetPresented = true + default: + break + } + } + } + +}