Add GitHub update checker and installer

This commit is contained in:
世界
2026-03-30 23:02:42 +08:00
parent 6b790c7a80
commit 63d1d0fe3f
15 changed files with 1663 additions and 2 deletions
@@ -0,0 +1,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
+10
View File
@@ -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)
+34
View File
@@ -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)
}
}
+181
View File
@@ -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
}
}
+45
View File
@@ -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
+181
View File
@@ -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
+26
View File
@@ -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
}
}
+26
View File
@@ -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
}
}
}
+423
View File
@@ -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" : {
+71
View File
@@ -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) {
+17
View File
@@ -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",