fix network

This commit is contained in:
JinJiangHuang
2026-03-10 21:37:51 +08:00
parent 76ed32ccf3
commit fac70d7496
7 changed files with 383 additions and 37 deletions
+153 -18
View File
@@ -5,6 +5,13 @@ class NetworkManager {
///
static let shared = NetworkManager()
///
static let defaultMaxRetries = 2
/// = base * 2^attempt + jitter
private static let retryBaseDelay: TimeInterval = 0.5
///
private static let retryMaxDelay: TimeInterval = 8.0
///
private static let fallbackCharsetNames: [String] = [
"utf-8",
@@ -43,11 +50,16 @@ class NetworkManager {
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 30
config.httpMaximumConnectionsPerHost = 5
config.waitsForConnectivity = true
self.session = URLSession(configuration: config)
}
/// GET
func getString(from urlString: String, headers: [String: String]? = nil) async throws -> String {
/// GET
func getString(
from urlString: String,
headers: [String: String]? = nil,
maxRetries: Int = NetworkManager.defaultMaxRetries
) async throws -> String {
guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else {
throw NetworkError.invalidURL(urlString)
}
@@ -56,17 +68,8 @@ class NetworkManager {
request.httpMethod = "GET"
headers?.forEach { request.setValue($1, forHTTPHeaderField: $0) }
let (data, response) = try await session.data(for: request)
let (data, httpResponse) = try await performRequest(request, maxRetries: maxRetries)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.httpError(httpResponse.statusCode)
}
// +
guard let str = Self.decodeString(data: data, response: httpResponse) else {
throw NetworkError.decodingError("文本解码失败")
}
@@ -74,24 +77,127 @@ class NetworkManager {
return str
}
/// GET JSON
func getJSON<T: Decodable>(from urlString: String, type: T.Type, headers: [String: String]? = nil) async throws -> T {
let str = try await getString(from: urlString, headers: headers)
/// GET JSON
func getJSON<T: Decodable>(
from urlString: String,
type: T.Type,
headers: [String: String]? = nil,
maxRetries: Int = NetworkManager.defaultMaxRetries
) async throws -> T {
let str = try await getString(from: urlString, headers: headers, maxRetries: maxRetries)
guard let data = str.data(using: .utf8) else {
throw NetworkError.decodingError("字符串转 Data 失败")
}
return try decoder.decode(T.self, from: data)
}
/// GET Data
func getData(from urlString: String) async throws -> Data {
/// GET Data HTTP
func getData(
from urlString: String,
maxRetries: Int = NetworkManager.defaultMaxRetries
) async throws -> Data {
guard let url = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) else {
throw NetworkError.invalidURL(urlString)
}
let (data, _) = try await session.data(from: url)
let request = URLRequest(url: url)
let (data, _) = try await performRequest(request, maxRetries: maxRetries)
return data
}
// MARK: -
/// HTTP 退
private func performRequest(
_ request: URLRequest,
maxRetries: Int
) async throws -> (Data, HTTPURLResponse) {
var lastError: Error = NetworkError.invalidResponse
let totalAttempts = max(1, maxRetries + 1)
for attempt in 0..<totalAttempts {
do {
try Task.checkCancellation()
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
let error = NetworkError.httpError(httpResponse.statusCode)
if Self.isRetryableHTTPStatus(httpResponse.statusCode) && attempt < totalAttempts - 1 {
lastError = error
try await retryDelay(attempt: attempt)
continue
}
throw error
}
return (data, httpResponse)
} catch is CancellationError {
throw CancellationError()
} catch {
lastError = error
if Self.isRetryableError(error) && attempt < totalAttempts - 1 {
try await retryDelay(attempt: attempt)
continue
}
throw error
}
}
throw lastError
}
/// 退 +
private func retryDelay(attempt: Int) async throws {
let base = Self.retryBaseDelay * pow(2.0, Double(attempt))
let jitter = Double.random(in: 0...0.5)
let delay = min(base + jitter, Self.retryMaxDelay)
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
/// HTTP
private static func isRetryableHTTPStatus(_ statusCode: Int) -> Bool {
switch statusCode {
case 408, 429, 500, 502, 503, 504: return true
default: return false
}
}
///
private static func isRetryableError(_ error: Error) -> Bool {
if error is CancellationError { return false }
if let networkError = error as? NetworkError {
if case .httpError(let code) = networkError {
return isRetryableHTTPStatus(code)
}
if case .invalidURL = networkError { return false }
if case .decodingError = networkError { return false }
}
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain {
switch nsError.code {
case NSURLErrorTimedOut,
NSURLErrorCannotFindHost,
NSURLErrorCannotConnectToHost,
NSURLErrorNetworkConnectionLost,
NSURLErrorDNSLookupFailed,
NSURLErrorNotConnectedToInternet,
NSURLErrorSecureConnectionFailed,
NSURLErrorDataNotAllowed:
return true
default:
return false
}
}
return false
}
///
/// 1)
/// 2)
@@ -152,4 +258,33 @@ enum NetworkError: LocalizedError {
case .decodingError(let msg): return "解码错误: \(msg)"
}
}
/// UI
var isNetworkUnavailable: Bool {
if case .httpError = self { return false }
return true
}
}
extension Error {
/// DNS
var isNetworkConnectionError: Bool {
if self is CancellationError { return false }
let nsError = self as NSError
if nsError.domain == NSURLErrorDomain {
switch nsError.code {
case NSURLErrorTimedOut,
NSURLErrorCannotFindHost,
NSURLErrorCannotConnectToHost,
NSURLErrorNetworkConnectionLost,
NSURLErrorDNSLookupFailed,
NSURLErrorNotConnectedToInternet,
NSURLErrorDataNotAllowed:
return true
default:
return false
}
}
return false
}
}
+66
View File
@@ -0,0 +1,66 @@
import Foundation
import Network
import Combine
/// NWPathMonitor
/// UI 线
@MainActor
final class NetworkMonitor: ObservableObject {
static let shared = NetworkMonitor()
///
@Published private(set) var isConnected: Bool = true
/// Wi-Fi / / 线 /
@Published private(set) var connectionType: ConnectionType = .unknown
///
let networkRestoredPublisher = PassthroughSubject<Void, Never>()
enum ConnectionType: String {
case wifi = "Wi-Fi"
case cellular = "蜂窝"
case wiredEthernet = "有线"
case unknown = "未知"
}
private let monitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "com.tvbox.networkmonitor", qos: .utility)
private var wasDisconnected = false
private init() {
startMonitoring()
}
private func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor [weak self] in
guard let self else { return }
let connected = path.status == .satisfied
let previouslyConnected = self.isConnected
self.isConnected = connected
self.connectionType = Self.resolveConnectionType(path)
if connected && !previouslyConnected {
self.networkRestoredPublisher.send()
}
if !connected {
self.wasDisconnected = true
}
}
}
monitor.start(queue: monitorQueue)
}
private static func resolveConnectionType(_ path: NWPath) -> ConnectionType {
if path.usesInterfaceType(.wifi) { return .wifi }
if path.usesInterfaceType(.cellular) { return .cellular }
if path.usesInterfaceType(.wiredEthernet) { return .wiredEthernet }
return .unknown
}
deinit {
monitor.cancel()
}
}
+56 -13
View File
@@ -218,6 +218,9 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
@ViewBuilder let placeholder: () -> Placeholder
@State private var loadedImage: PlatformImage?
@State private var loadFailed = false
private static var delayedRetryDelay: TimeInterval { 3.0 }
init(
url: URL?,
@@ -246,6 +249,7 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
}
.onDisappear {
loadedImage = nil
loadFailed = false
}
}
@@ -265,9 +269,17 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
guard !Task.isCancelled else { return }
ImageCache.shared.set(image, for: url)
loadedImage = image
loadFailed = false
} catch {
guard !Task.isCancelled else { return }
loadedImage = nil
if error.isNetworkConnectionError && !loadFailed {
loadFailed = true
try? await Task.sleep(nanoseconds: UInt64(Self.delayedRetryDelay * 1_000_000_000))
guard !Task.isCancelled else { return }
await loadImage()
}
}
}
}
@@ -307,33 +319,64 @@ final class ImageLoader {
self.session = URLSession(configuration: config)
}
private static let maxImageRetries = 1
private static let imageRetryDelay: TimeInterval = 1.0
func load(url: URL) async throws -> PlatformImage {
var request = URLRequest(url: url)
request.httpMethod = "GET"
// User-Agent
request.setValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
forHTTPHeaderField: "User-Agent"
)
// Referer
if let host = url.host {
request.setValue("https://\(host)/", forHTTPHeaderField: "Referer")
}
let (data, response) = try await session.data(for: request)
var lastError: Error = ImageLoadError.invalidData
let totalAttempts = Self.maxImageRetries + 1
// HTTP
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
throw ImageLoadError.httpError(httpResponse.statusCode)
for attempt in 0..<totalAttempts {
do {
try Task.checkCancellation()
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
let error = ImageLoadError.httpError(httpResponse.statusCode)
if Self.isRetryableImageError(error, statusCode: httpResponse.statusCode),
attempt < totalAttempts - 1 {
lastError = error
try await Task.sleep(nanoseconds: UInt64(Self.imageRetryDelay * 1_000_000_000))
continue
}
throw error
}
let maxPixelSize = thumbnailMaxPixelSize
guard let image = Self.decodeImage(data: data, maxPixelSize: maxPixelSize) else {
throw ImageLoadError.invalidData
}
return image
} catch is CancellationError {
throw CancellationError()
} catch {
lastError = error
if error.isNetworkConnectionError && attempt < totalAttempts - 1 {
try? await Task.sleep(nanoseconds: UInt64(Self.imageRetryDelay * 1_000_000_000))
continue
}
if attempt >= totalAttempts - 1 { throw error }
}
}
let maxPixelSize = thumbnailMaxPixelSize
guard let image = Self.decodeImage(data: data, maxPixelSize: maxPixelSize) else {
throw ImageLoadError.invalidData
}
return image
throw lastError
}
private static func isRetryableImageError(_ error: Error, statusCode: Int) -> Bool {
[408, 429, 500, 502, 503, 504].contains(statusCode)
}
var cacheUsage: (memory: Int, disk: Int) {
+27 -4
View File
@@ -1,14 +1,15 @@
import Foundation
import SwiftUI
import Combine
/// ViewModel
@MainActor
class HomeViewModel: ObservableObject {
///
/// ""
@Published var sorts: [MovieSort.SortData] = []
///
@Published var selectedSort: MovieSort.SortData?
///
/// ""
@Published var homeVideos: [Movie.Video] = []
///
@Published var categoryVideos: [Movie.Video] = []
@@ -23,6 +24,13 @@ class HomeViewModel: ObservableObject {
/// 访
private let sourceService = SourceService.shared
///
private var lastLoadFailedDueToNetwork = false
private var networkRestoredCancellable: AnyCancellable?
init() {
setupNetworkRestoredAutoRetry()
}
///
func loadSorts() async {
@@ -33,23 +41,38 @@ class HomeViewModel: ObservableObject {
do {
let result = try await sourceService.getSort(sourceBean: source)
// UI Android
// "" UI Android
var allSorts = [MovieSort.SortData.home()]
allSorts.append(contentsOf: result.sorts)
self.sorts = allSorts
self.homeVideos = result.homeVideos
lastLoadFailedDueToNetwork = false
if selectedSort == nil {
selectedSort = allSorts.first
}
} catch {
errorMessage = error.localizedDescription
lastLoadFailedDueToNetwork = error.isNetworkConnectionError
}
isLoading = false
}
///
private func setupNetworkRestoredAutoRetry() {
networkRestoredCancellable = NetworkMonitor.shared.networkRestoredPublisher
.sink { [weak self] in
guard let self else { return }
Task { @MainActor [weak self] in
guard let self else { return }
guard self.lastLoadFailedDueToNetwork || (self.sorts.isEmpty && self.homeVideos.isEmpty) else { return }
await self.refresh()
}
}
}
///
func selectSort(_ sort: MovieSort.SortData) {
//
@@ -89,7 +112,7 @@ class HomeViewModel: ObservableObject {
} else {
categoryVideos.append(contentsOf: videos)
}
//
// ""
currentPage = page
hasMore = !videos.isEmpty
} catch {
+34
View File
@@ -10,6 +10,8 @@ struct ContentView: View {
///
@EnvironmentObject var appState: AppState
///
@EnvironmentObject var networkMonitor: NetworkMonitor
/// ViewModel
@StateObject private var settingsVM = SettingsViewModel()
///
@@ -28,6 +30,9 @@ struct ContentView: View {
}
}
.overlay(multiRepoSelectionOverlay)
.overlay(alignment: .top) {
networkStatusBanner
}
.preferredColorScheme(.dark)
.onAppear {
//
@@ -353,6 +358,35 @@ struct ContentView: View {
}
}
///
@ViewBuilder
private var networkStatusBanner: some View {
if !networkMonitor.isConnected {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.font(.system(size: 13, weight: .semibold))
Text("网络连接已断开")
.font(.system(size: 13, weight: .medium))
if appState.isRetryingConfig {
ProgressView()
.scaleEffect(0.7)
.tint(.white)
}
}
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
Capsule()
.fill(Color.red.opacity(0.85))
)
.shadow(color: .black.opacity(0.3), radius: 8, y: 4)
.padding(.top, 8)
.transition(.move(edge: .top).combined(with: .opacity))
.animation(.easeInOut(duration: 0.3), value: networkMonitor.isConnected)
}
}
private func readPasteboardText() -> String? {
#if os(iOS)
UIPasteboard.general.string
+41 -2
View File
@@ -1,5 +1,6 @@
import SwiftUI
import SwiftData
import Combine
///
/// SwiftData `AppState`
@@ -7,6 +8,8 @@ import SwiftData
struct tvboxApp: App {
///
@StateObject private var appState = AppState()
///
@StateObject private var networkMonitor = NetworkMonitor.shared
/// SwiftData
/// Schema//使
@@ -29,6 +32,7 @@ struct tvboxApp: App {
WindowGroup {
ContentView()
.environmentObject(appState)
.environmentObject(networkMonitor)
}
.modelContainer(sharedModelContainer)
#if os(macOS)
@@ -47,6 +51,11 @@ class AppState: ObservableObject {
@Published var isConfigLoaded = false
/// key
@Published var currentSourceKey: String = ""
/// UI
@Published var configLoadError: String?
///
@Published var isRetryingConfig = false
#if os(macOS)
/// macOS //
@Published var splitViewVisibility: NavigationSplitViewVisibility = .all
@@ -54,6 +63,15 @@ class AppState: ObservableObject {
private var splitViewVisibilityBeforePlayerFullScreen: NavigationSplitViewVisibility?
#endif
///
private var lastVodUrl: String = ""
private var lastLiveUrl: String = ""
private var networkRestoredCancellable: AnyCancellable?
init() {
setupNetworkRestoredAutoRetry()
}
///
func loadConfig(url: String) async {
await loadConfig(vodUrl: url, liveUrl: nil)
@@ -69,21 +87,42 @@ class AppState: ObservableObject {
guard !trimmedVod.isEmpty else { return }
let resolvedLive = trimmedLive.isEmpty ? trimmedVod : trimmedLive
lastVodUrl = trimmedVod
lastLiveUrl = resolvedLive
configLoadError = nil
do {
try await ApiConfig.shared.loadConfigs(vodApiUrl: trimmedVod, liveApiUrl: resolvedLive)
applyLoadedConfigState()
} catch {
print("Failed to load config: \(error)")
if !(error is CancellationError) {
configLoadError = error.localizedDescription
}
}
}
///
/// ""
///
func applyLoadedConfigState() {
isConfigLoaded = true
configLoadError = nil
currentSourceKey = ApiConfig.shared.homeSourceBean?.key ?? ""
}
///
private func setupNetworkRestoredAutoRetry() {
networkRestoredCancellable = NetworkMonitor.shared.networkRestoredPublisher
.sink { [weak self] in
guard let self else { return }
Task { @MainActor [weak self] in
guard let self, !self.isConfigLoaded, !self.lastVodUrl.isEmpty else { return }
self.isRetryingConfig = true
await self.loadConfig(vodUrl: self.lastVodUrl, liveUrl: self.lastLiveUrl)
self.isRetryingConfig = false
}
}
}
#if os(macOS)
///
func enterPlayerFullScreen() {