Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Address updater feedback #3690

Merged
merged 11 commits into from
Jan 2, 2025
16 changes: 12 additions & 4 deletions DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -331,12 +331,20 @@ final class MoreOptionsMenu: NSMenu, NSMenuDelegate {
#if SPARKLE
guard NSApp.runType != .uiTests,
let updateController = Application.appDelegate.updateController,
let update = updateController.latestUpdate,
!update.isInstalled,
updateController.updateProgress.isDone
else {
let update = updateController.latestUpdate else {
return
}

// Log edge cases where menu item appears but doesn't function
// To be removed in a future version
if !update.isInstalled, updateController.updateProgress.isDone {
updateController.log()
}

guard updateController.hasPendingUpdate else {
return
}

addItem(UpdateMenuItemFactory.menuItem(for: update))
addItem(NSMenuItem.separator())
#endif
Expand Down
17 changes: 9 additions & 8 deletions DuckDuckGo/Updates/ReleaseNotesTabExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ extension ReleaseNotesValues {
releaseNotes: [String]? = nil,
releaseNotesPrivacyPro: [String]? = nil,
downloadProgress: Double? = nil,
automaticUpdate: Bool? = nil) {
automaticUpdate: Bool?) {
self.status = status.rawValue
self.currentVersion = currentVersion
self.latestVersion = latestVersion
Expand All @@ -145,14 +145,15 @@ extension ReleaseNotesValues {
self.automaticUpdate = automaticUpdate
}

init(from updateController: UpdateController?) {
init(from updateController: UpdateController) {
let currentVersion = "\(AppVersion().versionNumber) (\(AppVersion().buildNumber))"
let lastUpdate = UInt((updateController?.lastUpdateCheckDate ?? Date()).timeIntervalSince1970)
let lastUpdate = UInt((updateController.lastUpdateCheckDate ?? Date()).timeIntervalSince1970)

guard let updateController, let latestUpdate = updateController.latestUpdate else {
self.init(status: updateController?.updateProgress.toStatus ?? .loaded,
guard let latestUpdate = updateController.latestUpdate else {
self.init(status: updateController.updateProgress.toStatus,
currentVersion: currentVersion,
lastUpdate: lastUpdate)
lastUpdate: lastUpdate,
automaticUpdate: updateController.areAutomaticUpdatesEnabled)
Comment on lines +153 to +156
Copy link
Member Author

@quanganhdo quanganhdo Dec 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This happens when Release Notes screen is displayed but the appcast hasn't been loaded yet. We get both incorrect status (updateReady), wrong button text (Restart to Update / Update DuckDuckGo), and the button doesn't even work.

For more details: https://app.asana.com/0/0/1209058536716904/f

return
}

Expand Down Expand Up @@ -194,11 +195,11 @@ private extension Update {
private extension UpdateCycleProgress {
var toStatus: ReleaseNotesValues.Status {
switch self {
case .updateCycleDidStart: return .loading
case .updateCycleNotStarted, .updateCycleDidStart: return .loading
case .downloadDidStart, .downloading: return .updateDownloading
case .extractionDidStart, .extracting, .readyToInstallAndRelaunch, .installationDidStart, .installing: return .updatePreparing
case .updaterError: return .updateError
case .updateCycleNotStarted, .updateCycleDone: return .updateReady
case .updateCycleDone: return .loaded
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateReady should never be returned here unless we're truly ready to proceed with an update.

}
}

Expand Down
5 changes: 4 additions & 1 deletion DuckDuckGo/Updates/ReleaseNotesUserScript.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ final class ReleaseNotesUserScript: NSObject, Subfeature {
return
}

let updateController = Application.appDelegate.updateController
guard let updateController = Application.appDelegate.updateController else {
return
}
Comment on lines +80 to +82
Copy link
Member Author

@quanganhdo quanganhdo Dec 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to satisfy the compiler as AppDelegate.updateController is implicitly unwrapped already.


let values = ReleaseNotesValues(from: updateController)
broker?.push(method: "onUpdate", params: values, for: self, into: webView)
}
Expand Down
45 changes: 33 additions & 12 deletions DuckDuckGo/Updates/UpdateController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ final class UpdateController: NSObject, UpdateControllerProtocol {
@UserDefaultsWrapper(key: .automaticUpdates, defaultValue: true)
var areAutomaticUpdatesEnabled: Bool {
didSet {
Logger.updates.log("areAutomaticUpdatesEnabled: \(self.areAutomaticUpdatesEnabled)")
Logger.updates.log("areAutomaticUpdatesEnabled: \(self.areAutomaticUpdatesEnabled, privacy: .public)")
if oldValue != areAutomaticUpdatesEnabled {
userDriver?.cancelAndDismissCurrentUpdate()
try? configureUpdater()
Expand Down Expand Up @@ -239,7 +239,7 @@ extension UpdateController: SPUUpdaterDelegate {
}

func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
Logger.updates.error("Updater did abort with error: \(error.localizedDescription)")
Logger.updates.error("Updater did abort with error: \(error.localizedDescription, privacy: .public) (\(error.pixelParameters, privacy: .public))")
let errorCode = (error as NSError).code
guard ![Int(Sparkle.SUError.noUpdateError.rawValue),
Int(Sparkle.SUError.installationCanceledError.rawValue),
Expand All @@ -252,7 +252,7 @@ extension UpdateController: SPUUpdaterDelegate {
}

func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
Logger.updates.log("Updater did find valid update: \(item.displayVersionString)(\(item.versionString))")
Logger.updates.log("Updater did find valid update: \(item.displayVersionString, privacy: .public)(\(item.versionString, privacy: .public))")
PixelKit.fire(DebugEvent(GeneralPixel.updaterDidFindUpdate))
cachedUpdateResult = UpdateCheckResult(item: item, isInstalled: false)
}
Expand All @@ -261,7 +261,7 @@ extension UpdateController: SPUUpdaterDelegate {
let nsError = error as NSError
guard let item = nsError.userInfo[SPULatestAppcastItemFoundKey] as? SUAppcastItem else { return }

Logger.updates.log("Updater did not find update: \(String(describing: item.displayVersionString))(\(String(describing: item.versionString)))")
Logger.updates.log("Updater did not find valid update: \(item.displayVersionString, privacy: .public)(\(item.versionString, privacy: .public))")
PixelKit.fire(DebugEvent(GeneralPixel.updaterDidNotFindUpdate, error: error))

// Edge case: User upgrades to latest version within their rollout group
Expand All @@ -274,30 +274,51 @@ extension UpdateController: SPUUpdaterDelegate {
}

func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
Logger.updates.log("Updater did download update: \(item.displayVersionString)(\(item.versionString))")
Logger.updates.log("Updater did download update: \(item.displayVersionString, privacy: .public)(\(item.versionString, privacy: .public))")
PixelKit.fire(DebugEvent(GeneralPixel.updaterDidDownloadUpdate))
}

func updater(_ updater: SPUUpdater, didExtractUpdate item: SUAppcastItem) {
Logger.updates.log("Updater did extract update: \(item.displayVersionString)(\(item.versionString))")
Logger.updates.log("Updater did extract update: \(item.displayVersionString, privacy: .public)(\(item.versionString, privacy: .public))")
}

func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) {
Logger.updates.log("Updater will install update: \(item.displayVersionString)(\(item.versionString))")
Logger.updates.log("Updater will install update: \(item.displayVersionString, privacy: .public)(\(item.versionString, privacy: .public))")
}

func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool {
Logger.updates.log("Updater will install update on quit: \(item.displayVersionString, privacy: .public)(\(item.versionString, privacy: .public))")
userDriver?.configureResumeBlock(immediateInstallHandler)
return true
Comment on lines +289 to +292
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This delegate method isn't called in my testing scenarios, but when it does in prod, let's make sure we use the immediateInstallHandler to set up our resume block for Install Now.

}

func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: (any Error)?) {
if error == nil {
Logger.updates.log("Updater did finish update cycle")
updateProgress = .updateCycleDone
Logger.updates.log("Updater did finish update cycle with no error")
updateProgress = .updateCycleDone(.finishedWithNoError)
} else if let errorCode = (error as? NSError)?.code, errorCode == Int(Sparkle.SUError.noUpdateError.rawValue) {
Logger.updates.log("Updater did finish update cycle with no update found")
updateProgress = .updateCycleDone
} else {
Logger.updates.log("Updater did finish update cycle with error")
updateProgress = .updateCycleDone(.finishedWithNoUpdateFound)
} else if let error {
Logger.updates.log("Updater did finish update cycle with error: \(error.localizedDescription, privacy: .public) (\(error.pixelParameters, privacy: .public))")
}
}

func log() {
Logger.updates.log("areAutomaticUpdatesEnabled: \(self.areAutomaticUpdatesEnabled, privacy: .public)")
Logger.updates.log("updateProgress: \(self.updateProgress, privacy: .public)")
if let cachedUpdateResult {
Logger.updates.log("cachedUpdateResult: \(cachedUpdateResult.item.displayVersionString, privacy: .public)(\(cachedUpdateResult.item.versionString, privacy: .public))")
}
if let state = userDriver?.sparkleUpdateState {
Logger.updates.log("Sparkle update state: (userInitiated: \(state.userInitiated, privacy: .public), stage: \(state.stage.rawValue, privacy: .public))")
} else {
Logger.updates.log("Sparkle update state: Unknown")
}
if let userDriver {
Logger.updates.log("isResumable: \(userDriver.isResumable, privacy: .public)")
}
}
}

#endif
2 changes: 1 addition & 1 deletion DuckDuckGo/Updates/UpdateNotificationPresenter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ final class UpdateNotificationPresenter {
static let presentationTimeInterval: TimeInterval = 10

func showUpdateNotification(icon: NSImage, text: String, buttonText: String? = nil, presentMultiline: Bool = false) {
Logger.updates.log("Notification presented: \(text)")
Logger.updates.log("Notification presented: \(text, privacy: .public)")

DispatchQueue.main.async {
guard let windowController = WindowControllersManager.shared.lastKeyMainWindowController ?? WindowControllersManager.shared.mainWindowControllers.last,
Expand Down
79 changes: 66 additions & 13 deletions DuckDuckGo/Updates/UpdateUserDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,19 @@ enum UpdateState {
}
}

enum UpdateCycleProgress {
enum UpdateCycleProgress: CustomStringConvertible {
enum DoneReason: Int {
case finishedWithNoError = 100
case finishedWithNoUpdateFound = 101
case pausedAtDownloadCheckpoint = 102
case pausedAtRestartCheckpoint = 103
case proceededToInstallationAtRestartCheckpoint = 104
case dismissedWithNoError = 105
}

case updateCycleNotStarted
case updateCycleDidStart
case updateCycleDone
case updateCycleDone(DoneReason)
case downloadDidStart
case downloading(Double)
case extractionDidStart
Expand Down Expand Up @@ -75,19 +84,39 @@ enum UpdateCycleProgress {
default: return false
}
}

var description: String {
switch self {
case .updateCycleNotStarted: return "updateCycleNotStarted"
case .updateCycleDidStart: return "updateCycleDidStart"
case .updateCycleDone(let reason): return "updateCycleDone(\(reason.rawValue))"
case .downloadDidStart: return "downloadDidStart"
case .downloading(let percentage): return "downloading(\(percentage))"
case .extractionDidStart: return "extractionDidStart"
case .extracting(let percentage): return "extracting(\(percentage))"
case .readyToInstallAndRelaunch: return "readyToInstallAndRelaunch"
case .installationDidStart: return "installationDidStart"
case .installing: return "installing"
case .updaterError(let error): return "updaterError(\(error.localizedDescription))(\(error.pixelParameters))"
}
}
}

final class UpdateUserDriver: NSObject, SPUUserDriver {
enum Checkpoint: Equatable {
case download
case restart
case download // for manual updates, pause the process before downloading the update
case restart // for automatic updates, pause the process before attempting to restart
}

private var internalUserDecider: InternalUserDecider

private var checkpoint: Checkpoint

// Resume the update process when the user explicitly chooses to do so
private var onResuming: (() -> Void)?
private var onSkipping: () -> Void = {}

// Dismiss the current update for the time being but keep the downloaded file around
private var onDismiss: () -> Void = {}

var isResumable: Bool {
onResuming != nil
Expand All @@ -99,6 +128,8 @@ final class UpdateUserDriver: NSObject, SPUUserDriver {
@Published var updateProgress = UpdateCycleProgress.default
var updateProgressPublisher: Published<UpdateCycleProgress>.Publisher { $updateProgress }

private(set) var sparkleUpdateState: SPUUserUpdateState?

init(internalUserDecider: InternalUserDecider,
areAutomaticUpdatesEnabled: Bool) {
self.internalUserDecider = internalUserDecider
Expand All @@ -109,8 +140,13 @@ final class UpdateUserDriver: NSObject, SPUUserDriver {
onResuming?()
}

func configureResumeBlock(_ block: @escaping () -> Void) {
guard !isResumable else { return }
onResuming = block
}

func cancelAndDismissCurrentUpdate() {
onSkipping()
onDismiss()
}

func show(_ request: SPUUpdatePermissionRequest) async -> SUUpdatePermissionResponse {
Expand All @@ -122,21 +158,27 @@ final class UpdateUserDriver: NSObject, SPUUserDriver {
}

func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
Logger.updates.log("Updater started performing the update check. (isInternalUser: \(self.internalUserDecider.isInternalUser)")
Logger.updates.log("Updater started performing the update check. (isInternalUser: \(self.internalUserDecider.isInternalUser, privacy: .public))")
updateProgress = .updateCycleDidStart
}

func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping (SPUUserUpdateChoice) -> Void) {
Logger.updates.log("Updater shown update found: (userInitiated: \(state.userInitiated, privacy: .public), stage: \(state.stage.rawValue, privacy: .public))")
sparkleUpdateState = state

if appcastItem.isInformationOnlyUpdate {
Logger.updates.log("Updater dismissed due to information only update")
reply(.dismiss)
}

onSkipping = { reply(.skip) }
onDismiss = { reply(.dismiss) }
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skip can ignore the update, which we don't want.


if checkpoint == .download {
onResuming = { reply(.install) }
updateProgress = .updateCycleDone
updateProgress = .updateCycleDone(.pausedAtDownloadCheckpoint)
Logger.updates.log("Updater paused at download checkpoint (manual update pending user decision)")
} else {
Logger.updates.log("Updater proceeded to installation at download checkpoint")
reply(.install)
}
}
Expand All @@ -154,11 +196,13 @@ final class UpdateUserDriver: NSObject, SPUUserDriver {
}

func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) {
Logger.updates.error("Updater encountered an error: \(error.localizedDescription, privacy: .public) (\(error.pixelParameters, privacy: .public))")
updateProgress = .updaterError(error)
acknowledgement()
}

func showDownloadInitiated(cancellation: @escaping () -> Void) {
Logger.updates.log("Updater started downloading the update")
updateProgress = .downloadDidStart
}

Expand All @@ -176,6 +220,7 @@ final class UpdateUserDriver: NSObject, SPUUserDriver {
}

func showDownloadDidStartExtractingUpdate() {
Logger.updates.log("Updater started extracting the update")
updateProgress = .extractionDidStart
}

Expand All @@ -184,19 +229,27 @@ final class UpdateUserDriver: NSObject, SPUUserDriver {
}

func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) {
onSkipping = { reply(.skip) }
onDismiss = { reply(.dismiss) }

if checkpoint == .restart {
onResuming = { reply(.install) }
updateProgress = .updateCycleDone(.pausedAtRestartCheckpoint)
Logger.updates.log("Updater paused at restart checkpoint (automatic update pending user decision)")
} else {
reply(.install)
updateProgress = .updateCycleDone(.proceededToInstallationAtRestartCheckpoint)
Logger.updates.log("Updater proceeded to installation at restart checkpoint")
}

updateProgress = .updateCycleDone
}

func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
Logger.updates.info("Updater started the installation")
updateProgress = .installationDidStart

if !applicationTerminated {
Logger.updates.log("Updater re-sent a quit event")
retryTerminatingApplication()
}
Comment on lines +249 to +252
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not strictly necessary, but there seems to be no harm in doing this.

}

func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
Expand All @@ -210,7 +263,7 @@ final class UpdateUserDriver: NSObject, SPUUserDriver {

func dismissUpdateInstallation() {
guard !updateProgress.isFailed else { return }
updateProgress = .updateCycleDone
updateProgress = .updateCycleDone(.dismissedWithNoError)
}
}

Expand Down
Loading