cmux/Sources/Update/UpdatePopoverView.swift
2026-03-25 00:51:34 -07:00

436 lines
16 KiB
Swift

import AppKit
import SwiftUI
import Sparkle
/// Popover view that displays detailed update information and actions.
struct UpdatePopoverView: View {
@ObservedObject var model: UpdateViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 0) {
switch model.effectiveState {
case .idle:
if let detectedVersion = model.detectedUpdateVersion,
model.showsDetectedBackgroundUpdate {
DetectedBackgroundUpdateView(version: detectedVersion)
} else {
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 .installing(let installing):
InstallingView(installing: installing, dismiss: dismiss)
case .notFound(let notFound):
NotFoundView(notFound: notFound, dismiss: dismiss)
case .error(let error):
UpdateErrorView(error: error, dismiss: dismiss)
}
}
.frame(width: 300)
}
}
fileprivate struct DetectedBackgroundUpdateView: View {
let version: String
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "update.popover.updateAvailable", defaultValue: "Update Available"))
.font(.system(size: 13, weight: .semibold))
HStack(spacing: 6) {
Text(String(localized: "update.popover.version", defaultValue: "Version:"))
.foregroundColor(.secondary)
.frame(width: 60, alignment: .trailing)
Text(version)
}
.font(.system(size: 11))
}
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text(String(localized: "update.popover.checking", defaultValue: "Checking for updates…"))
.font(.system(size: 13))
}
}
.padding(16)
}
}
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(String(localized: "update.popover.enableAutoUpdates", defaultValue: "Enable automatic updates?"))
.font(.system(size: 13, weight: .semibold))
Text(String(localized: "update.popover.autoUpdatesDescription", defaultValue: "cmux can automatically check for updates in the background."))
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 8) {
Button(String(localized: "common.notNow", defaultValue: "Not Now")) {
request.reply(SUUpdatePermissionResponse(
automaticUpdateChecks: false,
sendSystemProfile: false))
dismiss()
}
.keyboardShortcut(.cancelAction)
Spacer()
Button(String(localized: "common.allow", defaultValue: "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(String(localized: "update.popover.checking", defaultValue: "Checking for updates…"))
.font(.system(size: 13))
}
HStack {
Spacer()
Button(String(localized: "common.cancel", defaultValue: "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(String(localized: "update.popover.updateAvailable", defaultValue: "Update Available"))
.font(.system(size: 13, weight: .semibold))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(String(localized: "update.popover.version", defaultValue: "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(String(localized: "update.popover.size", defaultValue: "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(String(localized: "update.popover.released", defaultValue: "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(String(localized: "common.skip", defaultValue: "Skip")) {
update.reply(.skip)
dismiss()
}
.controlSize(.small)
Button(String(localized: "common.later", defaultValue: "Later")) {
update.reply(.dismiss)
dismiss()
}
.controlSize(.small)
.keyboardShortcut(.cancelAction)
Spacer()
Button(String(localized: "common.installAndRelaunch", defaultValue: "Install and Relaunch")) {
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(String(localized: "update.popover.downloadingUpdate", defaultValue: "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(String(localized: "common.cancel", defaultValue: "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(String(localized: "update.popover.preparingUpdate", defaultValue: "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 InstallingView: View {
let installing: UpdateState.Installing
let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "update.popover.restartRequired", defaultValue: "Restart Required"))
.font(.system(size: 13, weight: .semibold))
Text(String(localized: "update.popover.restartRequired.message", defaultValue: "The update is ready. Please restart the application to complete the installation."))
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Button(String(localized: "common.restartLater", defaultValue: "Restart Later")) {
installing.dismiss()
dismiss()
}
.keyboardShortcut(.cancelAction)
.controlSize(.small)
Spacer()
Button(String(localized: "common.restartNow", defaultValue: "Restart Now")) {
installing.retryTerminatingApplication()
dismiss()
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
.padding(16)
}
}
fileprivate struct NotFoundView: View {
let notFound: UpdateState.NotFound
let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "update.popover.noUpdatesFound", defaultValue: "No Updates Found"))
.font(.system(size: 13, weight: .semibold))
Text(String(localized: "update.popover.noUpdatesFound.message", defaultValue: "You're already running the latest version."))
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Spacer()
Button(String(localized: "common.ok", defaultValue: "OK")) {
notFound.acknowledgement()
dismiss()
}
.keyboardShortcut(.defaultAction)
.controlSize(.small)
}
}
.padding(16)
}
}
fileprivate struct UpdateErrorView: View {
let error: UpdateState.Error
let dismiss: DismissAction
var body: some View {
let title = UpdateViewModel.userFacingErrorTitle(for: error.error)
let message = UpdateViewModel.userFacingErrorMessage(for: error.error)
let details = UpdateViewModel.errorDetails(
for: error.error,
technicalDetails: error.technicalDetails,
feedURLString: error.feedURLString
)
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(title)
.font(.system(size: 13, weight: .semibold))
}
Text(message)
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
VStack(alignment: .leading, spacing: 6) {
Text(String(localized: "update.popover.details", defaultValue: "Details"))
.font(.system(size: 11, weight: .semibold))
Text(details)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
}
HStack(spacing: 8) {
Button(String(localized: "common.copyDetails", defaultValue: "Copy Details")) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(details, forType: .string)
}
.controlSize(.small)
Button(String(localized: "common.ok", defaultValue: "OK")) {
error.dismiss()
dismiss()
}
.keyboardShortcut(.cancelAction)
.controlSize(.small)
Spacer()
Button(String(localized: "common.retry", defaultValue: "Retry")) {
error.retry()
dismiss()
}
.keyboardShortcut(.defaultAction)
.controlSize(.small)
}
}
.padding(16)
}
}