Add GitHub update checker and installer

This commit is contained in:
世界
2026-03-30 23:02:42 +08:00
parent 6b790c7a80
commit 63d1d0fe3f
15 changed files with 1663 additions and 2 deletions
@@ -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