feat: improve runtime setup and guidance

This commit is contained in:
xweiba
2026-08-08 15:18:40 +08:00
parent 3499f080d7
commit b07fa61195
54 changed files with 1794 additions and 325 deletions
+36
View File
@@ -116,3 +116,39 @@ struct VirtualLocationTipPreferences {
}
}
}
/// Controls the optional prompt asking users to share a verified third-party setup.
struct ThirdPartyCommunityPromptPreferences {
static let minimumCountForSuppression = 3
private enum Key {
static let presentationCount = "thirdPartyCommunityPrompt.presentationCount"
static let suppressed = "thirdPartyCommunityPrompt.suppressed"
}
private let defaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
}
@discardableResult
func recordPresentation() -> Int {
let next = defaults.integer(forKey: Key.presentationCount) + 1
defaults.set(next, forKey: Key.presentationCount)
return next
}
func shouldPresent() -> Bool {
!defaults.bool(forKey: Key.suppressed)
}
func canSuppress() -> Bool {
defaults.integer(forKey: Key.presentationCount) >= Self.minimumCountForSuppression
}
func suppress() {
guard canSuppress() else { return }
defaults.set(true, forKey: Key.suppressed)
}
}
+241
View File
@@ -0,0 +1,241 @@
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
}
}
+18 -8
View File
@@ -141,6 +141,12 @@ final class CertificateAuthorityStore {
try loadValidKeychainAuthority()
}
func reset() throws {
try removeLegacyFiles()
try keychain.remove()
RuntimeLogger.info("SHARED", "Certificate.store", "已删除设备 CA,等待重新生成")
}
private func loadValidKeychainAuthority() throws -> CertificateAuthority? {
guard let authority = try keychain.load() else { return nil }
guard validator(authority) else {
@@ -163,16 +169,20 @@ final class CertificateAuthorityStore {
}
private func removeLegacyFilesBestEffort() {
do {
try removeLegacyFiles()
} catch {
RuntimeLogger.error("SHARED", "Certificate.store", "删除旧 CA 文件失败,将在下次启动重试", error: error)
}
}
private func removeLegacyFiles() throws {
let fileManager = FileManager.default
// Remove private material first. Each item is retried on later launches
// when a valid Keychain authority is available.
for url in [keyURL, certificateURL] where fileManager.fileExists(atPath: url.path) {
do {
try fileManager.removeItem(at: url)
} catch {
RuntimeLogger.error("SHARED", "Certificate.store", "删除旧 CA 文件失败,将在下次启动重试", error: error)
}
try fileManager.removeItem(at: url)
}
if fileManager.fileExists(atPath: directory.path) {
try fileManager.removeItem(at: directory)
}
try? fileManager.removeItem(at: directory)
}
}
+56 -1
View File
@@ -21,17 +21,31 @@ final class ProxyRuntimeModeStore: ObservableObject {
private enum Key {
static let runtimeMode = "proxyRuntimeMode"
static let hasSelectedRuntimeMode = "hasSelectedProxyRuntimeMode"
static let localWiFiInitialized = "localWiFiRuntimeModeInitialized"
static let thirdPartyInitialized = "thirdPartyRuntimeModeInitialized"
static let initializationMigrationCompleted = "runtimeModeInitializationMigrationCompleted"
static let legacySetupCompleted = "setupCompleted"
}
@Published private(set) var mode: ProxyRuntimeMode
@Published private(set) var hasSelectedMode: Bool
@Published private(set) var localWiFiInitialized: Bool
@Published private(set) var thirdPartyInitialized: Bool
private let defaults: UserDefaults
private let legacyDefaults: UserDefaults
init(defaults: UserDefaults = AppGroup.defaults) {
init(
defaults: UserDefaults = AppGroup.defaults,
legacyDefaults: UserDefaults = .standard
) {
self.defaults = defaults
self.legacyDefaults = legacyDefaults
self.mode = defaults.string(forKey: Key.runtimeMode)
.flatMap(ProxyRuntimeMode.init(rawValue:)) ?? .localWiFi
self.hasSelectedMode = defaults.bool(forKey: Key.hasSelectedRuntimeMode)
self.localWiFiInitialized = defaults.bool(forKey: Key.localWiFiInitialized)
self.thirdPartyInitialized = defaults.bool(forKey: Key.thirdPartyInitialized)
migrateLegacyInitializationIfNeeded()
}
func setMode(_ mode: ProxyRuntimeMode) {
@@ -40,10 +54,51 @@ final class ProxyRuntimeModeStore: ObservableObject {
hasSelectedMode = true
defaults.set(mode.rawValue, forKey: Key.runtimeMode)
defaults.set(true, forKey: Key.hasSelectedRuntimeMode)
migrateLegacyInitializationIfNeeded()
if changed {
RuntimeLogger.info("APP", "Mode", "代理运行模式已切换", details: [
"模式": mode.displayName
])
}
}
func isInitialized(_ mode: ProxyRuntimeMode) -> Bool {
switch mode {
case .localWiFi: return localWiFiInitialized
case .thirdParty: return thirdPartyInitialized
}
}
func markInitialized(_ mode: ProxyRuntimeMode) {
setInitialized(true, for: mode)
}
func resetInitialization(_ mode: ProxyRuntimeMode) {
setInitialized(false, for: mode)
}
private func setInitialized(_ initialized: Bool, for mode: ProxyRuntimeMode) {
switch mode {
case .localWiFi:
localWiFiInitialized = initialized
defaults.set(initialized, forKey: Key.localWiFiInitialized)
case .thirdParty:
thirdPartyInitialized = initialized
defaults.set(initialized, forKey: Key.thirdPartyInitialized)
}
}
private func migrateLegacyInitializationIfNeeded() {
guard hasSelectedMode,
!defaults.bool(forKey: Key.initializationMigrationCompleted) else {
return
}
if legacyDefaults.bool(forKey: Key.legacySetupCompleted) {
setInitialized(true, for: mode)
RuntimeLogger.info("APP", "Mode", "已迁移旧版模式初始化状态", details: [
"模式": mode.displayName
])
}
defaults.set(true, forKey: Key.initializationMigrationCompleted)
}
}
+2 -2
View File
@@ -197,8 +197,8 @@ enum ThirdPartyProxyClient: String, CaseIterable, Identifiable {
}
}
var verificationText: String {
self == .shadowrocket ? "当前可测试" : "配置已提供,尚未验证"
var verificationText: String? {
self == .shadowrocket ? nil : "配置已提供,尚未验证"
}
var moduleFileName: String {
+1 -24
View File
@@ -1,6 +1,6 @@
import Foundation
/// UI
/// SetupCoordinator
enum VerificationResult: Equatable, Identifiable {
case success
case proxyNotRunning
@@ -26,27 +26,4 @@ enum VerificationResult: Equatable, Identifiable {
var isSuccess: Bool { self == .success }
///
var tipKind: TipKind? {
switch self {
case .success: return nil
case .proxyNotRunning, .verificationInProgress, .verificationSuperseded: return nil
case .certNotTrusted: return nil
case .wifiProxyNotConfigured: return .proxySetup
case .coordinateWriteFailed, .patchFailed: return .rewriteFailed
}
}
/// Wi-Fi
///
var wifiChangeReminderTipKind: TipKind? {
switch self {
case .proxyNotRunning:
return .proxySetup
case .certNotTrusted, .verificationInProgress, .verificationSuperseded:
return nil
default:
return tipKind
}
}
}