Init commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
public extension Binding {
|
||||
func unwrapped<T>(_ defaultValue: T) -> Binding<T> where Value == T? {
|
||||
Binding<T>(get: {
|
||||
wrappedValue ?? defaultValue
|
||||
}, set: { newValue in
|
||||
wrappedValue = newValue
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public func FormView(@ViewBuilder content: () -> some View) -> some View {
|
||||
Form {
|
||||
content()
|
||||
}
|
||||
#if os(macOS)
|
||||
.formStyle(.grouped)
|
||||
#endif
|
||||
}
|
||||
|
||||
public func FormTextItem(_ name: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(name)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.font(Font.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
public func FormItem(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
||||
#if os(iOS)
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
Spacer()
|
||||
content()
|
||||
}
|
||||
#else
|
||||
content()
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public func viewBuilder(@ViewBuilder _ builder: () -> some View) -> some View {
|
||||
builder()
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct ActiveDashboardView: View {
|
||||
public static let NotificationUpdateSelectedProfile = Notification.Name("update-selected-profile")
|
||||
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@Environment(\.selection) private var selection
|
||||
|
||||
@EnvironmentObject private var profile: ExtensionProfile
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var profileList: [Profile] = []
|
||||
@State private var selectedProfileID: Int64!
|
||||
@State private var reasserting = false
|
||||
@State private var observer: Any?
|
||||
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if profileList.isEmpty {
|
||||
Text("Empty profiles")
|
||||
} else {
|
||||
#if os(iOS)
|
||||
StartStopButton()
|
||||
#endif
|
||||
if profile.status.isConnected {
|
||||
Section("Status") {
|
||||
ExtensionStatusView()
|
||||
}
|
||||
}
|
||||
Section("Profile") {
|
||||
#if os(iOS)
|
||||
Picker(selection: $selectedProfileID) {
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
Text(profile.name).tag(profile.id)
|
||||
}
|
||||
} label: {}
|
||||
.pickerStyle(.inline)
|
||||
|
||||
#elseif os(macOS)
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
Picker(profile.name, selection: $selectedProfileID) {
|
||||
Text("").tag(profile.id)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
#endif
|
||||
}
|
||||
.onChange(of: selectedProfileID) { _ in
|
||||
reasserting = true
|
||||
Task.detached {
|
||||
await switchProfile(selectedProfileID!)
|
||||
}
|
||||
}
|
||||
.disabled(!profile.status.isSwitchable || reasserting)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.onChange(of: scenePhase, perform: { newValue in
|
||||
if newValue == .active {
|
||||
Task.detached {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
})
|
||||
.onChange(of: selection.wrappedValue, perform: { newValue in
|
||||
if newValue == .dashboard {
|
||||
Task.detached {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
})
|
||||
#elseif os(macOS)
|
||||
.onAppear {
|
||||
if observer == nil {
|
||||
observer = NotificationCenter.default.addObserver(forName: ActiveDashboardView.NotificationUpdateSelectedProfile, object: nil, queue: nil, using: { _ in
|
||||
Task.detached {
|
||||
await doReload()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if let observer {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func doReload() {
|
||||
defer {
|
||||
isLoading = false
|
||||
}
|
||||
do {
|
||||
profileList = try ProfileManager.list()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
if profileList.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
selectedProfileID = SharedPreferences.selectedProfileID
|
||||
if profileList.filter({ profile in
|
||||
profile.id == selectedProfileID
|
||||
})
|
||||
.isEmpty {
|
||||
selectedProfileID = profileList[0].id!
|
||||
SharedPreferences.selectedProfileID = selectedProfileID
|
||||
}
|
||||
}
|
||||
|
||||
private func switchProfile(_ newProfileID: Int64) {
|
||||
SharedPreferences.selectedProfileID = newProfileID
|
||||
NotificationCenter.default.post(name: ActiveDashboardView.NotificationUpdateSelectedProfile, object: nil)
|
||||
if profile.status.isConnected {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient(FilePath.sharedDirectory.relativePath)?.serviceReload()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
reasserting = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct DashboardView: View {
|
||||
@Environment(\.extensionProfile) private var extensionProfile
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
FormView {
|
||||
if let profile = extensionProfile.wrappedValue {
|
||||
ActiveDashboardView().environmentObject(profile)
|
||||
} else {
|
||||
InstallProfileButton()
|
||||
}
|
||||
}.navigationTitle("Dashboard")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct ExtensionStatusView: View {
|
||||
@State private var commandClient: LibboxCommandClient?
|
||||
@State private var message: LibboxStatusMessage?
|
||||
@State private var connectTask: Task<Void, Error>?
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
private let infoFont = Font.system(.caption, design: .monospaced)
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if let message {
|
||||
FormTextItem("Memory", LibboxFormatBytes(message.memory))
|
||||
FormTextItem("Goroutines", "\(message.goroutines)")
|
||||
FormTextItem("Connections", "\(message.connections)").contextMenu {
|
||||
Button("Close", role: .destructive) {
|
||||
Task.detached {
|
||||
closeConnections()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FormTextItem("Memory", "Loading...")
|
||||
FormTextItem("Goroutines", "Loading...")
|
||||
FormTextItem("Connections", "Loading...")
|
||||
}
|
||||
}
|
||||
|
||||
.onAppear(perform: doReload)
|
||||
.onDisappear {
|
||||
connectTask?.cancel()
|
||||
if let commandClient {
|
||||
try? commandClient.disconnect()
|
||||
}
|
||||
commandClient = nil
|
||||
}
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func doReload() {
|
||||
connectTask?.cancel()
|
||||
connectTask = Task.detached {
|
||||
await connect()
|
||||
}
|
||||
}
|
||||
|
||||
private func connect() async {
|
||||
let clientOptions = LibboxCommandClientOptions()
|
||||
clientOptions.command = LibboxCommandStatus
|
||||
clientOptions.statusInterval = Int64(2 * NSEC_PER_SEC)
|
||||
let client = LibboxNewCommandClient(FilePath.sharedDirectory.relativePath, statusHandler(self), clientOptions)!
|
||||
|
||||
do {
|
||||
for i in 0 ..< 10 {
|
||||
try await Task.sleep(nanoseconds: UInt64(Double(100 + (i * 50)) * Double(NSEC_PER_MSEC)))
|
||||
try Task.checkCancellation()
|
||||
let isConnected: Bool
|
||||
do {
|
||||
try client.connect()
|
||||
isConnected = true
|
||||
} catch {
|
||||
isConnected = false
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
if isConnected {
|
||||
commandClient = client
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
NSLog("failed to connect status: \(error.localizedDescription)")
|
||||
try? client.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func closeConnections() {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient(FilePath.sharedDirectory.relativePath)?.closeConnections()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
|
||||
private class statusHandler: NSObject, LibboxCommandClientHandlerProtocol {
|
||||
private let statusView: ExtensionStatusView
|
||||
|
||||
init(_ statusView: ExtensionStatusView) {
|
||||
self.statusView = statusView
|
||||
}
|
||||
|
||||
func connected() {}
|
||||
|
||||
func disconnected(_: String?) {}
|
||||
|
||||
func writeLog(_: String?) {}
|
||||
|
||||
func writeStatus(_ message: LibboxStatusMessage?) {
|
||||
statusView.message = message
|
||||
}
|
||||
|
||||
func writeGroups(_: LibboxOutboundGroupIteratorProtocol?) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct InstallProfileButton: View {
|
||||
@Environment(\.extensionProfile) private var extensionProfile
|
||||
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
Button("Install NetworkExtension") {
|
||||
Task {
|
||||
await installProfile()
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func installProfile() async {
|
||||
do {
|
||||
try await ExtensionProfile.install()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Library
|
||||
import NetworkExtension
|
||||
import SwiftUI
|
||||
|
||||
public struct StartStopButton: View {
|
||||
@Environment(\.extensionProfile) private var extensionProfile
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if let profile = extensionProfile.wrappedValue {
|
||||
Button0(profile)
|
||||
} else {
|
||||
#if os(iOS)
|
||||
Toggle(isOn: .constant(false)) {
|
||||
Text("Enabled")
|
||||
}
|
||||
#elseif os(macOS)
|
||||
Button(action: {}, label: {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
})
|
||||
.disabled(true)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Button0: View {
|
||||
@Environment(\.logClient) private var logClient
|
||||
@ObservedObject private var profile: ExtensionProfile
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
init(_ profile: ExtensionProfile) {
|
||||
self.profile = profile
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
viewBuilder {
|
||||
#if os(iOS)
|
||||
Toggle(isOn: Binding(get: {
|
||||
profile.status.isConnected
|
||||
}, set: { newValue, _ in
|
||||
Task.detached {
|
||||
await switchProfile(newValue)
|
||||
}
|
||||
})) {
|
||||
Text("Enabled")
|
||||
}
|
||||
#elseif os(macOS)
|
||||
Button(action: {
|
||||
Task.detached {
|
||||
await switchProfile(!profile.status.isConnected)
|
||||
}
|
||||
}, label: {
|
||||
if !profile.status.isConnected {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
} else {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
})
|
||||
#endif
|
||||
}
|
||||
.disabled(!profile.status.isEnabled)
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func switchProfile(_ isEnabled: Bool) async {
|
||||
do {
|
||||
if isEnabled {
|
||||
try await profile.start()
|
||||
logClient.wrappedValue?.reconnect()
|
||||
} else {
|
||||
profile.stop()
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public extension EnvironmentValues {
|
||||
private struct showMenuBarExtraKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<Bool> = .constant(true)
|
||||
}
|
||||
|
||||
var showMenuBarExtra: Binding<Bool> {
|
||||
get {
|
||||
self[showMenuBarExtraKey.self]
|
||||
}
|
||||
set {
|
||||
self[showMenuBarExtraKey.self] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private struct selectionKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<NavigationPage> = .constant(.dashboard)
|
||||
}
|
||||
|
||||
var selection: Binding<NavigationPage> {
|
||||
get {
|
||||
self[selectionKey.self]
|
||||
}
|
||||
set {
|
||||
self[selectionKey.self] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private struct extensionProfileKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<ExtensionProfile?> = .constant(nil)
|
||||
}
|
||||
|
||||
var extensionProfile: Binding<ExtensionProfile?> {
|
||||
get {
|
||||
self[extensionProfileKey.self]
|
||||
}
|
||||
set {
|
||||
self[extensionProfileKey.self] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private struct logClientKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<LogClient?> = .constant(nil)
|
||||
}
|
||||
|
||||
var logClient: Binding<LogClient?> {
|
||||
get {
|
||||
self[logClientKey.self]
|
||||
}
|
||||
set {
|
||||
self[logClientKey.self] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct GroupItemView: View {
|
||||
private let _group: Binding<OutboundGroup>
|
||||
private var group: OutboundGroup {
|
||||
_group.wrappedValue
|
||||
}
|
||||
|
||||
private let item: OutboundGroupItem
|
||||
public init(_ group: Binding<OutboundGroup>, _ item: OutboundGroupItem) {
|
||||
_group = group
|
||||
self.item = item
|
||||
}
|
||||
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public var body: some View {
|
||||
HStack {
|
||||
if group.selected == item.tag {
|
||||
Rectangle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 6)
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.frame(width: 6)
|
||||
}
|
||||
VStack {
|
||||
HStack {
|
||||
Text(item.tag)
|
||||
.truncationMode(.tail)
|
||||
.lineLimit(1)
|
||||
.font(.system(size: 14))
|
||||
Spacer(minLength: 6)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
HStack(alignment: .center) {
|
||||
Text(item.type)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.system(size: 12))
|
||||
Spacer(minLength: 6)
|
||||
if item.urlTestDelay > 0 {
|
||||
Text(item.delayString)
|
||||
.foregroundColor(item.delayColor)
|
||||
.font(.system(size: 11))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
.padding([.top, .bottom, .trailing], 12)
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.onTapGesture {
|
||||
if group.selectable, group.selected != item.tag {
|
||||
Task.detached {
|
||||
selectOutbound()
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectOutbound() {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient(FilePath.sharedDirectory.relativePath)!.selectOutbound(group.tag, outboundTag: item.tag)
|
||||
var newGroup = group
|
||||
newGroup.selected = item.tag
|
||||
_group.wrappedValue = newGroup
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
#if os(iOS)
|
||||
return Color(uiColor: .secondarySystemGroupedBackground)
|
||||
#elseif os(macOS)
|
||||
return Color(nsColor: .textBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct GroupListView: View {
|
||||
@State private var isLoading = true
|
||||
@State private var connectTask: Task<Void, Error>?
|
||||
@State private var commandClient: LibboxCommandClient?
|
||||
@State private var groups: [OutboundGroup] = []
|
||||
@State private var groupExpand: [String: Bool] = [:]
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
VStack {
|
||||
if isLoading {
|
||||
Text("Loading...")
|
||||
} else if !groups.isEmpty {
|
||||
ScrollView {
|
||||
VStack {
|
||||
ForEach(groups, id: \.hashValue) { it in
|
||||
GroupView(it, Binding(get: {
|
||||
groupExpand[it.tag] ?? it.selectable
|
||||
}, set: { newValue in
|
||||
groupExpand[it.tag] = newValue
|
||||
}))
|
||||
Spacer()
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
} else {
|
||||
Text("Empty groups")
|
||||
}
|
||||
}
|
||||
.onAppear(perform: doReload)
|
||||
.onDisappear {
|
||||
connectTask?.cancel()
|
||||
if let commandClient {
|
||||
try? commandClient.disconnect()
|
||||
}
|
||||
commandClient = nil
|
||||
}
|
||||
.navigationTitle("Groups")
|
||||
}
|
||||
|
||||
private func doReload() {
|
||||
connectTask?.cancel()
|
||||
connectTask = Task.detached {
|
||||
await connect()
|
||||
}
|
||||
}
|
||||
|
||||
private func connect() async {
|
||||
let clientOptions = LibboxCommandClientOptions()
|
||||
clientOptions.command = LibboxCommandGroup
|
||||
clientOptions.statusInterval = Int64(2 * NSEC_PER_SEC)
|
||||
let client = LibboxNewCommandClient(FilePath.sharedDirectory.relativePath, groupsHandler(self), clientOptions)!
|
||||
|
||||
do {
|
||||
for i in 0 ..< 10 {
|
||||
try await Task.sleep(nanoseconds: UInt64(Double(100 + (i * 50)) * Double(NSEC_PER_MSEC)))
|
||||
try Task.checkCancellation()
|
||||
let isConnected: Bool
|
||||
do {
|
||||
try client.connect()
|
||||
isConnected = true
|
||||
} catch {
|
||||
isConnected = false
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
if isConnected {
|
||||
commandClient = client
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
NSLog("failed to connect status: \(error.localizedDescription)")
|
||||
try? client.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func setGroups(_ groupIterator: LibboxOutboundGroupIteratorProtocol) {
|
||||
var goGroups = [LibboxOutboundGroup]()
|
||||
while groupIterator.hasNext() {
|
||||
goGroups.append(groupIterator.next()!)
|
||||
}
|
||||
var groups = [OutboundGroup]()
|
||||
for goGroup in goGroups {
|
||||
var items = [OutboundGroupItem]()
|
||||
let itemIterator = goGroup.getItems()!
|
||||
while itemIterator.hasNext() {
|
||||
let goItem = itemIterator.next()!
|
||||
items.append(OutboundGroupItem(tag: goItem.tag, type: goItem.type, urlTestTime: Date(timeIntervalSince1970: Double(goItem.urlTestTime)), urlTestDelay: UInt16(goItem.urlTestDelay)))
|
||||
}
|
||||
groups.append(OutboundGroup(tag: goGroup.tag, type: goGroup.type, selected: goGroup.selected, selectable: goGroup.selectable, items: items))
|
||||
}
|
||||
self.groups = groups
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private class groupsHandler: NSObject, LibboxCommandClientHandlerProtocol {
|
||||
private let groupListView: GroupListView
|
||||
|
||||
init(_ statusView: GroupListView) {
|
||||
groupListView = statusView
|
||||
}
|
||||
|
||||
func connected() {}
|
||||
|
||||
func disconnected(_: String?) {}
|
||||
|
||||
func writeLog(_: String?) {}
|
||||
|
||||
func writeStatus(_: LibboxStatusMessage?) {}
|
||||
|
||||
func writeGroups(_ groupIterator: LibboxOutboundGroupIteratorProtocol?) {
|
||||
groupListView.setGroups(groupIterator!)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct GroupView: View {
|
||||
private var expland: Binding<Bool>
|
||||
@State private var group: OutboundGroup
|
||||
@State private var geometryWidth: CGFloat = 300
|
||||
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public init(_ group: OutboundGroup, _ expland: Binding<Bool>) {
|
||||
self.group = group
|
||||
self.expland = expland
|
||||
}
|
||||
|
||||
private var title: some View {
|
||||
HStack {
|
||||
Text(group.tag)
|
||||
.font(.system(size: 17))
|
||||
Text(group.displayType)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(group.items.count)")
|
||||
.font(.system(size: 11))
|
||||
.padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4))
|
||||
.background(Color.gray.opacity(0.5))
|
||||
.cornerRadius(4)
|
||||
Button {
|
||||
expland.wrappedValue = !expland.wrappedValue
|
||||
} label: {
|
||||
if expland.wrappedValue {
|
||||
Image(systemName: "arrow.down.to.line")
|
||||
} else {
|
||||
Image(systemName: "arrow.up.to.line")
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
Button {
|
||||
Task.detached {
|
||||
doURLTest()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "bolt.fill")
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
Spacer(minLength: 6)
|
||||
}
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Section {
|
||||
if expland.wrappedValue {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()),
|
||||
count: explandColumnCount()))
|
||||
{
|
||||
ForEach(group.items, id: \.tag) { it in
|
||||
GroupItemView($group, it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack {
|
||||
ForEach(Array(itemGroups.enumerated()), id: \.offset) { items in
|
||||
HStack {
|
||||
ForEach(items.element, id: \.tag) { it in
|
||||
Rectangle()
|
||||
.fill(it.delayColor)
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
title
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.background {
|
||||
GeometryReader { geometry in
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.frame(height: 1)
|
||||
.onChange(of: geometry.size.width) { newValue in
|
||||
geometryWidth = newValue
|
||||
}
|
||||
.onAppear {
|
||||
geometryWidth = geometry.size.width
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private var itemGroups: [[OutboundGroupItem]] {
|
||||
let count = Int(Int(geometryWidth) / 20)
|
||||
if count == 0 {
|
||||
return [group.items]
|
||||
} else {
|
||||
return group.items.chunked(
|
||||
into: count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func explandColumnCount() -> Int {
|
||||
let count = Int(Int(geometryWidth) / 180)
|
||||
#if os(iOS)
|
||||
return count < 2 ? 2 : count
|
||||
#else
|
||||
return count < 1 ? 1 : count
|
||||
#endif
|
||||
}
|
||||
|
||||
private func doURLTest() {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient(FilePath.sharedDirectory.relativePath)!.urlTest(group.tag)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
func chunked(into size: Int) -> [[Element]] {
|
||||
stride(from: 0, to: count, by: size).map {
|
||||
Array(self[$0 ..< Swift.min($0 + size, count)])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public struct OutboundGroup: Codable {
|
||||
let tag: String
|
||||
let type: String
|
||||
var selected: String
|
||||
let selectable: Bool
|
||||
let items: [OutboundGroupItem]
|
||||
|
||||
var hashValue: Int {
|
||||
var value = tag.hashValue
|
||||
(value, _) = value.addingReportingOverflow(selected.hashValue)
|
||||
for item in items {
|
||||
(value, _) = value.addingReportingOverflow(item.urlTestTime.hashValue)
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
public extension OutboundGroup {
|
||||
var displayType: String {
|
||||
switch type {
|
||||
case "selector":
|
||||
return "Selector"
|
||||
case "urltest":
|
||||
return "URLTest"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public struct OutboundGroupItem: Codable {
|
||||
public let tag: String
|
||||
public let type: String
|
||||
|
||||
public let urlTestTime: Date
|
||||
public let urlTestDelay: UInt16
|
||||
}
|
||||
|
||||
public extension OutboundGroupItem {
|
||||
var delayString: String {
|
||||
"\(urlTestDelay)ms"
|
||||
}
|
||||
|
||||
var delayColor: Color {
|
||||
switch urlTestDelay {
|
||||
case 0:
|
||||
return .gray
|
||||
case ..<800:
|
||||
return .green
|
||||
case 800 ..< 1500:
|
||||
return .yellow
|
||||
default:
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public class LogClient: ObservableObject {
|
||||
private var maxLines: Int
|
||||
@Published public var isConnected: Bool
|
||||
@Published public var logList: [String]
|
||||
|
||||
private var commandClient: LibboxCommandClient!
|
||||
private var connectTask: Task<Void, Error>?
|
||||
|
||||
public init(_ maxLines: Int) {
|
||||
self.maxLines = maxLines
|
||||
isConnected = false
|
||||
logList = []
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let connectTask {
|
||||
connectTask.cancel()
|
||||
}
|
||||
if let commandClient {
|
||||
try? commandClient.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
public func reconnect() {
|
||||
if isConnected {
|
||||
return
|
||||
}
|
||||
if let connectTask {
|
||||
connectTask.cancel()
|
||||
}
|
||||
connectTask = Task.detached {
|
||||
await self.connect()
|
||||
}
|
||||
}
|
||||
|
||||
private func connect() async {
|
||||
let clientOptions = LibboxCommandClientOptions()
|
||||
clientOptions.command = LibboxCommandLog
|
||||
clientOptions.statusInterval = Int64(2 * NSEC_PER_SEC)
|
||||
let client = LibboxNewCommandClient(FilePath.sharedDirectory.relativePath, logHandler(self), clientOptions)!
|
||||
|
||||
do {
|
||||
for i in 0 ..< 10 {
|
||||
try await Task.sleep(nanoseconds: UInt64(Double(100 + (i * 50)) * Double(NSEC_PER_MSEC)))
|
||||
try Task.checkCancellation()
|
||||
let isConnected: Bool
|
||||
do {
|
||||
try client.connect()
|
||||
isConnected = true
|
||||
} catch {
|
||||
isConnected = false
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
if isConnected {
|
||||
commandClient = client
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try? client.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private class logHandler: NSObject, LibboxCommandClientHandlerProtocol {
|
||||
private let logClient: LogClient
|
||||
|
||||
init(_ logClient: LogClient) {
|
||||
self.logClient = logClient
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func connected() {
|
||||
logClient.logList.removeAll()
|
||||
logClient.isConnected = true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func disconnected(_ message: String?) {
|
||||
if let message {
|
||||
logClient.logList.append("(log client closed) \(message)")
|
||||
} else {
|
||||
logClient.logList.append("(log client closed)")
|
||||
}
|
||||
try? logClient.commandClient?.disconnect()
|
||||
logClient.commandClient = nil
|
||||
logClient.isConnected = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func writeLog(_ message: String?) {
|
||||
guard let message else {
|
||||
return
|
||||
}
|
||||
if logClient.logList.count > logClient.maxLines {
|
||||
logClient.logList.removeFirst()
|
||||
}
|
||||
logClient.logList.append(message)
|
||||
}
|
||||
|
||||
func writeStatus(_: LibboxStatusMessage?) {}
|
||||
func writeGroups(_: LibboxOutboundGroupIteratorProtocol?) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct LogView: View {
|
||||
@Environment(\.logClient) private var logClient
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if let logClient = logClient.wrappedValue {
|
||||
LogView0().environmentObject(logClient)
|
||||
} else {
|
||||
Text("Service not started")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Logs")
|
||||
}
|
||||
|
||||
private struct LogView0: View {
|
||||
@Environment(\.selection) private var selection
|
||||
@Environment(\.extensionProfile) private var extensionProfile
|
||||
@EnvironmentObject private var logClient: LogClient
|
||||
|
||||
private let logFont = Font.system(.caption2, design: .monospaced)
|
||||
|
||||
var body: some View {
|
||||
viewBuilder {
|
||||
if logClient.logList.isEmpty {
|
||||
VStack {
|
||||
if logClient.isConnected {
|
||||
Text("Empty logs")
|
||||
} else {
|
||||
Text("Service not started").onAppear(perform: connectLog)
|
||||
}
|
||||
}.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
} else {
|
||||
ScrollViewReader { reader in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(logClient.logList.enumerated()), id: \.offset) { it in
|
||||
Text(it.element)
|
||||
.font(logFont)
|
||||
Spacer(minLength: 5)
|
||||
}
|
||||
|
||||
.onChange(of: logClient.logList.count) { newCount in
|
||||
withAnimation {
|
||||
reader.scrollTo(newCount - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding()
|
||||
}
|
||||
.onAppear {
|
||||
reader.scrollTo(logClient.logList.count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func connectLog() {
|
||||
guard let profile = extensionProfile.wrappedValue else {
|
||||
return
|
||||
}
|
||||
if profile.status.isConnected, !logClient.isConnected {
|
||||
logClient.reconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public enum NavigationPage: Int, CaseIterable, Identifiable {
|
||||
public var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
case dashboard
|
||||
case groups
|
||||
case logs
|
||||
case profiles
|
||||
case settings
|
||||
}
|
||||
|
||||
public extension NavigationPage {
|
||||
var label: some View {
|
||||
Label(title, systemImage: iconImage)
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .dashboard:
|
||||
return NSLocalizedString("Dashboard", comment: "")
|
||||
case .groups:
|
||||
return NSLocalizedString("Groups", comment: "")
|
||||
case .logs:
|
||||
return NSLocalizedString("Logs", comment: "")
|
||||
case .profiles:
|
||||
return NSLocalizedString("Profiles", comment: "")
|
||||
case .settings:
|
||||
return NSLocalizedString("Settings", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
private var iconImage: String {
|
||||
switch self {
|
||||
case .dashboard:
|
||||
return "text.and.command.macwindow"
|
||||
case .groups:
|
||||
return "rectangle.3.group.fill"
|
||||
case .logs:
|
||||
return "doc.text.fill"
|
||||
case .profiles:
|
||||
return "list.bullet.rectangle.fill"
|
||||
case .settings:
|
||||
return "gear.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var contentView: some View {
|
||||
viewBuilder {
|
||||
switch self {
|
||||
case .dashboard:
|
||||
DashboardView()
|
||||
case .groups:
|
||||
GroupListView()
|
||||
case .logs:
|
||||
LogView()
|
||||
case .profiles:
|
||||
ProfileView()
|
||||
case .settings:
|
||||
SettingView()
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
#endif
|
||||
}
|
||||
|
||||
func visible(_ profile: ExtensionProfile?) -> Bool {
|
||||
switch self {
|
||||
case .groups:
|
||||
return profile?.status.isConnectedStrict == true
|
||||
case .profiles, .settings:
|
||||
#if os(iOS)
|
||||
return profile?.status.isConnected != true
|
||||
#else
|
||||
fallthrough
|
||||
#endif
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct EditProfileContentView: View {
|
||||
#if os(macOS)
|
||||
public static let windowID = "edit-profile-content"
|
||||
#endif
|
||||
|
||||
public struct Context: Codable, Hashable {
|
||||
public let profileID: Int64
|
||||
public let readOnly: Bool
|
||||
}
|
||||
|
||||
private let profileID: Int64?
|
||||
private let readOnly: Bool
|
||||
|
||||
public init(_ context: Context?) {
|
||||
profileID = context?.profileID
|
||||
readOnly = context?.readOnly == true
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var profile: Profile!
|
||||
@State private var profileContent: String = ""
|
||||
@State private var isChanged = false
|
||||
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
@State private var fatalError = false
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
loadContent()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
viewBuilder {
|
||||
if readOnly {
|
||||
TextEditor(text: .constant(profileContent))
|
||||
} else {
|
||||
TextEditor(text: $profileContent)
|
||||
}
|
||||
}
|
||||
.font(Font.system(.caption2, design: .monospaced))
|
||||
.disableAutocorrection(true)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.none)
|
||||
.background(Color(UIColor.secondarySystemGroupedBackground))
|
||||
#elseif os(macOS)
|
||||
.padding()
|
||||
#endif
|
||||
.onChange(of: profileContent) { _ in
|
||||
isChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"), action: {
|
||||
if fatalError {
|
||||
dismiss()
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
.navigationTitle(navigationTitle)
|
||||
#if os(macOS)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigation) {
|
||||
if !readOnly {
|
||||
Button(action: {
|
||||
Task.detached {
|
||||
saveContent()
|
||||
}
|
||||
}, label: {
|
||||
Image("save", label: Text("Save"))
|
||||
})
|
||||
.disabled(!isChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if !readOnly {
|
||||
Button("Save") {
|
||||
Task.detached {
|
||||
saveContent()
|
||||
}
|
||||
}.disabled(!isChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var navigationTitle: String {
|
||||
if readOnly {
|
||||
return "View Content"
|
||||
} else {
|
||||
return "Edit Content"
|
||||
}
|
||||
}
|
||||
|
||||
private func loadContent() {
|
||||
do {
|
||||
try loadContent0()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
fatalError = true
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
|
||||
private func loadContent0() throws {
|
||||
guard let profileID else {
|
||||
throw NSError(domain: "Context destroyed", code: 0)
|
||||
}
|
||||
guard let profile = try ProfileManager.get(profileID) else {
|
||||
throw NSError(domain: "Profile missing", code: 0)
|
||||
}
|
||||
profileContent = try profile.read()
|
||||
self.profile = profile
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func saveContent() {
|
||||
guard let profile else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try profile.write(profileContent)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
isChanged = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct EditProfileView: View {
|
||||
#if os(macOS)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
#endif
|
||||
|
||||
@EnvironmentObject private var profile: Profile
|
||||
|
||||
@State private var isLoading = false
|
||||
@State private var isChanged = false
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
FormView {
|
||||
FormItem("Name") {
|
||||
TextField("Name", text: $profile.name, prompt: Text("Required"))
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
|
||||
Picker(selection: $profile.type) {
|
||||
Text("Local").tag(ProfileType.local)
|
||||
Text("iCloud").tag(ProfileType.icloud)
|
||||
Text("Remote").tag(ProfileType.remote)
|
||||
} label: {
|
||||
Text("Type")
|
||||
}
|
||||
.disabled(true)
|
||||
if profile.type == .icloud {
|
||||
FormItem("Path") {
|
||||
TextField("Path", text: $profile.path, prompt: Text("Required"))
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else if profile.type == .remote {
|
||||
FormItem("URL") {
|
||||
TextField("URL", text: $profile.remoteURL.unwrapped(""), prompt: Text("Required"))
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
Toggle("Auto Update", isOn: $profile.autoUpdate)
|
||||
}
|
||||
if profile.type == .remote {
|
||||
Section("Status") {
|
||||
FormTextItem("Last Updated", profile.lastUpdatedString)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
Section("Action") {
|
||||
if profile.type != .remote {
|
||||
NavigationLink {
|
||||
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
|
||||
} label: {
|
||||
Text("Edit Content").foregroundColor(.accentColor)
|
||||
}
|
||||
} else {
|
||||
NavigationLink {
|
||||
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
|
||||
} label: {
|
||||
Text("View Content").foregroundColor(.accentColor)
|
||||
}
|
||||
Button("Update") {
|
||||
isLoading = true
|
||||
Task.detached {
|
||||
await updateProfile()
|
||||
}
|
||||
}
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onChange(of: profile.name, perform: { _ in
|
||||
isChanged = true
|
||||
})
|
||||
.onChange(of: profile.remoteURL, perform: { _ in
|
||||
isChanged = true
|
||||
})
|
||||
.onChange(of: profile.autoUpdate, perform: { _ in
|
||||
isChanged = true
|
||||
})
|
||||
.disabled(isLoading)
|
||||
#if os(macOS)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigation) {
|
||||
Button(action: {
|
||||
isLoading = true
|
||||
Task.detached {
|
||||
await saveProfile()
|
||||
}
|
||||
}, label: {
|
||||
Image("save", bundle: ApplicationLibrary.bundle, label: Text("Save"))
|
||||
})
|
||||
.disabled(isLoading || !isChanged)
|
||||
if profile.type != .remote {
|
||||
Button(action: {
|
||||
openWindow(id: EditProfileContentView.windowID, value: EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
|
||||
}, label: {
|
||||
Label("Edit Content", systemImage: "pencil")
|
||||
})
|
||||
.disabled(isLoading)
|
||||
} else {
|
||||
Button(action: {
|
||||
isLoading = true
|
||||
Task.detached {
|
||||
await updateProfile()
|
||||
}
|
||||
}, label: {
|
||||
Label("Update", systemImage: "arrow.clockwise")
|
||||
})
|
||||
.disabled(isLoading)
|
||||
Button(action: {
|
||||
openWindow(id: EditProfileContentView.windowID, value: EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
|
||||
}, label: {
|
||||
Label("View Content", systemImage: "doc.text.fill")
|
||||
})
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
isLoading = true
|
||||
Task.detached {
|
||||
await saveProfile()
|
||||
}
|
||||
}.disabled(!isChanged)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
.navigationTitle("Edit Profile")
|
||||
}
|
||||
|
||||
private func updateProfile() async {
|
||||
defer {
|
||||
isLoading = false
|
||||
}
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: UInt64(100 * Double(NSEC_PER_MSEC)))
|
||||
try profile.updateRemoteProfile()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
|
||||
private func saveProfile() async {
|
||||
do {
|
||||
_ = try ProfileManager.update(profile)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
isChanged = false
|
||||
isLoading = false
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: ProfileView.notificationName, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
#if os(macOS)
|
||||
public struct EditProfileWindowView: View {
|
||||
public static let windowID = "edit-profile"
|
||||
|
||||
private var profileID: Int64?
|
||||
|
||||
public init(_ profileID: Int64?) {
|
||||
self.profileID = profileID
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var profile: Profile!
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"), action: {
|
||||
dismiss()
|
||||
})
|
||||
)
|
||||
}
|
||||
} else {
|
||||
EditProfileView().environmentObject(profile!)
|
||||
}
|
||||
}
|
||||
.onExitCommand {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func doReload() async {
|
||||
guard let profileID else {
|
||||
errorMessage = "Context destroyed"
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
do {
|
||||
profile = try ProfileManager.get(profileID)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
if profile == nil {
|
||||
errorMessage = "Profile deleted"
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,223 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct NewProfileView: View {
|
||||
#if os(macOS)
|
||||
public static let windowID = "new-profile"
|
||||
#endif
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isSaving = false
|
||||
@State private var profileName = ""
|
||||
@State private var profileType = ProfileType.local
|
||||
@State private var fileImport = false
|
||||
@State private var fileURL: URL!
|
||||
@State private var remotePath = ""
|
||||
@State private var pickerPresented = false
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
private let callback: (() -> Void)?
|
||||
public init(_ callback: (() -> Void)? = nil) {
|
||||
self.callback = callback
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
FormView {
|
||||
FormItem("Name") {
|
||||
TextField("Name", text: $profileName, prompt: Text("Required"))
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
Picker(selection: $profileType) {
|
||||
Text("Local").tag(ProfileType.local)
|
||||
Text("iCloud").tag(ProfileType.icloud)
|
||||
Text("Remote").tag(ProfileType.remote)
|
||||
} label: {
|
||||
Text("Type")
|
||||
}
|
||||
if profileType == .local {
|
||||
Picker(selection: $fileImport) {
|
||||
Text("Create New").tag(false)
|
||||
Text("Import").tag(true)
|
||||
} label: {
|
||||
Text("File")
|
||||
}
|
||||
viewBuilder {
|
||||
if fileImport {
|
||||
HStack {
|
||||
Text("File Path")
|
||||
Spacer()
|
||||
Spacer()
|
||||
if let fileURL {
|
||||
Button(fileURL.fileName) {
|
||||
pickerPresented = true
|
||||
}
|
||||
} else {
|
||||
Button("Choose") {
|
||||
pickerPresented = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if profileType == .icloud {
|
||||
FormItem("Path") {
|
||||
TextField("Path", text: $remotePath, prompt: Text("Required"))
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else if profileType == .remote {
|
||||
FormItem("URL") {
|
||||
TextField("URL", text: $remotePath, prompt: Text("Required"))
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
if !isSaving {
|
||||
Button("Create") {
|
||||
isSaving = true
|
||||
Task.detached {
|
||||
await createProfile()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Profile")
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $pickerPresented,
|
||||
allowedContentTypes: [.json],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
do {
|
||||
let urls = try result.get()
|
||||
if !urls.isEmpty {
|
||||
fileURL = urls[0]
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createProfile() async {
|
||||
defer {
|
||||
isSaving = false
|
||||
}
|
||||
if profileName.isEmpty {
|
||||
errorMessage = "Missing profile name"
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
if remotePath.isEmpty {
|
||||
if profileType == .icloud {
|
||||
errorMessage = "Missing path"
|
||||
errorPresented = true
|
||||
return
|
||||
} else if profileType == .remote {
|
||||
errorMessage = "Missing URL"
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
}
|
||||
do {
|
||||
try createProfile0()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
dismiss()
|
||||
if let callback {
|
||||
callback()
|
||||
}
|
||||
#if os(macOS)
|
||||
NotificationCenter.default.post(name: ProfileView.notificationName, object: nil)
|
||||
resetFields()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func resetFields() {
|
||||
profileName = ""
|
||||
profileType = .local
|
||||
fileImport = false
|
||||
fileURL = nil
|
||||
remotePath = ""
|
||||
}
|
||||
|
||||
private func createProfile0() throws {
|
||||
let nextProfileID = try ProfileManager.nextID()
|
||||
|
||||
var savePath = ""
|
||||
var remoteURL: String? = nil
|
||||
|
||||
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 {
|
||||
errorMessage = "Missing file"
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
if !fileURL.startAccessingSecurityScopedResource() {
|
||||
errorMessage = "Missing access to selected file"
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
defer {
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
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)
|
||||
_ = saveURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
saveURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
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 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)
|
||||
savePath = profileConfig.relativePath
|
||||
remoteURL = remotePath
|
||||
}
|
||||
try ProfileManager.create(Profile(name: profileName, type: profileType, path: savePath, remoteURL: remoteURL))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct ProfileView: View {
|
||||
public static let notificationName = Notification.Name("\(FilePath.packageName).update-profile")
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var isUpdating = false
|
||||
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
@State private var profileList: [Profile] = []
|
||||
|
||||
#if os(iOS)
|
||||
@State private var editMode = EditMode.inactive
|
||||
#elseif os(macOS)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
#endif
|
||||
|
||||
@State private var observer: Any?
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
doReload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
#if os(iOS)
|
||||
FormView {
|
||||
NavigationLink {
|
||||
NewProfileView {
|
||||
Task.detached {
|
||||
doReload()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("New Profile").foregroundColor(.accentColor)
|
||||
}
|
||||
.disabled(editMode.isEditing)
|
||||
if profileList.isEmpty {
|
||||
Text("Empty Profiles")
|
||||
} else {
|
||||
List {
|
||||
ForEach(profileList, id: \.mustID) { profile in
|
||||
viewBuilder {
|
||||
if editMode.isEditing == true {
|
||||
Text(profile.name)
|
||||
} else {
|
||||
NavigationLink {
|
||||
EditProfileView().environmentObject(profile)
|
||||
} label: {
|
||||
Text(profile.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onMove(perform: moveProfile)
|
||||
.onDelete(perform: deleteProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if profileList.isEmpty {
|
||||
Text("Empty Profiles")
|
||||
} else {
|
||||
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")
|
||||
})
|
||||
}
|
||||
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)
|
||||
}
|
||||
.onMove(perform: moveProfile)
|
||||
.onDelete(perform: deleteProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.disabled(isUpdating)
|
||||
.navigationTitle("Profiles")
|
||||
#if os(macOS)
|
||||
.onAppear {
|
||||
if observer == nil {
|
||||
observer = NotificationCenter.default.addObserver(forName: ProfileView.notificationName, object: nil, queue: .main) { _ in
|
||||
Task.detached {
|
||||
doReload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if let observer {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
observer = nil
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button(action: {
|
||||
openWindow(id: NewProfileView.windowID)
|
||||
}, label: {
|
||||
Label("New Profile", systemImage: "plus.square.fill")
|
||||
})
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
EditButton().disabled(profileList.isEmpty)
|
||||
}
|
||||
}
|
||||
.environment(\.editMode, $editMode)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func deleteSelectedProfiles(_ profileID: [Int64]) {
|
||||
do {
|
||||
if try ProfileManager.delete(by: profileID) > 0 {
|
||||
isLoading = true
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
|
||||
private func doReload() {
|
||||
defer {
|
||||
isLoading = false
|
||||
}
|
||||
do {
|
||||
profileList = try ProfileManager.list()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func updateProfile(_ profile: Profile) {
|
||||
do {
|
||||
_ = try profile.updateRemoteProfile()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
isUpdating = false
|
||||
}
|
||||
|
||||
private func deleteProfile(_ profile: Profile) {
|
||||
Task.detached {
|
||||
do {
|
||||
_ = try ProfileManager.delete(profile)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
}
|
||||
}
|
||||
|
||||
private func moveProfile(from source: IndexSet, to destination: Int) {
|
||||
profileList.move(fromOffsets: source, toOffset: destination)
|
||||
for (index, profile) in profileList.enumerated() {
|
||||
profile.order = UInt32(index)
|
||||
}
|
||||
do {
|
||||
try ProfileManager.update(profileList)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteProfile(where profileIndex: IndexSet) {
|
||||
let profileToDelete = profileIndex.map { index in
|
||||
profileList[index]
|
||||
}
|
||||
profileList.remove(atOffsets: profileIndex)
|
||||
Task.detached {
|
||||
do {
|
||||
_ = try ProfileManager.delete(profileToDelete)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
public struct ServiceLogView: View {
|
||||
#if os(macOS)
|
||||
public static let windowID = "service-log"
|
||||
#endif
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var content = ""
|
||||
@State private var fileExporterPresented = false
|
||||
private let logFont = Font.system(.caption, design: .monospaced)
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
loadContent()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if content.isEmpty {
|
||||
Text("Empty content")
|
||||
} else {
|
||||
ScrollView {
|
||||
Text(content).font(logFont)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
Button("Export") {
|
||||
fileExporterPresented = true
|
||||
}
|
||||
.disabled(content.isEmpty)
|
||||
}
|
||||
.fileExporter(
|
||||
isPresented: $fileExporterPresented,
|
||||
document: LogDocument(content),
|
||||
contentType: .text,
|
||||
defaultFilename: "service-log.txt",
|
||||
onCompletion: { _ in }
|
||||
)
|
||||
.navigationTitle("Service Log")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func loadContent() {
|
||||
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 {}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private struct LogDocument: FileDocument {
|
||||
static var readableContentTypes = [UTType.text]
|
||||
|
||||
let content: String
|
||||
|
||||
init(_ content: String) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
if let data = configuration.file.regularFileContents {
|
||||
content = String(decoding: data, as: UTF8.self)
|
||||
} else {
|
||||
content = ""
|
||||
}
|
||||
}
|
||||
|
||||
func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper {
|
||||
FileWrapper(regularFileWithContents: Data(content.utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import ServiceManagement
|
||||
#endif
|
||||
|
||||
public struct SettingView: View {
|
||||
#if os(macOS)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
#endif
|
||||
|
||||
@State private var isLoading = true
|
||||
|
||||
#if os(macOS)
|
||||
@State private var startAtLogin = false
|
||||
@Environment(\.showMenuBarExtra) private var showMenuBarExtra
|
||||
#endif
|
||||
|
||||
@State private var disableMemoryLimit = false
|
||||
@State private var version = ""
|
||||
@State private var dataSize = ""
|
||||
|
||||
@State private var errorPresented = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
await loadSettings()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FormView {
|
||||
#if os(macOS)
|
||||
Section("MacOS") {
|
||||
Toggle("Start At Login", isOn: $startAtLogin)
|
||||
.onChange(of: startAtLogin) { newValue in
|
||||
Task.detached {
|
||||
updateLoginItems(newValue)
|
||||
}
|
||||
}
|
||||
Toggle("Show in Menu Bar", isOn: showMenuBarExtra)
|
||||
.onChange(of: showMenuBarExtra.wrappedValue) { newValue in
|
||||
Task.detached {
|
||||
SharedPreferences.showMenuBarExtra = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Section("Packet Tunnel") {
|
||||
Toggle("Disable Memory Limit", isOn: $disableMemoryLimit)
|
||||
.onChange(of: disableMemoryLimit) { newValue in
|
||||
Task.detached {
|
||||
SharedPreferences.disableMemoryLimit = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("Core") {
|
||||
FormTextItem("Version", version)
|
||||
FormTextItem("Data Size", dataSize)
|
||||
#if os(iOS)
|
||||
NavigationLink(destination: ServiceLogView()) {
|
||||
Text("View Service Log")
|
||||
}
|
||||
#elseif os(macOS)
|
||||
Button("View Service Log") {
|
||||
openWindow(id: ServiceLogView.windowID)
|
||||
}
|
||||
#endif
|
||||
Button("Clear Working Directory") {
|
||||
Task.detached {
|
||||
clearWorkingDirectory()
|
||||
}
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.alert(isPresented: $errorPresented) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("Ok"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func updateLoginItems(_ startAtLogin: Bool) {
|
||||
do {
|
||||
if startAtLogin {
|
||||
if SMAppService.mainApp.status == .enabled {
|
||||
try? SMAppService.mainApp.unregister()
|
||||
}
|
||||
|
||||
try SMAppService.mainApp.register()
|
||||
} else {
|
||||
try SMAppService.mainApp.unregister()
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
errorPresented = true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func loadSettings() async {
|
||||
#if os(macOS)
|
||||
startAtLogin = SMAppService.mainApp.status == .enabled
|
||||
#endif
|
||||
disableMemoryLimit = SharedPreferences.disableMemoryLimit
|
||||
version = LibboxVersion()
|
||||
dataSize = "Loading..."
|
||||
isLoading = false
|
||||
dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown"
|
||||
}
|
||||
|
||||
private func clearWorkingDirectory() {
|
||||
try? FileManager.default.removeItem(at: FilePath.workingDirectory)
|
||||
isLoading = true
|
||||
}
|
||||
}
|
||||
|
||||
private extension URL {
|
||||
func formattedSize() throws -> String? {
|
||||
guard let urls = FileManager.default.enumerator(at: self, includingPropertiesForKeys: nil)?.allObjects as? [URL] else {
|
||||
return nil
|
||||
}
|
||||
let size = try urls.lazy.reduce(0) {
|
||||
try ($1.resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize ?? 0) + $0
|
||||
}
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
guard let byteCount = formatter.string(for: size) else {
|
||||
return nil
|
||||
}
|
||||
return byteCount
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user