Improve UI

This commit is contained in:
世界
2024-02-08 10:37:37 +08:00
parent 70d43abd52
commit 1f6e99f576
32 changed files with 1201 additions and 646 deletions
@@ -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 doesnt 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
}
+114 -263
View File
@@ -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 Ive 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
}
}
}