mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-10-11 12:26:41 +00:00
macOS: Unobtrusive update views
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import OSLog
|
import OSLog
|
||||||
import Sparkle
|
import Sparkle
|
||||||
@@ -1004,7 +1005,34 @@ class AppDelegate: NSObject,
|
|||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func checkForUpdates(_ sender: Any?) {
|
@IBAction func checkForUpdates(_ sender: Any?) {
|
||||||
|
// Demo mode: simulate update check instead of real Sparkle check
|
||||||
|
// TODO: Replace with real updaterController.checkForUpdates(sender) when SPUUserDriver is implemented
|
||||||
|
|
||||||
|
guard let terminalWindow = NSApp.keyWindow as? TerminalWindow else {
|
||||||
|
// Fallback to real update check if no terminal window
|
||||||
updaterController.checkForUpdates(sender)
|
updaterController.checkForUpdates(sender)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let model = terminalWindow.updateUIModel
|
||||||
|
|
||||||
|
// Simulate the full update check flow
|
||||||
|
model.state = .checking
|
||||||
|
model.progress = nil
|
||||||
|
model.details = nil
|
||||||
|
model.error = nil
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||||
|
// Simulate finding an update
|
||||||
|
model.state = .updateAvailable
|
||||||
|
model.details = .init(
|
||||||
|
version: "1.2.0",
|
||||||
|
build: "demo",
|
||||||
|
size: "42 MB",
|
||||||
|
date: Date(),
|
||||||
|
notesSummary: "This is a demo of the update UI. New features and bug fixes would be listed here."
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func newWindow(_ sender: Any?) {
|
@IBAction func newWindow(_ sender: Any?) {
|
||||||
|
@@ -15,6 +15,10 @@ class TerminalWindow: NSWindow {
|
|||||||
/// Reset split zoom button in titlebar
|
/// Reset split zoom button in titlebar
|
||||||
private let resetZoomAccessory = NSTitlebarAccessoryViewController()
|
private let resetZoomAccessory = NSTitlebarAccessoryViewController()
|
||||||
|
|
||||||
|
/// Update notification UI in titlebar
|
||||||
|
private let updateAccessory = NSTitlebarAccessoryViewController()
|
||||||
|
private(set) var updateUIModel = UpdateViewModel()
|
||||||
|
|
||||||
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
||||||
private(set) var derivedConfig: DerivedConfig = .init()
|
private(set) var derivedConfig: DerivedConfig = .init()
|
||||||
|
|
||||||
@@ -85,6 +89,16 @@ class TerminalWindow: NSWindow {
|
|||||||
}))
|
}))
|
||||||
addTitlebarAccessoryViewController(resetZoomAccessory)
|
addTitlebarAccessoryViewController(resetZoomAccessory)
|
||||||
resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false
|
resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
// Create update notification accessory
|
||||||
|
updateAccessory.layoutAttribute = .right
|
||||||
|
updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
model: updateUIModel,
|
||||||
|
actions: createUpdateActions()
|
||||||
|
))
|
||||||
|
addTitlebarAccessoryViewController(updateAccessory)
|
||||||
|
updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup the accessory view for tabs that shows our keyboard shortcuts,
|
// Setup the accessory view for tabs that shows our keyboard shortcuts,
|
||||||
@@ -198,6 +212,9 @@ class TerminalWindow: NSWindow {
|
|||||||
if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) {
|
if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) {
|
||||||
removeTitlebarAccessoryViewController(at: idx)
|
removeTitlebarAccessoryViewController(at: idx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We don't need to do this with the update accessory. I don't know why but
|
||||||
|
// everything works fine.
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tabBarDidDisappear() {
|
private func tabBarDidDisappear() {
|
||||||
@@ -437,6 +454,94 @@ class TerminalWindow: NSWindow {
|
|||||||
standardWindowButton(.zoomButton)?.isHidden = true
|
standardWindowButton(.zoomButton)?.isHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Update UI
|
||||||
|
|
||||||
|
private func createUpdateActions() -> UpdateUIActions {
|
||||||
|
UpdateUIActions(
|
||||||
|
allowAutoChecks: { [weak self] in
|
||||||
|
print("Demo: Allow auto checks")
|
||||||
|
self?.updateUIModel.state = .idle
|
||||||
|
},
|
||||||
|
denyAutoChecks: { [weak self] in
|
||||||
|
print("Demo: Deny auto checks")
|
||||||
|
self?.updateUIModel.state = .idle
|
||||||
|
},
|
||||||
|
cancel: { [weak self] in
|
||||||
|
print("Demo: Cancel")
|
||||||
|
self?.updateUIModel.state = .idle
|
||||||
|
},
|
||||||
|
install: { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
print("Demo: Install - simulating download and install flow")
|
||||||
|
|
||||||
|
// Start downloading
|
||||||
|
self.updateUIModel.state = .downloading
|
||||||
|
self.updateUIModel.progress = 0.0
|
||||||
|
|
||||||
|
// Simulate download progress
|
||||||
|
for i in 1...10 {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) {
|
||||||
|
self.updateUIModel.progress = Double(i) / 10.0
|
||||||
|
|
||||||
|
if i == 10 {
|
||||||
|
// Move to extraction
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
self.updateUIModel.state = .extracting
|
||||||
|
self.updateUIModel.progress = 0.0
|
||||||
|
|
||||||
|
// Simulate extraction progress
|
||||||
|
for j in 1...5 {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) {
|
||||||
|
self.updateUIModel.progress = Double(j) / 5.0
|
||||||
|
|
||||||
|
if j == 5 {
|
||||||
|
// Move to ready to install
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
self.updateUIModel.state = .readyToInstall
|
||||||
|
self.updateUIModel.progress = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remindLater: { [weak self] in
|
||||||
|
print("Demo: Remind later")
|
||||||
|
self?.updateUIModel.state = .idle
|
||||||
|
},
|
||||||
|
skipThisVersion: { [weak self] in
|
||||||
|
print("Demo: Skip version")
|
||||||
|
self?.updateUIModel.state = .idle
|
||||||
|
},
|
||||||
|
showReleaseNotes: { [weak self] in
|
||||||
|
print("Demo: Show release notes")
|
||||||
|
guard let url = URL(string: "https://github.com/ghostty-org/ghostty/releases") else { return }
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
},
|
||||||
|
retry: { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Config
|
// MARK: Config
|
||||||
|
|
||||||
struct DerivedConfig {
|
struct DerivedConfig {
|
||||||
@@ -467,22 +572,21 @@ extension TerminalWindow {
|
|||||||
class ViewModel: ObservableObject {
|
class ViewModel: ObservableObject {
|
||||||
@Published var isSurfaceZoomed: Bool = false
|
@Published var isSurfaceZoomed: Bool = false
|
||||||
@Published var hasToolbar: Bool = false
|
@Published var hasToolbar: Bool = false
|
||||||
|
|
||||||
|
/// Calculates the top padding based on toolbar visibility and macOS version
|
||||||
|
fileprivate var accessoryTopPadding: CGFloat {
|
||||||
|
if #available(macOS 26.0, *) {
|
||||||
|
return hasToolbar ? 10 : 5
|
||||||
|
} else {
|
||||||
|
return hasToolbar ? 9 : 4
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ResetZoomAccessoryView: View {
|
struct ResetZoomAccessoryView: View {
|
||||||
@ObservedObject var viewModel: ViewModel
|
@ObservedObject var viewModel: ViewModel
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
// The padding from the top that the view appears. This was all just manually
|
|
||||||
// measured based on the OS.
|
|
||||||
var topPadding: CGFloat {
|
|
||||||
if #available(macOS 26.0, *) {
|
|
||||||
return viewModel.hasToolbar ? 10 : 5
|
|
||||||
} else {
|
|
||||||
return viewModel.hasToolbar ? 9 : 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if viewModel.isSurfaceZoomed {
|
if viewModel.isSurfaceZoomed {
|
||||||
VStack {
|
VStack {
|
||||||
@@ -497,10 +601,24 @@ extension TerminalWindow {
|
|||||||
}
|
}
|
||||||
// With a toolbar, the window title is taller, so we need more padding
|
// With a toolbar, the window title is taller, so we need more padding
|
||||||
// to properly align.
|
// to properly align.
|
||||||
.padding(.top, topPadding)
|
.padding(.top, viewModel.accessoryTopPadding)
|
||||||
// We always need space at the end of the titlebar
|
// We always need space at the end of the titlebar
|
||||||
.padding(.trailing, 10)
|
.padding(.trailing, 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A pill-shaped button that displays update status and provides access to update actions.
|
||||||
|
struct UpdateAccessoryView: View {
|
||||||
|
@ObservedObject var viewModel: ViewModel
|
||||||
|
@ObservedObject var model: UpdateViewModel
|
||||||
|
let actions: UpdateUIActions
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
UpdatePill(model: model, actions: actions)
|
||||||
|
.padding(.top, viewModel.accessoryTopPadding)
|
||||||
|
.padding(.trailing, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
65
macos/Sources/Features/Update/UpdateBadge.swift
Normal file
65
macos/Sources/Features/Update/UpdateBadge.swift
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A badge view that displays the current state of an update operation.
|
||||||
|
///
|
||||||
|
/// Shows different visual indicators based on the update state:
|
||||||
|
/// - Progress ring for downloading/extracting with progress
|
||||||
|
/// - Animated rotating icon for checking/installing
|
||||||
|
/// - Static icon for other states
|
||||||
|
struct UpdateBadge: View {
|
||||||
|
/// The update view model that provides the current state and progress
|
||||||
|
@ObservedObject var model: UpdateViewModel
|
||||||
|
|
||||||
|
/// Current rotation angle for animated icon states
|
||||||
|
@State private var rotationAngle: Double = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch model.state {
|
||||||
|
case .downloading, .extracting:
|
||||||
|
if let progress = model.progress {
|
||||||
|
ProgressRingView(progress: progress)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.down.circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
case .checking, .installing:
|
||||||
|
Image(systemName: model.iconName)
|
||||||
|
.rotationEffect(.degrees(rotationAngle))
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.linear(duration: 2.5).repeatForever(autoreverses: false)) {
|
||||||
|
rotationAngle = 360
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
rotationAngle = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
Image(systemName: model.iconName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A circular progress indicator with a stroke-based ring design.
|
||||||
|
///
|
||||||
|
/// Displays a partially filled circle that represents progress from 0.0 to 1.0.
|
||||||
|
fileprivate struct ProgressRingView: View {
|
||||||
|
/// The current progress value, ranging from 0.0 (empty) to 1.0 (complete)
|
||||||
|
let progress: Double
|
||||||
|
|
||||||
|
/// The width of the progress ring stroke
|
||||||
|
let lineWidth: CGFloat = 2
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.primary.opacity(0.2), lineWidth: lineWidth)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: progress)
|
||||||
|
.stroke(Color.primary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
51
macos/Sources/Features/Update/UpdatePill.swift
Normal file
51
macos/Sources/Features/Update/UpdatePill.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A pill-shaped button that displays update status and provides access to update actions.
|
||||||
|
struct UpdatePill: View {
|
||||||
|
/// The update view model that provides the current state and information
|
||||||
|
@ObservedObject var model: UpdateViewModel
|
||||||
|
|
||||||
|
/// The actions that can be performed on updates
|
||||||
|
let actions: UpdateUIActions
|
||||||
|
|
||||||
|
/// Whether the update popover is currently visible
|
||||||
|
@State private var showPopover = false
|
||||||
|
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pill-shaped button view that displays the update badge and text
|
||||||
|
@ViewBuilder
|
||||||
|
private var pillButton: some View {
|
||||||
|
Button(action: { showPopover.toggle() }) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
UpdateBadge(model: model)
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
|
||||||
|
Text(model.text)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(model.backgroundColor)
|
||||||
|
)
|
||||||
|
.foregroundColor(model.foregroundColor)
|
||||||
|
.contentShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(model.stateTooltip)
|
||||||
|
}
|
||||||
|
}
|
362
macos/Sources/Features/Update/UpdatePopoverView.swift
Normal file
362
macos/Sources/Features/Update/UpdatePopoverView.swift
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A popover view that displays detailed update information and action buttons.
|
||||||
|
///
|
||||||
|
/// The view adapts its content based on the current update state, showing appropriate
|
||||||
|
/// UI for checking, downloading, installing, or handling errors.
|
||||||
|
struct UpdatePopoverView: View {
|
||||||
|
/// The update view model that provides the current state and information
|
||||||
|
@ObservedObject var model: UpdateViewModel
|
||||||
|
|
||||||
|
/// The actions that can be performed on updates
|
||||||
|
let actions: UpdateUIActions
|
||||||
|
|
||||||
|
/// Environment value for dismissing the popover
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
switch model.state {
|
||||||
|
case .idle:
|
||||||
|
EmptyView()
|
||||||
|
|
||||||
|
case .permissionRequest:
|
||||||
|
permissionRequestView
|
||||||
|
|
||||||
|
case .checking:
|
||||||
|
checkingView
|
||||||
|
|
||||||
|
case .updateAvailable:
|
||||||
|
updateAvailableView
|
||||||
|
|
||||||
|
case .downloading:
|
||||||
|
downloadingView
|
||||||
|
|
||||||
|
case .extracting:
|
||||||
|
extractingView
|
||||||
|
|
||||||
|
case .readyToInstall:
|
||||||
|
readyToInstallView
|
||||||
|
|
||||||
|
case .installing:
|
||||||
|
installingView
|
||||||
|
|
||||||
|
case .notFound:
|
||||||
|
notFoundView
|
||||||
|
|
||||||
|
case .error:
|
||||||
|
errorView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View shown when requesting permission to enable automatic updates
|
||||||
|
private var permissionRequestView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Enable automatic updates?")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
|
||||||
|
Text("Ghostty can automatically check for and download updates in the background.")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button("Not Now") {
|
||||||
|
actions.denyAutoChecks()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Allow") {
|
||||||
|
actions.allowAutoChecks()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View shown while checking for updates
|
||||||
|
private var checkingView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
Text("Checking for updates…")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") {
|
||||||
|
actions.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View shown when an update is available, displaying version and size information
|
||||||
|
private var updateAvailableView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Update Available")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
|
||||||
|
if let details = model.details {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("Version:")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
Text(details.version)
|
||||||
|
}
|
||||||
|
.font(.system(size: 11))
|
||||||
|
|
||||||
|
if let size = details.size {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("Size:")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
Text(size)
|
||||||
|
}
|
||||||
|
.font(.system(size: 11))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button("Skip") {
|
||||||
|
actions.skipThisVersion()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
Button("Later") {
|
||||||
|
actions.remindLater()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Install") {
|
||||||
|
actions.install()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
|
||||||
|
if model.details?.notesSummary != nil {
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button(action: actions.showReleaseNotes) {
|
||||||
|
HStack {
|
||||||
|
Text("View Release Notes")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.right.square")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color(nsColor: .controlBackgroundColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View shown while downloading an update, with progress indicator
|
||||||
|
private var downloadingView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Downloading Update")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
|
||||||
|
if let progress = model.progress {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ProgressView(value: progress)
|
||||||
|
Text(String(format: "%.0f%%", progress * 100))
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") {
|
||||||
|
actions.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View shown while extracting/preparing the downloaded update
|
||||||
|
private var extractingView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Preparing Update")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
|
||||||
|
if let progress = model.progress {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ProgressView(value: progress)
|
||||||
|
Text(String(format: "%.0f%%", progress * 100))
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View shown when an update is ready to be installed
|
||||||
|
private var readyToInstallView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Ready to Install")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
|
||||||
|
if let details = model.details {
|
||||||
|
Text("Version \(details.version) is ready to install.")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button("Later") {
|
||||||
|
actions.remindLater()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Install and Relaunch") {
|
||||||
|
actions.install()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View shown during the installation process
|
||||||
|
private var installingView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
Text("Installing…")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("The application will relaunch shortly.")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View shown when no updates are found (already on latest version)
|
||||||
|
private var notFoundView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("No Updates Found")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
|
||||||
|
Text("You're already running the latest version.")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("OK") {
|
||||||
|
actions.remindLater()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View shown when an error occurs during the update process
|
||||||
|
private var errorView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
Text(model.error?.title ?? "Update Failed")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let message = model.error?.message {
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button("OK") {
|
||||||
|
actions.remindLater()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.controlSize(.small)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Retry") {
|
||||||
|
actions.retry()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
178
macos/Sources/Features/Update/UpdateViewModel.swift
Normal file
178
macos/Sources/Features/Update/UpdateViewModel.swift
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct UpdateUIActions {
|
||||||
|
let allowAutoChecks: () -> Void
|
||||||
|
let denyAutoChecks: () -> Void
|
||||||
|
let cancel: () -> Void
|
||||||
|
let install: () -> Void
|
||||||
|
let remindLater: () -> Void
|
||||||
|
let skipThisVersion: () -> Void
|
||||||
|
let showReleaseNotes: () -> Void
|
||||||
|
let retry: () -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateViewModel: ObservableObject {
|
||||||
|
@Published var state: State = .idle
|
||||||
|
@Published var progress: Double? = nil
|
||||||
|
@Published var details: Details? = nil
|
||||||
|
@Published var error: ErrorInfo? = nil
|
||||||
|
|
||||||
|
enum State: Equatable {
|
||||||
|
case idle
|
||||||
|
case permissionRequest
|
||||||
|
case checking
|
||||||
|
case updateAvailable
|
||||||
|
case downloading
|
||||||
|
case extracting
|
||||||
|
case readyToInstall
|
||||||
|
case installing
|
||||||
|
case notFound
|
||||||
|
case error
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ErrorInfo: Equatable {
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Details: Equatable {
|
||||||
|
let version: String
|
||||||
|
let build: String?
|
||||||
|
let size: String?
|
||||||
|
let date: Date?
|
||||||
|
let notesSummary: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
var stateTooltip: String {
|
||||||
|
switch state {
|
||||||
|
case .idle:
|
||||||
|
return ""
|
||||||
|
case .permissionRequest:
|
||||||
|
return "Update permission required"
|
||||||
|
case .checking:
|
||||||
|
return "Checking for updates…"
|
||||||
|
case .updateAvailable:
|
||||||
|
if let details {
|
||||||
|
return "Update available: \(details.version)"
|
||||||
|
}
|
||||||
|
return "Update available"
|
||||||
|
case .downloading:
|
||||||
|
if let progress {
|
||||||
|
return String(format: "Downloading %.0f%%…", progress * 100)
|
||||||
|
}
|
||||||
|
return "Downloading…"
|
||||||
|
case .extracting:
|
||||||
|
if let progress {
|
||||||
|
return String(format: "Preparing %.0f%%…", progress * 100)
|
||||||
|
}
|
||||||
|
return "Preparing…"
|
||||||
|
case .readyToInstall:
|
||||||
|
return "Ready to install"
|
||||||
|
case .installing:
|
||||||
|
return "Installing…"
|
||||||
|
case .notFound:
|
||||||
|
return "No updates found"
|
||||||
|
case .error:
|
||||||
|
return error?.title ?? "Update failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var text: String {
|
||||||
|
switch state {
|
||||||
|
case .idle:
|
||||||
|
return ""
|
||||||
|
case .permissionRequest:
|
||||||
|
return "Update Permission"
|
||||||
|
case .checking:
|
||||||
|
return "Checking for Updates…"
|
||||||
|
case .updateAvailable:
|
||||||
|
if let details {
|
||||||
|
return "Update Available: \(details.version)"
|
||||||
|
}
|
||||||
|
return "Update Available"
|
||||||
|
case .downloading:
|
||||||
|
if let progress {
|
||||||
|
return String(format: "Downloading: %.0f%%", progress * 100)
|
||||||
|
}
|
||||||
|
return "Downloading…"
|
||||||
|
case .extracting:
|
||||||
|
if let progress {
|
||||||
|
return String(format: "Preparing: %.0f%%", progress * 100)
|
||||||
|
}
|
||||||
|
return "Preparing…"
|
||||||
|
case .readyToInstall:
|
||||||
|
return "Install Update"
|
||||||
|
case .installing:
|
||||||
|
return "Installing…"
|
||||||
|
case .notFound:
|
||||||
|
return "No Updates Available"
|
||||||
|
case .error:
|
||||||
|
return error?.title ?? "Update Failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconName: String {
|
||||||
|
switch state {
|
||||||
|
case .idle:
|
||||||
|
return ""
|
||||||
|
case .permissionRequest:
|
||||||
|
return "questionmark.circle"
|
||||||
|
case .checking:
|
||||||
|
return "arrow.triangle.2.circlepath"
|
||||||
|
case .updateAvailable:
|
||||||
|
return "arrow.down.circle.fill"
|
||||||
|
case .downloading, .extracting:
|
||||||
|
return "" // Progress ring instead
|
||||||
|
case .readyToInstall:
|
||||||
|
return "checkmark.circle.fill"
|
||||||
|
case .installing:
|
||||||
|
return "gear"
|
||||||
|
case .notFound:
|
||||||
|
return "info.circle"
|
||||||
|
case .error:
|
||||||
|
return "exclamationmark.triangle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconColor: Color {
|
||||||
|
switch state {
|
||||||
|
case .idle:
|
||||||
|
return .secondary
|
||||||
|
case .permissionRequest, .checking:
|
||||||
|
return .secondary
|
||||||
|
case .updateAvailable, .readyToInstall:
|
||||||
|
return .accentColor
|
||||||
|
case .downloading, .extracting, .installing:
|
||||||
|
return .secondary
|
||||||
|
case .notFound:
|
||||||
|
return .secondary
|
||||||
|
case .error:
|
||||||
|
return .orange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var backgroundColor: Color {
|
||||||
|
switch state {
|
||||||
|
case .updateAvailable:
|
||||||
|
return .accentColor
|
||||||
|
case .readyToInstall:
|
||||||
|
return Color(nsColor: NSColor.systemGreen.blended(withFraction: 0.3, of: .black) ?? .systemGreen)
|
||||||
|
case .error:
|
||||||
|
return .orange.opacity(0.2)
|
||||||
|
default:
|
||||||
|
return Color(nsColor: .controlBackgroundColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var foregroundColor: Color {
|
||||||
|
switch state {
|
||||||
|
case .updateAvailable, .readyToInstall:
|
||||||
|
return .white
|
||||||
|
case .error:
|
||||||
|
return .orange
|
||||||
|
default:
|
||||||
|
return .primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user