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 { let picker = NSSharingServicePicker(items: [item])
picker.show(relativeTo: .zero, of: nsView, preferredEdge: .minY) picker.delegate = context.coordinator
} picker.show(relativeTo: .zero, of: nsView, preferredEdge: .minY)
} catch { DispatchQueue.main.async {
alert = AlertState(error: error) 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,12 +276,10 @@ 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")
@@ -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,42 +354,52 @@ public struct ProfileCard: View {
#if !os(tvOS) #if !os(tvOS)
private func shareProfile(_ profile: ProfilePreview, type: ShareItemType) { private func shareProfile(_ profile: ProfilePreview, type: ShareItemType) {
do { Task {
let url: URL do {
switch type { let url: URL
case .file: switch type {
url = try profile.origin.toContent().generateShareFile() case .file:
case .json: url = try await profile.origin.generateShareFileAsync()
url = try profile.origin.read().generateShareFile(name: "\(profile.name).json") case .json:
url = try await profile.origin.generateJSONShareFileAsync(name: "\(profile.name).json")
}
#if os(iOS)
presentShareController(url)
#elseif os(macOS)
let anchorView = viewModel.shareButtonView ?? NSApp.keyWindow?.contentView ?? NSView()
NSSharingServicePicker(items: [url]).show(
relativeTo: .zero,
of: anchorView,
preferredEdge: .minY
)
#endif
} catch {
viewModel.alert = AlertState(error: error)
} }
#if os(iOS)
presentShareController(url)
#elseif os(macOS)
let anchorView = viewModel.shareButtonView ?? NSApp.keyWindow?.contentView ?? NSView()
NSSharingServicePicker(items: [url]).show(
relativeTo: .zero,
of: anchorView,
preferredEdge: .minY
)
#endif
} catch {
viewModel.alert = AlertState(error: error)
} }
} }
private func exportProfile(_ profile: ProfilePreview, type: ExportItemType) { private func exportProfile(_ profile: ProfilePreview, type: ExportItemType) {
do { Task {
switch type { do {
case .file: let document: ProfileAnyExportDocument
let doc = try ProfileExportDocument(content: profile.origin.toContent()) switch type {
viewModel.exportDocument = ProfileAnyExportDocument(profile: doc) case .file:
case .json: let data = try await profile.origin.encodedContentDataAsync()
let doc = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name) document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
viewModel.exportDocument = ProfileAnyExportDocument(json: doc) case .json:
let content = try await profile.origin.readAsync()
document = ProfileAnyExportDocument(data: Data(content.utf8), filename: "\(profile.name).json", contentType: .json)
}
await MainActor.run {
viewModel.exportDocument = document
viewModel.showExporter = true
}
} catch {
await MainActor.run {
viewModel.alert = AlertState(error: error)
}
} }
viewModel.showExporter = true
} catch {
viewModel.alert = AlertState(error: error)
} }
} }
@@ -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,12 +632,10 @@ 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,
QRSSheet(profileName: profile.name, profileData: data) onDismiss: {
qrsShareData = nil
},
content: {
if let data = qrsShareData {
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,
QRSSheet(profileName: profile.name, profileData: data) onDismiss: {
qrsShareData = nil
},
content: {
if let data = qrsShareData {
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,18 +1000,26 @@ private struct ProfilePickerRow: View {
} }
private func exportProfile(type: ExportItemType) { private func exportProfile(type: ExportItemType) {
do { Task {
switch type { do {
case .file: let document: ProfileAnyExportDocument
let doc = try ProfileExportDocument(content: profile.origin.toContent()) switch type {
exportDocument = ProfileAnyExportDocument(profile: doc) case .file:
case .json: let data = try await profile.origin.encodedContentDataAsync()
let doc = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name) document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
exportDocument = ProfileAnyExportDocument(json: doc) case .json:
let content = try await profile.origin.readAsync()
document = ProfileAnyExportDocument(data: Data(content.utf8), filename: "\(profile.name).json", contentType: .json)
}
await MainActor.run {
exportDocument = document
showExporter = true
}
} catch {
await MainActor.run {
alert = AlertState(error: error)
}
} }
showExporter = true
} catch {
alert = AlertState(error: error)
} }
} }
#endif #endif
@@ -996,38 +1050,52 @@ private struct ProfilePickerRow: View {
#if os(macOS) #if os(macOS)
private func shareProfile(type: ShareItemType) { private func shareProfile(type: ShareItemType) {
do { Task {
let url: URL do {
switch type { let url: URL
case .file: switch type {
url = try profile.origin.toContent().generateShareFile() case .file:
case .json: url = try await profile.origin.generateShareFileAsync()
url = try profile.origin.read().generateShareFile(name: "\(profile.name).json") case .json:
url = try await profile.origin.generateJSONShareFileAsync(name: "\(profile.name).json")
}
await MainActor.run {
let anchorView = menuAnchorView ?? NSApp.keyWindow?.contentView ?? NSView()
NSSharingServicePicker(items: [url]).show(
relativeTo: .zero,
of: anchorView,
preferredEdge: .minY
)
}
} catch {
await MainActor.run {
alert = AlertState(error: error)
}
} }
let anchorView = menuAnchorView ?? NSApp.keyWindow?.contentView ?? NSView()
NSSharingServicePicker(items: [url]).show(
relativeTo: .zero,
of: anchorView,
preferredEdge: .minY
)
} catch {
alert = AlertState(error: error)
} }
} }
private func exportProfileMacOS(type: ExportItemType) { private func exportProfileMacOS(type: ExportItemType) {
do { Task {
switch type { do {
case .file: let document: ProfileAnyExportDocument
let doc = try ProfileExportDocument(content: profile.origin.toContent()) switch type {
exportDocument = ProfileAnyExportDocument(profile: doc) case .file:
case .json: let data = try await profile.origin.encodedContentDataAsync()
let doc = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name) document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
exportDocument = ProfileAnyExportDocument(json: doc) case .json:
let content = try await profile.origin.readAsync()
document = ProfileAnyExportDocument(data: Data(content.utf8), filename: "\(profile.name).json", contentType: .json)
}
await MainActor.run {
exportDocument = document
showExporter = true
}
} catch {
await MainActor.run {
alert = AlertState(error: error)
}
} }
showExporter = true
} catch {
alert = AlertState(error: error)
} }
} }
@@ -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,
QRSSheet(profileName: profile.name, profileData: data) onDismiss: {
qrsShareData = nil
},
content: {
if let data = qrsShareData {
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,18 +1391,26 @@ private struct ProfilePickerRow: View {
} }
private func exportProfile(type: ExportItemType) { private func exportProfile(type: ExportItemType) {
do { Task {
switch type { do {
case .file: let document: ProfileAnyExportDocument
let doc = try ProfileExportDocument(content: profile.origin.toContent()) switch type {
exportDocument = ProfileAnyExportDocument(profile: doc) case .file:
case .json: let data = try await profile.origin.encodedContentDataAsync()
let doc = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name) document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
exportDocument = ProfileAnyExportDocument(json: doc) case .json:
let content = try await profile.origin.readAsync()
document = ProfileAnyExportDocument(data: Data(content.utf8), filename: "\(profile.name).json", contentType: .json)
}
await MainActor.run {
exportDocument = document
showExporter = true
}
} catch {
await MainActor.run {
alert = AlertState(error: error)
}
} }
showExporter = true
} catch {
alert = AlertState(error: error)
} }
} }
@@ -48,27 +48,32 @@ 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 }
var error: NSError? let errorDescription: String? = await BlockingIO.run {
LibboxCheckConfig(content, &error) var error: NSError?
if let error { LibboxCheckConfig(content, &error)
configurationError = error.localizedDescription return 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 }
var error: NSError? do {
let result = LibboxFormatConfig(content, &error) let formatted: String? = try await BlockingIO.run {
if let error { var error: NSError?
let result = LibboxFormatConfig(content, &error)
if let error {
throw error
}
return result?.value
}
if let formatted, formatted != content {
profileContent = formatted
isChanged = true
}
} catch {
configurationError = error.localizedDescription configurationError = error.localizedDescription
return
}
if let formatted = result?.value, formatted != content {
profileContent = formatted
isChanged = true
} }
} }
@@ -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,20 +193,26 @@ public struct NewProfileMenuView: View {
#if !os(tvOS) #if !os(tvOS)
private func handleFileImport(_ result: Result<[URL], Error>) { private func handleFileImport(_ result: Result<[URL], Error>) {
do { Task { @MainActor in
let urls = try result.get() do {
guard let url = urls.first else { return } let urls = try result.get()
guard let url = urls.first else { return }
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(
or: NSError(domain: "NewProfileMenuView", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing access to selected file")])
) {
try LibboxProfileContent.from(Data(contentsOf: url))
} }
let data = try await BlockingIO.run {
try url.withRequiredSecurityScopedAccess(
or: NSError(domain: "NewProfileMenuView", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing access to selected file")])
) {
try Data(contentsOf: url)
}
}
let content = try LibboxProfileContent.from(data)
alert = AlertState( alert = AlertState(
title: String(localized: "Import Profile"), title: String(localized: "Import Profile"),
message: String(localized: "Are you sure to import profile \(content.name)?"), message: String(localized: "Are you sure to import profile \(content.name)?"),
@@ -223,9 +229,9 @@ public struct NewProfileMenuView: View {
}, },
secondaryButton: .cancel() secondaryButton: .cancel()
) )
} catch {
alert = AlertState(error: error)
} }
} catch {
alert = AlertState(error: error)
} }
} }
#endif #endif
@@ -107,43 +107,52 @@ 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")
if fileImport { try await BlockingIO.run {
guard let fileURL else { try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
throw NSError(domain: "NewProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing file")]) if fileImport {
guard let fileURL else {
throw NSError(domain: "NewProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing file")])
}
try fileURL.withRequiredSecurityScopedAccess(
or: NSError(domain: "NewProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing access to selected file")])
) {
try String(contentsOf: fileURL).write(to: profileConfig, atomically: true, encoding: .utf8)
}
} else {
try "{}".write(to: profileConfig, atomically: true, encoding: .utf8)
} }
try fileURL.withRequiredSecurityScopedAccess(
or: NSError(domain: "NewProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing access to selected file")])
) {
try String(contentsOf: fileURL).write(to: profileConfig, atomically: true, encoding: .utf8)
}
} else {
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) {
let saveURL = FilePath.iCloudDirectory.appendingPathComponent(remotePath, isDirectory: false) try FileManager.default.createDirectory(at: iCloudDirectory, withIntermediateDirectories: true)
do { }
_ = try String(contentsOf: saveURL) let saveURL = iCloudDirectory.appendingPathComponent(remotePath, isDirectory: false)
} catch { do {
try "{}".write(to: saveURL, atomically: true, encoding: .utf8) _ = try String(contentsOf: saveURL)
} catch {
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)
var error: NSError? try await BlockingIO.run {
LibboxCheckConfig(remoteContent, &error) var error: NSError?
if let error { LibboxCheckConfig(remoteContent, &error)
throw error if let 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 remoteContent.write(to: profileConfig, atomically: true, encoding: .utf8) try await BlockingIO.run {
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
try remoteContent.write(to: profileConfig, atomically: true, encoding: .utf8)
}
savePath = profileConfig.relativePath savePath = profileConfig.relativePath
remoteURL = remotePath remoteURL = remotePath
lastUpdated = .now lastUpdated = .now
+34 -22
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
#if os(macOS) let dataSize: String? = await BlockingIO.run {
if Variant.useSystemExtension { #if os(macOS)
if helperUnavailable { if Variant.useSystemExtension {
dataSize = nil if helperUnavailable {
} else if let size = try? RootHelperClient.shared.getWorkingDirectorySize() { return nil
dataSize = LibboxFormatBytes(size) }
guard let size = try? RootHelperClient.shared.getWorkingDirectorySize() else {
return nil
}
return LibboxFormatBytes(size)
} else { } else {
dataSize = nil return (try? workingDirectory.formattedSize()) ?? "Unknown"
} }
} else { #else
dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown" return (try? workingDirectory.formattedSize()) ?? "Unknown"
} #endif
#else }
dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown"
#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 RootHelperClient.shared.cleanWorkingDirectory() try await BlockingIO.run {
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,9 @@ 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 { 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)
@@ -20,4 +26,33 @@ public extension Profile {
try content.write(to: saveURL, atomically: true, encoding: .utf8) 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 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
+9 -7
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)
var error: NSError? try await BlockingIO.run {
LibboxCheckConfig(remoteContent, &error) var error: NSError?
if let error { LibboxCheckConfig(remoteContent, &error)
throw error if let 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())
}
}
}
}