Add GitHub update checker and installer
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
#if os(macOS)
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import os
|
||||
import SwiftUI
|
||||
|
||||
private let logger = Logger(category: "UpdateManager")
|
||||
|
||||
@MainActor
|
||||
public class UpdateManager: ObservableObject {
|
||||
private static let minimumSemver = "0.0.0-0"
|
||||
|
||||
@Published public var updateInfo: UpdateInfo?
|
||||
@Published public var isUpdateSheetPresented = false
|
||||
@Published public var isChecking = false
|
||||
@Published public var isDownloading = false
|
||||
@Published public var downloadProgress: Double = 0
|
||||
@Published public var alert: AlertState?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func updateTrackChanged(to track: UpdateTrack) async {
|
||||
await SharedPreferences.updateTrack.set(track.rawValue)
|
||||
guard let updateInfo, !track.allows(updateInfo) else {
|
||||
return
|
||||
}
|
||||
await setUpdateInfo(nil)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func loadCachedUpdate() async -> Bool {
|
||||
let cached = await SharedPreferences.cachedUpdateInfo.get()
|
||||
guard !cached.isEmpty,
|
||||
let data = cached.data(using: .utf8),
|
||||
let info = try? JSONDecoder().decode(UpdateInfo.self, from: data)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
let track = await currentTrack()
|
||||
guard track.allows(info),
|
||||
shouldKeepCachedUpdate(info.versionName, track: track, currentVersion: Bundle.main.version)
|
||||
else {
|
||||
await setUpdateInfo(nil)
|
||||
return false
|
||||
}
|
||||
|
||||
updateInfo = info
|
||||
return await shouldAutomaticallyPresent(info)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func checkForUpdate(presentIfFound: Bool = false, force: Bool = false, showsAlertOnFailure: Bool = true) async -> Bool {
|
||||
do {
|
||||
guard let info = try await refreshUpdateInfo(force: force, showsAlertOnFailure: showsAlertOnFailure) else {
|
||||
return false
|
||||
}
|
||||
guard presentIfFound else {
|
||||
return false
|
||||
}
|
||||
return await shouldAutomaticallyPresent(info)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public func showUpdateSheet() async {
|
||||
guard let updateInfo else { return }
|
||||
await SharedPreferences.lastShownUpdateVersion.set(updateInfo.versionName)
|
||||
isUpdateSheetPresented = true
|
||||
}
|
||||
|
||||
public func dismissUpdateSheet() {
|
||||
guard isUpdateSheetPresented else { return }
|
||||
isUpdateSheetPresented = false
|
||||
}
|
||||
|
||||
public func downloadAndInstall(environments: ExtensionEnvironments) async {
|
||||
guard let updateInfo else { return }
|
||||
|
||||
isDownloading = true
|
||||
downloadProgress = 0
|
||||
alert = nil
|
||||
|
||||
do {
|
||||
let pkgURL = try await PKGDownloader.download(from: updateInfo.downloadURL, expectedSize: updateInfo.fileSize) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.downloadProgress = progress
|
||||
}
|
||||
}
|
||||
|
||||
let authRef = try PKGInstaller.authorize()
|
||||
try await Task.detached {
|
||||
try PKGInstaller.install(pkgPath: pkgURL.path, authorization: authRef)
|
||||
}.value
|
||||
|
||||
var profile = environments.extensionProfile
|
||||
if profile == nil {
|
||||
await environments.reload()
|
||||
profile = environments.extensionProfile
|
||||
}
|
||||
if let profile, profile.status.isConnected {
|
||||
try? await profile.stop()
|
||||
var waitCount = 0
|
||||
while profile.status != .disconnected, waitCount < 10 {
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
waitCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try PKGInstaller.scheduleInstalledApplicationRelaunch()
|
||||
} catch {
|
||||
logger.warning("relaunch failed: \(error.localizedDescription)")
|
||||
}
|
||||
exit(0)
|
||||
} catch PKGInstallerError.authorizationCancelled {
|
||||
isDownloading = false
|
||||
} catch {
|
||||
isDownloading = false
|
||||
logger.error("update failed: \(error.localizedDescription)")
|
||||
alert = AlertState(action: "install update", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshUpdateInfo(force: Bool = false, showsAlertOnFailure: Bool = true) async throws -> UpdateInfo? {
|
||||
guard !isChecking else {
|
||||
throw CancellationError()
|
||||
}
|
||||
isChecking = true
|
||||
if showsAlertOnFailure {
|
||||
alert = nil
|
||||
}
|
||||
defer { isChecking = false }
|
||||
|
||||
do {
|
||||
let track = await currentTrack()
|
||||
let info = try await GitHubUpdateChecker.checkAsync(track: track, force: force)
|
||||
let currentTrack = await currentTrack()
|
||||
guard track == currentTrack else {
|
||||
throw CancellationError()
|
||||
}
|
||||
await setUpdateInfo(info)
|
||||
return info
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
logger.error("check for update failed: \(error.localizedDescription)")
|
||||
if showsAlertOnFailure {
|
||||
alert = AlertState(action: "check for update", error: error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func currentTrack() async -> UpdateTrack {
|
||||
let trackString = await SharedPreferences.updateTrack.get()
|
||||
return UpdateTrack.resolved(from: trackString)
|
||||
}
|
||||
|
||||
private func shouldAutomaticallyPresent(_ updateInfo: UpdateInfo) async -> Bool {
|
||||
let lastShownVersion = await SharedPreferences.lastShownUpdateVersion.get()
|
||||
return lastShownVersion != updateInfo.versionName
|
||||
}
|
||||
|
||||
private func shouldKeepCachedUpdate(_ version: String, track: UpdateTrack, currentVersion: String) -> Bool {
|
||||
guard Self.isValidSemver(version) else {
|
||||
return false
|
||||
}
|
||||
if LibboxCompareSemver(version, currentVersion) {
|
||||
return true
|
||||
}
|
||||
return track == .stable && Self.isValidPrereleaseSemver(currentVersion)
|
||||
}
|
||||
|
||||
private static func isValidSemver(_ version: String) -> Bool {
|
||||
let trimmedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmedVersion == minimumSemver || LibboxCompareSemver(trimmedVersion, minimumSemver)
|
||||
}
|
||||
|
||||
private static func isValidPrereleaseSemver(_ version: String) -> Bool {
|
||||
let trimmedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmedVersion.contains("-") && isValidSemver(trimmedVersion)
|
||||
}
|
||||
|
||||
private func setUpdateInfo(_ updateInfo: UpdateInfo?) async {
|
||||
self.updateInfo = updateInfo
|
||||
|
||||
guard let updateInfo,
|
||||
let data = try? JSONEncoder().encode(updateInfo)
|
||||
else {
|
||||
dismissUpdateSheet()
|
||||
await SharedPreferences.cachedUpdateInfo.set("")
|
||||
await SharedPreferences.lastShownUpdateVersion.set("")
|
||||
return
|
||||
}
|
||||
await SharedPreferences.cachedUpdateInfo.set(String(decoding: data, as: UTF8.self))
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,194 @@
|
||||
#if os(macOS)
|
||||
|
||||
enum GitHubEmoji {
|
||||
static func replaceShortcodes(in text: String) -> String {
|
||||
text.replacing(/:([\w+-]+):/) { match in
|
||||
shortcodes[String(match.1)] ?? String(match.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Common GitHub / gitmoji shortcodes → Unicode emoji
|
||||
private static let shortcodes: [String: String] = [
|
||||
// Gitmoji (commit conventions)
|
||||
"art": "🎨",
|
||||
"zap": "⚡",
|
||||
"fire": "🔥",
|
||||
"bug": "🐛",
|
||||
"ambulance": "🚑",
|
||||
"sparkles": "✨",
|
||||
"memo": "📝",
|
||||
"rocket": "🚀",
|
||||
"lipstick": "💄",
|
||||
"tada": "🎉",
|
||||
"white_check_mark": "✅",
|
||||
"lock": "🔒",
|
||||
"closed_lock_with_key": "🔐",
|
||||
"bookmark": "🔖",
|
||||
"rotating_light": "🚨",
|
||||
"construction": "🚧",
|
||||
"green_heart": "💚",
|
||||
"arrow_down": "⬇️",
|
||||
"arrow_up": "⬆️",
|
||||
"pushpin": "📌",
|
||||
"construction_worker": "👷",
|
||||
"chart_with_upwards_trend": "📈",
|
||||
"recycle": "♻️",
|
||||
"heavy_plus_sign": "➕",
|
||||
"heavy_minus_sign": "➖",
|
||||
"wrench": "🔧",
|
||||
"hammer": "🔨",
|
||||
"globe_with_meridians": "🌐",
|
||||
"pencil2": "✏️",
|
||||
"pencil": "📝",
|
||||
"poop": "💩",
|
||||
"rewind": "⏪",
|
||||
"twisted_rightwards_arrows": "🔀",
|
||||
"package": "📦",
|
||||
"alien": "👽",
|
||||
"truck": "🚚",
|
||||
"page_facing_up": "📄",
|
||||
"boom": "💥",
|
||||
"bento": "🍱",
|
||||
"wheelchair": "♿",
|
||||
"bulb": "💡",
|
||||
"beers": "🍻",
|
||||
"speech_balloon": "💬",
|
||||
"card_file_box": "🗃️",
|
||||
"loud_sound": "🔊",
|
||||
"mute": "🔇",
|
||||
"busts_in_silhouette": "👥",
|
||||
"children_crossing": "🚸",
|
||||
"building_construction": "🏗️",
|
||||
"iphone": "📱",
|
||||
"clown_face": "🤡",
|
||||
"egg": "🥚",
|
||||
"see_no_evil": "🙈",
|
||||
"camera_flash": "📸",
|
||||
"alembic": "⚗️",
|
||||
"mag": "🔍",
|
||||
"label": "🏷️",
|
||||
"seedling": "🌱",
|
||||
"triangular_flag_on_post": "🚩",
|
||||
"goal_net": "🥅",
|
||||
"dizzy": "💫",
|
||||
"wastebasket": "🗑️",
|
||||
"passport_control": "🛂",
|
||||
"adhesive_bandage": "🩹",
|
||||
"monocle_face": "🧐",
|
||||
"coffin": "⚰️",
|
||||
"test_tube": "🧪",
|
||||
"necktie": "👔",
|
||||
"stethoscope": "🩺",
|
||||
"bricks": "🧱",
|
||||
"technologist": "🧑💻",
|
||||
|
||||
// Common faces & people
|
||||
"smile": "😄",
|
||||
"laughing": "😆",
|
||||
"blush": "😊",
|
||||
"smiley": "😃",
|
||||
"grinning": "😀",
|
||||
"wink": "😉",
|
||||
"heart_eyes": "😍",
|
||||
"kissing_heart": "😘",
|
||||
"sunglasses": "😎",
|
||||
"thinking": "🤔",
|
||||
"thumbsup": "👍",
|
||||
"+1": "👍",
|
||||
"thumbsdown": "👎",
|
||||
"-1": "👎",
|
||||
"clap": "👏",
|
||||
"pray": "🙏",
|
||||
"wave": "👋",
|
||||
"raised_hands": "🙌",
|
||||
"ok_hand": "👌",
|
||||
"point_up": "☝️",
|
||||
"point_down": "👇",
|
||||
"point_left": "👈",
|
||||
"point_right": "👉",
|
||||
"muscle": "💪",
|
||||
|
||||
// Hearts & symbols
|
||||
"heart": "❤️",
|
||||
"broken_heart": "💔",
|
||||
"star": "⭐",
|
||||
"star2": "🌟",
|
||||
"warning": "⚠️",
|
||||
"x": "❌",
|
||||
"heavy_check_mark": "✔️",
|
||||
"question": "❓",
|
||||
"exclamation": "❗",
|
||||
"bangbang": "‼️",
|
||||
"interrobang": "⁉️",
|
||||
"100": "💯",
|
||||
|
||||
// Objects & nature
|
||||
"gear": "⚙️",
|
||||
"key": "🔑",
|
||||
"link": "🔗",
|
||||
"shield": "🛡️",
|
||||
"bell": "🔔",
|
||||
"no_bell": "🔕",
|
||||
"clipboard": "📋",
|
||||
"books": "📚",
|
||||
"book": "📖",
|
||||
"computer": "💻",
|
||||
"desktop_computer": "🖥️",
|
||||
"electric_plug": "🔌",
|
||||
"battery": "🔋",
|
||||
"floppy_disk": "💾",
|
||||
"file_folder": "📁",
|
||||
"open_file_folder": "📂",
|
||||
"calendar": "📅",
|
||||
"clock1": "🕐",
|
||||
"hourglass": "⌛",
|
||||
"stopwatch": "⏱️",
|
||||
"timer_clock": "⏲️",
|
||||
"inbox_tray": "📥",
|
||||
"outbox_tray": "📤",
|
||||
"envelope": "✉️",
|
||||
"email": "📧",
|
||||
"newspaper": "📰",
|
||||
"scroll": "📜",
|
||||
"trophy": "🏆",
|
||||
"medal_sports": "🏅",
|
||||
"gem": "💎",
|
||||
"hammer_and_wrench": "🛠️",
|
||||
"nut_and_bolt": "🔩",
|
||||
"chains": "⛓️",
|
||||
"magnet": "🧲",
|
||||
"trash": "🗑️",
|
||||
"world_map": "🗺️",
|
||||
|
||||
// Arrows & indicators
|
||||
"arrow_right": "➡️",
|
||||
"arrow_left": "⬅️",
|
||||
"arrow_upper_right": "↗️",
|
||||
"arrow_lower_right": "↘️",
|
||||
"arrows_counterclockwise": "🔄",
|
||||
"back": "🔙",
|
||||
"new": "🆕",
|
||||
"up": "🆙",
|
||||
"cool": "🆒",
|
||||
"free": "🆓",
|
||||
"information_source": "ℹ️",
|
||||
|
||||
// Nature & weather
|
||||
"sunny": "☀️",
|
||||
"cloud": "☁️",
|
||||
"snowflake": "❄️",
|
||||
"rainbow": "🌈",
|
||||
"ocean": "🌊",
|
||||
"leaves": "🍃",
|
||||
"four_leaf_clover": "🍀",
|
||||
"evergreen_tree": "🌲",
|
||||
"deciduous_tree": "🌳",
|
||||
"cactus": "🌵",
|
||||
"cherry_blossom": "🌸",
|
||||
"rose": "🌹",
|
||||
"sunflower": "🌻",
|
||||
"herb": "🌿",
|
||||
]
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -31,6 +31,12 @@ public struct AppView: View {
|
||||
@State private var menuBarExtraInBackground = false
|
||||
@State private var helperStatusLoaded = false
|
||||
@State private var rootHelperRegistrationStatus: SMAppService.Status = .notRegistered
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@EnvironmentObject private var updateManager: UpdateManager
|
||||
@State private var updateTrack: UpdateTrack = .stable
|
||||
@State private var checkUpdateEnabled = false
|
||||
@State private var cacheSize: Int64 = 0
|
||||
@State private var cacheSizeText = ""
|
||||
#endif
|
||||
|
||||
@State private var alert: AlertState?
|
||||
@@ -91,6 +97,111 @@ public struct AppView: View {
|
||||
}
|
||||
|
||||
if Variant.useSystemExtension {
|
||||
FormTextItem("Cache Size", cacheSizeText)
|
||||
if cacheSize > 0 {
|
||||
// Safe: System Extension's working directory is in its own container
|
||||
// (/var/root/Library/Containers/…), not under the app's cacheDirectory.
|
||||
FormButton(role: .destructive) {
|
||||
Task.detached {
|
||||
let cacheDir = FilePath.cacheDirectory
|
||||
if let contents = try? FileManager.default.contentsOfDirectory(
|
||||
at: cacheDir,
|
||||
includingPropertiesForKeys: nil
|
||||
) {
|
||||
for item in contents {
|
||||
try? FileManager.default.removeItem(at: item)
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
cacheSize = 0
|
||||
cacheSizeText = ByteCountFormatter.string(fromByteCount: 0, countStyle: .file)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Clear Cache", systemImage: "trash")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if Variant.useSystemExtension {
|
||||
Section("Update Settings") {
|
||||
Picker("Update Track", selection: $updateTrack) {
|
||||
Text("Stable").tag(UpdateTrack.stable)
|
||||
Text("Beta").tag(UpdateTrack.beta)
|
||||
}
|
||||
.onChangeCompat(of: updateTrack) { newValue in
|
||||
Task {
|
||||
await updateManager.updateTrackChanged(to: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Automatic Update Check", isOn: $checkUpdateEnabled)
|
||||
.onChangeCompat(of: checkUpdateEnabled) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.checkUpdateEnabled.set(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
FormButton {
|
||||
Task {
|
||||
do {
|
||||
if try await updateManager.refreshUpdateInfo() != nil {
|
||||
await updateManager.showUpdateSheet()
|
||||
} else {
|
||||
alert = AlertState(
|
||||
title: String(localized: "Check Update"),
|
||||
message: String(localized: "No updates available")
|
||||
)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} label: {
|
||||
if updateManager.isChecking {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Checking...")
|
||||
}
|
||||
} else {
|
||||
Label("Check Update", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
.disabled(updateManager.isChecking)
|
||||
.contextMenu {
|
||||
Button("Force Show Latest Version as Update") {
|
||||
Task {
|
||||
do {
|
||||
if try await updateManager.refreshUpdateInfo(force: true) != nil {
|
||||
await updateManager.showUpdateSheet()
|
||||
} else {
|
||||
alert = AlertState(
|
||||
title: String(localized: "Check Update"),
|
||||
message: String(localized: "No updates available")
|
||||
)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
.disabled(updateManager.isChecking)
|
||||
}
|
||||
|
||||
if let info = updateManager.updateInfo {
|
||||
FormButton {
|
||||
Task {
|
||||
await updateManager.showUpdateSheet()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Update", systemImage: "arrow.down.circle")
|
||||
Spacer()
|
||||
Text("v\(info.versionName)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("System Extension") {
|
||||
FormButton {
|
||||
Task {
|
||||
@@ -163,7 +274,10 @@ public struct AppView: View {
|
||||
}
|
||||
}
|
||||
.alert($alert)
|
||||
.navigationTitle("App")
|
||||
#if os(macOS)
|
||||
.alert($updateManager.alert)
|
||||
#endif
|
||||
.navigationTitle("App")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
@@ -174,12 +288,18 @@ public struct AppView: View {
|
||||
#if os(macOS)
|
||||
startAtLogin = SMAppService.mainApp.status == .enabled
|
||||
menuBarExtraInBackground = await SharedPreferences.menuBarExtraInBackground.get()
|
||||
if Variant.useSystemExtension {
|
||||
let trackString = await SharedPreferences.updateTrack.get()
|
||||
updateTrack = UpdateTrack.resolved(from: trackString)
|
||||
checkUpdateEnabled = await SharedPreferences.checkUpdateEnabled.get()
|
||||
}
|
||||
#endif
|
||||
isLoading = false
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension {
|
||||
refreshHelperStatus()
|
||||
helperStatusLoaded = true
|
||||
refreshCacheSize()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -332,5 +452,32 @@ public struct AppView: View {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Preferences.app"))
|
||||
}
|
||||
|
||||
private func refreshCacheSize() {
|
||||
Task.detached {
|
||||
let size = Self.calculateDirSize(FilePath.cacheDirectory)
|
||||
await MainActor.run {
|
||||
cacheSize = size
|
||||
cacheSizeText = ByteCountFormatter.string(fromByteCount: size, countStyle: .file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func calculateDirSize(_ dir: URL) -> Int64 {
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: [.fileSizeKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return 0
|
||||
}
|
||||
var size: Int64 = 0
|
||||
for case let fileURL as URL in enumerator {
|
||||
if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||
size += Int64(fileSize)
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#if os(macOS)
|
||||
|
||||
import AppKit
|
||||
import Library
|
||||
import MarkdownUI
|
||||
import SwiftUI
|
||||
|
||||
public struct UpdateSheet: View {
|
||||
@ObservedObject var updateManager: UpdateManager
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
|
||||
public init(updateManager: UpdateManager) {
|
||||
self.updateManager = updateManager
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("Check Update")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text("New version available: \(updateManager.updateInfo?.versionName ?? "")")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if let releaseNotes = updateManager.updateInfo?.releaseNotes, !releaseNotes.isEmpty {
|
||||
ScrollView {
|
||||
Markdown(GitHubEmoji.replaceShortcodes(in: releaseNotes))
|
||||
.markdownTheme(.gitHub.text {
|
||||
FontSize(10)
|
||||
})
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 300)
|
||||
}
|
||||
|
||||
if updateManager.isDownloading {
|
||||
ProgressView(value: updateManager.downloadProgress)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
if let releaseURL = updateManager.updateInfo?.releaseURL,
|
||||
let url = URL(string: releaseURL)
|
||||
{
|
||||
Button("View Release") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Cancel", role: .cancel) {
|
||||
updateManager.dismissUpdateSheet()
|
||||
}
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
.disabled(updateManager.isDownloading)
|
||||
|
||||
Button("Update") {
|
||||
Task {
|
||||
await updateManager.downloadAndInstall(environments: environments)
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(updateManager.isDownloading)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(minWidth: 480)
|
||||
.interactiveDismissDisabled(updateManager.isDownloading)
|
||||
.alert($updateManager.alert)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user