Add profile sharing

This commit is contained in:
世界
2023-07-30 14:13:57 +08:00
parent 27ffee06e2
commit 2ca6c515b9
25 changed files with 675 additions and 123 deletions
@@ -0,0 +1,97 @@
import Foundation
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
public struct ShareButton<Label>: View where Label: View {
private let items: () throws -> [Any]
private let label: Label
@Binding private var alert: Alert?
public init(_ alert: Binding<Alert?>, @ViewBuilder label: () -> Label, items: @escaping () throws -> [Any]) {
_alert = alert
self.items = items
self.label = label()
}
#if canImport(AppKit)
@State private var sharePresented = false
#endif
public var body: some View {
Button {
#if os(iOS)
do {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
try windowScene.keyWindow?.rootViewController?.present(UIActivityViewController(activityItems: items(), applicationActivities: nil), animated: true, completion: nil)
}
} catch {
alert = Alert(error)
}
#elseif canImport(AppKit)
sharePresented = true
#endif
} label: {
label
}
#if canImport(AppKit)
.background(SharingServicePicker($sharePresented, $alert, items))
#endif
}
private func shareItems() {}
}
#if canImport(AppKit)
private struct SharingServicePicker: NSViewRepresentable {
@Binding private var isPresented: Bool
@Binding private var alert: Alert?
private let items: () throws -> [Any]
init(_ isPresented: Binding<Bool>, _ alert: Binding<Alert?>, _ items: @escaping () throws -> [Any]) {
_isPresented = isPresented
_alert = alert
self.items = items
}
func makeNSView(context _: Context) -> NSView {
let view = NSView()
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
if isPresented {
do {
let picker = try NSSharingServicePicker(items: items())
picker.delegate = context.coordinator
DispatchQueue.main.async {
picker.show(relativeTo: .zero, of: nsView, preferredEdge: .minY)
}
} catch {
alert = Alert(error)
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, NSSharingServicePickerDelegate {
private let parent: SharingServicePicker
init(_ parent: SharingServicePicker) {
self.parent = parent
}
func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, didChoose _: NSSharingService?) {
sharingServicePicker.delegate = nil
parent.isPresented = false
}
}
}
#endif
@@ -79,7 +79,7 @@ public struct ActiveDashboardView: View {
}
}
.alertBinding($alert)
.onChange(of: profile.status, perform: { newValue in
.onChangeCompat(of: profile.status) { newValue in
if newValue == .disconnecting || newValue == .connected {
Task.detached {
if let serviceError = try? String(contentsOf: ExtensionProvider.errorFile) {
@@ -90,22 +90,22 @@ public struct ActiveDashboardView: View {
}
}
}
})
}
#if os(iOS) || os(tvOS)
.onChange(of: scenePhase, perform: { newValue in
.onChangeCompat(of: scenePhase) { newValue in
if newValue == .active {
Task.detached {
await doReload()
}
}
})
.onChange(of: selection.wrappedValue, perform: { newValue in
}
.onChangeCompat(of: selection.wrappedValue) { newValue in
if newValue == .dashboard {
Task.detached {
await doReload()
}
}
})
}
#elseif os(macOS)
.onAppear {
if observer == nil {
@@ -30,7 +30,7 @@ public struct DashboardView: View {
#endif
}
#if os(macOS)
.onChange(of: controlActiveState, perform: { newValue in
.onChangeCompat(of: controlActiveState) { newValue in
if newValue != .inactive {
if Variant.useSystemExtension {
if !isLoading {
@@ -38,7 +38,7 @@ public struct DashboardView: View {
}
}
}
})
}
#endif
.navigationTitle("Dashboard")
}
@@ -68,4 +68,17 @@ public extension EnvironmentValues {
self[importRemoteProfileKey.self] = newValue
}
}
private struct importProfileKey: EnvironmentKey {
static var defaultValue: Binding<LibboxProfileContent?> = .constant(nil)
}
var importProfile: Binding<LibboxProfileContent?> {
get {
self[importProfileKey.self]
}
set {
self[importProfileKey.self] = newValue
}
}
}
@@ -53,7 +53,7 @@
#elseif os(macOS)
.padding()
#endif
.onChange(of: profileContent) { _ in
.onChangeCompat(of: profileContent) {
isChanged = true
}
}
@@ -6,13 +6,16 @@ public struct EditProfileView: View {
@Environment(\.openWindow) private var openWindow
#endif
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var profile: Profile
@State private var isLoading = false
@State private var isChanged = false
@State private var alert: Alert?
public init() {}
private let updateCallback: (() -> Void)?
public init(_ updateCallback: (() -> Void)? = nil) {
self.updateCallback = updateCallback
}
public var body: some View {
FormView {
@@ -63,6 +66,11 @@ public struct EditProfileView: View {
} label: {
Text("View Content").foregroundColor(.accentColor)
}
ShareButton($alert) {
Text("Share")
} items: {
try [profile.toContent().generateShareFile()]
}
#endif
Button("Update") {
isLoading = true
@@ -71,32 +79,24 @@ public struct EditProfileView: View {
}
}
.disabled(isLoading)
#if os(iOS)
if #available(iOS 16.0, *) {
ShareLink(item: profile.shareLink) {
Text("Share")
}
} else {
Button("Share") {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
windowScene.keyWindow?.rootViewController?.present(UIActivityViewController(activityItems: [profile.shareLink], applicationActivities: nil), animated: true, completion: nil)
}
}
Button("Delete", role: .destructive) {
Task.detached {
await deleteProfile()
}
#endif
}
}
}
#endif
}
.onChange(of: profile.name, perform: { _ in
.onChangeCompat(of: profile.name) {
isChanged = true
})
.onChange(of: profile.remoteURL, perform: { _ in
}
.onChangeCompat(of: profile.remoteURL) {
isChanged = true
})
.onChange(of: profile.autoUpdate, perform: { _ in
}
.onChangeCompat(of: profile.autoUpdate) {
isChanged = true
})
}
.disabled(isLoading)
#if os(macOS)
.toolbar {
@@ -164,6 +164,17 @@ public struct EditProfileView: View {
}
}
private func deleteProfile() async {
do {
try ProfileManager.delete(profile)
} catch {
alert = Alert(error)
return
}
await performCallback()
dismiss()
}
private func saveProfile() async {
do {
_ = try ProfileManager.update(profile)
@@ -173,8 +184,16 @@ public struct EditProfileView: View {
}
isChanged = false
isLoading = false
await MainActor.run {
NotificationCenter.default.post(name: ProfileView.notificationName, object: nil)
await performCallback()
}
private func performCallback() async {
if let updateCallback {
updateCallback()
} else {
await MainActor.run {
NotificationCenter.default.post(name: ProfileView.notificationName, object: nil)
}
}
}
}
@@ -7,6 +7,7 @@ import SwiftUI
public struct ProfileView: View {
public static let notificationName = Notification.Name("\(FilePath.packageName).update-profile")
@Environment(\.importProfile) private var importProfile
@Environment(\.importRemoteProfile) private var importRemoteProfile
@State private var importRemoteProfileRequest: NewProfileView.ImportRequest?
@State private var importRemoteProfilePresented = false
@@ -86,28 +87,7 @@ public struct ProfileView: View {
if editMode.isEditing == true {
Text(profile.name)
} else {
NavigationLink {
EditProfileView().environmentObject(profile)
} label: {
Text(profile.name)
}
}
}
.contextMenu {
if profile.type == .remote {
Button {
isUpdating = true
Task.detached {
updateProfile(profile)
}
} label: {
Label("Update", systemImage: "arrow.clockwise")
}
}
Button(role: .destructive) {
deleteProfile(profile)
} label: {
Label("Delete", systemImage: "trash.fill")
ProfileItem(self, profile)
}
}
}
@@ -124,44 +104,7 @@ public struct ProfileView: View {
FormView {
List {
ForEach(profileList, id: \.mustID) { profile in
HStack {
VStack(alignment: .leading) {
Text(profile.name)
if profile.type == .remote {
Spacer(minLength: 4)
Text("Last Updated: \(profile.lastUpdatedString)").font(.caption)
}
}
HStack {
if profile.type == .remote {
Button(action: {
isUpdating = true
Task.detached {
updateProfile(profile)
}
}, label: {
Image(systemName: "arrow.clockwise")
})
ShareLink(item: profile.shareLink) {
Image(systemName: "square.and.arrow.up.fill")
}
}
Button(action: {
openWindow(id: EditProfileWindowView.windowID, value: profile.mustID)
}, label: {
Image(systemName: "pencil")
})
Button(action: {
deleteProfile(profile)
}, label: {
Image(systemName: "trash.fill")
})
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
ProfileItem(self, profile)
}
.onMove(perform: moveProfile)
.onDelete(perform: deleteProfile)
@@ -175,6 +118,10 @@ public struct ProfileView: View {
.navigationTitle("Profiles")
.alertBinding($alert, $isLoading)
.onAppear {
if let profile = importProfile.wrappedValue {
importProfile.wrappedValue = nil
createImportProfileDialog(profile)
}
if let remoteProfile = importRemoteProfile.wrappedValue {
importRemoteProfile.wrappedValue = nil
createImportRemoteProfileDialog(remoteProfile)
@@ -189,7 +136,13 @@ public struct ProfileView: View {
}
#endif
}
.onChange(of: importRemoteProfile.wrappedValue) { newValue in
.onChangeCompat(of: importProfile.wrappedValue) { newValue in
if let newValue {
importProfile.wrappedValue = nil
createImportProfileDialog(newValue)
}
}
.onChangeCompat(of: importRemoteProfile.wrappedValue) { newValue in
if let newValue {
importRemoteProfile.wrappedValue = nil
createImportRemoteProfileDialog(newValue)
@@ -221,11 +174,30 @@ public struct ProfileView: View {
#endif
}
private func createImportProfileDialog(_ profile: LibboxProfileContent) {
alert = Alert(
title: Text("Import Profile"),
message: Text("Are you sure to import profile \(profile.name)?"),
primaryButton: .default(Text("Import")) {
do {
try profile.importProfile()
} catch {
alert = Alert(error)
return
}
Task.detached {
doReload()
}
},
secondaryButton: .cancel()
)
}
private func createImportRemoteProfileDialog(_ newValue: LibboxImportRemoteProfile) {
importRemoteProfileRequest = .init(name: newValue.name, url: newValue.url)
alert = Alert(
title: Text("Import Remote Profile"),
message: Text("Are you sure to import remote configuration \(newValue.name)? You will connect to \(newValue.host) to download the configuration."),
message: Text("Are you sure to import remote profile \(newValue.name)? You will connect to \(newValue.host) to download the configuration."),
primaryButton: .default(Text("Import")) {
#if os(iOS) || os(tvOS)
importRemoteProfilePresented = true
@@ -283,7 +255,7 @@ public struct ProfileView: View {
alert = Alert(error)
return
}
isLoading = true
doReload()
}
}
@@ -313,4 +285,103 @@ public struct ProfileView: View {
}
}
}
public struct ProfileItem: View {
private let parent: ProfileView
private let profile: Profile
public init(_ parent: ProfileView, _ profile: Profile) {
self.parent = parent
self.profile = profile
}
public var body: some View {
#if os(iOS) || os(macOS)
if #available(iOS 16.0, macOS 13.0,*) {
body0.draggable(profile)
} else {
body0
}
#else
body0
#endif
}
private var body0: some View {
viewBuilder {
#if !os(macOS)
NavigationLink {
EditProfileView {
Task.detached {
parent.doReload()
}
}.environmentObject(profile)
} label: {
Text(profile.name)
}
.contextMenu {
ShareButton(parent.$alert) {
Label("Share", systemImage: "square.and.arrow.up.fill")
} items: {
try [profile.toContent().generateShareFile()]
}
if profile.type == .remote {
Button {
parent.isUpdating = true
Task.detached {
parent.updateProfile(profile)
}
} label: {
Label("Update", systemImage: "arrow.clockwise")
}
}
Button(role: .destructive) {
parent.deleteProfile(profile)
} label: {
Label("Delete", systemImage: "trash.fill")
}
}
#else
HStack {
VStack(alignment: .leading) {
Text(profile.name)
if profile.type == .remote {
Spacer(minLength: 4)
Text("Last Updated: \(profile.lastUpdatedString)").font(.caption)
}
}
HStack {
if profile.type == .remote {
Button(action: {
parent.isUpdating = true
Task.detached {
parent.updateProfile(profile)
}
}, label: {
Image(systemName: "arrow.clockwise")
})
}
ShareButton(parent.$alert) {
Image(systemName: "square.and.arrow.up.fill")
} items: {
try [profile.toContent().generateShareFile()]
}
Button(action: {
parent.openWindow(id: EditProfileWindowView.windowID, value: profile.mustID)
}, label: {
Image(systemName: "pencil")
})
Button(action: {
parent.deleteProfile(profile)
}, label: {
Image(systemName: "trash.fill")
})
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
}
}
}
}
@@ -41,7 +41,7 @@ public struct SettingView: View {
#if os(macOS)
Section("MacOS") {
Toggle("Start At Login", isOn: $startAtLogin)
.onChange(of: startAtLogin) { newValue in
.onChangeCompat(of: startAtLogin) { newValue in
Task.detached {
updateLoginItems(newValue)
}
@@ -57,7 +57,7 @@ public struct SettingView: View {
}
if showMenuBarExtra.wrappedValue {
Toggle("Keep Menu Bar in Background", isOn: $keepMenuBarInBackground)
.onChange(of: keepMenuBarInBackground) { newValue in
.onChangeCompat(of: keepMenuBarInBackground) { newValue in
Task.detached {
SharedPreferences.menuBarExtraInBackground = newValue
}