macos: Fallback to standard driver when no unobtrusive targets exist

This commit is contained in:
Mitchell Hashimoto
2025-10-09 17:08:21 -07:00
parent f2e5b8fb2d
commit f124bb4975
3 changed files with 121 additions and 5 deletions

View File

@@ -5,6 +5,12 @@ import GhosttyKit
/// The base class for all standalone, "normal" terminal windows. This sets the basic /// The base class for all standalone, "normal" terminal windows. This sets the basic
/// style and configuration of the window based on the app configuration. /// style and configuration of the window based on the app configuration.
class TerminalWindow: NSWindow { 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 /// 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. /// used by the manual float on top menu item feature.
static let defaultLevelKey: String = "TerminalDefaultLevel" static let defaultLevelKey: String = "TerminalDefaultLevel"
@@ -45,6 +51,9 @@ class TerminalWindow: NSWindow {
} }
override func awakeFromNib() { 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 // 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 // again. I'm not sure why this is required. If you don't do this, then
// tabs restore as separate windows. // tabs restore as separate windows.
@@ -124,6 +133,11 @@ class TerminalWindow: NSWindow {
// still become key/main and receive events. // still become key/main and receive events.
override var canBecomeKey: Bool { return true } override var canBecomeKey: Bool { return true }
override var canBecomeMain: 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() { override func becomeKey() {
super.becomeKey() super.becomeKey()

View File

@@ -10,21 +10,56 @@ class UpdateDriver: NSObject, SPUUserDriver {
self.viewModel = viewModel self.viewModel = viewModel
self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil) self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil)
super.init() 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, func show(_ request: SPUUpdatePermissionRequest,
reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) { reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) {
viewModel.state = .permissionRequest(.init(request: request, reply: reply)) viewModel.state = .permissionRequest(.init(request: request, reply: reply))
if !hasUnobtrusiveTarget {
standard.show(request, reply: reply)
}
} }
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
viewModel.state = .checking(.init(cancel: cancellation)) viewModel.state = .checking(.init(cancel: cancellation))
if !hasUnobtrusiveTarget {
standard.showUserInitiatedUpdateCheck(cancellation: cancellation)
}
} }
func showUpdateFound(with appcastItem: SUAppcastItem, func showUpdateFound(with appcastItem: SUAppcastItem,
state: SPUUserUpdateState, state: SPUUserUpdateState,
reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply)) viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply))
if !hasUnobtrusiveTarget {
standard.showUpdateFound(with: appcastItem, state: state, reply: reply)
}
} }
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
@@ -39,7 +74,12 @@ class UpdateDriver: NSObject, SPUUserDriver {
func showUpdateNotFoundWithError(_ error: any Error, func showUpdateNotFoundWithError(_ error: any Error,
acknowledgement: @escaping () -> Void) { acknowledgement: @escaping () -> Void) {
viewModel.state = .notFound viewModel.state = .notFound
acknowledgement()
if !hasUnobtrusiveTarget {
standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement)
} else {
acknowledgement()
}
} }
func showUpdaterError(_ error: any Error, func showUpdaterError(_ error: any Error,
@@ -56,6 +96,12 @@ class UpdateDriver: NSObject, SPUUserDriver {
dismiss: { [weak viewModel] in dismiss: { [weak viewModel] in
viewModel?.state = .idle viewModel?.state = .idle
})) }))
if !hasUnobtrusiveTarget {
standard.showUpdaterError(error, acknowledgement: acknowledgement)
} else {
acknowledgement()
}
} }
func showDownloadInitiated(cancellation: @escaping () -> Void) { func showDownloadInitiated(cancellation: @escaping () -> Void) {
@@ -63,6 +109,10 @@ class UpdateDriver: NSObject, SPUUserDriver {
cancel: cancellation, cancel: cancellation,
expectedLength: nil, expectedLength: nil,
progress: 0)) progress: 0))
if !hasUnobtrusiveTarget {
standard.showDownloadInitiated(cancellation: cancellation)
}
} }
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
@@ -74,6 +124,10 @@ class UpdateDriver: NSObject, SPUUserDriver {
cancel: downloading.cancel, cancel: downloading.cancel,
expectedLength: expectedContentLength, expectedLength: expectedContentLength,
progress: 0)) progress: 0))
if !hasUnobtrusiveTarget {
standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength)
}
} }
func showDownloadDidReceiveData(ofLength length: UInt64) { func showDownloadDidReceiveData(ofLength length: UInt64) {
@@ -85,36 +139,67 @@ class UpdateDriver: NSObject, SPUUserDriver {
cancel: downloading.cancel, cancel: downloading.cancel,
expectedLength: downloading.expectedLength, expectedLength: downloading.expectedLength,
progress: downloading.progress + length)) progress: downloading.progress + length))
if !hasUnobtrusiveTarget {
standard.showDownloadDidReceiveData(ofLength: length)
}
} }
func showDownloadDidStartExtractingUpdate() { func showDownloadDidStartExtractingUpdate() {
viewModel.state = .extracting(.init(progress: 0)) viewModel.state = .extracting(.init(progress: 0))
if !hasUnobtrusiveTarget {
standard.showDownloadDidStartExtractingUpdate()
}
} }
func showExtractionReceivedProgress(_ progress: Double) { func showExtractionReceivedProgress(_ progress: Double) {
viewModel.state = .extracting(.init(progress: progress)) viewModel.state = .extracting(.init(progress: progress))
if !hasUnobtrusiveTarget {
standard.showExtractionReceivedProgress(progress)
}
} }
func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
viewModel.state = .readyToInstall(.init(reply: reply)) viewModel.state = .readyToInstall(.init(reply: reply))
if !hasUnobtrusiveTarget {
standard.showReady(toInstallAndRelaunch: reply)
}
} }
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
viewModel.state = .installing viewModel.state = .installing
if !hasUnobtrusiveTarget {
standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication)
}
} }
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
// We don't do anything here. standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement)
viewModel.state = .idle viewModel.state = .idle
} }
func showUpdateInFocus() { func showUpdateInFocus() {
// We don't currently implement this because our update state is if !hasUnobtrusiveTarget {
// shown in a terminal window. We may want to implement this at some standard.showUpdateInFocus()
// point to handle the case that no windows are open, though. }
} }
func dismissUpdateInstallation() { func dismissUpdateInstallation() {
viewModel.state = .idle 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
}
} }
} }

View File

@@ -134,6 +134,23 @@ enum UpdateState: Equatable {
return false 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 { static func == (lhs: UpdateState, rhs: UpdateState) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case (.idle, .idle): case (.idle, .idle):