mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-21 22:30:46 +08:00
242 lines
9.0 KiB
Swift
242 lines
9.0 KiB
Swift
import Foundation
|
|
|
|
struct AppUpdatePrompt: Identifiable, Equatable {
|
|
enum Requirement: String {
|
|
case recommended
|
|
case required
|
|
}
|
|
|
|
let currentVersion: String
|
|
let latestVersion: String
|
|
let minimumSupportedVersion: String
|
|
let requirement: Requirement
|
|
let releaseNotes: String?
|
|
|
|
var id: String {
|
|
"\(requirement.rawValue)-\(latestVersion)-\(minimumSupportedVersion)"
|
|
}
|
|
}
|
|
|
|
struct AppRemoteConfiguration: Decodable, Equatable {
|
|
let latestVersion: String
|
|
let minimumSupportedVersion: String
|
|
let communityPromptClients: [String]
|
|
|
|
static let fallback = AppRemoteConfiguration(
|
|
latestVersion: "1.0.2",
|
|
minimumSupportedVersion: "1.0.0",
|
|
communityPromptClients: [
|
|
ThirdPartyProxyClient.surge.rawValue,
|
|
ThirdPartyProxyClient.quantumultX.rawValue,
|
|
ThirdPartyProxyClient.loon.rawValue,
|
|
ThirdPartyProxyClient.stash.rawValue,
|
|
ThirdPartyProxyClient.egern.rawValue
|
|
]
|
|
)
|
|
|
|
init(
|
|
latestVersion: String,
|
|
minimumSupportedVersion: String,
|
|
communityPromptClients: [String]
|
|
) {
|
|
self.latestVersion = latestVersion
|
|
self.minimumSupportedVersion = minimumSupportedVersion
|
|
self.communityPromptClients = communityPromptClients
|
|
}
|
|
|
|
static func decode(_ data: Data) throws -> AppRemoteConfiguration {
|
|
let configuration = try JSONDecoder().decode(AppRemoteConfiguration.self, from: data)
|
|
guard let latest = NumericVersion(configuration.latestVersion),
|
|
let minimum = NumericVersion(configuration.minimumSupportedVersion),
|
|
minimum <= latest else {
|
|
throw AppRemoteConfigurationError.invalidVersion
|
|
}
|
|
guard configuration.communityPromptClients.allSatisfy({
|
|
ThirdPartyProxyClient(rawValue: $0) != nil
|
|
}) else {
|
|
throw AppRemoteConfigurationError.invalidClient
|
|
}
|
|
return configuration
|
|
}
|
|
|
|
func updatePrompt(currentVersion: String, releaseNotes: String? = nil) -> AppUpdatePrompt? {
|
|
guard let current = NumericVersion(currentVersion),
|
|
let latest = NumericVersion(latestVersion),
|
|
let minimum = NumericVersion(minimumSupportedVersion) else {
|
|
return nil
|
|
}
|
|
if current < minimum {
|
|
return AppUpdatePrompt(
|
|
currentVersion: currentVersion,
|
|
latestVersion: latestVersion,
|
|
minimumSupportedVersion: minimumSupportedVersion,
|
|
requirement: .required,
|
|
releaseNotes: releaseNotes
|
|
)
|
|
}
|
|
if current < latest {
|
|
return AppUpdatePrompt(
|
|
currentVersion: currentVersion,
|
|
latestVersion: latestVersion,
|
|
minimumSupportedVersion: minimumSupportedVersion,
|
|
requirement: .recommended,
|
|
releaseNotes: releaseNotes
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requestsCommunityPrompt(for client: ThirdPartyProxyClient) -> Bool {
|
|
client != .shadowrocket && communityPromptClients.contains(client.rawValue)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class AppRemoteConfigurationStore: ObservableObject {
|
|
static let shared = AppRemoteConfigurationStore()
|
|
|
|
@Published private(set) var configuration = AppRemoteConfiguration.fallback
|
|
|
|
func apply(_ configuration: AppRemoteConfiguration) {
|
|
self.configuration = configuration
|
|
}
|
|
|
|
func requestsCommunityPrompt(for client: ThirdPartyProxyClient) -> Bool {
|
|
configuration.requestsCommunityPrompt(for: client)
|
|
}
|
|
}
|
|
|
|
enum AppRemoteConfigurationService {
|
|
static let configurationURL = URL(
|
|
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/version.txt"
|
|
)!
|
|
static let releasesURL = URL(
|
|
string: "https://github.com/xweiba/location-spoofer/releases/latest"
|
|
)!
|
|
|
|
static func fetch() async -> AppRemoteConfiguration? {
|
|
let sessionConfiguration = URLSessionConfiguration.ephemeral
|
|
sessionConfiguration.timeoutIntervalForRequest = 1.5
|
|
sessionConfiguration.timeoutIntervalForResource = 2
|
|
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
sessionConfiguration.waitsForConnectivity = false
|
|
let session = URLSession(configuration: sessionConfiguration)
|
|
defer { session.finishTasksAndInvalidate() }
|
|
|
|
var request = URLRequest(url: configurationURL)
|
|
request.timeoutInterval = 1.5
|
|
|
|
do {
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse,
|
|
httpResponse.statusCode == 200 else {
|
|
RuntimeLogger.warning("APP", "Update", "版本配置请求返回非 200,继续使用内置配置")
|
|
return nil
|
|
}
|
|
let configuration = try AppRemoteConfiguration.decode(data)
|
|
RuntimeLogger.info("APP", "Update", "远程版本配置加载成功", details: [
|
|
"最新版本": configuration.latestVersion,
|
|
"最低版本": configuration.minimumSupportedVersion,
|
|
"社区征集客户端数": String(configuration.communityPromptClients.count)
|
|
])
|
|
return configuration
|
|
} catch {
|
|
RuntimeLogger.warning("APP", "Update", "版本配置加载失败,继续使用内置配置", details: [
|
|
"错误": error.localizedDescription
|
|
])
|
|
return nil
|
|
}
|
|
}
|
|
|
|
static func fetchReleaseNotes(version: String) async -> String? {
|
|
guard let url = URL(
|
|
string: "https://raw.githubusercontent.com/xweiba/location-spoofer/main/docs/releases/v\(version).md"
|
|
) else {
|
|
return nil
|
|
}
|
|
let sessionConfiguration = URLSessionConfiguration.ephemeral
|
|
sessionConfiguration.timeoutIntervalForRequest = 1.5
|
|
sessionConfiguration.timeoutIntervalForResource = 2
|
|
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
sessionConfiguration.waitsForConnectivity = false
|
|
let session = URLSession(configuration: sessionConfiguration)
|
|
defer { session.finishTasksAndInvalidate() }
|
|
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = 1.5
|
|
|
|
do {
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse,
|
|
httpResponse.statusCode == 200 else {
|
|
RuntimeLogger.info("APP", "Update", "版本说明不存在,将使用最新 Release 页面", details: [
|
|
"版本": version
|
|
])
|
|
return nil
|
|
}
|
|
guard let markdown = String(data: data, encoding: .utf8) else {
|
|
RuntimeLogger.info("APP", "Update", "版本说明编码无效,将使用最新 Release 页面", details: [
|
|
"版本": version
|
|
])
|
|
return nil
|
|
}
|
|
return releaseNotesSummary(markdown)
|
|
} catch {
|
|
RuntimeLogger.info("APP", "Update", "版本说明加载失败,将使用最新 Release 页面", details: [
|
|
"版本": version,
|
|
"错误": error.localizedDescription
|
|
])
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func releaseNotesSummary(_ markdown: String) -> String? {
|
|
var items: [String] = []
|
|
var inMainUpdates = false
|
|
for rawLine in markdown.components(separatedBy: .newlines) {
|
|
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if line == "## 主要更新" {
|
|
inMainUpdates = true
|
|
continue
|
|
}
|
|
if inMainUpdates, line.hasPrefix("## ") {
|
|
break
|
|
}
|
|
guard inMainUpdates, line.hasPrefix("- ") else { continue }
|
|
items.append("• " + line.dropFirst(2))
|
|
}
|
|
guard !items.isEmpty else { return nil }
|
|
return items.joined(separator: "\n")
|
|
}
|
|
}
|
|
|
|
private enum AppRemoteConfigurationError: Error {
|
|
case invalidVersion
|
|
case invalidClient
|
|
}
|
|
|
|
private struct NumericVersion: Comparable {
|
|
let components: [Int]
|
|
|
|
init?(_ value: String) {
|
|
let parts = value.split(separator: ".", omittingEmptySubsequences: false)
|
|
guard !parts.isEmpty else { return nil }
|
|
var parsed: [Int] = []
|
|
for part in parts {
|
|
guard !part.isEmpty, let number = Int(part), number >= 0 else { return nil }
|
|
parsed.append(number)
|
|
}
|
|
components = parsed
|
|
}
|
|
|
|
static func < (lhs: NumericVersion, rhs: NumericVersion) -> Bool {
|
|
let count = max(lhs.components.count, rhs.components.count)
|
|
for index in 0..<count {
|
|
let left = index < lhs.components.count ? lhs.components[index] : 0
|
|
let right = index < rhs.components.count ? rhs.components[index] : 0
|
|
if left != right { return left < right }
|
|
}
|
|
return false
|
|
}
|
|
}
|