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 menuBarExtraInBackground = false
|
||||||
@State private var helperStatusLoaded = false
|
@State private var helperStatusLoaded = false
|
||||||
@State private var rootHelperRegistrationStatus: SMAppService.Status = .notRegistered
|
@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
|
#endif
|
||||||
|
|
||||||
@State private var alert: AlertState?
|
@State private var alert: AlertState?
|
||||||
@@ -91,6 +97,111 @@ public struct AppView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if Variant.useSystemExtension {
|
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") {
|
Section("System Extension") {
|
||||||
FormButton {
|
FormButton {
|
||||||
Task {
|
Task {
|
||||||
@@ -163,6 +274,9 @@ public struct AppView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert($alert)
|
.alert($alert)
|
||||||
|
#if os(macOS)
|
||||||
|
.alert($updateManager.alert)
|
||||||
|
#endif
|
||||||
.navigationTitle("App")
|
.navigationTitle("App")
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -174,12 +288,18 @@ public struct AppView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
startAtLogin = SMAppService.mainApp.status == .enabled
|
startAtLogin = SMAppService.mainApp.status == .enabled
|
||||||
menuBarExtraInBackground = await SharedPreferences.menuBarExtraInBackground.get()
|
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
|
#endif
|
||||||
isLoading = false
|
isLoading = false
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
if Variant.useSystemExtension {
|
if Variant.useSystemExtension {
|
||||||
refreshHelperStatus()
|
refreshHelperStatus()
|
||||||
helperStatusLoaded = true
|
helperStatusLoaded = true
|
||||||
|
refreshCacheSize()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -332,5 +452,32 @@ public struct AppView: View {
|
|||||||
NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Preferences.app"))
|
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
|
#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
|
||||||
@@ -121,6 +121,16 @@ public enum SharedPreferences {
|
|||||||
try await batchDelete([alwaysOn.name, onDemandEnabled.name, onDemandRules.name])
|
try await batchDelete([alwaysOn.name, onDemandEnabled.name, onDemandRules.name])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update (macOS standalone)
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
public static let checkUpdateEnabled = Preference<Bool>("check_update_enabled", defaultValue: false)
|
||||||
|
public static let updateCheckPrompted = Preference<Bool>("update_check_prompted", defaultValue: false)
|
||||||
|
public static let updateTrack = Preference<String>("update_track", defaultValue: "")
|
||||||
|
public static let cachedUpdateInfo = Preference<String>("cached_update_info", defaultValue: "")
|
||||||
|
public static let lastShownUpdateVersion = Preference<String>("last_shown_update_version", defaultValue: "")
|
||||||
|
#endif
|
||||||
|
|
||||||
// Core
|
// Core
|
||||||
|
|
||||||
public static let disableDeprecatedWarnings = Preference<Bool>("disable_deprecated_warnings", defaultValue: false)
|
public static let disableDeprecatedWarnings = Preference<Bool>("disable_deprecated_warnings", defaultValue: false)
|
||||||
|
|||||||
@@ -45,7 +45,41 @@ public class HTTPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func writeTo(_ url: String?, path: String, progress: ((Int64, Int64) -> Void)? = nil) throws {
|
||||||
|
#if DEBUG
|
||||||
|
precondition(!Thread.isMainThread, "HTTPClient.writeTo(...) must not be called on the main thread")
|
||||||
|
#endif
|
||||||
|
let request = client.newRequest()!
|
||||||
|
request.setUserAgent(HTTPClient.userAgent)
|
||||||
|
try request.setURL(url)
|
||||||
|
let response = try request.execute()
|
||||||
|
if let progress {
|
||||||
|
let handler = WriteToProgressHandler(progress)
|
||||||
|
try response.writeTo(withProgress: path, handler: handler)
|
||||||
|
} else {
|
||||||
|
try response.write(to: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func writeToAsync(_ url: String?, path: String, progress: ((Int64, Int64) -> Void)? = nil) async throws {
|
||||||
|
try await BlockingIO.run {
|
||||||
|
try HTTPClient().writeTo(url, path: path, progress: progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
client.close()
|
client.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class WriteToProgressHandler: NSObject, LibboxHTTPResponseWriteToProgressHandlerProtocol {
|
||||||
|
private let handler: (Int64, Int64) -> Void
|
||||||
|
|
||||||
|
init(_ handler: @escaping (Int64, Int64) -> Void) {
|
||||||
|
self.handler = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(_ progress: Int64, total: Int64) {
|
||||||
|
handler(progress, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
import Libbox
|
||||||
|
|
||||||
|
public enum GitHubUpdateChecker {
|
||||||
|
private static let releasesURL = "https://api.github.com/repos/SagerNet/sing-box/releases"
|
||||||
|
private static let releasesPerPage = 100
|
||||||
|
private static let minimumSemver = "0.0.0-0"
|
||||||
|
|
||||||
|
public static func checkAsync(track: UpdateTrack, force: Bool = false) async throws -> UpdateInfo? {
|
||||||
|
try await BlockingIO.run {
|
||||||
|
try check(track: track, force: force)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func check(track: UpdateTrack, force: Bool = false) throws -> UpdateInfo? {
|
||||||
|
let client = HTTPClient()
|
||||||
|
guard let releases = try fetchReleases(client: client, track: track) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let currentVersion = Bundle.main.version
|
||||||
|
|
||||||
|
var bestRelease: GitHubRelease?
|
||||||
|
var bestVersion: String?
|
||||||
|
var bestAsset: GitHubAsset?
|
||||||
|
|
||||||
|
for release in releases {
|
||||||
|
if release.draft { continue }
|
||||||
|
if track == .stable, release.prerelease { continue }
|
||||||
|
guard let pkgAsset = findPKGAsset(in: release.assets) else { continue }
|
||||||
|
|
||||||
|
let version = release.tagName.hasPrefix("v")
|
||||||
|
? String(release.tagName.dropFirst())
|
||||||
|
: release.tagName
|
||||||
|
|
||||||
|
guard shouldIncludeRelease(
|
||||||
|
version: version,
|
||||||
|
currentVersion: currentVersion,
|
||||||
|
track: track,
|
||||||
|
force: force
|
||||||
|
) else { continue }
|
||||||
|
|
||||||
|
if let best = bestVersion {
|
||||||
|
guard LibboxCompareSemver(version, best) else { continue }
|
||||||
|
}
|
||||||
|
|
||||||
|
bestRelease = release
|
||||||
|
bestVersion = version
|
||||||
|
bestAsset = pkgAsset
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let release = bestRelease,
|
||||||
|
let version = bestVersion,
|
||||||
|
let pkgAsset = bestAsset
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return UpdateInfo(
|
||||||
|
versionName: version,
|
||||||
|
releaseURL: release.htmlURL,
|
||||||
|
downloadURL: pkgAsset.browserDownloadURL,
|
||||||
|
releaseNotes: release.body,
|
||||||
|
isPrerelease: release.prerelease,
|
||||||
|
fileSize: pkgAsset.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func findPKGAsset(in assets: [GitHubAsset]) -> GitHubAsset? {
|
||||||
|
let pkgAssets = assets.filter { $0.name.hasSuffix(".pkg") }
|
||||||
|
|
||||||
|
let preferred = preferredPKGVariant()
|
||||||
|
|
||||||
|
if let match = pkgAssets.first(where: { $0.name.contains(preferred) }) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
if let universal = pkgAssets.first(where: { $0.name.contains("Universal") }) {
|
||||||
|
return universal
|
||||||
|
}
|
||||||
|
return pkgAssets.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func preferredPKGVariant() -> String {
|
||||||
|
if let hostSupportsArm64 = hostSupportsArm64() {
|
||||||
|
return hostSupportsArm64 ? "Apple" : "Intel"
|
||||||
|
}
|
||||||
|
|
||||||
|
#if arch(arm64)
|
||||||
|
return "Apple"
|
||||||
|
#else
|
||||||
|
return "Intel"
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hostSupportsArm64() -> Bool? {
|
||||||
|
var value: Int32 = 0
|
||||||
|
var size = MemoryLayout.size(ofValue: value)
|
||||||
|
let result = withUnsafeMutablePointer(to: &value) {
|
||||||
|
sysctlbyname("hw.optional.arm64", $0, &size, nil, 0)
|
||||||
|
}
|
||||||
|
guard result == 0 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return value != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shouldIncludeRelease(
|
||||||
|
version: String,
|
||||||
|
currentVersion: String,
|
||||||
|
track: UpdateTrack,
|
||||||
|
force: Bool
|
||||||
|
) -> Bool {
|
||||||
|
guard isValidSemver(version) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if force || LibboxCompareSemver(version, currentVersion) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return track == .stable && 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 static func fetchReleases(client: HTTPClient, track: UpdateTrack) throws -> [GitHubRelease]? {
|
||||||
|
var allReleases: [GitHubRelease] = []
|
||||||
|
var page = 1
|
||||||
|
|
||||||
|
while true {
|
||||||
|
let releasesJSON = try client.getString("\(releasesURL)?per_page=\(releasesPerPage)&page=\(page)")
|
||||||
|
guard let data = releasesJSON.data(using: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let pageReleases = try JSONDecoder().decode([GitHubRelease].self, from: data)
|
||||||
|
allReleases.append(contentsOf: pageReleases)
|
||||||
|
|
||||||
|
if track != .stable || pageReleases.count < releasesPerPage {
|
||||||
|
return allReleases
|
||||||
|
}
|
||||||
|
page += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GitHubRelease: Decodable {
|
||||||
|
let tagName: String
|
||||||
|
let htmlURL: String
|
||||||
|
let body: String?
|
||||||
|
let draft: Bool
|
||||||
|
let prerelease: Bool
|
||||||
|
let assets: [GitHubAsset]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case tagName = "tag_name"
|
||||||
|
case htmlURL = "html_url"
|
||||||
|
case body
|
||||||
|
case draft
|
||||||
|
case prerelease
|
||||||
|
case assets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GitHubAsset: Decodable {
|
||||||
|
let name: String
|
||||||
|
let browserDownloadURL: String
|
||||||
|
let size: Int64
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
case browserDownloadURL = "browser_download_url"
|
||||||
|
case size
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum PKGDownloader {
|
||||||
|
public static func download(
|
||||||
|
from url: String,
|
||||||
|
expectedSize: Int64,
|
||||||
|
progress: @escaping (Double) -> Void
|
||||||
|
) async throws -> URL {
|
||||||
|
let updatesDir = FilePath.cacheDirectory.appendingPathComponent("updates", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: updatesDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let filename = URL(string: url)!.lastPathComponent
|
||||||
|
let destination = updatesDir.appendingPathComponent(filename)
|
||||||
|
|
||||||
|
if let attrs = try? FileManager.default.attributesOfItem(atPath: destination.path),
|
||||||
|
let fileSize = attrs[.size] as? Int64,
|
||||||
|
expectedSize > 0, fileSize == expectedSize
|
||||||
|
{
|
||||||
|
progress(1.0)
|
||||||
|
return destination
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean old PKG files
|
||||||
|
if let contents = try? FileManager.default.contentsOfDirectory(at: updatesDir, includingPropertiesForKeys: nil) {
|
||||||
|
for file in contents where file.pathExtension == "pkg" && file.lastPathComponent != filename {
|
||||||
|
try? FileManager.default.removeItem(at: file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try? FileManager.default.removeItem(at: destination)
|
||||||
|
var lastReported = 0.0
|
||||||
|
try await HTTPClient.writeToAsync(url, path: destination.path) { bytesWritten, totalBytes in
|
||||||
|
guard totalBytes > 0 else { return }
|
||||||
|
let current = Double(bytesWritten) / Double(totalBytes)
|
||||||
|
guard current - lastReported >= 0.01 || current >= 1.0 else { return }
|
||||||
|
lastReported = current
|
||||||
|
progress(current)
|
||||||
|
}
|
||||||
|
return destination
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
public enum PKGInstaller {
|
||||||
|
private static let installerExitStatusMarker = "__PKG_INSTALLER_EXIT_STATUS__="
|
||||||
|
|
||||||
|
private typealias ExecuteWithPrivilegesFunc = @convention(c) (
|
||||||
|
AuthorizationRef,
|
||||||
|
UnsafePointer<CChar>,
|
||||||
|
AuthorizationFlags,
|
||||||
|
UnsafePointer<UnsafeMutablePointer<CChar>?>,
|
||||||
|
UnsafeMutablePointer<UnsafeMutablePointer<FILE>?>?
|
||||||
|
) -> OSStatus
|
||||||
|
|
||||||
|
public static func authorize() throws -> AuthorizationRef {
|
||||||
|
var authRef: AuthorizationRef?
|
||||||
|
var status = AuthorizationCreate(nil, nil, [], &authRef)
|
||||||
|
guard status == errAuthorizationSuccess, let authRef else {
|
||||||
|
throw PKGInstallerError.authorizationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
let rightName = kAuthorizationRightExecute
|
||||||
|
var item = AuthorizationItem(name: rightName, valueLength: 0, value: nil, flags: 0)
|
||||||
|
withUnsafeMutablePointer(to: &item) { itemPtr in
|
||||||
|
var rights = AuthorizationRights(count: 1, items: itemPtr)
|
||||||
|
let flags: AuthorizationFlags = [.interactionAllowed, .extendRights, .preAuthorize]
|
||||||
|
status = AuthorizationCopyRights(authRef, &rights, nil, flags, nil)
|
||||||
|
}
|
||||||
|
guard status == errAuthorizationSuccess else {
|
||||||
|
if status == errAuthorizationCanceled {
|
||||||
|
AuthorizationFree(authRef, [])
|
||||||
|
throw PKGInstallerError.authorizationCancelled
|
||||||
|
}
|
||||||
|
AuthorizationFree(authRef, [])
|
||||||
|
throw PKGInstallerError.authorizationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
return authRef
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func install(pkgPath: String, authorization authRef: AuthorizationRef) throws {
|
||||||
|
defer { AuthorizationFree(authRef, []) }
|
||||||
|
|
||||||
|
guard let sym = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "AuthorizationExecuteWithPrivileges") else {
|
||||||
|
throw PKGInstallerError.authorizationFailed
|
||||||
|
}
|
||||||
|
let executeWithPrivileges = unsafeBitCast(sym, to: ExecuteWithPrivilegesFunc.self)
|
||||||
|
|
||||||
|
let escapedPkgPath = shellQuote(pkgPath)
|
||||||
|
let command = "/usr/sbin/installer -pkg \(escapedPkgPath) -target / 2>&1; status=$?; printf '\\n\(installerExitStatusMarker)%d\\n' \"$status\"; exit \"$status\""
|
||||||
|
let tool = "/bin/sh"
|
||||||
|
var cArgs: [UnsafeMutablePointer<CChar>?] = [
|
||||||
|
strdup("-c"), strdup(command), nil,
|
||||||
|
]
|
||||||
|
defer { for i in 0 ..< cArgs.count - 1 {
|
||||||
|
free(cArgs[i])
|
||||||
|
} }
|
||||||
|
|
||||||
|
var pipe: UnsafeMutablePointer<FILE>?
|
||||||
|
let status = executeWithPrivileges(authRef, tool, [], &cArgs, &pipe)
|
||||||
|
guard status == errAuthorizationSuccess else {
|
||||||
|
throw PKGInstallerError.authorizationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = pipe.map(readOutput(from:)) ?? ""
|
||||||
|
let (exitStatus, installerOutput) = parseInstallerOutput(output)
|
||||||
|
guard let exitStatus else {
|
||||||
|
throw PKGInstallerError.installationFailed(installerOutput.isEmpty ? "Installer exited without reporting a status" : installerOutput)
|
||||||
|
}
|
||||||
|
guard exitStatus == 0 else {
|
||||||
|
if installerOutput.isEmpty {
|
||||||
|
throw PKGInstallerError.installationFailed("Installer failed with exit status \(exitStatus)")
|
||||||
|
}
|
||||||
|
throw PKGInstallerError.installationFailed(installerOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func scheduleInstalledApplicationRelaunch() throws {
|
||||||
|
guard let appPath = findInstalledAppPath() else {
|
||||||
|
throw PKGInstallerError.relaunchFailed("Installed app not found in /Applications")
|
||||||
|
}
|
||||||
|
|
||||||
|
let escapedApp = appPath.replacingOccurrences(of: "'", with: "'\\''")
|
||||||
|
let processID = ProcessInfo.processInfo.processIdentifier
|
||||||
|
let command = "while kill -0 \(processID) 2>/dev/null; do sleep 1; done; open '\(escapedApp)' >/dev/null 2>&1"
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(filePath: "/bin/sh")
|
||||||
|
process.arguments = ["-c", command]
|
||||||
|
if let nullHandle = FileHandle(forWritingAtPath: "/dev/null") {
|
||||||
|
process.standardOutput = nullHandle
|
||||||
|
process.standardError = nullHandle
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
throw PKGInstallerError.relaunchFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func findInstalledAppPath() -> String? {
|
||||||
|
if let bundleID = Bundle.main.bundleIdentifier,
|
||||||
|
let contents = try? FileManager.default.contentsOfDirectory(atPath: "/Applications")
|
||||||
|
{
|
||||||
|
for item in contents where item.hasSuffix(".app") {
|
||||||
|
let path = "/Applications/\(item)"
|
||||||
|
if let bundle = Bundle(path: path), bundle.bundleIdentifier == bundleID {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fallback = "/Applications/\(Bundle.main.bundleURL.lastPathComponent)"
|
||||||
|
if FileManager.default.fileExists(atPath: fallback) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shellQuote(_ value: String) -> String {
|
||||||
|
"'\(value.replacingOccurrences(of: "'", with: "'\\''"))'"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readOutput(from pipe: UnsafeMutablePointer<FILE>) -> String {
|
||||||
|
defer { fclose(pipe) }
|
||||||
|
|
||||||
|
var data = Data()
|
||||||
|
let bufferSize = 4096
|
||||||
|
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
|
||||||
|
defer { buffer.deallocate() }
|
||||||
|
|
||||||
|
while true {
|
||||||
|
let count = fread(buffer, 1, bufferSize, pipe)
|
||||||
|
if count > 0 {
|
||||||
|
data.append(buffer, count: count)
|
||||||
|
}
|
||||||
|
if count < bufferSize {
|
||||||
|
if feof(pipe) != 0 || ferror(pipe) != 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(decoding: data, as: UTF8.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseInstallerOutput(_ output: String) -> (Int32?, String) {
|
||||||
|
let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let markerRange = output.range(of: installerExitStatusMarker, options: .backwards) else {
|
||||||
|
return (nil, trimmedOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusText = output[markerRange.upperBound...].prefix { $0.isNumber || $0 == "-" }
|
||||||
|
let installerOutput = String(output[..<markerRange.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return (Int32(String(statusText)), installerOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PKGInstallerError: LocalizedError {
|
||||||
|
case authorizationFailed
|
||||||
|
case authorizationCancelled
|
||||||
|
case installationFailed(String)
|
||||||
|
case relaunchFailed(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .authorizationFailed:
|
||||||
|
return "Authorization failed"
|
||||||
|
case .authorizationCancelled:
|
||||||
|
return "Authorization cancelled"
|
||||||
|
case let .installationFailed(message):
|
||||||
|
return message
|
||||||
|
case let .relaunchFailed(message):
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct UpdateInfo: Codable {
|
||||||
|
public let versionName: String
|
||||||
|
public let releaseURL: String
|
||||||
|
public let downloadURL: String
|
||||||
|
public let releaseNotes: String?
|
||||||
|
public let isPrerelease: Bool
|
||||||
|
public let fileSize: Int64
|
||||||
|
|
||||||
|
public init(
|
||||||
|
versionName: String,
|
||||||
|
releaseURL: String,
|
||||||
|
downloadURL: String,
|
||||||
|
releaseNotes: String?,
|
||||||
|
isPrerelease: Bool,
|
||||||
|
fileSize: Int64
|
||||||
|
) {
|
||||||
|
self.versionName = versionName
|
||||||
|
self.releaseURL = releaseURL
|
||||||
|
self.downloadURL = downloadURL
|
||||||
|
self.releaseNotes = releaseNotes
|
||||||
|
self.isPrerelease = isPrerelease
|
||||||
|
self.fileSize = fileSize
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum UpdateTrack: String, Codable, CaseIterable {
|
||||||
|
case stable
|
||||||
|
case beta
|
||||||
|
|
||||||
|
public static var defaultForCurrentBuild: Self {
|
||||||
|
Bundle.main.version.contains("-") ? .beta : .stable
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func resolved(from rawValue: String) -> Self {
|
||||||
|
guard !rawValue.isEmpty else {
|
||||||
|
return defaultForCurrentBuild
|
||||||
|
}
|
||||||
|
return Self(rawValue: rawValue) ?? defaultForCurrentBuild
|
||||||
|
}
|
||||||
|
|
||||||
|
public func allows(_ updateInfo: UpdateInfo) -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .stable:
|
||||||
|
return !updateInfo.isPrerelease
|
||||||
|
case .beta:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -979,6 +979,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Automatic Update Check" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "بررسی خودکار بهروزرسانی"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Автоматическая проверка обновлений"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "自动检查更新"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "自動檢查更新"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Automatically connect or disconnect VPN based on rules." : {
|
"Automatically connect or disconnect VPN based on rules." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -1035,6 +1063,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Beta" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "بتا"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Бета"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "测试版"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "測試版"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Browse" : {
|
"Browse" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -1091,6 +1147,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Cache Size" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "اندازه حافظه پنهان"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Размер кэша"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "缓存大小"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "快取大小"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Camera" : {
|
"Camera" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -1315,6 +1399,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Check Update" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "بررسی بهروزرسانی"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Проверить обновления"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "检查更新"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "檢查更新"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Checking..." : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "در حال بررسی..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Проверка..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "检查中..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "檢查中..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Choose" : {
|
"Choose" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -1371,6 +1511,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Clear Cache" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "پاکسازی حافظه پنهان"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Очистить кэш"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "清除缓存"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "清除快取"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Clear Logs" : {
|
"Clear Logs" : {
|
||||||
"comment" : "Clear all logs",
|
"comment" : "Clear all logs",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3568,6 +3736,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Force Show Latest Version as Update" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "نمایش آخرین نسخه بهعنوان بهروزرسانی"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Показать последнюю версию как обновление"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "强制显示最新版本为更新"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "強制顯示最新版本為更新"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Format" : {
|
"Format" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -5497,6 +5693,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"New version available: %@" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "نسخه جدید موجود: %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Доступна новая версия: %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "新版本可用:%@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "新版本可用:%@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"No connection rules. Add rules to specify which domains trigger VPN connection." : {
|
"No connection rules. Add rules to specify which domains trigger VPN connection." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -5556,6 +5780,62 @@
|
|||||||
"No documentation.\n\n[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/excludedevicecommunication)" : {
|
"No documentation.\n\n[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/excludedevicecommunication)" : {
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
},
|
},
|
||||||
|
"No updates available" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "بهروزرسانی موجود نیست"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Обновлений не найдено"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "没有可用的更新"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "沒有可用的更新"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"No, thanks" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "نه، ممنون"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Нет, спасибо"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "不,谢谢"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "不,謝謝"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Ok" : {
|
"Ok" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -7307,6 +7587,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Stable" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "پایدار"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Стабильная"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "稳定版"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "穩定版"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Start" : {
|
"Start" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -8254,6 +8562,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Update Settings" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "تنظیمات بهروزرسانی"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Настройки обновлений"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "更新设置"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "更新設定"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Update Track" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "کانال بهروزرسانی"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Канал обновлений"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "更新通道"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "更新通道"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Uplink" : {
|
"Uplink" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -8384,6 +8748,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"v%@" : {
|
||||||
|
"shouldTranslate" : false
|
||||||
|
},
|
||||||
"Version" : {
|
"Version" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -8440,6 +8807,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"View Release" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "مشاهده انتشار"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Просмотреть релиз"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "查看发布"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "查看發佈"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"VPN will not connect automatically." : {
|
"VPN will not connect automatically." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
@@ -8608,6 +9003,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Would you like to enable automatic update checking from **GitHub**?" : {
|
||||||
|
"localizations" : {
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "آیا میخواهید بررسی خودکار بهروزرسانی از **GitHub** را فعال کنید؟"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Включить автоматическую проверку обновлений через **GitHub**?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "是否启用从 **GitHub** 自动检查更新?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "是否啟用從 **GitHub** 自動檢查更新?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Wrong application location" : {
|
"Wrong application location" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fa" : {
|
"fa" : {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ public struct MacApplication: Scene {
|
|||||||
@State private var showMenuBarExtra = false
|
@State private var showMenuBarExtra = false
|
||||||
@State private var menuBarExtraSpeedMode = MenuBarExtraSpeedMode.enabled.rawValue
|
@State private var menuBarExtraSpeedMode = MenuBarExtraSpeedMode.enabled.rawValue
|
||||||
@StateObject private var environments = ExtensionEnvironments()
|
@StateObject private var environments = ExtensionEnvironments()
|
||||||
|
@StateObject private var updateManager = UpdateManager()
|
||||||
@State private var statusBarController: StatusBarController?
|
@State private var statusBarController: StatusBarController?
|
||||||
|
@State private var showUpdateCheckPrompt = false
|
||||||
|
|
||||||
private let profileEditor: (Binding<String>, Bool) -> AnyView = { text, isEditable in
|
private let profileEditor: (Binding<String>, Bool) -> AnyView = { text, isEditable in
|
||||||
AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable))
|
AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable))
|
||||||
@@ -27,6 +29,32 @@ public struct MacApplication: Scene {
|
|||||||
.environment(\.showMenuBarExtra, $showMenuBarExtra)
|
.environment(\.showMenuBarExtra, $showMenuBarExtra)
|
||||||
.environment(\.menuBarExtraSpeedMode, $menuBarExtraSpeedMode)
|
.environment(\.menuBarExtraSpeedMode, $menuBarExtraSpeedMode)
|
||||||
.environmentObject(environments)
|
.environmentObject(environments)
|
||||||
|
.environmentObject(updateManager)
|
||||||
|
.alert(
|
||||||
|
"Check Update",
|
||||||
|
isPresented: $showUpdateCheckPrompt
|
||||||
|
) {
|
||||||
|
Button("Ok") {
|
||||||
|
Task {
|
||||||
|
await SharedPreferences.updateCheckPrompted.set(true)
|
||||||
|
await SharedPreferences.checkUpdateEnabled.set(true)
|
||||||
|
await runAutomaticUpdateCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("No, thanks", role: .cancel) {
|
||||||
|
Task {
|
||||||
|
await SharedPreferences.updateCheckPrompted.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("Would you like to enable automatic update checking from **GitHub**?")
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $updateManager.isUpdateSheetPresented, onDismiss: {
|
||||||
|
updateManager.dismissUpdateSheet()
|
||||||
|
}) {
|
||||||
|
UpdateSheet(updateManager: updateManager)
|
||||||
|
.environmentObject(environments)
|
||||||
|
}
|
||||||
.onChangeCompat(of: showMenuBarExtra) { newValue in
|
.onChangeCompat(of: showMenuBarExtra) { newValue in
|
||||||
statusBarController?.updateVisibility(newValue)
|
statusBarController?.updateVisibility(newValue)
|
||||||
Task {
|
Task {
|
||||||
@@ -81,6 +109,49 @@ public struct MacApplication: Scene {
|
|||||||
statusBarController = StatusBarController(environments: environments)
|
statusBarController = StatusBarController(environments: environments)
|
||||||
statusBarController?.updateVisibility(showMenuBarExtra)
|
statusBarController?.updateVisibility(showMenuBarExtra)
|
||||||
statusBarController?.updateSpeedMode(menuBarExtraSpeedMode)
|
statusBarController?.updateSpeedMode(menuBarExtraSpeedMode)
|
||||||
|
|
||||||
|
if Variant.useSystemExtension {
|
||||||
|
let shouldPresentCachedUpdate = await updateManager.loadCachedUpdate()
|
||||||
|
let checkUpdateEnabled = await SharedPreferences.checkUpdateEnabled.get()
|
||||||
|
let prompted = await SharedPreferences.updateCheckPrompted.get()
|
||||||
|
if !prompted {
|
||||||
|
showUpdateCheckPrompt = true
|
||||||
|
} else if checkUpdateEnabled {
|
||||||
|
if shouldPresentCachedUpdate {
|
||||||
|
await presentUpdateSheet()
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await runAutomaticUpdateCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runAutomaticUpdateCheck() async {
|
||||||
|
let shouldPresent = await updateManager.checkForUpdate(presentIfFound: true, showsAlertOnFailure: false)
|
||||||
|
if shouldPresent {
|
||||||
|
await presentUpdateSheet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentUpdateSheet() async {
|
||||||
|
guard updateManager.updateInfo != nil else { return }
|
||||||
|
await openMainWindowIfNeeded()
|
||||||
|
await updateManager.showUpdateSheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openMainWindowIfNeeded() async {
|
||||||
|
let mainWindow = NSApp.windows.first(where: { $0.identifier?.rawValue == "main" })
|
||||||
|
let shouldActivate = NSApp.activationPolicy() == .accessory || !(mainWindow?.isVisible ?? false) || !NSApp.isActive
|
||||||
|
guard shouldActivate else { return }
|
||||||
|
|
||||||
|
NSApp.setActivationPolicy(.regular)
|
||||||
|
mainWindow?.makeKeyAndOrderFront(nil)
|
||||||
|
if let dockApp = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.dock").first {
|
||||||
|
dockApp.activate()
|
||||||
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
|
}
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hide(closeApp: Bool) {
|
private func hide(closeApp: Bool) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
3A3AA7FF2A4EFDB3002F78AB /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
|
3A3AA7FF2A4EFDB3002F78AB /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
|
||||||
3A3DEBEB2A4FFE2D00373BF4 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A3DEBE62A4FFA6000373BF4 /* AppIntents.framework */; };
|
3A3DEBEB2A4FFE2D00373BF4 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A3DEBE62A4FFA6000373BF4 /* AppIntents.framework */; };
|
||||||
3A4A020D2B53E3DC004EFB87 /* QRCode in Frameworks */ = {isa = PBXBuildFile; productRef = 3A4A020C2B53E3DC004EFB87 /* QRCode */; };
|
3A4A020D2B53E3DC004EFB87 /* QRCode in Frameworks */ = {isa = PBXBuildFile; productRef = 3A4A020C2B53E3DC004EFB87 /* QRCode */; };
|
||||||
|
3A4CA8CC2F75381F009C36CA /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 3A4CA8CB2F75381F009C36CA /* MarkdownUI */; };
|
||||||
3A4EAD1B2A4FEB02005435B3 /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
|
3A4EAD1B2A4FEB02005435B3 /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
|
||||||
3A4EAD372A4FEC20005435B3 /* ApplicationLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */; };
|
3A4EAD372A4FEC20005435B3 /* ApplicationLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */; };
|
||||||
3A4FB1572A73467F007012B9 /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
|
3A4FB1572A73467F007012B9 /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
|
||||||
@@ -634,6 +635,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
3A4CA8CC2F75381F009C36CA /* MarkdownUI in Frameworks */,
|
||||||
3A4EAD1B2A4FEB02005435B3 /* Library.framework in Frameworks */,
|
3A4EAD1B2A4FEB02005435B3 /* Library.framework in Frameworks */,
|
||||||
3A4A020D2B53E3DC004EFB87 /* QRCode in Frameworks */,
|
3A4A020D2B53E3DC004EFB87 /* QRCode in Frameworks */,
|
||||||
);
|
);
|
||||||
@@ -922,6 +924,7 @@
|
|||||||
name = ApplicationLibrary;
|
name = ApplicationLibrary;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
3A4A020C2B53E3DC004EFB87 /* QRCode */,
|
3A4A020C2B53E3DC004EFB87 /* QRCode */,
|
||||||
|
3A4CA8CB2F75381F009C36CA /* MarkdownUI */,
|
||||||
);
|
);
|
||||||
productName = ApplicationLibrary;
|
productName = ApplicationLibrary;
|
||||||
productReference = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */;
|
productReference = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */;
|
||||||
@@ -1368,6 +1371,7 @@
|
|||||||
3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */,
|
3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */,
|
||||||
3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */,
|
3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */,
|
||||||
3ACE5E012EE1A91100644196 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */,
|
3ACE5E012EE1A91100644196 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */,
|
||||||
|
3A4CA8CA2F75381F009C36CA /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 3AEC20C72A45991900A63465 /* Products */;
|
productRefGroup = 3AEC20C72A45991900A63465 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -3342,6 +3346,14 @@
|
|||||||
minimumVersion = 17.0.0;
|
minimumVersion = 17.0.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
3A4CA8CA2F75381F009C36CA /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 2.4.1;
|
||||||
|
};
|
||||||
|
};
|
||||||
3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */ = {
|
3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/christophhagen/BinaryCodable";
|
repositoryURL = "https://github.com/christophhagen/BinaryCodable";
|
||||||
@@ -3381,6 +3393,11 @@
|
|||||||
package = 3A4A020B2B53E3DC004EFB87 /* XCRemoteSwiftPackageReference "qrcode" */;
|
package = 3A4A020B2B53E3DC004EFB87 /* XCRemoteSwiftPackageReference "qrcode" */;
|
||||||
productName = QRCode;
|
productName = QRCode;
|
||||||
};
|
};
|
||||||
|
3A4CA8CB2F75381F009C36CA /* MarkdownUI */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 3A4CA8CA2F75381F009C36CA /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
|
||||||
|
productName = MarkdownUI;
|
||||||
|
};
|
||||||
3A7E90372A46778E00D53052 /* BinaryCodable */ = {
|
3A7E90372A46778E00D53052 /* BinaryCodable */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */;
|
package = 3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "1e51842f566cb5b99c8ac7575b8910b65467e19dda2da9562c06bf365e78c2e9",
|
"originHash" : "8da21fbbe117848311fb8e66b6d976022dc80720a4c3ace8c3db96e20d68e580",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "binarycodable",
|
"identity" : "binarycodable",
|
||||||
@@ -55,6 +55,15 @@
|
|||||||
"version" : "6.29.3"
|
"version" : "6.29.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "networkimage",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
||||||
|
"version" : "6.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "qrcode",
|
"identity" : "qrcode",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -73,6 +82,15 @@
|
|||||||
"version" : "2.0.0"
|
"version" : "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-cmark",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-cmark",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
|
||||||
|
"version" : "0.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-collections",
|
"identity" : "swift-collections",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -82,6 +100,15 @@
|
|||||||
"version" : "1.3.0"
|
"version" : "1.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-markdown-ui",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
|
||||||
|
"version" : "2.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-qrcode-generator",
|
"identity" : "swift-qrcode-generator",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
Reference in New Issue
Block a user