Improve UI
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
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 FormSection(@ViewBuilder content: () -> some View, @ViewBuilder footer: () -> some View) -> some View {
|
||||
Section {
|
||||
content()
|
||||
} footer: {
|
||||
footer()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public func FormView(@ViewBuilder content: () -> some View) -> some View {
|
||||
Form {
|
||||
content()
|
||||
}
|
||||
#if os(macOS)
|
||||
.formStyle(.grouped)
|
||||
#endif
|
||||
}
|
||||
|
||||
public func FormTextItem(_ name: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(name)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.font(Font.system(.caption, design: .monospaced))
|
||||
#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
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,9 +1,9 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@ public struct InstallProfileButton: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button("Install NetworkExtension") {
|
||||
FormButton {
|
||||
Task {
|
||||
await installProfile()
|
||||
}
|
||||
} label: {
|
||||
Label("Install Network Extension", systemImage: "lock.doc.fill")
|
||||
}
|
||||
.alertBinding($alert)
|
||||
}
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button("Install SystemExtension") {
|
||||
FormButton {
|
||||
Task {
|
||||
await installSystemExtension()
|
||||
}
|
||||
} label: {
|
||||
Label("Install System Extension", systemImage: "lock.doc.fill")
|
||||
}
|
||||
.alertBinding($alert)
|
||||
}
|
||||
|
||||
@@ -17,12 +17,15 @@ public enum NavigationPage: Int, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
public extension NavigationPage {
|
||||
static var macosDefaultPages: [NavigationPage] {
|
||||
[.logs, .profiles, .settings]
|
||||
}
|
||||
#if os(macOS)
|
||||
static var macosDefaultPages: [NavigationPage] {
|
||||
[.logs, .profiles, .settings]
|
||||
}
|
||||
#endif
|
||||
|
||||
var label: some View {
|
||||
Label(title, systemImage: iconImage)
|
||||
.tint(.textColor)
|
||||
}
|
||||
|
||||
var title: String {
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
|
||||
@MainActor
|
||||
public struct EditProfileContentView: View {
|
||||
#if os(macOS)
|
||||
public static let windowID = "edit-profile-content"
|
||||
#endif
|
||||
|
||||
public struct Context: Codable, Hashable {
|
||||
public let profileID: Int64
|
||||
public let readOnly: Bool
|
||||
|
||||
@@ -4,10 +4,6 @@ import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct EditProfileView: View {
|
||||
#if os(macOS)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
#endif
|
||||
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var profile: Profile
|
||||
@@ -58,39 +54,45 @@ public struct EditProfileView: View {
|
||||
FormTextItem("Last Updated", profile.lastUpdatedString)
|
||||
}
|
||||
}
|
||||
#if os(iOS) || os(tvOS)
|
||||
Section("Action") {
|
||||
if profile.type != .remote {
|
||||
#if os(iOS)
|
||||
NavigationLink {
|
||||
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
|
||||
} label: {
|
||||
Text("Edit Content").foregroundColor(.accentColor)
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
#if os(iOS)
|
||||
NavigationLink {
|
||||
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
|
||||
} label: {
|
||||
Text("View Content").foregroundColor(.accentColor)
|
||||
}
|
||||
#endif
|
||||
Button("Update") {
|
||||
isLoading = true
|
||||
Task {
|
||||
await updateProfile()
|
||||
}
|
||||
Section("Action") {
|
||||
if profile.type != .remote {
|
||||
#if os(iOS) || os(macOS)
|
||||
NavigationLink {
|
||||
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
|
||||
} label: {
|
||||
Label("Edit Content", systemImage: "pencil")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
.disabled(isLoading)
|
||||
}
|
||||
Button("Delete", role: .destructive) {
|
||||
#endif
|
||||
} else {
|
||||
#if os(iOS) || os(macOS)
|
||||
NavigationLink {
|
||||
EditProfileContentView(EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
|
||||
} label: {
|
||||
Label("View Content", systemImage: "doc.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
#endif
|
||||
FormButton {
|
||||
isLoading = true
|
||||
Task {
|
||||
await deleteProfile()
|
||||
await updateProfile()
|
||||
}
|
||||
} label: {
|
||||
Label("Update", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.foregroundColor(.accentColor)
|
||||
.disabled(isLoading)
|
||||
}
|
||||
#endif
|
||||
FormButton(role: .destructive) {
|
||||
Task {
|
||||
await deleteProfile()
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash.fill")
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: profile.name) {
|
||||
isChanged = true
|
||||
@@ -114,30 +116,6 @@ public struct EditProfileView: View {
|
||||
Image("save", bundle: ApplicationLibrary.bundle, label: Text("Save"))
|
||||
}
|
||||
.disabled(isLoading || !isChanged)
|
||||
if profile.type != .remote {
|
||||
Button {
|
||||
openWindow(id: EditProfileContentView.windowID, value: EditProfileContentView.Context(profileID: profile.id!, readOnly: false))
|
||||
} label: {
|
||||
Label("Edit Content", systemImage: "pencil")
|
||||
}
|
||||
.disabled(isLoading)
|
||||
} else {
|
||||
Button {
|
||||
isLoading = true
|
||||
Task {
|
||||
await updateProfile()
|
||||
}
|
||||
} label: {
|
||||
Label("Update", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(isLoading)
|
||||
Button {
|
||||
openWindow(id: EditProfileContentView.windowID, value: EditProfileContentView.Context(profileID: profile.id!, readOnly: true))
|
||||
} label: {
|
||||
Label("View Content", systemImage: "doc.text.fill")
|
||||
}
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
#if os(macOS)
|
||||
@MainActor
|
||||
public struct EditProfileWindowView: View {
|
||||
public static let windowID = "edit-profile"
|
||||
|
||||
private var profileID: Int64?
|
||||
|
||||
public init(_ profileID: Int64?) {
|
||||
self.profileID = profileID
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var profile: Profile!
|
||||
@State private var alert: Alert?
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task {
|
||||
await doReload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
EditProfileView().environmentObject(profile!)
|
||||
}
|
||||
}
|
||||
.alertBinding($alert)
|
||||
.onExitCommand {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func doReload() async {
|
||||
guard let profileID else {
|
||||
alert = Alert(errorMessage: "Context destroyed")
|
||||
return
|
||||
}
|
||||
do {
|
||||
profile = try await ProfileManager.get(profileID)
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
return
|
||||
}
|
||||
if profile == nil {
|
||||
alert = Alert(errorMessage: "Profile deleted")
|
||||
return
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -5,10 +5,6 @@ import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct NewProfileView: View {
|
||||
#if os(macOS)
|
||||
public static let windowID = "new-profile"
|
||||
#endif
|
||||
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@@ -100,11 +96,13 @@ public struct NewProfileView: View {
|
||||
}
|
||||
Section {
|
||||
if !isSaving {
|
||||
Button("Create") {
|
||||
FormButton {
|
||||
isSaving = true
|
||||
Task {
|
||||
await createProfile()
|
||||
}
|
||||
} label: {
|
||||
Label("Create", systemImage: "doc.fill.badge.plus")
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
|
||||
@@ -21,8 +21,6 @@ public struct ProfileView: View {
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
@State private var editMode = EditMode.inactive
|
||||
#elseif os(macOS)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
@@ -39,73 +37,67 @@ public struct ProfileView: View {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
#if os(iOS) || os(tvOS)
|
||||
ZStack {
|
||||
if let importRemoteProfileRequest {
|
||||
NavigationDestinationCompat(isPresented: $importRemoteProfilePresented) {
|
||||
NewProfileView(importRemoteProfileRequest)
|
||||
}
|
||||
ZStack {
|
||||
if let importRemoteProfileRequest {
|
||||
NavigationDestinationCompat(isPresented: $importRemoteProfilePresented) {
|
||||
NewProfileView(importRemoteProfileRequest)
|
||||
}
|
||||
FormView {
|
||||
#if os(iOS)
|
||||
}
|
||||
FormView {
|
||||
#if os(iOS)
|
||||
NavigationLink {
|
||||
NewProfileView()
|
||||
} label: {
|
||||
Text("New Profile").foregroundColor(.accentColor)
|
||||
}
|
||||
.disabled(editMode.isEditing)
|
||||
#elseif os(macOS)
|
||||
NavigationLink {
|
||||
NewProfileView()
|
||||
} label: {
|
||||
Text("New Profile")
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
Section {
|
||||
NavigationLink {
|
||||
NewProfileView()
|
||||
} label: {
|
||||
Text("New Profile").foregroundColor(.accentColor)
|
||||
}
|
||||
.disabled(editMode.isEditing)
|
||||
#elseif os(tvOS)
|
||||
Section {
|
||||
if ApplicationLibrary.inPreview || devicePickerSupports(.applicationService(name: "sing-box"), parameters: { .applicationService }) {
|
||||
NavigationLink {
|
||||
NewProfileView()
|
||||
} label: {
|
||||
Text("New Profile").foregroundColor(.accentColor)
|
||||
}
|
||||
if ApplicationLibrary.inPreview || devicePickerSupports(.applicationService(name: "sing-box"), parameters: { .applicationService }) {
|
||||
NavigationLink {
|
||||
ImportProfileView {
|
||||
await doReload()
|
||||
}
|
||||
} label: {
|
||||
Text("Import Profile").foregroundColor(.accentColor)
|
||||
ImportProfileView {
|
||||
await doReload()
|
||||
}
|
||||
} label: {
|
||||
Text("Import Profile").foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if profileList.isEmpty {
|
||||
Text("Empty profiles")
|
||||
} else {
|
||||
List {
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
viewBuilder {
|
||||
}
|
||||
#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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if profileList.isEmpty {
|
||||
Text("Empty profiles")
|
||||
} else {
|
||||
FormView {
|
||||
List {
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
ProfileItem(self, profile)
|
||||
}
|
||||
.onMove(perform: moveProfile)
|
||||
.onDelete(perform: deleteProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isUpdating)
|
||||
@@ -140,17 +132,7 @@ public struct ProfileView: View {
|
||||
// await doReload()
|
||||
// }
|
||||
}
|
||||
#if os(macOS)
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button {
|
||||
openWindow(id: NewProfileView.windowID)
|
||||
} label: {
|
||||
Label("New Profile", systemImage: "plus.square.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
#if os(iOS)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
EditButton().disabled(profileList.isEmpty)
|
||||
@@ -202,11 +184,7 @@ public struct ProfileView: View {
|
||||
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")) {
|
||||
#if os(iOS) || os(tvOS)
|
||||
importRemoteProfilePresented = true
|
||||
#elseif os(macOS)
|
||||
openWindow(id: NewProfileView.windowID, value: importRemoteProfileRequest!)
|
||||
#endif
|
||||
importRemoteProfilePresented = true
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
@@ -285,6 +263,7 @@ public struct ProfileView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public struct ProfileItem: View {
|
||||
private let parent: ProfileView
|
||||
@State private var profile: ProfilePreview
|
||||
@@ -307,7 +286,6 @@ public struct ProfileView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private var body0: some View {
|
||||
viewBuilder {
|
||||
#if !os(macOS)
|
||||
@@ -346,57 +324,63 @@ public struct ProfileView: View {
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash.fill")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
#else
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(profile.name)
|
||||
if profile.type == .remote {
|
||||
Spacer(minLength: 4)
|
||||
Text("Last Updated: \(profile.origin.lastUpdatedString)").font(.caption)
|
||||
}
|
||||
}
|
||||
NavigationLink {
|
||||
EditProfileView().environmentObject(profile.origin)
|
||||
} label: {
|
||||
HStack {
|
||||
if profile.type == .remote {
|
||||
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 {
|
||||
parent.isUpdating = true
|
||||
Task {
|
||||
await parent.updateProfile(profile.origin)
|
||||
profile = ProfilePreview(profile.origin)
|
||||
await parent.deleteProfile(profile.origin)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
Button {
|
||||
shareLinkPresented = true
|
||||
} label: {
|
||||
Image(systemName: "qrcode")
|
||||
}
|
||||
.popover(isPresented: $shareLinkPresented, arrowEdge: .bottom) {
|
||||
shareLinkView
|
||||
Image(systemName: "trash.fill")
|
||||
}
|
||||
.padding([.leading, .trailing], 4)
|
||||
}
|
||||
ProfileShareButton(parent.$alert, profile.origin) {
|
||||
Image(systemName: "square.and.arrow.up.fill")
|
||||
}
|
||||
Button {
|
||||
parent.openWindow(id: EditProfileWindowView.windowID, value: profile.id)
|
||||
} label: {
|
||||
Image(systemName: "pencil")
|
||||
}
|
||||
Button {
|
||||
Task {
|
||||
await parent.deleteProfile(profile.origin)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "trash.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -412,6 +396,9 @@ public struct ProfileView: View {
|
||||
shareLinkView0
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
shareLinkView0
|
||||
.frame(minWidth: 300, minHeight: 300)
|
||||
#else
|
||||
shareLinkView0
|
||||
#endif
|
||||
|
||||
@@ -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 {
|
||||
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,155 @@
|
||||
#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 {
|
||||
FormSection {
|
||||
Toggle("Start At Login", isOn: $startAtLogin)
|
||||
.onChangeCompat(of: startAtLogin) { newValue in
|
||||
Task {
|
||||
updateLoginItems(newValue)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("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.")
|
||||
}
|
||||
|
||||
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 {
|
||||
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")) {}
|
||||
)
|
||||
}
|
||||
}
|
||||
} 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")) {}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,165 @@
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
struct PacketTunnelView: View {
|
||||
#if os(macOS)
|
||||
public static let windowID = "packet-tunnel"
|
||||
#endif
|
||||
|
||||
@State private var isLoading = true
|
||||
|
||||
@State private var ignoreMemoryLimit = false
|
||||
@State private var ignoreDeviceSleep = 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 {
|
||||
FormSection {
|
||||
Toggle("Ignore Memory Limit", isOn: $ignoreMemoryLimit)
|
||||
.onChangeCompat(of: ignoreMemoryLimit) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.ignoreMemoryLimit.set(newValue)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Do not enforce memory limits on sing-box. Will cause OOM on non-jailbroken iOS and tvOS devices.")
|
||||
}
|
||||
|
||||
FormSection {
|
||||
Toggle("Ignore Device Sleep", isOn: $ignoreDeviceSleep)
|
||||
.onChangeCompat(of: ignoreDeviceSleep) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.ignoreDeviceSleep.set(newValue)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Ignore system `sleep()` and `wake()` events. May cause increased power usage, only enable if you encounter unexpected `rejected ... while device paused` errors.")
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
|
||||
FormSection {
|
||||
Toggle("includeAllNetworks", isOn: $includeAllNetworks)
|
||||
.onChangeCompat(of: includeAllNetworks) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.includeAllNetworks.set(newValue)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("""
|
||||
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.
|
||||
|
||||
[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/3131931-includeallnetworks)
|
||||
""")
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
FormSection {
|
||||
Toggle("excludeAPNs", isOn: $excludeAPNs)
|
||||
.onChangeCompat(of: excludeAPNs) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.excludeAPNs.set(newValue)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("""
|
||||
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)
|
||||
""")
|
||||
}
|
||||
|
||||
FormSection {
|
||||
Toggle("excludeCellularServices", isOn: $excludeCellularServices)
|
||||
.onChangeCompat(of: excludeCellularServices) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.excludeCellularServices.set(newValue)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("""
|
||||
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)
|
||||
""")
|
||||
}
|
||||
|
||||
FormSection {
|
||||
Toggle("excludeLocalNetworks", isOn: $excludeLocalNetworks)
|
||||
.onChangeCompat(of: excludeLocalNetworks) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.excludeLocalNetworks.set(newValue)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("""
|
||||
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)
|
||||
""")
|
||||
}
|
||||
|
||||
FormSection {
|
||||
Toggle("enforceRoutes", isOn: $enforceRoutes)
|
||||
.onChangeCompat(of: enforceRoutes) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.enforceRoutes.set(newValue)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("""
|
||||
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)
|
||||
""")
|
||||
}
|
||||
|
||||
#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()
|
||||
ignoreDeviceSleep = await SharedPreferences.ignoreDeviceSleep.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,65 @@
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct ProfileOverrideView: View {
|
||||
@State private var isLoading = true
|
||||
@State private var excludeDefaultRoute = false
|
||||
@State private var autoRouteUseSubRangesByDefault = false
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
await loadSettings()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FormView {
|
||||
FormSection {
|
||||
Toggle("Hide VPN Icon", isOn: $excludeDefaultRoute)
|
||||
.onChangeCompat(of: excludeDefaultRoute) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.excludeDefaultRoute.set(newValue)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Append `0.0.0.0/31` to `inet4_route_exclude_address` if not exists.")
|
||||
}
|
||||
|
||||
FormSection {
|
||||
Toggle("No Default Route", isOn: $autoRouteUseSubRangesByDefault)
|
||||
.onChangeCompat(of: autoRouteUseSubRangesByDefault) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.autoRouteUseSubRangesByDefault.set(newValue)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("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)")
|
||||
}
|
||||
|
||||
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()
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
import Foundation
|
||||
import Library
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
public struct ServiceLogView: View {
|
||||
#if os(macOS)
|
||||
public static let windowID = "service-log"
|
||||
#endif
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var content = ""
|
||||
@State private var fileExporterPresented = false
|
||||
@State private var alert: Alert?
|
||||
|
||||
private let logFont = Font.system(.caption, design: .monospaced)
|
||||
|
||||
public init() {}
|
||||
@@ -41,26 +38,22 @@ public struct ServiceLogView: View {
|
||||
#if !os(tvOS)
|
||||
.toolbar {
|
||||
if !content.isEmpty {
|
||||
Button("Export") {
|
||||
fileExporterPresented = true
|
||||
ShareButtonCompat($alert) {
|
||||
Label("Export", systemImage: "square.and.arrow.up.fill")
|
||||
} itemURL: {
|
||||
try content.generateShareFile(name: "service.log")
|
||||
}
|
||||
Button("Delete", role: .destructive) {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await deleteContent()
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.fileExporter(
|
||||
isPresented: $fileExporterPresented,
|
||||
document: LogDocument(content),
|
||||
contentType: .text,
|
||||
defaultFilename: "service-log.txt",
|
||||
onCompletion: { _ in }
|
||||
)
|
||||
#endif
|
||||
.alertBinding($alert)
|
||||
.navigationTitle("Service Log")
|
||||
#if os(tvOS)
|
||||
.focusable()
|
||||
@@ -77,6 +70,27 @@ public struct ServiceLogView: View {
|
||||
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 = await "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
|
||||
@@ -91,28 +105,4 @@ public struct ServiceLogView: View {
|
||||
isLoading = true
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
private struct LogDocument: FileDocument {
|
||||
static var readableContentTypes = [UTType.text]
|
||||
|
||||
let content: String
|
||||
|
||||
init(_ content: String) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
if let data = configuration.file.regularFileContents {
|
||||
content = String(decoding: data, as: UTF8.self)
|
||||
} else {
|
||||
content = ""
|
||||
}
|
||||
}
|
||||
|
||||
func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper {
|
||||
FileWrapper(regularFileWithContents: Data(content.utf8))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,286 +1,137 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import ServiceManagement
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
public struct SettingView: View {
|
||||
#if os(macOS)
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
#endif
|
||||
private enum Tabs: Int, CaseIterable, Identifiable {
|
||||
public var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
@State private var isLoading = true
|
||||
#if os(macOS)
|
||||
case app
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
@State private var startAtLogin = false
|
||||
@Environment(\.showMenuBarExtra) private var showMenuBarExtra
|
||||
@State private var keepMenuBarInBackground = false
|
||||
#endif
|
||||
case core, packetTunnel, profileOverride, sponsor
|
||||
|
||||
@State private var alwaysOn = false
|
||||
@State private var disableMemoryLimit = false
|
||||
var label: some View {
|
||||
Label(title, systemImage: iconImage)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
@State private var includeAllNetworks = false
|
||||
#endif
|
||||
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 .profileOverride:
|
||||
return NSLocalizedString("Profile Override", comment: "")
|
||||
case .sponsor:
|
||||
return NSLocalizedString("Sponsor", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
@State private var ignoreDeviceSleep = false
|
||||
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 .profileOverride:
|
||||
return "square.dashed.inset.filled"
|
||||
case .sponsor:
|
||||
return "heart.fill"
|
||||
}
|
||||
}
|
||||
|
||||
@State private var version = ""
|
||||
@State private var dataSize = ""
|
||||
@MainActor
|
||||
var contentView: some View {
|
||||
viewBuilder {
|
||||
switch self {
|
||||
#if os(macOS)
|
||||
case .app:
|
||||
MacAppView()
|
||||
#endif
|
||||
case .core:
|
||||
CoreView()
|
||||
case .packetTunnel:
|
||||
PacketTunnelView()
|
||||
case .profileOverride:
|
||||
ProfileOverrideView()
|
||||
case .sponsor:
|
||||
SponsorView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
#if os(iOS)
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var navigationLink: some View {
|
||||
NavigationLink {
|
||||
contentView
|
||||
} label: {
|
||||
label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var isLoading = false
|
||||
@State private var taiwanFlagAvailable = false
|
||||
@State private var alert: Alert?
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
viewBuilder {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task {
|
||||
await loadSettings()
|
||||
}
|
||||
FormView {
|
||||
#if os(macOS)
|
||||
Tabs.app.navigationLink
|
||||
#endif
|
||||
ForEach([Tabs.core, Tabs.packetTunnel, Tabs.profileOverride]) { it in
|
||||
it.navigationLink
|
||||
}
|
||||
Section("About") {
|
||||
Link(destination: URL(string: "https://sing-box.sagernet.org/")!) {
|
||||
Label("Documentation", systemImage: "doc.on.doc.fill")
|
||||
}
|
||||
} else {
|
||||
FormView {
|
||||
#if os(macOS)
|
||||
Section("MacOS") {
|
||||
Toggle("Start At Login", isOn: $startAtLogin)
|
||||
.onChangeCompat(of: startAtLogin) { newValue in
|
||||
Task {
|
||||
updateLoginItems(newValue)
|
||||
}
|
||||
}
|
||||
Toggle("Show in Menu Bar", isOn: showMenuBarExtra)
|
||||
.onChange(of: showMenuBarExtra.wrappedValue) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.showMenuBarExtra.set(newValue)
|
||||
if !newValue {
|
||||
keepMenuBarInBackground = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if showMenuBarExtra.wrappedValue {
|
||||
Toggle("Keep Menu Bar in Background", isOn: $keepMenuBarInBackground)
|
||||
.onChangeCompat(of: keepMenuBarInBackground) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.menuBarExtraInBackground.set(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Section("Packet Tunnel") {
|
||||
Toggle("Always On", isOn: $alwaysOn)
|
||||
.onChangeCompat(of: alwaysOn) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.alwaysOn.set(newValue)
|
||||
await updateAlwaysOn(newValue)
|
||||
}
|
||||
}
|
||||
Toggle("Disable Memory Limit", isOn: $disableMemoryLimit)
|
||||
.onChangeCompat(of: disableMemoryLimit) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.disableMemoryLimit.set(newValue)
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
Toggle("Include All Networks", isOn: $includeAllNetworks)
|
||||
.onChangeCompat(of: includeAllNetworks) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.includeAllNetworks.set(newValue)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Toggle("Ignore Device Sleep", isOn: $ignoreDeviceSleep)
|
||||
.onChangeCompat(of: ignoreDeviceSleep) { newValue in
|
||||
Task {
|
||||
await SharedPreferences.ignoreDeviceSleep.set(newValue)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension {
|
||||
HStack {
|
||||
Button("Update System Extension") {
|
||||
Task {
|
||||
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")) {}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button {
|
||||
Task {
|
||||
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")) {}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("Uninstall System Extension").foregroundColor(.red)
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
#endif
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
#if !os(tvOS)
|
||||
RequestReviewButton {
|
||||
Label("Rate on the App Store", systemImage: "text.bubble.fill")
|
||||
}
|
||||
Section("Core") {
|
||||
FormTextItem("Version", version)
|
||||
FormTextItem("Data Size", dataSize)
|
||||
#if os(iOS) || os(tvOS)
|
||||
NavigationLink(destination: ServiceLogView()) {
|
||||
Text("View Service Log")
|
||||
}
|
||||
Button("Clear Working Directory") {
|
||||
Task {
|
||||
await clearWorkingDirectory()
|
||||
#endif
|
||||
Tabs.sponsor.navigationLink
|
||||
}
|
||||
Section("Debug") {
|
||||
NavigationLink {
|
||||
ServiceLogView()
|
||||
} label: {
|
||||
Label("Service Log", systemImage: "doc.on.clipboard")
|
||||
}
|
||||
FormTextItem("Taiwan Flag Available", "touchid") {
|
||||
if isLoading {
|
||||
Text("Loading...")
|
||||
.onAppear {
|
||||
Task.detached {
|
||||
taiwanFlagAvailable = !DeviceCensorship.isChinaDevice()
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
#elseif os(macOS)
|
||||
HStack {
|
||||
Button("View Service Log") {
|
||||
openWindow(id: ServiceLogView.windowID)
|
||||
}
|
||||
Button("Open Working Directory") {
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: FilePath.workingDirectory.relativePath)
|
||||
}
|
||||
Button {
|
||||
Task {
|
||||
await clearWorkingDirectory()
|
||||
}
|
||||
} label: {
|
||||
Text("Clear Working Directory").foregroundColor(.red)
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
#endif
|
||||
}
|
||||
Section("Debug") {
|
||||
FormTextItem("Taiwan Flag Available", taiwanFlagAvailable.description)
|
||||
} else {
|
||||
Text(taiwanFlagAvailable.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alertBinding($alert)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func updateLoginItems(_ startAtLogin: Bool) {
|
||||
do {
|
||||
if startAtLogin {
|
||||
if SMAppService.mainApp.status == .enabled {
|
||||
try? SMAppService.mainApp.unregister()
|
||||
}
|
||||
|
||||
try SMAppService.mainApp.register()
|
||||
} else {
|
||||
try SMAppService.mainApp.unregister()
|
||||
}
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func loadSettings() async {
|
||||
#if os(macOS)
|
||||
startAtLogin = SMAppService.mainApp.status == .enabled
|
||||
keepMenuBarInBackground = await SharedPreferences.menuBarExtraInBackground.get()
|
||||
#endif
|
||||
alwaysOn = await SharedPreferences.alwaysOn.get()
|
||||
disableMemoryLimit = await SharedPreferences.disableMemoryLimit.get()
|
||||
#if !os(tvOS)
|
||||
includeAllNetworks = await SharedPreferences.includeAllNetworks.get()
|
||||
#endif
|
||||
ignoreDeviceSleep = await SharedPreferences.ignoreDeviceSleep.get()
|
||||
if ApplicationLibrary.inPreview {
|
||||
version = "<redacted>"
|
||||
dataSize = LibboxFormatBytes(1000 * 1000 * 10)
|
||||
taiwanFlagAvailable = true
|
||||
isLoading = false
|
||||
} else {
|
||||
version = LibboxVersion()
|
||||
dataSize = "Loading..."
|
||||
taiwanFlagAvailable = !DeviceCensorship.isChinaDevice()
|
||||
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 clearWorkingDirectory() async {
|
||||
try? FileManager.default.removeItem(at: FilePath.workingDirectory)
|
||||
await MainActor.run {
|
||||
isLoading = true
|
||||
}
|
||||
}
|
||||
|
||||
private func updateAlwaysOn(_ newState: Bool) async {
|
||||
guard let profile = try? await ExtensionProfile.load() else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await profile.updateAlwaysOn(newState)
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
|
||||
public struct SponsorView: View {
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var products: [Product] = []
|
||||
@State private var subscriptionError: Error?
|
||||
@State private var isPurchasing = false
|
||||
@State private var alert: Alert?
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Section("Without commission") {
|
||||
FormButton("GitHub Sponsor (recommended)") {
|
||||
openURL(URL(string: "https://github.com/sponsors/nekohasekai")!)
|
||||
}
|
||||
FormButton("Other methods") {
|
||||
openURL(URL(string: "https://sekai.icu/sponsor/")!)
|
||||
}
|
||||
}
|
||||
Section("Via App Store") {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.onAppear {
|
||||
Task.detached {
|
||||
await loadProducts()
|
||||
}
|
||||
}
|
||||
} else if let subscriptionError {
|
||||
Text("Sponsor via App Store not available: \(subscriptionError.localizedDescription)")
|
||||
} else {
|
||||
ForEach(products, id: \.id) { it in
|
||||
FormButton(it.displayName) {
|
||||
isPurchasing = true
|
||||
Task.detached {
|
||||
do {
|
||||
let result = try await it.purchase()
|
||||
switch result {
|
||||
case .success:
|
||||
alert = Alert(title: Text("Success"), message: Text("Thank u."))
|
||||
case .pending:
|
||||
break
|
||||
case .userCancelled:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
alert = Alert(error)
|
||||
}
|
||||
isPurchasing = false
|
||||
}
|
||||
}
|
||||
.disabled(isPurchasing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alertBinding($alert)
|
||||
.navigationTitle("Sponsor")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func loadProducts() async {
|
||||
defer {
|
||||
isLoading = false
|
||||
}
|
||||
do {
|
||||
let productIds = ["sponsor_1_1", "sponsor_10", "sponsor_100"]
|
||||
products = try await Product.products(for: productIds)
|
||||
} catch {
|
||||
subscriptionError = error
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user