From 3e3749e3a6f191fee39e30f42973853e7f4fcbe5 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Wed, 28 Oct 2026 18:56:07 +0800 Subject: [PATCH] feat: show runtime status dots in menu bar --- .../App/Composition/MainApp.swift | 19 +------ .../App/UI/Home/RuntimeControlCard.swift | 14 +---- .../App/UI/Workspace/StatusCardView.swift | 52 ++++++++++++++++++- 3 files changed, 53 insertions(+), 32 deletions(-) diff --git a/ShadowsocksX-NG2/App/Composition/MainApp.swift b/ShadowsocksX-NG2/App/Composition/MainApp.swift index 3de41e8f..b203bd02 100644 --- a/ShadowsocksX-NG2/App/Composition/MainApp.swift +++ b/ShadowsocksX-NG2/App/Composition/MainApp.swift @@ -62,7 +62,7 @@ struct ShadowsocksXNG2App: App { ProxyStatusMenu( control: proxyControl, statusDisplay: statusDisplay) } label: { - StatusMenuLabel(control: proxyControl) + StatusMenuLabel(control: proxyControl, statusDisplay: statusDisplay) } // .menuBarExtraStyle(.window) @@ -148,23 +148,6 @@ private final class NSAppWindowActivationApplier: WindowActivationPolicyApplying } } -/// 状态菜单的标签视图(状态栏图标)。图标为静态 SF Symbol,不随代理状态 -/// 变化,故不观察 control——control 仅承载启动 resync。挂载时机与菜单内容 -/// 视图不同:内容视图首次点开才创建,而标签在启动时即挂载——启动 resync -/// 只能挂在这里,挂在菜单内容上等于从不执行。主窗口的启动呈现由 Window -/// scene 的 defaultLaunchBehavior 负责,与本任务无时序耦合。 -private struct StatusMenuLabel: View { - let control: ProxyControlWorkflow - - var body: some View { - Image(systemName: "paperplane.fill") - .accessibilityLabel("ShadowsocksX-NG2") - .task { - await control.resyncOnLaunch() - } - } -} - /// 组合根装配:目录提交协调器与全部生产运行时适配器只在此接线一次,各 scene /// 不再重复设置提交回调;目录工作流的实现依赖经 CatalogWorkflowDependencies /// 显式装配(workflow 不自行创建生产默认实现);Legacy 导入后的 2.0 运行时 diff --git a/ShadowsocksX-NG2/App/UI/Home/RuntimeControlCard.swift b/ShadowsocksX-NG2/App/UI/Home/RuntimeControlCard.swift index bef3a7a0..b1570b03 100644 --- a/ShadowsocksX-NG2/App/UI/Home/RuntimeControlCard.swift +++ b/ShadowsocksX-NG2/App/UI/Home/RuntimeControlCard.swift @@ -64,10 +64,7 @@ struct RuntimeControlCard: View { private func statusRow(_ row: StatusCardModel.Row) -> some View { HStack(spacing: 6) { - Circle() - .fill(statusColor(row.tone)) - .frame(width: 8, height: 8) - .accessibilityHidden(true) + RuntimeStatusDot(tone: row.tone, diameter: 8) Text(row.text) .font(.body.weight(.medium)) .foregroundStyle(.secondary) @@ -75,15 +72,6 @@ struct RuntimeControlCard: View { } } - private func statusColor(_ tone: StatusCardModel.Tone) -> Color { - switch tone { - case .positive: .green - case .attention: .orange - case .failure: .red - case .neutral: .secondary - } - } - private var agentBinding: Binding { Binding( get: { control.snapshot.agentIntentEnabled }, diff --git a/ShadowsocksX-NG2/App/UI/Workspace/StatusCardView.swift b/ShadowsocksX-NG2/App/UI/Workspace/StatusCardView.swift index ce52914d..421d8d16 100644 --- a/ShadowsocksX-NG2/App/UI/Workspace/StatusCardView.swift +++ b/ShadowsocksX-NG2/App/UI/Workspace/StatusCardView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI /// 菜单栏面板代理状态卡。状态行经 StatusDisplayModel 的瞬态门控呈现 @@ -36,8 +37,21 @@ struct StatusCardView: View { .lineLimit(2) .help(detail.text) } +} - private func color(_ tone: StatusCardModel.Tone) -> Color { +/// 首页与状态栏共用圆点及色调映射,尺寸由显示位置决定。 +struct RuntimeStatusDot: View { + let tone: StatusCardModel.Tone + let diameter: CGFloat + + var body: some View { + Circle() + .fill(color) + .frame(width: diameter, height: diameter) + .accessibilityHidden(true) + } + + private var color: Color { switch tone { case .positive: .green case .attention: .orange @@ -46,3 +60,39 @@ struct StatusCardView: View { } } } + +/// 标签在启动时即挂载;启动 resync 必须留在此处,不能移入首次点开才创建的菜单内容。 +struct StatusMenuLabel: View { + let control: ProxyControlWorkflow + let statusDisplay: StatusDisplayModel + @Environment(\.colorScheme) private var colorScheme + @Environment(\.displayScale) private var displayScale + + var body: some View { + Image(nsImage: statusImage) + .renderingMode(.original) + .accessibilityLabel("ShadowsocksX-NG2") + .task { + await control.resyncOnLaunch() + } + } + + /// MenuBarExtra 使用单张图像承载主图标和圆点,保留圆点的原始颜色。 + private var statusImage: NSImage { + let renderer = ImageRenderer( + content: HStack(spacing: 2) { + Image(systemName: "paperplane.fill") + .font(.system(size: 16)) + .foregroundStyle(.primary) + .frame(width: 18, height: 18) + VStack(spacing: 5) { + RuntimeStatusDot(tone: statusDisplay.agent.row.tone, diameter: 4) + RuntimeStatusDot(tone: statusDisplay.systemProxy.row.tone, diameter: 4) + } + } + .environment(\.colorScheme, colorScheme)) + renderer.scale = displayScale + return renderer.nsImage ?? NSImage( + systemSymbolName: "paperplane.fill", accessibilityDescription: nil) ?? NSImage(size: .zero) + } +}