Rewrite menu bar extra with AppKit
This commit is contained in:
@@ -24,8 +24,6 @@ import SwiftUI
|
|||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
pendingAuthorizationRequest = true
|
pendingAuthorizationRequest = true
|
||||||
manager.requestAlwaysAuthorization()
|
manager.requestAlwaysAuthorization()
|
||||||
case .authorized, .authorizedAlways:
|
|
||||||
onAuthorizationGranted?()
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -69,10 +67,10 @@ public struct GlobalChecksModifier: ViewModifier {
|
|||||||
handleImportRemoteProfile()
|
handleImportRemoteProfile()
|
||||||
}
|
}
|
||||||
.onChangeCompat(of: importProfile.wrappedValue) { _ in
|
.onChangeCompat(of: importProfile.wrappedValue) { _ in
|
||||||
handleImportProfile()
|
Task { @MainActor in handleImportProfile() }
|
||||||
}
|
}
|
||||||
.onChangeCompat(of: importRemoteProfile.wrappedValue) { _ in
|
.onChangeCompat(of: importRemoteProfile.wrappedValue) { _ in
|
||||||
handleImportRemoteProfile()
|
Task { @MainActor in handleImportRemoteProfile() }
|
||||||
}
|
}
|
||||||
.onChangeCompat(of: environments.extensionProfile?.status) { status in
|
.onChangeCompat(of: environments.extensionProfile?.status) { status in
|
||||||
handleStatusChange(status)
|
handleStatusChange(status)
|
||||||
@@ -84,10 +82,10 @@ public struct GlobalChecksModifier: ViewModifier {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
content
|
content
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .extensionRequiresWIFIState)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .extensionRequiresWIFIState)) { _ in
|
||||||
handleWiFiStateNotification()
|
Task { @MainActor in handleWiFiStateNotification() }
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .extensionRequiresHelperService)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .extensionRequiresHelperService)) { _ in
|
||||||
handleHelperServiceNotification()
|
Task { @MainActor in handleHelperServiceNotification() }
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
content
|
content
|
||||||
@@ -133,26 +131,24 @@ public struct GlobalChecksModifier: ViewModifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleStatusChange(_ status: NEVPNStatus?) {
|
private func handleStatusChange(_ status: NEVPNStatus?) {
|
||||||
guard let status else { return }
|
Task { @MainActor in
|
||||||
switch status {
|
guard let status else { return }
|
||||||
case .connected:
|
switch status {
|
||||||
notStarted = false
|
case .connected:
|
||||||
Task {
|
notStarted = false
|
||||||
await checkDeprecatedNotes()
|
await checkDeprecatedNotes()
|
||||||
}
|
case .connecting:
|
||||||
case .connecting:
|
notStarted = true
|
||||||
notStarted = true
|
case .disconnected:
|
||||||
case .disconnected:
|
if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) {
|
||||||
if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) {
|
if notStarted, let profile = environments.extensionProfile {
|
||||||
if notStarted, let profile = environments.extensionProfile {
|
|
||||||
Task {
|
|
||||||
await checkLastDisconnectError(profile: profile)
|
await checkLastDisconnectError(profile: profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
notStarted = false
|
||||||
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
notStarted = false
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ public struct ConnectionListView: View {
|
|||||||
viewModel.connect()
|
viewModel.connect()
|
||||||
}
|
}
|
||||||
.onReceive(environments.commandClient.$connections) { connections in
|
.onReceive(environments.commandClient.$connections) { connections in
|
||||||
viewModel.setConnections(connections)
|
Task { @MainActor in
|
||||||
|
viewModel.setConnections(connections)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChangeCompat(of: viewModel.connectionStateFilter) { filter in
|
.onChangeCompat(of: viewModel.connectionStateFilter) { filter in
|
||||||
environments.commandClient.connectionStateFilter = filter
|
environments.commandClient.connectionStateFilter = filter
|
||||||
|
|||||||
@@ -108,20 +108,22 @@ public struct StartStopButton: View {
|
|||||||
.disabled(!profile.status.isEnabled)
|
.disabled(!profile.status.isEnabled)
|
||||||
.alert($alert)
|
.alert($alert)
|
||||||
.onReceive(timer) { _ in
|
.onReceive(timer) { _ in
|
||||||
currentTime = Date()
|
Task { @MainActor in
|
||||||
|
currentTime = Date()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChangeCompat(of: profile.status) { status in
|
.onChangeCompat(of: profile.status) { status in
|
||||||
if isStarting {
|
Task { @MainActor in
|
||||||
if status == .disconnected {
|
if isStarting {
|
||||||
isStarting = false
|
if status == .disconnected {
|
||||||
if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) {
|
isStarting = false
|
||||||
Task {
|
if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) {
|
||||||
await checkStartupError()
|
await checkStartupError()
|
||||||
}
|
}
|
||||||
|
} else if status.isConnectedStrict {
|
||||||
|
isStarting = false
|
||||||
|
environments.commandClient.connect()
|
||||||
}
|
}
|
||||||
} else if status.isConnectedStrict {
|
|
||||||
isStarting = false
|
|
||||||
environments.commandClient.connect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ public extension EnvironmentValues {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct menuBarExtraSpeedModeKey: EnvironmentKey {
|
||||||
|
static let defaultValue: Binding<Int> = .constant(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var menuBarExtraSpeedMode: Binding<Int> {
|
||||||
|
get {
|
||||||
|
self[menuBarExtraSpeedModeKey.self]
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
self[menuBarExtraSpeedModeKey.self] = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct selectionKey: EnvironmentKey {
|
private struct selectionKey: EnvironmentKey {
|
||||||
static let defaultValue: Binding<NavigationPage> = .constant(.dashboard)
|
static let defaultValue: Binding<NavigationPage> = .constant(.dashboard)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ public struct GroupListView: View {
|
|||||||
viewModel.connect()
|
viewModel.connect()
|
||||||
}
|
}
|
||||||
.onReceive(environments.commandClient.$groups) { groups in
|
.onReceive(environments.commandClient.$groups) { groups in
|
||||||
viewModel.setGroups(groups)
|
Task { @MainActor in
|
||||||
|
viewModel.setGroups(groups)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,11 +137,11 @@ import SwiftUI
|
|||||||
} label: {
|
} label: {
|
||||||
Text(symbol)
|
Text(symbol)
|
||||||
.font(.system(.body, design: .monospaced).weight(.medium))
|
.font(.system(.body, design: .monospaced).weight(.medium))
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.frame(minWidth: symbol.count > 1 ? nil : 32, minHeight: 32)
|
.frame(minWidth: symbol.count > 1 ? nil : 32, minHeight: 32)
|
||||||
#else
|
#else
|
||||||
.frame(minWidth: symbol.count > 1 ? nil : 24)
|
.frame(minWidth: symbol.count > 1 ? nil : 24)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public struct AppView: View {
|
|||||||
|
|
||||||
@State private var startAtLogin = false
|
@State private var startAtLogin = false
|
||||||
@Environment(\.showMenuBarExtra) private var showMenuBarExtra
|
@Environment(\.showMenuBarExtra) private var showMenuBarExtra
|
||||||
|
@Environment(\.menuBarExtraSpeedMode) private var menuBarExtraSpeedMode
|
||||||
@State private var menuBarExtraInBackground = false
|
@State private var menuBarExtraInBackground = false
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
|
||||||
@@ -47,6 +48,17 @@ public struct AppView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if showMenuBarExtra.wrappedValue {
|
if showMenuBarExtra.wrappedValue {
|
||||||
|
Picker("Real-time Speed", selection: menuBarExtraSpeedMode) {
|
||||||
|
ForEach(MenuBarExtraSpeedMode.allCases, id: \.rawValue) { mode in
|
||||||
|
Text(mode.name).tag(mode.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChangeCompat(of: menuBarExtraSpeedMode.wrappedValue) { newValue in
|
||||||
|
Task {
|
||||||
|
await SharedPreferences.menuBarExtraSpeedMode.set(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Toggle("Keep Menu Bar in Background", isOn: $menuBarExtraInBackground)
|
Toggle("Keep Menu Bar in Background", isOn: $menuBarExtraInBackground)
|
||||||
.onChangeCompat(of: menuBarExtraInBackground) { newValue in
|
.onChangeCompat(of: menuBarExtraInBackground) { newValue in
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
public enum MenuBarExtraSpeedMode: Int, CaseIterable {
|
||||||
|
case disabled = 0
|
||||||
|
case enabled = 1
|
||||||
|
case separate = 2
|
||||||
|
|
||||||
|
public var name: String {
|
||||||
|
switch self {
|
||||||
|
case .disabled:
|
||||||
|
return NSLocalizedString("Disabled", comment: "")
|
||||||
|
case .enabled:
|
||||||
|
return NSLocalizedString("Enabled", comment: "")
|
||||||
|
case .separate:
|
||||||
|
return NSLocalizedString("Detailed", comment: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
public enum SharedPreferences {
|
public enum SharedPreferences {
|
||||||
public static let selectedProfileID = Preference<Int64>("selected_profile_id", defaultValue: -1)
|
public static let selectedProfileID = Preference<Int64>("selected_profile_id", defaultValue: -1)
|
||||||
|
|
||||||
@@ -47,10 +66,15 @@ public enum SharedPreferences {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
public static let showMenuBarExtra = Preference<Bool>("show_menu_bar_extra", defaultValue: true)
|
public static let showMenuBarExtra = Preference<Bool>("show_menu_bar_extra", defaultValue: true)
|
||||||
public static let menuBarExtraInBackground = Preference<Bool>("menu_bar_extra_in_background", defaultValue: false)
|
public static let menuBarExtraInBackground = Preference<Bool>("menu_bar_extra_in_background", defaultValue: false)
|
||||||
|
public static let menuBarExtraSpeedMode = Preference<Int>("menu_bar_extra_speed_mode", defaultValue: MenuBarExtraSpeedMode.enabled.rawValue)
|
||||||
public static let startedByUser = Preference<Bool>("started_by_user", defaultValue: false)
|
public static let startedByUser = Preference<Bool>("started_by_user", defaultValue: false)
|
||||||
|
|
||||||
public static func resetMacOS() async {
|
public static func resetMacOS() async {
|
||||||
try? await batchDelete([showMenuBarExtra.name, menuBarExtraInBackground.name])
|
try? await batchDelete([
|
||||||
|
showMenuBarExtra.name,
|
||||||
|
menuBarExtraInBackground.name,
|
||||||
|
menuBarExtraSpeedMode.name,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -739,6 +739,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Detailed" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Disable Deprecated Warnings" : {
|
"Disable Deprecated Warnings" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -749,6 +752,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Disabled" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Disconnect" : {
|
"Disconnect" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1009,6 +1015,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Enabled" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"enforceRoutes" : {
|
"enforceRoutes" : {
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
@@ -1180,6 +1189,9 @@
|
|||||||
},
|
},
|
||||||
"Goroutines" : {
|
"Goroutines" : {
|
||||||
"shouldTranslate" : false
|
"shouldTranslate" : false
|
||||||
|
},
|
||||||
|
"Group" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Groups" : {
|
"Groups" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1749,6 +1761,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"NetworkExtension not installed" : {
|
"NetworkExtension not installed" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"zh-Hans" : {
|
"zh-Hans" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1817,6 +1830,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Not installed" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Ok" : {
|
"Ok" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2072,6 +2088,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Real-time Speed" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Reboot required." : {
|
"Reboot required." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import ApplicationLibrary
|
import ApplicationLibrary
|
||||||
|
import Libbox
|
||||||
import Library
|
import Library
|
||||||
|
import NetworkExtension
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
public struct MacApplication: Scene {
|
public struct MacApplication: Scene {
|
||||||
@State private var showMenuBarExtra = false
|
@State private var showMenuBarExtra = false
|
||||||
@State private var isMenuPresented = false
|
@State private var menuBarExtraSpeedMode = MenuBarExtraSpeedMode.enabled.rawValue
|
||||||
@StateObject private var environments = ExtensionEnvironments()
|
@StateObject private var environments = ExtensionEnvironments()
|
||||||
|
@State private var statusBarController: StatusBarController?
|
||||||
|
|
||||||
private let profileEditor: (Binding<String>, Bool) -> AnyView = { text, isEditable in
|
private let profileEditor: (Binding<String>, Bool) -> AnyView = { text, isEditable in
|
||||||
AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable))
|
AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable))
|
||||||
@@ -21,7 +24,20 @@ public struct MacApplication: Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.environment(\.showMenuBarExtra, $showMenuBarExtra)
|
.environment(\.showMenuBarExtra, $showMenuBarExtra)
|
||||||
|
.environment(\.menuBarExtraSpeedMode, $menuBarExtraSpeedMode)
|
||||||
.environmentObject(environments)
|
.environmentObject(environments)
|
||||||
|
.onChangeCompat(of: showMenuBarExtra) { newValue in
|
||||||
|
statusBarController?.updateVisibility(newValue)
|
||||||
|
Task {
|
||||||
|
await SharedPreferences.showMenuBarExtra.set(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChangeCompat(of: menuBarExtraSpeedMode) { newValue in
|
||||||
|
statusBarController?.updateSpeedMode(newValue)
|
||||||
|
Task {
|
||||||
|
await SharedPreferences.menuBarExtraSpeedMode.set(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentSize)
|
||||||
.commands {
|
.commands {
|
||||||
@@ -48,15 +64,6 @@ public struct MacApplication: Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MenuBarExtra(isInserted: $showMenuBarExtra) {
|
|
||||||
MenuView(isMenuPresented: $isMenuPresented)
|
|
||||||
.environmentObject(environments)
|
|
||||||
} label: {
|
|
||||||
Image("MenuIcon")
|
|
||||||
}
|
|
||||||
.menuBarExtraStyle(.window)
|
|
||||||
.menuBarExtraAccess(isPresented: $isMenuPresented)
|
|
||||||
|
|
||||||
WindowGroup(for: EditProfileContentView.Context.self) { $context in
|
WindowGroup(for: EditProfileContentView.Context.self) { $context in
|
||||||
EditProfileContentWindow(context: context)
|
EditProfileContentWindow(context: context)
|
||||||
.environment(\.profileEditor, profileEditor)
|
.environment(\.profileEditor, profileEditor)
|
||||||
@@ -67,6 +74,10 @@ public struct MacApplication: Scene {
|
|||||||
|
|
||||||
private func initialize() async {
|
private func initialize() async {
|
||||||
showMenuBarExtra = await SharedPreferences.showMenuBarExtra.get()
|
showMenuBarExtra = await SharedPreferences.showMenuBarExtra.get()
|
||||||
|
menuBarExtraSpeedMode = await SharedPreferences.menuBarExtraSpeedMode.get()
|
||||||
|
statusBarController = StatusBarController(environments: environments)
|
||||||
|
statusBarController?.updateVisibility(showMenuBarExtra)
|
||||||
|
statusBarController?.updateSpeedMode(menuBarExtraSpeedMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hide(closeApp: Bool) {
|
private func hide(closeApp: Bool) {
|
||||||
|
|||||||
+16
-12
@@ -57,23 +57,27 @@ public struct MainView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChangeCompat(of: controlActiveState) { newValue in
|
.onChangeCompat(of: controlActiveState) { newValue in
|
||||||
viewModel.onControlActiveStateChange(newValue, environments: environments)
|
Task { @MainActor in
|
||||||
|
viewModel.onControlActiveStateChange(newValue, environments: environments)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChangeCompat(of: viewModel.selection) { value in
|
.onChangeCompat(of: viewModel.selection) { value in
|
||||||
viewModel.onSelectionChange(value, environments: environments)
|
Task { @MainActor in
|
||||||
if value != .settings {
|
viewModel.onSelectionChange(value, environments: environments)
|
||||||
settingsNavigationPath = NavigationPath()
|
if value != .settings {
|
||||||
pendingSettingsPage = nil
|
settingsNavigationPath = NavigationPath()
|
||||||
return
|
pendingSettingsPage = nil
|
||||||
}
|
return
|
||||||
if let page = pendingSettingsPage {
|
}
|
||||||
settingsNavigationPath = NavigationPath()
|
if let page = pendingSettingsPage {
|
||||||
settingsNavigationPath.append(page)
|
settingsNavigationPath = NavigationPath()
|
||||||
pendingSettingsPage = nil
|
settingsNavigationPath.append(page)
|
||||||
|
pendingSettingsPage = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onReceive(environments.openSettings) {
|
.onReceive(environments.openSettings) {
|
||||||
viewModel.openSettings()
|
Task { @MainActor in viewModel.openSettings() }
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToSettingsPage)) { notification in
|
.onReceive(NotificationCenter.default.publisher(for: .navigateToSettingsPage)) { notification in
|
||||||
guard let page = notification.object as? SettingsPage else { return }
|
guard let page = notification.object as? SettingsPage else { return }
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
import ApplicationLibrary
|
|
||||||
import Foundation
|
|
||||||
import Libbox
|
|
||||||
import Library
|
|
||||||
import MacControlCenterUI
|
|
||||||
import MenuBarExtraAccess
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
public struct MenuView: View {
|
|
||||||
@Environment(\.openWindow) private var openWindow
|
|
||||||
|
|
||||||
private static let sliderWidth: CGFloat = 270
|
|
||||||
|
|
||||||
@Binding private var isMenuPresented: Bool
|
|
||||||
|
|
||||||
@State private var isLoading = true
|
|
||||||
@State private var profile: ExtensionProfile?
|
|
||||||
|
|
||||||
public init(isMenuPresented: Binding<Bool>) {
|
|
||||||
_isMenuPresented = isMenuPresented
|
|
||||||
}
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
MacControlCenterMenu(isPresented: $isMenuPresented) {
|
|
||||||
MenuHeader("sing-box") {
|
|
||||||
if isLoading {
|
|
||||||
Text("Loading...").foregroundColor(.secondary).onAppear {
|
|
||||||
Task {
|
|
||||||
await loadProfile()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if let profile {
|
|
||||||
Text(LibboxVersion()).foregroundColor(.secondary)
|
|
||||||
StatusSwitch(profile)
|
|
||||||
} else {
|
|
||||||
Text("NetworkExtension not installed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(minWidth: MenuView.sliderWidth)
|
|
||||||
if let profile {
|
|
||||||
ProfilePicker(profile)
|
|
||||||
}
|
|
||||||
Divider()
|
|
||||||
MenuCommand {
|
|
||||||
NSApp.setActivationPolicy(.regular)
|
|
||||||
openWindow(id: "main")
|
|
||||||
if let dockApp = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.dock").first {
|
|
||||||
dockApp.activate()
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Text("Open")
|
|
||||||
}
|
|
||||||
MenuCommand {
|
|
||||||
NSApp.terminate(nil)
|
|
||||||
} label: {
|
|
||||||
Text("Quit")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadProfile() async {
|
|
||||||
profile = try? await ExtensionProfile.load()
|
|
||||||
if let profile {
|
|
||||||
profile.register()
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct StatusSwitch: View {
|
|
||||||
@ObservedObject private var profile: ExtensionProfile
|
|
||||||
@State private var alert: AlertState?
|
|
||||||
|
|
||||||
init(_ profile: ExtensionProfile) {
|
|
||||||
self.profile = profile
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Toggle(isOn: Binding(get: {
|
|
||||||
profile.status.isConnected
|
|
||||||
}, set: { _ in
|
|
||||||
Task {
|
|
||||||
await switchProfile(!profile.status.isConnected)
|
|
||||||
}
|
|
||||||
})) {}
|
|
||||||
.toggleStyle(.switch)
|
|
||||||
.disabled(!profile.status.isEnabled)
|
|
||||||
.alert($alert)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func switchProfile(_ isEnabled: Bool) async {
|
|
||||||
do {
|
|
||||||
if isEnabled {
|
|
||||||
try await profile.start()
|
|
||||||
} else {
|
|
||||||
try await profile.stop()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
alert = AlertState(error: error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ProfilePicker: View {
|
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
|
||||||
@ObservedObject private var profile: ExtensionProfile
|
|
||||||
|
|
||||||
init(_ profile: ExtensionProfile) {
|
|
||||||
self.profile = profile
|
|
||||||
}
|
|
||||||
|
|
||||||
@State private var isLoading = true
|
|
||||||
@State private var profileList: [ProfilePreview] = []
|
|
||||||
@State private var selectedProfileID: Int64 = 0
|
|
||||||
@State private var reasserting = false
|
|
||||||
@State private var alert: AlertState?
|
|
||||||
|
|
||||||
private var selectedProfileIDLocal: Binding<Int64> {
|
|
||||||
$selectedProfileID.withSetter { newValue in
|
|
||||||
reasserting = true
|
|
||||||
Task { [self] in
|
|
||||||
await switchProfile(newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
if isLoading {
|
|
||||||
ProgressView().onAppear {
|
|
||||||
Task {
|
|
||||||
await doReload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if profileList.isEmpty {
|
|
||||||
Text("Empty profiles")
|
|
||||||
} else {
|
|
||||||
MenuSection(String(localized: "Profile"))
|
|
||||||
Picker("", selection: selectedProfileIDLocal) {
|
|
||||||
ForEach(profileList, id: \.id) { profile in
|
|
||||||
Text(profile.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.inline)
|
|
||||||
.disabled(!profile.status.isSwitchable || reasserting)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onReceive(environments.profileUpdate) { _ in
|
|
||||||
Task {
|
|
||||||
await doReload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onReceive(environments.selectedProfileUpdate) { _ in
|
|
||||||
Task {
|
|
||||||
selectedProfileID = await SharedPreferences.selectedProfileID.get()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert($alert)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func doReload() async {
|
|
||||||
defer {
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
profileList = try await ProfileManager.list().map { ProfilePreview($0) }
|
|
||||||
} catch {
|
|
||||||
alert = AlertState(error: error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if profileList.isEmpty {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectedProfileID = await SharedPreferences.selectedProfileID.get()
|
|
||||||
if profileList.filter({ profile in
|
|
||||||
profile.id == selectedProfileID
|
|
||||||
})
|
|
||||||
.isEmpty {
|
|
||||||
selectedProfileID = profileList[0].id
|
|
||||||
await SharedPreferences.selectedProfileID.set(selectedProfileID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func switchProfile(_ newProfileID: Int64) async {
|
|
||||||
await SharedPreferences.selectedProfileID.set(newProfileID)
|
|
||||||
environments.selectedProfileUpdate.send()
|
|
||||||
if profile.status.isConnected {
|
|
||||||
do {
|
|
||||||
try await profile.reloadService()
|
|
||||||
} catch {
|
|
||||||
alert = AlertState(error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reasserting = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import SwiftUI
|
|||||||
public struct SidebarView: View {
|
public struct SidebarView: View {
|
||||||
@Binding var selection: NavigationPage
|
@Binding var selection: NavigationPage
|
||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
|
@State private var localSelection: NavigationPage = .dashboard
|
||||||
|
|
||||||
public init(selection: Binding<NavigationPage>) {
|
public init(selection: Binding<NavigationPage>) {
|
||||||
_selection = selection
|
_selection = selection
|
||||||
@@ -19,9 +20,9 @@ public struct SidebarView: View {
|
|||||||
sidebarContent(isConnected: profile.status.isConnectedStrict, profile: profile)
|
sidebarContent(isConnected: profile.status.isConnectedStrict, profile: profile)
|
||||||
.onReceive(profile.$status) { _ in }
|
.onReceive(profile.$status) { _ in }
|
||||||
.onChangeCompat(of: profile.status) {
|
.onChangeCompat(of: profile.status) {
|
||||||
if !selection.visible(profile) {
|
if !localSelection.visible(profile) {
|
||||||
DispatchQueue.main.async {
|
Task { @MainActor in
|
||||||
selection = .dashboard
|
localSelection = .dashboard
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,7 +33,7 @@ public struct SidebarView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func sidebarContent(isConnected: Bool, profile: ExtensionProfile?) -> some View {
|
private func sidebarContent(isConnected: Bool, profile: ExtensionProfile?) -> some View {
|
||||||
List(selection: $selection) {
|
List(selection: $localSelection) {
|
||||||
if isConnected {
|
if isConnected {
|
||||||
Section(NavigationPage.dashboard.title) {
|
Section(NavigationPage.dashboard.title) {
|
||||||
Label("Overview", systemImage: "text.and.command.macwindow")
|
Label("Overview", systemImage: "text.and.command.macwindow")
|
||||||
@@ -55,5 +56,20 @@ public struct SidebarView: View {
|
|||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
.scrollDisabled(true)
|
.scrollDisabled(true)
|
||||||
|
.onAppear {
|
||||||
|
localSelection = selection
|
||||||
|
}
|
||||||
|
.onChangeCompat(of: selection) { newValue in
|
||||||
|
if localSelection != newValue {
|
||||||
|
localSelection = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChangeCompat(of: localSelection) { newValue in
|
||||||
|
if selection != newValue {
|
||||||
|
Task { @MainActor in
|
||||||
|
selection = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
import AppKit
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import Libbox
|
||||||
|
import Library
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public class StatusBarController: NSObject, NSMenuDelegate {
|
||||||
|
private var statusItem: NSStatusItem?
|
||||||
|
private let environments: ExtensionEnvironments
|
||||||
|
private var commandClient: CommandClient?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var statusCancellable: AnyCancellable?
|
||||||
|
private var speedMode: MenuBarExtraSpeedMode = .enabled
|
||||||
|
|
||||||
|
private var menu: NSMenu?
|
||||||
|
private var headerItem: NSMenuItem?
|
||||||
|
private var headerView: StatusBarHeaderView?
|
||||||
|
private var groupsItem: NSMenuItem?
|
||||||
|
private var profilesItem: NSMenuItem?
|
||||||
|
|
||||||
|
public init(environments: ExtensionEnvironments) {
|
||||||
|
self.environments = environments
|
||||||
|
super.init()
|
||||||
|
observeProfile()
|
||||||
|
Task {
|
||||||
|
await initialize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initialize() async {
|
||||||
|
let showMenuBarExtra = await SharedPreferences.showMenuBarExtra.get()
|
||||||
|
speedMode = await MenuBarExtraSpeedMode(rawValue: SharedPreferences.menuBarExtraSpeedMode.get()) ?? .enabled
|
||||||
|
updateVisibility(showMenuBarExtra)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateVisibility(_ show: Bool) {
|
||||||
|
if show {
|
||||||
|
createStatusItem()
|
||||||
|
} else {
|
||||||
|
destroyStatusItem()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateSpeedMode(_ mode: Int) {
|
||||||
|
speedMode = MenuBarExtraSpeedMode(rawValue: mode) ?? .enabled
|
||||||
|
updateCommandClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createStatusItem() {
|
||||||
|
guard statusItem == nil else { return }
|
||||||
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
|
|
||||||
|
let button = statusItem!.button!
|
||||||
|
button.image = NSImage(named: "MenuIcon")
|
||||||
|
button.image?.isTemplate = true
|
||||||
|
button.imagePosition = .imageTrailing
|
||||||
|
|
||||||
|
menu = NSMenu()
|
||||||
|
menu!.delegate = self
|
||||||
|
statusItem!.menu = menu
|
||||||
|
|
||||||
|
buildMenu()
|
||||||
|
updateCommandClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func destroyStatusItem() {
|
||||||
|
if let statusItem {
|
||||||
|
NSStatusBar.system.removeStatusItem(statusItem)
|
||||||
|
self.statusItem = nil
|
||||||
|
}
|
||||||
|
menu = nil
|
||||||
|
headerView = nil
|
||||||
|
commandClient?.disconnect()
|
||||||
|
commandClient = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildMenu() {
|
||||||
|
guard let menu else { return }
|
||||||
|
menu.removeAllItems()
|
||||||
|
|
||||||
|
headerView = StatusBarHeaderView(environments: environments)
|
||||||
|
headerItem = NSMenuItem()
|
||||||
|
headerItem!.view = headerView
|
||||||
|
menu.addItem(headerItem!)
|
||||||
|
|
||||||
|
groupsItem = NSMenuItem(title: NSLocalizedString("Group", comment: ""), action: nil, keyEquivalent: "")
|
||||||
|
groupsItem!.submenu = NSMenu()
|
||||||
|
groupsItem!.isHidden = true
|
||||||
|
menu.addItem(groupsItem!)
|
||||||
|
|
||||||
|
profilesItem = NSMenuItem(title: NSLocalizedString("Profile", comment: ""), action: nil, keyEquivalent: "")
|
||||||
|
profilesItem!.submenu = NSMenu()
|
||||||
|
menu.addItem(profilesItem!)
|
||||||
|
|
||||||
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
|
let openItem = NSMenuItem(title: NSLocalizedString("Open", comment: ""), action: #selector(openApp), keyEquivalent: "")
|
||||||
|
openItem.target = self
|
||||||
|
menu.addItem(openItem)
|
||||||
|
|
||||||
|
let quitItem = NSMenuItem(title: NSLocalizedString("Quit", comment: ""), action: #selector(quitApp), keyEquivalent: "")
|
||||||
|
quitItem.target = self
|
||||||
|
menu.addItem(quitItem)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await loadProfiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeProfile() {
|
||||||
|
environments.$extensionProfile
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] profile in
|
||||||
|
self?.headerView?.updateProfile(profile)
|
||||||
|
self?.observeProfileStatus(profile)
|
||||||
|
self?.updateCommandClient()
|
||||||
|
self?.updateGroupsVisibility()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
environments.profileUpdate
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
Task {
|
||||||
|
await self?.loadProfiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
environments.selectedProfileUpdate
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
Task {
|
||||||
|
await self?.loadProfiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
environments.commandClient.$groups
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] groups in
|
||||||
|
self?.updateGroupsMenu(groups)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeProfileStatus(_ profile: ExtensionProfile?) {
|
||||||
|
statusCancellable = nil
|
||||||
|
guard let profile else { return }
|
||||||
|
statusCancellable = profile.$status
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.updateCommandClient()
|
||||||
|
self?.updateGroupsVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateGroupsVisibility() {
|
||||||
|
let isConnected = environments.extensionProfile?.status.isConnected == true
|
||||||
|
groupsItem?.isHidden = !isConnected
|
||||||
|
if isConnected {
|
||||||
|
environments.commandClient.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadProfiles() async {
|
||||||
|
guard let submenu = profilesItem?.submenu else { return }
|
||||||
|
submenu.removeAllItems()
|
||||||
|
|
||||||
|
let profileList: [ProfilePreview]
|
||||||
|
do {
|
||||||
|
profileList = try await ProfileManager.list().map { ProfilePreview($0) }
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if profileList.isEmpty {
|
||||||
|
let emptyItem = NSMenuItem(title: NSLocalizedString("Empty profiles", comment: ""), action: nil, keyEquivalent: "")
|
||||||
|
emptyItem.isEnabled = false
|
||||||
|
submenu.addItem(emptyItem)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedProfileID = await SharedPreferences.selectedProfileID.get()
|
||||||
|
if !profileList.contains(where: { $0.id == selectedProfileID }) {
|
||||||
|
selectedProfileID = profileList[0].id
|
||||||
|
await SharedPreferences.selectedProfileID.set(selectedProfileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for profile in profileList {
|
||||||
|
let item = NSMenuItem(title: profile.name, action: #selector(selectProfile(_:)), keyEquivalent: "")
|
||||||
|
item.target = self
|
||||||
|
item.representedObject = profile.id
|
||||||
|
item.state = profile.id == selectedProfileID ? .on : .off
|
||||||
|
submenu.addItem(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateGroupsMenu(_ groups: [LibboxOutboundGroup]?) {
|
||||||
|
guard let submenu = groupsItem?.submenu else { return }
|
||||||
|
submenu.removeAllItems()
|
||||||
|
|
||||||
|
guard let groups else { return }
|
||||||
|
|
||||||
|
let selectableGroups = groups.filter { $0.selectable }
|
||||||
|
if selectableGroups.isEmpty {
|
||||||
|
groupsItem?.isHidden = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groupsItem?.isHidden = environments.extensionProfile?.status.isConnected != true
|
||||||
|
|
||||||
|
let font = NSFont.menuFont(ofSize: 0)
|
||||||
|
let attrs: [NSAttributedString.Key: Any] = [.font: font]
|
||||||
|
|
||||||
|
for group in selectableGroups {
|
||||||
|
let groupItem = NSMenuItem(title: group.tag, action: nil, keyEquivalent: "")
|
||||||
|
let groupSubmenu = NSMenu()
|
||||||
|
|
||||||
|
var outboundData: [(LibboxOutboundGroupItem, NSMenuItem)] = []
|
||||||
|
var maxTagWidth: CGFloat = 0
|
||||||
|
var maxDelayWidth: CGFloat = 0
|
||||||
|
|
||||||
|
let items = group.getItems()!
|
||||||
|
while items.hasNext() {
|
||||||
|
let outbound = items.next()!
|
||||||
|
let outboundItem = NSMenuItem(
|
||||||
|
title: outbound.tag,
|
||||||
|
action: #selector(selectOutbound(_:)),
|
||||||
|
keyEquivalent: ""
|
||||||
|
)
|
||||||
|
outboundItem.target = self
|
||||||
|
outboundItem.representedObject = ["groupTag": group.tag, "outboundTag": outbound.tag]
|
||||||
|
outboundItem.state = group.selected == outbound.tag ? .on : .off
|
||||||
|
|
||||||
|
let tagWidth = (outbound.tag as NSString).size(withAttributes: attrs).width
|
||||||
|
maxTagWidth = max(maxTagWidth, tagWidth)
|
||||||
|
|
||||||
|
if outbound.urlTestDelay > 0 {
|
||||||
|
let delayText = "\(outbound.urlTestDelay)ms"
|
||||||
|
let delayWidth = (delayText as NSString).size(withAttributes: attrs).width
|
||||||
|
maxDelayWidth = max(maxDelayWidth, delayWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
outboundData.append((outbound, outboundItem))
|
||||||
|
}
|
||||||
|
|
||||||
|
let tabLocation = maxTagWidth + 20 + maxDelayWidth
|
||||||
|
let tabStop = NSTextTab(textAlignment: .right, location: tabLocation)
|
||||||
|
let style = NSMutableParagraphStyle()
|
||||||
|
style.tabStops = [tabStop]
|
||||||
|
|
||||||
|
for (outbound, outboundItem) in outboundData {
|
||||||
|
let delay = outbound.urlTestDelay
|
||||||
|
let delayText = delay > 0 ? "\(delay)ms" : ""
|
||||||
|
let fullText = "\(outbound.tag)\t\(delayText)"
|
||||||
|
let attrString = NSMutableAttributedString(
|
||||||
|
string: fullText,
|
||||||
|
attributes: [.font: font, .paragraphStyle: style]
|
||||||
|
)
|
||||||
|
if delay > 0 {
|
||||||
|
let color = NSColor.delayColor(for: UInt16(delay))
|
||||||
|
let delayStart = (fullText as NSString).length - (delayText as NSString).length
|
||||||
|
attrString.addAttribute(
|
||||||
|
.foregroundColor,
|
||||||
|
value: color,
|
||||||
|
range: NSRange(location: delayStart, length: (delayText as NSString).length)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
outboundItem.attributedTitle = attrString
|
||||||
|
groupSubmenu.addItem(outboundItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
groupItem.submenu = groupSubmenu
|
||||||
|
submenu.addItem(groupItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func selectProfile(_ sender: NSMenuItem) {
|
||||||
|
guard let profileID = sender.representedObject as? Int64 else { return }
|
||||||
|
Task {
|
||||||
|
await SharedPreferences.selectedProfileID.set(profileID)
|
||||||
|
environments.selectedProfileUpdate.send()
|
||||||
|
if environments.extensionProfile?.status.isConnected == true {
|
||||||
|
do {
|
||||||
|
try await environments.extensionProfile?.reloadService()
|
||||||
|
} catch {
|
||||||
|
showAlert(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadProfiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func selectOutbound(_ sender: NSMenuItem) {
|
||||||
|
guard let info = sender.representedObject as? [String: String],
|
||||||
|
let groupTag = info["groupTag"],
|
||||||
|
let outboundTag = info["outboundTag"]
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await LibboxNewStandaloneCommandClient()!.selectOutbound(groupTag, outboundTag: outboundTag)
|
||||||
|
} catch {
|
||||||
|
showAlert(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func openApp() {
|
||||||
|
NSApp.setActivationPolicy(.regular)
|
||||||
|
if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "main" }) {
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
}
|
||||||
|
if let dockApp = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.dock").first {
|
||||||
|
dockApp.activate()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func quitApp() {
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateCommandClient() {
|
||||||
|
guard statusItem != nil else { return }
|
||||||
|
let shouldConnect = speedMode != .disabled && environments.extensionProfile?.status.isConnectedStrict == true
|
||||||
|
if shouldConnect {
|
||||||
|
if commandClient == nil {
|
||||||
|
commandClient = CommandClient(.status)
|
||||||
|
commandClient!.$status
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] status in
|
||||||
|
self?.updateSpeedDisplay(status: status)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
commandClient!.connect()
|
||||||
|
} else {
|
||||||
|
commandClient?.disconnect()
|
||||||
|
updateSpeedDisplay(status: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSpeedDisplay(status: LibboxStatusMessage?) {
|
||||||
|
guard let button = statusItem?.button else { return }
|
||||||
|
if speedMode == .disabled || status == nil || !status!.trafficAvailable {
|
||||||
|
button.title = ""
|
||||||
|
} else if speedMode == .separate {
|
||||||
|
button.title = "↑ \(LibboxFormatBytes(status!.uplink))/s ↓ \(LibboxFormatBytes(status!.downlink))/s "
|
||||||
|
} else {
|
||||||
|
button.title = "\(LibboxFormatBytes(status!.uplink + status!.downlink))/s "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showAlert(error: Error) {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = NSLocalizedString("Error", comment: "")
|
||||||
|
alert.informativeText = error.localizedDescription
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.addButton(withTitle: NSLocalizedString("Ok", comment: ""))
|
||||||
|
alert.runModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NSMenuDelegate
|
||||||
|
|
||||||
|
public func menuWillOpen(_: NSMenu) {
|
||||||
|
headerView?.refresh()
|
||||||
|
Task {
|
||||||
|
await loadProfiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - StatusBarHeaderView
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private class StatusBarHeaderView: NSView {
|
||||||
|
private let environments: ExtensionEnvironments
|
||||||
|
private let titleLabel: NSTextField
|
||||||
|
private let statusSwitch: NSSwitch
|
||||||
|
private let loadingIndicator: NSProgressIndicator
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(environments: ExtensionEnvironments) {
|
||||||
|
self.environments = environments
|
||||||
|
|
||||||
|
titleLabel = NSTextField(labelWithString: "sing-box")
|
||||||
|
statusSwitch = NSSwitch()
|
||||||
|
loadingIndicator = NSProgressIndicator()
|
||||||
|
|
||||||
|
super.init(frame: NSRect(x: 0, y: 0, width: 250, height: 36))
|
||||||
|
setupView()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder _: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupView() {
|
||||||
|
titleLabel.font = .boldSystemFont(ofSize: 13)
|
||||||
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(titleLabel)
|
||||||
|
|
||||||
|
statusSwitch.target = self
|
||||||
|
statusSwitch.action = #selector(statusSwitchChanged)
|
||||||
|
statusSwitch.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(statusSwitch)
|
||||||
|
|
||||||
|
loadingIndicator.style = .spinning
|
||||||
|
loadingIndicator.controlSize = .small
|
||||||
|
loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
loadingIndicator.startAnimation(nil)
|
||||||
|
addSubview(loadingIndicator)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
|
||||||
|
titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||||
|
|
||||||
|
statusSwitch.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
|
||||||
|
statusSwitch.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||||
|
|
||||||
|
loadingIndicator.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
|
||||||
|
loadingIndicator.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
Task {
|
||||||
|
if environments.extensionProfile == nil {
|
||||||
|
await environments.reload()
|
||||||
|
}
|
||||||
|
updateProfile(environments.extensionProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProfile(_ profile: ExtensionProfile?) {
|
||||||
|
loadingIndicator.isHidden = profile != nil
|
||||||
|
statusSwitch.isHidden = profile == nil
|
||||||
|
|
||||||
|
if let profile {
|
||||||
|
statusSwitch.isEnabled = profile.status.isEnabled
|
||||||
|
statusSwitch.state = profile.status.isConnected ? .on : .off
|
||||||
|
observeProfileStatus(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeProfileStatus(_ profile: ExtensionProfile) {
|
||||||
|
cancellables.removeAll()
|
||||||
|
profile.$status
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] status in
|
||||||
|
self?.statusSwitch.isEnabled = status.isEnabled
|
||||||
|
self?.statusSwitch.state = status.isConnected ? .on : .off
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func statusSwitchChanged() {
|
||||||
|
let isOn = statusSwitch.state == .on
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
if isOn {
|
||||||
|
try await environments.extensionProfile?.start()
|
||||||
|
} else {
|
||||||
|
try await environments.extensionProfile?.stop()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
statusSwitch.state = isOn ? .off : .on
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = NSLocalizedString("Error", comment: "")
|
||||||
|
alert.informativeText = error.localizedDescription
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.addButton(withTitle: NSLocalizedString("Ok", comment: ""))
|
||||||
|
alert.runModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NSColor Extension
|
||||||
|
|
||||||
|
extension NSColor {
|
||||||
|
static func delayColor(for delay: UInt16) -> NSColor {
|
||||||
|
switch delay {
|
||||||
|
case 0:
|
||||||
|
return .systemGray
|
||||||
|
case ..<800:
|
||||||
|
return .systemGreen
|
||||||
|
case 800 ..< 1500:
|
||||||
|
return .systemYellow
|
||||||
|
default:
|
||||||
|
return .systemOrange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
SHELL := /bin/bash
|
SHELL := /bin/bash
|
||||||
.SHELLFLAGS := -o pipefail -c
|
.SHELLFLAGS := -o pipefail -c
|
||||||
|
.SILENT:
|
||||||
|
|
||||||
build_all: build_ios build_macos build_tvos
|
build_all: build_ios build_macos build_tvos
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,6 @@
|
|||||||
3AEECC3A2A6DFDC5006A0E0C /* MacLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEECC2F2A6DFDAD006A0E0C /* MacLibrary.framework */; };
|
3AEECC3A2A6DFDC5006A0E0C /* MacLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEECC2F2A6DFDAD006A0E0C /* MacLibrary.framework */; };
|
||||||
3AEECC412A6DFE29006A0E0C /* MacLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEECC2F2A6DFDAD006A0E0C /* MacLibrary.framework */; };
|
3AEECC412A6DFE29006A0E0C /* MacLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEECC2F2A6DFDAD006A0E0C /* MacLibrary.framework */; };
|
||||||
3AEECC452A6DFE61006A0E0C /* ApplicationLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */; };
|
3AEECC452A6DFE61006A0E0C /* ApplicationLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */; };
|
||||||
3AEECC4E2A6DFF13006A0E0C /* MacControlCenterUI in Frameworks */ = {isa = PBXBuildFile; productRef = 3AEECC4D2A6DFF13006A0E0C /* MacControlCenterUI */; };
|
|
||||||
3AF3A3D22B2207F3001FD7C1 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AF3A3D12B2207E1001FD7C1 /* libresolv.tbd */; };
|
3AF3A3D22B2207F3001FD7C1 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AF3A3D12B2207E1001FD7C1 /* libresolv.tbd */; };
|
||||||
3AFE19402EF5677100F61E06 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC72A302EED94F60039DEA4 /* SystemConfiguration.framework */; };
|
3AFE19402EF5677100F61E06 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC72A302EED94F60039DEA4 /* SystemConfiguration.framework */; };
|
||||||
9C37F17D2C131100001B9FA8 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9C37F17C2C131100001B9FA8 /* Localizable.xcstrings */; };
|
9C37F17D2C131100001B9FA8 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9C37F17C2C131100001B9FA8 /* Localizable.xcstrings */; };
|
||||||
@@ -730,7 +729,6 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
3AEECC452A6DFE61006A0E0C /* ApplicationLibrary.framework in Frameworks */,
|
3AEECC452A6DFE61006A0E0C /* ApplicationLibrary.framework in Frameworks */,
|
||||||
3AEECC4E2A6DFF13006A0E0C /* MacControlCenterUI in Frameworks */,
|
|
||||||
3ACE5E032EE1A91300644196 /* CodeEditSourceEditor in Frameworks */,
|
3ACE5E032EE1A91300644196 /* CodeEditSourceEditor in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -1159,7 +1157,6 @@
|
|||||||
);
|
);
|
||||||
name = MacLibrary;
|
name = MacLibrary;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
3AEECC4D2A6DFF13006A0E0C /* MacControlCenterUI */,
|
|
||||||
3ACE5E022EE1A91200644196 /* CodeEditSourceEditor */,
|
3ACE5E022EE1A91200644196 /* CodeEditSourceEditor */,
|
||||||
);
|
);
|
||||||
productName = MacLibrary;
|
productName = MacLibrary;
|
||||||
@@ -1237,7 +1234,6 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */,
|
3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */,
|
||||||
3A017F902A4AB2E4009149FA /* XCRemoteSwiftPackageReference "GRDB" */,
|
3A017F902A4AB2E4009149FA /* XCRemoteSwiftPackageReference "GRDB" */,
|
||||||
3A57DF3A2A4D705000690BC5 /* XCRemoteSwiftPackageReference "MacControlCenterUI" */,
|
|
||||||
3A4A020B2B53E3DC004EFB87 /* XCRemoteSwiftPackageReference "qrcode" */,
|
3A4A020B2B53E3DC004EFB87 /* XCRemoteSwiftPackageReference "qrcode" */,
|
||||||
3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */,
|
3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */,
|
||||||
3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */,
|
3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */,
|
||||||
@@ -3002,14 +2998,6 @@
|
|||||||
minimumVersion = 17.0.0;
|
minimumVersion = 17.0.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
3A57DF3A2A4D705000690BC5 /* XCRemoteSwiftPackageReference "MacControlCenterUI" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/orchetect/MacControlCenterUI";
|
|
||||||
requirement = {
|
|
||||||
kind = upToNextMajorVersion;
|
|
||||||
minimumVersion = 2.0.7;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */ = {
|
3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/christophhagen/BinaryCodable";
|
repositoryURL = "https://github.com/christophhagen/BinaryCodable";
|
||||||
@@ -3059,11 +3047,6 @@
|
|||||||
package = 3ACE5E012EE1A91100644196 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */;
|
package = 3ACE5E012EE1A91100644196 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */;
|
||||||
productName = CodeEditSourceEditor;
|
productName = CodeEditSourceEditor;
|
||||||
};
|
};
|
||||||
3AEECC4D2A6DFF13006A0E0C /* MacControlCenterUI */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = 3A57DF3A2A4D705000690BC5 /* XCRemoteSwiftPackageReference "MacControlCenterUI" */;
|
|
||||||
productName = MacControlCenterUI;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 3AEC20BD2A45991900A63465 /* Project object */;
|
rootObject = 3AEC20BD2A45991900A63465 /* Project object */;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "e52954b983309896985fa80fd8b8c5705fce23b28f9e149f71b7336e5cba3e37",
|
"originHash" : "1e51842f566cb5b99c8ac7575b8910b65467e19dda2da9562c06bf365e78c2e9",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "binarycodable",
|
"identity" : "binarycodable",
|
||||||
@@ -55,24 +55,6 @@
|
|||||||
"version" : "6.29.3"
|
"version" : "6.29.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "maccontrolcenterui",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/orchetect/MacControlCenterUI",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "40950d0b3f67dae4259e9ef2c14599977c1f4512",
|
|
||||||
"version" : "2.7.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "menubarextraaccess",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/orchetect/MenuBarExtraAccess",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "707dff6f55217b3ef5b6be84ced3e83511d4df5c",
|
|
||||||
"version" : "1.2.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "qrcode",
|
"identity" : "qrcode",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
Reference in New Issue
Block a user