diff --git a/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift b/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift index e36f98c..5958a86 100644 --- a/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift +++ b/ApplicationLibrary/Views/Abstract/GlobalChecksModifier.swift @@ -24,8 +24,6 @@ import SwiftUI case .notDetermined: pendingAuthorizationRequest = true manager.requestAlwaysAuthorization() - case .authorized, .authorizedAlways: - onAuthorizationGranted?() default: break } @@ -69,10 +67,10 @@ public struct GlobalChecksModifier: ViewModifier { handleImportRemoteProfile() } .onChangeCompat(of: importProfile.wrappedValue) { _ in - handleImportProfile() + Task { @MainActor in handleImportProfile() } } .onChangeCompat(of: importRemoteProfile.wrappedValue) { _ in - handleImportRemoteProfile() + Task { @MainActor in handleImportRemoteProfile() } } .onChangeCompat(of: environments.extensionProfile?.status) { status in handleStatusChange(status) @@ -84,10 +82,10 @@ public struct GlobalChecksModifier: ViewModifier { #if os(macOS) content .onReceive(NotificationCenter.default.publisher(for: .extensionRequiresWIFIState)) { _ in - handleWiFiStateNotification() + Task { @MainActor in handleWiFiStateNotification() } } .onReceive(NotificationCenter.default.publisher(for: .extensionRequiresHelperService)) { _ in - handleHelperServiceNotification() + Task { @MainActor in handleHelperServiceNotification() } } #else content @@ -133,26 +131,24 @@ public struct GlobalChecksModifier: ViewModifier { } private func handleStatusChange(_ status: NEVPNStatus?) { - guard let status else { return } - switch status { - case .connected: - notStarted = false - Task { + Task { @MainActor in + guard let status else { return } + switch status { + case .connected: + notStarted = false await checkDeprecatedNotes() - } - case .connecting: - notStarted = true - case .disconnected: - if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) { - if notStarted, let profile = environments.extensionProfile { - Task { + case .connecting: + notStarted = true + case .disconnected: + if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) { + if notStarted, let profile = environments.extensionProfile { await checkLastDisconnectError(profile: profile) } } + notStarted = false + default: + break } - notStarted = false - default: - break } } diff --git a/ApplicationLibrary/Views/Connections/ConnectionListView.swift b/ApplicationLibrary/Views/Connections/ConnectionListView.swift index 1ec8d74..f585288 100644 --- a/ApplicationLibrary/Views/Connections/ConnectionListView.swift +++ b/ApplicationLibrary/Views/Connections/ConnectionListView.swift @@ -63,7 +63,9 @@ public struct ConnectionListView: View { viewModel.connect() } .onReceive(environments.commandClient.$connections) { connections in - viewModel.setConnections(connections) + Task { @MainActor in + viewModel.setConnections(connections) + } } .onChangeCompat(of: viewModel.connectionStateFilter) { filter in environments.commandClient.connectionStateFilter = filter diff --git a/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift b/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift index 8d91170..875d67b 100644 --- a/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift +++ b/ApplicationLibrary/Views/Dashboard/Components/StartStopButton.swift @@ -108,20 +108,22 @@ public struct StartStopButton: View { .disabled(!profile.status.isEnabled) .alert($alert) .onReceive(timer) { _ in - currentTime = Date() + Task { @MainActor in + currentTime = Date() + } } .onChangeCompat(of: profile.status) { status in - if isStarting { - if status == .disconnected { - isStarting = false - if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) { - Task { + Task { @MainActor in + if isStarting { + if status == .disconnected { + isStarting = false + if #available(iOS 16.0, macOS 13.0, tvOS 17.0, *) { await checkStartupError() } + } else if status.isConnectedStrict { + isStarting = false + environments.commandClient.connect() } - } else if status.isConnectedStrict { - isStarting = false - environments.commandClient.connect() } } } diff --git a/ApplicationLibrary/Views/EnvironmentValues.swift b/ApplicationLibrary/Views/EnvironmentValues.swift index fbf1829..1ac3b6e 100644 --- a/ApplicationLibrary/Views/EnvironmentValues.swift +++ b/ApplicationLibrary/Views/EnvironmentValues.swift @@ -17,6 +17,19 @@ public extension EnvironmentValues { } } + private struct menuBarExtraSpeedModeKey: EnvironmentKey { + static let defaultValue: Binding = .constant(1) + } + + var menuBarExtraSpeedMode: Binding { + get { + self[menuBarExtraSpeedModeKey.self] + } + set { + self[menuBarExtraSpeedModeKey.self] = newValue + } + } + private struct selectionKey: EnvironmentKey { static let defaultValue: Binding = .constant(.dashboard) } diff --git a/ApplicationLibrary/Views/Groups/GroupListView.swift b/ApplicationLibrary/Views/Groups/GroupListView.swift index 362cdbb..c2fa6b0 100644 --- a/ApplicationLibrary/Views/Groups/GroupListView.swift +++ b/ApplicationLibrary/Views/Groups/GroupListView.swift @@ -28,7 +28,9 @@ public struct GroupListView: View { viewModel.connect() } .onReceive(environments.commandClient.$groups) { groups in - viewModel.setGroups(groups) + Task { @MainActor in + viewModel.setGroups(groups) + } } } } diff --git a/ApplicationLibrary/Views/Profile/EditorToolbarView.swift b/ApplicationLibrary/Views/Profile/EditorToolbarView.swift index 6e98bc2..13108ae 100644 --- a/ApplicationLibrary/Views/Profile/EditorToolbarView.swift +++ b/ApplicationLibrary/Views/Profile/EditorToolbarView.swift @@ -137,11 +137,11 @@ import SwiftUI } label: { Text(symbol) .font(.system(.body, design: .monospaced).weight(.medium)) - #if os(iOS) + #if os(iOS) .frame(minWidth: symbol.count > 1 ? nil : 32, minHeight: 32) - #else + #else .frame(minWidth: symbol.count > 1 ? nil : 24) - #endif + #endif } } } diff --git a/ApplicationLibrary/Views/Setting/MacAppView.swift b/ApplicationLibrary/Views/Setting/MacAppView.swift index c5015ad..fa999c7 100644 --- a/ApplicationLibrary/Views/Setting/MacAppView.swift +++ b/ApplicationLibrary/Views/Setting/MacAppView.swift @@ -11,6 +11,7 @@ public struct AppView: View { @State private var startAtLogin = false @Environment(\.showMenuBarExtra) private var showMenuBarExtra + @Environment(\.menuBarExtraSpeedMode) private var menuBarExtraSpeedMode @State private var menuBarExtraInBackground = false #if os(macOS) @@ -47,6 +48,17 @@ public struct AppView: View { } 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) .onChangeCompat(of: menuBarExtraInBackground) { newValue in Task { diff --git a/Library/Database/SharedPreferences.swift b/Library/Database/SharedPreferences.swift index 200dc2e..39999eb 100644 --- a/Library/Database/SharedPreferences.swift +++ b/Library/Database/SharedPreferences.swift @@ -1,5 +1,24 @@ 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 static let selectedProfileID = Preference("selected_profile_id", defaultValue: -1) @@ -47,10 +66,15 @@ public enum SharedPreferences { #if os(macOS) public static let showMenuBarExtra = Preference("show_menu_bar_extra", defaultValue: true) public static let menuBarExtraInBackground = Preference("menu_bar_extra_in_background", defaultValue: false) + public static let menuBarExtraSpeedMode = Preference("menu_bar_extra_speed_mode", defaultValue: MenuBarExtraSpeedMode.enabled.rawValue) public static let startedByUser = Preference("started_by_user", defaultValue: false) public static func resetMacOS() async { - try? await batchDelete([showMenuBarExtra.name, menuBarExtraInBackground.name]) + try? await batchDelete([ + showMenuBarExtra.name, + menuBarExtraInBackground.name, + menuBarExtraSpeedMode.name, + ]) } #endif diff --git a/Localizable.xcstrings b/Localizable.xcstrings index bb8da1f..f2521af 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -739,6 +739,9 @@ } } } + }, + "Detailed" : { + }, "Disable Deprecated Warnings" : { "localizations" : { @@ -749,6 +752,9 @@ } } } + }, + "Disabled" : { + }, "Disconnect" : { "localizations" : { @@ -1009,6 +1015,9 @@ } } } + }, + "Enabled" : { + }, "enforceRoutes" : { "shouldTranslate" : false @@ -1180,6 +1189,9 @@ }, "Goroutines" : { "shouldTranslate" : false + }, + "Group" : { + }, "Groups" : { "localizations" : { @@ -1749,6 +1761,7 @@ } }, "NetworkExtension not installed" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1817,6 +1830,9 @@ } } } + }, + "Not installed" : { + }, "Ok" : { "localizations" : { @@ -2072,6 +2088,9 @@ } } } + }, + "Real-time Speed" : { + }, "Reboot required." : { "localizations" : { diff --git a/MacLibrary/MacApplication.swift b/MacLibrary/MacApplication.swift index 548924e..2f68084 100644 --- a/MacLibrary/MacApplication.swift +++ b/MacLibrary/MacApplication.swift @@ -1,11 +1,14 @@ import ApplicationLibrary +import Libbox import Library +import NetworkExtension import SwiftUI public struct MacApplication: Scene { @State private var showMenuBarExtra = false - @State private var isMenuPresented = false + @State private var menuBarExtraSpeedMode = MenuBarExtraSpeedMode.enabled.rawValue @StateObject private var environments = ExtensionEnvironments() + @State private var statusBarController: StatusBarController? private let profileEditor: (Binding, Bool) -> AnyView = { text, isEditable in AnyView(ProfileEditorWrapperView(text: text, isEditable: isEditable)) @@ -21,7 +24,20 @@ public struct MacApplication: Scene { } } .environment(\.showMenuBarExtra, $showMenuBarExtra) + .environment(\.menuBarExtraSpeedMode, $menuBarExtraSpeedMode) .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) .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 EditProfileContentWindow(context: context) .environment(\.profileEditor, profileEditor) @@ -67,6 +74,10 @@ public struct MacApplication: Scene { private func initialize() async { 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) { diff --git a/MacLibrary/MainView.swift b/MacLibrary/MainView.swift index 03d9de4..1e360d9 100644 --- a/MacLibrary/MainView.swift +++ b/MacLibrary/MainView.swift @@ -57,23 +57,27 @@ public struct MainView: View { } } .onChangeCompat(of: controlActiveState) { newValue in - viewModel.onControlActiveStateChange(newValue, environments: environments) + Task { @MainActor in + viewModel.onControlActiveStateChange(newValue, environments: environments) + } } .onChangeCompat(of: viewModel.selection) { value in - viewModel.onSelectionChange(value, environments: environments) - if value != .settings { - settingsNavigationPath = NavigationPath() - pendingSettingsPage = nil - return - } - if let page = pendingSettingsPage { - settingsNavigationPath = NavigationPath() - settingsNavigationPath.append(page) - pendingSettingsPage = nil + Task { @MainActor in + viewModel.onSelectionChange(value, environments: environments) + if value != .settings { + settingsNavigationPath = NavigationPath() + pendingSettingsPage = nil + return + } + if let page = pendingSettingsPage { + settingsNavigationPath = NavigationPath() + settingsNavigationPath.append(page) + pendingSettingsPage = nil + } } } .onReceive(environments.openSettings) { - viewModel.openSettings() + Task { @MainActor in viewModel.openSettings() } } .onReceive(NotificationCenter.default.publisher(for: .navigateToSettingsPage)) { notification in guard let page = notification.object as? SettingsPage else { return } diff --git a/MacLibrary/MenuView.swift b/MacLibrary/MenuView.swift deleted file mode 100644 index dbeb91d..0000000 --- a/MacLibrary/MenuView.swift +++ /dev/null @@ -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) { - _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 { - $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 - } - } -} diff --git a/MacLibrary/SidebarView.swift b/MacLibrary/SidebarView.swift index 442e0be..2e57cdc 100644 --- a/MacLibrary/SidebarView.swift +++ b/MacLibrary/SidebarView.swift @@ -5,6 +5,7 @@ import SwiftUI public struct SidebarView: View { @Binding var selection: NavigationPage @EnvironmentObject private var environments: ExtensionEnvironments + @State private var localSelection: NavigationPage = .dashboard public init(selection: Binding) { _selection = selection @@ -19,9 +20,9 @@ public struct SidebarView: View { sidebarContent(isConnected: profile.status.isConnectedStrict, profile: profile) .onReceive(profile.$status) { _ in } .onChangeCompat(of: profile.status) { - if !selection.visible(profile) { - DispatchQueue.main.async { - selection = .dashboard + if !localSelection.visible(profile) { + Task { @MainActor in + localSelection = .dashboard } } } @@ -32,7 +33,7 @@ public struct SidebarView: View { @ViewBuilder private func sidebarContent(isConnected: Bool, profile: ExtensionProfile?) -> some View { - List(selection: $selection) { + List(selection: $localSelection) { if isConnected { Section(NavigationPage.dashboard.title) { Label("Overview", systemImage: "text.and.command.macwindow") @@ -55,5 +56,20 @@ public struct SidebarView: View { } .listStyle(.sidebar) .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 + } + } + } } } diff --git a/MacLibrary/StatusBarController.swift b/MacLibrary/StatusBarController.swift new file mode 100644 index 0000000..e65efb0 --- /dev/null +++ b/MacLibrary/StatusBarController.swift @@ -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() + 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() + + 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 + } + } +} diff --git a/Makefile b/Makefile index 68dffea..33a7fb7 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ SHELL := /bin/bash .SHELLFLAGS := -o pipefail -c +.SILENT: build_all: build_ios build_macos build_tvos diff --git a/sing-box.xcodeproj/project.pbxproj b/sing-box.xcodeproj/project.pbxproj index 093f076..891b19a 100644 --- a/sing-box.xcodeproj/project.pbxproj +++ b/sing-box.xcodeproj/project.pbxproj @@ -54,7 +54,6 @@ 3AEECC3A2A6DFDC5006A0E0C /* 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 */; }; - 3AEECC4E2A6DFF13006A0E0C /* MacControlCenterUI in Frameworks */ = {isa = PBXBuildFile; productRef = 3AEECC4D2A6DFF13006A0E0C /* MacControlCenterUI */; }; 3AF3A3D22B2207F3001FD7C1 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AF3A3D12B2207E1001FD7C1 /* libresolv.tbd */; }; 3AFE19402EF5677100F61E06 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC72A302EED94F60039DEA4 /* SystemConfiguration.framework */; }; 9C37F17D2C131100001B9FA8 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9C37F17C2C131100001B9FA8 /* Localizable.xcstrings */; }; @@ -730,7 +729,6 @@ buildActionMask = 2147483647; files = ( 3AEECC452A6DFE61006A0E0C /* ApplicationLibrary.framework in Frameworks */, - 3AEECC4E2A6DFF13006A0E0C /* MacControlCenterUI in Frameworks */, 3ACE5E032EE1A91300644196 /* CodeEditSourceEditor in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1159,7 +1157,6 @@ ); name = MacLibrary; packageProductDependencies = ( - 3AEECC4D2A6DFF13006A0E0C /* MacControlCenterUI */, 3ACE5E022EE1A91200644196 /* CodeEditSourceEditor */, ); productName = MacLibrary; @@ -1237,7 +1234,6 @@ packageReferences = ( 3A7E90362A46778E00D53052 /* XCRemoteSwiftPackageReference "BinaryCodable" */, 3A017F902A4AB2E4009149FA /* XCRemoteSwiftPackageReference "GRDB" */, - 3A57DF3A2A4D705000690BC5 /* XCRemoteSwiftPackageReference "MacControlCenterUI" */, 3A4A020B2B53E3DC004EFB87 /* XCRemoteSwiftPackageReference "qrcode" */, 3A2E87F02ED5A91100644195 /* XCLocalSwiftPackageReference "Frameworks/Runestone" */, 3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */, @@ -3002,14 +2998,6 @@ 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" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/christophhagen/BinaryCodable"; @@ -3059,11 +3047,6 @@ package = 3ACE5E012EE1A91100644196 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; productName = CodeEditSourceEditor; }; - 3AEECC4D2A6DFF13006A0E0C /* MacControlCenterUI */ = { - isa = XCSwiftPackageProductDependency; - package = 3A57DF3A2A4D705000690BC5 /* XCRemoteSwiftPackageReference "MacControlCenterUI" */; - productName = MacControlCenterUI; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 3AEC20BD2A45991900A63465 /* Project object */; diff --git a/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f660959..f858f4f 100644 --- a/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e52954b983309896985fa80fd8b8c5705fce23b28f9e149f71b7336e5cba3e37", + "originHash" : "1e51842f566cb5b99c8ac7575b8910b65467e19dda2da9562c06bf365e78c2e9", "pins" : [ { "identity" : "binarycodable", @@ -55,24 +55,6 @@ "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", "kind" : "remoteSourceControl",