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
+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
}
}
}