diff --git a/Whisky.xcodeproj/project.pbxproj b/Whisky.xcodeproj/project.pbxproj index b2bb8e826..44af5f66e 100644 --- a/Whisky.xcodeproj/project.pbxproj +++ b/Whisky.xcodeproj/project.pbxproj @@ -57,7 +57,13 @@ 8CB681E52AED7C6F0018D319 /* WhiskyKit in Resources */ = {isa = PBXBuildFile; fileRef = 8CB681E42AED7C6F0018D319 /* WhiskyKit */; }; 8CB681E72AED7CD00018D319 /* WhiskyKit in Frameworks */ = {isa = PBXBuildFile; productRef = 8CB681E62AED7CD00018D319 /* WhiskyKit */; }; DB696FC82AFAE5DA0037EB2F /* PinCreationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB696FC72AFAE5DA0037EB2F /* PinCreationView.swift */; }; + EB051A092B150F7100F5F5B7 /* UpdatePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB051A082B150F7100F5F5B7 /* UpdatePreviewView.swift */; }; + EB051A0E2B16EA7E00F5F5B7 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = EB051A0D2B16EA7E00F5F5B7 /* MarkdownUI */; }; + EB051A102B1710A700F5F5B7 /* SparkleUpdaterEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB051A0F2B1710A700F5F5B7 /* SparkleUpdaterEvents.swift */; }; + EB051A132B17263300F5F5B7 /* UpdateControllerViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB051A122B17263300F5F5B7 /* UpdateControllerViewModifier.swift */; }; + EB0CD3672B4D217B006C9CA9 /* BundleIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB0CD3662B4D217B006C9CA9 /* BundleIcon.swift */; }; EB58FB552A499896002DC184 /* SemanticVersion in Frameworks */ = {isa = PBXBuildFile; productRef = EB58FB542A499896002DC184 /* SemanticVersion */; }; + EBB21B662B198370000C3FA0 /* UpdateInstallingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBB21B652B198370000C3FA0 /* UpdateInstallingView.swift */; }; EEA5A2462A31DD65008274AE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEA5A2452A31DD65008274AE /* AppDelegate.swift */; }; /* End PBXBuildFile section */ @@ -153,6 +159,11 @@ 8C73E1332AF472FC00B6FB45 /* ProgramMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramMenuView.swift; sourceTree = ""; }; 8CB681E42AED7C6F0018D319 /* WhiskyKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = WhiskyKit; sourceTree = ""; }; DB696FC72AFAE5DA0037EB2F /* PinCreationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCreationView.swift; sourceTree = ""; }; + EB051A082B150F7100F5F5B7 /* UpdatePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePreviewView.swift; sourceTree = ""; }; + EB051A0F2B1710A700F5F5B7 /* SparkleUpdaterEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdaterEvents.swift; sourceTree = ""; }; + EB051A122B17263300F5F5B7 /* UpdateControllerViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateControllerViewModifier.swift; sourceTree = ""; }; + EB0CD3662B4D217B006C9CA9 /* BundleIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIcon.swift; sourceTree = ""; }; + EBB21B652B198370000C3FA0 /* UpdateInstallingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateInstallingView.swift; sourceTree = ""; }; EEA5A2452A31DD65008274AE /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -164,6 +175,7 @@ 8C2AEFC82AED79B700CB568F /* WhiskyKit in Frameworks */, 6E064B1229DD32A200D9A2D2 /* Sparkle in Frameworks */, EB58FB552A499896002DC184 /* SemanticVersion in Frameworks */, + EB051A0E2B16EA7E00F5F5B7 /* MarkdownUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -205,6 +217,7 @@ children = ( 63FFDE852ADF0C7700178665 /* BottomBar.swift */, 6330DD952B1B0EA4007A625A /* RenameView.swift */, + EB0CD3662B4D217B006C9CA9 /* BundleIcon.swift */, ); path = Common; sourceTree = ""; @@ -313,6 +326,7 @@ 6E40495729CCA19C006E3F1B /* ContentView.swift */, 6E064B1329DD331F00D9A2D2 /* SparkleView.swift */, 6E7C07BF2AAF570100F6E66B /* FileOpenView.swift */, + EB051A112B17261A00F5F5B7 /* Updater */, ); path = Views; sourceTree = ""; @@ -340,6 +354,7 @@ 6E621CEE2A5F631200C9AAB3 /* Winetricks.swift */, 6E70A4A02A9A280C007799E9 /* WhiskyCmd.swift */, 6E7C07BD2AAE7B0100F6E66B /* ProgramShortcut.swift */, + EB051A0F2B1710A700F5F5B7 /* SparkleUpdaterEvents.swift */, ); path = Utils; sourceTree = ""; @@ -384,6 +399,16 @@ path = WhiskyThumbnail; sourceTree = ""; }; + EB051A112B17261A00F5F5B7 /* Updater */ = { + isa = PBXGroup; + children = ( + EB051A082B150F7100F5F5B7 /* UpdatePreviewView.swift */, + EB051A122B17263300F5F5B7 /* UpdateControllerViewModifier.swift */, + EBB21B652B198370000C3FA0 /* UpdateInstallingView.swift */, + ); + path = Updater; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -408,6 +433,7 @@ 6E064B1129DD32A200D9A2D2 /* Sparkle */, EB58FB542A499896002DC184 /* SemanticVersion */, 8C2AEFC72AED79B700CB568F /* WhiskyKit */, + EB051A0D2B16EA7E00F5F5B7 /* MarkdownUI */, ); productName = Whisky; productReference = 6E40495229CCA19C006E3F1B /* Whisky.app */; @@ -513,6 +539,7 @@ 6E95F66E2AB3F33C00D585D1 /* XCRemoteSwiftPackageReference "SwiftyTextTable" */, 6E95F6712AB3F67200D585D1 /* XCRemoteSwiftPackageReference "Progress" */, 8C2AEFC62AED79B700CB568F /* XCLocalSwiftPackageReference "WhiskyKit" */, + EB051A0C2B16EA2E00F5F5B7 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, ); productRefGroup = 6E40495329CCA19C006E3F1B /* Products */; projectDirPath = ""; @@ -575,6 +602,7 @@ buildActionMask = 2147483647; files = ( EEA5A2462A31DD65008274AE /* AppDelegate.swift in Sources */, + EBB21B662B198370000C3FA0 /* UpdateInstallingView.swift in Sources */, 6E70A4A12A9A280C007799E9 /* WhiskyCmd.swift in Sources */, 6E40495829CCA19C006E3F1B /* ContentView.swift in Sources */, 6EF557982A410599001A4F09 /* SetupView.swift in Sources */, @@ -582,7 +610,9 @@ DB696FC82AFAE5DA0037EB2F /* PinCreationView.swift in Sources */, 6E7C07BE2AAE7B0100F6E66B /* ProgramShortcut.swift in Sources */, 6E355E5829D78249002D83BE /* ConfigView.swift in Sources */, + EB051A102B1710A700F5F5B7 /* SparkleUpdaterEvents.swift in Sources */, 63FFDE862ADF0C7700178665 /* BottomBar.swift in Sources */, + EB051A092B150F7100F5F5B7 /* UpdatePreviewView.swift in Sources */, 6E6C0CF62A419A8300356232 /* GPTKDownloadView.swift in Sources */, 6365C4C32B1AA8CD00AAE1FD /* BottleListEntry.swift in Sources */, 6E50D98529CDF25B008C39F6 /* BottleCreationView.swift in Sources */, @@ -592,6 +622,7 @@ 6E6C0CF42A419A7600356232 /* RosettaView.swift in Sources */, 6E6C0CF82A419A8C00356232 /* GPTKInstallView.swift in Sources */, 6365C4C12B1AA69D00AAE1FD /* Animation+Extensions.swift in Sources */, + EB051A132B17263300F5F5B7 /* UpdateControllerViewModifier.swift in Sources */, 6E40498329CCA91B006E3F1B /* Bottle+Extensions.swift in Sources */, 6E621CEF2A5F631300C9AAB3 /* Winetricks.swift in Sources */, 6E17B6462AF3FDC100831173 /* PinView.swift in Sources */, @@ -604,6 +635,7 @@ 6E7C07C02AAF570100F6E66B /* FileOpenView.swift in Sources */, 6E355E6029D7D8BD002D83BE /* Program+Extensions.swift in Sources */, 6E6C0CF22A419A6800356232 /* WelcomeView.swift in Sources */, + EB0CD3672B4D217B006C9CA9 /* BundleIcon.swift in Sources */, 6E40498129CCA8B0006E3F1B /* BottleView.swift in Sources */, 6E355E5E29D7D85D002D83BE /* ProgramView.swift in Sources */, ); @@ -862,6 +894,7 @@ buildSettings = { ARCHS = arm64; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = ""; @@ -1029,6 +1062,14 @@ minimumVersion = 0.4.0; }; }; + EB051A0C2B16EA2E00F5F5B7 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.2.0; + }; + }; EB58FB532A499896002DC184 /* XCRemoteSwiftPackageReference "SemanticVersion" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SwiftPackageIndex/SemanticVersion"; @@ -1078,6 +1119,11 @@ isa = XCSwiftPackageProductDependency; productName = WhiskyKit; }; + EB051A0D2B16EA7E00F5F5B7 /* MarkdownUI */ = { + isa = XCSwiftPackageProductDependency; + package = EB051A0C2B16EA2E00F5F5B7 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; + productName = MarkdownUI; + }; EB58FB542A499896002DC184 /* SemanticVersion */ = { isa = XCSwiftPackageProductDependency; package = EB58FB532A499896002DC184 /* XCRemoteSwiftPackageReference "SemanticVersion" */; diff --git a/Whisky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Whisky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 067e72711..c07eb60cb 100644 --- a/Whisky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Whisky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "7aff8d1b31148d32c5933d75557d42f6323ee3d1", + "version" : "6.0.0" + } + }, { "identity" : "progress.swift", "kind" : "remoteSourceControl", @@ -14,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SwiftPackageIndex/SemanticVersion", "state" : { - "revision" : "a70840d5fca686ae3bd2fcf8aecc5ded0bd4f125", - "version" : "0.3.6" + "revision" : "ea8eea9d89842a29af1b8e6c7677f1c86e72fa42", + "version" : "0.4.0" } }, { @@ -24,7 +33,7 @@ "location" : "https://github.com/sparkle-project/Sparkle", "state" : { "branch" : "2.x", - "revision" : "b7b858dbf385cdd1fe1ab8a3f3ee8586fa850d5d" + "revision" : "1a0b023e1c4d37302ae6401b8f9c38af0729e21d" } }, { @@ -36,6 +45,15 @@ "version" : "1.2.3" } }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui.git", + "state" : { + "revision" : "5df8a4adedd6ae4eb2455ef60ff75183984daeb8", + "version" : "2.2.0" + } + }, { "identity" : "swiftytexttable", "kind" : "remoteSourceControl", diff --git a/Whisky/Localizable.xcstrings b/Whisky/Localizable.xcstrings index a3cd00e14..1e9746efd 100644 --- a/Whisky/Localizable.xcstrings +++ b/Whisky/Localizable.xcstrings @@ -249,6 +249,36 @@ } } }, + "app.name" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Whisky" + } + } + } + }, + "app.version" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ (%@)" + } + } + } + }, + "button.cancel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, "button.cDrive" : { "localizations" : { "da" : { @@ -6472,7 +6502,7 @@ "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Nom de la bouteilleĀ :" + "value" : "Nom de la bouteille :" } }, "it" : { @@ -10418,7 +10448,7 @@ "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Nouveau nomĀ :" + "value" : "Nouveau nom :" } }, "it" : { @@ -14647,6 +14677,56 @@ } } }, + "update.checkingForUpdates" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checking for updates..." + } + } + } + }, + "update.checkingForUpdates.description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Whisky is checking for updates..." + } + } + } + }, + "update.description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You're running %@ (%@). The latest available update is %@ (%@). Would you like to update?" + } + } + } + }, + "update.downloading" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloading Update..." + } + } + } + }, + "update.extracting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extracting Files..." + } + } + } + }, "update.gptk.description" : { "localizations" : { "da" : { @@ -14983,6 +15063,116 @@ } } }, + "update.initializating" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Initializing..." + } + } + } + }, + "update.installing" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installing update..." + } + } + } + }, + "update.newUpdate" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Updates Available" + } + } + } + }, + "update.noChangeLog" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No change log available." + } + } + } + }, + "update.noUpdatesFound" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Updates Available" + } + } + } + }, + "update.noUpdatesFound.description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Congrats! You are on the latest version of Whisky," + } + } + } + }, + "update.readyToRelaunch" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update Ready" + } + } + } + }, + "update.readyToRelaunch.description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The update is ready to be installed. Press \\\"Relaunch\\\" to install the update and relaunch Whisky." + } + } + } + }, + "update.relaunch" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relaunch" + } + } + } + }, + "update.update" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update" + } + } + } + }, + "update.updaterError" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to Update" + } + } + } + }, "wine.clearShaderCaches" : { "localizations" : { "da" : { diff --git a/Whisky/Utils/SparkleUpdaterEvents.swift b/Whisky/Utils/SparkleUpdaterEvents.swift new file mode 100644 index 000000000..c890b3de0 --- /dev/null +++ b/Whisky/Utils/SparkleUpdaterEvents.swift @@ -0,0 +1,259 @@ +// +// SparkleUpdaterEvents.swift +// Whisky +// +// This file is part of Whisky. +// +// Whisky is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, +// either version 3 of the License, or (at your option) any later version. +// +// Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with Whisky. +// If not, see https://www.gnu.org/licenses/. +// + +import Foundation +import Sparkle + +class SparkleUpdaterEvents: NSObject, SPUUserDriver, ObservableObject { + static let shared = SparkleUpdaterEvents() + + enum UpdaterState { + case idle, error, checking, updateFound, updateNotFound, initializing, + downloading, extracting, installing, readyToRelaunch + } + + enum UpdateOption { + case install, dismiss + } + + @Published var state: UpdaterState = .idle + @Published var downloadBytesTotal: Double = 0 + @Published var downloadBytesReceived: Double = 0 + @Published var extractProgress: Double = 0 + + // Errors + var errorData: NSError? + private var errorAcknowledgementCallback: (() -> Void)? + + // Checking for updates + private var checkingForUpdatesCancellationCallback: (() -> Void)? + + // Update found + var appcastItem: SUAppcastItem? + private var updateFoundActionCallback: ((SPUUserUpdateChoice) -> Void)? + + // Update not found + private var updateNotFoundAcknowledgementCallback: (() -> Void)? + + // Downloading + var downloadStartedAt: Date? + private var downloadingCancellationCallback: (() -> Void)? + + // Ready to relaunch + private var updateReadyRelaunchCallback: ((SPUUserUpdateChoice) -> Void)? + + /// Clear all callbacks + private func clearCallbacks() { + self.checkingForUpdatesCancellationCallback = .none + self.updateNotFoundAcknowledgementCallback = .none + self.updateFoundActionCallback = .none + self.downloadingCancellationCallback = .none + self.updateReadyRelaunchCallback = .none + } + + /// Implementation of `SPUUserDriver` protocol + internal func show(_ request: SPUUpdatePermissionRequest) async -> SUUpdatePermissionResponse { + return .init( + automaticUpdateChecks: false, + sendSystemProfile: false + ) + } + + /// Implementation of `SPUUserDriver` protocol + internal func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { + state = .checking + self.checkingForUpdatesCancellationCallback = cancellation + } + + /// Cancel the update check + func cancelUpdateCheck() { + self.checkingForUpdatesCancellationCallback?() + clearCallbacks() + self.state = .idle + } + + /// Implementation of `SPUUserDriver` protocol + internal func showUpdateFound( + with appcastItem: SUAppcastItem, + state: SPUUserUpdateState, + reply: @escaping (SPUUserUpdateChoice) -> Void + ) { + clearCallbacks() + self.appcastItem = appcastItem + self.updateFoundActionCallback = reply + self.state = .updateFound + } + + /// Call to tell sparkle to download / dissmiss the update + func shouldUpdate(_ action: UpdateOption) { + guard let callback = self.updateFoundActionCallback else { return } + switch action { + case .install: + callback(.install) + self.state = .initializing + case .dismiss: + callback(.dismiss) + self.state = .idle + } + // Reset callback + clearCallbacks() + } + + /// Implementation of `SPUUserDriver` protocol + internal func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { + // Never needed + return + } + + /// Implementation of `SPUUserDriver` protocol + internal func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) { + clearCallbacks() + self.errorData = error as NSError + self.state = .error + } + + /// Implementation of `SPUUserDriver` protocol + internal func showUpdateNotFoundWithError(_ error: Error, acknowledgement: @escaping () -> Void) { + clearCallbacks() + self.updateNotFoundAcknowledgementCallback = acknowledgement + self.state = .updateNotFound + } + + /// Acknowledgement of the update not being found + func updateNotFoundAcknowledgement() { + self.updateNotFoundAcknowledgementCallback?() + clearCallbacks() + self.state = .idle + } + + /// Implementation of `SPUUserDriver` protocol + internal func showUpdaterError(_ error: Error, acknowledgement: @escaping () -> Void) { + clearCallbacks() + self.errorData = error as NSError + self.errorAcknowledgementCallback = acknowledgement + self.state = .error + } + + /// Acknowledgement of the error + func errorAcknowledgement() { + self.errorAcknowledgementCallback?() + clearCallbacks() + self.errorData = .none + self.state = .idle + } + + /// Implementation of `SPUUserDriver` protocol + internal func showDownloadInitiated(cancellation: @escaping () -> Void) { + clearCallbacks() + self.downloadStartedAt = Date() + self.downloadingCancellationCallback = cancellation + self.state = .downloading + } + + /// Cancel the download + func cancelDownload() { + self.downloadingCancellationCallback?() + // Reset download + clearCallbacks() + self.downloadBytesTotal = 0 + self.downloadBytesReceived = 0 + self.downloadStartedAt = .none + self.state = .idle + } + + /// Implementation of `SPUUserDriver` protocol + internal func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { + self.downloadBytesTotal = Double(expectedContentLength) + } + + /// Implementation of `SPUUserDriver` protocol + internal func showDownloadDidReceiveData(ofLength length: UInt64) { + self.downloadBytesReceived += Double(length) + } + + /// Implementation of `SPUUserDriver` protocol + internal func showDownloadDidStartExtractingUpdate() { + clearCallbacks() + // Reset download + self.downloadBytesTotal = 0 + self.downloadBytesReceived = 0 + self.downloadStartedAt = .none + self.state = .extracting + } + + /// Implementation of `SPUUserDriver` protocol + internal func showExtractionReceivedProgress(_ progress: Double) { + self.extractProgress = progress + } + + /// Implementation of `SPUUserDriver` protocol + internal func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) { + clearCallbacks() + self.updateReadyRelaunchCallback = reply + self.state = .readyToRelaunch + } + + /// Call to tell sparkle to install the update + func relaunch(_ action: UpdateOption) { + guard let callback = self.updateReadyRelaunchCallback else { return } + switch action { + case .install: + callback(.install) + self.state = .installing + case .dismiss: + callback(.dismiss) + self.state = .idle + } + // Reset callback + clearCallbacks() + } + + /// Implementation of `SPUUserDriver` protocol + internal func showInstallingUpdate( + withApplicationTerminated applicationTerminated: Bool, + retryTerminatingApplication: @escaping () -> Void + ) { + clearCallbacks() + // Reset extract + self.extractProgress = 0 + self.state = .installing + } + + /// Implementation of `SPUUserDriver` protocol + internal func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { + clearCallbacks() + // Never used + acknowledgement() + return + } + + /// Implementation of `SPUUserDriver` protocol + internal func showUpdateInFocus() { + // Never needed + return + } + + /// Implementation of `SPUUserDriver` protocol + internal func dismissUpdateInstallation() { + // If it is in the checking state, it means that there is no update available + if self.state == .checking { + clearCallbacks() + self.state = .updateNotFound + } + } +} diff --git a/Whisky/Views/Common/BundleIcon.swift b/Whisky/Views/Common/BundleIcon.swift new file mode 100644 index 000000000..33ed35d3f --- /dev/null +++ b/Whisky/Views/Common/BundleIcon.swift @@ -0,0 +1,39 @@ +// +// BundleIcon.swift +// Whisky +// +// This file is part of Whisky. +// +// Whisky is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, +// either version 3 of the License, or (at your option) any later version. +// +// Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with Whisky. +// If not, see https://www.gnu.org/licenses/. +// + +import SwiftUI + +struct BundleIcon: View { + var body: some View { + if let image = NSImage(named: "AppIcon") { + Image(nsImage: image) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + Image(systemName: "nosign.app") + .resizable() + .scaledToFit() + } + } +} + +#Preview { + BundleIcon() + .frame(width: 50, height: 50) +} diff --git a/Whisky/Views/ContentView.swift b/Whisky/Views/ContentView.swift index 616fb790c..d7b5cad7c 100644 --- a/Whisky/Views/ContentView.swift +++ b/Whisky/Views/ContentView.swift @@ -20,10 +20,12 @@ import SwiftUI import UniformTypeIdentifiers import WhiskyKit import SemanticVersion +import Sparkle struct ContentView: View { @AppStorage("selectedBottleURL") private var selectedBottleURL: URL? @EnvironmentObject var bottleVM: BottleVM + @Binding var showSetup: Bool @State private var selected: URL? diff --git a/Whisky/Views/Updater/UpdateControllerViewModifier.swift b/Whisky/Views/Updater/UpdateControllerViewModifier.swift new file mode 100644 index 000000000..98519dd3b --- /dev/null +++ b/Whisky/Views/Updater/UpdateControllerViewModifier.swift @@ -0,0 +1,218 @@ +// +// UpdateControllerViewModifier.swift +// Whisky +// +// This file is part of Whisky. +// +// Whisky is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, +// either version 3 of the License, or (at your option) any later version. +// +// Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with Whisky. +// If not, see https://www.gnu.org/licenses/. +// + +import SwiftUI +import Sparkle + +enum UpdateState { + case initializating, downloading, extracting, installing +} + +extension View { + func updateController() -> some View { + return modifier(UpdateControllerViewModifier()) + } +} + +struct UpdateControllerViewModifier: ViewModifier { + @State private var sheetCheckingUpdateViewPresented = false + @State private var sheetUpdateNotFoundViewPresented = false + @State private var sheetChangeLogViewPresented = false + @State private var sheetUpdateInstallingViewPresented = false + @State private var sheetUpdateReadyToRelaunchViewPresented = false + @State private var sheetUpdateErrorViewPresented = false + @ObservedObject private var updater = SparkleUpdaterEvents.shared + + func body(content: Content) -> some View { + content + .sheet(isPresented: $sheetCheckingUpdateViewPresented, content: { + UpdateControllerCheckingForUpdatesView(updater: updater) + }) + .sheet(isPresented: $sheetUpdateNotFoundViewPresented, content: { + UpdateControllerUpdateNotFoundView(updater: updater) + }) + .sheet(isPresented: $sheetChangeLogViewPresented, content: { + UpdatePreviewView( + dismiss: { + updater.shouldUpdate(.dismiss) + }, + install: { + updater.shouldUpdate(.install) + }, + markdownText: updater.appcastItem?.itemDescription, + nextVersion: updater.appcastItem?.displayVersionString ?? "1.0.0", + nextVersionNumber: updater.appcastItem?.versionString ?? "0" + ) + .interactiveDismissDisabled() + }) + .sheet(isPresented: $sheetUpdateInstallingViewPresented, content: { + UpdateInstallingView( + downloadStatedAt: updater.downloadStartedAt, + cancelDownload: { + updater.cancelDownload() + }, + state: $updater.state, + downloadableBytes: $updater.downloadBytesTotal, + downloadedBytes: $updater.downloadBytesReceived, + extractProgress: $updater.extractProgress + ) + .interactiveDismissDisabled() + .frame(width: 500) + }) + .sheet(isPresented: $sheetUpdateReadyToRelaunchViewPresented, content: { + UpdateControllerReadyToRelaunchView(updater: updater) + }) + .sheet(isPresented: $sheetUpdateErrorViewPresented, content: { + UpdateControllerErrorView(updater: updater) + }) + .onChange(of: updater.state, { _, newValue in + updateViews(newState: newValue) + }) + } + + func updateViews(newState: SparkleUpdaterEvents.UpdaterState) { + // Dissmiss all old views + sheetCheckingUpdateViewPresented = false + sheetChangeLogViewPresented = false + sheetUpdateInstallingViewPresented = false + sheetUpdateReadyToRelaunchViewPresented = false + sheetUpdateErrorViewPresented = false + sheetUpdateNotFoundViewPresented = false + + // Enable new view + switch newState { + case .checking: + sheetCheckingUpdateViewPresented = true + case .updateFound: + sheetChangeLogViewPresented = true + case .initializing, .downloading, .extracting, .installing: + sheetUpdateInstallingViewPresented = true + case .readyToRelaunch: + sheetUpdateReadyToRelaunchViewPresented = true + case .error: + sheetUpdateErrorViewPresented = true + case .updateNotFound: + sheetUpdateNotFoundViewPresented = true + case .idle: + break + } + } +} + +struct UpdateControllerCheckingForUpdatesView: View { + let updater: SparkleUpdaterEvents + + var body: some View { + HStack(alignment: .top, spacing: 20) { + BundleIcon().frame(width: 60, height: 60) + VStack(alignment: .leading, spacing: 12) { + Text("update.checkingForUpdates") + .fontWeight(.bold) + Text("update.checkingForUpdates.description") + ProgressView() + .progressViewStyle(.linear) + HStack { + Spacer() + Button("button.cancel") { + updater.cancelUpdateCheck() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } + } + } + .padding(20) + .frame(width: 500, alignment: .leading) + .interactiveDismissDisabled() + } +} + +struct UpdateControllerUpdateNotFoundView: View { + let updater: SparkleUpdaterEvents + + var body: some View { + HStack(alignment: .top, spacing: 20) { + BundleIcon().frame(width: 60, height: 60) + VStack(alignment: .leading, spacing: 12) { + Text("update.noUpdatesFound") + .fontWeight(.bold) + Text("update.noUpdatesFound.description") + HStack { + Spacer() + Button("button.ok") { + updater.updateNotFoundAcknowledgement() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } + } + } + .padding(20) + .frame(width: 500, alignment: .leading) + } +} + +struct UpdateControllerReadyToRelaunchView: View { + let updater: SparkleUpdaterEvents + + var body: some View { + HStack(alignment: .top, spacing: 20) { + BundleIcon().frame(width: 60, height: 60) + VStack(alignment: .leading, spacing: 12) { + Text("update.readyToRelaunch") + .fontWeight(.bold) + Text("update.readyToRelaunch.description") + HStack { + Spacer() + Button("update.relaunch") { + updater.relaunch(.install) + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } + } + } + .padding(20) + .frame(width: 500, alignment: .leading) + } +} + +struct UpdateControllerErrorView: View { + let updater: SparkleUpdaterEvents + + var body: some View { + HStack(alignment: .top, spacing: 20) { + BundleIcon().frame(width: 60, height: 60) + VStack(alignment: .leading, spacing: 12) { + Text("update.updaterError") + .fontWeight(.bold) + Text(updater.errorData?.localizedDescription ?? "") + HStack { + Spacer() + Button("button.ok") { + updater.errorAcknowledgement() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } + } + } + .padding(20) + .frame(width: 500, alignment: .leading) + } +} diff --git a/Whisky/Views/Updater/UpdateInstallingView.swift b/Whisky/Views/Updater/UpdateInstallingView.swift new file mode 100644 index 000000000..73db1256f --- /dev/null +++ b/Whisky/Views/Updater/UpdateInstallingView.swift @@ -0,0 +1,205 @@ +// +// UpdateInstallingView.swift +// Whisky +// +// This file is part of Whisky. +// +// Whisky is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, +// either version 3 of the License, or (at your option) any later version. +// +// Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with Whisky. +// If not, see https://www.gnu.org/licenses/. +// + +import SwiftUI + +struct UpdateInstallingView: View { + let downloadStatedAt: Date? + let cancelDownload: () -> Void + + @Binding var state: SparkleUpdaterEvents.UpdaterState + @Binding var downloadableBytes: Double + @Binding var downloadedBytes: Double + @Binding var extractProgress: Double + + @State private var fractionProgress: Double = 0 + @State private var downloadSpeed: Double = 0 + @State private var shouldShowEstimate: Bool = false + + var body: some View { + HStack(alignment: .top, spacing: 20) { + BundleIcon().frame(width: 60, height: 60) + VStack(alignment: .leading, spacing: 12) { + if state == .downloading { + Text("update.downloading") + .fontWeight(.bold) + } else if state == .extracting { + Text("update.extracting") + .fontWeight(.bold) + } else if state == .installing { + Text("update.installing") + .fontWeight(.bold) + } else if state == .initializing { + Text("update.initializating") + .fontWeight(.bold) + } + if state == .installing || state == .initializing { + ProgressView() + .progressViewStyle(.linear) + } else if state == .downloading || state == .extracting { + VStack(spacing: 2) { + ProgressView(value: fractionProgress, total: 1) + .progressViewStyle(.linear) + if state == .downloading { + HStack { + Text(String(format: String(localized: "setup.gptk.progress"), + formatBytes(bytes: downloadedBytes), + formatBytes(bytes: downloadableBytes))) + + Text(String(" ")) + + (shouldShowEstimate ? + Text(String(format: String(localized: "setup.gptk.eta"), + formatRemainingTime( + remainingBytes: downloadableBytes - downloadedBytes))) + : Text(String())) + Spacer() + } + .font(.subheadline) + .monospacedDigit() + } + } + if state == .downloading { + HStack { + Spacer() + Button("button.cancel") { + cancelDownload() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + + } + .font(.subheadline) + .monospacedDigit() + } + } + } + } + .padding(20) + .frame(alignment: .leading) + .onChange(of: downloadedBytes) { + if state != .downloading { + return + } + if let downloadStatedAt = downloadStatedAt { + checkShouldShowEstimate() + let currentTime = Date() + let elapsedTime = currentTime.timeIntervalSince(downloadStatedAt) + if downloadedBytes > 0 { + downloadSpeed = Double(downloadedBytes) / elapsedTime + } else { + downloadSpeed = 0 + } + withAnimation { + if downloadableBytes > 0 { + fractionProgress = downloadedBytes / downloadableBytes + } else { + fractionProgress = 0 + } + } + } else { + downloadSpeed = 0 + fractionProgress = 0 + } + + } + .onChange(of: extractProgress) { + if state != .extracting { + return + } + withAnimation { + fractionProgress = extractProgress / 100 + } + } + .onChange(of: state) { + if state == .installing { + update() + } + } + .onAppear { + if state == .installing { + update() + } + } + } + + func formatBytes(bytes: Double) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.zeroPadsFractionDigits = true + return formatter.string(fromByteCount: Int64(bytes)) + } + + func update() { + Task(priority: .low) { + // Stuff + try await Task.sleep(nanoseconds: UInt64(2 * Double(NSEC_PER_SEC))) + // Relaunch using sketchy ways + await WhiskyApp.killBottles() + // Actualy relaunch (I don't want to make a helper for this so.... you get...) + let task = Process() + task.launchPath = "/bin/sh" + task.arguments = [ + "-c", + """ + kill "\(ProcessInfo.processInfo.processIdentifier)"; + sleep 0.5; open "\(Bundle.main.bundlePath)" + """ + ] + task.launch() + // Relaunch + exit(0) + } + } + + func checkShouldShowEstimate() { + if let downloadStatedAt = downloadStatedAt { + let elapsedTime = Date().timeIntervalSince(downloadStatedAt) + withAnimation { + shouldShowEstimate = Int(elapsedTime.rounded()) > 5 && downloadedBytes != 0 + } + return + } + + withAnimation { + shouldShowEstimate = false + } + } + + func formatRemainingTime(remainingBytes: Double) -> String { + if downloadSpeed == 0 { + return "" + } + let remainingTimeInSeconds = remainingBytes / downloadSpeed + + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute, .second] + formatter.unitsStyle = .full + return formatter.string(from: TimeInterval(remainingTimeInSeconds)) ?? "" + } +} + +#Preview { + UpdateInstallingView( + downloadStatedAt: .none, + cancelDownload: {}, + state: .constant(.downloading), + downloadableBytes: .constant(1000000), + downloadedBytes: .constant(1000), + extractProgress: .constant(0)) + .frame(width: 500) + +} diff --git a/Whisky/Views/Updater/UpdatePreviewView.swift b/Whisky/Views/Updater/UpdatePreviewView.swift new file mode 100644 index 000000000..f38177c56 --- /dev/null +++ b/Whisky/Views/Updater/UpdatePreviewView.swift @@ -0,0 +1,110 @@ +// +// UpdateUI.swift +// Whisky +// +// This file is part of Whisky. +// +// Whisky is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, +// either version 3 of the License, or (at your option) any later version. +// +// Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with Whisky. +// If not, see https://www.gnu.org/licenses/. +// + +import SwiftUI +import Sparkle +import MarkdownUI + +struct UpdatePreviewView: View { + let dismiss: () -> Void + let install: () -> Void + let markdownText: String? + let nextVersion: String + let nextVersionNumber: String + + private let currentVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0" + private let currentVersionNumber = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "0" + + var body: some View { + HStack { + VStack(alignment: .center, spacing: 12) { + BundleIcon().frame(width: 80, height: 80) + VStack(alignment: .center, spacing: 2) { + Text("app.name") + .font(.title) + .fontWeight(.bold) + Text(String(format: String(localized: "app.version"), + "v" + currentVersion, + currentVersionNumber)) + .opacity(0.8) + } + Text(String(format: String(localized: "update.description"), + "v" + currentVersion, + currentVersionNumber, + "v" + nextVersion, + nextVersionNumber)) + .opacity(0.8) + .multilineTextAlignment(.center) + Spacer() + .frame(height: 8) + VStack(spacing: 12) { + Button { + Task(priority: .userInitiated) { + install() + } + } label: { + Text("update.update") + .padding(8) + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity) + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + Button { + dismiss() + } label: { + Text("button.cancel") + .padding(8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderless) + } + } + .frame(maxHeight: .infinity, alignment: .center) + .frame(width: 200) + .padding(.horizontal, 40) + .padding(.vertical, 20) + ScrollView { + VStack(alignment: .leading) { + Text("update.newUpdate") + .font(.title2) + .fontWeight(.bold) + .padding(.bottom, 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + if let markdownText = markdownText { + VStack(alignment: .leading) { + Markdown(markdownText) + .padding(.bottom, 20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } else { + Text("update.noChangeLog") + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding(20) + .background(.ultraThickMaterial) + } + .frame(width: 700, height: 400) + } +} + +#Preview { + UpdatePreviewView(dismiss: {}, install: {}, markdownText: "# Hello", nextVersion: "1.0.0", nextVersionNumber: "10") +} diff --git a/Whisky/Views/WhiskyApp.swift b/Whisky/Views/WhiskyApp.swift index bb16bc6ba..8c98138ae 100644 --- a/Whisky/Views/WhiskyApp.swift +++ b/Whisky/Views/WhiskyApp.swift @@ -23,19 +23,27 @@ import WhiskyKit @main struct WhiskyApp: App { @State var showSetup: Bool = false + @State var showUpdater = false @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @Environment(\.openURL) var openURL - private let updaterController: SPUStandardUpdaterController + private let updaterController: SPUUpdater init() { - updaterController = SPUStandardUpdaterController(startingUpdater: true, - updaterDelegate: nil, - userDriverDelegate: nil) + updaterController = SPUUpdater( + hostBundle: .main, + applicationBundle: .main, + userDriver: SparkleUpdaterEvents.shared, + delegate: nil) + + do { try updaterController.start() } catch { + print(error) + } } var body: some Scene { WindowGroup { ContentView(showSetup: $showSetup) + .updateController() .frame(minWidth: 550, minHeight: 250) .environmentObject(BottleVM.shared) .onAppear { @@ -46,7 +54,7 @@ struct WhiskyApp: App { .handlesExternalEvents(matching: ["{same path of URL?}"]) .commands { CommandGroup(after: .appInfo) { - SparkleView(updater: updaterController.updater) + SparkleView(updater: updaterController) } CommandGroup(before: .systemServices) { Divider()