Fix main-thread blocking I/O

This commit is contained in:
世界
2026-02-05 02:37:02 +08:00
parent 4ae421cd04
commit 2e682d746c
17 changed files with 643 additions and 237 deletions
+35
View File
@@ -2,6 +2,9 @@ import Foundation
public extension Profile {
func read() throws -> String {
#if DEBUG
precondition(!Thread.isMainThread, "Profile.read() must not be called on the main thread")
#endif
switch type {
case .local, .remote:
return try String(contentsOfFile: path)
@@ -12,6 +15,9 @@ public extension Profile {
}
func write(_ content: String) throws {
#if DEBUG
precondition(!Thread.isMainThread, "Profile.write(...) must not be called on the main thread")
#endif
switch type {
case .local, .remote:
try content.write(toFile: path, atomically: true, encoding: .utf8)
@@ -20,4 +26,33 @@ public extension Profile {
try content.write(to: saveURL, atomically: true, encoding: .utf8)
}
}
func readAsync() async throws -> String {
let type = type
let path = path
return try await BlockingIO.run {
switch type {
case .local, .remote:
return try String(contentsOfFile: path)
case .icloud:
let saveURL = FilePath.iCloudDirectory.appendingPathComponent(path)
return try String(contentsOf: saveURL)
}
}
}
func writeAsync(_ content: String) async throws {
let type = type
let path = path
let content = content
try await BlockingIO.run {
switch type {
case .local, .remote:
try content.write(toFile: path, atomically: true, encoding: .utf8)
case .icloud:
let saveURL = FilePath.iCloudDirectory.appendingPathComponent(path)
try content.write(to: saveURL, atomically: true, encoding: .utf8)
}
}
}
}
+99 -3
View File
@@ -26,6 +26,79 @@ public extension Profile {
}
return content
}
func encodedContentDataAsync() async throws -> Data {
let name = name
let type = type
let remoteURL = remoteURL
let autoUpdate = autoUpdate
let autoUpdateInterval = autoUpdateInterval
let lastUpdated = lastUpdated
let config = try await readAsync()
return try await BlockingIO.run {
let content = LibboxProfileContent()
content.name = name
switch type {
case .local, .icloud:
content.type = LibboxProfileTypeLocal
case .remote:
content.type = LibboxProfileTypeRemote
}
content.config = config
if type == .remote {
content.remotePath = remoteURL!
content.autoUpdate = autoUpdate
content.autoUpdateInterval = autoUpdateInterval
if let lastUpdated {
content.lastUpdated = Int64(lastUpdated.timeIntervalSince1970 * 1000)
}
}
guard let encoded = content.encode() else {
throw NSError(domain: "Profile", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to encode profile"])
}
return encoded
}
}
func generateShareFileAsync() async throws -> URL {
let name = name
let type = type
let remoteURL = remoteURL
let autoUpdate = autoUpdate
let autoUpdateInterval = autoUpdateInterval
let lastUpdated = lastUpdated
let config = try await readAsync()
return try await BlockingIO.run {
let content = LibboxProfileContent()
content.name = name
switch type {
case .local, .icloud:
content.type = LibboxProfileTypeLocal
case .remote:
content.type = LibboxProfileTypeRemote
}
content.config = config
if type == .remote {
content.remotePath = remoteURL!
content.autoUpdate = autoUpdate
content.autoUpdateInterval = autoUpdateInterval
if let lastUpdated {
content.lastUpdated = Int64(lastUpdated.timeIntervalSince1970 * 1000)
}
}
return try content.generateShareFile()
}
}
func generateJSONShareFileAsync(name: String) async throws -> URL {
let filename = name
let config = try await readAsync()
return try await BlockingIO.run {
try config.generateShareFile(name: filename)
}
}
}
public func dateFromTimestamp(_ timestamp: Int64) -> Date {
@@ -57,17 +130,34 @@ public extension LibboxProfileContent {
@discardableResult
func importProfile() async throws -> Profile {
let name = name
let type = type
let config = config
let remotePath = remotePath
let autoUpdate = autoUpdate
let autoUpdateInterval = autoUpdateInterval
let lastUpdated = lastUpdated
let nextProfileID = try await ProfileManager.nextID()
let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true)
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json")
try config.write(to: profileConfig, atomically: true, encoding: .utf8)
var lastUpdatedAt: Date?
if lastUpdated > 0 {
lastUpdatedAt = dateFromTimestamp(lastUpdated)
}
try await BlockingIO.run {
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
try config.write(to: profileConfig, atomically: true, encoding: .utf8)
}
let uniqueProfileName = try await ProfileManager.uniqueName(name)
let profile = Profile(name: uniqueProfileName, type: ProfileType(rawValue: Int(type))!, path: profileConfig.relativePath, remoteURL: remotePath, autoUpdate: autoUpdate, autoUpdateInterval: autoUpdateInterval, lastUpdated: lastUpdatedAt)
let profile = Profile(
name: uniqueProfileName,
type: ProfileType(rawValue: Int(type))!,
path: profileConfig.relativePath,
remoteURL: remotePath,
autoUpdate: autoUpdate,
autoUpdateInterval: autoUpdateInterval,
lastUpdated: lastUpdatedAt
)
try await ProfileManager.create(profile)
await SharedPreferences.selectedProfileID.set(profile.mustID)
return profile
@@ -190,6 +280,12 @@ public extension UTType {
public let filename: String
public let contentType: UTType
public init(data: Data, filename: String, contentType: UTType) {
self.data = data
self.filename = filename
self.contentType = contentType
}
public init(profile: ProfileExportDocument) {
data = profile.data
filename = profile.filename
+9 -7
View File
@@ -7,21 +7,23 @@ public extension Profile {
if type != .remote {
return
}
let remoteContent = try HTTPClient().getString(remoteURL)
var error: NSError?
LibboxCheckConfig(remoteContent, &error)
if let error {
throw error
let remoteContent = try await HTTPClient.getStringAsync(remoteURL)
try await BlockingIO.run {
var error: NSError?
LibboxCheckConfig(remoteContent, &error)
if let error {
throw error
}
}
lastUpdated = Date()
try await ProfileManager.update(self)
do {
let oldContent = try read()
let oldContent = try await readAsync()
if oldContent == remoteContent {
return
}
} catch {}
try write(remoteContent)
try await writeAsync(remoteContent)
try await onProfileUpdated()
}
+4 -5
View File
@@ -193,7 +193,7 @@ public class ExtensionProfile: ObservableObject {
])
}
let configContent = try profile.read()
let configContent = try await profile.readAsync()
options["configContent"] = NSString(string: configContent)
options["ignoreMemoryLimit"] = await NSNumber(value: SharedPreferences.ignoreMemoryLimit.get())
@@ -215,10 +215,9 @@ public class ExtensionProfile: ObservableObject {
}
public func fetchProfile() async throws {
if let profile = try await ProfileManager.get(Int64(SharedPreferences.selectedProfileID.get())) {
if profile.type == .icloud {
_ = try profile.read()
}
let profileID = await SharedPreferences.selectedProfileID.get()
if let profile = try await ProfileManager.get(profileID), profile.type == .icloud {
_ = try await profile.readAsync()
}
}
+13
View File
@@ -24,6 +24,9 @@ public class HTTPClient {
}
public func getString(_ url: String?) throws -> String {
#if DEBUG
precondition(!Thread.isMainThread, "HTTPClient.getString(...) must not be called on the main thread")
#endif
let request = client.newRequest()!
request.setUserAgent(HTTPClient.userAgent)
try request.setURL(url)
@@ -32,6 +35,16 @@ public class HTTPClient {
return content.value
}
public func getStringAsync(_ url: String?) async throws -> String {
try await Self.getStringAsync(url)
}
public static func getStringAsync(_ url: String?) async throws -> String {
try await BlockingIO.run {
try HTTPClient().getString(url)
}
}
deinit {
client.close()
}
+1 -1
View File
@@ -57,7 +57,7 @@
}
}
public class RootHelperClient {
public class RootHelperClient: @unchecked Sendable {
public static let shared = RootHelperClient()
private var connection: NSXPCConnection?
+35
View File
@@ -0,0 +1,35 @@
import Foundation
public enum BlockingIO {
private static let queue = DispatchQueue(
label: "io.nekohasekai.sing-box.blocking-io",
qos: .userInitiated,
attributes: .concurrent
)
public static func run<T: Sendable>(_ operation: @escaping @Sendable () throws -> T) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
queue.async {
#if DEBUG
precondition(!Thread.isMainThread, "BlockingIO operation must not run on the main thread")
#endif
do {
try continuation.resume(returning: operation())
} catch {
continuation.resume(throwing: error)
}
}
}
}
public static func run<T: Sendable>(_ operation: @escaping @Sendable () -> T) async -> T {
await withCheckedContinuation { continuation in
queue.async {
#if DEBUG
precondition(!Thread.isMainThread, "BlockingIO operation must not run on the main thread")
#endif
continuation.resume(returning: operation())
}
}
}
}