refactor: Fix macOS standalone application
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user