Init commit

This commit is contained in:
世界
2023-07-15 15:03:45 +08:00
commit f441b89efb
122 changed files with 7355 additions and 0 deletions
@@ -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
}
}