add iOS
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public extension Alert {
|
||||
init(_ error: Error, _ dismissAction: (() -> Void)? = nil) {
|
||||
self.init(
|
||||
errorMessage: error.localizedDescription,
|
||||
dismissAction
|
||||
)
|
||||
}
|
||||
|
||||
init(errorMessage: String, _ dismissAction: (() -> Void)? = nil) {
|
||||
self.init(
|
||||
title: Text("错误提示"),
|
||||
message: Text(errorMessage),
|
||||
dismissButton: .default(Text("确定")) {
|
||||
dismissAction?()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
init(title: String = "提示", okMessage: String, _ dismissAction: (() -> Void)? = nil) {
|
||||
self.init(
|
||||
title: Text(title),
|
||||
message: Text(okMessage),
|
||||
dismissButton: .default(Text("确定")) {
|
||||
dismissAction?()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
init(OKORNOtitle: String = "提示", YesOrNOMessage: String, _ primaryAction: (() -> Void)? = nil) {
|
||||
self.init(
|
||||
title: Text(OKORNOtitle),
|
||||
message: Text(YesOrNOMessage),
|
||||
primaryButton: .default(Text("确定"), action: {
|
||||
primaryAction?()
|
||||
}),
|
||||
secondaryButton: .cancel(Text("取消"), action: {
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension View {
|
||||
func alertBinding(_ binding: Binding<Alert?>) -> some View {
|
||||
|
||||
alert(isPresented: Binding(get: {
|
||||
binding.wrappedValue != nil
|
||||
}, set: { newValue, _ in
|
||||
if !newValue {
|
||||
binding.wrappedValue = nil
|
||||
}
|
||||
})) {
|
||||
binding.wrappedValue!
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
func alertBinding(_ binding: Binding<Alert?>, _ isLoading: Binding<Bool>) -> some View {
|
||||
alert(isPresented: Binding(get: {
|
||||
binding.wrappedValue != nil
|
||||
}, set: { newValue, _ in
|
||||
if !newValue, !isLoading.wrappedValue {
|
||||
binding.wrappedValue = nil
|
||||
}
|
||||
})) {
|
||||
binding.wrappedValue!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public struct BackButton: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
public var body: some View {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public extension Binding {
|
||||
func withSetter(_ setter: @escaping (Value) -> Void) -> Binding<Value> {
|
||||
Binding {
|
||||
wrappedValue
|
||||
} set: { [setter] newValue, _ in
|
||||
setter(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public extension Binding where Value == Int32 {
|
||||
func stringBinding(defaultValue: Int32) -> Binding<String> {
|
||||
Binding<String> {
|
||||
var intValue = wrappedValue
|
||||
if intValue == 0 {
|
||||
intValue = defaultValue
|
||||
}
|
||||
return String(intValue)
|
||||
} set: { newValue in
|
||||
var newIntValue = Int32(newValue) ?? defaultValue
|
||||
if newIntValue == 0 {
|
||||
newIntValue = defaultValue
|
||||
}
|
||||
wrappedValue = newIntValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public struct DeleteButton<Label>: View where Label: View {
|
||||
private let action: () async -> Void
|
||||
private let label: Label
|
||||
|
||||
@State private var performDelete = false
|
||||
@State private var timer: Timer?
|
||||
@State private var isLoading = false
|
||||
|
||||
public init(action: @escaping () async -> Void, @ViewBuilder label: () -> Label) {
|
||||
self.action = action
|
||||
self.label = label()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button(role: .destructive) {
|
||||
isLoading = true
|
||||
if let timer {
|
||||
timer.invalidate()
|
||||
}
|
||||
if !performDelete {
|
||||
performDelete = true
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
|
||||
timer = nil
|
||||
performDelete = false
|
||||
}
|
||||
isLoading = true
|
||||
} else {
|
||||
Task {
|
||||
await action()
|
||||
isLoading = false
|
||||
performDelete = false
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if !performDelete {
|
||||
label
|
||||
} else {
|
||||
label.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
public enum DeviceCensorship {
|
||||
public static func isChinaDevice() -> Bool {
|
||||
let bannedCharacter = "\u{1F1F9}\u{1F1FC}" as NSString
|
||||
var imageData: Data
|
||||
#if canImport(UIKit)
|
||||
let attributes = [NSAttributedString.Key.font:
|
||||
UIFont.systemFont(ofSize: 8)]
|
||||
UIGraphicsBeginImageContext(bannedCharacter.size(withAttributes: attributes))
|
||||
bannedCharacter.draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
|
||||
var imagePNG: Data?
|
||||
if let charImage = UIGraphicsGetImageFromCurrentImageContext() {
|
||||
imagePNG = charImage.pngData()
|
||||
}
|
||||
UIGraphicsEndImageContext()
|
||||
guard let imagePNG else {
|
||||
return false
|
||||
}
|
||||
guard let uiImage = UIImage(data: imagePNG) else {
|
||||
return false
|
||||
}
|
||||
guard let cgImage = uiImage.cgImage else { return false }
|
||||
guard let cgImageData = cgImage.dataProvider?.data as Data? else { return false }
|
||||
imageData = cgImageData
|
||||
#elseif canImport(AppKit)
|
||||
let attributes = [NSAttributedString.Key.font:
|
||||
NSFont.systemFont(ofSize: 8)]
|
||||
let characterSize = bannedCharacter.size(withAttributes: attributes)
|
||||
let characterRect = NSRect(origin: .zero, size: characterSize)
|
||||
|
||||
let characterBitmap = NSBitmapImageRep(bitmapDataPlanes: nil,
|
||||
pixelsWide: Int(characterSize.width),
|
||||
pixelsHigh: Int(characterSize.height),
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: NSColorSpaceName.calibratedRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0)
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: characterBitmap!)
|
||||
NSGraphicsContext.current?.imageInterpolation = .high
|
||||
|
||||
bannedCharacter.draw(in: characterRect, withAttributes: attributes)
|
||||
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
guard let imagePNG = characterBitmap?.representation(using: .png, properties: [:]) else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let nsImage = NSImage(data: imagePNG) else {
|
||||
return false
|
||||
}
|
||||
guard let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
return false
|
||||
}
|
||||
guard let cgImageData = cgImage.dataProvider?.data as Data? else { return false }
|
||||
imageData = cgImageData
|
||||
#endif
|
||||
let rawData: UnsafePointer<UInt8> = CFDataGetBytePtr(imageData as CFData)
|
||||
for index in stride(from: 0, to: imageData.count, by: 4) {
|
||||
let r = rawData[index]
|
||||
let g = rawData[index + 1]
|
||||
let b = rawData[index + 2]
|
||||
if !(r == g && g == b) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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: LocalizedStringKey, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(name)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.font(Font.system(.caption, design: .monospaced))
|
||||
#if os(iOS) || os(macOS)
|
||||
.textSelection(.enabled)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public func FormTextItem(_ name: LocalizedStringKey, _ systemImage: String, @ViewBuilder _ value: () -> some View) -> some View {
|
||||
HStack {
|
||||
Label(name, systemImage: systemImage)
|
||||
Spacer()
|
||||
value()
|
||||
.multilineTextAlignment(.trailing)
|
||||
.font(Font.system(.caption, design: .monospaced))
|
||||
#if os(iOS) || os(macOS)
|
||||
.textSelection(.enabled)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public func FormItem(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
||||
#if os(iOS) || os(tvOS)
|
||||
HStack {
|
||||
Text(title)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
Spacer()
|
||||
Spacer()
|
||||
content()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
content()
|
||||
#endif
|
||||
}
|
||||
|
||||
public func FormToggle(_ titleKey: LocalizedStringKey, _ subtitleKey: LocalizedStringKey, _ isOn: Binding<Bool>, _ action: @escaping (_ newValue: Bool) async -> Void) -> some View {
|
||||
#if os(macOS)
|
||||
Toggle(isOn: isOn) {
|
||||
VStack(alignment: .leading) {
|
||||
Text(titleKey)
|
||||
Spacer()
|
||||
Text(subtitleKey)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: isOn.wrappedValue) { newValue in
|
||||
Task {
|
||||
await action(newValue)
|
||||
}
|
||||
}
|
||||
#else
|
||||
Section {
|
||||
Toggle(titleKey, isOn: isOn)
|
||||
.onChangeCompat(of: isOn.wrappedValue) { newValue in
|
||||
Task {
|
||||
await action(newValue)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text(subtitleKey)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
public func FormButton(action: @escaping () -> Void, @ViewBuilder label: () -> some View) -> some View {
|
||||
Button(action: action, label: label)
|
||||
#if os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
public func FormButton(_ titleKey: some StringProtocol, action: @escaping () -> Void) -> some View {
|
||||
Button(titleKey, action: action)
|
||||
#if os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
public func FormButton(role: ButtonRole?, action: @escaping () -> Void, @ViewBuilder label: () -> some View) -> some View {
|
||||
Button(role: role, action: action, label: label)
|
||||
#if os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
public func FormNavigationLink(@ViewBuilder destination: () -> some View, @ViewBuilder label: () -> some View) -> some View {
|
||||
#if !os(tvOS)
|
||||
return NavigationLink(destination: destination, label: label)
|
||||
#else
|
||||
return NavigationLink(destination: {
|
||||
destination()
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarLeading) {
|
||||
BackButton()
|
||||
}
|
||||
}
|
||||
}, label: label)
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
func NavigationDestinationCompat(isPresented: Binding<Bool>, @ViewBuilder destination: () -> some View) -> some View {
|
||||
NavigationLink(
|
||||
destination: destination(),
|
||||
isActive: isPresented,
|
||||
label: {
|
||||
EmptyView()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
public func NavigationStackCompat(@ViewBuilder content: () -> some View) -> some View {
|
||||
viewBuilder {
|
||||
if #available(iOS 17.0, *) {
|
||||
// view not updating in iOS 16, but why?
|
||||
NavigationStack {
|
||||
content()
|
||||
}
|
||||
} else {
|
||||
NavigationView(content: content)
|
||||
#if !os(macOS)
|
||||
.navigationViewStyle(.stack)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#if !os(tvOS)
|
||||
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
|
||||
public func RequestReviewButton(label: @escaping () -> some View) -> some View {
|
||||
viewBuilder {
|
||||
if #available(iOS 16.0, macOS 13.0, visionOS 1.0, *) {
|
||||
RequestReviewButton0(label: label)
|
||||
} else {
|
||||
#if os(iOS)
|
||||
RequestReviewButton1(label: label)
|
||||
#else
|
||||
EmptyView()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, macOS 13.0, visionOS 1.0, *)
|
||||
struct RequestReviewButton0<Label: View>: View {
|
||||
@Environment(\.requestReview) private var requestReview
|
||||
|
||||
private let label: () -> Label
|
||||
init(label: @escaping () -> Label) {
|
||||
self.label = label
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
FormButton(action: {
|
||||
requestReview()
|
||||
}, label: label)
|
||||
}
|
||||
}
|
||||
|
||||
struct RequestReviewButton1<Label: View>: View {
|
||||
private let label: () -> Label
|
||||
init(label: @escaping () -> Label) {
|
||||
self.label = label
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
SKStoreReviewController.requestReview()
|
||||
}, label: label)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
public struct ProfileShareButton<Label>: View where Label: View {
|
||||
private let alert: Binding<Alert?>
|
||||
private let profile: Profile
|
||||
private let label: () -> Label
|
||||
|
||||
public init(_ alert: Binding<Alert?>, _ profile: Profile, label: @escaping () -> Label) {
|
||||
self.alert = alert
|
||||
self.profile = profile
|
||||
self.label = label
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
#if os(iOS)
|
||||
if #available(iOS 17.4, *) {
|
||||
bodyCompat
|
||||
} else if #available(iOS 16.0, *) {
|
||||
ShareLink(item: profile, subject: Text(profile.name), preview: SharePreview("Share profile"), label: label)
|
||||
} else if UIDevice.current.userInterfaceIdiom != .pad {
|
||||
bodyCompat
|
||||
}
|
||||
#else
|
||||
bodyCompat
|
||||
#endif
|
||||
}
|
||||
|
||||
private var bodyCompat: some View {
|
||||
ShareButtonCompat(alert, label: label) {
|
||||
try profile.toContent().generateShareFile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ShareButtonCompat<Label>: View where Label: View {
|
||||
private let label: () -> Label
|
||||
private let itemURL: () throws -> URL
|
||||
|
||||
@Binding private var alert: Alert?
|
||||
|
||||
#if os(macOS)
|
||||
@State private var sharePresented = false
|
||||
#endif
|
||||
|
||||
public init(_ alert: Binding<Alert?>, @ViewBuilder label: @escaping () -> Label, itemURL: @escaping () throws -> URL) {
|
||||
_alert = alert
|
||||
self.label = label
|
||||
self.itemURL = itemURL
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button(action: shareItem, label: label)
|
||||
#if os(macOS)
|
||||
.background(SharingServicePicker($sharePresented, $alert, itemURL))
|
||||
#endif
|
||||
}
|
||||
|
||||
private func shareItem() {
|
||||
#if os(iOS)
|
||||
Task {
|
||||
await shareItem0()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
sharePresented = true
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private nonisolated func shareItem0() async {
|
||||
do {
|
||||
let shareItem = try itemURL()
|
||||
await MainActor.run {
|
||||
shareItem1(shareItem)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shareItem1(_ item: URL) {
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||
windowScene.keyWindow?.rootViewController?.present(UIActivityViewController(activityItems: [item], applicationActivities: nil), animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private struct SharingServicePicker: NSViewRepresentable {
|
||||
@Binding private var isPresented: Bool
|
||||
@Binding private var alert: Alert?
|
||||
private let item: () throws -> URL
|
||||
|
||||
init(_ isPresented: Binding<Bool>, _ alert: Binding<Alert?>, _ item: @escaping () throws -> URL) {
|
||||
_isPresented = isPresented
|
||||
_alert = alert
|
||||
self.item = item
|
||||
}
|
||||
|
||||
func makeNSView(context _: Context) -> NSView {
|
||||
let view = NSView()
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
if isPresented {
|
||||
do {
|
||||
let picker = try NSSharingServicePicker(items: [item()])
|
||||
picker.delegate = context.coordinator
|
||||
DispatchQueue.main.async {
|
||||
picker.show(relativeTo: .zero, of: nsView, preferredEdge: .minY)
|
||||
}
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, NSSharingServicePickerDelegate {
|
||||
private let parent: SharingServicePicker
|
||||
|
||||
init(_ parent: SharingServicePicker) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func sharingServicePicker(_ sharingServicePicker: NSSharingServicePicker, didChoose _: NSSharingService?) {
|
||||
sharingServicePicker.delegate = nil
|
||||
parent.isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public func viewBuilder(@ViewBuilder _ builder: () -> some View) -> some View {
|
||||
builder()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
public extension View {
|
||||
func onChangeCompat(of value: some Equatable, _ action: @escaping () -> Void) -> some View {
|
||||
// if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) {
|
||||
// return onChange(of: value, action)
|
||||
// } else {
|
||||
onChange(of: value) { _ in
|
||||
action()
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
func onChangeCompat<V>(of value: V, _ action: @escaping (_ newValue: V) -> Void) -> some View where V: Equatable {
|
||||
// if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) {
|
||||
// return onChange(of: value) { _, newValue in
|
||||
// action(newValue)
|
||||
// }
|
||||
// } else {
|
||||
onChange(of: value, perform: action)
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct ActiveDashboardView: View {
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@Environment(\.selection) private var parentSelection
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@EnvironmentObject private var profile: ExtensionProfile
|
||||
@State private var isLoading = true
|
||||
@State private var profileList: [ProfilePreview] = []
|
||||
@State private var selectedProfileID: Int64 = 0
|
||||
@State private var alert: Alert?
|
||||
@State private var selection = DashboardPage.overview
|
||||
@State private var systemProxyAvailable = false
|
||||
@State private var systemProxyEnabled = false
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ApplicationLibrary.inPreview {
|
||||
body1
|
||||
} else {
|
||||
body1
|
||||
.onAppear {
|
||||
Task {
|
||||
await doReloadSystemProxy()
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: profile.status) { newStatus in
|
||||
if newStatus == .connected {
|
||||
Task {
|
||||
await doReloadSystemProxy()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var body1: some View {
|
||||
VStack {
|
||||
#if os(iOS) || os(tvOS)
|
||||
if ApplicationLibrary.inPreview || profile.status.isConnectedStrict {
|
||||
Picker("Page", selection: $selection) {
|
||||
ForEach(DashboardPage.allCases) { page in
|
||||
page.label
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
#if os(iOS)
|
||||
.padding([.leading, .trailing])
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
TabView(selection: $selection) {
|
||||
ForEach(DashboardPage.allCases) { page in
|
||||
page.contentView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
|
||||
.tag(page)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .always))
|
||||
} else {
|
||||
OverviewView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
OverviewView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
|
||||
#endif
|
||||
}
|
||||
.onReceive(environments.profileUpdate) { _ in
|
||||
Task {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
.onReceive(environments.selectedProfileUpdate) { _ in
|
||||
Task {
|
||||
selectedProfileID = await SharedPreferences.selectedProfileID.get()
|
||||
if profile.status.isConnected {
|
||||
await doReloadSystemProxy()
|
||||
}
|
||||
}
|
||||
}
|
||||
.alertBinding($alert)
|
||||
}
|
||||
|
||||
private func doReload() async {
|
||||
defer {
|
||||
isLoading = false
|
||||
}
|
||||
if ApplicationLibrary.inPreview {
|
||||
profileList = [
|
||||
ProfilePreview(Profile(id: 0, name: "profile local", type: .local, path: "")),
|
||||
ProfilePreview(Profile(id: 1, name: "profile remote", type: .remote, path: "", lastUpdated: Date(timeIntervalSince1970: 0))),
|
||||
]
|
||||
systemProxyAvailable = true
|
||||
systemProxyEnabled = true
|
||||
selectedProfileID = 0
|
||||
|
||||
} else {
|
||||
do {
|
||||
profileList = try await ProfileManager.list().map { ProfilePreview($0) }
|
||||
if profileList.isEmpty {
|
||||
return
|
||||
}
|
||||
selectedProfileID = await SharedPreferences.selectedProfileID.get()
|
||||
if profileList.filter({ profile in
|
||||
profile.id == selectedProfileID
|
||||
})
|
||||
.isEmpty {
|
||||
selectedProfileID = profileList[0].id
|
||||
await SharedPreferences.selectedProfileID.set(selectedProfileID)
|
||||
}
|
||||
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
environments.emptyProfiles = profileList.isEmpty
|
||||
}
|
||||
|
||||
private nonisolated func doReloadSystemProxy() async {
|
||||
do {
|
||||
let status = try LibboxNewStandaloneCommandClient()!.getSystemProxyStatus()
|
||||
await MainActor.run {
|
||||
systemProxyAvailable = status.available
|
||||
systemProxyEnabled = status.enabled
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//
|
||||
// ActiveDashboardViewMACOS.swift
|
||||
// ApplicationLibrary
|
||||
//
|
||||
// Created by Mac on 2024/10/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct ActiveDashboardViewMACOS: View {
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@Environment(\.selection) private var parentSelection
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@EnvironmentObject private var profile: ExtensionProfile
|
||||
@State private var isLoading = true
|
||||
@State private var profileList: [ProfilePreview] = []
|
||||
@State private var selectedProfileID: Int64 = 0
|
||||
@State private var alert: Alert?
|
||||
@State private var selection = DashboardPage.overview
|
||||
@State private var systemProxyAvailable = false
|
||||
@State private var systemProxyEnabled = false
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ApplicationLibrary.inPreview {
|
||||
body1
|
||||
} else {
|
||||
body1
|
||||
.onAppear {
|
||||
Task {
|
||||
await doReloadSystemProxy()
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: profile.status) { newStatus in
|
||||
if newStatus == .connected {
|
||||
Task {
|
||||
await doReloadSystemProxy()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var body1: some View {
|
||||
VStack {
|
||||
#if os(iOS) || os(tvOS)
|
||||
if ApplicationLibrary.inPreview || profile.status.isConnectedStrict {
|
||||
/* Picker("Page", selection: $selection) {
|
||||
ForEach(DashboardPage.allCases) { page in
|
||||
page.label
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
#if os(iOS)
|
||||
.padding([.leading, .trailing])
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
TabView(selection: $selection) {
|
||||
ForEach(DashboardPage.allCases) { page in
|
||||
page.contentView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
|
||||
.tag(page)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .always))
|
||||
*/
|
||||
OverviewView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
|
||||
} else {
|
||||
OverviewView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
OverviewView($profileList, $selectedProfileID, $systemProxyAvailable, $systemProxyEnabled)
|
||||
#endif
|
||||
}
|
||||
.onReceive(environments.profileUpdate) { _ in
|
||||
Task {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
.onReceive(environments.selectedProfileUpdate) { _ in
|
||||
Task {
|
||||
selectedProfileID = await SharedPreferences.selectedProfileID.get()
|
||||
if profile.status.isConnected {
|
||||
await doReloadSystemProxy()
|
||||
}
|
||||
}
|
||||
}
|
||||
.alertBinding($alert)
|
||||
}
|
||||
|
||||
private func doReload() async {
|
||||
defer {
|
||||
isLoading = false
|
||||
}
|
||||
if ApplicationLibrary.inPreview {
|
||||
profileList = [
|
||||
ProfilePreview(Profile(id: 0, name: "profile local", type: .local, path: "")),
|
||||
ProfilePreview(Profile(id: 1, name: "profile remote", type: .remote, path: "", lastUpdated: Date(timeIntervalSince1970: 0))),
|
||||
]
|
||||
systemProxyAvailable = true
|
||||
systemProxyEnabled = true
|
||||
selectedProfileID = 0
|
||||
|
||||
} else {
|
||||
do {
|
||||
profileList = try await ProfileManager.list().map { ProfilePreview($0) }
|
||||
if profileList.isEmpty {
|
||||
//add profile remote
|
||||
|
||||
Task {
|
||||
if StoreManager.shared.getSuburlData().count > 5 {
|
||||
await createProfile()
|
||||
}
|
||||
|
||||
}
|
||||
await environments.reload()
|
||||
|
||||
return
|
||||
}
|
||||
selectedProfileID = await SharedPreferences.selectedProfileID.get()
|
||||
if profileList.filter({ profile in
|
||||
profile.id == selectedProfileID
|
||||
})
|
||||
.isEmpty {
|
||||
selectedProfileID = profileList[0].id
|
||||
await SharedPreferences.selectedProfileID.set(selectedProfileID)
|
||||
}
|
||||
|
||||
|
||||
// try await ProfileManager.list().forEach { p in
|
||||
// await ProfileManager.update(p)
|
||||
// }
|
||||
|
||||
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
environments.emptyProfiles = profileList.isEmpty
|
||||
}
|
||||
|
||||
private nonisolated func doReloadSystemProxy() async {
|
||||
do {
|
||||
let status = try LibboxNewStandaloneCommandClient()!.getSystemProxyStatus()
|
||||
await MainActor.run {
|
||||
systemProxyAvailable = status.available
|
||||
systemProxyEnabled = status.enabled
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func createProfile() async {
|
||||
do {
|
||||
try await createProfileBackground()
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
environments.profileUpdate.send()
|
||||
}
|
||||
|
||||
private nonisolated func createProfileBackground() async throws {
|
||||
let nextProfileID = try await ProfileManager.nextID()
|
||||
|
||||
var savePath = ""
|
||||
let remoteURL: String = StoreManager.shared.getSuburlData()
|
||||
var lastUpdated: Date? = nil
|
||||
let remoteContent = try HTTPClient().getString(remoteURL)
|
||||
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
|
||||
|
||||
lastUpdated = .now
|
||||
|
||||
try await ProfileManager.create(Profile(
|
||||
name: "VPN",
|
||||
type: ProfileType.remote,
|
||||
path: savePath,
|
||||
remoteURL: remoteURL,
|
||||
autoUpdate: true,
|
||||
autoUpdateInterval: 60,
|
||||
lastUpdated: lastUpdated
|
||||
))
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
try UIProfileUpdateTask.configure()
|
||||
let list = try await ProfileManager.list()
|
||||
list.forEach { profileOne in
|
||||
print("\(profileOne.path) \n \(profileOne.remoteURL ?? "") \n \(profileOne.id ?? 0 )")
|
||||
}
|
||||
#else
|
||||
try await ProfileUpdateTask.configure()
|
||||
#endif
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct ClashModeView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@StateObject private var commandClient = CommandClient(.clashMode)
|
||||
@State private var clashMode = ""
|
||||
@State private var alert: Alert?
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
VStack {
|
||||
|
||||
if commandClient.clashModeList.count > 1 {
|
||||
Picker("", selection: Binding(get: {
|
||||
clashMode
|
||||
}, set: { newMode in
|
||||
clashMode = newMode
|
||||
Task {
|
||||
await setClashMode(newMode)
|
||||
await restartVPNServer()
|
||||
}
|
||||
}), content: {
|
||||
ForEach(commandClient.clashModeList, id: \.self) { it in
|
||||
/*if it.lowercased() == "rule" {
|
||||
Text("规则模式")
|
||||
}else if it.lowercased() == "direct" {
|
||||
Text("直连模式")
|
||||
}else if it.lowercased() == "global" {
|
||||
Text("全局模式")
|
||||
}else{
|
||||
|
||||
}*/
|
||||
Text(it)
|
||||
|
||||
}
|
||||
})
|
||||
.pickerStyle(.segmented)
|
||||
.padding([.top], 8)
|
||||
}else{
|
||||
//DEBUG
|
||||
/*
|
||||
let newclashModeList = ["规则模式","直连模式","全局模式"]
|
||||
Picker("", selection: Binding(get: {
|
||||
clashMode
|
||||
}, set: { newMode in
|
||||
|
||||
}), content: {
|
||||
ForEach(newclashModeList, id: \.self) { it in
|
||||
/*if it.lowercased() == "rule" {
|
||||
Text("规则模式")
|
||||
}else if it.lowercased() == "direct" {
|
||||
Text("直连模式")
|
||||
}else if it.lowercased() == "global" {
|
||||
Text("全局模式")
|
||||
}else{
|
||||
Text(it)
|
||||
}*/
|
||||
|
||||
Text(it)
|
||||
|
||||
}
|
||||
})
|
||||
.pickerStyle(.segmented)
|
||||
.padding([.top], 8)
|
||||
*/
|
||||
}
|
||||
|
||||
}
|
||||
.onReceive(commandClient.$clashMode) { newMode in
|
||||
clashMode = newMode
|
||||
}
|
||||
.padding([.leading, .trailing])
|
||||
.onAppear {
|
||||
commandClient.connect()
|
||||
}
|
||||
.onDisappear {
|
||||
commandClient.disconnect()
|
||||
}
|
||||
.onChangeCompat(of: scenePhase) { newValue in
|
||||
print("\(String(describing: commandClient.status))")
|
||||
if newValue == .active {
|
||||
commandClient.connect()
|
||||
} else {
|
||||
commandClient.disconnect()
|
||||
}
|
||||
}
|
||||
.alertBinding($alert)
|
||||
}
|
||||
|
||||
private nonisolated func setClashMode(_ newMode: String) async {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient()!.setClashMode(newMode)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func restartVPNServer() async {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient()!.serviceReload()
|
||||
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public enum DashboardPage: Int, CaseIterable, Identifiable {
|
||||
public var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
case overview
|
||||
case groups
|
||||
}
|
||||
|
||||
public extension DashboardPage {
|
||||
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .overview:
|
||||
return NSLocalizedString("Overview", comment: "")
|
||||
case .groups:
|
||||
return NSLocalizedString("Groups", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
var label: some View {
|
||||
switch self {
|
||||
case .overview:
|
||||
return Label("Overview", systemImage: "text.and.command.macwindow")
|
||||
case .groups:
|
||||
return Label("Groups", systemImage: "rectangle.3.group.fill")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func contentView(_ profileList: Binding<[ProfilePreview]>, _ selectedProfileID: Binding<Int64>, _ systemProxyAvailable: Binding<Bool>, _ systemProxyEnabled: Binding<Bool>) -> some View {
|
||||
viewBuilder {
|
||||
switch self {
|
||||
case .overview:
|
||||
OverviewView(profileList, selectedProfileID, systemProxyAvailable, systemProxyEnabled)
|
||||
case .groups:
|
||||
GroupListView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct DashboardView: View {
|
||||
#if os(macOS)
|
||||
@Environment(\.controlActiveState) private var controlActiveState
|
||||
@State private var isLoading = true
|
||||
@State private var systemExtensionInstalled = true
|
||||
#endif
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
#if os(macOS)
|
||||
HStack{
|
||||
|
||||
|
||||
|
||||
Button {
|
||||
withAnimation{
|
||||
// isUserViewActive.toggle()
|
||||
}
|
||||
} label: {
|
||||
|
||||
Image(systemName: "circle.grid.cross")
|
||||
.font(.title2)
|
||||
.padding(5)
|
||||
.foregroundColor(.white)
|
||||
.background(
|
||||
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(.white.opacity(0.25),lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
/* Button {
|
||||
withAnimation{
|
||||
isSubscriptionActive.toggle()
|
||||
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.font(.title2)
|
||||
.padding(12)
|
||||
.background(
|
||||
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(.white.opacity(0.25),lineWidth: 1)
|
||||
)
|
||||
} */
|
||||
|
||||
|
||||
// Text Bubble
|
||||
Button {
|
||||
withAnimation{
|
||||
// isSubscriptionActive.toggle()
|
||||
|
||||
}
|
||||
} label: {
|
||||
|
||||
Text("超值折扣")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.orange)
|
||||
)
|
||||
.overlay(
|
||||
TriangleShape()
|
||||
.fill(Color.orange)
|
||||
.frame(width: 10, height: 10)
|
||||
.offset(x: 10)
|
||||
, alignment: .trailing
|
||||
)
|
||||
|
||||
// Panda Image with VIP Tag
|
||||
HStack(spacing: 10){
|
||||
Text(verbatim: "")
|
||||
Image("applogo") // Replace with actual image asset
|
||||
.resizable()
|
||||
.frame(width: 30, height: 30)
|
||||
.cornerRadius(15)
|
||||
|
||||
// Text("VIP")
|
||||
// .font(.caption)
|
||||
// .fontWeight(.bold)
|
||||
// .foregroundColor(.yellow)
|
||||
// .padding(4)
|
||||
// .background(Color.orange)
|
||||
// .cornerRadius(4)
|
||||
}
|
||||
|
||||
}
|
||||
.padding()
|
||||
|
||||
Button {
|
||||
withAnimation{
|
||||
// isKefuActive.toggle()
|
||||
}
|
||||
} label: {
|
||||
|
||||
Image(systemName: "person.fill.questionmark")
|
||||
.font(.title2)
|
||||
.padding(5)
|
||||
.foregroundColor(.white)
|
||||
.background(
|
||||
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(.white.opacity(0.25),lineWidth: 1)
|
||||
)
|
||||
Text("客服").foregroundColor(.white)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if Variant.useSystemExtension {
|
||||
viewBuilder {
|
||||
if !systemExtensionInstalled {
|
||||
FormView {
|
||||
InstallSystemExtensionButton {
|
||||
await reload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DashboardView0()
|
||||
}
|
||||
}.onAppear {
|
||||
Task {
|
||||
await reload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DashboardView0()
|
||||
}
|
||||
#else
|
||||
DashboardView0()
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
.onChangeCompat(of: controlActiveState) { newValue in
|
||||
if newValue != .inactive {
|
||||
if Variant.useSystemExtension {
|
||||
if !isLoading {
|
||||
Task {
|
||||
await reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
|
||||
struct TriangleShape: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#if os(macOS)
|
||||
private nonisolated func reload() async {
|
||||
let systemExtensionInstalled = await SystemExtension.isInstalled()
|
||||
await MainActor.run {
|
||||
self.systemExtensionInstalled = systemExtensionInstalled
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
struct DashboardView0: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
|
||||
var body: some View {
|
||||
if ApplicationLibrary.inPreview {
|
||||
// ActiveDashboardView()/
|
||||
ActiveDashboardViewMACOS()
|
||||
} else if environments.extensionProfileLoading {
|
||||
ProgressView()
|
||||
} else if let profile = environments.extensionProfile {
|
||||
DashboardView1().environmentObject(profile)
|
||||
} else {
|
||||
FormView {
|
||||
|
||||
InstallProfileButton { error in
|
||||
//add profile remote
|
||||
|
||||
await environments.reload()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
struct DashboardView1: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@EnvironmentObject private var profile: ExtensionProfile
|
||||
@State private var alert: Alert?
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
// ActiveDashboardView()
|
||||
ActiveDashboardViewMACOS()
|
||||
}
|
||||
.alertBinding($alert)
|
||||
.onChangeCompat(of: profile.status) { newValue in
|
||||
if newValue == .disconnecting || newValue == .connected {
|
||||
Task {
|
||||
await checkServiceError()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func checkServiceError() async {
|
||||
var error: NSError?
|
||||
let message = LibboxReadServiceError(&error)
|
||||
if error != nil {
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
alert = Alert(title: Text("Service Error"), message: Text(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//
|
||||
// DashboardViewMacOS.swift
|
||||
// ApplicationLibrary
|
||||
//
|
||||
// Created by Mac on 2024/10/22.
|
||||
//
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct DashboardViewMacOS: View {
|
||||
#if os(macOS)
|
||||
@Environment(\.controlActiveState) private var controlActiveState
|
||||
@State private var isLoading = true
|
||||
@State private var systemExtensionInstalled = true
|
||||
#endif
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension {
|
||||
viewBuilder {
|
||||
if !systemExtensionInstalled {
|
||||
FormView {
|
||||
InstallSystemExtensionButton {
|
||||
await reload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DashboardView0()
|
||||
|
||||
}
|
||||
}.onAppear {
|
||||
Task {
|
||||
await reload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DashboardView0()
|
||||
}
|
||||
#else
|
||||
DashboardView0()
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
.onChangeCompat(of: controlActiveState) { newValue in
|
||||
if newValue != .inactive {
|
||||
if Variant.useSystemExtension {
|
||||
if !isLoading {
|
||||
Task {
|
||||
await reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private nonisolated func reload() async {
|
||||
let systemExtensionInstalled = await SystemExtension.isInstalled()
|
||||
await MainActor.run {
|
||||
self.systemExtensionInstalled = systemExtensionInstalled
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
struct DashboardView0: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
|
||||
var body: some View {
|
||||
if ApplicationLibrary.inPreview {
|
||||
ActiveDashboardView()
|
||||
} else if environments.extensionProfileLoading {
|
||||
ProgressView()
|
||||
} else if let profile = environments.extensionProfile {
|
||||
DashboardView1().environmentObject(profile)
|
||||
} else {
|
||||
FormView {
|
||||
|
||||
InstallProfileButton {
|
||||
//add profile remote
|
||||
|
||||
await environments.reload()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
struct DashboardView1: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@EnvironmentObject private var profile: ExtensionProfile
|
||||
@State private var alert: Alert?
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ActiveDashboardView()
|
||||
|
||||
}
|
||||
.alertBinding($alert)
|
||||
.onChangeCompat(of: profile.status) { newValue in
|
||||
if newValue == .disconnecting || newValue == .connected {
|
||||
Task {
|
||||
await checkServiceError()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func checkServiceError() async {
|
||||
var error: NSError?
|
||||
let message = LibboxReadServiceError(&error)
|
||||
if error != nil {
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
alert = Alert(title: Text("Service Error"), message: Text(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct ExtensionStatusView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@StateObject private var commandClient = CommandClient(.status)
|
||||
@State private var columnCount: Int = 4
|
||||
@State private var alert: Alert?
|
||||
|
||||
private let infoFont = Font.system(.caption, design: .monospaced)
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
if columnCount == 1 {
|
||||
ScrollView {
|
||||
body0
|
||||
}
|
||||
} else {
|
||||
body0
|
||||
}
|
||||
}
|
||||
|
||||
public var body0: some View {
|
||||
viewBuilder {
|
||||
VStack {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: columnCount), alignment: .leading) {
|
||||
if ApplicationLibrary.inPreview {
|
||||
StatusItem("Status") {
|
||||
StatusLine("Memory", "6.4 MB")
|
||||
StatusLine("Goroutines", "89")
|
||||
}
|
||||
StatusItem("Connections") {
|
||||
StatusLine("Inbound", "34")
|
||||
StatusLine("Outbound", "28")
|
||||
}
|
||||
StatusItem("Traffic") {
|
||||
StatusLine("Uplink", "38 B/s")
|
||||
StatusLine("Downlink", "249 MB/s")
|
||||
}
|
||||
StatusItem("TrafficTotal") {
|
||||
StatusLine("Uplink", "52 MB")
|
||||
StatusLine("Downlink", "5.6 GB")
|
||||
}
|
||||
} else if let message = commandClient.status {
|
||||
// StatusItem("Status") {
|
||||
// StatusLine("Memory", LibboxFormatMemoryBytes(message.memory))
|
||||
// StatusLine("Goroutines", "\(message.goroutines)")
|
||||
// }
|
||||
// StatusItem("Connections") {
|
||||
// StatusLine("Inbound", "\(message.connectionsIn)")
|
||||
// StatusLine("Outbound", "\(message.connectionsOut)")
|
||||
// }
|
||||
if message.trafficAvailable {
|
||||
// StatusItem("网速") {
|
||||
// StatusLine("上传", "\(LibboxFormatBytes(message.uplink))/s")
|
||||
// StatusLine("下载", "\(LibboxFormatBytes(message.downlink))/s")
|
||||
// }
|
||||
StatusItem("总流量") {
|
||||
HStack(spacing: 10, content: {
|
||||
StatusLine("上传", LibboxFormatBytes(message.uplinkTotal))
|
||||
Spacer()
|
||||
StatusLine("下载", LibboxFormatBytes(message.downlinkTotal))
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
StatusItem("总流量") {
|
||||
HStack(spacing: 10, content: {
|
||||
StatusLine("上传", ".../s")
|
||||
Spacer()
|
||||
StatusLine("下载", ".../s")
|
||||
})
|
||||
}
|
||||
// StatusItem("Connections") {
|
||||
// StatusLine("Inbound", "...")
|
||||
// StatusLine("Outbound", "...")
|
||||
// }
|
||||
}
|
||||
}.background {
|
||||
GeometryReader { geometry in
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.frame(height: 1)
|
||||
.onChangeCompat(of: geometry.size.width) { newValue in
|
||||
updateColumnCount(newValue)
|
||||
}
|
||||
.onAppear {
|
||||
updateColumnCount(geometry.size.width)
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
.frame(alignment: .topLeading)
|
||||
.padding([.top, .leading, .trailing])
|
||||
}
|
||||
.onAppear {
|
||||
commandClient.connect()
|
||||
}
|
||||
.onDisappear {
|
||||
commandClient.disconnect()
|
||||
}
|
||||
.onChangeCompat(of: scenePhase) { newValue in
|
||||
if newValue == .active {
|
||||
commandClient.connect()
|
||||
} else {
|
||||
commandClient.disconnect()
|
||||
}
|
||||
}
|
||||
.alertBinding($alert)
|
||||
}
|
||||
|
||||
private func updateColumnCount(_ width: Double) {
|
||||
|
||||
let v = Int(Int(width) / 155)
|
||||
let new = v <= 1 ? 1 : (v > 4 ? 4 : (v % 2 == 0 ? v : v - 1))
|
||||
|
||||
if new != columnCount {
|
||||
columnCount = new
|
||||
}
|
||||
|
||||
//固定只有一行显示。
|
||||
print(columnCount)
|
||||
columnCount = 1
|
||||
}
|
||||
|
||||
private struct StatusItem<T>: View where T: View {
|
||||
private let title: String
|
||||
@ViewBuilder private let content: () -> T
|
||||
|
||||
init(_ title: String, @ViewBuilder content: @escaping () -> T) {
|
||||
self.title = title
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
}.padding(.bottom, 8)
|
||||
content()
|
||||
}
|
||||
.frame(minWidth: 125, alignment: .topLeading)
|
||||
#if os(tvOS)
|
||||
.padding(EdgeInsets(top: 20, leading: 26, bottom: 20, trailing: 26))
|
||||
#else
|
||||
.padding(EdgeInsets(top: 10, leading: 13, bottom: 10, trailing: 13))
|
||||
#endif
|
||||
.background(backgroundColor.opacity(0.7))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
#if os(iOS)
|
||||
return Color(uiColor: .secondarySystemGroupedBackground)
|
||||
#elseif os(macOS)
|
||||
return Color(nsColor: .textBackgroundColor)
|
||||
#elseif os(tvOS)
|
||||
return Color(uiColor: .black)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatusLine: View {
|
||||
private let name: String
|
||||
private let value: String
|
||||
|
||||
init(_ name: String, _ value: String) {
|
||||
self.name = name
|
||||
self.value = value
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(name)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct InstallProfileButton: View {
|
||||
|
||||
private let callback: (Error?) async -> Void
|
||||
public init(_ callback: @escaping ((Error?) async -> Void)) {
|
||||
self.callback = callback
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
parsePowerButton()
|
||||
|
||||
/* FormButton {
|
||||
Task {
|
||||
await installProfile()
|
||||
}
|
||||
} label: {
|
||||
Label("安装VPN网络扩展", systemImage: "lock.doc.fill")
|
||||
.font(.title3)
|
||||
.padding(15)
|
||||
.foregroundColor(.red)
|
||||
.background(
|
||||
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(.white.opacity(0.25),lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.alertBinding($alert)*/
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
func parsePowerButton()->some View{
|
||||
|
||||
Button {
|
||||
|
||||
Task {
|
||||
await installProfile()
|
||||
}
|
||||
} label: {
|
||||
|
||||
ZStack{
|
||||
|
||||
ZStack{
|
||||
//
|
||||
// LottieView(animationFileName: "1d2a0fe5" , loopMode: .loop)
|
||||
LottieView(animationFileName: "8dfa14a6" , loopMode: .loop).scaleEffect(0.2)
|
||||
Text("开启服务").bold().font(.title2).foregroundStyle(Color.white)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
// Max Frame...
|
||||
.frame(width: 190,height: 190)
|
||||
|
||||
}.padding()
|
||||
|
||||
|
||||
}
|
||||
|
||||
private func installProfile() async {
|
||||
|
||||
print("installProfile...")
|
||||
do {
|
||||
try await ExtensionProfile.install()
|
||||
await callback(nil)
|
||||
|
||||
|
||||
} catch {
|
||||
print("installProfile: \(error.localizedDescription)")
|
||||
await callback(error)
|
||||
// alert = Alert(error)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
#if os(macOS)
|
||||
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct InstallSystemExtensionButton: View {
|
||||
@State private var alert: Alert?
|
||||
private let callback: () async -> Void
|
||||
public init(_ callback: @escaping () async -> Void) {
|
||||
self.callback = callback
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
FormButton {
|
||||
Task {
|
||||
await installSystemExtension()
|
||||
}
|
||||
} label: {
|
||||
Label("Install System Extension", systemImage: "lock.doc.fill")
|
||||
}
|
||||
.alertBinding($alert)
|
||||
}
|
||||
|
||||
private func installSystemExtension() async {
|
||||
do {
|
||||
if let result = try await SystemExtension.install() {
|
||||
if result == .willCompleteAfterReboot {
|
||||
alert = Alert(errorMessage: "Need Reboot")
|
||||
}
|
||||
}
|
||||
await callback()
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,296 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct OverviewView: View {
|
||||
@Environment(\.selection) private var selection
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@EnvironmentObject private var profile: ExtensionProfile
|
||||
@Binding private var profileList: [ProfilePreview]
|
||||
@Binding private var selectedProfileID: Int64
|
||||
@Binding private var systemProxyAvailable: Bool
|
||||
@Binding private var systemProxyEnabled: Bool
|
||||
@State private var alert: Alert?
|
||||
@State private var reasserting = false
|
||||
|
||||
private var selectedProfileIDLocal: Binding<Int64> {
|
||||
$selectedProfileID.withSetter { newValue in
|
||||
reasserting = true
|
||||
Task { [self] in
|
||||
await switchProfile(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public init(_ profileList: Binding<[ProfilePreview]>, _ selectedProfileID: Binding<Int64>, _ systemProxyAvailable: Binding<Bool>, _ systemProxyEnabled: Binding<Bool>) {
|
||||
_profileList = profileList
|
||||
_selectedProfileID = selectedProfileID
|
||||
_systemProxyAvailable = systemProxyAvailable
|
||||
_systemProxyEnabled = systemProxyEnabled
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
if profileList.isEmpty
|
||||
{
|
||||
VStack {
|
||||
|
||||
parsePowerButton()
|
||||
|
||||
}.alertBinding($alert)
|
||||
|
||||
}else{
|
||||
VStack {
|
||||
#if os(iOS) || os(tvOS)
|
||||
StartStopButton()
|
||||
#elseif os(macOS)
|
||||
StartStopButton().buttonStyle(PlainButtonStyle()) // 移除按钮的默认样式
|
||||
#endif
|
||||
|
||||
if ApplicationLibrary.inPreview || profile.status.isConnected {
|
||||
ClashModeView()
|
||||
ExtensionStatusView()
|
||||
Spacer()
|
||||
}
|
||||
}.alertBinding($alert)
|
||||
.disabled(!ApplicationLibrary.inPreview && (!profile.status.isSwitchable || reasserting))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func parsePowerButton()->some View{
|
||||
VStack{
|
||||
Button {
|
||||
|
||||
|
||||
} label: {
|
||||
|
||||
ZStack{
|
||||
|
||||
ZStack{
|
||||
|
||||
LottieView(animationFileName: "1d2a0fe5" , loopMode: .playOnce).scaleEffect(0.2)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
// Max Frame...
|
||||
.frame(width: 190,height: 190)
|
||||
.background(
|
||||
|
||||
ZStack{
|
||||
|
||||
// Rings....
|
||||
Circle()
|
||||
.trim(from: 0.3, to: 0.5)
|
||||
.stroke(
|
||||
|
||||
LinearGradient(colors: [
|
||||
|
||||
Color("gray"),
|
||||
Color("gray")
|
||||
.opacity(0.5),
|
||||
Color("gray")
|
||||
.opacity(0.3),
|
||||
Color( "gray")
|
||||
.opacity(0.1),
|
||||
|
||||
], startPoint: .leading, endPoint: .trailing),
|
||||
|
||||
style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round)
|
||||
)
|
||||
// Shadows...
|
||||
.shadow(color: Color( "gray"), radius: 5, x: 1, y: -4)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0.3, to: 0.55)
|
||||
.stroke(
|
||||
|
||||
LinearGradient(colors: [
|
||||
|
||||
Color( "gray2"),
|
||||
Color( "gray2")
|
||||
.opacity(0.5),
|
||||
Color("gray2")
|
||||
.opacity(0.3),
|
||||
Color( "gray2")
|
||||
.opacity(0.1),
|
||||
|
||||
], startPoint: .leading, endPoint: .trailing),
|
||||
|
||||
style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round)
|
||||
)
|
||||
// Shadows...
|
||||
.shadow(color: Color( "gray2"), radius: 5, x: 1, y: -4)
|
||||
.rotationEffect(.init(degrees: 160))
|
||||
|
||||
// Main Little Ring...
|
||||
Circle()
|
||||
.stroke(
|
||||
|
||||
Color("Ring1")
|
||||
.opacity(0.01),
|
||||
lineWidth: 11
|
||||
)
|
||||
// Toggling Shadow when button is Clicked...
|
||||
.shadow(color: Color("Ring2").opacity( 0), radius: 5, x: 1, y: -4)
|
||||
}
|
||||
|
||||
|
||||
)
|
||||
|
||||
}.padding().disabled(true)
|
||||
// .padding(.top,UIScreen.main.bounds.height < 750 ? 30 : 100)
|
||||
Text("正在初始化...").font(.system(size: 18, weight: .semibold)).foregroundStyle(.white)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public var bodyOld: some View {
|
||||
VStack {
|
||||
|
||||
if profileList.isEmpty {
|
||||
VStack{
|
||||
LottieView(animationFileName: "8c31d47d" , loopMode: .loop)
|
||||
} .aspectRatio(contentMode: .fill)
|
||||
.scaleEffect(0.1).frame(width: 190,height: 190).padding()
|
||||
// VStack{
|
||||
// LottieView(animationFileName: "51a05581" , loopMode: .loop)
|
||||
// }.padding()
|
||||
// Spacer()
|
||||
|
||||
//Text("Empty profiles")
|
||||
//retry download profiles
|
||||
|
||||
} else {
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
StartStopButton()
|
||||
/**
|
||||
Picker("Profile", selection: selectedProfileIDLocal, content: {
|
||||
ForEach(profileList, id: \.self) { it in
|
||||
Text(it.name).tag(it.id)
|
||||
|
||||
}
|
||||
})
|
||||
.pickerStyle(.segmented)
|
||||
.padding([.top], 8)
|
||||
|
||||
*/
|
||||
|
||||
|
||||
|
||||
// Section("Profile") {
|
||||
// Picker(selection: selectedProfileIDLocal) {
|
||||
// ForEach(profileList, id: \.id) { profile in
|
||||
// Text(profile.name).tag(profile.id)
|
||||
// }
|
||||
// } label: {}
|
||||
// .pickerStyle(.inline)
|
||||
// }
|
||||
|
||||
|
||||
#elseif os(macOS)
|
||||
StartStopButton().buttonStyle(PlainButtonStyle()) // 移除按钮的默认样式
|
||||
|
||||
/*if ApplicationLibrary.inPreview || profile.status.isConnectedStrict, systemProxyAvailable {
|
||||
Toggle("HTTP Proxy", isOn: $systemProxyEnabled)
|
||||
.onChangeCompat(of: systemProxyEnabled) { newValue in
|
||||
Task {
|
||||
await setSystemProxyEnabled(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("Profile") {
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
Picker(profile.name, selection: selectedProfileIDLocal) {
|
||||
Text("").tag(profile.id)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
}*/
|
||||
#endif
|
||||
/*
|
||||
FormView {
|
||||
#if os(iOS) || os(tvOS)
|
||||
StartStopButton()
|
||||
if ApplicationLibrary.inPreview || profile.status.isConnectedStrict, systemProxyAvailable {
|
||||
Toggle("HTTP Proxy", isOn: $systemProxyEnabled)
|
||||
.onChangeCompat(of: systemProxyEnabled) { newValue in
|
||||
Task {
|
||||
await setSystemProxyEnabled(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
// Profile订阅列表
|
||||
Section("Profile") {
|
||||
Picker(selection: selectedProfileIDLocal) {
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
Text(profile.name).tag(profile.id)
|
||||
}
|
||||
} label: {}
|
||||
.pickerStyle(.inline)
|
||||
}*/
|
||||
#elseif os(macOS)
|
||||
if ApplicationLibrary.inPreview || profile.status.isConnectedStrict, systemProxyAvailable {
|
||||
Toggle("HTTP Proxy", isOn: $systemProxyEnabled)
|
||||
.onChangeCompat(of: systemProxyEnabled) { newValue in
|
||||
Task {
|
||||
await setSystemProxyEnabled(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("Profile") {
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
Picker(profile.name, selection: selectedProfileIDLocal) {
|
||||
Text("").tag(profile.id)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
*/
|
||||
|
||||
if ApplicationLibrary.inPreview || profile.status.isConnected {
|
||||
ClashModeView()
|
||||
ExtensionStatusView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.alertBinding($alert)
|
||||
.disabled(!ApplicationLibrary.inPreview && (!profile.status.isSwitchable || reasserting))
|
||||
}
|
||||
|
||||
private func switchProfile(_ newProfileID: Int64) async {
|
||||
await SharedPreferences.selectedProfileID.set(newProfileID)
|
||||
environments.selectedProfileUpdate.send()
|
||||
if profile.status.isConnected {
|
||||
do {
|
||||
try await serviceReload()
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
reasserting = false
|
||||
}
|
||||
|
||||
private nonisolated func serviceReload() async throws {
|
||||
try LibboxNewStandaloneCommandClient()!.serviceReload()
|
||||
}
|
||||
|
||||
private nonisolated func setSystemProxyEnabled(_ isEnabled: Bool) async {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient()!.setSystemProxyEnabled(isEnabled)
|
||||
await SharedPreferences.systemProxyEnabled.set(isEnabled)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import Library
|
||||
import Lottie
|
||||
import NetworkExtension
|
||||
import SwiftUI
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct StartStopButton: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if ApplicationLibrary.inPreview {
|
||||
#if os(iOS) || os(tvOS)
|
||||
Toggle(isOn: .constant(true)) {
|
||||
Text("Enabled")
|
||||
}
|
||||
#elseif os(macOS)
|
||||
Button {} label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
#endif
|
||||
|
||||
} else if let profile = environments.extensionProfile {
|
||||
Button0().environmentObject(profile)
|
||||
} else {
|
||||
#if os(iOS) || os(tvOS)
|
||||
EmptyView()
|
||||
#elseif os(macOS)
|
||||
Button {} label: {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
}
|
||||
.disabled(true)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.disabled(environments.emptyProfiles)
|
||||
}
|
||||
|
||||
private struct Button0: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@EnvironmentObject private var profile: ExtensionProfile
|
||||
@State private var alert: Alert?
|
||||
@State var vpnStatus : NEVPNStatus = .invalid
|
||||
var body: some View {
|
||||
viewBuilder {
|
||||
#if os(iOS) || os(tvOS)
|
||||
/* Toggle(isOn: Binding(get: {
|
||||
profile.status.isConnected
|
||||
}, set: { newValue, _ in
|
||||
Task {
|
||||
await switchProfile(newValue)
|
||||
}
|
||||
})) {
|
||||
Text("Enabled")
|
||||
}*/
|
||||
VStack{
|
||||
PowerButton()
|
||||
|
||||
Label {
|
||||
if vpnStatus == .connected {
|
||||
Text("已连接")
|
||||
}else if vpnStatus == .disconnected {
|
||||
Text("断开连接")
|
||||
}else if vpnStatus == .connecting {
|
||||
Text("连接中...")
|
||||
}else if vpnStatus == .disconnecting {
|
||||
Text("断开中...")
|
||||
}else {
|
||||
Text("切换中...")
|
||||
}
|
||||
|
||||
} icon: {
|
||||
Image(systemName: vpnStatus.isConnectedStrict ? "checkmark.shield" : "shield.slash")
|
||||
}
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
// Spacer()
|
||||
|
||||
}
|
||||
|
||||
#elseif os(macOS)
|
||||
|
||||
VStack{
|
||||
PowerButton().background(Color.clear)
|
||||
|
||||
Label {
|
||||
if vpnStatus == .connected {
|
||||
Text("已连接")
|
||||
}else if vpnStatus == .disconnected {
|
||||
Text("断开连接")
|
||||
}else if vpnStatus == .connecting {
|
||||
Text("连接中...")
|
||||
}else if vpnStatus == .disconnecting {
|
||||
Text("断开中...")
|
||||
}else {
|
||||
Text("切换中...")
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: vpnStatus.isConnectedStrict ? "checkmark.shield" : "shield.slash")
|
||||
}
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
//Spacer()
|
||||
|
||||
}
|
||||
|
||||
/*Button {
|
||||
Task {
|
||||
await switchProfile(!profile.status.isConnectedStrict)
|
||||
}
|
||||
} label: {
|
||||
if !profile.status.isConnected {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
} else {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
}
|
||||
*/
|
||||
#endif
|
||||
}
|
||||
.disabled(!profile.status.isEnabled)
|
||||
.alertBinding($alert)
|
||||
.onAppear {
|
||||
Task{
|
||||
vpnStatus = profile.status //.isConnected
|
||||
}
|
||||
}.onChangeCompat(of: profile.status) { newStatus in
|
||||
|
||||
vpnStatus = newStatus
|
||||
// if newStatus == .connected {
|
||||
// Task {
|
||||
// await doReloadSystemProxy()
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func PowerButton()->some View{
|
||||
|
||||
Button {
|
||||
|
||||
Task {
|
||||
if self.vpnStatus.isConnected {
|
||||
await switchProfile(false)
|
||||
}else{
|
||||
await switchProfile(true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
} label: {
|
||||
|
||||
ZStack{
|
||||
|
||||
ZStack{
|
||||
#if os(iOS) || os(tvOS)
|
||||
#endif
|
||||
if vpnStatus == .connected {
|
||||
LottieView(animationFileName: "51a05581" , loopMode: .playOnce).scaleEffect(0.5)
|
||||
}else if (vpnStatus == .connecting || vpnStatus == .disconnecting) {
|
||||
LottieView(animationFileName: "65ea130a" , loopMode: .loop)
|
||||
} else{
|
||||
LottieView(animationFileName: "1d2a0fe5" , loopMode: .playOnce).scaleEffect(0.2)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
// Max Frame...
|
||||
.frame(width: 190,height: 190)
|
||||
.background(
|
||||
|
||||
ZStack{
|
||||
|
||||
// Rings....
|
||||
Circle()
|
||||
.trim(from: vpnStatus.isConnected ? 0 : 0.3, to: vpnStatus.isConnected ? 1 : 0.5)
|
||||
.stroke(
|
||||
|
||||
LinearGradient(colors: [
|
||||
|
||||
Color(vpnStatus.isConnected ? "Ring1" : "gray"),
|
||||
Color(vpnStatus.isConnected ? "Ring1" : "gray")
|
||||
.opacity(0.5),
|
||||
Color(vpnStatus.isConnected ? "Ring1" : "gray")
|
||||
.opacity(0.3),
|
||||
Color(vpnStatus.isConnected ? "Ring1" : "gray")
|
||||
.opacity(0.1),
|
||||
|
||||
], startPoint: .leading, endPoint: .trailing),
|
||||
|
||||
style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round)
|
||||
)
|
||||
// Shadows...
|
||||
.shadow(color: Color(vpnStatus.isConnected ? "Ring1" : "gray"), radius: 5, x: 1, y: -4)
|
||||
|
||||
Circle()
|
||||
.trim(from: vpnStatus.isConnected ? 0 : 0.3, to: vpnStatus.isConnected ? 1 : 0.55)
|
||||
.stroke(
|
||||
|
||||
LinearGradient(colors: [
|
||||
|
||||
Color(vpnStatus.isConnected ? "Ring2" : "gray2"),
|
||||
Color(vpnStatus.isConnected ? "Ring2" : "gray2")
|
||||
.opacity(0.5),
|
||||
Color(vpnStatus.isConnected ? "Ring2" : "gray2")
|
||||
.opacity(0.3),
|
||||
Color(vpnStatus.isConnected ? "Ring2" : "gray2")
|
||||
.opacity(0.1),
|
||||
|
||||
], startPoint: .leading, endPoint: .trailing),
|
||||
|
||||
style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round)
|
||||
)
|
||||
// Shadows...
|
||||
.shadow(color: Color(vpnStatus.isConnected ? "Ring2" : "gray2"), radius: 5, x: 1, y: -4)
|
||||
.rotationEffect(.init(degrees: 160))
|
||||
|
||||
// Main Little Ring...
|
||||
Circle()
|
||||
.stroke(
|
||||
|
||||
Color("Ring1")
|
||||
.opacity(0.01),
|
||||
lineWidth: 11
|
||||
)
|
||||
// Toggling Shadow when button is Clicked...
|
||||
.shadow(color: Color("Ring2").opacity(vpnStatus.isConnected ? 0.04 : 0), radius: 5, x: 1, y: -4)
|
||||
}
|
||||
|
||||
|
||||
)
|
||||
|
||||
}.padding()
|
||||
// .padding(.top,UIScreen.main.bounds.height < 750 ? 30 : 100)
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
private nonisolated func switchProfile(_ isEnabled: Bool) async {
|
||||
do {
|
||||
if isEnabled {
|
||||
try await profile.start()
|
||||
await environments.logClient.connect()
|
||||
|
||||
} else {
|
||||
try await profile.stop()
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*private nonisolated func doReloadSystemProxy() async {
|
||||
do {
|
||||
|
||||
let status = try LibboxNewStandaloneCommandClient()!.getSystemProxyStatus()
|
||||
await MainActor.run {
|
||||
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
struct LottieView: UIViewRepresentable {
|
||||
|
||||
var animationFileName: String
|
||||
let loopMode: LottieLoopMode
|
||||
|
||||
func updateUIView(_ uiView: UIViewType, context: Context) {
|
||||
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> Lottie.LottieAnimationView {
|
||||
let animationView = LottieAnimationView(name: animationFileName)
|
||||
animationView.loopMode = loopMode
|
||||
animationView.play()
|
||||
animationView.contentMode = .scaleAspectFit
|
||||
|
||||
return animationView
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
struct LottieView: NSViewRepresentable {
|
||||
|
||||
var animationFileName: String
|
||||
let loopMode: LottieLoopMode
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
let animationView = LottieAnimationView(name: animationFileName)
|
||||
animationView.loopMode = loopMode
|
||||
animationView.play()
|
||||
animationView.contentMode = .scaleAspectFit
|
||||
|
||||
// Add the Lottie animation view as a subview of the NSView
|
||||
animationView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(animationView)
|
||||
|
||||
// Set up constraints to fill the parent view
|
||||
NSLayoutConstraint.activate([
|
||||
animationView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
animationView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
animationView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
animationView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
])
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
// Optionally implement updates to the NSView
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// XufeiTixingButton.swift
|
||||
// ApplicationLibrary
|
||||
//
|
||||
// Created by Mac on 2024/10/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
//
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct XufeiTixingButton: View {
|
||||
|
||||
private let callback: () async -> Void
|
||||
public init(_ callback: @escaping (() async -> Void)) {
|
||||
self.callback = callback
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
parsePowerButton()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
func parsePowerButton()->some View{
|
||||
|
||||
Button {
|
||||
|
||||
Task {
|
||||
await callback()
|
||||
}
|
||||
} label: {
|
||||
|
||||
ZStack{
|
||||
|
||||
ZStack{
|
||||
//
|
||||
// LottieView(animationFileName: "1d2a0fe5" , loopMode: .loop)
|
||||
LottieView(animationFileName: "8dfa14a6" , loopMode: .loop).scaleEffect(0.2)
|
||||
Text("开启服务").bold().font(.title2).foregroundStyle(Color.white)
|
||||
//LottieView(animationFileName: "8dfa14a6" , loopMode: .loop).scaleEffect(0.5)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
// Max Frame...
|
||||
.frame(width: 190,height: 190)
|
||||
|
||||
}.padding()
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
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 importRemoteProfileKey: EnvironmentKey {
|
||||
static var defaultValue: Binding<LibboxImportRemoteProfile?> = .constant(nil)
|
||||
}
|
||||
|
||||
var importRemoteProfile: Binding<LibboxImportRemoteProfile?> {
|
||||
get {
|
||||
self[importRemoteProfileKey.self]
|
||||
}
|
||||
set {
|
||||
self[importRemoteProfileKey.self] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private struct importProfileKey: EnvironmentKey {
|
||||
static var defaultValue: Binding<LibboxProfileContent?> = .constant(nil)
|
||||
}
|
||||
|
||||
var importProfile: Binding<LibboxProfileContent?> {
|
||||
get {
|
||||
self[importProfileKey.self]
|
||||
}
|
||||
set {
|
||||
self[importProfileKey.self] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import Libbox
|
||||
import Network
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
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 alert: Alert?
|
||||
|
||||
|
||||
/* old style
|
||||
public var body: some View {
|
||||
Button {
|
||||
if group.selectable, group.selected != item.tag {
|
||||
Task {
|
||||
await selectOutbound()
|
||||
UserDefaults.standard.setValue("\(item.tag)", forKey: "selectedNode")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
VStack {
|
||||
HStack {
|
||||
Text(item.tag)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.foreground)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
HStack {
|
||||
Text(item.displayType)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
VStack {
|
||||
if group.selected == item.tag {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if item.urlTestDelay > 0 {
|
||||
Text(item.delayString)
|
||||
.font(.caption)
|
||||
.foregroundColor(item.delayColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.buttonStyle(.borderless)
|
||||
.padding(EdgeInsets(top: 10, leading: 13, bottom: 10, trailing: 13))
|
||||
.background(backgroundColor)
|
||||
.cornerRadius(10)
|
||||
#endif
|
||||
.alertBinding($alert)
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
func testPing(to server: String, port: Int, completion: @escaping (TimeInterval?) -> Void) {
|
||||
let startTime = Date()
|
||||
|
||||
let connection = NWConnection(host: NWEndpoint.Host(server), port: NWEndpoint.Port(rawValue: UInt16(port))!, using: .tcp)
|
||||
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
// 连接成功,计算响应时间
|
||||
let pingTime = Date().timeIntervalSince(startTime)
|
||||
completion(pingTime)
|
||||
connection.cancel() // 连接完成后取消
|
||||
case .failed(_):
|
||||
completion(nil)
|
||||
connection.cancel()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 启动连接
|
||||
connection.start(queue: .global())
|
||||
}
|
||||
|
||||
|
||||
|
||||
public var body: some View {
|
||||
Button {
|
||||
print("\(group.selectable) group.selected - item.tag: >>> "+group.selected + " " + item.tag)
|
||||
// print(item.toString)
|
||||
|
||||
if group.selectable, group.selected != item.tag {
|
||||
|
||||
|
||||
// var item = _group.wrappedValue.items[index] // Make a mutable copy of the item
|
||||
|
||||
Task {
|
||||
|
||||
|
||||
if let server = item.server ,let port = item.server_port{
|
||||
//说明是第一次查看节点列表 然后 点击列表
|
||||
|
||||
testPing(to: server, port: port) { pingTime in
|
||||
if let ping = pingTime {
|
||||
print("Ping 时间: \(ping) 秒")
|
||||
|
||||
Task{
|
||||
await pingOutbound(tag: item.tag,pingtime: UInt16((pingTime ?? 0)*1000))
|
||||
}
|
||||
|
||||
|
||||
} else {
|
||||
print("连接超时")
|
||||
}
|
||||
}
|
||||
|
||||
UserDefaults.standard.setValue("\(item.tag)", forKey: "selectedNode")
|
||||
await selectOutbound()
|
||||
|
||||
}else{
|
||||
|
||||
await selectOutbound()
|
||||
|
||||
UserDefaults.standard.setValue("\(item.tag)", forKey: "selectedNode")
|
||||
|
||||
//切换节点后重启服务
|
||||
|
||||
await restartVPNServer()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: 2) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
// Image(server.name)
|
||||
// .resizable()
|
||||
// .aspectRatio(contentMode: .fit)
|
||||
// .frame(width: 20, height: 20)
|
||||
|
||||
Text(item.tag)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Label {
|
||||
Text(item.type)
|
||||
// Text(item.urlTestDelay <= 3000 ? "在线可用" : "不可用")
|
||||
} icon: {
|
||||
// Image(systemName: item.urlTestDelay <= 3000 ? "checkmark" : "xmark")
|
||||
} .foregroundColor(.gray)
|
||||
// .foregroundColor(item.urlTestDelay <= 3000 ?.green : .red)
|
||||
.font(.caption2)
|
||||
}
|
||||
|
||||
Spacer(minLength: 10)
|
||||
|
||||
// Change Server Button...
|
||||
|
||||
VStack {
|
||||
if group.selected == item.tag {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if item.urlTestDelay > 0 {
|
||||
Text(item.delayString)
|
||||
.font(.caption)
|
||||
.foregroundColor(item.delayColor)
|
||||
}else{
|
||||
//正在测速
|
||||
Text("...")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 50)
|
||||
.padding(.horizontal)
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.buttonStyle(.borderless)
|
||||
// .padding(EdgeInsets(top: 10, leading: 13, bottom: 10, trailing: 13))
|
||||
// .background(backgroundColor)
|
||||
// .cornerRadius(10)
|
||||
#endif
|
||||
.alertBinding($alert)
|
||||
}
|
||||
|
||||
|
||||
private nonisolated func pingOutbound(tag: String,pingtime: UInt16) async {
|
||||
let newGroup = await group
|
||||
|
||||
let newitems = newGroup.items.map { item in
|
||||
var mutableItem = item
|
||||
if (item.tag == tag) {
|
||||
mutableItem.urlTestDelay = pingtime
|
||||
}
|
||||
return mutableItem
|
||||
}
|
||||
|
||||
let newOut = OutboundGroup(tag: newGroup.tag, type: newGroup.type, selected:tag, selectable: newGroup.selectable, isExpand: newGroup.isExpand, items: newitems)
|
||||
|
||||
await MainActor.run { [newOut] in
|
||||
_group.wrappedValue = newOut
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private nonisolated func selectOutbound() async {
|
||||
do {
|
||||
try await LibboxNewStandaloneCommandClient()!.selectOutbound(group.tag, outboundTag: item.tag)
|
||||
var newGroup = await group
|
||||
newGroup.selected = item.tag
|
||||
await MainActor.run { [newGroup] in
|
||||
_group.wrappedValue = newGroup
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func setSystemProxyEnabled(_ isEnabled: Bool) async {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient()!.setSystemProxyEnabled(isEnabled)
|
||||
await SharedPreferences.systemProxyEnabled.set(isEnabled)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private nonisolated func restartVPNServer() async {
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient()!.serviceReload()
|
||||
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
#if os(iOS)
|
||||
return Color(uiColor: .secondarySystemGroupedBackground)
|
||||
#elseif os(macOS)
|
||||
return Color(nsColor: .textBackgroundColor)
|
||||
#elseif os(tvOS)
|
||||
return Color.black
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
|
||||
|
||||
public struct GroupListView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var isLoading = true
|
||||
@StateObject private var commandClient = CommandClient(.groups)
|
||||
@State private var groups: [OutboundGroup] = []
|
||||
|
||||
public init() {
|
||||
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack {
|
||||
if isLoading {
|
||||
Spacer().frame(height: 20)
|
||||
Text("请开启VPN后查看节点数据").foregroundColor(.red)
|
||||
} else if !groups.isEmpty {
|
||||
ScrollView {
|
||||
VStack {
|
||||
|
||||
ForEach(groups, id: \.hashValue) { it in
|
||||
GroupView(it)
|
||||
}
|
||||
}.padding() //.background(Color.gray.opacity(0.1))
|
||||
}
|
||||
} else {
|
||||
Spacer().frame(height: 20)
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
connect()
|
||||
}
|
||||
.onDisappear {
|
||||
commandClient.disconnect()
|
||||
}
|
||||
.onChangeCompat(of: scenePhase) { newValue in
|
||||
if newValue == .active {
|
||||
commandClient.connect()
|
||||
} else {
|
||||
commandClient.disconnect()
|
||||
}
|
||||
}
|
||||
.onReceive(commandClient.$groups, perform: { groups in
|
||||
if let groups {
|
||||
setGroups(groups)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func connect() {
|
||||
if ApplicationLibrary.inPreview {
|
||||
groups = [
|
||||
OutboundGroup(tag: "my_group", type: "selector", selected: "server", selectable: true, isExpand: true, items: [
|
||||
OutboundGroupItem(tag: "server", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 12),
|
||||
OutboundGroupItem(tag: "server2", type: "WireGuard", urlTestTime: .now, urlTestDelay: 34),
|
||||
OutboundGroupItem(tag: "auto", type: "URLTest", urlTestTime: .now, urlTestDelay: 100),
|
||||
]),
|
||||
OutboundGroup(tag: "group2", type: "urltest", selected: "client", selectable: true, isExpand: false, items:
|
||||
(0 ..< 234).map { index in
|
||||
OutboundGroupItem(tag: "client\(index)", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: UInt16(100 + index * 10))
|
||||
}),
|
||||
]
|
||||
isLoading = false
|
||||
} else {
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
groups = [
|
||||
OutboundGroup(tag: "my_group", type: "selector", selected: "server", selectable: true, isExpand: true, items: [
|
||||
OutboundGroupItem(tag: "🇭🇰🇭🇰Hong Kong 01", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 12),
|
||||
OutboundGroupItem(tag: "🇭🇰🇭🇰Hong Kong 03", type: "WireGuard", urlTestTime: .now, urlTestDelay: 34),
|
||||
OutboundGroupItem(tag: "🇭🇰🇭🇰Hong Kong 04", type: "URLTest", urlTestTime: .now, urlTestDelay: 100),
|
||||
OutboundGroupItem(tag: "🇯🇵🇯🇵🇯🇵Japan 01", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 12),
|
||||
OutboundGroupItem(tag: "🇯🇵🇯🇵Japan Kong 01", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 112),
|
||||
OutboundGroupItem(tag: "🇯🇵🇯🇵Japan Kong 02", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 1222),
|
||||
OutboundGroupItem(tag: "🇯🇵🇯🇵Japan Kong 03", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 4412),
|
||||
OutboundGroupItem(tag: "🇯🇵🇯🇵Japan Kong 04", type: "Shadowsocks", urlTestTime: .now, urlTestDelay: 512),
|
||||
]),
|
||||
]
|
||||
isLoading = false
|
||||
#endif
|
||||
commandClient.connect()
|
||||
}
|
||||
}
|
||||
|
||||
private func setGroups(_ goGroups: [LibboxOutboundGroup]) {
|
||||
|
||||
var groups = [OutboundGroup]()
|
||||
if let goGroup = goGroups.first {
|
||||
goGroup.isExpand = true
|
||||
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, isExpand: goGroup.isExpand, items: items))
|
||||
UserDefaults.standard.setValue("\(goGroup.tag)", forKey: "goGrouptag")
|
||||
}
|
||||
|
||||
/* 原有的全部显示逻辑
|
||||
var groups = [OutboundGroup]()
|
||||
for goGroup in goGroups {
|
||||
print(goGroup.tag)
|
||||
//默认设置为开启
|
||||
goGroup.isExpand = true
|
||||
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, isExpand: goGroup.isExpand, items: items))
|
||||
}*/
|
||||
self.groups = groups
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct GroupView: View {
|
||||
@State private var group: OutboundGroup
|
||||
@State private var geometryWidth: CGFloat = 300
|
||||
@State private var alert: Alert?
|
||||
|
||||
|
||||
public init(_ group: OutboundGroup) {
|
||||
_group = State(initialValue: group)
|
||||
}
|
||||
|
||||
private var title: some View {
|
||||
HStack {
|
||||
// Text(group.tag)
|
||||
// .font(.headline)
|
||||
//Text()
|
||||
Text(group.tag)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(group.items.count)")
|
||||
.font(.subheadline)
|
||||
.padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4))
|
||||
.background(Color.gray.opacity(0.5))
|
||||
.cornerRadius(4)
|
||||
Button {
|
||||
group.isExpand = !group.isExpand
|
||||
Task {
|
||||
await setGroupExpand()
|
||||
}
|
||||
} label: {
|
||||
HStack{
|
||||
|
||||
if group.isExpand {
|
||||
Image(systemName: "arrow.down.to.line")
|
||||
Text("收起").font(.subheadline)
|
||||
} else {
|
||||
Image(systemName: "arrow.up.to.line")
|
||||
Text("展开").font(.subheadline)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
#if os(macOS) || os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
Button {
|
||||
Task {
|
||||
await doURLTest()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "bolt.fill")
|
||||
Text("测速").font(.subheadline)
|
||||
}
|
||||
#if os(macOS) || os(tvOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
.alertBinding($alert)
|
||||
.padding([.top, .bottom], 10)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Section {
|
||||
if group.isExpand {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()),
|
||||
count: explandColumnCount()))
|
||||
{
|
||||
ForEach(group.items, id: \.tag) { it in
|
||||
GroupItemView($group, it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 5) {
|
||||
ForEach(Array(itemGroups.enumerated()), id: \.offset) { items in
|
||||
HStack(spacing: 5) {
|
||||
ForEach(items.element, id: \.tag) { it in
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(it.delayColor)
|
||||
if it.tag == group.selected {
|
||||
Rectangle()
|
||||
.fill(Color.white)
|
||||
#if !os(tvOS)
|
||||
.frame(width: 5, height: 5)
|
||||
#else
|
||||
.frame(width: 15, height: 15)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.frame(width: 10, height: 10)
|
||||
#else
|
||||
.frame(width: 30, height: 30)
|
||||
#endif
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
//title .frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.background {
|
||||
/* GeometryReader { geometry in
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.frame(height: 1)
|
||||
.onChangeCompat(of: geometry.size.width) { newValue in
|
||||
geometryWidth = newValue
|
||||
}
|
||||
.onAppear {
|
||||
geometryWidth = geometry.size.width
|
||||
}
|
||||
}.padding() */
|
||||
}.padding(.leading,-18.0)
|
||||
}
|
||||
|
||||
private var itemGroups: [[OutboundGroupItem]] {
|
||||
let count: Int
|
||||
#if os(tvOS)
|
||||
count = Int(Int(geometryWidth) / 40)
|
||||
#else
|
||||
count = Int(Int(geometryWidth) / 20)
|
||||
#endif
|
||||
if count == 0 {
|
||||
return [group.items]
|
||||
} else {
|
||||
return group.items.chunked(
|
||||
into: count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func explandColumnCount() -> Int {
|
||||
let standardCount = Int(Int(geometryWidth) / 180)
|
||||
#if os(iOS)
|
||||
return standardCount < 2 ? 1 : standardCount
|
||||
#elseif os(tvOS)
|
||||
return 4
|
||||
#else
|
||||
return standardCount < 1 ? 1 : standardCount
|
||||
#endif
|
||||
}
|
||||
|
||||
private nonisolated func doURLTest() async {
|
||||
do {
|
||||
try await LibboxNewStandaloneCommandClient()!.urlTest(group.tag)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func setGroupExpand() async {
|
||||
do {
|
||||
try await LibboxNewStandaloneCommandClient()!.setGroupExpand(group.tag, isExpand: group.isExpand)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,12 @@
|
||||
//
|
||||
// NodesListView.swift
|
||||
// ApplicationLibrary
|
||||
//
|
||||
// Created by Mac on 2024/10/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
//NodesListView
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import SwiftUI
|
||||
|
||||
public struct OutboundGroup: Codable {
|
||||
let tag: String
|
||||
let type: String
|
||||
var selected: String
|
||||
let selectable: Bool
|
||||
var isExpand: 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 init(tag: String, type: String, selected: String, selectable: Bool,isExpand: Bool,items: [OutboundGroupItem] ) {
|
||||
self.tag = tag
|
||||
self.type = type
|
||||
self.selected = selected
|
||||
self.selectable = selectable
|
||||
self.isExpand = isExpand
|
||||
self.items = items
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
public extension OutboundGroup {
|
||||
|
||||
var newhashValue: 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 {
|
||||
LibboxProxyDisplayType(type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import SwiftUI
|
||||
|
||||
public struct OutboundGroupItem: Codable {
|
||||
public let tag: String
|
||||
public let type: String
|
||||
|
||||
public let urlTestTime: Date
|
||||
public var urlTestDelay: UInt16
|
||||
|
||||
|
||||
public var server: String? = ""
|
||||
public var server_port: Int? = 0
|
||||
public var method: String? = ""
|
||||
public var password: String? = ""
|
||||
|
||||
//server: String?,server_port: String?,method: String?,serverpassword String?
|
||||
|
||||
// 手动定义初始化器
|
||||
public init(tag: String, type: String, urlTestTime: Date, urlTestDelay: UInt16) {
|
||||
self.tag = tag
|
||||
self.type = type
|
||||
self.urlTestTime = urlTestTime
|
||||
self.urlTestDelay = urlTestDelay
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
public extension OutboundGroupItem {
|
||||
|
||||
mutating func setpingms(pingDelay: UInt16){
|
||||
urlTestDelay = pingDelay
|
||||
}
|
||||
|
||||
var toString: String {
|
||||
"\(tag) - \(type) - \(server ?? "") - \(server_port ?? 0) - \(method ?? "") - \(password ?? "") "
|
||||
}
|
||||
var displayType: String {
|
||||
LibboxProxyDisplayType(type)
|
||||
}
|
||||
|
||||
var delayString: String {
|
||||
"\(urlTestDelay)ms"
|
||||
}
|
||||
|
||||
var delayColor: Color {
|
||||
switch urlTestDelay {
|
||||
case 0:
|
||||
return .gray
|
||||
case ..<400:
|
||||
return .green
|
||||
case ..<800:
|
||||
return .yellow
|
||||
case 800 ..< 1500:
|
||||
return .orange
|
||||
default:
|
||||
return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct LogView: View {
|
||||
@Environment(\.selection) private var selection
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
LogView0().environmentObject(environments.logClient)
|
||||
}
|
||||
|
||||
private struct LogView0: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@EnvironmentObject private var logClient: CommandClient
|
||||
private let logFont = Font.system(.caption2, design: .monospaced)
|
||||
|
||||
var body: some View {
|
||||
if ApplicationLibrary.inPreview {
|
||||
let logList = [
|
||||
"(packet-tunnel) log server started",
|
||||
"INFO[0000] router: loaded geoip database: 250 codes",
|
||||
"INFO[0000] router: loaded geosite database: 1400 codes",
|
||||
"INFO[0000] router: updated default interface en0, index 11",
|
||||
"inbound/tun[0]: started at utun3",
|
||||
"sing-box started (1.666s)",
|
||||
]
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(logList.enumerated()), id: \.offset) { it in
|
||||
Text(it.element)
|
||||
.font(logFont)
|
||||
#if os(tvOS)
|
||||
.focusable()
|
||||
#endif
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding()
|
||||
}
|
||||
#if os(tvOS)
|
||||
.focusEffectDisabled()
|
||||
.focusSection()
|
||||
#endif
|
||||
} else if logClient.logList.isEmpty {
|
||||
VStack {
|
||||
if logClient.isConnected {
|
||||
Text("Empty logs")
|
||||
} else {
|
||||
Text("Service not started").onAppear {
|
||||
environments.connectLog()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ScrollViewReader { reader in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(logClient.logList.enumerated()), id: \.offset) { it in
|
||||
Text(it.element)
|
||||
.font(logFont)
|
||||
#if os(tvOS)
|
||||
.focusable()
|
||||
#endif
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
|
||||
.onChangeCompat(of: logClient.logList.count) { newCount in
|
||||
withAnimation {
|
||||
reader.scrollTo(newCount - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding()
|
||||
}
|
||||
#if os(tvOS)
|
||||
.focusEffectDisabled()
|
||||
.focusSection()
|
||||
#endif
|
||||
.onAppear {
|
||||
reader.scrollTo(logClient.logList.count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public enum NavigationPage: Int, CaseIterable, Identifiable {
|
||||
public var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
case dashboard
|
||||
#if os(macOS)
|
||||
case groups
|
||||
#endif
|
||||
case logs
|
||||
case profiles
|
||||
case settings
|
||||
}
|
||||
|
||||
public extension NavigationPage {
|
||||
#if os(macOS)
|
||||
static var macosDefaultPages: [NavigationPage] {
|
||||
[.logs, .profiles, .settings]
|
||||
}
|
||||
#endif
|
||||
|
||||
var label: some View {
|
||||
Label(title, systemImage: iconImage)
|
||||
.tint(.textColor)
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .dashboard:
|
||||
return NSLocalizedString("Dashboard", comment: "")
|
||||
#if os(macOS)
|
||||
case .groups:
|
||||
return NSLocalizedString("Groups", comment: "")
|
||||
#endif
|
||||
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"
|
||||
#if os(macOS)
|
||||
case .groups:
|
||||
return "rectangle.3.group.fill"
|
||||
#endif
|
||||
case .logs:
|
||||
return "doc.text.fill"
|
||||
case .profiles:
|
||||
return "list.bullet.rectangle.fill"
|
||||
case .settings:
|
||||
return "gear.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var contentView: some View {
|
||||
viewBuilder {
|
||||
switch self {
|
||||
case .dashboard:
|
||||
|
||||
DashboardView()
|
||||
#if os(macOS)
|
||||
case .groups:
|
||||
GroupListView()
|
||||
#endif
|
||||
case .logs:
|
||||
LogView()
|
||||
case .profiles:
|
||||
ProfileView()
|
||||
case .settings:
|
||||
SettingView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
#if os(iOS)
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var contentViewMac: some View{
|
||||
|
||||
viewBuilder {
|
||||
DashboardView().frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
//DashboardViewMac().frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
func visible(_ profile: ExtensionProfile?) -> Bool {
|
||||
switch self {
|
||||
case .groups:
|
||||
return profile?.status.isConnectedStrict == true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
#if os(iOS) || os(macOS)
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct EditProfileContentView: View {
|
||||
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 = ""
|
||||
@State private var isChanged = false
|
||||
@State private var alert: Alert?
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task {
|
||||
await loadContent()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
viewBuilder {
|
||||
if readOnly {
|
||||
TextEditor(text: .constant(profileContent))
|
||||
} else {
|
||||
TextEditor(text: $profileContent)
|
||||
}
|
||||
}
|
||||
.font(Font.system(.caption2, design: .monospaced))
|
||||
.autocorrectionDisabled(true)
|
||||
// https://stackoverflow.com/questions/66721935/swiftui-how-to-disable-the-smart-quotes-in-texteditor
|
||||
// https://stackoverflow.com/questions/74034171/textfield-with-autocorrectiondisabled-still-shows-predictive-text-bar
|
||||
.textContentType(.init(rawValue: ""))
|
||||
#if os(iOS)
|
||||
.keyboardType(.asciiCapable)
|
||||
.textInputAutocapitalization(.none)
|
||||
.background(Color(UIColor.secondarySystemGroupedBackground))
|
||||
#elseif os(macOS)
|
||||
.padding()
|
||||
#endif
|
||||
.onChangeCompat(of: profileContent) {
|
||||
isChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.alertBinding($alert)
|
||||
.navigationTitle(navigationTitle)
|
||||
#if os(macOS)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigation) {
|
||||
if !readOnly {
|
||||
Button {
|
||||
Task {
|
||||
await saveContent()
|
||||
}
|
||||
} label: {
|
||||
Label("Save", image: "save")
|
||||
}
|
||||
.disabled(!isChanged)
|
||||
} else {
|
||||
Button {
|
||||
NSPasteboard.general.setString(profileContent, forType: .fileContents)
|
||||
} label: {
|
||||
Label("Copy", systemImage: "clipboard.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if !readOnly {
|
||||
Button("Save") {
|
||||
Task {
|
||||
await saveContent()
|
||||
}
|
||||
}.disabled(!isChanged)
|
||||
} else {
|
||||
Button("Copy") {
|
||||
UIPasteboard.general.string = profileContent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var navigationTitle: String {
|
||||
if readOnly {
|
||||
return "View Content"
|
||||
} else {
|
||||
return "Edit Content"
|
||||
}
|
||||
}
|
||||
|
||||
private func loadContent() async {
|
||||
do {
|
||||
try await loadContentBackground()
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private nonisolated func loadContentBackground() async throws {
|
||||
guard let profileID else {
|
||||
throw NSError(domain: "Context destroyed", code: 0)
|
||||
}
|
||||
guard let profile = try await ProfileManager.get(profileID) else {
|
||||
throw NSError(domain: "Profile missing", code: 0)
|
||||
}
|
||||
let profileContent = try profile.read()
|
||||
await MainActor.run {
|
||||
self.profile = profile
|
||||
self.profileContent = profileContent
|
||||
}
|
||||
}
|
||||
|
||||
private func saveContent() async {
|
||||
guard let profile else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await saveContentBackground(profile)
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
return
|
||||
}
|
||||
isChanged = false
|
||||
}
|
||||
|
||||
private nonisolated func saveContentBackground(_ profile: Profile) async throws {
|
||||
try await profile.write(profileContent)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,177 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct EditProfileView: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var profile: Profile
|
||||
|
||||
@State private var isLoading = false
|
||||
@State private var isChanged = false
|
||||
@State private var alert: Alert?
|
||||
@State private var shareLinkPresented = false
|
||||
@State private var shareLinkText: String?
|
||||
|
||||
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)
|
||||
FormItem("Auto Update Interval") {
|
||||
TextField("Auto Update Interval", text: $profile.autoUpdateInterval.stringBinding(defaultValue: 60), prompt: Text("In Minutes"))
|
||||
.multilineTextAlignment(.trailing)
|
||||
#if os(iOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
if profile.type == .remote {
|
||||
Section("Status") {
|
||||
FormTextItem("Last Updated", profile.lastUpdatedString)
|
||||
}
|
||||
}
|
||||
Section("Action") {
|
||||
if profile.type != .remote {
|
||||
#if os(iOS) || os(macOS)
|
||||
FormNavigationLink {
|
||||
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
|
||||
} label: {
|
||||
Label("Edit Content", systemImage: "pencil")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
#if os(iOS) || os(macOS)
|
||||
FormNavigationLink {
|
||||
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
|
||||
} label: {
|
||||
Label("View Content", systemImage: "doc.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
#endif
|
||||
FormButton {
|
||||
isLoading = true
|
||||
Task {
|
||||
await updateProfile()
|
||||
}
|
||||
} label: {
|
||||
Label("Update", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.foregroundColor(.accentColor)
|
||||
.disabled(isLoading)
|
||||
}
|
||||
FormButton(role: .destructive) {
|
||||
Task {
|
||||
await deleteProfile()
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash.fill")
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: profile.name) {
|
||||
isChanged = true
|
||||
}
|
||||
.onChangeCompat(of: profile.remoteURL) {
|
||||
isChanged = true
|
||||
}
|
||||
.onChangeCompat(of: profile.autoUpdate) {
|
||||
isChanged = true
|
||||
}
|
||||
.disabled(isLoading)
|
||||
#if os(macOS)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigation) {
|
||||
Button {
|
||||
isLoading = true
|
||||
Task {
|
||||
await saveProfile()
|
||||
}
|
||||
} label: {
|
||||
Image("save", bundle: ApplicationLibrary.bundle, label: Text("Save"))
|
||||
}
|
||||
.disabled(isLoading || !isChanged)
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
isLoading = true
|
||||
Task {
|
||||
await saveProfile()
|
||||
}
|
||||
}.disabled(!isChanged)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.alertBinding($alert)
|
||||
.navigationTitle("Edit Profile")
|
||||
}
|
||||
|
||||
private func updateProfile() async {
|
||||
defer {
|
||||
isLoading = false
|
||||
}
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: UInt64(100 * Double(NSEC_PER_MSEC)))
|
||||
try await profile.updateRemoteProfile()
|
||||
environments.profileUpdate.send()
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteProfile() async {
|
||||
do {
|
||||
try await ProfileManager.delete(profile)
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
return
|
||||
}
|
||||
environments.profileUpdate.send()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func saveProfile() async {
|
||||
do {
|
||||
_ = try await ProfileManager.update(profile)
|
||||
#if os(iOS) || os(tvOS)
|
||||
try await UIProfileUpdateTask.configure()
|
||||
#else
|
||||
try await ProfileUpdateTask.configure()
|
||||
#endif
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
return
|
||||
}
|
||||
isChanged = false
|
||||
isLoading = false
|
||||
environments.profileUpdate.send()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
#if os(tvOS)
|
||||
|
||||
import DeviceDiscoveryUI
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct ImportProfileView: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isLoading = false
|
||||
@State private var selected = false
|
||||
@State private var alert: Alert?
|
||||
@State private var connection: NWConnection?
|
||||
@State private var socket: NWSocket?
|
||||
@State private var profiles: [LibboxProfilePreview]?
|
||||
@State private var isImporting = false
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
VStack(alignment: .center) {
|
||||
if !selected {
|
||||
Form {
|
||||
Section {
|
||||
EmptyView()
|
||||
} footer: {
|
||||
Text("To import configurations from your iPhone or iPad, make sure sing-box is the **same version** on both devices and **VPN is disabled**.")
|
||||
}
|
||||
|
||||
DevicePicker(
|
||||
.applicationService(name: "sing-box:profile"))
|
||||
{ endpoint in
|
||||
selected = true
|
||||
Task {
|
||||
await handleEndpoint(endpoint)
|
||||
}
|
||||
} label: {
|
||||
Text("Select Device")
|
||||
} fallback: {
|
||||
EmptyView()
|
||||
} parameters: {
|
||||
.applicationService
|
||||
}
|
||||
}
|
||||
} else if let profiles {
|
||||
Form {
|
||||
Section {
|
||||
EmptyView()
|
||||
} footer: {
|
||||
Text("\(profiles.count) Profiles")
|
||||
}
|
||||
ForEach(profiles, id: \.profileID) { profile in
|
||||
Button(profile.name) {
|
||||
isLoading = true
|
||||
Task {
|
||||
selectProfile(profileID: profile.profileID)
|
||||
isLoading = false
|
||||
}
|
||||
}.disabled(isLoading || isImporting)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Connecting...")
|
||||
}
|
||||
}
|
||||
.focusSection()
|
||||
.alertBinding($alert)
|
||||
.navigationTitle("Import Profile")
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
if let connection {
|
||||
connection.stateUpdateHandler = nil
|
||||
connection.cancel()
|
||||
self.connection = nil
|
||||
}
|
||||
if let socket {
|
||||
socket.cancel()
|
||||
self.socket = nil
|
||||
}
|
||||
selected = false
|
||||
profiles = nil
|
||||
}
|
||||
|
||||
private func handleEndpoint(_ endpoint: NWEndpoint) async {
|
||||
let connection = NWConnection(to: endpoint, using: NWParameters.applicationService)
|
||||
self.connection = connection
|
||||
socket = NWSocket(connection)
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case let .failed(error):
|
||||
DispatchQueue.main.async { [self] in
|
||||
reset()
|
||||
alert = Alert(error)
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
connection.start(queue: .global())
|
||||
do {
|
||||
try await loopMessages()
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func loopMessages() async throws {
|
||||
guard let socket = await socket else {
|
||||
return
|
||||
}
|
||||
var message: Data
|
||||
while true {
|
||||
do {
|
||||
message = try socket.read()
|
||||
} catch {
|
||||
throw NSError(domain: "read from connection: \(error.localizedDescription)", code: 0)
|
||||
}
|
||||
var error: NSError?
|
||||
switch Int64(message[0]) {
|
||||
case LibboxMessageTypeError:
|
||||
let message = LibboxDecodeErrorMessage(message, &error)
|
||||
if let error {
|
||||
throw error
|
||||
}
|
||||
if let message {
|
||||
throw NSError(domain: "remote error: \(message.message)", code: 0)
|
||||
}
|
||||
case LibboxMessageTypeProfileList:
|
||||
let decoder = LibboxProfileDecoder()
|
||||
try decoder.decode(message)
|
||||
let iterator = decoder.iterator()!
|
||||
var profiles = [LibboxProfilePreview]()
|
||||
while iterator.hasNext() {
|
||||
let profile = iterator.next()!
|
||||
if profile.type == LibboxProfileTypeiCloud {
|
||||
// not supported on tvOS
|
||||
continue
|
||||
}
|
||||
profiles.append(profile)
|
||||
}
|
||||
await MainActor.run { [self, profiles] in
|
||||
self.profiles = profiles
|
||||
isImporting = false
|
||||
}
|
||||
case LibboxMessageTypeProfileContent:
|
||||
let content = LibboxDecodeProfileContent(message, &error)
|
||||
if let error {
|
||||
throw error
|
||||
}
|
||||
try await importProfile(content!)
|
||||
return
|
||||
default:
|
||||
throw NSError(domain: "unknown message type \(message[0])", code: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectProfile(profileID: Int64) {
|
||||
guard let connection else {
|
||||
return
|
||||
}
|
||||
guard let socket else {
|
||||
return
|
||||
}
|
||||
connection.stateUpdateHandler = nil
|
||||
let request = LibboxProfileContentRequest()
|
||||
request.profileID = profileID
|
||||
do {
|
||||
try socket.write(request.encode())
|
||||
isImporting = true
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func importProfile(_ content: LibboxProfileContent) async throws {
|
||||
var type: ProfileType = .local
|
||||
switch content.type {
|
||||
case LibboxProfileTypeLocal:
|
||||
type = .local
|
||||
case LibboxProfileTypeiCloud:
|
||||
type = .icloud
|
||||
case LibboxProfileTypeRemote:
|
||||
type = .remote
|
||||
default:
|
||||
break
|
||||
}
|
||||
let nextProfileID = try await ProfileManager.nextID()
|
||||
let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
|
||||
let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json")
|
||||
try content.config.write(to: profileConfig, atomically: true, encoding: .utf8)
|
||||
var lastUpdated: Date?
|
||||
if content.lastUpdated > 0 {
|
||||
lastUpdated = Date(timeIntervalSince1970: Double(content.lastUpdated))
|
||||
}
|
||||
try await ProfileManager.create(Profile(name: content.name, type: type, path: profileConfig.relativePath, remoteURL: content.remotePath, autoUpdate: content.autoUpdate, lastUpdated: lastUpdated))
|
||||
await reset()
|
||||
await MainActor.run {
|
||||
environments.profileUpdate.send()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,253 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct NewProfileView: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isSaving = false
|
||||
@State private var profileName = "Sub"
|
||||
@State private var profileType = ProfileType.remote
|
||||
@State private var fileImport = false
|
||||
@State private var fileURL: URL!
|
||||
@State private var remotePath = ""
|
||||
@State private var autoUpdate = true
|
||||
@State private var autoUpdateInterval: Int32 = 60
|
||||
@State private var pickerPresented = false
|
||||
@State private var alert: Alert?
|
||||
|
||||
public struct ImportRequest: Codable, Hashable {
|
||||
public let name: String
|
||||
public let url: String
|
||||
}
|
||||
|
||||
public init(_ importRequest: ImportRequest? = nil) {
|
||||
if let importRequest {
|
||||
_profileName = .init(initialValue: importRequest.name)
|
||||
_profileType = .init(initialValue: .remote)
|
||||
_remotePath = .init(initialValue: importRequest.url)
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
FormView {
|
||||
FormItem("Name") {
|
||||
TextField("Name", text: $profileName, prompt: Text("Required"))
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
Picker(selection: $profileType) {
|
||||
#if !os(tvOS)
|
||||
Text("Local").tag(ProfileType.local)
|
||||
Text("iCloud").tag(ProfileType.icloud)
|
||||
#endif
|
||||
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")
|
||||
}
|
||||
#if os(tvOS)
|
||||
.disabled(true)
|
||||
#endif
|
||||
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)
|
||||
}
|
||||
Toggle("Auto Update", isOn: $autoUpdate)
|
||||
FormItem("Auto Update Interval") {
|
||||
TextField("Auto Update Interval", text: $autoUpdateInterval.stringBinding(defaultValue: 60), prompt: Text("In Minutes"))
|
||||
.multilineTextAlignment(.trailing)
|
||||
#if os(iOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Section {
|
||||
if !isSaving {
|
||||
FormButton {
|
||||
isSaving = true
|
||||
Task {
|
||||
await createProfile()
|
||||
}
|
||||
} label: {
|
||||
Label("Create", systemImage: "doc.fill.badge.plus")
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Profile")
|
||||
.alertBinding($alert)
|
||||
#if os(iOS) || os(macOS)
|
||||
.fileImporter(
|
||||
isPresented: $pickerPresented,
|
||||
allowedContentTypes: [.json],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
do {
|
||||
let urls = try result.get()
|
||||
if !urls.isEmpty {
|
||||
fileURL = urls[0]
|
||||
}
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func createProfile() async {
|
||||
defer {
|
||||
isSaving = false
|
||||
}
|
||||
if profileName.isEmpty {
|
||||
alert = Alert(errorMessage: "Missing profile name")
|
||||
return
|
||||
}
|
||||
if remotePath.isEmpty {
|
||||
if profileType == .icloud {
|
||||
alert = Alert(errorMessage: "Missing path")
|
||||
return
|
||||
} else if profileType == .remote {
|
||||
alert = Alert(errorMessage: "Missing URL")
|
||||
return
|
||||
}
|
||||
}
|
||||
do {
|
||||
try await createProfileBackground()
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
return
|
||||
}
|
||||
environments.profileUpdate.send()
|
||||
dismiss()
|
||||
#if os(macOS)
|
||||
resetFields()
|
||||
#endif
|
||||
}
|
||||
|
||||
private func resetFields() {
|
||||
profileName = ""
|
||||
profileType = .local
|
||||
fileImport = false
|
||||
fileURL = nil
|
||||
remotePath = ""
|
||||
}
|
||||
|
||||
private nonisolated func createProfileBackground() async throws {
|
||||
let nextProfileID = try await ProfileManager.nextID()
|
||||
|
||||
var savePath = ""
|
||||
var remoteURL: String? = nil
|
||||
var lastUpdated: Date? = nil
|
||||
|
||||
let profileName = await profileName
|
||||
let profileType = await profileType
|
||||
let fileImport = await fileImport
|
||||
let fileURL = await fileURL
|
||||
let remotePath = await remotePath
|
||||
let autoUpdate = await autoUpdate
|
||||
let autoUpdateInterval = await autoUpdateInterval
|
||||
|
||||
if profileType == .local {
|
||||
let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
|
||||
let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json")
|
||||
if fileImport {
|
||||
guard let fileURL else {
|
||||
throw NSError(domain: "Missing file", code: 0)
|
||||
}
|
||||
if !fileURL.startAccessingSecurityScopedResource() {
|
||||
throw NSError(domain: "Missing access to selected file", code: 0)
|
||||
}
|
||||
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)
|
||||
print(remoteContent)
|
||||
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
|
||||
lastUpdated = .now
|
||||
}
|
||||
try await ProfileManager.create(Profile(
|
||||
name: profileName,
|
||||
type: profileType,
|
||||
path: savePath,
|
||||
remoteURL: remoteURL,
|
||||
autoUpdate: autoUpdate,
|
||||
autoUpdateInterval: autoUpdateInterval,
|
||||
lastUpdated: lastUpdated
|
||||
))
|
||||
if profileType == .remote {
|
||||
#if os(iOS) || os(tvOS)
|
||||
try await UIProfileUpdateTask.configure()
|
||||
#else
|
||||
try await ProfileUpdateTask.configure()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import Network
|
||||
import QRCode
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct ProfileView: View {
|
||||
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@Environment(\.importProfile) private var importProfile
|
||||
@Environment(\.importRemoteProfile) private var importRemoteProfile
|
||||
@State private var importRemoteProfileRequest: NewProfileView.ImportRequest?
|
||||
@State private var importRemoteProfilePresented = false
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var isUpdating = false
|
||||
|
||||
@State private var alert: Alert?
|
||||
@State private var profileList: [ProfilePreview] = []
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
@State private var editMode = EditMode.inactive
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
@Environment(\.devicePickerSupports) private var devicePickerSupports
|
||||
#endif
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
VStack {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ZStack {
|
||||
if let importRemoteProfileRequest {
|
||||
NavigationDestinationCompat(isPresented: $importRemoteProfilePresented) {
|
||||
NewProfileView(importRemoteProfileRequest)
|
||||
}
|
||||
}
|
||||
FormView {
|
||||
#if os(iOS)
|
||||
FormNavigationLink {
|
||||
NewProfileView()
|
||||
} label: {
|
||||
Text("New Profile").foregroundColor(.accentColor)
|
||||
}
|
||||
.disabled(editMode.isEditing)
|
||||
#elseif os(macOS)
|
||||
FormNavigationLink {
|
||||
NewProfileView()
|
||||
} label: {
|
||||
Text("New Profile")
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
Section {
|
||||
FormNavigationLink {
|
||||
NewProfileView()
|
||||
} label: {
|
||||
Text("New Profile").foregroundColor(.accentColor)
|
||||
}
|
||||
if ApplicationLibrary.inPreview || devicePickerSupports(.applicationService(name: "sing-box"), parameters: { .applicationService }) {
|
||||
FormNavigationLink {
|
||||
ImportProfileView()
|
||||
} label: {
|
||||
Text("Import Profile").foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if profileList.isEmpty {
|
||||
Text("Empty profiles")
|
||||
} else {
|
||||
List {
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
viewBuilder {
|
||||
#if os(iOS) || os(tvOS)
|
||||
if editMode.isEditing == true {
|
||||
Text(profile.name)
|
||||
} else {
|
||||
ProfileItem(self, profile)
|
||||
}
|
||||
#else
|
||||
ProfileItem(self, profile)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onMove(perform: moveProfile)
|
||||
.onDelete(perform: deleteProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isUpdating)
|
||||
.alertBinding($alert, $isLoading)
|
||||
.onAppear {
|
||||
if let profile = importProfile.wrappedValue {
|
||||
importProfile.wrappedValue = nil
|
||||
createImportProfileDialog(profile)
|
||||
}
|
||||
if let remoteProfile = importRemoteProfile.wrappedValue {
|
||||
importRemoteProfile.wrappedValue = nil
|
||||
createImportRemoteProfileDialog(remoteProfile)
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: importProfile.wrappedValue) { newValue in
|
||||
if let newValue {
|
||||
importProfile.wrappedValue = nil
|
||||
createImportProfileDialog(newValue)
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: importRemoteProfile.wrappedValue) { newValue in
|
||||
if let newValue {
|
||||
importRemoteProfile.wrappedValue = nil
|
||||
createImportRemoteProfileDialog(newValue)
|
||||
}
|
||||
}
|
||||
.onReceive(environments.profileUpdate) { _ in
|
||||
Task {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
EditButton().disabled(profileList.isEmpty)
|
||||
}
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if editMode == .inactive {
|
||||
Button("Edit") {
|
||||
editMode = .active
|
||||
}
|
||||
.disabled(profileList.isEmpty)
|
||||
} else {
|
||||
Button("Done") {
|
||||
editMode = .inactive
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if os(iOS) || os(tvOS)
|
||||
.environment(\.editMode, $editMode)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func createImportProfileDialog(_ profile: LibboxProfileContent) {
|
||||
alert = Alert(
|
||||
title: Text("Import Profile"),
|
||||
message: Text("Are you sure to import profile \(profile.name)?"),
|
||||
primaryButton: .default(Text("Import")) {
|
||||
Task {
|
||||
do {
|
||||
try await profile.importProfile()
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
return
|
||||
}
|
||||
await doReload()
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
private func createImportRemoteProfileDialog(_ newValue: LibboxImportRemoteProfile) {
|
||||
importRemoteProfileRequest = .init(name: newValue.name, url: newValue.url)
|
||||
alert = Alert(
|
||||
title: Text("Import Remote Profile"),
|
||||
message: Text("Are you sure to import remote profile \(newValue.name)? You will connect to \(newValue.host) to download the configuration."),
|
||||
primaryButton: .default(Text("Import")) {
|
||||
importRemoteProfilePresented = true
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
private func doReload() async {
|
||||
defer {
|
||||
isLoading = false
|
||||
}
|
||||
if ApplicationLibrary.inPreview {
|
||||
profileList = [
|
||||
ProfilePreview(Profile(id: 0, name: "profile local", type: .local, path: "")),
|
||||
ProfilePreview(Profile(id: 1, name: "profile remote", type: .remote, path: "", lastUpdated: Date(timeIntervalSince1970: 0))),
|
||||
]
|
||||
} else {
|
||||
do {
|
||||
profileList = try await ProfileManager.list().map { ProfilePreview($0) }
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
environments.emptyProfiles = profileList.isEmpty
|
||||
}
|
||||
|
||||
private func updateProfile(_ profile: Profile) async {
|
||||
await updateProfileBackground(profile)
|
||||
isUpdating = false
|
||||
}
|
||||
|
||||
private nonisolated func updateProfileBackground(_ profile: Profile) async {
|
||||
do {
|
||||
_ = try await profile.updateRemoteProfile()
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteProfile(_ profile: Profile) async {
|
||||
do {
|
||||
_ = try await ProfileManager.delete(profile)
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
return
|
||||
}
|
||||
environments.profileUpdate.send()
|
||||
}
|
||||
|
||||
private func moveProfile(from source: IndexSet, to destination: Int) {
|
||||
profileList.move(fromOffsets: source, toOffset: destination)
|
||||
for (index, profile) in profileList.enumerated() {
|
||||
profileList[index].order = UInt32(index)
|
||||
profile.origin.order = UInt32(index)
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
try await ProfileManager.update(profileList.map(\.origin))
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
environments.profileUpdate.send()
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteProfile(where profileIndex: IndexSet) {
|
||||
let profileToDelete = profileIndex.map { index in
|
||||
profileList[index].origin
|
||||
}
|
||||
profileList.remove(atOffsets: profileIndex)
|
||||
environments.emptyProfiles = profileList.isEmpty
|
||||
Task {
|
||||
do {
|
||||
_ = try await ProfileManager.delete(profileToDelete)
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
environments.profileUpdate.send()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public struct ProfileItem: View {
|
||||
private let parent: ProfileView
|
||||
@State private var profile: ProfilePreview
|
||||
@State private var shareLinkPresented = false
|
||||
|
||||
public init(_ parent: ProfileView, _ profile: ProfilePreview) {
|
||||
self.parent = parent
|
||||
_profile = State(initialValue: profile)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
#if os(iOS) || os(macOS)
|
||||
if #available(iOS 16.0, macOS 13.0,*) {
|
||||
body0.draggable(profile.origin)
|
||||
} else {
|
||||
body0
|
||||
}
|
||||
#else
|
||||
body0
|
||||
#endif
|
||||
}
|
||||
|
||||
private var body0: some View {
|
||||
viewBuilder {
|
||||
#if !os(macOS)
|
||||
FormNavigationLink {
|
||||
EditProfileView().environmentObject(profile.origin)
|
||||
} label: {
|
||||
Text(profile.name)
|
||||
}
|
||||
.sheet(isPresented: $shareLinkPresented) {
|
||||
shareLinkView.padding()
|
||||
}
|
||||
.contextMenu {
|
||||
ProfileShareButton(parent.$alert, profile.origin) {
|
||||
Label("Share", systemImage: "square.and.arrow.up.fill")
|
||||
}
|
||||
|
||||
if profile.type == .remote {
|
||||
Button {
|
||||
shareLinkPresented = true
|
||||
} label: {
|
||||
Label("Share URL as QR Code", systemImage: "qrcode")
|
||||
}
|
||||
Button {
|
||||
parent.isUpdating = true
|
||||
Task {
|
||||
await parent.updateProfile(profile.origin)
|
||||
profile = ProfilePreview(profile.origin)
|
||||
}
|
||||
} label: {
|
||||
Label("Update", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await parent.deleteProfile(profile.origin)
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash.fill")
|
||||
}
|
||||
}
|
||||
#else
|
||||
FormNavigationLink {
|
||||
EditProfileView().environmentObject(profile.origin)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(profile.name)
|
||||
if profile.type == .remote {
|
||||
Spacer(minLength: 4)
|
||||
Text("Last Updated: \(profile.origin.lastUpdatedString)").font(.caption)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
if profile.type == .remote {
|
||||
Button {
|
||||
parent.isUpdating = true
|
||||
Task {
|
||||
await parent.updateProfile(profile.origin)
|
||||
profile = ProfilePreview(profile.origin)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
|
||||
Button {
|
||||
shareLinkPresented = true
|
||||
} label: {
|
||||
Image(systemName: "qrcode")
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
.popover(isPresented: $shareLinkPresented, arrowEdge: .bottom) {
|
||||
shareLinkView
|
||||
}
|
||||
}
|
||||
ProfileShareButton(parent.$alert, profile.origin) {
|
||||
Image(systemName: "square.and.arrow.up.fill")
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
Button {
|
||||
Task {
|
||||
await parent.deleteProfile(profile.origin)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "trash.fill")
|
||||
}
|
||||
.padding([.leading, .trailing], 4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var shareLinkView: some View {
|
||||
#if os(iOS)
|
||||
viewBuilder {
|
||||
if #available(iOS 16.0, *) {
|
||||
shareLinkView0
|
||||
.presentationDetents([.medium])
|
||||
.presentationDragIndicator(.visible)
|
||||
} else {
|
||||
shareLinkView0
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
shareLinkView0
|
||||
.frame(minWidth: 300, minHeight: 300)
|
||||
#else
|
||||
shareLinkView0
|
||||
#endif
|
||||
}
|
||||
|
||||
private var foregroundColor: CGColor {
|
||||
#if canImport(UIKit)
|
||||
return UIColor.label.cgColor
|
||||
#elseif canImport(AppKit)
|
||||
return NSColor.labelColor.cgColor
|
||||
#endif
|
||||
}
|
||||
|
||||
private var shareLinkView0: some View {
|
||||
QRCodeViewUI(
|
||||
content: LibboxGenerateRemoteProfileImportLink(profile.name, profile.remoteURL!),
|
||||
errorCorrection: .low,
|
||||
foregroundColor: foregroundColor,
|
||||
backgroundColor: CGColor(gray: 1.0, alpha: 0.0)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct CoreView: View {
|
||||
@State private var isLoading = true
|
||||
|
||||
@State private var version = ""
|
||||
@State private var dataSize = ""
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task {
|
||||
await loadSettings()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FormView {
|
||||
FormTextItem("Version", version)
|
||||
FormTextItem("Data Size", dataSize)
|
||||
|
||||
Section("Working Directory") {
|
||||
#if os(macOS)
|
||||
FormButton {
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: FilePath.workingDirectory.relativePath)
|
||||
} label: {
|
||||
Label("Open", systemImage: "macwindow.and.cursorarrow")
|
||||
}
|
||||
#endif
|
||||
FormButton(role: .destructive) {
|
||||
Task {
|
||||
await destroyWorkingDirectory()
|
||||
}
|
||||
} label: {
|
||||
Label("Destroy", systemImage: "trash.fill")
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Core")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private nonisolated func loadSettings() async {
|
||||
if ApplicationLibrary.inPreview {
|
||||
version = "<redacted>"
|
||||
dataSize = LibboxFormatBytes(1000 * 1000 * 10)
|
||||
isLoading = false
|
||||
} else {
|
||||
version = LibboxVersion()
|
||||
dataSize = "Loading..."
|
||||
isLoading = false
|
||||
await loadSettingsBackground()
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func loadSettingsBackground() async {
|
||||
let dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown"
|
||||
await MainActor.run {
|
||||
self.dataSize = dataSize
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func destroyWorkingDirectory() async {
|
||||
try? FileManager.default.removeItem(at: FilePath.workingDirectory)
|
||||
await MainActor.run {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
#if os(macOS)
|
||||
|
||||
import AppKit
|
||||
import Library
|
||||
import ServiceManagement
|
||||
import SwiftUI
|
||||
|
||||
public struct MacAppView: View {
|
||||
@State private var isLoading = true
|
||||
|
||||
@State private var startAtLogin = false
|
||||
@Environment(\.showMenuBarExtra) private var showMenuBarExtra
|
||||
@State private var menuBarExtraInBackground = false
|
||||
|
||||
@State private var alert: Alert?
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task {
|
||||
await loadSettings()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FormView {
|
||||
FormToggle("Start At Login", "Launch the application when the system is logged in. If enabled at the same time as `Show in Menu Bar` and `Keep Menu Bar in Background`, the application interface will not be opened automatically.", $startAtLogin) { newValue in
|
||||
updateLoginItems(newValue)
|
||||
}
|
||||
|
||||
Toggle("Show in Menu Bar", isOn: showMenuBarExtra)
|
||||
.onChangeCompat(of: showMenuBarExtra.wrappedValue) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.showMenuBarExtra.set(newValue)
|
||||
if !newValue {
|
||||
menuBarExtraInBackground = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if showMenuBarExtra.wrappedValue {
|
||||
Toggle("Keep Menu Bar in Background", isOn: $menuBarExtraInBackground)
|
||||
.onChangeCompat(of: menuBarExtraInBackground) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.menuBarExtraInBackground.set(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if Variant.useSystemExtension {
|
||||
Section("System Extension") {
|
||||
FormButton {
|
||||
Task {
|
||||
await updateSystemExtension()
|
||||
}
|
||||
} label: {
|
||||
Label("Update", systemImage: "arrow.down.doc.fill")
|
||||
}
|
||||
FormButton(role: .destructive) {
|
||||
Task {
|
||||
await uninstallSystemExtension()
|
||||
}
|
||||
} label: {
|
||||
Label("Uninstall", systemImage: "trash.fill").foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alertBinding($alert)
|
||||
.navigationTitle("App")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func loadSettings() async {
|
||||
startAtLogin = SMAppService.mainApp.status == .enabled
|
||||
menuBarExtraInBackground = await SharedPreferences.menuBarExtraInBackground.get()
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
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 {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSystemExtension() async {
|
||||
do {
|
||||
if let result = try await SystemExtension.install(forceUpdate: true) {
|
||||
switch result {
|
||||
case .completed:
|
||||
alert = Alert(
|
||||
title: Text("Update"),
|
||||
message: Text("System Extension updated."),
|
||||
dismissButton: .default(Text("Ok")) {}
|
||||
)
|
||||
case .willCompleteAfterReboot:
|
||||
alert = Alert(
|
||||
title: Text("Update"),
|
||||
message: Text("Reboot required."),
|
||||
dismissButton: .default(Text("Ok")) {}
|
||||
)
|
||||
@unknown default:
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func uninstallSystemExtension() async {
|
||||
do {
|
||||
if let result = try await SystemExtension.uninstall() {
|
||||
switch result {
|
||||
case .completed:
|
||||
alert = Alert(
|
||||
title: Text("Uninstall"),
|
||||
message: Text("System Extension removed."),
|
||||
dismissButton: .default(Text("Ok")) {}
|
||||
)
|
||||
case .willCompleteAfterReboot:
|
||||
alert = Alert(
|
||||
title: Text("Uninstall"),
|
||||
message: Text("Reboot required."),
|
||||
dismissButton: .default(Text("Ok")) {}
|
||||
)
|
||||
@unknown default:
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct OnDemandRulesView: View {
|
||||
@State private var isLoading = true
|
||||
@State private var alwaysOn = false
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
await loadSettings()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FormView {
|
||||
FormToggle("Always On", """
|
||||
Implement always-on via on-demand rules.
|
||||
|
||||
This should not be an intended use of the API, so you cannot disable VPN in system settings. To stop the service manually, use the in-app interface or simply delete the VPN profile.
|
||||
""", $alwaysOn) { newValue in
|
||||
await SharedPreferences.alwaysOn.set(newValue)
|
||||
}
|
||||
|
||||
FormButton {
|
||||
Task {
|
||||
await SharedPreferences.resetOnDemandRules()
|
||||
isLoading = true
|
||||
}
|
||||
} label: {
|
||||
Label("Reset", systemImage: "eraser.fill")
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("On Demand Rules")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func loadSettings() async {
|
||||
alwaysOn = await SharedPreferences.alwaysOn.get()
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct PacketTunnelView: View {
|
||||
@State private var isLoading = true
|
||||
|
||||
@State private var ignoreMemoryLimit = false
|
||||
|
||||
@State private var includeAllNetworks = false
|
||||
@State private var excludeAPNs = false
|
||||
@State private var excludeCellularServices = false
|
||||
@State private var excludeLocalNetworks = false
|
||||
@State private var enforceRoutes = false
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
await loadSettings()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FormView {
|
||||
FormToggle("Ignore Memory Limit", """
|
||||
Do not enforce memory limits on sing-box. Will cause OOM on non-jailbroken iOS and tvOS devices.
|
||||
""", $ignoreMemoryLimit) { newValue in
|
||||
await SharedPreferences.ignoreMemoryLimit.set(newValue)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
FormToggle("includeAllNetworks", """
|
||||
If this property is true, the system routes network traffic through the tunnel except traffic for designated system services necessary for maintaining expected device functionality. You can exclude some types of traffic using the **excludeAPNs**, **excludeLocalNetworks**, and **excludeCellularServices** properties in combination with this property.
|
||||
|
||||
when enabled, the default TUN stack is changed to `gvisor`, and the `system` and `mixed` stacks are not available.
|
||||
|
||||
[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/3131931-includeallnetworks)
|
||||
""", $includeAllNetworks) { newValue in
|
||||
await SharedPreferences.includeAllNetworks.set(newValue)
|
||||
}
|
||||
|
||||
FormToggle("excludeAPNs", """
|
||||
If this property is true, the system excludes Apple Push Notification services (APNs) traffic, but only when the **includeAllNetworks** property is also true.
|
||||
|
||||
[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/4140516-excludeapns)
|
||||
""", $excludeAPNs) { newValue in
|
||||
await SharedPreferences.excludeAPNs.set(newValue)
|
||||
}
|
||||
|
||||
FormToggle("excludeCellularServices", """
|
||||
If this property is true, the system excludes cellular services — such as Wi-Fi Calling, MMS, SMS, and Visual Voicemail — but only when the **includeAllNetworks** property is also true. This property doesn’t impact services that use the cellular network only — such as VoLTE — which the system automatically excludes.
|
||||
|
||||
[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/4140517-excludecellularservices)
|
||||
""", $excludeCellularServices) { newValue in
|
||||
await SharedPreferences.excludeCellularServices.set(newValue)
|
||||
}
|
||||
|
||||
FormToggle("excludeLocalNetworks", """
|
||||
If this property is true, the system excludes network connections to hosts on the local network — such as AirPlay, AirDrop, and CarPlay — but only when the **includeAllNetworks** or **enforceRoutes** property is also true.
|
||||
|
||||
[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/3143658-excludelocalnetworks)
|
||||
""", $excludeLocalNetworks) { newValue in
|
||||
await SharedPreferences.excludeLocalNetworks.set(newValue)
|
||||
}
|
||||
|
||||
FormToggle("enforceRoutes", """
|
||||
If this property is true when the **includeAllNetworks** property is false, the system scopes the included routes to the VPN and the excluded routes to the current primary network interface. This property supersedes the system routing table and scoping operations by apps.
|
||||
|
||||
If you set both the **enforceRoutes** and **excludeLocalNetworks** properties to true, the system excludes network connections to hosts on the local network.
|
||||
|
||||
[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/3689459-enforceroutes)
|
||||
""", $enforceRoutes) { newValue in
|
||||
await SharedPreferences.enforceRoutes.set(newValue)
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
FormButton {
|
||||
Task {
|
||||
await SharedPreferences.resetPacketTunnel()
|
||||
isLoading = true
|
||||
}
|
||||
} label: {
|
||||
Label("Reset", systemImage: "eraser.fill")
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Packet Tunnel")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func loadSettings() async {
|
||||
ignoreMemoryLimit = await SharedPreferences.ignoreMemoryLimit.get()
|
||||
#if !os(tvOS)
|
||||
includeAllNetworks = await SharedPreferences.includeAllNetworks.get()
|
||||
excludeAPNs = await SharedPreferences.excludeAPNs.get()
|
||||
excludeCellularServices = await SharedPreferences.excludeCellularServices.get()
|
||||
excludeLocalNetworks = await SharedPreferences.excludeLocalNetworks.get()
|
||||
enforceRoutes = await SharedPreferences.enforceRoutes.get()
|
||||
#endif
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct ProfileOverrideView: View {
|
||||
@State private var isLoading = true
|
||||
@State private var excludeDefaultRoute = false
|
||||
@State private var autoRouteUseSubRangesByDefault = false
|
||||
@State private var excludeAPNsRoute = false
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
await loadSettings()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FormView {
|
||||
FormToggle("Hide VPN Icon", "Append `0.0.0.0/31` to `inet4_route_exclude_address` if not exists.", $excludeDefaultRoute) { newValue in
|
||||
await SharedPreferences.excludeDefaultRoute.set(newValue)
|
||||
}
|
||||
|
||||
FormToggle("No Default Route", """
|
||||
By default, segment routing is used in `auto_route` instead of global routing. If `*_<route_address/route_exclude_address>` exists in the configuration, this item will not take effect on the corresponding network (commonly used to resolve HomeKit compatibility issues).
|
||||
""", $autoRouteUseSubRangesByDefault) { newValue in
|
||||
await SharedPreferences.autoRouteUseSubRangesByDefault.set(newValue)
|
||||
}
|
||||
|
||||
FormToggle("Exclude APNs Route", "Append `push.apple.com` to `bypass_domain`, and `17.0.0.0/8` to `inet4_route_exclude_address`.", $excludeAPNsRoute) { newValue in
|
||||
await SharedPreferences.excludeAPNsRoute.set(newValue)
|
||||
}
|
||||
|
||||
FormButton {
|
||||
Task {
|
||||
await SharedPreferences.resetProfileOverride()
|
||||
isLoading = true
|
||||
}
|
||||
} label: {
|
||||
Label("Reset", systemImage: "eraser.fill")
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Profile Override")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func loadSettings() async {
|
||||
excludeDefaultRoute = await SharedPreferences.excludeDefaultRoute.get()
|
||||
autoRouteUseSubRangesByDefault = await SharedPreferences.autoRouteUseSubRangesByDefault.get()
|
||||
excludeAPNsRoute = await SharedPreferences.excludeAPNsRoute.get()
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct ServiceLogView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var content = ""
|
||||
@State private var alert: Alert?
|
||||
|
||||
private let logFont = Font.system(.caption, design: .monospaced)
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task {
|
||||
await loadContent()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if content.isEmpty {
|
||||
Text("Empty content")
|
||||
} else {
|
||||
ScrollView {
|
||||
Text(content)
|
||||
.font(logFont)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
if !content.isEmpty {
|
||||
#if !os(tvOS)
|
||||
ShareButtonCompat($alert) {
|
||||
Label("Export", systemImage: "square.and.arrow.up.fill")
|
||||
} itemURL: {
|
||||
try content.generateShareFile(name: "service.log")
|
||||
}
|
||||
#endif
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await deleteContent()
|
||||
}
|
||||
} label: {
|
||||
#if !os(tvOS)
|
||||
Label("Delete", systemImage: "trash.fill")
|
||||
#else
|
||||
Image(systemName: "trash.fill")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
.alertBinding($alert)
|
||||
.navigationTitle("Service Log")
|
||||
#if os(tvOS)
|
||||
.focusable()
|
||||
#endif
|
||||
}
|
||||
|
||||
private nonisolated func loadContent() async {
|
||||
var content = ""
|
||||
do {
|
||||
content = try String(contentsOf: FilePath.cacheDirectory.appendingPathComponent("stderr.log"))
|
||||
} catch {}
|
||||
if content.isEmpty {
|
||||
do {
|
||||
content = try String(contentsOf: FilePath.cacheDirectory.appendingPathComponent("stderr.log.old"))
|
||||
} catch {}
|
||||
}
|
||||
#if DEBUG
|
||||
if content.isEmpty {
|
||||
content = "Empty content"
|
||||
}
|
||||
#endif
|
||||
if !content.isEmpty {
|
||||
var systemInfo = utsname()
|
||||
uname(&systemInfo)
|
||||
let machineMirror = Mirror(reflecting: systemInfo.machine)
|
||||
let machineName = machineMirror.children.reduce("") { identifier, element in
|
||||
guard let value = element.value as? Int8, value != 0 else { return identifier }
|
||||
return identifier + String(UnicodeScalar(UInt8(value)))
|
||||
}
|
||||
var deviceInfo = "Machine: " + machineName + "\n"
|
||||
#if os(iOS)
|
||||
await deviceInfo += "System: " + (UIDevice.current.systemName) + " " + (UIDevice.current.systemVersion) + "\n"
|
||||
#elseif os(macOS)
|
||||
deviceInfo += "System: macOS " + ProcessInfo().operatingSystemVersionString + "\n"
|
||||
#endif
|
||||
content = deviceInfo + "\n" + content
|
||||
}
|
||||
await MainActor.run { [content] in
|
||||
self.content = content
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func deleteContent() async {
|
||||
try? FileManager.default.removeItem(at: FilePath.cacheDirectory.appendingPathComponent("stderr.log"))
|
||||
try? FileManager.default.removeItem(at: FilePath.cacheDirectory.appendingPathComponent("stderr.log.old"))
|
||||
await MainActor.run {
|
||||
dismiss()
|
||||
isLoading = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct SettingView: View {
|
||||
private enum Tabs: Int, CaseIterable, Identifiable {
|
||||
public var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
case app
|
||||
#endif
|
||||
|
||||
case core, packetTunnel, onDemandRules, profileOverride, sponsors
|
||||
|
||||
var label: some View {
|
||||
Label(title, systemImage: iconImage)
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
#if os(macOS)
|
||||
case .app:
|
||||
return NSLocalizedString("App", comment: "")
|
||||
#endif
|
||||
case .core:
|
||||
return NSLocalizedString("Core", comment: "")
|
||||
case .packetTunnel:
|
||||
return NSLocalizedString("Packet Tunnel", comment: "")
|
||||
case .onDemandRules:
|
||||
return NSLocalizedString("On Demand Rules", comment: "")
|
||||
case .profileOverride:
|
||||
return NSLocalizedString("Profile Override", comment: "")
|
||||
case .sponsors:
|
||||
return NSLocalizedString("Sponsors", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
private var iconImage: String {
|
||||
switch self {
|
||||
#if os(macOS)
|
||||
case .app:
|
||||
return "app.badge.fill"
|
||||
#endif
|
||||
case .core:
|
||||
return "shippingbox.fill"
|
||||
case .packetTunnel:
|
||||
return "aspectratio.fill"
|
||||
case .onDemandRules:
|
||||
return "filemenu.and.selection"
|
||||
case .profileOverride:
|
||||
return "square.dashed.inset.filled"
|
||||
case .sponsors:
|
||||
return "heart.fill"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var contentView: some View {
|
||||
viewBuilder {
|
||||
switch self {
|
||||
#if os(macOS)
|
||||
case .app:
|
||||
MacAppView()
|
||||
#endif
|
||||
case .core:
|
||||
CoreView()
|
||||
case .packetTunnel:
|
||||
PacketTunnelView()
|
||||
case .onDemandRules:
|
||||
OnDemandRulesView()
|
||||
case .profileOverride:
|
||||
ProfileOverrideView()
|
||||
case .sponsors:
|
||||
SponsorsView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
#if os(iOS)
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var navigationLink: some View {
|
||||
FormNavigationLink {
|
||||
contentView
|
||||
} label: {
|
||||
label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var taiwanFlagAvailable = false
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
FormView {
|
||||
#if os(macOS)
|
||||
Tabs.app.navigationLink
|
||||
#endif
|
||||
ForEach([Tabs.core, Tabs.packetTunnel, Tabs.onDemandRules, Tabs.profileOverride]) { it in
|
||||
it.navigationLink
|
||||
}
|
||||
#if !os(tvOS)
|
||||
Section("About") {
|
||||
Link(destination: URL(string: "https://sing-box.sagernet.org/")!) {
|
||||
Label("Documentation", systemImage: "doc.on.doc.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
RequestReviewButton {
|
||||
Label("Rate on the App Store", systemImage: "text.bubble.fill")
|
||||
}
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension {
|
||||
Tabs.sponsors.navigationLink
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
Section("Debug") {
|
||||
FormNavigationLink {
|
||||
ServiceLogView()
|
||||
} label: {
|
||||
Label("Service Log", systemImage: "doc.on.clipboard")
|
||||
}
|
||||
FormTextItem("Taiwan Flag Available", "touchid") {
|
||||
if isLoading {
|
||||
Text("Loading...")
|
||||
.onAppear {
|
||||
Task.detached {
|
||||
let available: Bool
|
||||
if ApplicationLibrary.inPreview {
|
||||
available = true
|
||||
} else {
|
||||
available = !DeviceCensorship.isChinaDevice()
|
||||
}
|
||||
await MainActor.run {
|
||||
taiwanFlagAvailable = available
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(taiwanFlagAvailable.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public struct SponsorsView: View {
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
FormView {
|
||||
Section {
|
||||
EmptyView()
|
||||
} footer: {
|
||||
Text("**If I’ve defended your modern life, please consider sponsoring me.**")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
FormButton("GitHub Sponsor (recommended)") {
|
||||
openURL(URL(string: "https://github.com/sponsors/nekohasekai")!)
|
||||
}
|
||||
FormButton("Other methods") {
|
||||
openURL(URL(string: "https://sekai.icu/sponsors/")!)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sponsors")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// ActivityIndicatorModifier.swift
|
||||
// ApplicationLibrary
|
||||
//
|
||||
// Created by Mac on 2024/10/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct ActivityIndicator: UIViewRepresentable {
|
||||
@Binding var isAnimating: Bool
|
||||
let style: UIActivityIndicatorView.Style
|
||||
|
||||
func makeUIView(context: UIViewRepresentableContext<ActivityIndicator>) -> UIActivityIndicatorView {
|
||||
return UIActivityIndicatorView(style: style)
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext<ActivityIndicator>) {
|
||||
isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
|
||||
}
|
||||
}
|
||||
|
||||
public struct ActivityIndicatorModifier: AnimatableModifier {
|
||||
var isLoading: Bool
|
||||
|
||||
public init(isLoading: Bool, color: Color = .primary, lineWidth: CGFloat = 3) {
|
||||
self.isLoading = isLoading
|
||||
}
|
||||
|
||||
var animatableData: Bool {
|
||||
get { isLoading }
|
||||
set { isLoading = newValue }
|
||||
}
|
||||
|
||||
public func body(content: Content) -> some View {
|
||||
ZStack {
|
||||
if isLoading {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .center) {
|
||||
content
|
||||
.disabled(self.isLoading)
|
||||
.blur(radius: self.isLoading ? 3 : 0)
|
||||
|
||||
VStack {
|
||||
// Text("配置中...").font(.subheadline)
|
||||
ActivityIndicator(isAnimating: .constant(true), style: .large)
|
||||
}
|
||||
.frame(width: geometry.size.width / 2,
|
||||
height: geometry.size.height / 5)
|
||||
.background(Color.secondary.colorInvert())
|
||||
.foregroundColor(Color.primary)
|
||||
.cornerRadius(20)
|
||||
.opacity(self.isLoading ? 1 : 0)
|
||||
.position(x: geometry.frame(in: .local).midX, y: geometry.frame(in: .local).midY)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user