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
/// 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.
@@ -125,6 +134,11 @@ class TerminalWindow: NSWindow {
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()
resetZoomTabButton.contentTintColor = .controlAccentColor

View File

@@ -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,8 +74,13 @@ class UpdateDriver: NSObject, SPUUserDriver {
func showUpdateNotFoundWithError(_ error: any Error,
acknowledgement: @escaping () -> Void) {
viewModel.state = .notFound
if !hasUnobtrusiveTarget {
standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement)
} else {
acknowledgement()
}
}
func showUpdaterError(_ error: any Error,
acknowledgement: @escaping () -> Void) {
@@ -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
}
}
}

View File

@@ -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):