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