mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-01 07:33:46 +00:00
feat(ui): debounce transient status rows via shared display gate
The home run-control card and the menu-bar status card render the agent and system-proxy status rows straight from snapshot replacements, so fast operations (e.g. proxy mode switches) flash transient states like "applying" before settling. Add a presentation-layer debounce shared by both surfaces: - TransientDelayGate: per-stream gate where stable values display immediately, transient values (starting/applying/repairing) display after 400 ms and are dropped when superseded, and displayed transients are held for at least 400 ms. - StatusDisplayModel: app-level shared model deriving agent and system-proxy payloads (row + detail + hint) from workflow snapshots through two independent gates; domain facts keep their cadence. - Wire the model through the composition root into RuntimeControlCard and StatusCardView; toggles, approval actions, the difference view and target/mode rows keep reading the raw snapshot.
This commit is contained in:
@@ -8,6 +8,9 @@ struct ShadowsocksXNG2App: App {
|
||||
@NSApplicationDelegateAdaptor(ConnectivityApplicationDelegate.self) private var appDelegate
|
||||
private let connectivityLifecycle: ConnectivityLifecycle
|
||||
private let proxyController: ProxyRuntimeController
|
||||
/// 状态显示 debounce 模型:进程期强持有(与首页/菜单栏两个表面共享同一
|
||||
/// 门控状态机),body 不读它。
|
||||
private let statusDisplay: StatusDisplayModel
|
||||
@StateObject private var catalogWorkflow: CatalogWorkflow
|
||||
@StateObject private var proxyControl: ProxyControlWorkflow
|
||||
@StateObject private var loginController: LaunchAtLoginController
|
||||
@@ -32,6 +35,7 @@ struct ShadowsocksXNG2App: App {
|
||||
let composition = AppComposition.make()
|
||||
connectivityLifecycle = composition.connectivityLifecycle
|
||||
proxyController = composition.controller
|
||||
statusDisplay = composition.statusDisplay
|
||||
_catalogWorkflow = StateObject(wrappedValue: composition.catalogWorkflow)
|
||||
_proxyControl = StateObject(wrappedValue: composition.proxyControl)
|
||||
_loginController = StateObject(wrappedValue: composition.loginController)
|
||||
@@ -55,7 +59,7 @@ struct ShadowsocksXNG2App: App {
|
||||
var body: some Scene {
|
||||
MenuBarExtra {
|
||||
ProxyStatusMenu(
|
||||
control: proxyControl)
|
||||
control: proxyControl, statusDisplay: statusDisplay)
|
||||
} label: {
|
||||
StatusMenuLabel(control: proxyControl)
|
||||
}
|
||||
@@ -169,6 +173,7 @@ private struct AppComposition {
|
||||
let controller: ProxyRuntimeController
|
||||
let catalogWorkflow: CatalogWorkflow
|
||||
let proxyControl: ProxyControlWorkflow
|
||||
let statusDisplay: StatusDisplayModel
|
||||
let loginController: LaunchAtLoginController
|
||||
let settingsWorkflow: SettingsWorkflow
|
||||
let diagnosticsWorkflow: DiagnosticsWorkflow
|
||||
@@ -206,6 +211,9 @@ private struct AppComposition {
|
||||
runtime: ControllerProxyRuntimeAdapter(controller: controller),
|
||||
targetFacts: catalogWorkflow,
|
||||
interfaceFacts: SystemInterfaceFactsProvider())
|
||||
// 状态显示 debounce 模型:订阅代理控制工作流 snapshot,组合根接线一次,
|
||||
// 首页运行控制卡与菜单栏状态卡共享同一门控状态机。
|
||||
let statusDisplay = StatusDisplayModel(control: proxyControl)
|
||||
// 设置工作流 module(Candidate 02):设置窗口的唯一 seam,组合根接线一次;
|
||||
// 写入侧经窄缝 SettingsCommitting,运行时控制器薄扩展即生产实现。
|
||||
let settingsWorkflow = SettingsWorkflow(
|
||||
@@ -218,13 +226,15 @@ private struct AppComposition {
|
||||
let workspace = makeWorkspace(
|
||||
WorkspaceCompositionContext(
|
||||
dependencies: dependencies, controller: controller, workflow: catalogWorkflow,
|
||||
control: proxyControl, plugins: plugins, settings: settingsWorkflow,
|
||||
control: proxyControl, statusDisplay: statusDisplay, plugins: plugins,
|
||||
settings: settingsWorkflow,
|
||||
login: loginController, clipboard: textClipboard))
|
||||
return AppComposition(
|
||||
connectivityLifecycle: connectivityLifecycle,
|
||||
controller: controller,
|
||||
catalogWorkflow: catalogWorkflow,
|
||||
proxyControl: proxyControl,
|
||||
statusDisplay: statusDisplay,
|
||||
loginController: loginController,
|
||||
settingsWorkflow: settingsWorkflow,
|
||||
diagnosticsWorkflow: diagnosticsWorkflow,
|
||||
@@ -297,6 +307,7 @@ private struct AppComposition {
|
||||
route: route,
|
||||
workflow: context.workflow,
|
||||
control: context.control,
|
||||
statusDisplay: context.statusDisplay,
|
||||
plugins: PluginManagementModel(catalog: context.plugins),
|
||||
settingsWorkflow: context.settings,
|
||||
loginController: context.login,
|
||||
@@ -360,6 +371,7 @@ private struct WorkspaceCompositionContext {
|
||||
let controller: ProxyRuntimeController
|
||||
let workflow: CatalogWorkflow
|
||||
let control: ProxyControlWorkflow
|
||||
let statusDisplay: StatusDisplayModel
|
||||
let plugins: PluginCatalog
|
||||
let settings: SettingsWorkflow
|
||||
let login: LaunchAtLoginController
|
||||
|
||||
@@ -2,11 +2,13 @@ import SwiftUI
|
||||
|
||||
/// 首页分区:代理模式切换、运行控制、终端代理环境变量命令。
|
||||
/// 模式与两个开关(agent/系统代理)走代理控制
|
||||
/// 工作流的整体 snapshot;命令复制是 UI 副作用。三种模式(规则/全局/直连)
|
||||
/// 工作流的整体 snapshot;运行控制卡的状态行走 statusDisplay 的瞬态门控
|
||||
/// 呈现;命令复制是 UI 副作用。三种模式(规则/全局/直连)
|
||||
/// 与规则子选项共用同一快照。
|
||||
struct HomeView: View {
|
||||
@ObservedObject var workflow: CatalogWorkflow
|
||||
@ObservedObject var control: ProxyControlWorkflow
|
||||
let statusDisplay: StatusDisplayModel
|
||||
let browser: HomeTargetBrowser
|
||||
let activation: ActivationFeedbackState
|
||||
let showServers: () -> Void
|
||||
@@ -23,7 +25,7 @@ struct HomeView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
VStack(spacing: 16) {
|
||||
RuntimeControlCard(control: control)
|
||||
RuntimeControlCard(control: control, statusDisplay: statusDisplay)
|
||||
ModeCard(control: control)
|
||||
TerminalProxyEnvironmentCard(control: control, clipboard: clipboard, errors: errors)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 「运行控制」卡:agent 与系统代理两个开关并排,绑定同一
|
||||
/// snapshot 的持久化意图;各自呈现运行状态/实际应用与点名原因。两个开关
|
||||
/// snapshot 的持久化意图;状态行经 StatusDisplayModel 的瞬态门控呈现——
|
||||
/// 快速切换模式这类瞬间成功的操作不再闪「应用中」等中间态。两个开关
|
||||
/// 互不代替:关闭系统代理清除全部系统代理配置,关闭 agent 会级联关闭仍开启
|
||||
/// 的系统代理意图并先清理再停止监听;helper 待批准时呈现登录项批准路径。
|
||||
struct RuntimeControlCard: View {
|
||||
@ObservedObject var control: ProxyControlWorkflow
|
||||
let statusDisplay: StatusDisplayModel
|
||||
|
||||
var body: some View {
|
||||
let card = StatusCardModel.card(from: control.snapshot)
|
||||
HomeCard(
|
||||
title: String(localized: "运行控制"),
|
||||
subtitle: nil,
|
||||
@@ -21,8 +22,8 @@ struct RuntimeControlCard: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
statusRow(card.runtimeRow)
|
||||
if let detail = card.runtimeDetail {
|
||||
statusRow(statusDisplay.agent.row)
|
||||
if let detail = statusDisplay.agent.detail {
|
||||
Text(detail.text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
@@ -37,8 +38,8 @@ struct RuntimeControlCard: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
statusRow(card.systemProxyRow)
|
||||
Text(systemProxyHint)
|
||||
statusRow(statusDisplay.systemProxy.row)
|
||||
Text(statusDisplay.systemProxy.hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -49,10 +50,9 @@ struct RuntimeControlCard: View {
|
||||
.controlSize(.small)
|
||||
}
|
||||
SystemProxyDifferenceView(control: control)
|
||||
if let systemProxyDetail = card.systemProxyDetail,
|
||||
control.snapshot.systemProxyApplication != .paused
|
||||
if let systemProxyDetail = statusDisplay.systemProxy.detail,
|
||||
!statusDisplay.systemProxy.isPaused
|
||||
{
|
||||
|
||||
Text(systemProxyDetail.text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
@@ -99,36 +99,6 @@ struct RuntimeControlCard: View {
|
||||
private var systemProxyApprovalNeeded: Bool {
|
||||
control.snapshot.systemProxyApprovalRequired
|
||||
}
|
||||
|
||||
private var systemProxyHint: String {
|
||||
let exitRequirement =
|
||||
control.snapshot.proxyMode == .direct
|
||||
? String(localized: "直连模式不依赖活动服务器")
|
||||
: String(localized: "规则/全局模式需要可用的活动服务器")
|
||||
if systemProxyApprovalNeeded {
|
||||
return String(localized: "系统代理助手等待批准。请在系统设置 → 登录项与扩展中允许。")
|
||||
}
|
||||
return switch control.snapshot.systemProxyApplication {
|
||||
case .idle:
|
||||
String(localized: "开启后让 macOS 系统代理指向本地入口;\(exitRequirement)")
|
||||
case .pending:
|
||||
String(localized: "已请求应用系统代理,等待本地入口就绪;\(exitRequirement)")
|
||||
case .applied:
|
||||
String(localized: "系统代理已指向本地入口")
|
||||
case .failed, .repairFailed, .clearFailed:
|
||||
String(localized: "操作失败,原因见下方")
|
||||
case .paused:
|
||||
AppPresentation.systemProxyPausedNotice
|
||||
case .changed:
|
||||
String(localized: "当前系统代理配置与预期存在差异")
|
||||
case .unreadable:
|
||||
String(localized: "无法检查当前系统代理配置")
|
||||
case .applying:
|
||||
String(localized: "正在应用系统代理配置…")
|
||||
case .repairing:
|
||||
String(localized: "正在修复系统代理配置…")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Names and commands come from current workflow facts; no configuration decisions in SwiftUI.
|
||||
|
||||
@@ -2,9 +2,11 @@ import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// 菜单栏窗口式面板:重开主窗口、只读状态卡、无效服务器提示与退出。
|
||||
/// 控制与目标选择留在工作区;状态卡继续从 control 的整体 snapshot 派生。
|
||||
/// 控制与目标选择留在工作区;状态卡的状态行走 statusDisplay 的瞬态门控
|
||||
/// 呈现(与首页运行控制卡同一 debounce 口径)。
|
||||
struct ProxyStatusMenu: View {
|
||||
@ObservedObject var control: ProxyControlWorkflow
|
||||
let statusDisplay: StatusDisplayModel
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
/// 只捕获本面板的宿主窗口,打开主窗口时不会误关其他窗口。
|
||||
@State private var windowAnchor = NSView(frame: .zero)
|
||||
@@ -26,7 +28,7 @@ struct ProxyStatusMenu: View {
|
||||
.padding(12)
|
||||
|
||||
Divider()
|
||||
StatusCardView(control: control)
|
||||
StatusCardView(control: control, statusDisplay: statusDisplay)
|
||||
Divider()
|
||||
Button("退出 ShadowsocksX-NG2(代理仍在后台运行)") {
|
||||
NSApp.terminate(nil)
|
||||
|
||||
@@ -18,6 +18,9 @@ struct MainWindowView: View {
|
||||
@ObservedObject var route: WorkspaceRoute
|
||||
@ObservedObject var workflow: CatalogWorkflow
|
||||
@ObservedObject var control: ProxyControlWorkflow
|
||||
/// 首页运行控制卡与菜单栏状态卡共享的状态显示 debounce 模型;壳 body
|
||||
/// 不读它(分区视图自行观察),用 let 转发。
|
||||
let statusDisplay: StatusDisplayModel
|
||||
let plugins: PluginManagementModel
|
||||
let settingsWorkflow: SettingsWorkflow
|
||||
let loginController: LaunchAtLoginController
|
||||
@@ -127,6 +130,7 @@ struct MainWindowView: View {
|
||||
WorkspaceHomeView(
|
||||
workflow: workflow,
|
||||
control: control,
|
||||
statusDisplay: statusDisplay,
|
||||
browser: homeBrowser,
|
||||
activation: activationFeedback,
|
||||
showServers: { route.navigate(to: .servers) },
|
||||
@@ -183,6 +187,7 @@ extension WorkspaceDestination {
|
||||
private struct WorkspaceHomeView: View {
|
||||
@ObservedObject var workflow: CatalogWorkflow
|
||||
@ObservedObject var control: ProxyControlWorkflow
|
||||
let statusDisplay: StatusDisplayModel
|
||||
let browser: HomeTargetBrowser
|
||||
let activation: ActivationFeedbackState
|
||||
let showServers: () -> Void
|
||||
@@ -193,6 +198,7 @@ private struct WorkspaceHomeView: View {
|
||||
HomeView(
|
||||
workflow: workflow,
|
||||
control: control,
|
||||
statusDisplay: statusDisplay,
|
||||
browser: browser,
|
||||
activation: activation,
|
||||
showServers: showServers,
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 菜单栏面板代理状态卡
|
||||
/// 菜单栏面板代理状态卡。状态行经 StatusDisplayModel 的瞬态门控呈现
|
||||
/// (与首页运行控制卡同一 debounce 口径);活动目标与模式行仍从 control
|
||||
/// 的整体 snapshot 直接派生。
|
||||
struct StatusCardView: View {
|
||||
@ObservedObject var control: ProxyControlWorkflow
|
||||
let statusDisplay: StatusDisplayModel
|
||||
|
||||
var body: some View {
|
||||
let card = StatusCardModel.card(from: control.snapshot)
|
||||
row(card.runtimeRow)
|
||||
if let detail = card.runtimeDetail {
|
||||
row(statusDisplay.agent.row)
|
||||
if let detail = statusDisplay.agent.detail {
|
||||
detailLine(detail)
|
||||
}
|
||||
row(card.systemProxyRow)
|
||||
if let detail = card.systemProxyDetail {
|
||||
row(statusDisplay.systemProxy.row)
|
||||
if let detail = statusDisplay.systemProxy.detail {
|
||||
detailLine(detail)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 状态显示 debounce 呈现模型:首页运行控制卡与菜单栏状态卡共用的两个意图
|
||||
/// (代理服务、系统代理)状态呈现,从代理控制工作流的整体 snapshot 派生
|
||||
/// payload 后经瞬态门控发布。稳定态(已应用/失败/待批准等)立即可见;瞬态
|
||||
/// (启动中/应用中/修复中)延迟上屏、短命即被吞掉,已上屏瞬态保持最短时
|
||||
/// 长——快速切换代理模式这类多数瞬间成功的操作不再让状态行闪中间态。
|
||||
/// 行、色调与点名 detail 复用 StatusCardModel 的既有口径,此处只做派生
|
||||
/// 捆绑(hint 一并随系统代理流更新,行与说明文案不会短暂不一致)。
|
||||
@Observable
|
||||
@MainActor
|
||||
final class StatusDisplayModel {
|
||||
/// 代理服务意图的状态呈现:状态行与点名原因。
|
||||
struct AgentStatus: Equatable {
|
||||
let row: StatusCardModel.Row
|
||||
let detail: StatusCardModel.DetailLine?
|
||||
}
|
||||
|
||||
/// 系统代理意图的状态呈现:状态行、点名原因与运行控制卡说明文案。
|
||||
struct SystemProxyStatus: Equatable {
|
||||
let row: StatusCardModel.Row
|
||||
/// 点名原因不在此抑制:面板把它作为暂停说明行渲染,首页按 isPaused
|
||||
/// 抑制(沿两侧原有口径)。
|
||||
let detail: StatusCardModel.DetailLine?
|
||||
let hint: String
|
||||
/// 已暂停时详情与提示文案重复(hint 已是暂停说明),首页据此不渲染
|
||||
/// detail;面板无 hint,仍渲染 detail 作为暂停说明。
|
||||
let isPaused: Bool
|
||||
}
|
||||
|
||||
private(set) var agent: AgentStatus
|
||||
private(set) var systemProxy: SystemProxyStatus
|
||||
|
||||
private let agentGate: TransientDelayGate<AgentStatus>
|
||||
private let systemProxyGate: TransientDelayGate<SystemProxyStatus>
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
/// 当前排队的迟显任务;无排队为 nil。供测试等待迟显完成。
|
||||
var scheduledAgentDisplayTask: Task<Void, Never>? { agentGate.scheduledDisplayTask }
|
||||
var scheduledSystemProxyDisplayTask: Task<Void, Never>? {
|
||||
systemProxyGate.scheduledDisplayTask
|
||||
}
|
||||
|
||||
/// 生产接线:订阅代理控制工作流的整体 snapshot 发布。
|
||||
convenience init(
|
||||
control: ProxyControlWorkflow,
|
||||
interval: TimeInterval = 0.4,
|
||||
now: @escaping () -> TimeInterval = { Date().timeIntervalSinceReferenceDate },
|
||||
sleep: @escaping (TimeInterval) async throws -> Void = {
|
||||
try await Task.sleep(for: .seconds($0))
|
||||
}
|
||||
) {
|
||||
self.init(snapshots: control.$snapshot, interval: interval, now: now, sleep: sleep)
|
||||
}
|
||||
|
||||
/// 测试缝:直接注入 snapshot 流(订阅即送达当前值,两流首值立即上屏)。
|
||||
init<Snapshots: Publisher>(
|
||||
snapshots: Snapshots,
|
||||
interval: TimeInterval = 0.4,
|
||||
now: @escaping () -> TimeInterval = { Date().timeIntervalSinceReferenceDate },
|
||||
sleep: @escaping (TimeInterval) async throws -> Void = {
|
||||
try await Task.sleep(for: .seconds($0))
|
||||
}
|
||||
) where Snapshots.Output == ProxyControlSnapshot, Snapshots.Failure == Never {
|
||||
agent = AgentStatus(row: .init(label: "", text: "", tone: .neutral), detail: nil)
|
||||
systemProxy = SystemProxyStatus(
|
||||
row: .init(label: "", text: "", tone: .neutral), detail: nil, hint: "", isPaused: false)
|
||||
agentGate = TransientDelayGate(interval: interval, now: now, sleep: sleep)
|
||||
systemProxyGate = TransientDelayGate(interval: interval, now: now, sleep: sleep)
|
||||
// 存储属性至此完整初始化,显示回调才可捕获 self。
|
||||
agentGate.display = { [weak self] in self?.agent = $0 }
|
||||
systemProxyGate.display = { [weak self] in self?.systemProxy = $0 }
|
||||
// 订阅即送达当前 snapshot:两流首值立即上屏(门控首值语义)。
|
||||
snapshots
|
||||
.sink { [weak self] snapshot in self?.handle(snapshot) }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func handle(_ snapshot: ProxyControlSnapshot) {
|
||||
agentGate.update(
|
||||
Self.agentStatus(from: snapshot),
|
||||
isTransient: snapshot.runtime.status == .starting)
|
||||
systemProxyGate.update(
|
||||
Self.systemProxyStatus(from: snapshot),
|
||||
isTransient: Self.isSystemProxyTransient(snapshot.systemProxyApplication))
|
||||
}
|
||||
|
||||
// MARK: - payload 派生
|
||||
|
||||
static func agentStatus(from snapshot: ProxyControlSnapshot) -> AgentStatus {
|
||||
let card = StatusCardModel.card(from: snapshot)
|
||||
return AgentStatus(row: card.runtimeRow, detail: card.runtimeDetail)
|
||||
}
|
||||
|
||||
static func systemProxyStatus(from snapshot: ProxyControlSnapshot) -> SystemProxyStatus {
|
||||
let card = StatusCardModel.card(from: snapshot)
|
||||
return SystemProxyStatus(
|
||||
row: card.systemProxyRow,
|
||||
detail: card.systemProxyDetail,
|
||||
hint: systemProxyHint(from: snapshot),
|
||||
isPaused: snapshot.systemProxyApplication == .paused)
|
||||
}
|
||||
|
||||
/// 系统代理侧瞬态 = 正在写入/修复系统代理配置;pending(待批准/待应用)是
|
||||
/// 需要用户行动的 resting 态,不算瞬态。
|
||||
static func isSystemProxyTransient(_ application: SystemProxyApplicationFacts) -> Bool {
|
||||
application == .applying || application == .repairing
|
||||
}
|
||||
|
||||
/// 运行控制卡的说明文案(原视图内派生移入,随系统代理流一起门控/去重)。
|
||||
private static func systemProxyHint(from snapshot: ProxyControlSnapshot) -> String {
|
||||
let exitRequirement =
|
||||
snapshot.proxyMode == .direct
|
||||
? String(localized: "直连模式不依赖活动服务器")
|
||||
: String(localized: "规则/全局模式需要可用的活动服务器")
|
||||
if snapshot.systemProxyApprovalRequired {
|
||||
return String(localized: "系统代理助手等待批准。请在系统设置 → 登录项与扩展中允许。")
|
||||
}
|
||||
return switch snapshot.systemProxyApplication {
|
||||
case .idle:
|
||||
String(localized: "开启后让 macOS 系统代理指向本地入口;\(exitRequirement)")
|
||||
case .pending:
|
||||
String(localized: "已请求应用系统代理,等待本地入口就绪;\(exitRequirement)")
|
||||
case .applied:
|
||||
String(localized: "系统代理已指向本地入口")
|
||||
case .failed, .repairFailed, .clearFailed:
|
||||
String(localized: "操作失败,原因见下方")
|
||||
case .paused:
|
||||
AppPresentation.systemProxyPausedNotice
|
||||
case .changed:
|
||||
String(localized: "当前系统代理配置与预期存在差异")
|
||||
case .unreadable:
|
||||
String(localized: "无法检查当前系统代理配置")
|
||||
case .applying:
|
||||
String(localized: "正在应用系统代理配置…")
|
||||
case .repairing:
|
||||
String(localized: "正在修复系统代理配置…")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
/// 单条状态显示流的瞬态延迟门控:稳定值立即上屏;瞬态值(启动中/应用中/
|
||||
/// 修复中等短命中间态)延迟一个 interval 后才上屏,期间被新值取代则按新值
|
||||
/// 重新裁定;已上屏的瞬态至少保持一个 interval,避免「应用中」只闪一瞬又跳
|
||||
/// 终态的二次闪烁。首值直接上屏——初始绘制没有可被闪烁的上一状态。
|
||||
///
|
||||
/// 时间经 now/sleep 注入,测试用虚拟时钟驱动;不持有 SwiftUI 类型。
|
||||
@MainActor
|
||||
final class TransientDelayGate<Value: Equatable> {
|
||||
private let interval: TimeInterval
|
||||
private let now: () -> TimeInterval
|
||||
private let sleep: (TimeInterval) async throws -> Void
|
||||
|
||||
/// 显示回调;构造后立即接线。持有方须在自身完成初始化后才能捕获 self,
|
||||
/// 故不入构造参数。nil 时门控照常裁决但不外发。
|
||||
var display: ((Value) -> Void)?
|
||||
|
||||
private var displayed: Value?
|
||||
private var pending: (value: Value, isTransient: Bool)?
|
||||
/// 已上屏瞬态的保持截止时刻;稳定值上屏时清除。窗口内的替换被排迟到
|
||||
/// 截止时刻,防止瞬态只闪一瞬。
|
||||
private var holdUntil: TimeInterval?
|
||||
private var scheduleGeneration = 0
|
||||
private var pendingTask: Task<Void, Never>?
|
||||
|
||||
/// 当前排队显示任务;无排队(已上屏或无待显值)为 nil。供测试等待迟显完成。
|
||||
var scheduledDisplayTask: Task<Void, Never>? { pendingTask }
|
||||
|
||||
init(
|
||||
interval: TimeInterval,
|
||||
now: @escaping () -> TimeInterval = { Date().timeIntervalSinceReferenceDate },
|
||||
sleep: @escaping (TimeInterval) async throws -> Void = {
|
||||
try await Task.sleep(for: .seconds($0))
|
||||
}
|
||||
) {
|
||||
self.interval = interval
|
||||
self.now = now
|
||||
self.sleep = sleep
|
||||
}
|
||||
|
||||
func update(_ value: Value, isTransient: Bool) {
|
||||
if displayed == nil {
|
||||
pending = (value, isTransient)
|
||||
displayPending()
|
||||
return
|
||||
}
|
||||
if displayed == value {
|
||||
pending = nil
|
||||
pendingTask?.cancel()
|
||||
return
|
||||
}
|
||||
// 重复 payload 不重排:沿用原定时刻,避免连续重发把瞬态无限推后。
|
||||
if pending?.value == value, pending?.isTransient == isTransient { return }
|
||||
pending = (value, isTransient)
|
||||
pendingTask?.cancel()
|
||||
let arrival = now()
|
||||
// 无保持窗口时用负无穷做中性值:稳定值的最早上屏时刻就是到达时刻,
|
||||
// 立即同步上屏(不能用正的极小值——虚拟时钟 0 会被顶成 epsilon 延迟)。
|
||||
let earliest = max(
|
||||
isTransient ? arrival + interval : arrival,
|
||||
holdUntil ?? -.greatestFiniteMagnitude)
|
||||
let delay = max(0, earliest - arrival)
|
||||
if delay <= 0 {
|
||||
displayPending()
|
||||
return
|
||||
}
|
||||
scheduleGeneration += 1
|
||||
let generation = scheduleGeneration
|
||||
pendingTask = Task { [weak self] in
|
||||
await self?.displayPendingIfCurrent(generation, after: delay)
|
||||
}
|
||||
}
|
||||
|
||||
private func displayPendingIfCurrent(_ generation: Int, after delay: TimeInterval) async {
|
||||
guard generation == scheduleGeneration else { return }
|
||||
try? await sleep(delay)
|
||||
guard generation == scheduleGeneration else { return }
|
||||
displayPending()
|
||||
}
|
||||
|
||||
private func displayPending() {
|
||||
guard let current = pending else { return }
|
||||
displayed = current.value
|
||||
holdUntil = current.isTransient ? now() + interval : nil
|
||||
pending = nil
|
||||
pendingTask = nil
|
||||
display?(current.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// 顺序虚拟 sleep 记录器:调用前先 `armNextCall()` 预约一对门闩,下一次
|
||||
/// sleep 记录延迟、报到后挂起在预约门闩上;测试据此按调用序推进虚拟时钟,
|
||||
/// 不轮询、不真实等待。
|
||||
@MainActor
|
||||
final class SleepGateRecorder {
|
||||
private(set) var delays: [TimeInterval] = []
|
||||
private var armedEntered: AsyncGate?
|
||||
private var armedRelease: AsyncGate?
|
||||
|
||||
func sleep(_ delay: TimeInterval) async {
|
||||
delays.append(delay)
|
||||
let entered = armedEntered ?? AsyncGate()
|
||||
let release = armedRelease ?? AsyncGate()
|
||||
armedEntered = nil
|
||||
armedRelease = nil
|
||||
entered.release()
|
||||
await release.wait()
|
||||
}
|
||||
|
||||
func armNextCall() -> (entered: AsyncGate, release: AsyncGate) {
|
||||
let pair = (entered: AsyncGate(), release: AsyncGate())
|
||||
armedEntered = pair.entered
|
||||
armedRelease = pair.release
|
||||
return pair
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import ShadowsocksX_NG2
|
||||
|
||||
/// 状态显示 debounce 呈现模型:代理服务与系统代理两个意图的呈现 payload
|
||||
/// 从整体 snapshot 派生后经瞬态门控发布。断言用语义色调与结构(detail 空
|
||||
/// 非空、hint 变化),不断言具体本地化文案。
|
||||
@MainActor
|
||||
struct StatusDisplayModelTests {
|
||||
private let runningFacts = ProxyRuntimeFacts(status: .running, isOn: true)
|
||||
|
||||
/// 快速模式切换:applying 短命即回 applied,「应用中」整段不得上屏。
|
||||
@Test func fastModeSwitchNeverShowsTransientSystemProxyState() async {
|
||||
let snapshots = CurrentValueSubject<ProxyControlSnapshot, Never>(
|
||||
makeSnapshot(systemProxyApplication: .applied))
|
||||
let clock = SleepGateRecorder()
|
||||
var now: TimeInterval = 0
|
||||
let model = StatusDisplayModel(
|
||||
snapshots: snapshots, interval: 0.4, now: { now },
|
||||
sleep: { delay in await clock.sleep(delay) })
|
||||
#expect(model.systemProxy.row.tone == .positive)
|
||||
let armed = clock.armNextCall()
|
||||
snapshots.send(makeSnapshot(systemProxyApplication: .applying))
|
||||
let transientTask = model.scheduledSystemProxyDisplayTask
|
||||
#expect(model.systemProxy.row.tone == .positive, "瞬态「应用中」不得立即上屏")
|
||||
snapshots.send(makeSnapshot(systemProxyApplication: .applied))
|
||||
#expect(model.systemProxy.row.tone == .positive, "快路径完成后瞬态从未上屏")
|
||||
armed.release.release()
|
||||
await transientTask?.value
|
||||
#expect(clock.delays == [0.4])
|
||||
}
|
||||
|
||||
/// applying 持续存在时(真正慢的写入)延迟后要上屏,不能永远藏住。
|
||||
@Test func persistentSystemProxyTransientDisplaysAfterDelay() async {
|
||||
let snapshots = CurrentValueSubject<ProxyControlSnapshot, Never>(
|
||||
makeSnapshot(systemProxyApplication: .applied))
|
||||
let clock = SleepGateRecorder()
|
||||
var now: TimeInterval = 0
|
||||
let model = StatusDisplayModel(
|
||||
snapshots: snapshots, interval: 0.4, now: { now },
|
||||
sleep: { delay in await clock.sleep(delay) })
|
||||
let armed = clock.armNextCall()
|
||||
snapshots.send(makeSnapshot(systemProxyApplication: .applying))
|
||||
let task = model.scheduledSystemProxyDisplayTask
|
||||
await armed.entered.wait()
|
||||
#expect(model.systemProxy.row.tone == .positive)
|
||||
now = 0.4
|
||||
armed.release.release()
|
||||
await task?.value
|
||||
#expect(model.systemProxy.row.tone == .attention)
|
||||
}
|
||||
|
||||
/// pending(待应用/待批准)是 resting 态:立即可见,不经门控。
|
||||
@Test func pendingApplicationStateIsNotGated() async {
|
||||
let snapshots = CurrentValueSubject<ProxyControlSnapshot, Never>(
|
||||
makeSnapshot(systemProxyApplication: .applied))
|
||||
let clock = SleepGateRecorder()
|
||||
let model = StatusDisplayModel(
|
||||
snapshots: snapshots, interval: 0.4, now: { 0 },
|
||||
sleep: { delay in await clock.sleep(delay) })
|
||||
let pending = makeSnapshot(systemProxyApplication: .pending)
|
||||
snapshots.send(pending)
|
||||
#expect(
|
||||
StatusDisplayModel.systemProxyStatus(from: pending).row.tone == .attention,
|
||||
"派生口径:待应用 = attention")
|
||||
#expect(model.systemProxy.row.tone == .attention)
|
||||
#expect(clock.delays.isEmpty)
|
||||
#expect(model.scheduledSystemProxyDisplayTask == nil)
|
||||
}
|
||||
|
||||
/// 代理服务侧瞬态 = 启动中:同样延迟上屏。
|
||||
@Test func agentStartingStateIsGated() async {
|
||||
let snapshots = CurrentValueSubject<ProxyControlSnapshot, Never>(
|
||||
makeSnapshot(systemProxyApplication: .idle))
|
||||
let clock = SleepGateRecorder()
|
||||
var now: TimeInterval = 0
|
||||
let model = StatusDisplayModel(
|
||||
snapshots: snapshots, interval: 0.4, now: { now },
|
||||
sleep: { delay in await clock.sleep(delay) })
|
||||
let armed = clock.armNextCall()
|
||||
snapshots.send(
|
||||
makeSnapshot(
|
||||
facts: ProxyRuntimeFacts(status: .starting, isOn: true),
|
||||
systemProxyApplication: .idle))
|
||||
let task = model.scheduledAgentDisplayTask
|
||||
#expect(model.agent.row.tone == .positive, "运行中保持可见")
|
||||
await armed.entered.wait()
|
||||
now = 0.4
|
||||
armed.release.release()
|
||||
await task?.value
|
||||
#expect(model.agent.row.tone == .neutral, "启动中延迟后上屏")
|
||||
}
|
||||
|
||||
/// 点名原因不随 payload 抑制:无法检查(面板与首页都渲染)与已暂停
|
||||
/// (payload 携带暂停事实,首页据此抑制、面板渲染作暂停说明)并存。
|
||||
@Test func pausedStateCarriesDetailAndPauseFlag() async {
|
||||
let snapshots = CurrentValueSubject<ProxyControlSnapshot, Never>(
|
||||
makeSnapshot(systemProxyApplication: .applied))
|
||||
let model = StatusDisplayModel(
|
||||
snapshots: snapshots, interval: 0.4, now: { 0 }, sleep: { _ in })
|
||||
snapshots.send(makeSnapshot(systemProxyApplication: .unreadable(.unknown)))
|
||||
#expect(model.systemProxy.detail != nil, "无法检查的点名原因立即可见")
|
||||
#expect(model.systemProxy.row.tone == .failure)
|
||||
#expect(!model.systemProxy.isPaused)
|
||||
snapshots.send(makeSnapshot(systemProxyApplication: .paused))
|
||||
#expect(model.systemProxy.detail != nil, "暂停说明保留在 detail(面板渲染)")
|
||||
#expect(model.systemProxy.isPaused, "首页据此抑制 detail(hint 已是暂停说明)")
|
||||
#expect(model.systemProxy.row.tone == .attention)
|
||||
}
|
||||
|
||||
/// hint 文案随系统代理流派生:批准阻塞提示立即生效(稳定态)。
|
||||
@Test func approvalRequirementChangesHintImmediately() async {
|
||||
let snapshots = CurrentValueSubject<ProxyControlSnapshot, Never>(
|
||||
makeSnapshot(systemProxyApplication: .idle))
|
||||
let model = StatusDisplayModel(
|
||||
snapshots: snapshots, interval: 0.4, now: { 0 }, sleep: { _ in })
|
||||
let idleHint = model.systemProxy.hint
|
||||
snapshots.send(makeSnapshot(systemProxyApplication: .idle, approvalRequired: true))
|
||||
#expect(model.systemProxy.hint != idleHint)
|
||||
#expect(model.systemProxy.row.tone == .neutral)
|
||||
}
|
||||
|
||||
/// 从运行时事实构造最小 snapshot(其余字段与本组用例无关;与
|
||||
/// StatusCardModelTests 的夹具同形状)。
|
||||
private func makeSnapshot(
|
||||
facts: ProxyRuntimeFacts = ProxyRuntimeFacts(status: .running, isOn: true),
|
||||
systemProxyApplication: SystemProxyApplicationFacts,
|
||||
approvalRequired: Bool = false
|
||||
) -> ProxyControlSnapshot {
|
||||
ProxyControlSnapshot(
|
||||
runtime: facts,
|
||||
agentIntentEnabled: true,
|
||||
activationFailure: nil,
|
||||
systemProxyIntentEnabled: false,
|
||||
systemProxyApplication: systemProxyApplication,
|
||||
systemProxyApprovalRequired: approvalRequired,
|
||||
proxyMode: .rule,
|
||||
ruleDefaultAction: .proxyWhenUnmatched,
|
||||
availableModes: ProxyMode.availableModes,
|
||||
activeTarget: nil,
|
||||
skippedInvalidServerCount: 0,
|
||||
httpExport: HTTPExportCapability(
|
||||
copyableLine:
|
||||
"export http_proxy=http://127.0.0.1:11087;export https_proxy=http://127.0.0.1:11087;"),
|
||||
commandAddressPicker: TerminalCommandAddressPicker(
|
||||
isVisible: false,
|
||||
candidates: [
|
||||
TerminalCommandAddress(bsdName: "lo0", address: "127.0.0.1", displayName: "lo0")
|
||||
],
|
||||
selected: TerminalCommandAddress(bsdName: "lo0", address: "127.0.0.1", displayName: "lo0")),
|
||||
terminalProxyEnvironmentCommands: TerminalProxyEnvironmentCommands(
|
||||
listen: RuntimeListenFacts(listen: ProxyListeningConfiguration()),
|
||||
commandAddress: TerminalCommandAddress(
|
||||
bsdName: "lo0", address: "127.0.0.1", displayName: "lo0")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import ShadowsocksX_NG2
|
||||
|
||||
/// 瞬态门控裁决表:稳定值立即可见、瞬态值延迟上屏、短命瞬态被吞、
|
||||
/// 已上屏瞬态保持最短时长。时间经注入的 now/sleep 虚拟化,不真实等待。
|
||||
@MainActor
|
||||
struct TransientDelayGateTests {
|
||||
@Test func firstValueDisplaysImmediatelyWithoutDelay() {
|
||||
var displayed: [String] = []
|
||||
let gate = TransientDelayGate<String>(
|
||||
interval: 0.4,
|
||||
now: { 0 },
|
||||
sleep: { _ in })
|
||||
gate.display = { displayed.append($0) }
|
||||
gate.update("applied", isTransient: false)
|
||||
#expect(displayed == ["applied"])
|
||||
}
|
||||
|
||||
@Test func transientValueDisplaysOnlyAfterDelayInterval() async {
|
||||
var now: TimeInterval = 0
|
||||
var displayed: [String] = []
|
||||
var delays: [TimeInterval] = []
|
||||
let sleepEntered = AsyncGate()
|
||||
let sleepRelease = AsyncGate()
|
||||
let gate = TransientDelayGate<String>(
|
||||
interval: 0.4,
|
||||
now: { now },
|
||||
sleep: { delay in
|
||||
delays.append(delay)
|
||||
sleepEntered.release()
|
||||
await sleepRelease.wait()
|
||||
})
|
||||
gate.display = { displayed.append($0) }
|
||||
gate.update("applied", isTransient: false)
|
||||
gate.update("applying", isTransient: true)
|
||||
let task = gate.scheduledDisplayTask
|
||||
await sleepEntered.wait()
|
||||
#expect(delays == [0.4])
|
||||
#expect(displayed == ["applied"])
|
||||
now = 0.4
|
||||
sleepRelease.release()
|
||||
await task?.value
|
||||
#expect(displayed == ["applied", "applying"])
|
||||
}
|
||||
|
||||
@Test func shortLivedTransientIsSupersededAndNeverDisplayed() async {
|
||||
let clock = SleepGateRecorder()
|
||||
var now: TimeInterval = 0
|
||||
var displayed: [String] = []
|
||||
let gate = TransientDelayGate<String>(
|
||||
interval: 0.4,
|
||||
now: { now },
|
||||
sleep: { delay in await clock.sleep(delay) })
|
||||
gate.display = { displayed.append($0) }
|
||||
gate.update("applied", isTransient: false)
|
||||
let transientSleep = clock.armNextCall()
|
||||
gate.update("applying", isTransient: true)
|
||||
let transientTask = gate.scheduledDisplayTask
|
||||
await transientSleep.entered.wait()
|
||||
now = 0.2
|
||||
gate.update("failed", isTransient: false)
|
||||
#expect(displayed == ["applied", "failed"])
|
||||
transientSleep.release.release()
|
||||
await transientTask?.value
|
||||
#expect(displayed == ["applied", "failed"])
|
||||
}
|
||||
|
||||
@Test func displayedTransientHoldsForMinimumInterval() async {
|
||||
let clock = SleepGateRecorder()
|
||||
var now: TimeInterval = 0
|
||||
var displayed: [String] = []
|
||||
let gate = TransientDelayGate<String>(
|
||||
interval: 0.4,
|
||||
now: { now },
|
||||
sleep: { delay in await clock.sleep(delay) })
|
||||
gate.display = { displayed.append($0) }
|
||||
gate.update("applied", isTransient: false)
|
||||
let transientSleep = clock.armNextCall()
|
||||
gate.update("applying", isTransient: true)
|
||||
let transientTask = gate.scheduledDisplayTask
|
||||
await transientSleep.entered.wait()
|
||||
now = 0.4
|
||||
transientSleep.release.release()
|
||||
await transientTask?.value
|
||||
#expect(displayed == ["applied", "applying"])
|
||||
let heldSleep = clock.armNextCall()
|
||||
gate.update("failed", isTransient: false)
|
||||
let heldTask = gate.scheduledDisplayTask
|
||||
#expect(displayed == ["applied", "applying"])
|
||||
await heldSleep.entered.wait()
|
||||
#expect(clock.delays == [0.4, 0.4])
|
||||
now = 0.8
|
||||
heldSleep.release.release()
|
||||
await heldTask?.value
|
||||
#expect(displayed == ["applied", "applying", "failed"])
|
||||
}
|
||||
|
||||
@Test func transientAfterDisplayedTransientWaitsForDelayAndHold() async {
|
||||
let clock = SleepGateRecorder()
|
||||
var now: TimeInterval = 0
|
||||
var displayed: [String] = []
|
||||
let gate = TransientDelayGate<String>(
|
||||
interval: 0.4,
|
||||
now: { now },
|
||||
sleep: { delay in await clock.sleep(delay) })
|
||||
gate.display = { displayed.append($0) }
|
||||
gate.update("applied", isTransient: false)
|
||||
let applyingSleep = clock.armNextCall()
|
||||
gate.update("applying", isTransient: true)
|
||||
let applyingTask = gate.scheduledDisplayTask
|
||||
await applyingSleep.entered.wait()
|
||||
now = 0.4
|
||||
applyingSleep.release.release()
|
||||
await applyingTask?.value
|
||||
#expect(displayed == ["applied", "applying"])
|
||||
let repairingSleep = clock.armNextCall()
|
||||
now = 0.5
|
||||
gate.update("repairing", isTransient: true)
|
||||
let repairingTask = gate.scheduledDisplayTask
|
||||
#expect(displayed == ["applied", "applying"])
|
||||
await repairingSleep.entered.wait()
|
||||
#expect(clock.delays == [0.4, 0.4])
|
||||
now = 0.9
|
||||
repairingSleep.release.release()
|
||||
await repairingTask?.value
|
||||
#expect(displayed == ["applied", "applying", "repairing"])
|
||||
}
|
||||
|
||||
@Test func duplicatePendingPayloadKeepsOriginalSchedule() async {
|
||||
let clock = SleepGateRecorder()
|
||||
var now: TimeInterval = 0
|
||||
var displayed: [String] = []
|
||||
let gate = TransientDelayGate<String>(
|
||||
interval: 0.4,
|
||||
now: { now },
|
||||
sleep: { delay in await clock.sleep(delay) })
|
||||
gate.display = { displayed.append($0) }
|
||||
gate.update("applied", isTransient: false)
|
||||
let applyingSleep = clock.armNextCall()
|
||||
gate.update("applying", isTransient: true)
|
||||
let applyingTask = gate.scheduledDisplayTask
|
||||
await applyingSleep.entered.wait()
|
||||
now = 0.1
|
||||
gate.update("applying", isTransient: true)
|
||||
now = 0.4
|
||||
applyingSleep.release.release()
|
||||
await applyingTask?.value
|
||||
#expect(displayed == ["applied", "applying"])
|
||||
#expect(clock.delays == [0.4])
|
||||
}
|
||||
|
||||
@Test func valueEqualToDisplayedCancelsPendingDisplay() async {
|
||||
let clock = SleepGateRecorder()
|
||||
var now: TimeInterval = 0
|
||||
var displayed: [String] = []
|
||||
let gate = TransientDelayGate<String>(
|
||||
interval: 0.4,
|
||||
now: { now },
|
||||
sleep: { delay in await clock.sleep(delay) })
|
||||
gate.display = { displayed.append($0) }
|
||||
gate.update("applied", isTransient: false)
|
||||
let applyingSleep = clock.armNextCall()
|
||||
gate.update("applying", isTransient: true)
|
||||
let applyingTask = gate.scheduledDisplayTask
|
||||
await applyingSleep.entered.wait()
|
||||
gate.update("applied", isTransient: false)
|
||||
#expect(displayed == ["applied"])
|
||||
applyingSleep.release.release()
|
||||
await applyingTask?.value
|
||||
#expect(displayed == ["applied"])
|
||||
#expect(clock.delays == [0.4])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user