summaryrefslogtreecommitdiff
path: root/macos/Sources/Features/Update/UpdatePopoverView.swift
diff options
context:
space:
mode:
Diffstat (limited to 'macos/Sources/Features/Update/UpdatePopoverView.swift')
-rw-r--r--macos/Sources/Features/Update/UpdatePopoverView.swift402
1 files changed, 402 insertions, 0 deletions
diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift
new file mode 100644
index 000000000..7634d27de
--- /dev/null
+++ b/macos/Sources/Features/Update/UpdatePopoverView.swift
@@ -0,0 +1,402 @@
+import SwiftUI
+import Sparkle
+
+/// 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
+
+ /// 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:
+ // Shouldn't happen in a well-formed view stack. Higher levels
+ // should not call the popover for idles.
+ EmptyView()
+
+ case .permissionRequest(let request):
+ PermissionRequestView(request: request, dismiss: dismiss)
+
+ case .checking(let checking):
+ CheckingView(checking: checking, dismiss: dismiss)
+
+ case .updateAvailable(let update):
+ UpdateAvailableView(update: update, dismiss: dismiss)
+
+ case .downloading(let download):
+ DownloadingView(download: download, dismiss: dismiss)
+
+ case .extracting(let extracting):
+ ExtractingView(extracting: extracting)
+
+ case .readyToInstall(let ready):
+ ReadyToInstallView(ready: ready, dismiss: dismiss)
+
+ case .installing:
+ InstallingView()
+
+ case .notFound:
+ NotFoundView(dismiss: dismiss)
+
+ case .error(let error):
+ UpdateErrorView(error: error, dismiss: dismiss)
+ }
+ }
+ .frame(width: 300)
+ }
+}
+
+fileprivate struct PermissionRequestView: View {
+ let request: UpdateState.PermissionRequest
+ let dismiss: DismissAction
+
+ var body: 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 updates in the background.")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ HStack(spacing: 8) {
+ Button("Not Now") {
+ request.reply(SUUpdatePermissionResponse(
+ automaticUpdateChecks: false,
+ sendSystemProfile: false))
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+
+ Spacer()
+
+ Button("Allow") {
+ request.reply(SUUpdatePermissionResponse(
+ automaticUpdateChecks: true,
+ sendSystemProfile: false))
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ .padding(16)
+ }
+}
+
+fileprivate struct CheckingView: View {
+ let checking: UpdateState.Checking
+ let dismiss: DismissAction
+
+ var body: 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") {
+ checking.cancel()
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+}
+
+fileprivate struct UpdateAvailableView: View {
+ let update: UpdateState.UpdateAvailable
+ let dismiss: DismissAction
+
+ private let labelWidth: CGFloat = 60
+
+ var body: 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))
+
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 6) {
+ Text("Version:")
+ .foregroundColor(.secondary)
+ .frame(width: labelWidth, alignment: .trailing)
+ Text(update.appcastItem.displayVersionString)
+ }
+ .font(.system(size: 11))
+
+ if update.appcastItem.contentLength > 0 {
+ HStack(spacing: 6) {
+ Text("Size:")
+ .foregroundColor(.secondary)
+ .frame(width: labelWidth, alignment: .trailing)
+ Text(ByteCountFormatter.string(fromByteCount: Int64(update.appcastItem.contentLength), countStyle: .file))
+ }
+ .font(.system(size: 11))
+ }
+
+ if let date = update.appcastItem.date {
+ HStack(spacing: 6) {
+ Text("Released:")
+ .foregroundColor(.secondary)
+ .frame(width: labelWidth, alignment: .trailing)
+ Text(date.formatted(date: .abbreviated, time: .omitted))
+ }
+ .font(.system(size: 11))
+ }
+ }
+ .textSelection(.enabled)
+ }
+
+ HStack(spacing: 8) {
+ Button("Skip") {
+ update.reply(.skip)
+ dismiss()
+ }
+ .controlSize(.small)
+
+ Button("Later") {
+ update.reply(.dismiss)
+ dismiss()
+ }
+ .controlSize(.small)
+ .keyboardShortcut(.cancelAction)
+
+ Spacer()
+
+ Button("Install") {
+ update.reply(.install)
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+
+ if let notes = update.releaseNotes {
+ Divider()
+
+ Link(destination: notes.url) {
+ HStack {
+ Image(systemName: "doc.text")
+ .font(.system(size: 11))
+ Text(notes.label)
+ .font(.system(size: 11, weight: .medium))
+ Spacer()
+ Image(systemName: "arrow.up.right")
+ .font(.system(size: 10))
+ }
+ .foregroundColor(.primary)
+ .padding(12)
+ .frame(maxWidth: .infinity)
+ .background(Color(nsColor: .controlBackgroundColor))
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+}
+
+fileprivate struct DownloadingView: View {
+ let download: UpdateState.Downloading
+ let dismiss: DismissAction
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Downloading Update")
+ .font(.system(size: 13, weight: .semibold))
+
+ if let expectedLength = download.expectedLength, expectedLength > 0 {
+ let progress = min(1, max(0, Double(download.progress) / Double(expectedLength)))
+ 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") {
+ download.cancel()
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+}
+
+fileprivate struct ExtractingView: View {
+ let extracting: UpdateState.Extracting
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Preparing Update")
+ .font(.system(size: 13, weight: .semibold))
+
+ VStack(alignment: .leading, spacing: 6) {
+ ProgressView(value: min(1, max(0, extracting.progress)), total: 1.0)
+ Text(String(format: "%.0f%%", min(1, max(0, extracting.progress)) * 100))
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ }
+ }
+ .padding(16)
+ }
+}
+
+fileprivate struct ReadyToInstallView: View {
+ let ready: UpdateState.ReadyToInstall
+ let dismiss: DismissAction
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Ready to Install")
+ .font(.system(size: 13, weight: .semibold))
+
+ Text("The update is ready to install.")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ }
+
+ HStack(spacing: 8) {
+ Button("Later") {
+ ready.reply(.dismiss)
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+ .controlSize(.small)
+
+ Spacer()
+
+ Button("Install and Relaunch") {
+ ready.reply(.install)
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+}
+
+fileprivate struct InstallingView: View {
+ var body: 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)
+ }
+}
+
+fileprivate struct NotFoundView: View {
+ let dismiss: DismissAction
+
+ var body: 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") {
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+}
+
+fileprivate struct UpdateErrorView: View {
+ let error: UpdateState.Error
+ let dismiss: DismissAction
+
+ var body: 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("Update Failed")
+ .font(.system(size: 13, weight: .semibold))
+ }
+
+ Text(error.error.localizedDescription)
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ HStack(spacing: 8) {
+ Button("OK") {
+ error.dismiss()
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+ .controlSize(.small)
+
+ Spacer()
+
+ Button("Retry") {
+ error.retry()
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .controlSize(.small)
+ }
+ }
+ .padding(16)
+ }
+}