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,22 +35,23 @@ public struct ProfileShareButton<Label>: View where Label: View {
private var bodyCompat: some View { private var bodyCompat: some View {
ShareButtonCompat(alert, label: label) { ShareButtonCompat(alert, label: label) {
try profile.toContent().generateShareFile() try await profile.generateShareFileAsync()
} }
} }
} }
public struct ShareButtonCompat<Label>: View where Label: View { public struct ShareButtonCompat<Label>: View where Label: View {
private let label: () -> Label private let label: () -> Label
private let itemURL: () throws -> URL private let itemURL: () async throws -> URL
@Binding private var alert: AlertState? @Binding private var alert: AlertState?
#if os(macOS) #if os(macOS)
@State private var sharePresented = false @State private var sharePresented = false
@State private var shareItemURL: URL?
#endif #endif
public init(_ alert: Binding<AlertState?>, @ViewBuilder label: @escaping () -> Label, itemURL: @escaping () throws -> URL) { public init(_ alert: Binding<AlertState?>, @ViewBuilder label: @escaping () -> Label, itemURL: @escaping () async throws -> URL) {
_alert = alert _alert = alert
self.label = label self.label = label
self.itemURL = itemURL self.itemURL = itemURL
@@ -60,7 +61,7 @@ public struct ShareButtonCompat<Label>: View where Label: View {
Button(action: shareItem, label: label) Button(action: shareItem, label: label)
.buttonStyle(.plain) .buttonStyle(.plain)
#if os(macOS) #if os(macOS)
.background(SharingServicePicker($sharePresented, $alert, itemURL)) .background(SharingServicePicker($sharePresented, $alert, $shareItemURL))
#endif #endif
} }
@@ -70,7 +71,9 @@ public struct ShareButtonCompat<Label>: View where Label: View {
await shareItemAsync() await shareItemAsync()
} }
#elseif os(macOS) #elseif os(macOS)
sharePresented = true Task {
await shareItemAsync()
}
#endif #endif
} }
@@ -103,6 +106,21 @@ public struct ShareButtonCompat<Label>: View where Label: View {
animated: true animated: true
) )
} }
#elseif os(macOS)
private nonisolated func shareItemAsync() async {
do {
let shareItem = try await itemURL()
await MainActor.run {
shareItemURL = shareItem
sharePresented = true
}
} catch {
await MainActor.run {
alert = AlertState(error: error)
}
}
}
#endif #endif
} }
@@ -110,12 +128,12 @@ public struct ShareButtonCompat<Label>: View where Label: View {
private struct SharingServicePicker: NSViewRepresentable { private struct SharingServicePicker: NSViewRepresentable {
@Binding private var isPresented: Bool @Binding private var isPresented: Bool
@Binding private var alert: AlertState? @Binding private var alert: AlertState?
private let item: () throws -> URL @Binding private var item: URL?
init(_ isPresented: Binding<Bool>, _ alert: Binding<AlertState?>, _ item: @escaping () throws -> URL) { init(_ isPresented: Binding<Bool>, _ alert: Binding<AlertState?>, _ item: Binding<URL?>) {
_isPresented = isPresented _isPresented = isPresented
_alert = alert _alert = alert
self.item = item _item = item
} }
func makeNSView(context _: Context) -> NSView { func makeNSView(context _: Context) -> NSView {
@@ -125,14 +143,15 @@ public struct ShareButtonCompat<Label>: View where Label: View {
func updateNSView(_ nsView: NSView, context: Context) { func updateNSView(_ nsView: NSView, context: Context) {
if isPresented { if isPresented {
do { guard let item else {
let picker = try NSSharingServicePicker(items: [item()]) return
picker.delegate = context.coordinator
DispatchQueue.main.async {
picker.show(relativeTo: .zero, of: nsView, preferredEdge: .minY)
} }
} catch { let picker = NSSharingServicePicker(items: [item])
alert = AlertState(error: error) picker.delegate = context.coordinator
picker.show(relativeTo: .zero, of: nsView, preferredEdge: .minY)
DispatchQueue.main.async {
isPresented = false
self.item = nil
} }
} }
} }
@@ -78,6 +78,20 @@ public struct ProfileCard: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL) QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
} }
} }
.sheet(
isPresented: $viewModel.showQRSShare,
onDismiss: {
viewModel.qrsShareData = nil
viewModel.qrsShareProfileName = nil
},
content: {
if let data = viewModel.qrsShareData, let name = viewModel.qrsShareProfileName {
QRSSheet(profileName: name, profileData: data)
} else {
ProgressView()
}
}
)
#else #else
.sheet(isPresented: $viewModel.showNewProfile, onDismiss: { .sheet(isPresented: $viewModel.showNewProfile, onDismiss: {
environments.profileUpdate.send() environments.profileUpdate.send()
@@ -98,11 +112,20 @@ public struct ProfileCard: View {
} }
} }
#endif #endif
.sheet(isPresented: $viewModel.showQRSShare) { .sheet(
if let profile = selectedProfile, let data = try? profile.origin.toContent().encode() { isPresented: $viewModel.showQRSShare,
QRSSheet(profileName: profile.name, profileData: data) onDismiss: {
viewModel.qrsShareData = nil
viewModel.qrsShareProfileName = nil
},
content: {
if let data = viewModel.qrsShareData, let name = viewModel.qrsShareProfileName {
QRSSheet(profileName: name, profileData: data)
} else {
ProgressView()
} }
} }
)
.fileExporter( .fileExporter(
isPresented: $viewModel.showExporter, isPresented: $viewModel.showExporter,
document: viewModel.exportDocument, document: viewModel.exportDocument,
@@ -253,13 +276,11 @@ public struct ProfileCard: View {
} }
} }
if let data = try? profile.origin.toContent().encode() { Button {
FormNavigationLink { prepareQRSShare(profile)
QRSSheet(profileName: profile.name, profileData: data)
} label: { } label: {
Label("Share as QRS Code", systemImage: "qrcode") Label("Share as QRS Code", systemImage: "qrcode")
} }
}
} label: { } label: {
Image(systemName: "square.and.arrow.up") Image(systemName: "square.and.arrow.up")
.font(.system(size: 16)) .font(.system(size: 16))
@@ -301,7 +322,7 @@ public struct ProfileCard: View {
} }
Button { Button {
viewModel.showQRSShare = true prepareQRSShare(profile)
} label: { } label: {
Label("Share as QRS Code", systemImage: "qrcode") Label("Share as QRS Code", systemImage: "qrcode")
} }
@@ -333,13 +354,14 @@ public struct ProfileCard: View {
#if !os(tvOS) #if !os(tvOS)
private func shareProfile(_ profile: ProfilePreview, type: ShareItemType) { private func shareProfile(_ profile: ProfilePreview, type: ShareItemType) {
Task {
do { do {
let url: URL let url: URL
switch type { switch type {
case .file: case .file:
url = try profile.origin.toContent().generateShareFile() url = try await profile.origin.generateShareFileAsync()
case .json: case .json:
url = try profile.origin.read().generateShareFile(name: "\(profile.name).json") url = try await profile.origin.generateJSONShareFileAsync(name: "\(profile.name).json")
} }
#if os(iOS) #if os(iOS)
presentShareController(url) presentShareController(url)
@@ -355,22 +377,31 @@ public struct ProfileCard: View {
viewModel.alert = AlertState(error: error) viewModel.alert = AlertState(error: error)
} }
} }
}
private func exportProfile(_ profile: ProfilePreview, type: ExportItemType) { private func exportProfile(_ profile: ProfilePreview, type: ExportItemType) {
Task {
do { do {
let document: ProfileAnyExportDocument
switch type { switch type {
case .file: case .file:
let doc = try ProfileExportDocument(content: profile.origin.toContent()) let data = try await profile.origin.encodedContentDataAsync()
viewModel.exportDocument = ProfileAnyExportDocument(profile: doc) document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
case .json: case .json:
let doc = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name) let content = try await profile.origin.readAsync()
viewModel.exportDocument = ProfileAnyExportDocument(json: doc) document = ProfileAnyExportDocument(data: Data(content.utf8), filename: "\(profile.name).json", contentType: .json)
} }
await MainActor.run {
viewModel.exportDocument = document
viewModel.showExporter = true viewModel.showExporter = true
}
} catch { } catch {
await MainActor.run {
viewModel.alert = AlertState(error: error) viewModel.alert = AlertState(error: error)
} }
} }
}
}
#if os(iOS) #if os(iOS)
private func presentShareController(_ item: URL) { private func presentShareController(_ item: URL) {
@@ -391,6 +422,20 @@ public struct ProfileCard: View {
#endif #endif
#endif #endif
private func prepareQRSShare(_ profile: ProfilePreview) {
viewModel.qrsShareProfileName = profile.name
viewModel.qrsShareData = nil
viewModel.showQRSShare = true
Task {
do {
viewModel.qrsShareData = try await profile.origin.encodedContentDataAsync()
} catch {
viewModel.alert = AlertState(error: error)
viewModel.showQRSShare = false
}
}
}
@ViewBuilder @ViewBuilder
private func profileInfo(for profile: ProfilePreview) -> some View { private func profileInfo(for profile: ProfilePreview) -> some View {
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -474,6 +519,8 @@ extension ProfileCard {
@Published var showProfilePicker = false @Published var showProfilePicker = false
@Published var showQRCode = false @Published var showQRCode = false
@Published var showQRSShare = false @Published var showQRSShare = false
@Published var qrsShareData: Data?
@Published var qrsShareProfileName: String?
@Published var isUpdating = false @Published var isUpdating = false
@Published var alert: AlertState? @Published var alert: AlertState?
@Published var profileToEdit: Profile? @Published var profileToEdit: Profile?
@@ -529,6 +529,7 @@ private struct ProfilePickerRow: View {
@State private var isUpdating = false @State private var isUpdating = false
@State private var showQRCode = false @State private var showQRCode = false
@State private var showQRSShare = false @State private var showQRSShare = false
@State private var qrsShareData: Data?
#if os(macOS) #if os(macOS)
@State private var shareItemType: ShareItemType? @State private var shareItemType: ShareItemType?
@State private var exportItemType: ExportItemType? @State private var exportItemType: ExportItemType?
@@ -561,6 +562,19 @@ private struct ProfilePickerRow: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL) QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
} }
} }
.sheet(
isPresented: $showQRSShare,
onDismiss: {
qrsShareData = nil
},
content: {
if let data = qrsShareData {
QRSSheet(profileName: profile.name, profileData: data)
} else {
ProgressView()
}
}
)
} }
private var tvOSNormalBody: some View { private var tvOSNormalBody: some View {
@@ -618,13 +632,11 @@ private struct ProfilePickerRow: View {
} }
} }
if let data = try? profile.origin.toContent().encode() { Button {
FormNavigationLink { prepareQRSShare()
QRSSheet(profileName: profile.name, profileData: data)
} label: { } label: {
Label("Share as QRS Code", systemImage: "barcode") Label("Share as QRS Code", systemImage: "barcode")
} }
}
} label: { } label: {
Label("Share", systemImage: "square.and.arrow.up") Label("Share", systemImage: "square.and.arrow.up")
} }
@@ -710,11 +722,19 @@ private struct ProfilePickerRow: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL) QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
} }
} }
.sheet(isPresented: $showQRSShare) { .sheet(
if let data = try? profile.origin.toContent().encode() { isPresented: $showQRSShare,
onDismiss: {
qrsShareData = nil
},
content: {
if let data = qrsShareData {
QRSSheet(profileName: profile.name, profileData: data) QRSSheet(profileName: profile.name, profileData: data)
} else {
ProgressView()
} }
} }
)
.fileExporter( .fileExporter(
isPresented: $showExporter, isPresented: $showExporter,
document: exportDocument, document: exportDocument,
@@ -749,11 +769,19 @@ private struct ProfilePickerRow: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL) QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
} }
} }
.sheet(isPresented: $showQRSShare) { .sheet(
if let data = try? profile.origin.toContent().encode() { isPresented: $showQRSShare,
onDismiss: {
qrsShareData = nil
},
content: {
if let data = qrsShareData {
QRSSheet(profileName: profile.name, profileData: data) QRSSheet(profileName: profile.name, profileData: data)
} else {
ProgressView()
} }
} }
)
.fileExporter( .fileExporter(
isPresented: $showExporter, isPresented: $showExporter,
document: exportDocument, document: exportDocument,
@@ -881,6 +909,24 @@ private struct ProfilePickerRow: View {
#endif #endif
} }
private func prepareQRSShare() {
qrsShareData = nil
showQRSShare = true
Task {
do {
let data = try await profile.origin.encodedContentDataAsync()
await MainActor.run {
qrsShareData = data
}
} catch {
await MainActor.run {
alert = AlertState(error: error)
showQRSShare = false
}
}
}
}
#if !os(tvOS) #if !os(tvOS)
@ViewBuilder @ViewBuilder
private var shareMenu: some View { private var shareMenu: some View {
@@ -919,7 +965,7 @@ private struct ProfilePickerRow: View {
ShareButtonCompat($alert) { ShareButtonCompat($alert) {
Label("Share File", systemImage: "doc") Label("Share File", systemImage: "doc")
} itemURL: { } itemURL: {
try profile.origin.toContent().generateShareFile() try await profile.origin.generateShareFileAsync()
} }
Button { Button {
@@ -931,7 +977,7 @@ private struct ProfilePickerRow: View {
ShareButtonCompat($alert) { ShareButtonCompat($alert) {
Label("Share Content JSON File", systemImage: "curlybraces") Label("Share Content JSON File", systemImage: "curlybraces")
} itemURL: { } itemURL: {
try profile.origin.read().generateShareFile(name: "\(profile.name).json") try await profile.origin.generateJSONShareFileAsync(name: "\(profile.name).json")
} }
#endif #endif
@@ -944,7 +990,7 @@ private struct ProfilePickerRow: View {
} }
Button { Button {
showQRSShare = true prepareQRSShare()
} label: { } label: {
Label("Share as QRS Code", systemImage: "barcode") Label("Share as QRS Code", systemImage: "barcode")
} }
@@ -954,20 +1000,28 @@ private struct ProfilePickerRow: View {
} }
private func exportProfile(type: ExportItemType) { private func exportProfile(type: ExportItemType) {
Task {
do { do {
let document: ProfileAnyExportDocument
switch type { switch type {
case .file: case .file:
let doc = try ProfileExportDocument(content: profile.origin.toContent()) let data = try await profile.origin.encodedContentDataAsync()
exportDocument = ProfileAnyExportDocument(profile: doc) document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
case .json: case .json:
let doc = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name) let content = try await profile.origin.readAsync()
exportDocument = ProfileAnyExportDocument(json: doc) document = ProfileAnyExportDocument(data: Data(content.utf8), filename: "\(profile.name).json", contentType: .json)
} }
await MainActor.run {
exportDocument = document
showExporter = true showExporter = true
}
} catch { } catch {
await MainActor.run {
alert = AlertState(error: error) alert = AlertState(error: error)
} }
} }
}
}
#endif #endif
private var profileInfo: some View { private var profileInfo: some View {
@@ -996,40 +1050,54 @@ private struct ProfilePickerRow: View {
#if os(macOS) #if os(macOS)
private func shareProfile(type: ShareItemType) { private func shareProfile(type: ShareItemType) {
Task {
do { do {
let url: URL let url: URL
switch type { switch type {
case .file: case .file:
url = try profile.origin.toContent().generateShareFile() url = try await profile.origin.generateShareFileAsync()
case .json: case .json:
url = try profile.origin.read().generateShareFile(name: "\(profile.name).json") url = try await profile.origin.generateJSONShareFileAsync(name: "\(profile.name).json")
} }
await MainActor.run {
let anchorView = menuAnchorView ?? NSApp.keyWindow?.contentView ?? NSView() let anchorView = menuAnchorView ?? NSApp.keyWindow?.contentView ?? NSView()
NSSharingServicePicker(items: [url]).show( NSSharingServicePicker(items: [url]).show(
relativeTo: .zero, relativeTo: .zero,
of: anchorView, of: anchorView,
preferredEdge: .minY preferredEdge: .minY
) )
}
} catch { } catch {
await MainActor.run {
alert = AlertState(error: error) alert = AlertState(error: error)
} }
} }
}
}
private func exportProfileMacOS(type: ExportItemType) { private func exportProfileMacOS(type: ExportItemType) {
Task {
do { do {
let document: ProfileAnyExportDocument
switch type { switch type {
case .file: case .file:
let doc = try ProfileExportDocument(content: profile.origin.toContent()) let data = try await profile.origin.encodedContentDataAsync()
exportDocument = ProfileAnyExportDocument(profile: doc) document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
case .json: case .json:
let doc = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name) let content = try await profile.origin.readAsync()
exportDocument = ProfileAnyExportDocument(json: doc) document = ProfileAnyExportDocument(data: Data(content.utf8), filename: "\(profile.name).json", contentType: .json)
} }
await MainActor.run {
exportDocument = document
showExporter = true showExporter = true
}
} catch { } catch {
await MainActor.run {
alert = AlertState(error: error) alert = AlertState(error: error)
} }
} }
}
}
static func previewContent(profile: ProfilePreview, width: CGFloat) -> some View { static func previewContent(profile: ProfilePreview, width: CGFloat) -> some View {
HStack(spacing: 12) { HStack(spacing: 12) {
@@ -1135,6 +1203,7 @@ private struct ProfilePickerRow: View {
@State private var isUpdating = false @State private var isUpdating = false
@State private var showQRCode = false @State private var showQRCode = false
@State private var showQRSShare = false @State private var showQRSShare = false
@State private var qrsShareData: Data?
@State private var exportDocument: ProfileAnyExportDocument? @State private var exportDocument: ProfileAnyExportDocument?
@State private var showExporter = false @State private var showExporter = false
@@ -1194,11 +1263,19 @@ private struct ProfilePickerRow: View {
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL) QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
} }
} }
.sheet(isPresented: $showQRSShare) { .sheet(
if let data = try? profile.origin.toContent().encode() { isPresented: $showQRSShare,
onDismiss: {
qrsShareData = nil
},
content: {
if let data = qrsShareData {
QRSSheet(profileName: profile.name, profileData: data) QRSSheet(profileName: profile.name, profileData: data)
} else {
ProgressView()
} }
} }
)
.fileExporter( .fileExporter(
isPresented: $showExporter, isPresented: $showExporter,
document: exportDocument, document: exportDocument,
@@ -1250,6 +1327,24 @@ private struct ProfilePickerRow: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
private func prepareQRSShare() {
qrsShareData = nil
showQRSShare = true
Task {
do {
let data = try await profile.origin.encodedContentDataAsync()
await MainActor.run {
qrsShareData = data
}
} catch {
await MainActor.run {
alert = AlertState(error: error)
showQRSShare = false
}
}
}
}
@ViewBuilder @ViewBuilder
private var shareMenu: some View { private var shareMenu: some View {
Menu { Menu {
@@ -1262,7 +1357,7 @@ private struct ProfilePickerRow: View {
ShareButtonCompat($alert) { ShareButtonCompat($alert) {
Label("Share File", systemImage: "doc") Label("Share File", systemImage: "doc")
} itemURL: { } itemURL: {
try profile.origin.toContent().generateShareFile() try await profile.origin.generateShareFileAsync()
} }
Button { Button {
@@ -1274,7 +1369,7 @@ private struct ProfilePickerRow: View {
ShareButtonCompat($alert) { ShareButtonCompat($alert) {
Label("Share Content JSON File", systemImage: "curlybraces") Label("Share Content JSON File", systemImage: "curlybraces")
} itemURL: { } itemURL: {
try profile.origin.read().generateShareFile(name: "\(profile.name).json") try await profile.origin.generateJSONShareFileAsync(name: "\(profile.name).json")
} }
if profile.type == .remote { if profile.type == .remote {
@@ -1286,7 +1381,7 @@ private struct ProfilePickerRow: View {
} }
Button { Button {
showQRSShare = true prepareQRSShare()
} label: { } label: {
Label("Share as QRS Code", systemImage: "barcode") Label("Share as QRS Code", systemImage: "barcode")
} }
@@ -1296,20 +1391,28 @@ private struct ProfilePickerRow: View {
} }
private func exportProfile(type: ExportItemType) { private func exportProfile(type: ExportItemType) {
Task {
do { do {
let document: ProfileAnyExportDocument
switch type { switch type {
case .file: case .file:
let doc = try ProfileExportDocument(content: profile.origin.toContent()) let data = try await profile.origin.encodedContentDataAsync()
exportDocument = ProfileAnyExportDocument(profile: doc) document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
case .json: case .json:
let doc = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name) let content = try await profile.origin.readAsync()
exportDocument = ProfileAnyExportDocument(json: doc) document = ProfileAnyExportDocument(data: Data(content.utf8), filename: "\(profile.name).json", contentType: .json)
} }
await MainActor.run {
exportDocument = document
showExporter = true showExporter = true
}
} catch { } catch {
await MainActor.run {
alert = AlertState(error: error) alert = AlertState(error: error)
} }
} }
}
}
private var profileInfo: some View { private var profileInfo: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -48,28 +48,33 @@ public final class EditProfileContentViewModel: BaseViewModel {
private func checkConfiguration() async { private func checkConfiguration() async {
let content = profileContent let content = profileContent
if content.isEmpty { return } if content.isEmpty { return }
let errorDescription: String? = await BlockingIO.run {
var error: NSError? var error: NSError?
LibboxCheckConfig(content, &error) LibboxCheckConfig(content, &error)
if let error { return error?.localizedDescription
configurationError = error.localizedDescription
} else {
configurationError = nil
} }
configurationError = errorDescription
} }
public func formatConfiguration() async { public func formatConfiguration() async {
let content = profileContent let content = profileContent
if content.isEmpty { return } if content.isEmpty { return }
do {
let formatted: String? = try await BlockingIO.run {
var error: NSError? var error: NSError?
let result = LibboxFormatConfig(content, &error) let result = LibboxFormatConfig(content, &error)
if let error { if let error {
configurationError = error.localizedDescription throw error
return
} }
if let formatted = result?.value, formatted != content { return result?.value
}
if let formatted, formatted != content {
profileContent = formatted profileContent = formatted
isChanged = true isChanged = true
} }
} catch {
configurationError = error.localizedDescription
}
} }
public func dismissConfigurationError() { public func dismissConfigurationError() {
@@ -92,7 +97,7 @@ public final class EditProfileContentViewModel: BaseViewModel {
guard let profile = try await ProfileManager.get(profileID) else { guard let profile = try await ProfileManager.get(profileID) else {
throw NSError(domain: "EditProfileContentViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Profile missing")]) throw NSError(domain: "EditProfileContentViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Profile missing")])
} }
let profileContent = try profile.read() let profileContent = try await profile.readAsync()
await MainActor.run { await MainActor.run {
self.profile = profile self.profile = profile
self.profileContent = profileContent self.profileContent = profileContent
@@ -114,6 +119,6 @@ public final class EditProfileContentViewModel: BaseViewModel {
private nonisolated func saveContentBackground(_ profile: Profile) async throws { private nonisolated func saveContentBackground(_ profile: Profile) async throws {
let profileContent = await profileContent let profileContent = await profileContent
try profile.write(profileContent) try await profile.writeAsync(profileContent)
} }
} }
@@ -139,17 +139,32 @@
default: default:
break break
} }
let profileName = content.name
let profileConfigContent = content.config
let remotePath = content.remotePath
let autoUpdate = content.autoUpdate
let autoUpdateInterval = content.autoUpdateInterval
let nextProfileID = try await ProfileManager.nextID() let nextProfileID = try await ProfileManager.nextID()
let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true) let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true)
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json") let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json")
try content.config.write(to: profileConfig, atomically: true, encoding: .utf8) try await BlockingIO.run {
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
try profileConfigContent.write(to: profileConfig, atomically: true, encoding: .utf8)
}
var lastUpdated: Date? var lastUpdated: Date?
if content.lastUpdated > 0 { if content.lastUpdated > 0 {
lastUpdated = dateFromTimestamp(content.lastUpdated) lastUpdated = dateFromTimestamp(content.lastUpdated)
} }
let uniqueProfileName = try await ProfileManager.uniqueName(content.name) let uniqueProfileName = try await ProfileManager.uniqueName(profileName)
let profile = Profile(name: uniqueProfileName, type: type, path: profileConfig.relativePath, remoteURL: content.remotePath, autoUpdate: content.autoUpdate, lastUpdated: lastUpdated) let profile = Profile(
name: uniqueProfileName,
type: type,
path: profileConfig.relativePath,
remoteURL: remotePath,
autoUpdate: autoUpdate,
autoUpdateInterval: autoUpdateInterval,
lastUpdated: lastUpdated
)
try await ProfileManager.create(profile) try await ProfileManager.create(profile)
await SharedPreferences.selectedProfileID.set(profile.mustID) await SharedPreferences.selectedProfileID.set(profile.mustID)
await reset() await reset()
@@ -193,6 +193,7 @@ public struct NewProfileMenuView: View {
#if !os(tvOS) #if !os(tvOS)
private func handleFileImport(_ result: Result<[URL], Error>) { private func handleFileImport(_ result: Result<[URL], Error>) {
Task { @MainActor in
do { do {
let urls = try result.get() let urls = try result.get()
guard let url = urls.first else { return } guard let url = urls.first else { return }
@@ -200,12 +201,17 @@ public struct NewProfileMenuView: View {
if url.pathExtension.lowercased() == "json" { if url.pathExtension.lowercased() == "json" {
let fileName = url.deletingPathExtension().lastPathComponent let fileName = url.deletingPathExtension().lastPathComponent
localImportRequest = NewProfileView.LocalImportRequest(name: fileName, fileURL: url) localImportRequest = NewProfileView.LocalImportRequest(name: fileName, fileURL: url)
} else { return
let content = try url.withRequiredSecurityScopedAccess( }
let data = try await BlockingIO.run {
try url.withRequiredSecurityScopedAccess(
or: NSError(domain: "NewProfileMenuView", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing access to selected file")]) or: NSError(domain: "NewProfileMenuView", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing access to selected file")])
) { ) {
try LibboxProfileContent.from(Data(contentsOf: url)) try Data(contentsOf: url)
} }
}
let content = try LibboxProfileContent.from(data)
alert = AlertState( alert = AlertState(
title: String(localized: "Import Profile"), title: String(localized: "Import Profile"),
@@ -223,11 +229,11 @@ public struct NewProfileMenuView: View {
}, },
secondaryButton: .cancel() secondaryButton: .cancel()
) )
}
} catch { } catch {
alert = AlertState(error: error) alert = AlertState(error: error)
} }
} }
}
#endif #endif
#if !os(tvOS) #if !os(tvOS)
@@ -107,8 +107,9 @@ public final class NewProfileViewModel: BaseViewModel {
if profileType == .local { if profileType == .local {
let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true) let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true)
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json") let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json")
try await BlockingIO.run {
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
if fileImport { if fileImport {
guard let fileURL else { guard let fileURL else {
throw NSError(domain: "NewProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing file")]) throw NSError(domain: "NewProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing file")])
@@ -121,29 +122,37 @@ public final class NewProfileViewModel: BaseViewModel {
} else { } else {
try "{}".write(to: profileConfig, atomically: true, encoding: .utf8) try "{}".write(to: profileConfig, atomically: true, encoding: .utf8)
} }
}
savePath = profileConfig.relativePath savePath = profileConfig.relativePath
} else if profileType == .icloud { } else if profileType == .icloud {
if !FileManager.default.fileExists(atPath: FilePath.iCloudDirectory.path) { let iCloudDirectory = FilePath.iCloudDirectory
try FileManager.default.createDirectory(at: FilePath.iCloudDirectory, withIntermediateDirectories: true) try await BlockingIO.run {
if !FileManager.default.fileExists(atPath: iCloudDirectory.path) {
try FileManager.default.createDirectory(at: iCloudDirectory, withIntermediateDirectories: true)
} }
let saveURL = FilePath.iCloudDirectory.appendingPathComponent(remotePath, isDirectory: false) let saveURL = iCloudDirectory.appendingPathComponent(remotePath, isDirectory: false)
do { do {
_ = try String(contentsOf: saveURL) _ = try String(contentsOf: saveURL)
} catch { } catch {
try "{}".write(to: saveURL, atomically: true, encoding: .utf8) try "{}".write(to: saveURL, atomically: true, encoding: .utf8)
} }
}
savePath = remotePath savePath = remotePath
} else if profileType == .remote { } else if profileType == .remote {
let remoteContent = try HTTPClient().getString(remotePath) let remoteContent = try await HTTPClient.getStringAsync(remotePath)
try await BlockingIO.run {
var error: NSError? var error: NSError?
LibboxCheckConfig(remoteContent, &error) LibboxCheckConfig(remoteContent, &error)
if let error { if let error {
throw error throw error
} }
}
let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true) let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true)
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json") let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json")
try await BlockingIO.run {
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
try remoteContent.write(to: profileConfig, atomically: true, encoding: .utf8) try remoteContent.write(to: profileConfig, atomically: true, encoding: .utf8)
}
savePath = profileConfig.relativePath savePath = profileConfig.relativePath
remoteURL = remotePath remoteURL = remotePath
lastUpdated = .now lastUpdated = .now
+27 -15
View File
@@ -150,22 +150,24 @@ public struct CoreView: View {
#if os(macOS) #if os(macOS)
let helperUnavailable = Variant.useSystemExtension && HelperServiceManager.rootHelperStatus != .enabled let helperUnavailable = Variant.useSystemExtension && HelperServiceManager.rootHelperStatus != .enabled
#endif #endif
let dataSize: String? let workingDirectory = FilePath.workingDirectory
let dataSize: String? = await BlockingIO.run {
#if os(macOS) #if os(macOS)
if Variant.useSystemExtension { if Variant.useSystemExtension {
if helperUnavailable { if helperUnavailable {
dataSize = nil return nil
} else if let size = try? RootHelperClient.shared.getWorkingDirectorySize() {
dataSize = LibboxFormatBytes(size)
} else {
dataSize = nil
} }
guard let size = try? RootHelperClient.shared.getWorkingDirectorySize() else {
return nil
}
return LibboxFormatBytes(size)
} else { } else {
dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown" return (try? workingDirectory.formattedSize()) ?? "Unknown"
} }
#else #else
dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown" return (try? workingDirectory.formattedSize()) ?? "Unknown"
#endif #endif
}
await MainActor.run { await MainActor.run {
#if os(macOS) #if os(macOS)
self.helperUnavailable = helperUnavailable self.helperUnavailable = helperUnavailable
@@ -208,14 +210,21 @@ public struct CoreView: View {
private func destroyWorkingDirectory() async { private func destroyWorkingDirectory() async {
do { do {
let workingDirectory = FilePath.workingDirectory
#if os(macOS) #if os(macOS)
if Variant.useSystemExtension { if Variant.useSystemExtension {
try await BlockingIO.run {
try RootHelperClient.shared.cleanWorkingDirectory() try RootHelperClient.shared.cleanWorkingDirectory()
}
} else { } else {
try clearWorkingDirectoryContents() try await BlockingIO.run {
try Self.clearWorkingDirectoryContents(at: workingDirectory)
}
} }
#else #else
try clearWorkingDirectoryContents() try await BlockingIO.run {
try Self.clearWorkingDirectoryContents(at: workingDirectory)
}
#if os(iOS) #if os(iOS)
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
await notifyFileProviderWorkingDirectoryChanged() await notifyFileProviderWorkingDirectoryChanged()
@@ -228,8 +237,7 @@ public struct CoreView: View {
} }
} }
private func clearWorkingDirectoryContents() throws { private nonisolated static func clearWorkingDirectoryContents(at url: URL) throws {
let url = FilePath.workingDirectory
guard FileManager.default.fileExists(atPath: url.path) else { guard FileManager.default.fileExists(atPath: url.path) else {
return return
} }
@@ -301,11 +309,15 @@ public struct CoreView: View {
private extension URL { private extension URL {
func formattedSize() throws -> String? { func formattedSize() throws -> String? {
guard let urls = FileManager.default.enumerator(at: self, includingPropertiesForKeys: nil)?.allObjects as? [URL] else { guard let enumerator = FileManager.default.enumerator(
at: self,
includingPropertiesForKeys: [.totalFileAllocatedSizeKey]
) else {
return nil return nil
} }
let size = try urls.lazy.reduce(0) { var size = 0
try ($1.resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize ?? 0) + $0 while let url = enumerator.nextObject() as? URL {
size += try url.resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize ?? 0
} }
let formatter = ByteCountFormatter() let formatter = ByteCountFormatter()
formatter.countStyle = .file formatter.countStyle = .file
@@ -38,7 +38,7 @@ public struct ServiceLogView: View {
ShareButtonCompat($viewModel.alert) { ShareButtonCompat($viewModel.alert) {
Label("Export", systemImage: "square.and.arrow.up.fill") Label("Export", systemImage: "square.and.arrow.up.fill")
} itemURL: { } itemURL: {
try viewModel.generateShareFile() try await viewModel.generateShareFileAsync()
} }
#endif #endif
Button(role: .destructive) { Button(role: .destructive) {
@@ -16,14 +16,13 @@ final class ServiceLogViewModel: BaseViewModel {
} }
nonisolated func loadContent() async { nonisolated func loadContent() async {
var content = "" let primaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log")
do { let secondaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log.old")
content = try String(contentsOf: FilePath.cacheDirectory.appendingPathComponent("stderr.log")) var content = await BlockingIO.run {
} catch {} if let primaryContent = try? String(contentsOf: primaryLogURL), !primaryContent.isEmpty {
if content.isEmpty { return primaryContent
do { }
content = try String(contentsOf: FilePath.cacheDirectory.appendingPathComponent("stderr.log.old")) return (try? String(contentsOf: secondaryLogURL)) ?? ""
} catch {}
} }
#if DEBUG #if DEBUG
if content.isEmpty { if content.isEmpty {
@@ -53,8 +52,12 @@ final class ServiceLogViewModel: BaseViewModel {
} }
nonisolated func deleteContent(dismiss: DismissAction) async { nonisolated func deleteContent(dismiss: DismissAction) async {
try? FileManager.default.removeItem(at: FilePath.cacheDirectory.appendingPathComponent("stderr.log")) let primaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log")
try? FileManager.default.removeItem(at: FilePath.cacheDirectory.appendingPathComponent("stderr.log.old")) let secondaryLogURL = FilePath.cacheDirectory.appendingPathComponent("stderr.log.old")
await BlockingIO.run {
try? FileManager.default.removeItem(at: primaryLogURL)
try? FileManager.default.removeItem(at: secondaryLogURL)
}
await MainActor.run { await MainActor.run {
dismiss() dismiss()
isLoading = true isLoading = true
@@ -64,4 +67,11 @@ final class ServiceLogViewModel: BaseViewModel {
func generateShareFile() throws -> URL { func generateShareFile() throws -> URL {
try content.generateShareFile(name: "service.log") try content.generateShareFile(name: "service.log")
} }
func generateShareFileAsync() async throws -> URL {
let content = content
return try await BlockingIO.run {
try content.generateShareFile(name: "service.log")
}
}
} }
+35
View File
@@ -2,6 +2,9 @@ import Foundation
public extension Profile { public extension Profile {
func read() throws -> String { func read() throws -> String {
#if DEBUG
precondition(!Thread.isMainThread, "Profile.read() must not be called on the main thread")
#endif
switch type { switch type {
case .local, .remote: case .local, .remote:
return try String(contentsOfFile: path) return try String(contentsOfFile: path)
@@ -12,6 +15,37 @@ public extension Profile {
} }
func write(_ content: String) throws { 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)
case .icloud:
let saveURL = FilePath.iCloudDirectory.appendingPathComponent(path)
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 { switch type {
case .local, .remote: case .local, .remote:
try content.write(toFile: path, atomically: true, encoding: .utf8) try content.write(toFile: path, atomically: true, encoding: .utf8)
@@ -21,3 +55,4 @@ public extension Profile {
} }
} }
} }
}
+99 -3
View File
@@ -26,6 +26,79 @@ public extension Profile {
} }
return content 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 { public func dateFromTimestamp(_ timestamp: Int64) -> Date {
@@ -57,17 +130,34 @@ public extension LibboxProfileContent {
@discardableResult @discardableResult
func importProfile() async throws -> Profile { 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 nextProfileID = try await ProfileManager.nextID()
let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true) let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true)
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json") let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json")
try config.write(to: profileConfig, atomically: true, encoding: .utf8)
var lastUpdatedAt: Date? var lastUpdatedAt: Date?
if lastUpdated > 0 { if lastUpdated > 0 {
lastUpdatedAt = dateFromTimestamp(lastUpdated) 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 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) try await ProfileManager.create(profile)
await SharedPreferences.selectedProfileID.set(profile.mustID) await SharedPreferences.selectedProfileID.set(profile.mustID)
return profile return profile
@@ -190,6 +280,12 @@ public extension UTType {
public let filename: String public let filename: String
public let contentType: UTType 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) { public init(profile: ProfileExportDocument) {
data = profile.data data = profile.data
filename = profile.filename filename = profile.filename
+5 -3
View File
@@ -7,21 +7,23 @@ public extension Profile {
if type != .remote { if type != .remote {
return return
} }
let remoteContent = try HTTPClient().getString(remoteURL) let remoteContent = try await HTTPClient.getStringAsync(remoteURL)
try await BlockingIO.run {
var error: NSError? var error: NSError?
LibboxCheckConfig(remoteContent, &error) LibboxCheckConfig(remoteContent, &error)
if let error { if let error {
throw error throw error
} }
}
lastUpdated = Date() lastUpdated = Date()
try await ProfileManager.update(self) try await ProfileManager.update(self)
do { do {
let oldContent = try read() let oldContent = try await readAsync()
if oldContent == remoteContent { if oldContent == remoteContent {
return return
} }
} catch {} } catch {}
try write(remoteContent) try await writeAsync(remoteContent)
try await onProfileUpdated() 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["configContent"] = NSString(string: configContent)
options["ignoreMemoryLimit"] = await NSNumber(value: SharedPreferences.ignoreMemoryLimit.get()) options["ignoreMemoryLimit"] = await NSNumber(value: SharedPreferences.ignoreMemoryLimit.get())
@@ -215,10 +215,9 @@ public class ExtensionProfile: ObservableObject {
} }
public func fetchProfile() async throws { public func fetchProfile() async throws {
if let profile = try await ProfileManager.get(Int64(SharedPreferences.selectedProfileID.get())) { let profileID = await SharedPreferences.selectedProfileID.get()
if profile.type == .icloud { if let profile = try await ProfileManager.get(profileID), profile.type == .icloud {
_ = try profile.read() _ = try await profile.readAsync()
}
} }
} }
+13
View File
@@ -24,6 +24,9 @@ public class HTTPClient {
} }
public func getString(_ url: String?) throws -> String { 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()! let request = client.newRequest()!
request.setUserAgent(HTTPClient.userAgent) request.setUserAgent(HTTPClient.userAgent)
try request.setURL(url) try request.setURL(url)
@@ -32,6 +35,16 @@ public class HTTPClient {
return content.value 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 { deinit {
client.close() client.close()
} }
+1 -1
View File
@@ -57,7 +57,7 @@
} }
} }
public class RootHelperClient { public class RootHelperClient: @unchecked Sendable {
public static let shared = RootHelperClient() public static let shared = RootHelperClient()
private var connection: NSXPCConnection? 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())
}
}
}
}