mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-21 22:30:46 +08:00
feat: improve runtime setup and guidance
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,8 +197,8 @@ enum ThirdPartyProxyClient: String, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
var verificationText: String {
|
||||
self == .shadowrocket ? "当前可测试" : "配置已提供,尚未验证"
|
||||
var verificationText: String? {
|
||||
self == .shadowrocket ? nil : "配置已提供,尚未验证"
|
||||
}
|
||||
|
||||
var moduleFileName: String {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user