macOS: Show update information as an overlay

pull/9116/head
Mitchell Hashimoto 2025-10-08 13:24:37 -07:00
parent fc347a6040
commit 81e3ff90a3
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
4 changed files with 110 additions and 108 deletions

View File

@ -104,6 +104,11 @@ class AppDelegate: NSObject,
/// Update view model for UI display
@Published private(set) var updateUIModel = UpdateViewModel()
/// Update actions for UI interactions
private(set) lazy var updateActions: UpdateUIActions = {
createUpdateActions()
}()
/// The elapsed time since the process was started
var timeSinceLaunch: TimeInterval {
@ -1029,6 +1034,85 @@ class AppDelegate: NSObject,
)
}
}
private func createUpdateActions() -> UpdateUIActions {
return UpdateUIActions(
allowAutoChecks: {
print("Demo: Allow auto checks")
self.updateUIModel.state = .idle
},
denyAutoChecks: {
print("Demo: Deny auto checks")
self.updateUIModel.state = .idle
},
cancel: {
print("Demo: Cancel")
self.updateUIModel.state = .idle
},
install: {
print("Demo: Install - simulating download and install flow")
self.updateUIModel.state = .downloading
self.updateUIModel.progress = 0.0
for i in 1...10 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) {
self.updateUIModel.progress = Double(i) / 10.0
if i == 10 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.updateUIModel.state = .extracting
self.updateUIModel.progress = 0.0
for j in 1...5 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) {
self.updateUIModel.progress = Double(j) / 5.0
if j == 5 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.updateUIModel.state = .readyToInstall
self.updateUIModel.progress = nil
}
}
}
}
}
}
}
}
},
remindLater: {
print("Demo: Remind later")
self.updateUIModel.state = .idle
},
skipThisVersion: {
print("Demo: Skip version")
self.updateUIModel.state = .idle
},
showReleaseNotes: {
print("Demo: Show release notes")
guard let url = URL(string: "https://github.com/ghostty-org/ghostty/releases") else { return }
NSWorkspace.shared.open(url)
},
retry: {
print("Demo: Retry - simulating update check")
self.updateUIModel.state = .checking
self.updateUIModel.progress = nil
self.updateUIModel.error = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.updateUIModel.state = .updateAvailable
self.updateUIModel.details = .init(
version: "1.2.0",
build: "demo",
size: "42 MB",
date: Date(),
notesSummary: "This is a demo of the update UI."
)
}
}
)
}
@IBAction func newWindow(_ sender: Any?) {
_ = TerminalController.newWindow(ghostty)

View File

@ -109,6 +109,26 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
self.delegate?.performAction(action, on: surfaceView)
}
}
// Show update information above all else.
UpdateOverlay()
}
}
}
}
fileprivate struct UpdateOverlay: View {
var body: some View {
if let appDelegate = NSApp.delegate as? AppDelegate {
VStack {
Spacer()
HStack {
Spacer()
UpdatePill(model: appDelegate.updateUIModel, actions: appDelegate.updateActions)
.padding(.bottom, 12)
.padding(.trailing, 12)
}
}
}
}

View File

@ -94,7 +94,7 @@ class TerminalWindow: NSWindow {
updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView(
viewModel: viewModel,
model: appDelegate.updateUIModel,
actions: createUpdateActions()
actions: appDelegate.updateActions
))
addTitlebarAccessoryViewController(updateAccessory)
updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false
@ -453,105 +453,6 @@ class TerminalWindow: NSWindow {
standardWindowButton(.zoomButton)?.isHidden = true
}
// MARK: Update UI
private func createUpdateActions() -> UpdateUIActions {
guard let appDelegate = NSApp.delegate as? AppDelegate else {
return UpdateUIActions(
allowAutoChecks: {},
denyAutoChecks: {},
cancel: {},
install: {},
remindLater: {},
skipThisVersion: {},
showReleaseNotes: {},
retry: {}
)
}
return UpdateUIActions(
allowAutoChecks: {
print("Demo: Allow auto checks")
appDelegate.updateUIModel.state = .idle
},
denyAutoChecks: {
print("Demo: Deny auto checks")
appDelegate.updateUIModel.state = .idle
},
cancel: {
print("Demo: Cancel")
appDelegate.updateUIModel.state = .idle
},
install: {
print("Demo: Install - simulating download and install flow")
// Start downloading
appDelegate.updateUIModel.state = .downloading
appDelegate.updateUIModel.progress = 0.0
// Simulate download progress
for i in 1...10 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) {
appDelegate.updateUIModel.progress = Double(i) / 10.0
if i == 10 {
// Move to extraction
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
appDelegate.updateUIModel.state = .extracting
appDelegate.updateUIModel.progress = 0.0
// Simulate extraction progress
for j in 1...5 {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) {
appDelegate.updateUIModel.progress = Double(j) / 5.0
if j == 5 {
// Move to ready to install
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
appDelegate.updateUIModel.state = .readyToInstall
appDelegate.updateUIModel.progress = nil
}
}
}
}
}
}
}
}
},
remindLater: {
print("Demo: Remind later")
appDelegate.updateUIModel.state = .idle
},
skipThisVersion: {
print("Demo: Skip version")
appDelegate.updateUIModel.state = .idle
},
showReleaseNotes: {
print("Demo: Show release notes")
guard let url = URL(string: "https://github.com/ghostty-org/ghostty/releases") else { return }
NSWorkspace.shared.open(url)
},
retry: {
print("Demo: Retry - simulating update check")
appDelegate.updateUIModel.state = .checking
appDelegate.updateUIModel.progress = nil
appDelegate.updateUIModel.error = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
appDelegate.updateUIModel.state = .updateAvailable
appDelegate.updateUIModel.details = .init(
version: "1.2.0",
build: "demo",
size: "42 MB",
date: Date(),
notesSummary: "This is a demo of the update UI."
)
}
}
)
}
// MARK: Config
struct DerivedConfig {

View File

@ -13,14 +13,11 @@ struct UpdatePill: View {
var body: some View {
if model.state != .idle {
VStack {
pillButton
Spacer()
}
.popover(isPresented: $showPopover, arrowEdge: .bottom) {
UpdatePopoverView(model: model, actions: actions)
}
.transition(.opacity.combined(with: .scale(scale: 0.95)))
pillButton
.popover(isPresented: $showPopover, arrowEdge: .bottom) {
UpdatePopoverView(model: model, actions: actions)
}
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
}