Add GitHub update checker and installer
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user