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