refactor: Fix macOS standalone application
This commit is contained in:
@@ -100,7 +100,7 @@ public class ProfileServer {
|
||||
try await processProfileContentRequest(data)
|
||||
}
|
||||
default:
|
||||
throw NSError(domain: "unexpected message type \(messageType)", code: 0)
|
||||
throw NSError(domain: "ProfileServer", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Unexpected message type \(messageType)")])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ public class ProfileServer {
|
||||
|
||||
let profile = try await ProfileManager.get(request!.profileID)
|
||||
guard let profile else {
|
||||
throw NSError(domain: "profile not found", code: 0)
|
||||
throw NSError(domain: "ProfileServer", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Profile not found")])
|
||||
}
|
||||
let content = LibboxProfileContent()
|
||||
content.name = profile.name
|
||||
|
||||
@@ -19,7 +19,7 @@ import Library
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
throw NSError(domain: "register task failed", code: 0)
|
||||
throw NSError(domain: "UIProfileUpdateTask", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Register task failed")])
|
||||
}
|
||||
registered = true
|
||||
}
|
||||
|
||||
@@ -1,121 +1,8 @@
|
||||
import Library
|
||||
@_exported import struct Library.AlertState
|
||||
import SwiftUI
|
||||
|
||||
public struct AlertState: Equatable {
|
||||
public var title: String
|
||||
public var message: String
|
||||
public var primaryButton: ButtonState?
|
||||
public var secondaryButton: ButtonState?
|
||||
public var onDismiss: (() -> Void)?
|
||||
|
||||
public struct ButtonState: Equatable {
|
||||
public var label: String
|
||||
public var role: ButtonRole?
|
||||
public var action: (() -> Void)?
|
||||
|
||||
public init(label: String, role: ButtonRole? = nil, action: (() -> Void)? = nil) {
|
||||
self.label = label
|
||||
self.role = role
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public static func == (lhs: ButtonState, rhs: ButtonState) -> Bool {
|
||||
lhs.label == rhs.label && lhs.role == rhs.role
|
||||
}
|
||||
|
||||
public static func `default`(_ label: String, action: (() -> Void)? = nil) -> ButtonState {
|
||||
ButtonState(label: label, action: action)
|
||||
}
|
||||
|
||||
public static func cancel(_ label: String = String(localized: "Cancel"), action: (() -> Void)? = nil) -> ButtonState {
|
||||
ButtonState(label: label, role: .cancel, action: action)
|
||||
}
|
||||
|
||||
public static func destructive(_ label: String, action: (() -> Void)? = nil) -> ButtonState {
|
||||
ButtonState(label: label, role: .destructive, action: action)
|
||||
}
|
||||
}
|
||||
|
||||
public init(error: Error, dismiss: (() -> Void)? = nil) {
|
||||
self.init(errorMessage: error.localizedDescription, dismiss: dismiss)
|
||||
}
|
||||
|
||||
public init(errorMessage: String, dismiss: (() -> Void)? = nil) {
|
||||
title = String(localized: "Error")
|
||||
message = errorMessage
|
||||
primaryButton = .default(String(localized: "Ok"), action: dismiss)
|
||||
secondaryButton = nil
|
||||
onDismiss = nil
|
||||
}
|
||||
|
||||
public init(title: String, message: String, dismissButton: ButtonState? = nil) {
|
||||
self.title = title
|
||||
self.message = message
|
||||
primaryButton = dismissButton ?? .default(String(localized: "Ok"))
|
||||
secondaryButton = nil
|
||||
onDismiss = nil
|
||||
}
|
||||
|
||||
public init(title: String, message: String, primaryButton: ButtonState, secondaryButton: ButtonState) {
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.primaryButton = primaryButton
|
||||
self.secondaryButton = secondaryButton
|
||||
onDismiss = nil
|
||||
}
|
||||
|
||||
public init(title: String, message: String, primaryButton: ButtonState, secondaryButton: ButtonState, onDismiss: @escaping () -> Void) {
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.primaryButton = primaryButton
|
||||
self.secondaryButton = secondaryButton
|
||||
self.onDismiss = onDismiss
|
||||
}
|
||||
|
||||
public static func == (lhs: AlertState, rhs: AlertState) -> Bool {
|
||||
lhs.title == rhs.title && lhs.message == rhs.message &&
|
||||
lhs.primaryButton == rhs.primaryButton && lhs.secondaryButton == rhs.secondaryButton
|
||||
}
|
||||
}
|
||||
|
||||
public extension View {
|
||||
@ViewBuilder
|
||||
func alert(_ binding: Binding<AlertState?>) -> some View {
|
||||
alert(
|
||||
binding.wrappedValue?.title ?? "",
|
||||
isPresented: Binding(
|
||||
get: { binding.wrappedValue != nil },
|
||||
set: { newValue, _ in
|
||||
if !newValue {
|
||||
binding.wrappedValue?.onDismiss?()
|
||||
binding.wrappedValue = nil
|
||||
}
|
||||
}
|
||||
),
|
||||
presenting: binding.wrappedValue
|
||||
) { alertState in
|
||||
if let secondary = alertState.secondaryButton {
|
||||
Button(role: alertState.primaryButton?.role) {
|
||||
alertState.primaryButton?.action?()
|
||||
} label: {
|
||||
Text(alertState.primaryButton?.label ?? "Ok")
|
||||
}
|
||||
Button(role: secondary.role) {
|
||||
secondary.action?()
|
||||
} label: {
|
||||
Text(secondary.label)
|
||||
}
|
||||
} else if let primary = alertState.primaryButton {
|
||||
Button(role: primary.role) {
|
||||
primary.action?()
|
||||
} label: {
|
||||
Text(primary.label)
|
||||
}
|
||||
}
|
||||
} message: { alertState in
|
||||
Text(alertState.message)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func alert(_ binding: Binding<AlertState?>, isLoading: Binding<Bool>) -> some View {
|
||||
alert(
|
||||
|
||||
@@ -153,3 +153,15 @@ public func FormNavigationLink(@ViewBuilder destination: () -> some View, @ViewB
|
||||
}, label: label)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
public func FormNavigationLink(value: some Hashable, @ViewBuilder label: () -> some View) -> some View {
|
||||
NavigationLink(value: value, label: label)
|
||||
}
|
||||
|
||||
public extension View {
|
||||
func formNavigationDestination<D: Hashable>(for data: D.Type, @ViewBuilder destination: @escaping (D) -> some View) -> some View {
|
||||
navigationDestination(for: data, destination: destination)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import NetworkExtension
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import CoreLocation
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
@MainActor
|
||||
private final class WIFIStateLocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
|
||||
private let manager = CLLocationManager()
|
||||
var onAuthorizationGranted: (() -> Void)?
|
||||
private var pendingAuthorizationRequest = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
}
|
||||
|
||||
func requestAuthorizationAndShowWarning() {
|
||||
let status = manager.authorizationStatus
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
pendingAuthorizationRequest = true
|
||||
manager.requestAlwaysAuthorization()
|
||||
case .authorized, .authorizedAlways:
|
||||
onAuthorizationGranted?()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
Task { @MainActor in
|
||||
guard self.pendingAuthorizationRequest else { return }
|
||||
self.pendingAuthorizationRequest = false
|
||||
let status = manager.authorizationStatus
|
||||
if status == .authorized || status == .authorizedAlways {
|
||||
self.onAuthorizationGranted?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
public struct GlobalChecksModifier: ViewModifier {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@Environment(\.importProfile) private var importProfile
|
||||
@Environment(\.importRemoteProfile) private var importRemoteProfile
|
||||
@Environment(\.selection) private var selection
|
||||
|
||||
@State private var alert: AlertState?
|
||||
@State private var notStarted = false
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
#if os(macOS)
|
||||
@StateObject private var wifiLocationManager = WIFIStateLocationManager()
|
||||
#endif
|
||||
|
||||
public init() {}
|
||||
|
||||
public func body(content: Content) -> some View {
|
||||
contentView(content)
|
||||
.alert($alert)
|
||||
.onAppear {
|
||||
handleImportProfile()
|
||||
handleImportRemoteProfile()
|
||||
}
|
||||
.onChangeCompat(of: importProfile.wrappedValue) { _ in
|
||||
handleImportProfile()
|
||||
}
|
||||
.onChangeCompat(of: importRemoteProfile.wrappedValue) { _ in
|
||||
handleImportRemoteProfile()
|
||||
}
|
||||
.onChangeCompat(of: environments.extensionProfile?.status) { status in
|
||||
handleStatusChange(status)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func contentView(_ content: Content) -> some View {
|
||||
#if os(macOS)
|
||||
content
|
||||
.onReceive(NotificationCenter.default.publisher(for: .extensionRequiresWIFIState)) { _ in
|
||||
handleWiFiStateNotification()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .extensionRequiresHelperService)) { _ in
|
||||
handleHelperServiceNotification()
|
||||
}
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
|
||||
private func handleImportProfile() {
|
||||
guard let profile = importProfile.wrappedValue else { return }
|
||||
importProfile.wrappedValue = nil
|
||||
alert = AlertState(
|
||||
title: String(localized: "Import Profile"),
|
||||
message: String(localized: "Are you sure to import profile \(profile.name)?"),
|
||||
primaryButton: .default(String(localized: "Import")) { [weak environments, selection] in
|
||||
selection.wrappedValue = .dashboard
|
||||
Task {
|
||||
do {
|
||||
try await profile.importProfile()
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = AlertState(error: error)
|
||||
}
|
||||
return
|
||||
}
|
||||
environments?.profileUpdate.send()
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
private func handleImportRemoteProfile() {
|
||||
guard let remoteProfile = importRemoteProfile.wrappedValue else { return }
|
||||
importRemoteProfile.wrappedValue = nil
|
||||
alert = AlertState(
|
||||
title: String(localized: "Import Remote Profile"),
|
||||
message: String(localized: "Are you sure to import remote profile \(remoteProfile.name)? You will connect to \(remoteProfile.host) to download the configuration."),
|
||||
primaryButton: .default(String(localized: "Import")) { [weak environments, selection] in
|
||||
selection.wrappedValue = .dashboard
|
||||
environments?.pendingImportRemoteProfile = ImportRemoteProfileRequest(name: remoteProfile.name, url: remoteProfile.url)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
|
||||
private func handleStatusChange(_ status: NEVPNStatus?) {
|
||||
guard let status else { return }
|
||||
switch status {
|
||||
case .connected:
|
||||
notStarted = false
|
||||
Task {
|
||||
await checkDeprecatedNotes()
|
||||
}
|
||||
case .connecting:
|
||||
notStarted = true
|
||||
case .disconnected:
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) {
|
||||
if notStarted, let profile = environments.extensionProfile {
|
||||
Task {
|
||||
await checkLastDisconnectError(profile: profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
notStarted = false
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, macOS 13.0, tvOS 17.0, *)
|
||||
private nonisolated func checkLastDisconnectError(profile: ExtensionProfile) async {
|
||||
if let alertState = await profile.checkLastDisconnectError() {
|
||||
await MainActor.run {
|
||||
alert = alertState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func checkDeprecatedNotes() async {
|
||||
let disableWarnings = await SharedPreferences.disableDeprecatedWarnings.get()
|
||||
guard !disableWarnings else { return }
|
||||
|
||||
do {
|
||||
let reports = try LibboxNewStandaloneCommandClient()!.getDeprecatedNotes()
|
||||
if reports.hasNext() {
|
||||
await MainActor.run {
|
||||
showNextDeprecatedNote(reports)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alert = AlertState(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showNextDeprecatedNote(_ reports: any LibboxDeprecatedNoteIteratorProtocol) {
|
||||
guard reports.hasNext() else { return }
|
||||
|
||||
let report = reports.next()!
|
||||
let continueChain: () -> Void = {
|
||||
Task.detached {
|
||||
try? await Task.sleep(nanoseconds: 300 * NSEC_PER_MSEC)
|
||||
await MainActor.run {
|
||||
showNextDeprecatedNote(reports)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if report.migrationLink.isEmpty {
|
||||
var state = AlertState(
|
||||
title: String(localized: "Deprecated Warning"),
|
||||
message: report.message(),
|
||||
dismissButton: .cancel(String(localized: "Ok"))
|
||||
)
|
||||
state.onDismiss = continueChain
|
||||
alert = state
|
||||
} else {
|
||||
alert = AlertState(
|
||||
title: String(localized: "Deprecated Warning"),
|
||||
message: report.message(),
|
||||
primaryButton: .default(String(localized: "Documentation")) {
|
||||
openURL(URL(string: report.migrationLink)!)
|
||||
},
|
||||
secondaryButton: .cancel(String(localized: "Ok")),
|
||||
onDismiss: continueChain
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func handleWiFiStateNotification() {
|
||||
guard Variant.useSystemExtension else { return }
|
||||
wifiLocationManager.onAuthorizationGranted = {
|
||||
alert = AlertState(
|
||||
title: String(localized: "WiFi State Access"),
|
||||
message: String(localized: "In the standalone version of SFM, reading WiFi state requires this app to be running. After you quit the SFM app, the sing-box service cannot continue to provide `wifi_ssid` and `wifi_bssid` routing rules.")
|
||||
)
|
||||
}
|
||||
wifiLocationManager.requestAuthorizationAndShowWarning()
|
||||
}
|
||||
|
||||
private func handleHelperServiceNotification() {
|
||||
guard Variant.useSystemExtension, HelperServiceManager.rootHelperStatus != .enabled else { return }
|
||||
alert = AlertState(
|
||||
title: String(localized: "Helper Service Required"),
|
||||
message: String(localized: "The sing-box service requires Helper Service to provide process lookup functionality, which supports `process_name` and `process_path` routing rules."),
|
||||
primaryButton: .default(String(localized: "App Settings")) {
|
||||
NotificationCenter.default.post(name: .navigateToSettingsPage, object: SettingsPage.app)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
public extension View {
|
||||
func globalChecks() -> some View {
|
||||
modifier(GlobalChecksModifier())
|
||||
}
|
||||
}
|
||||
@@ -495,10 +495,10 @@ extension ProfileCard {
|
||||
@Published var profileToEdit: Profile?
|
||||
@Published var shareItemType: ShareItemType?
|
||||
#if !os(tvOS)
|
||||
@Published var profileExportDocument: ProfileExportDocument?
|
||||
@Published var showProfileExporter = false
|
||||
@Published var profileJSONExportDocument: ProfileJSONExportDocument?
|
||||
@Published var showJSONExporter = false
|
||||
@Published var profileExportDocument: ProfileExportDocument?
|
||||
@Published var showProfileExporter = false
|
||||
@Published var profileJSONExportDocument: ProfileJSONExportDocument?
|
||||
@Published var showJSONExporter = false
|
||||
#endif
|
||||
#if os(macOS)
|
||||
var shareButtonView: NSView?
|
||||
@@ -511,10 +511,7 @@ extension ProfileCard {
|
||||
try await profile.updateRemoteProfile()
|
||||
environments.profileUpdate.send()
|
||||
} catch {
|
||||
alert = AlertState(
|
||||
title: String(localized: "Update Failed"),
|
||||
message: error.localizedDescription
|
||||
)
|
||||
alert = AlertState(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,10 +402,7 @@ struct ProfilePickerSheet: View {
|
||||
try await profile.origin.updateRemoteProfile()
|
||||
environments.profileUpdate.send()
|
||||
} catch {
|
||||
alert = AlertState(
|
||||
title: String(localized: "Update Failed"),
|
||||
message: error.localizedDescription
|
||||
)
|
||||
alert = AlertState(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,8 @@ import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct DashboardView: View {
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Environment(\.importProfile) private var importProfile
|
||||
@Environment(\.importRemoteProfile) private var importRemoteProfile
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@StateObject private var coordinator = DashboardViewModel()
|
||||
@State private var importRemoteProfileRequest: NewProfileView.ImportRequest?
|
||||
|
||||
#if os(macOS)
|
||||
@Environment(\.controlActiveState) private var controlActiveState
|
||||
@@ -19,24 +15,16 @@ public struct DashboardView: View {
|
||||
|
||||
public var body: some View {
|
||||
content
|
||||
.alert($coordinator.alert)
|
||||
.onAppear {
|
||||
coordinator.setOpenURL { openURL($0) }
|
||||
coordinator.setEnvironments(environments)
|
||||
#if os(macOS)
|
||||
Task { await coordinator.reload() }
|
||||
#endif
|
||||
handleImportProfile()
|
||||
handleImportRemoteProfile()
|
||||
}
|
||||
.onChangeCompat(of: importProfile.wrappedValue) { _ in
|
||||
handleImportProfile()
|
||||
}
|
||||
.onChangeCompat(of: importRemoteProfile.wrappedValue) { _ in
|
||||
handleImportRemoteProfile()
|
||||
}
|
||||
#if os(tvOS)
|
||||
.navigationDestination(item: $importRemoteProfileRequest) { request in
|
||||
NewProfileView(request)
|
||||
.navigationDestination(item: $environments.pendingImportRemoteProfile) { request in
|
||||
NewProfileView(.init(name: request.name, url: request.url))
|
||||
.environmentObject(environments)
|
||||
.onDisappear {
|
||||
environments.profileUpdate.send()
|
||||
@@ -48,7 +36,7 @@ public struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
#else
|
||||
.sheet(item: $importRemoteProfileRequest) { request in
|
||||
.sheet(item: $environments.pendingImportRemoteProfile) { request in
|
||||
importRemoteProfileSheet(for: request)
|
||||
}
|
||||
#endif
|
||||
@@ -60,48 +48,12 @@ public struct DashboardView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
private func handleImportProfile() {
|
||||
if let profile = importProfile.wrappedValue {
|
||||
importProfile.wrappedValue = nil
|
||||
coordinator.alert = AlertState(
|
||||
title: String(localized: "Import Profile"),
|
||||
message: String(localized: "Are you sure to import profile \(profile.name)?"),
|
||||
primaryButton: .default(String(localized: "Import")) {
|
||||
Task {
|
||||
do {
|
||||
try await profile.importProfile()
|
||||
} catch {
|
||||
coordinator.alert = AlertState(error: error)
|
||||
return
|
||||
}
|
||||
environments.profileUpdate.send()
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleImportRemoteProfile() {
|
||||
if let remoteProfile = importRemoteProfile.wrappedValue {
|
||||
importRemoteProfile.wrappedValue = nil
|
||||
coordinator.alert = AlertState(
|
||||
title: String(localized: "Import Remote Profile"),
|
||||
message: String(localized: "Are you sure to import remote profile \(remoteProfile.name)? You will connect to \(remoteProfile.host) to download the configuration."),
|
||||
primaryButton: .default(String(localized: "Import")) {
|
||||
importRemoteProfileRequest = .init(name: remoteProfile.name, url: remoteProfile.url)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func importRemoteProfileSheet(for request: NewProfileView.ImportRequest) -> some View {
|
||||
private func importRemoteProfileSheet(for request: ImportRemoteProfileRequest) -> some View {
|
||||
NavigationSheet(title: "Import Profile", onDismiss: {
|
||||
environments.profileUpdate.send()
|
||||
}, content: {
|
||||
NewProfileView(request)
|
||||
NewProfileView(.init(name: request.name, url: request.url))
|
||||
.environmentObject(environments)
|
||||
})
|
||||
}
|
||||
@@ -132,9 +84,13 @@ public struct DashboardView: View {
|
||||
} else if let profile = environments.extensionProfile {
|
||||
activeDashboardView
|
||||
.environmentObject(profile)
|
||||
.alert($coordinator.alert)
|
||||
.onChangeCompat(of: profile.status) { status in
|
||||
coordinator.handleStatusChange(status, profile: profile)
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension, status == .connected {
|
||||
UserServiceEndpointPublisher.shared.refreshEndpointRegistration()
|
||||
UserServiceEndpointPublisher.shared.checkExtensionRequirements()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
FormView {
|
||||
|
||||
@@ -2,12 +2,15 @@ import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import NetworkExtension
|
||||
import os
|
||||
import SwiftUI
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
private let logger = Logger(category: "DashboardViewModel")
|
||||
|
||||
@MainActor
|
||||
public final class DashboardViewModel: BaseViewModel {
|
||||
@Published public var profileList: [ProfilePreview] = []
|
||||
@@ -15,14 +18,12 @@ public final class DashboardViewModel: BaseViewModel {
|
||||
@Published public var selection = DashboardPage.overview
|
||||
@Published public var systemProxyAvailable = false
|
||||
@Published public var systemProxyEnabled = false
|
||||
@Published public var notStarted = false
|
||||
|
||||
#if os(macOS)
|
||||
@Published public var systemExtensionInstalled = true
|
||||
#endif
|
||||
|
||||
private weak var environments: ExtensionEnvironments?
|
||||
private var openURL: ((URL) -> Void)?
|
||||
|
||||
public func setEnvironments(_ environments: ExtensionEnvironments) {
|
||||
self.environments = environments
|
||||
@@ -33,10 +34,6 @@ public final class DashboardViewModel: BaseViewModel {
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
public func setOpenURL(_ openURL: @escaping (URL) -> Void) {
|
||||
self.openURL = openURL
|
||||
}
|
||||
|
||||
public func reload() async {
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension {
|
||||
@@ -86,84 +83,13 @@ public final class DashboardViewModel: BaseViewModel {
|
||||
systemProxyAvailable = status.available
|
||||
systemProxyEnabled = status.enabled
|
||||
} catch {
|
||||
NSLog("reloadSystemProxy: \(error)")
|
||||
logger.debug("reloadSystemProxy: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
public func updateSelectedProfile() async {
|
||||
selectedProfileID = await SharedPreferences.selectedProfileID.get()
|
||||
}
|
||||
|
||||
public func handleStatusChange(_ status: NEVPNStatus, profile: ExtensionProfile) {
|
||||
if status == .connected {
|
||||
notStarted = false
|
||||
Task { await checkDeprecatedNotes() }
|
||||
} else if status == .connecting {
|
||||
notStarted = true
|
||||
} else if status == .disconnected {
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) {
|
||||
if notStarted {
|
||||
Task { await checkLastDisconnectError(profile: profile) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func checkDeprecatedNotes() async {
|
||||
let disableWarnings = await SharedPreferences.disableDeprecatedWarnings.get()
|
||||
guard !disableWarnings else { return }
|
||||
|
||||
do {
|
||||
let reports = try LibboxNewStandaloneCommandClient()!.getDeprecatedNotes()
|
||||
if reports.hasNext() {
|
||||
await MainActor.run {
|
||||
loopShowDeprecateNotes(reports)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
NSLog("checkDeprecatedNotes: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func loopShowDeprecateNotes(_ reports: any LibboxDeprecatedNoteIteratorProtocol) {
|
||||
guard reports.hasNext() else { return }
|
||||
|
||||
let report = reports.next()!
|
||||
let continueChain: () -> Void = { [weak self] in
|
||||
_ = Task.detached {
|
||||
try? await Task.sleep(nanoseconds: 300 * NSEC_PER_MSEC)
|
||||
await self?.loopShowDeprecateNotes(reports)
|
||||
}
|
||||
}
|
||||
|
||||
if report.migrationLink.isEmpty {
|
||||
alert = AlertState(
|
||||
title: String(localized: "Deprecated Warning"),
|
||||
message: report.message(),
|
||||
dismissButton: .cancel(String(localized: "Ok"))
|
||||
)
|
||||
alert?.onDismiss = continueChain
|
||||
} else {
|
||||
alert = AlertState(
|
||||
title: String(localized: "Deprecated Warning"),
|
||||
message: report.message(),
|
||||
primaryButton: .default(String(localized: "Documentation")) {
|
||||
self.openURL?(URL(string: report.migrationLink)!)
|
||||
},
|
||||
secondaryButton: .cancel(String(localized: "Ok")),
|
||||
onDismiss: continueChain
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, macOS 13.0, tvOS 17.0, *)
|
||||
nonisolated func checkLastDisconnectError(profile: ExtensionProfile) async {
|
||||
if let alertState = await profile.checkLastDisconnectError() {
|
||||
await MainActor.run {
|
||||
alert = alertState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, macOS 13.0, tvOS 17.0, *)
|
||||
@@ -184,7 +110,7 @@ extension ExtensionProfile {
|
||||
)
|
||||
}
|
||||
#endif
|
||||
return AlertState(title: String(localized: "Service Error"), message: nsError.localizedDescription)
|
||||
return AlertState(error: nsError)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ public final class OverviewViewModel: BaseViewModel {
|
||||
|
||||
if profile.status.isConnected {
|
||||
do {
|
||||
try await serviceReload()
|
||||
try await profile.reloadService()
|
||||
} catch {
|
||||
alert = AlertState(error: error)
|
||||
}
|
||||
@@ -21,10 +21,6 @@ public final class OverviewViewModel: BaseViewModel {
|
||||
reasserting = false
|
||||
}
|
||||
|
||||
public nonisolated func serviceReload() async throws {
|
||||
try LibboxNewStandaloneCommandClient()!.serviceReload()
|
||||
}
|
||||
|
||||
public nonisolated func setSystemProxyEnabled(_ enabled: Bool, profile: ExtensionProfile) async {
|
||||
do {
|
||||
await SharedPreferences.systemProxyEnabled.set(enabled)
|
||||
|
||||
@@ -86,7 +86,9 @@ private struct LogViewContent: View {
|
||||
let button = UIButton(type: .system)
|
||||
let config = UIImage.SymbolConfiguration(scale: .large)
|
||||
button.setImage(UIImage(systemName: "line.3.horizontal.circle", withConfiguration: config), for: .normal)
|
||||
button.tintColor = colorScheme == .dark ? .white : .black
|
||||
if #available(iOS 17.0, *) {
|
||||
button.tintColor = colorScheme == .dark ? .white : .black
|
||||
}
|
||||
button.showsMenuAsPrimaryAction = true
|
||||
button.menu = createMenu()
|
||||
button.setContentHuggingPriority(.required, for: .horizontal)
|
||||
@@ -96,7 +98,9 @@ private struct LogViewContent: View {
|
||||
|
||||
func updateUIView(_ uiView: UIButton, context _: Context) {
|
||||
uiView.menu = createMenu()
|
||||
uiView.tintColor = colorScheme == .dark ? .white : .black
|
||||
if #available(iOS 17.0, *) {
|
||||
uiView.tintColor = colorScheme == .dark ? .white : .black
|
||||
}
|
||||
}
|
||||
|
||||
private func createMenu() -> UIMenu {
|
||||
|
||||
@@ -86,9 +86,11 @@ public class LogDataModel: ObservableObject {
|
||||
lastProcessedLogCount = 0
|
||||
lastEffectiveLevel = nil
|
||||
lastSearchText = ""
|
||||
filteredLogs = []
|
||||
visibleLogs = []
|
||||
commandClient.clearLogs()
|
||||
Task.detached {
|
||||
let client = LibboxNewStandaloneCommandClient()
|
||||
try? client?.clearLogs()
|
||||
try? LibboxNewStandaloneCommandClient()!.clearLogs()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,10 +39,10 @@ public final class EditProfileContentViewModel: BaseViewModel {
|
||||
|
||||
private nonisolated func loadContentBackground() async throws {
|
||||
guard let profileID else {
|
||||
throw NSError(domain: "Context destroyed", code: 0)
|
||||
throw NSError(domain: "EditProfileContentViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Context destroyed")])
|
||||
}
|
||||
guard let profile = try await ProfileManager.get(profileID) else {
|
||||
throw NSError(domain: "Profile missing", code: 0)
|
||||
throw NSError(domain: "EditProfileContentViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Profile missing")])
|
||||
}
|
||||
let profileContent = try profile.read()
|
||||
await MainActor.run {
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
do {
|
||||
message = try socket.read()
|
||||
} catch {
|
||||
throw NSError(domain: "read from connection: \(error.localizedDescription)", code: 0)
|
||||
throw NSError(domain: "ImportProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Read from connection: \(error.localizedDescription)")])
|
||||
}
|
||||
var error: NSError?
|
||||
switch Int64(message[0]) {
|
||||
@@ -71,7 +71,7 @@
|
||||
throw error
|
||||
}
|
||||
if let message {
|
||||
throw NSError(domain: "remote error: \(message.message)", code: 0)
|
||||
throw NSError(domain: "ImportProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Remote error: \(message.message)")])
|
||||
}
|
||||
case LibboxMessageTypeProfileList:
|
||||
let decoder = LibboxProfileDecoder()
|
||||
@@ -97,7 +97,7 @@
|
||||
try await importProfile(content!, environments: environments)
|
||||
return
|
||||
default:
|
||||
throw NSError(domain: "unknown message type \(message[0])", code: 0)
|
||||
throw NSError(domain: "ImportProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Unknown message type \(message[0])")])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,17 +243,11 @@ public struct NewProfileMenuView: View {
|
||||
var error: NSError?
|
||||
let remoteProfile = LibboxParseRemoteProfileImportLink(string, &error)
|
||||
if let error {
|
||||
alert = AlertState(
|
||||
title: String(localized: "Invalid QR Code"),
|
||||
message: error.localizedDescription
|
||||
)
|
||||
alert = AlertState(error: error)
|
||||
return
|
||||
}
|
||||
guard let remoteProfile else {
|
||||
alert = AlertState(
|
||||
title: String(localized: "Invalid QR Code"),
|
||||
message: String(localized: "The QR code does not contain a valid profile import link.")
|
||||
)
|
||||
alert = AlertState(errorMessage: String(localized: "The QR code does not contain a valid profile import link."))
|
||||
return
|
||||
}
|
||||
importRequest = NewProfileView.ImportRequest(name: remoteProfile.name, url: remoteProfile.url)
|
||||
|
||||
@@ -111,10 +111,10 @@ public final class NewProfileViewModel: BaseViewModel {
|
||||
let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json")
|
||||
if fileImport {
|
||||
guard let fileURL else {
|
||||
throw NSError(domain: "Missing file", code: 0)
|
||||
throw NSError(domain: "NewProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing file")])
|
||||
}
|
||||
if !fileURL.startAccessingSecurityScopedResource() {
|
||||
throw NSError(domain: "Missing access to selected file", code: 0)
|
||||
throw NSError(domain: "NewProfileViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: String(localized: "Missing access to selected file")])
|
||||
}
|
||||
defer {
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
|
||||
@@ -48,54 +48,54 @@ public struct QRSDisplayView: View {
|
||||
Text(String(localized: "FPS"))
|
||||
Spacer()
|
||||
#if os(tvOS)
|
||||
Button {
|
||||
fps = max(1, fps - 1)
|
||||
} label: {
|
||||
Image(systemName: "minus")
|
||||
}
|
||||
Text(verbatim: "\(Int(fps))")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 50)
|
||||
Button {
|
||||
fps = min(60, fps + 1)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
Button {
|
||||
fps = max(1, fps - 1)
|
||||
} label: {
|
||||
Image(systemName: "minus")
|
||||
}
|
||||
Text(verbatim: "\(Int(fps))")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 50)
|
||||
Button {
|
||||
fps = min(60, fps + 1)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
#else
|
||||
Text(verbatim: "\(Int(fps))")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(verbatim: "\(Int(fps))")
|
||||
.foregroundStyle(.secondary)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
Slider(value: $fps, in: 1 ... 60, step: 1)
|
||||
Slider(value: $fps, in: 1 ... 60, step: 1)
|
||||
#endif
|
||||
|
||||
HStack {
|
||||
Text(String(localized: "Slice Size"))
|
||||
Spacer()
|
||||
#if os(tvOS)
|
||||
Button {
|
||||
sliceSize = max(100, sliceSize - 100)
|
||||
} label: {
|
||||
Image(systemName: "minus")
|
||||
}
|
||||
Text("\(Int(sliceSize))")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 50)
|
||||
Button {
|
||||
sliceSize = min(1500, sliceSize + 100)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
Button {
|
||||
sliceSize = max(100, sliceSize - 100)
|
||||
} label: {
|
||||
Image(systemName: "minus")
|
||||
}
|
||||
Text("\(Int(sliceSize))")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 50)
|
||||
Button {
|
||||
sliceSize = min(1500, sliceSize + 100)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
#else
|
||||
Text("\(Int(sliceSize))")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(Int(sliceSize))")
|
||||
.foregroundStyle(.secondary)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
Slider(value: $sliceSize, in: 100 ... 1500, step: 100)
|
||||
Slider(value: $sliceSize, in: 100 ... 1500, step: 100)
|
||||
#endif
|
||||
}
|
||||
.padding(.horizontal)
|
||||
@@ -130,9 +130,9 @@ public struct QRSDisplayView: View {
|
||||
.padding(.horizontal)
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding()
|
||||
.padding()
|
||||
#else
|
||||
.padding([.horizontal, .bottom])
|
||||
.padding([.horizontal, .bottom])
|
||||
#endif
|
||||
.onAppear {
|
||||
setupGenerator()
|
||||
|
||||
@@ -145,10 +145,7 @@
|
||||
message: String(localized: "Please enable camera access in Settings to scan QR codes.")
|
||||
)
|
||||
default:
|
||||
alert = AlertState(
|
||||
title: String(localized: "Scanner Error"),
|
||||
message: error.localizedDescription
|
||||
)
|
||||
alert = AlertState(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import SwiftUI
|
||||
#if os(iOS)
|
||||
import FileProvider
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import ServiceManagement
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
@@ -15,7 +17,11 @@ public struct CoreView: View {
|
||||
@State private var disableDeprecatedWarnings = false
|
||||
|
||||
@State private var version = ""
|
||||
@State private var dataSize = ""
|
||||
@State private var dataSize: String?
|
||||
|
||||
#if os(macOS)
|
||||
@State private var helperUnavailable = false
|
||||
#endif
|
||||
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
@@ -29,7 +35,21 @@ public struct CoreView: View {
|
||||
} else {
|
||||
FormView {
|
||||
FormTextItem("Version", version)
|
||||
FormTextItem("Data Size", dataSize)
|
||||
if let dataSize {
|
||||
FormTextItem("Data Size", dataSize)
|
||||
} else {
|
||||
#if os(macOS)
|
||||
HStack {
|
||||
Text("Data Size")
|
||||
Spacer()
|
||||
Text("Unavailable")
|
||||
.foregroundStyle(.red)
|
||||
.onTapGesture {
|
||||
alert = helperRequiredAlert()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
if Variant.isBeta {
|
||||
Section {}
|
||||
@@ -40,10 +60,12 @@ public struct CoreView: View {
|
||||
|
||||
Section("Working Directory") {
|
||||
#if os(macOS)
|
||||
FormButton {
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: FilePath.workingDirectory.relativePath)
|
||||
} label: {
|
||||
Label("Open", systemImage: "macwindow.and.cursorarrow")
|
||||
if !Variant.useSystemExtension {
|
||||
FormButton {
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: FilePath.workingDirectory.relativePath)
|
||||
} label: {
|
||||
Label("Open", systemImage: "macwindow.and.cursorarrow")
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
if #available(iOS 16.0, *) {
|
||||
@@ -85,7 +107,11 @@ public struct CoreView: View {
|
||||
} else {
|
||||
await MainActor.run {
|
||||
version = LibboxVersion()
|
||||
dataSize = "Loading..."
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension {
|
||||
helperUnavailable = HelperServiceManager.rootHelperStatus != .enabled
|
||||
}
|
||||
#endif
|
||||
isLoading = false
|
||||
}
|
||||
await loadSettingsBackground()
|
||||
@@ -94,7 +120,20 @@ public struct CoreView: View {
|
||||
|
||||
private nonisolated func loadSettingsBackground() async {
|
||||
let disableDeprecatedWarnings = await SharedPreferences.disableDeprecatedWarnings.get()
|
||||
let dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown"
|
||||
let dataSize: String?
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension {
|
||||
if let size = try? RootHelperClient.shared.getWorkingDirectorySize() {
|
||||
dataSize = LibboxFormatBytes(size)
|
||||
} else {
|
||||
dataSize = nil
|
||||
}
|
||||
} else {
|
||||
dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown"
|
||||
}
|
||||
#else
|
||||
dataSize = (try? FilePath.workingDirectory.formattedSize()) ?? "Unknown"
|
||||
#endif
|
||||
await MainActor.run {
|
||||
self.disableDeprecatedWarnings = disableDeprecatedWarnings
|
||||
self.dataSize = dataSize
|
||||
@@ -102,6 +141,12 @@ public struct CoreView: View {
|
||||
}
|
||||
|
||||
private func confirmDestroyWorkingDirectory() async {
|
||||
#if os(macOS)
|
||||
if helperUnavailable {
|
||||
alert = helperRequiredAlert()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
if environments.extensionProfile?.status.isConnected == true {
|
||||
alert = AlertState(
|
||||
title: String(localized: "Service is Running"),
|
||||
@@ -119,17 +164,44 @@ public struct CoreView: View {
|
||||
}
|
||||
|
||||
private func stopServiceAndDestroy() async {
|
||||
try? await environments.extensionProfile?.stop()
|
||||
await destroyWorkingDirectory()
|
||||
}
|
||||
|
||||
private nonisolated func destroyWorkingDirectory() async {
|
||||
try? FileManager.default.removeItem(at: FilePath.workingDirectory)
|
||||
await MainActor.run {
|
||||
isLoading = true
|
||||
do {
|
||||
try await environments.extensionProfile!.stop()
|
||||
await destroyWorkingDirectory()
|
||||
} catch {
|
||||
alert = AlertState(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func destroyWorkingDirectory() async {
|
||||
do {
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension {
|
||||
try RootHelperClient.shared.cleanWorkingDirectory()
|
||||
} else {
|
||||
try FileManager.default.removeItem(at: FilePath.workingDirectory)
|
||||
}
|
||||
#else
|
||||
try FileManager.default.removeItem(at: FilePath.workingDirectory)
|
||||
#endif
|
||||
isLoading = true
|
||||
} catch {
|
||||
alert = AlertState(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func helperRequiredAlert() -> AlertState {
|
||||
AlertState(
|
||||
title: String(localized: "Helper Service Required"),
|
||||
message: String(localized: "Managing working directory requires Helper Service."),
|
||||
primaryButton: .default(String(localized: "App Settings")) {
|
||||
NotificationCenter.default.post(name: .navigateToSettingsPage, object: SettingsPage.app)
|
||||
},
|
||||
secondaryButton: .cancel(String(localized: "Ok"))
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
@available(iOS 16.0, *)
|
||||
private nonisolated func openInFilesApp() async {
|
||||
|
||||
@@ -12,6 +12,11 @@ public struct AppView: View {
|
||||
@State private var startAtLogin = false
|
||||
@Environment(\.showMenuBarExtra) private var showMenuBarExtra
|
||||
@State private var menuBarExtraInBackground = false
|
||||
#if os(macOS)
|
||||
|
||||
@State private var rootHelperRegistrationStatus: SMAppService.Status = .notRegistered
|
||||
|
||||
#endif
|
||||
|
||||
@State private var alert: AlertState?
|
||||
|
||||
@@ -67,6 +72,48 @@ public struct AppView: View {
|
||||
Label("Uninstall", systemImage: "trash.fill").foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if rootHelperRegistrationStatus == .enabled {
|
||||
FormButton {
|
||||
performHelperAction {
|
||||
try HelperServiceManager.unregisterRootHelper()
|
||||
try HelperServiceManager.registerRootHelper()
|
||||
}
|
||||
} label: {
|
||||
Label("Update", systemImage: "arrow.down.doc.fill")
|
||||
}
|
||||
FormButton(role: .destructive) {
|
||||
performHelperAction {
|
||||
try HelperServiceManager.unregisterRootHelper()
|
||||
}
|
||||
} label: {
|
||||
Label("Uninstall", systemImage: "trash.fill").foregroundColor(.red)
|
||||
}
|
||||
} else if rootHelperRegistrationStatus == .requiresApproval {
|
||||
FormButton {
|
||||
openHelperSettings()
|
||||
} label: {
|
||||
Label("Enable", systemImage: "switch.2")
|
||||
}
|
||||
} else {
|
||||
FormButton {
|
||||
performHelperAction {
|
||||
try HelperServiceManager.registerRootHelper()
|
||||
}
|
||||
} label: {
|
||||
Label("Install", systemImage: "square.and.arrow.down.fill")
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Helper Service")
|
||||
Text("This helper service provides process lookup for `process_name` and `process_path` routing rules, and manages the working directory.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -83,6 +130,9 @@ public struct AppView: View {
|
||||
#if os(macOS)
|
||||
startAtLogin = SMAppService.mainApp.status == .enabled
|
||||
menuBarExtraInBackground = await SharedPreferences.menuBarExtraInBackground.get()
|
||||
if Variant.useSystemExtension {
|
||||
refreshHelperStatus()
|
||||
}
|
||||
#endif
|
||||
isLoading = false
|
||||
}
|
||||
@@ -147,5 +197,31 @@ public struct AppView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func performHelperAction(_ action: () throws -> Void) {
|
||||
do {
|
||||
try action()
|
||||
refreshHelperStatus()
|
||||
} catch {
|
||||
alert = AlertState(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshHelperStatus() {
|
||||
rootHelperRegistrationStatus = HelperServiceManager.rootHelperStatus
|
||||
}
|
||||
|
||||
private func openHelperSettings() {
|
||||
if #available(macOS 13.0, *) {
|
||||
SMAppService.openSystemSettingsLoginItems()
|
||||
return
|
||||
}
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.users?LoginItems"),
|
||||
NSWorkspace.shared.open(url)
|
||||
{
|
||||
return
|
||||
}
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/System Preferences.app"))
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ public struct OnDemandRulesView: View {
|
||||
await SharedPreferences.onDemandRules.set(rules)
|
||||
let savedRules = await SharedPreferences.onDemandRules.get()
|
||||
if savedRules != rules {
|
||||
alert = AlertState(errorMessage: "Failed to save rules")
|
||||
alert = AlertState(errorMessage: String(localized: "Failed to save rules"))
|
||||
return
|
||||
}
|
||||
await updateService()
|
||||
|
||||
@@ -19,7 +19,7 @@ struct PacketTunnelView: View {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
Task {
|
||||
await loadSettings()
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,7 @@ struct PacketTunnelView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadSettings() async {
|
||||
ignoreMemoryLimit = await SharedPreferences.ignoreMemoryLimit.get()
|
||||
#if !os(tvOS)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@@ -15,7 +14,7 @@ public struct ProfileOverrideView: View {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView().onAppear {
|
||||
Task.detached {
|
||||
Task {
|
||||
await loadSettings()
|
||||
}
|
||||
}
|
||||
@@ -64,12 +63,13 @@ public struct ProfileOverrideView: View {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient()?.serviceReload()
|
||||
try await profile.reloadService()
|
||||
} catch {
|
||||
alert = AlertState(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadSettings() async {
|
||||
excludeDefaultRoute = await SharedPreferences.excludeDefaultRoute.get()
|
||||
autoRouteUseSubRangesByDefault = await SharedPreferences.autoRouteUseSubRangesByDefault.get()
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
#if os(macOS)
|
||||
private struct SettingsNavigationPathKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<NavigationPath>? = nil
|
||||
}
|
||||
|
||||
public extension EnvironmentValues {
|
||||
var settingsNavigationPath: Binding<NavigationPath>? {
|
||||
get { self[SettingsNavigationPathKey.self] }
|
||||
set { self[SettingsNavigationPathKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
public enum SettingsPage: Hashable {
|
||||
case app
|
||||
case core, packetTunnel, onDemandRules, profileOverride, sponsors
|
||||
}
|
||||
#endif
|
||||
|
||||
public struct SettingView: View {
|
||||
private enum Tabs: Int, CaseIterable, Identifiable {
|
||||
var id: Self {
|
||||
@@ -13,6 +33,25 @@ public struct SettingView: View {
|
||||
|
||||
case core, packetTunnel, onDemandRules, profileOverride, sponsors
|
||||
|
||||
#if os(macOS)
|
||||
var page: SettingsPage {
|
||||
switch self {
|
||||
case .app:
|
||||
return .app
|
||||
case .core:
|
||||
return .core
|
||||
case .packetTunnel:
|
||||
return .packetTunnel
|
||||
case .onDemandRules:
|
||||
return .onDemandRules
|
||||
case .profileOverride:
|
||||
return .profileOverride
|
||||
case .sponsors:
|
||||
return .sponsors
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
var label: some View {
|
||||
Label(title, systemImage: iconImage)
|
||||
}
|
||||
@@ -83,16 +122,45 @@ public struct SettingView: View {
|
||||
|
||||
@MainActor
|
||||
var navigationLink: some View {
|
||||
FormNavigationLink {
|
||||
contentView
|
||||
} label: {
|
||||
label
|
||||
}
|
||||
#if os(macOS)
|
||||
FormNavigationLink(value: page) {
|
||||
label
|
||||
}
|
||||
#else
|
||||
FormNavigationLink {
|
||||
contentView
|
||||
} label: {
|
||||
label
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@StateObject private var viewModel = SettingViewModel()
|
||||
#if os(macOS)
|
||||
@MainActor
|
||||
@ViewBuilder
|
||||
private static func destinationView(for page: SettingsPage) -> some View {
|
||||
Group {
|
||||
switch page {
|
||||
case .app:
|
||||
AppView()
|
||||
case .core:
|
||||
CoreView()
|
||||
case .packetTunnel:
|
||||
PacketTunnelView()
|
||||
case .onDemandRules:
|
||||
OnDemandRulesView()
|
||||
case .profileOverride:
|
||||
ProfileOverrideView()
|
||||
case .sponsors:
|
||||
SponsorsView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
}
|
||||
#endif
|
||||
|
||||
@StateObject private var viewModel = SettingViewModel()
|
||||
public init() {}
|
||||
public var body: some View {
|
||||
FormView {
|
||||
@@ -159,5 +227,10 @@ public struct SettingView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.formNavigationDestination(for: SettingsPage.self) { page in
|
||||
Self.destinationView(for: page)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user