diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 35af3c73..8cc8586b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1078,6 +1078,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent updateController.checkForUpdates() } + @objc func applyUpdateIfAvailable(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.installUpdate() + } + + @objc func attemptUpdate(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.attemptUpdate() + } + private func setupMenuBarExtra() { let store = TerminalNotificationStore.shared menuBarExtraController = MenuBarExtraController( diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b887a15e..f8efb60a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3330,6 +3330,22 @@ struct ContentView: View { keywords: ["update", "upgrade", "release"] ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.applyUpdateIfAvailable", + title: constant("Apply Update (If Available)"), + subtitle: constant("Global"), + keywords: ["apply", "install", "update", "available"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.attemptUpdate", + title: constant("Attempt Update"), + subtitle: constant("Global"), + keywords: ["attempt", "check", "update", "upgrade", "release"] + ) + ) contributions.append( CommandPaletteCommandContribution( @@ -3719,6 +3735,12 @@ struct ContentView: View { registry.register(commandId: "palette.checkForUpdates") { AppDelegate.shared?.checkForUpdates(nil) } + registry.register(commandId: "palette.applyUpdateIfAvailable") { + AppDelegate.shared?.applyUpdateIfAvailable(nil) + } + registry.register(commandId: "palette.attemptUpdate") { + AppDelegate.shared?.attemptUpdate(nil) + } registry.register(commandId: "palette.renameWorkspace") { beginRenameWorkspaceFlow() diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 0fc1c4e1..94fae950 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -8,6 +8,8 @@ class UpdateController { private(set) var updater: SPUUpdater private let userDriver: UpdateDriver private var installCancellable: AnyCancellable? + private var attemptInstallCancellable: AnyCancellable? + private var didObserveAttemptUpdateProgress: Bool = false private var noUpdateDismissCancellable: AnyCancellable? private var noUpdateDismissWorkItem: DispatchWorkItem? private var readyCheckWorkItem: DispatchWorkItem? @@ -46,6 +48,7 @@ class UpdateController { deinit { installCancellable?.cancel() + attemptInstallCancellable?.cancel() noUpdateDismissCancellable?.cancel() noUpdateDismissWorkItem?.cancel() readyCheckWorkItem?.cancel() @@ -107,6 +110,35 @@ class UpdateController { } } + /// Check for updates and auto-confirm install if one is found. + func attemptUpdate() { + stopAttemptUpdateMonitoring() + didObserveAttemptUpdateProgress = false + + attemptInstallCancellable = viewModel.$state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self else { return } + + if state.isInstallable || !state.isIdle { + self.didObserveAttemptUpdateProgress = true + } + + if case .updateAvailable = state { + UpdateLogStore.shared.append("attemptUpdate auto-confirming available update") + state.confirm() + return + } + + guard self.didObserveAttemptUpdateProgress, !state.isInstallable else { + return + } + self.stopAttemptUpdateMonitoring() + } + + checkForUpdates() + } + /// Check for updates (used by the menu item). @objc func checkForUpdates() { UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))") @@ -175,6 +207,12 @@ class UpdateController { return true } + private func stopAttemptUpdateMonitoring() { + attemptInstallCancellable?.cancel() + attemptInstallCancellable = nil + didObserveAttemptUpdateProgress = false + } + private func installNoUpdateDismissObserver() { noUpdateDismissCancellable = Publishers.CombineLatest(viewModel.$state, viewModel.$overrideState) .receive(on: DispatchQueue.main) diff --git a/tests/test_command_palette_update_commands.py b/tests/test_command_palette_update_commands.py new file mode 100755 index 00000000..b126d6e8 --- /dev/null +++ b/tests/test_command_palette_update_commands.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Regression test for command-palette update command wiring.""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def expect_regex(content: str, pattern: str, message: str, failures: list[str]) -> None: + if re.search(pattern, content, flags=re.DOTALL) is None: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + content_view_path = repo_root / "Sources" / "ContentView.swift" + app_delegate_path = repo_root / "Sources" / "AppDelegate.swift" + controller_path = repo_root / "Sources" / "Update" / "UpdateController.swift" + + missing_paths = [ + str(path) + for path in [content_view_path, app_delegate_path, controller_path] + if not path.exists() + ] + if missing_paths: + print("Missing expected files:") + for path in missing_paths: + print(f" - {path}") + return 1 + + content_view = read_text(content_view_path) + app_delegate = read_text(app_delegate_path) + controller = read_text(controller_path) + + failures: list[str] = [] + + expect_regex( + content_view, + r'commandId:\s*"palette\.applyUpdateIfAvailable".*?title:\s*constant\("Apply Update \(If Available\)"\).*?keywords:\s*\[[^\]]*"apply"[^\]]*"install"[^\]]*"update"[^\]]*"available"[^\]]*\]', + "Missing or incomplete `palette.applyUpdateIfAvailable` contribution", + failures, + ) + expect_regex( + content_view, + r'commandId:\s*"palette\.attemptUpdate".*?title:\s*constant\("Attempt Update"\).*?keywords:\s*\[[^\]]*"attempt"[^\]]*"check"[^\]]*"update"[^\]]*\]', + "Missing or incomplete `palette.attemptUpdate` contribution", + failures, + ) + expect_regex( + content_view, + r'registry\.register\(commandId:\s*"palette\.applyUpdateIfAvailable"\)\s*\{\s*AppDelegate\.shared\?\.applyUpdateIfAvailable\(nil\)\s*\}', + "Missing handler registration for `palette.applyUpdateIfAvailable`", + failures, + ) + expect_regex( + content_view, + r'registry\.register\(commandId:\s*"palette\.attemptUpdate"\)\s*\{\s*AppDelegate\.shared\?\.attemptUpdate\(nil\)\s*\}', + "Missing handler registration for `palette.attemptUpdate`", + failures, + ) + + expect_regex( + app_delegate, + r'@objc\s+func\s+applyUpdateIfAvailable\(_\s+sender:\s+Any\?\)\s*\{\s*updateViewModel\.overrideState\s*=\s*nil\s*updateController\.installUpdate\(\)\s*\}', + "`AppDelegate.applyUpdateIfAvailable` is missing or does not call `updateController.installUpdate()`", + failures, + ) + expect_regex( + app_delegate, + r'@objc\s+func\s+attemptUpdate\(_\s+sender:\s+Any\?\)\s*\{\s*updateViewModel\.overrideState\s*=\s*nil\s*updateController\.attemptUpdate\(\)\s*\}', + "`AppDelegate.attemptUpdate` is missing or does not call `updateController.attemptUpdate()`", + failures, + ) + + expect_regex( + controller, + r'func\s+attemptUpdate\(\)\s*\{', + "`UpdateController.attemptUpdate()` is missing", + failures, + ) + if "state.confirm()" not in controller: + failures.append("`UpdateController.attemptUpdate()` no longer auto-confirms update installation") + if "checkForUpdates()" not in controller: + failures.append("`UpdateController.attemptUpdate()` no longer triggers a check before install") + + if failures: + print("FAIL: command-palette update command regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: command-palette update commands expose apply + attempt wiring") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())