* test: cover cached update pill first-click flow * fix: use cached sidebar update popover
506 lines
18 KiB
Swift
506 lines
18 KiB
Swift
import AppKit
|
|
import Sparkle
|
|
import SwiftUI
|
|
|
|
/// Popover view that displays detailed update information and actions.
|
|
struct UpdatePopoverView: View {
|
|
@ObservedObject var model: UpdateViewModel
|
|
let dismiss: () -> Void
|
|
|
|
init(model: UpdateViewModel, dismiss: @escaping () -> Void = {}) {
|
|
self.model = model
|
|
self.dismiss = dismiss
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
switch model.effectiveState {
|
|
case .idle:
|
|
if model.showsDetectedBackgroundUpdate,
|
|
let detectedItem = model.detectedUpdateItem {
|
|
DetectedBackgroundUpdateView(item: detectedItem, dismiss: dismiss)
|
|
} else if let detectedVersion = model.detectedUpdateVersion,
|
|
model.showsDetectedBackgroundUpdate {
|
|
DetectedBackgroundUpdatePendingView(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 UpdateMetadataView: View {
|
|
let item: SUAppcastItem
|
|
let labelWidth: CGFloat
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 6) {
|
|
Text(String(localized: "update.popover.version", defaultValue: "Version:"))
|
|
.foregroundColor(.secondary)
|
|
.frame(width: labelWidth, alignment: .trailing)
|
|
Text(item.displayVersionString)
|
|
}
|
|
.font(.system(size: 11))
|
|
|
|
if item.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(item.contentLength), countStyle: .file))
|
|
}
|
|
.font(.system(size: 11))
|
|
}
|
|
|
|
if let date = item.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)
|
|
}
|
|
}
|
|
|
|
fileprivate struct UpdateReleaseNotesLink: View {
|
|
let notes: UpdateState.ReleaseNotes
|
|
|
|
var body: some View {
|
|
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 DetectedBackgroundUpdateView: View {
|
|
let item: SUAppcastItem
|
|
let dismiss: () -> Void
|
|
|
|
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))
|
|
|
|
UpdateMetadataView(item: item, labelWidth: labelWidth)
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
Button(String(localized: "common.later", defaultValue: "Later")) {
|
|
dismiss()
|
|
}
|
|
.controlSize(.small)
|
|
.keyboardShortcut(.cancelAction)
|
|
|
|
Spacer()
|
|
|
|
Button(String(localized: "common.installAndRelaunch", defaultValue: "Install and Relaunch")) {
|
|
AppDelegate.shared?.attemptUpdate(nil)
|
|
dismiss()
|
|
}
|
|
.keyboardShortcut(.defaultAction)
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
.padding(16)
|
|
|
|
if let notes = UpdateState.ReleaseNotes(displayVersionString: item.displayVersionString) {
|
|
Divider()
|
|
UpdateReleaseNotesLink(notes: notes)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate struct DetectedBackgroundUpdatePendingView: 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: () -> Void
|
|
|
|
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: () -> Void
|
|
|
|
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: () -> Void
|
|
|
|
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))
|
|
|
|
UpdateMetadataView(item: update.appcastItem, labelWidth: labelWidth)
|
|
}
|
|
|
|
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()
|
|
UpdateReleaseNotesLink(notes: notes)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate struct DownloadingView: View {
|
|
let download: UpdateState.Downloading
|
|
let dismiss: () -> Void
|
|
|
|
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: () -> Void
|
|
|
|
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: () -> Void
|
|
|
|
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: () -> Void
|
|
|
|
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)
|
|
}
|
|
}
|