Rewrite menu bar extra with AppKit

This commit is contained in:
世界
2026-01-05 19:19:11 +08:00
parent 91edd77b00
commit ce0d7ead88
17 changed files with 666 additions and 301 deletions
@@ -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,28 +131,26 @@ public struct GlobalChecksModifier: ViewModifier {
} }
private func handleStatusChange(_ status: NEVPNStatus?) { private func handleStatusChange(_ status: NEVPNStatus?) {
Task { @MainActor in
guard let status else { return } guard let status else { return }
switch status { switch status {
case .connected: case .connected:
notStarted = false notStarted = false
Task {
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 notStarted = false
default: default:
break break
} }
} }
}
@available(iOS 16.0, macOS 13.0, tvOS 17.0, *) @available(iOS 16.0, macOS 13.0, tvOS 17.0, *)
private nonisolated func checkLastDisconnectError(profile: ExtensionProfile) async { private nonisolated func checkLastDisconnectError(profile: ExtensionProfile) async {
@@ -63,8 +63,10 @@ public struct ConnectionListView: View {
viewModel.connect() viewModel.connect()
} }
.onReceive(environments.commandClient.$connections) { connections in .onReceive(environments.commandClient.$connections) { connections in
Task { @MainActor in
viewModel.setConnections(connections) viewModel.setConnections(connections)
} }
}
.onChangeCompat(of: viewModel.connectionStateFilter) { filter in .onChangeCompat(of: viewModel.connectionStateFilter) { filter in
environments.commandClient.connectionStateFilter = filter environments.commandClient.connectionStateFilter = filter
environments.commandClient.filterConnectionsNow() environments.commandClient.filterConnectionsNow()
@@ -108,17 +108,18 @@ public struct StartStopButton: View {
.disabled(!profile.status.isEnabled) .disabled(!profile.status.isEnabled)
.alert($alert) .alert($alert)
.onReceive(timer) { _ in .onReceive(timer) { _ in
Task { @MainActor in
currentTime = Date() currentTime = Date()
} }
}
.onChangeCompat(of: profile.status) { status in .onChangeCompat(of: profile.status) { status in
Task { @MainActor in
if isStarting { if isStarting {
if status == .disconnected { if status == .disconnected {
isStarting = false isStarting = false
if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) { if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) {
Task {
await checkStartupError() await checkStartupError()
} }
}
} else if status.isConnectedStrict { } else if status.isConnectedStrict {
isStarting = false isStarting = false
environments.commandClient.connect() environments.commandClient.connect()
@@ -126,6 +127,7 @@ public struct StartStopButton: View {
} }
} }
} }
}
#if os(iOS) #if os(iOS)
private var showRuntimeDuration: Bool { private var showRuntimeDuration: Bool {
@@ -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
Task { @MainActor in
viewModel.setGroups(groups) viewModel.setGroups(groups)
} }
} }
} }
}
@@ -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 {
+25 -1
View File
@@ -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
+19
View File
@@ -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" : {
+21 -10
View File
@@ -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) {
+5 -1
View File
@@ -57,9 +57,12 @@ public struct MainView: View {
} }
} }
.onChangeCompat(of: controlActiveState) { newValue in .onChangeCompat(of: controlActiveState) { newValue in
Task { @MainActor in
viewModel.onControlActiveStateChange(newValue, environments: environments) viewModel.onControlActiveStateChange(newValue, environments: environments)
} }
}
.onChangeCompat(of: viewModel.selection) { value in .onChangeCompat(of: viewModel.selection) { value in
Task { @MainActor in
viewModel.onSelectionChange(value, environments: environments) viewModel.onSelectionChange(value, environments: environments)
if value != .settings { if value != .settings {
settingsNavigationPath = NavigationPath() settingsNavigationPath = NavigationPath()
@@ -72,8 +75,9 @@ public struct MainView: View {
pendingSettingsPage = nil 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 }
-203
View File
@@ -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
}
}
}
+20 -4
View File
@@ -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
}
}
}
} }
} }
+501
View File
@@ -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
View File
@@ -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
-17
View File
@@ -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",