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
|
||||
@@ -121,6 +121,16 @@ public enum SharedPreferences {
|
||||
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
|
||||
|
||||
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 {
|
||||
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." : {
|
||||
"localizations" : {
|
||||
"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" : {
|
||||
"localizations" : {
|
||||
"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" : {
|
||||
"localizations" : {
|
||||
"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" : {
|
||||
"localizations" : {
|
||||
"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" : {
|
||||
"comment" : "Clear all logs",
|
||||
"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" : {
|
||||
"localizations" : {
|
||||
"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." : {
|
||||
"localizations" : {
|
||||
"fa" : {
|
||||
@@ -5556,6 +5780,62 @@
|
||||
"No documentation.\n\n[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/excludedevicecommunication)" : {
|
||||
"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" : {
|
||||
"localizations" : {
|
||||
"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" : {
|
||||
"localizations" : {
|
||||
"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" : {
|
||||
"localizations" : {
|
||||
"fa" : {
|
||||
@@ -8384,6 +8748,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"v%@" : {
|
||||
"shouldTranslate" : false
|
||||
},
|
||||
"Version" : {
|
||||
"localizations" : {
|
||||
"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." : {
|
||||
"localizations" : {
|
||||
"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" : {
|
||||
"localizations" : {
|
||||
"fa" : {
|
||||
|
||||
@@ -9,7 +9,9 @@ public struct MacApplication: Scene {
|
||||
@State private var showMenuBarExtra = false
|
||||
@State private var menuBarExtraSpeedMode = MenuBarExtraSpeedMode.enabled.rawValue
|
||||
@StateObject private var environments = ExtensionEnvironments()
|
||||
@StateObject private var updateManager = UpdateManager()
|
||||
@State private var statusBarController: StatusBarController?
|
||||
@State private var showUpdateCheckPrompt = false
|
||||
|
||||
private let profileEditor: (Binding<String>, Bool) -> AnyView = { text, isEditable in
|
||||
AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable))
|
||||
@@ -27,6 +29,32 @@ public struct MacApplication: Scene {
|
||||
.environment(\.showMenuBarExtra, $showMenuBarExtra)
|
||||
.environment(\.menuBarExtraSpeedMode, $menuBarExtraSpeedMode)
|
||||
.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
|
||||
statusBarController?.updateVisibility(newValue)
|
||||
Task {
|
||||
@@ -81,6 +109,49 @@ public struct MacApplication: Scene {
|
||||
statusBarController = StatusBarController(environments: environments)
|
||||
statusBarController?.updateVisibility(showMenuBarExtra)
|
||||
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) {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
3A3AA7FF2A4EFDB3002F78AB /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
|
||||
3A3DEBEB2A4FFE2D00373BF4 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A3DEBE62A4FFA6000373BF4 /* AppIntents.framework */; };
|
||||
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 */; };
|
||||
3A4EAD372A4FEC20005435B3 /* ApplicationLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */; };
|
||||
3A4FB1572A73467F007012B9 /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
|
||||
@@ -634,6 +635,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3A4CA8CC2F75381F009C36CA /* MarkdownUI in Frameworks */,
|
||||
3A4EAD1B2A4FEB02005435B3 /* Library.framework in Frameworks */,
|
||||
3A4A020D2B53E3DC004EFB87 /* QRCode in Frameworks */,
|
||||
);
|
||||
@@ -922,6 +924,7 @@
|
||||
name = ApplicationLibrary;
|
||||
packageProductDependencies = (
|
||||
3A4A020C2B53E3DC004EFB87 /* QRCode */,
|
||||
3A4CA8CB2F75381F009C36CA /* MarkdownUI */,
|
||||
);
|
||||
productName = ApplicationLibrary;
|
||||
productReference = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */;
|
||||
@@ -1368,6 +1371,7 @@
|
||||
3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */,
|
||||
3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */,
|
||||
3ACE5E012EE1A91100644196 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */,
|
||||
3A4CA8CA2F75381F009C36CA /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
||||
);
|
||||
productRefGroup = 3AEC20C72A45991900A63465 /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -3342,6 +3346,14 @@
|
||||
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" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/christophhagen/BinaryCodable";
|
||||
@@ -3381,6 +3393,11 @@
|
||||
package = 3A4A020B2B53E3DC004EFB87 /* XCRemoteSwiftPackageReference "qrcode" */;
|
||||
productName = QRCode;
|
||||
};
|
||||
3A4CA8CB2F75381F009C36CA /* MarkdownUI */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 3A4CA8CA2F75381F009C36CA /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
|
||||
productName = MarkdownUI;
|
||||
};
|
||||
3A7E90372A46778E00D53052 /* BinaryCodable */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "1e51842f566cb5b99c8ac7575b8910b65467e19dda2da9562c06bf365e78c2e9",
|
||||
"originHash" : "8da21fbbe117848311fb8e66b6d976022dc80720a4c3ace8c3db96e20d68e580",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "binarycodable",
|
||||
@@ -55,6 +55,15 @@
|
||||
"version" : "6.29.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "networkimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
||||
"state" : {
|
||||
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
||||
"version" : "6.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "qrcode",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -73,6 +82,15 @@
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -82,6 +100,15 @@
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
Reference in New Issue
Block a user