summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMitchell Hashimoto <m@mitchellh.com>2025-10-09 17:08:21 -0700
committerMitchell Hashimoto <m@mitchellh.com>2025-10-09 17:38:24 -0700
commitf124bb4975efaad430ff09aa4243075824cab359 (patch)
tree56fde7230f8297406d32f65bb7b6e8694afe2f02
parentf2e5b8fb2dd0de1136c208654a6100dfdbd3187c (diff)
macos: Fallback to standard driver when no unobtrusive targets exist
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift14
-rw-r--r--macos/Sources/Features/Update/UpdateDriver.swift95
-rw-r--r--macos/Sources/Features/Update/UpdateViewModel.swift17
3 files changed, 121 insertions, 5 deletions
diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift
index 4737bacaf..8ffdc3e35 100644
--- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift
+++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift
@@ -5,6 +5,12 @@ import GhosttyKit
/// The base class for all standalone, "normal" terminal windows. This sets the basic
/// style and configuration of the window based on the app configuration.
class TerminalWindow: NSWindow {
+ /// Posted when a terminal window awakes from nib.
+ static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake")
+
+ /// Posted when a terminal window will close
+ static let terminalWillCloseNotification = Notification.Name("TerminalWindowWillClose")
+
/// This is the key in UserDefaults to use for the default `level` value. This is
/// used by the manual float on top menu item feature.
static let defaultLevelKey: String = "TerminalDefaultLevel"
@@ -45,6 +51,9 @@ class TerminalWindow: NSWindow {
}
override func awakeFromNib() {
+ // Notify that this terminal window has loaded
+ NotificationCenter.default.post(name: Self.terminalDidAwake, object: self)
+
// This is required so that window restoration properly creates our tabs
// again. I'm not sure why this is required. If you don't do this, then
// tabs restore as separate windows.
@@ -124,6 +133,11 @@ class TerminalWindow: NSWindow {
// still become key/main and receive events.
override var canBecomeKey: Bool { return true }
override var canBecomeMain: Bool { return true }
+
+ override func close() {
+ NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self)
+ super.close()
+ }
override func becomeKey() {
super.becomeKey()
diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift
index 80064854c..cd1d051e2 100644
--- a/macos/Sources/Features/Update/UpdateDriver.swift
+++ b/macos/Sources/Features/Update/UpdateDriver.swift
@@ -10,21 +10,56 @@ class UpdateDriver: NSObject, SPUUserDriver {
self.viewModel = viewModel
self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil)
super.init()
+
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(handleTerminalWindowWillClose),
+ name: TerminalWindow.terminalWillCloseNotification,
+ object: nil)
+ }
+
+ deinit {
+ NotificationCenter.default.removeObserver(self)
+ }
+
+ @objc private func handleTerminalWindowWillClose() {
+ // If we lost the ability to show unobtrusive states, cancel whatever
+ // update state we're in. This will allow the manual `check for updates`
+ // call to initialize the standard driver.
+ //
+ // We have to do this after a short delay so that the window can fully
+ // close.
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in
+ guard let self else { return }
+ guard !hasUnobtrusiveTarget else { return }
+ viewModel.state.cancel()
+ viewModel.state = .idle
+ }
}
func show(_ request: SPUUpdatePermissionRequest,
reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) {
viewModel.state = .permissionRequest(.init(request: request, reply: reply))
+ if !hasUnobtrusiveTarget {
+ standard.show(request, reply: reply)
+ }
}
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
viewModel.state = .checking(.init(cancel: cancellation))
+
+ if !hasUnobtrusiveTarget {
+ standard.showUserInitiatedUpdateCheck(cancellation: cancellation)
+ }
}
func showUpdateFound(with appcastItem: SUAppcastItem,
state: SPUUserUpdateState,
reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply))
+ if !hasUnobtrusiveTarget {
+ standard.showUpdateFound(with: appcastItem, state: state, reply: reply)
+ }
}
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
@@ -39,7 +74,12 @@ class UpdateDriver: NSObject, SPUUserDriver {
func showUpdateNotFoundWithError(_ error: any Error,
acknowledgement: @escaping () -> Void) {
viewModel.state = .notFound
- acknowledgement()
+
+ if !hasUnobtrusiveTarget {
+ standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement)
+ } else {
+ acknowledgement()
+ }
}
func showUpdaterError(_ error: any Error,
@@ -56,6 +96,12 @@ class UpdateDriver: NSObject, SPUUserDriver {
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}))
+
+ if !hasUnobtrusiveTarget {
+ standard.showUpdaterError(error, acknowledgement: acknowledgement)
+ } else {
+ acknowledgement()
+ }
}
func showDownloadInitiated(cancellation: @escaping () -> Void) {
@@ -63,6 +109,10 @@ class UpdateDriver: NSObject, SPUUserDriver {
cancel: cancellation,
expectedLength: nil,
progress: 0))
+
+ if !hasUnobtrusiveTarget {
+ standard.showDownloadInitiated(cancellation: cancellation)
+ }
}
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
@@ -74,6 +124,10 @@ class UpdateDriver: NSObject, SPUUserDriver {
cancel: downloading.cancel,
expectedLength: expectedContentLength,
progress: 0))
+
+ if !hasUnobtrusiveTarget {
+ standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength)
+ }
}
func showDownloadDidReceiveData(ofLength length: UInt64) {
@@ -85,36 +139,67 @@ class UpdateDriver: NSObject, SPUUserDriver {
cancel: downloading.cancel,
expectedLength: downloading.expectedLength,
progress: downloading.progress + length))
+
+ if !hasUnobtrusiveTarget {
+ standard.showDownloadDidReceiveData(ofLength: length)
+ }
}
func showDownloadDidStartExtractingUpdate() {
viewModel.state = .extracting(.init(progress: 0))
+
+ if !hasUnobtrusiveTarget {
+ standard.showDownloadDidStartExtractingUpdate()
+ }
}
func showExtractionReceivedProgress(_ progress: Double) {
viewModel.state = .extracting(.init(progress: progress))
+
+ if !hasUnobtrusiveTarget {
+ standard.showExtractionReceivedProgress(progress)
+ }
}
func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
viewModel.state = .readyToInstall(.init(reply: reply))
+
+ if !hasUnobtrusiveTarget {
+ standard.showReady(toInstallAndRelaunch: reply)
+ }
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
viewModel.state = .installing
+
+ if !hasUnobtrusiveTarget {
+ standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication)
+ }
}
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
- // We don't do anything here.
+ standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement)
viewModel.state = .idle
}
func showUpdateInFocus() {
- // We don't currently implement this because our update state is
- // shown in a terminal window. We may want to implement this at some
- // point to handle the case that no windows are open, though.
+ if !hasUnobtrusiveTarget {
+ standard.showUpdateInFocus()
+ }
}
func dismissUpdateInstallation() {
viewModel.state = .idle
+ standard.dismissUpdateInstallation()
+ }
+
+ // MARK: No-Window Fallback
+
+ /// True if there is a target that can render our unobtrusive update checker.
+ var hasUnobtrusiveTarget: Bool {
+ NSApp.windows.contains { window in
+ window is TerminalWindow &&
+ window.isVisible
+ }
}
}
diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift
index 7b6119771..0678997d7 100644
--- a/macos/Sources/Features/Update/UpdateViewModel.swift
+++ b/macos/Sources/Features/Update/UpdateViewModel.swift
@@ -134,6 +134,23 @@ enum UpdateState: Equatable {
return false
}
+ func cancel() {
+ switch self {
+ case .checking(let checking):
+ checking.cancel()
+ case .updateAvailable(let available):
+ available.reply(.dismiss)
+ case .downloading(let downloading):
+ downloading.cancel()
+ case .readyToInstall(let ready):
+ ready.reply(.dismiss)
+ case .error(let err):
+ err.dismiss()
+ default:
+ break
+ }
+ }
+
static func == (lhs: UpdateState, rhs: UpdateState) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle):