summaryrefslogtreecommitdiff
path: root/macos/Sources/Features/Update/UpdateSimulator.swift
diff options
context:
space:
mode:
authorMitchell Hashimoto <m@mitchellh.com>2025-10-10 08:52:59 -0700
committerGitHub <noreply@github.com>2025-10-10 08:52:59 -0700
commit989acacbf9654586a96a958f3843b089e1d0b94c (patch)
tree5bb5791ecdd49dc85703a951b015486ff837ff31 /macos/Sources/Features/Update/UpdateSimulator.swift
parent2a58aaf8370d276c29e35d49d03052d2d0d083a8 (diff)
parente0ee10e9025614c8a6c35768e51c1659eed11b85 (diff)
macOS: Unobtrusive Updates (a.k.a. OpenAI Demogate Fix) (#9116)
Fixes #2638 Fixes #9056 This changes our update check and notification system to be fully unobtrusive if a terminal window is open. If a terminal window is open, update system notifications now appear in the titlebar or the bottom right corner of the window (if the titlebar is unavailable for any reason). Importantly, this means no more window popups! If a terminal window is not open, then explicit update checks will open the existing, standard, dedicated window that currently exists. This only triggers for manual update checks while the window is not open, though. Or at least, that is the intention, I'm not sure if I got all the logic there 100% correct. **AI disclosure:** I used Amp considerably on the path to this fix. Sorry, I wanted to use Codex due to the source, but I wanted to get this fix out quickly and used a tool I was more familiar with. I manually modified most of the code and understand it all. ## Demo Update flows are complex and can do many things, so I built a simulator to test the various states. This section will show videos of this. ### Happy Path (Full Update Check and Install) https://github.com/user-attachments/assets/0d9c3396-cad1-4f13-b247-0fcc7382b47e ### Happy Path (No titlebar) https://github.com/user-attachments/assets/839ffc20-b2d2-459b-9558-29f0f233d7a2 ### No Update Available https://github.com/user-attachments/assets/44650a98-c39b-4119-a8d0-64c21e06b79f ### Error https://github.com/user-attachments/assets/ab27fe8c-4dd9-48f2-ad4f-23928d6a6829 ### First Launch Permission Check Note: This would show up automatically, not manually triggered. https://github.com/user-attachments/assets/0add869e-eea8-4600-b119-4a236e77c4bf ## TODO - [x] Fix progress percentage causing width wiggling - [x] Fix padding from edges to be aligned on top/bottom and right
Diffstat (limited to 'macos/Sources/Features/Update/UpdateSimulator.swift')
-rw-r--r--macos/Sources/Features/Update/UpdateSimulator.swift275
1 files changed, 275 insertions, 0 deletions
diff --git a/macos/Sources/Features/Update/UpdateSimulator.swift b/macos/Sources/Features/Update/UpdateSimulator.swift
new file mode 100644
index 000000000..96fab4835
--- /dev/null
+++ b/macos/Sources/Features/Update/UpdateSimulator.swift
@@ -0,0 +1,275 @@
+import Foundation
+import Sparkle
+
+/// Simulates various update scenarios for testing the update UI.
+///
+/// The expected usage is by overriding the `checkForUpdates` function in AppDelegate and
+/// calling one of these instead. This will allow us to test the update flows without having to use
+/// real updates.
+enum UpdateSimulator {
+ /// Complete successful update flow: checking → available → download → extract → ready → install → idle
+ case happyPath
+
+ /// No updates available: checking (2s) → "No Updates Available" (3s) → idle
+ case notFound
+
+ /// Error during check: checking (2s) → error with retry callback
+ case error
+
+ /// Slower download for testing progress UI: checking → available → download (20 steps, ~10s) → extract → install
+ case slowDownload
+
+ /// Initial permission request flow: shows permission dialog → proceeds with happy path if accepted
+ case permissionRequest
+
+ /// User cancels during download: checking → available → download (5 steps) → cancels → idle
+ case cancelDuringDownload
+
+ /// User cancels while checking: checking (1s) → cancels → idle
+ case cancelDuringChecking
+
+ func simulate(with viewModel: UpdateViewModel) {
+ switch self {
+ case .happyPath:
+ simulateHappyPath(viewModel)
+ case .notFound:
+ simulateNotFound(viewModel)
+ case .error:
+ simulateError(viewModel)
+ case .slowDownload:
+ simulateSlowDownload(viewModel)
+ case .permissionRequest:
+ simulatePermissionRequest(viewModel)
+ case .cancelDuringDownload:
+ simulateCancelDuringDownload(viewModel)
+ case .cancelDuringChecking:
+ simulateCancelDuringChecking(viewModel)
+ }
+ }
+
+ private func simulateHappyPath(_ viewModel: UpdateViewModel) {
+ viewModel.state = .checking(.init(cancel: {
+ viewModel.state = .idle
+ }))
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ viewModel.state = .updateAvailable(.init(
+ appcastItem: SUAppcastItem.empty(),
+ reply: { choice in
+ if choice == .install {
+ simulateDownload(viewModel)
+ } else {
+ viewModel.state = .idle
+ }
+ }
+ ))
+ }
+ }
+
+ private func simulateNotFound(_ viewModel: UpdateViewModel) {
+ viewModel.state = .checking(.init(cancel: {
+ viewModel.state = .idle
+ }))
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ viewModel.state = .notFound
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
+ viewModel.state = .idle
+ }
+ }
+ }
+
+ private func simulateError(_ viewModel: UpdateViewModel) {
+ viewModel.state = .checking(.init(cancel: {
+ viewModel.state = .idle
+ }))
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ viewModel.state = .error(.init(
+ error: NSError(domain: "UpdateError", code: 1, userInfo: [
+ NSLocalizedDescriptionKey: "Failed to check for updates"
+ ]),
+ retry: {
+ simulateHappyPath(viewModel)
+ },
+ dismiss: {
+ viewModel.state = .idle
+ }
+ ))
+ }
+ }
+
+ private func simulateSlowDownload(_ viewModel: UpdateViewModel) {
+ viewModel.state = .checking(.init(cancel: {
+ viewModel.state = .idle
+ }))
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ viewModel.state = .updateAvailable(.init(
+ appcastItem: SUAppcastItem.empty(),
+ reply: { choice in
+ if choice == .install {
+ simulateSlowDownloadProgress(viewModel)
+ } else {
+ viewModel.state = .idle
+ }
+ }
+ ))
+ }
+ }
+
+ private func simulateSlowDownloadProgress(_ viewModel: UpdateViewModel) {
+ let download = UpdateState.Downloading(
+ cancel: {
+ viewModel.state = .idle
+ },
+ expectedLength: nil,
+ progress: 0
+ )
+ viewModel.state = .downloading(download)
+
+ for i in 1...20 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.5) {
+ let updatedDownload = UpdateState.Downloading(
+ cancel: download.cancel,
+ expectedLength: 2000,
+ progress: UInt64(i * 100)
+ )
+ viewModel.state = .downloading(updatedDownload)
+
+ if i == 20 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ simulateExtract(viewModel)
+ }
+ }
+ }
+ }
+ }
+
+ private func simulatePermissionRequest(_ viewModel: UpdateViewModel) {
+ let request = SPUUpdatePermissionRequest(systemProfile: [])
+ viewModel.state = .permissionRequest(.init(
+ request: request,
+ reply: { response in
+ if response.automaticUpdateChecks {
+ simulateHappyPath(viewModel)
+ } else {
+ viewModel.state = .idle
+ }
+ }
+ ))
+ }
+
+ private func simulateCancelDuringDownload(_ viewModel: UpdateViewModel) {
+ viewModel.state = .checking(.init(cancel: {
+ viewModel.state = .idle
+ }))
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ viewModel.state = .updateAvailable(.init(
+ appcastItem: SUAppcastItem.empty(),
+ reply: { choice in
+ if choice == .install {
+ simulateDownloadThenCancel(viewModel)
+ } else {
+ viewModel.state = .idle
+ }
+ }
+ ))
+ }
+ }
+
+ private func simulateDownloadThenCancel(_ viewModel: UpdateViewModel) {
+ let download = UpdateState.Downloading(
+ cancel: {
+ viewModel.state = .idle
+ },
+ expectedLength: nil,
+ progress: 0
+ )
+ viewModel.state = .downloading(download)
+
+ for i in 1...5 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) {
+ let updatedDownload = UpdateState.Downloading(
+ cancel: download.cancel,
+ expectedLength: 1000,
+ progress: UInt64(i * 100)
+ )
+ viewModel.state = .downloading(updatedDownload)
+
+ if i == 5 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ viewModel.state = .idle
+ }
+ }
+ }
+ }
+ }
+
+ private func simulateCancelDuringChecking(_ viewModel: UpdateViewModel) {
+ viewModel.state = .checking(.init(cancel: {
+ viewModel.state = .idle
+ }))
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
+ viewModel.state = .idle
+ }
+ }
+
+ private func simulateDownload(_ viewModel: UpdateViewModel) {
+ let download = UpdateState.Downloading(
+ cancel: {
+ viewModel.state = .idle
+ },
+ expectedLength: nil,
+ progress: 0
+ )
+ viewModel.state = .downloading(download)
+
+ for i in 1...10 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) {
+ let updatedDownload = UpdateState.Downloading(
+ cancel: download.cancel,
+ expectedLength: 1000,
+ progress: UInt64(i * 100)
+ )
+ viewModel.state = .downloading(updatedDownload)
+
+ if i == 10 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ simulateExtract(viewModel)
+ }
+ }
+ }
+ }
+ }
+
+ private func simulateExtract(_ viewModel: UpdateViewModel) {
+ viewModel.state = .extracting(.init(progress: 0.0))
+
+ for j in 1...5 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) {
+ viewModel.state = .extracting(.init(progress: Double(j) / 5.0))
+
+ if j == 5 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ viewModel.state = .readyToInstall(.init(
+ reply: { choice in
+ if choice == .install {
+ viewModel.state = .installing
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ viewModel.state = .idle
+ }
+ } else {
+ viewModel.state = .idle
+ }
+ }
+ ))
+ }
+ }
+ }
+ }
+ }
+}