diff --git a/ApplicationLibrary/Service/UpdateManager.swift b/ApplicationLibrary/Service/UpdateManager.swift new file mode 100644 index 0000000..67d4aa8 --- /dev/null +++ b/ApplicationLibrary/Service/UpdateManager.swift @@ -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 diff --git a/ApplicationLibrary/Views/Setting/GitHubEmoji.swift b/ApplicationLibrary/Views/Setting/GitHubEmoji.swift new file mode 100644 index 0000000..e048ba8 --- /dev/null +++ b/ApplicationLibrary/Views/Setting/GitHubEmoji.swift @@ -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 diff --git a/ApplicationLibrary/Views/Setting/MacAppView.swift b/ApplicationLibrary/Views/Setting/MacAppView.swift index fccf76c..a1d1ead 100644 --- a/ApplicationLibrary/Views/Setting/MacAppView.swift +++ b/ApplicationLibrary/Views/Setting/MacAppView.swift @@ -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 } diff --git a/ApplicationLibrary/Views/Setting/UpdateSheet.swift b/ApplicationLibrary/Views/Setting/UpdateSheet.swift new file mode 100644 index 0000000..1a6f77f --- /dev/null +++ b/ApplicationLibrary/Views/Setting/UpdateSheet.swift @@ -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 diff --git a/Library/Database/SharedPreferences.swift b/Library/Database/SharedPreferences.swift index b974d00..277a272 100644 --- a/Library/Database/SharedPreferences.swift +++ b/Library/Database/SharedPreferences.swift @@ -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("check_update_enabled", defaultValue: false) + public static let updateCheckPrompted = Preference("update_check_prompted", defaultValue: false) + public static let updateTrack = Preference("update_track", defaultValue: "") + public static let cachedUpdateInfo = Preference("cached_update_info", defaultValue: "") + public static let lastShownUpdateVersion = Preference("last_shown_update_version", defaultValue: "") + #endif + // Core public static let disableDeprecatedWarnings = Preference("disable_deprecated_warnings", defaultValue: false) diff --git a/Library/Network/HTTPClient.swift b/Library/Network/HTTPClient.swift index ca40f73..1796389 100644 --- a/Library/Network/HTTPClient.swift +++ b/Library/Network/HTTPClient.swift @@ -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) + } +} diff --git a/Library/Update/GitHubUpdateChecker.swift b/Library/Update/GitHubUpdateChecker.swift new file mode 100644 index 0000000..733fac2 --- /dev/null +++ b/Library/Update/GitHubUpdateChecker.swift @@ -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 + } +} diff --git a/Library/Update/PKGDownloader.swift b/Library/Update/PKGDownloader.swift new file mode 100644 index 0000000..8151bb1 --- /dev/null +++ b/Library/Update/PKGDownloader.swift @@ -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 diff --git a/Library/Update/PKGInstaller.swift b/Library/Update/PKGInstaller.swift new file mode 100644 index 0000000..a25c7d4 --- /dev/null +++ b/Library/Update/PKGInstaller.swift @@ -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, + AuthorizationFlags, + UnsafePointer?>, + UnsafeMutablePointer?>? + ) -> 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?] = [ + strdup("-c"), strdup(command), nil, + ] + defer { for i in 0 ..< cArgs.count - 1 { + free(cArgs[i]) + } } + + var pipe: UnsafeMutablePointer? + 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) -> String { + defer { fclose(pipe) } + + var data = Data() + let bufferSize = 4096 + let buffer = UnsafeMutablePointer.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[.. 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 + } + } +} diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 6d0a360..87f8ac6 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -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" : { diff --git a/MacLibrary/MacApplication.swift b/MacLibrary/MacApplication.swift index 6267300..d6bcfe1 100644 --- a/MacLibrary/MacApplication.swift +++ b/MacLibrary/MacApplication.swift @@ -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, 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) { diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index 6b9fdd1..f4ca277 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -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" */; diff --git a/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f858f4f..00d86e7 100644 --- a/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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",