Fix main-thread blocking I/O
This commit is contained in:
@@ -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)
|
||||
}
|
||||
} catch {
|
||||
alert = AlertState(error: error)
|
||||
guard let item else {
|
||||
return
|
||||
}
|
||||
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,12 +276,10 @@ public struct ProfileCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
if let data = try? profile.origin.toContent().encode() {
|
||||
FormNavigationLink {
|
||||
QRSSheet(profileName: profile.name, profileData: data)
|
||||
} label: {
|
||||
Label("Share as QRS Code", systemImage: "qrcode")
|
||||
}
|
||||
Button {
|
||||
prepareQRSShare(profile)
|
||||
} label: {
|
||||
Label("Share as QRS Code", systemImage: "qrcode")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
@@ -301,7 +322,7 @@ public struct ProfileCard: View {
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.showQRSShare = true
|
||||
prepareQRSShare(profile)
|
||||
} label: {
|
||||
Label("Share as QRS Code", systemImage: "qrcode")
|
||||
}
|
||||
@@ -333,42 +354,52 @@ public struct ProfileCard: View {
|
||||
|
||||
#if !os(tvOS)
|
||||
private func shareProfile(_ profile: ProfilePreview, type: ShareItemType) {
|
||||
do {
|
||||
let url: URL
|
||||
switch type {
|
||||
case .file:
|
||||
url = try profile.origin.toContent().generateShareFile()
|
||||
case .json:
|
||||
url = try profile.origin.read().generateShareFile(name: "\(profile.name).json")
|
||||
Task {
|
||||
do {
|
||||
let url: URL
|
||||
switch type {
|
||||
case .file:
|
||||
url = try await profile.origin.generateShareFileAsync()
|
||||
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) {
|
||||
do {
|
||||
switch type {
|
||||
case .file:
|
||||
let doc = try ProfileExportDocument(content: profile.origin.toContent())
|
||||
viewModel.exportDocument = ProfileAnyExportDocument(profile: doc)
|
||||
case .json:
|
||||
let doc = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name)
|
||||
viewModel.exportDocument = ProfileAnyExportDocument(json: doc)
|
||||
Task {
|
||||
do {
|
||||
let document: ProfileAnyExportDocument
|
||||
switch type {
|
||||
case .file:
|
||||
let data = try await profile.origin.encodedContentDataAsync()
|
||||
document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
|
||||
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
|
||||
|
||||
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,12 +632,10 @@ private struct ProfilePickerRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
if let data = try? profile.origin.toContent().encode() {
|
||||
FormNavigationLink {
|
||||
QRSSheet(profileName: profile.name, profileData: data)
|
||||
} label: {
|
||||
Label("Share as QRS Code", systemImage: "barcode")
|
||||
}
|
||||
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() {
|
||||
QRSSheet(profileName: profile.name, profileData: data)
|
||||
.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() {
|
||||
QRSSheet(profileName: profile.name, profileData: data)
|
||||
.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,18 +1000,26 @@ private struct ProfilePickerRow: View {
|
||||
}
|
||||
|
||||
private func exportProfile(type: ExportItemType) {
|
||||
do {
|
||||
switch type {
|
||||
case .file:
|
||||
let doc = try ProfileExportDocument(content: profile.origin.toContent())
|
||||
exportDocument = ProfileAnyExportDocument(profile: doc)
|
||||
case .json:
|
||||
let doc = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name)
|
||||
exportDocument = ProfileAnyExportDocument(json: doc)
|
||||
Task {
|
||||
do {
|
||||
let document: ProfileAnyExportDocument
|
||||
switch type {
|
||||
case .file:
|
||||
let data = try await profile.origin.encodedContentDataAsync()
|
||||
document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
|
||||
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
|
||||
@@ -996,38 +1050,52 @@ private struct ProfilePickerRow: View {
|
||||
|
||||
#if os(macOS)
|
||||
private func shareProfile(type: ShareItemType) {
|
||||
do {
|
||||
let url: URL
|
||||
switch type {
|
||||
case .file:
|
||||
url = try profile.origin.toContent().generateShareFile()
|
||||
case .json:
|
||||
url = try profile.origin.read().generateShareFile(name: "\(profile.name).json")
|
||||
Task {
|
||||
do {
|
||||
let url: URL
|
||||
switch type {
|
||||
case .file:
|
||||
url = try await profile.origin.generateShareFileAsync()
|
||||
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) {
|
||||
do {
|
||||
switch type {
|
||||
case .file:
|
||||
let doc = try ProfileExportDocument(content: profile.origin.toContent())
|
||||
exportDocument = ProfileAnyExportDocument(profile: doc)
|
||||
case .json:
|
||||
let doc = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name)
|
||||
exportDocument = ProfileAnyExportDocument(json: doc)
|
||||
Task {
|
||||
do {
|
||||
let document: ProfileAnyExportDocument
|
||||
switch type {
|
||||
case .file:
|
||||
let data = try await profile.origin.encodedContentDataAsync()
|
||||
document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
|
||||
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 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() {
|
||||
QRSSheet(profileName: profile.name, profileData: data)
|
||||
.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,18 +1391,26 @@ private struct ProfilePickerRow: View {
|
||||
}
|
||||
|
||||
private func exportProfile(type: ExportItemType) {
|
||||
do {
|
||||
switch type {
|
||||
case .file:
|
||||
let doc = try ProfileExportDocument(content: profile.origin.toContent())
|
||||
exportDocument = ProfileAnyExportDocument(profile: doc)
|
||||
case .json:
|
||||
let doc = try ProfileJSONExportDocument(jsonContent: profile.origin.read(), name: profile.name)
|
||||
exportDocument = ProfileAnyExportDocument(json: doc)
|
||||
Task {
|
||||
do {
|
||||
let document: ProfileAnyExportDocument
|
||||
switch type {
|
||||
case .file:
|
||||
let data = try await profile.origin.encodedContentDataAsync()
|
||||
document = ProfileAnyExportDocument(data: data, filename: "\(profile.name).bpf", contentType: .data)
|
||||
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 {
|
||||
let content = profileContent
|
||||
if content.isEmpty { return }
|
||||
var error: NSError?
|
||||
LibboxCheckConfig(content, &error)
|
||||
if let error {
|
||||
configurationError = error.localizedDescription
|
||||
} else {
|
||||
configurationError = nil
|
||||
let errorDescription: String? = await BlockingIO.run {
|
||||
var error: NSError?
|
||||
LibboxCheckConfig(content, &error)
|
||||
return error?.localizedDescription
|
||||
}
|
||||
configurationError = errorDescription
|
||||
}
|
||||
|
||||
public func formatConfiguration() async {
|
||||
let content = profileContent
|
||||
if content.isEmpty { return }
|
||||
var error: NSError?
|
||||
let result = LibboxFormatConfig(content, &error)
|
||||
if let error {
|
||||
do {
|
||||
let formatted: String? = try await BlockingIO.run {
|
||||
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
|
||||
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 {
|
||||
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,20 +193,26 @@ public struct NewProfileMenuView: View {
|
||||
|
||||
#if !os(tvOS)
|
||||
private func handleFileImport(_ result: Result<[URL], Error>) {
|
||||
do {
|
||||
let urls = try result.get()
|
||||
guard let url = urls.first else { return }
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let urls = try result.get()
|
||||
guard let url = urls.first else { return }
|
||||
|
||||
if url.pathExtension.lowercased() == "json" {
|
||||
let fileName = url.deletingPathExtension().lastPathComponent
|
||||
localImportRequest = NewProfileView.LocalImportRequest(name: fileName, fileURL: url)
|
||||
} else {
|
||||
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))
|
||||
if url.pathExtension.lowercased() == "json" {
|
||||
let fileName = url.deletingPathExtension().lastPathComponent
|
||||
localImportRequest = NewProfileView.LocalImportRequest(name: fileName, fileURL: url)
|
||||
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 Data(contentsOf: url)
|
||||
}
|
||||
}
|
||||
let content = try LibboxProfileContent.from(data)
|
||||
|
||||
alert = AlertState(
|
||||
title: String(localized: "Import Profile"),
|
||||
message: String(localized: "Are you sure to import profile \(content.name)?"),
|
||||
@@ -223,9 +229,9 @@ public struct NewProfileMenuView: View {
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
} catch {
|
||||
alert = AlertState(error: error)
|
||||
}
|
||||
} catch {
|
||||
alert = AlertState(error: error)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -107,43 +107,52 @@ 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")
|
||||
if fileImport {
|
||||
guard let fileURL else {
|
||||
throw NSError(domain: "NewProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing file")])
|
||||
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")])
|
||||
}
|
||||
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
|
||||
} else if profileType == .icloud {
|
||||
if !FileManager.default.fileExists(atPath: FilePath.iCloudDirectory.path) {
|
||||
try FileManager.default.createDirectory(at: FilePath.iCloudDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
let saveURL = FilePath.iCloudDirectory.appendingPathComponent(remotePath, isDirectory: false)
|
||||
do {
|
||||
_ = try String(contentsOf: saveURL)
|
||||
} catch {
|
||||
try "{}".write(to: saveURL, atomically: true, encoding: .utf8)
|
||||
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 = 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)
|
||||
var error: NSError?
|
||||
LibboxCheckConfig(remoteContent, &error)
|
||||
if let error {
|
||||
throw error
|
||||
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 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
|
||||
remoteURL = remotePath
|
||||
lastUpdated = .now
|
||||
|
||||
@@ -150,22 +150,24 @@ public struct CoreView: View {
|
||||
#if os(macOS)
|
||||
let helperUnavailable = Variant.useSystemExtension && HelperServiceManager.rootHelperStatus != .enabled
|
||||
#endif
|
||||
let dataSize: String?
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension {
|
||||
if helperUnavailable {
|
||||
dataSize = nil
|
||||
} else if let size = try? RootHelperClient.shared.getWorkingDirectorySize() {
|
||||
dataSize = LibboxFormatBytes(size)
|
||||
let workingDirectory = FilePath.workingDirectory
|
||||
let dataSize: String? = await BlockingIO.run {
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension {
|
||||
if helperUnavailable {
|
||||
return nil
|
||||
}
|
||||
guard let size = try? RootHelperClient.shared.getWorkingDirectorySize() else {
|
||||
return nil
|
||||
}
|
||||
return LibboxFormatBytes(size)
|
||||
} else {
|
||||
dataSize = nil
|
||||
return (try? workingDirectory.formattedSize()) ?? "Unknown"
|
||||
}
|
||||
} else {
|
||||
dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown"
|
||||
}
|
||||
#else
|
||||
dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown"
|
||||
#endif
|
||||
#else
|
||||
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 RootHelperClient.shared.cleanWorkingDirectory()
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,21 +7,23 @@ public extension Profile {
|
||||
if type != .remote {
|
||||
return
|
||||
}
|
||||
let remoteContent = try HTTPClient().getString(remoteURL)
|
||||
var error: NSError?
|
||||
LibboxCheckConfig(remoteContent, &error)
|
||||
if let error {
|
||||
throw error
|
||||
let remoteContent = try await HTTPClient.getStringAsync(remoteURL)
|
||||
try await BlockingIO.run {
|
||||
var error: NSError?
|
||||
LibboxCheckConfig(remoteContent, &error)
|
||||
if let error {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
lastUpdated = Date()
|
||||
try await ProfileManager.update(self)
|
||||
do {
|
||||
let oldContent = try read()
|
||||
let oldContent = try await readAsync()
|
||||
if oldContent == remoteContent {
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
try write(remoteContent)
|
||||
try await writeAsync(remoteContent)
|
||||
try await onProfileUpdated()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
public class RootHelperClient {
|
||||
public class RootHelperClient: @unchecked Sendable {
|
||||
public static let shared = RootHelperClient()
|
||||
|
||||
private var connection: NSXPCConnection?
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user