diff --git a/StaticRouteHelper.xcodeproj/project.pbxproj b/StaticRouteHelper.xcodeproj/project.pbxproj index 0a8d990..37db997 100644 --- a/StaticRouteHelper.xcodeproj/project.pbxproj +++ b/StaticRouteHelper.xcodeproj/project.pbxproj @@ -567,7 +567,7 @@ CODE_SIGN_IDENTITY = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 63; + CURRENT_PROJECT_VERSION = 66; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -598,7 +598,7 @@ CODE_SIGN_IDENTITY = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 63; + CURRENT_PROJECT_VERSION = 66; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -760,9 +760,9 @@ "$(inherited)", "@executable_path/../Frameworks", ); - CURRENT_PROJECT_VERSION = 63; + CURRENT_PROJECT_VERSION = 66; MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 2.2.0; + MARKETING_VERSION = 2.2.1; PRODUCT_BUNDLE_IDENTIFIER = cn.magicdian.staticrouter; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ROUTER; @@ -790,9 +790,9 @@ "$(inherited)", "@executable_path/../Frameworks", ); - CURRENT_PROJECT_VERSION = 63; + CURRENT_PROJECT_VERSION = 66; MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 2.2.0; + MARKETING_VERSION = 2.2.1; PRODUCT_BUNDLE_IDENTIFIER = cn.magicdian.staticrouter; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ROUTER; diff --git a/StaticRouter/Components/HelperToolMonitor.swift b/StaticRouter/Components/HelperToolMonitor.swift index 8f30b03..41a2479 100644 --- a/StaticRouter/Components/HelperToolMonitor.swift +++ b/StaticRouter/Components/HelperToolMonitor.swift @@ -9,6 +9,20 @@ import Foundation import EmbeddedPropertyList class HelperToolMonitor { + struct StartReport { + struct Failure { + let directory: URL + let errnoCode: Int32 + } + + let monitoredDirectoryCount: Int + let activeSourceCount: Int + let failures: [Failure] + + var hasActiveSources: Bool { activeSourceCount > 0 } + var isDegraded: Bool { activeSourceCount == 0 } + } + struct InstallationStatus { enum HelperToolExecutable { /// The Helper tool exists in its expected location. Associated value is the helper tools bundle version @@ -25,6 +39,8 @@ class HelperToolMonitor { private var dispatchSources = [URL: DispatchSourceFileSystemObject]() private let dirMonitorQUeue = DispatchQueue(label: "dirmonitor",attributes: .concurrent) private let constants: SharedConstant + private var isStarted = false + private(set) var lastStartReport: StartReport? /// Creates the monitor. init(constants: SharedConstant){ @@ -32,31 +48,67 @@ class HelperToolMonitor { self.monitoredDirs = [constants.blessedLocation.deletingLastPathComponent(),constants.blessedPropertyListLocation.deletingLastPathComponent()] } - func start(changeOccurred: @escaping (InstallationStatus) -> Void) { - - if dispatchSources.isEmpty { - for monitoredDir in monitoredDirs { - let fileDescriptor = open((monitoredDir as NSURL).fileSystemRepresentation, O_EVTONLY) - let dispatchSource = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fileDescriptor, - eventMask: .write, - queue: dirMonitorQUeue) - dispatchSources[monitoredDir] = dispatchSource - dispatchSource.setEventHandler { - changeOccurred(self.determineStatus()) - } - dispatchSource.setCancelHandler { - close(fileDescriptor) - self.dispatchSources.removeValue(forKey: monitoredDir) - } - dispatchSource.resume() + @discardableResult + func start(changeOccurred: @escaping (InstallationStatus) -> Void) -> StartReport { + if isStarted, let lastStartReport { + return lastStartReport + } + + isStarted = true + var failures = [StartReport.Failure]() + + for monitoredDir in monitoredDirs { + let fileDescriptor = open((monitoredDir as NSURL).fileSystemRepresentation, O_EVTONLY) + guard fileDescriptor >= 0 else { + failures.append( + StartReport.Failure( + directory: monitoredDir, + errnoCode: errno + ) + ) + continue } + + let dispatchSource = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fileDescriptor, + eventMask: .write, + queue: dirMonitorQUeue) + dispatchSources[monitoredDir] = dispatchSource + dispatchSource.setEventHandler { + changeOccurred(self.determineStatus()) + } + dispatchSource.setCancelHandler { + close(fileDescriptor) + } + dispatchSource.resume() } + + let report = StartReport( + monitoredDirectoryCount: monitoredDirs.count, + activeSourceCount: dispatchSources.count, + failures: failures + ) + lastStartReport = report + + for failure in failures { + let message = String(cString: strerror(failure.errnoCode)) + print("[HelperToolMonitor] failed to watch '\(failure.directory.path)' errno=\(failure.errnoCode) message='\(message)' degraded=\(report.isDegraded)") + } + + if report.isDegraded { + print("[HelperToolMonitor] no active filesystem watcher source is available; fallback refresh is required") + } + + return report } func stop(){ + guard isStarted else { return } for source in dispatchSources.values { source.cancel() } + dispatchSources.removeAll() + isStarted = false + lastStartReport = nil } func determineStatus() -> InstallationStatus { @@ -88,7 +140,7 @@ class HelperToolMonitor { } -enum HelperToolInstallationState { +enum HelperToolInstallationState: Equatable { case installed case pendingActivation case needUpgrade diff --git a/StaticRouter/Services/RouterService.swift b/StaticRouter/Services/RouterService.swift index d0a9033..dac768c 100644 --- a/StaticRouter/Services/RouterService.swift +++ b/StaticRouter/Services/RouterService.swift @@ -5,6 +5,7 @@ import Foundation import Combine +import AppKit import SecureXPC import Blessed import Authorized @@ -104,6 +105,11 @@ final class RouterService: ObservableObject { /// Combine subscriptions for helperManager state propagation. private var helperManagerCancellables = Set() + /// Fallback polling when helper monitor cannot create filesystem watchers. + private var helperStatusFallbackPollingCancellable: AnyCancellable? + private var appDidBecomeActiveCancellable: AnyCancellable? + private let helperStatusFallbackInterval: TimeInterval = 10 + private var isHelperStatusFallbackEnabled = false // MARK: Init @@ -126,18 +132,14 @@ final class RouterService: ObservableObject { ) // Watch helper directories; on change, refresh manager state and re-derive helperStatus - helperMonitor.start { [weak self] _ in + let monitorStartReport = helperMonitor.start { [weak self] _ in guard let self else { return } - self.helperManager.refreshState() - let state = Self.resolveInstallationState( - activeMethod: self.helperManager.activeMethod, - isPendingApproval: self.helperManager.isPendingApproval, - constants: self.sharedConstants - ) - DispatchQueue.main.async { - self.helperStatus = state - } + self.refreshHelperStatusFromManager() + } + if monitorStartReport.isDegraded { + enableHelperStatusFallbackPolling() } + observeAppActivationForHelperStatusRefresh() // Propagate helperManager state changes (from didBecomeActive / Timer monitoring) // to helperStatus so the UI reflects switch state changes in real time. @@ -146,11 +148,14 @@ final class RouterService: ObservableObject { .receive(on: DispatchQueue.main) .sink { [weak self] (activeMethod, isPendingApproval) in guard let self else { return } - self.helperStatus = Self.resolveInstallationState( + let nextState = Self.resolveInstallationState( activeMethod: activeMethod, isPendingApproval: isPendingApproval, constants: self.sharedConstants ) + if self.helperStatus != nextState { + self.helperStatus = nextState + } } .store(in: &helperManagerCancellables) @@ -159,6 +164,9 @@ final class RouterService: ObservableObject { } deinit { + helperMonitor.stop() + helperStatusFallbackPollingCancellable?.cancel() + appDidBecomeActiveCancellable?.cancel() monitoringTask?.cancel() } @@ -405,6 +413,49 @@ final class RouterService: ObservableObject { } } + /// Refreshes helper manager state and safely updates helperStatus on the main queue. + private func refreshHelperStatusFromManager() { + helperManager.refreshState() + let nextState = Self.resolveInstallationState( + activeMethod: helperManager.activeMethod, + isPendingApproval: helperManager.isPendingApproval, + constants: sharedConstants + ) + DispatchQueue.main.async { + if self.helperStatus != nextState { + self.helperStatus = nextState + } + } + } + + private func enableHelperStatusFallbackPolling() { + guard !isHelperStatusFallbackEnabled else { return } + isHelperStatusFallbackEnabled = true + + // Run one immediate refresh so the UI is updated without waiting a full polling interval. + refreshHelperStatusFromManager() + + helperStatusFallbackPollingCancellable = Timer.publish( + every: helperStatusFallbackInterval, + on: .main, + in: .common + ) + .autoconnect() + .sink { [weak self] _ in + self?.refreshHelperStatusFromManager() + } + } + + private func observeAppActivationForHelperStatusRefresh() { + appDidBecomeActiveCancellable = NotificationCenter.default + .publisher(for: NSApplication.didBecomeActiveNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.isHelperStatusFallbackEnabled else { return } + self.refreshHelperStatusFromManager() + } + } + // MARK: - PF_ROUTE Monitor /// 启动后台 PF_ROUTE socket 监听循环,订阅 RTM_ADD / RTM_DELETE 事件, diff --git a/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/.openspec.yaml b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/.openspec.yaml new file mode 100644 index 0000000..2ca4bc8 --- /dev/null +++ b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-24 diff --git a/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/design.md b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/design.md new file mode 100644 index 0000000..94bf60b --- /dev/null +++ b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/design.md @@ -0,0 +1,69 @@ +## 上下文 + +当前 `RouterService.init()` 会立即调用 `HelperToolMonitor.start(changeOccurred:)`。`HelperToolMonitor` 对每个监控目录执行 `open(path, O_EVTONLY)`,随后无条件调用 `DispatchSource.makeFileSystemObjectSource(...)`。在 macOS 13 的部分环境中,`open` 可能返回负值(例如目录不存在、权限受限或系统限制),无效文件描述符进入 `DispatchSource` 创建后触发 `EXC_BREAKPOINT`,导致应用启动崩溃。 + +该崩溃发生在主线程初始化阶段,属于高优先级可用性问题。目标是在不改变现有 Helper 安装逻辑的前提下,提升监控初始化的健壮性并提供可观测降级行为。 + +## 目标 / 非目标 + +**目标:** +- 保证 `HelperToolMonitor.start` 在任意监控目录打开失败时不会崩溃。 +- 保证 `RouterService` 在监听器部分不可用时仍可完成初始化,UI 可展示 helper 状态。 +- 在监听不可用时提供最小可用兜底(低频轮询刷新状态),避免状态永久陈旧。 +- 保持现有公开 API 与调用方行为兼容,不引入破坏性接口变更。 + +**非目标:** +- 不重构 `PrivilegedHelperManager` 的安装/卸载状态机。 +- 不修改 XPC 协议或 helper 二进制安装路径策略。 +- 不在本次变更中引入新的后台守护进程或持久化监控元数据。 + +## 决策 + +### 决策 1:对每个监控目录进行“可失败初始化” +- 方案:仅在 `open` 返回 `fd >= 0` 时创建并保存 `DispatchSource`;失败时记录日志并跳过该目录。 +- 理由:崩溃根因是无效 `fd` 被用于 source 创建,源头短路可直接消除 `SIGTRAP`。 +- 备选方案: + - 备选 A:在失败时 `fatalError` 终止启动。否决,和“修复启动崩溃”目标冲突。 + - 备选 B:预创建目录后重试。否决,涉及系统目录写权限且风险更高。 + +### 决策 2:为“无可用 source”场景增加低频轮询兜底 +- 方案:`HelperToolMonitor.start` 返回启动结果(可用 source 数量或布尔值);若为 0,`RouterService` 启动一个低频 `Timer`/任务定期 `refreshState()`(例如 5-10 秒)并同步 `helperStatus`。 +- 理由:即使文件系统事件监听不可用,状态仍可更新,避免用户看到永久错误状态。 +- 备选方案: + - 备选 A:无 source 时完全不更新状态。否决,功能退化过大。 + - 备选 B:高频轮询(<1 秒)。否决,增加不必要 CPU/IO 开销。 + +### 决策 3:强化停止与重入语义 +- 方案:`stop()` 仅取消当前已注册 source;`start()` 在已启动时直接返回,避免重复创建。取消后清理映射,确保再次 `start()` 可重新建立监听。 +- 理由:避免生命周期边界上的资源泄漏与重复监听。 +- 备选方案: + - 备选 A:每次 `start()` 先 `stop()` 再重建。可行但会引入短暂监听空窗,优先保留幂等启动。 + +## 风险 / 权衡 + +- [风险] 轮询兜底会降低状态变更实时性。 + → 缓解:将轮询间隔控制在低频(5-10 秒)并在应用激活时触发一次即时刷新。 + +- [风险] 启动失败日志若过多会干扰调试。 + → 缓解:仅在状态变化或首次失败时输出一次结构化日志。 + +- [风险] 不同 macOS 版本对系统目录监听权限差异可能导致行为不一致。 + → 缓解:增加 macOS 13 目标回归用例,并保持逻辑为“能力探测 + 自动降级”。 + +## Migration Plan + +1. 修改 `HelperToolMonitor`:引入 `fd` 校验、启动结果返回、幂等 stop/start。 +2. 修改 `RouterService`:根据启动结果决定是否启用轮询兜底。 +3. 增加测试: + - `open` 失败时不崩溃且返回“部分/全部失败”状态。 + - `RouterService` 在无 source 情况下仍能初始化并刷新 `helperStatus`。 +4. 在 macOS 13.4 环境执行手工验证: + - 冷启动不崩溃。 + - 安装/卸载 helper 后状态在可接受时间内更新。 + +回滚策略:如出现副作用,可回滚本变更并恢复原监听路径;不涉及数据迁移,无持久化兼容风险。 + +## Open Questions + +- 轮询间隔最终取值(5 秒或 10 秒)是否需要配置化。 +- 是否需要在 UI 暴露“监听降级中”的诊断提示(当前计划仅日志可见)。 diff --git a/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/proposal.md b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/proposal.md new file mode 100644 index 0000000..4c534f3 --- /dev/null +++ b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/proposal.md @@ -0,0 +1,30 @@ +## 为什么 + +macOS 13 上应用启动会在主线程必现崩溃,崩溃栈指向 `HelperToolMonitor.start(changeOccurred:)` 内部的 `DispatchSource` 创建流程。当前实现在 `open(..., O_EVTONLY)` 失败时仍继续创建 `DispatchSource`,触发 `EXC_BREAKPOINT (SIGTRAP)`,导致应用无法启动。 + +## 变更内容 + +- 修复 `HelperToolMonitor` 目录监听初始化流程:`open` 失败时不再创建 `DispatchSource`,改为记录失败并安全跳过该监控项。 +- 为目录监听增加可观测的降级行为:当全部监听源不可用时,`RouterService` 仍可完成初始化,Helper 状态通过一次主动刷新或低频兜底刷新维持可用。 +- 强化生命周期管理:仅对已创建的 source 执行 `resume/cancel`,防止重复启动、重复关闭或非法文件描述符路径。 +- 增加 macOS 13 回归验证场景,覆盖“目录不可监听/权限受限/路径不存在”三类启动条件。 + +## 功能 (Capabilities) + +### 新增功能 +- `helper-monitor-stability`: 规范 Helper 安装状态监听在异常文件系统条件下的容错行为,确保启动阶段不崩溃并提供可预期的降级策略。 + +### 修改功能 +- `router-service`: `RouterService` 初始化阶段在监听器部分失败时不应崩溃,且应保持 helper 状态可读与后续可恢复。 + +## 影响 + +- 受影响代码: + - `StaticRouter/Components/HelperToolMonitor.swift` + - `StaticRouter/Services/RouterService.swift` +- 受影响行为: + - 启动期 helper 状态监控初始化流程 + - macOS 13 上首帧稳定性与安装状态展示一致性 +- 测试与验证: + - 新增/更新单元测试(监听源创建失败路径) + - 手工回归:macOS 13.4 启动、安装/卸载 helper 后状态刷新 diff --git a/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/specs/helper-monitor-stability/spec.md b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/specs/helper-monitor-stability/spec.md new file mode 100644 index 0000000..3618db2 --- /dev/null +++ b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/specs/helper-monitor-stability/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### 需求:HelperToolMonitor 在无效文件描述符场景必须避免崩溃 +系统必须在创建文件系统监听源前校验 `open` 返回值;当文件描述符无效(小于 0)时,禁止调用 `DispatchSource.makeFileSystemObjectSource`,并且启动流程必须继续执行。 + +#### 场景:监控目录不可访问 +- **当** `HelperToolMonitor` 对某个监控目录执行 `open(..., O_EVTONLY)` 返回负值 +- **那么** 系统必须跳过该目录的 source 创建,且应用启动流程不发生崩溃 + +#### 场景:部分目录可监听 +- **当** 两个监控目录中仅有一个能成功打开 +- **那么** 系统必须仅为成功目录创建监听源,并保持监听功能可用 + +### 需求:监听初始化结果必须可观测 +系统必须暴露监听初始化结果(例如可用 source 数量或布尔状态),以便调用方判定是否需要降级策略。 + +#### 场景:至少一个 source 可用 +- **当** `HelperToolMonitor.start` 完成且至少创建一个监听源 +- **那么** 返回结果必须标识监听已可用,调用方不得进入轮询降级路径 + +#### 场景:无 source 可用 +- **当** `HelperToolMonitor.start` 完成且未创建任何监听源 +- **那么** 返回结果必须标识监听不可用,调用方可触发兜底刷新策略 + +### 需求:监听生命周期必须幂等 +系统必须保证 `start/stop` 在重复调用时行为可预测,不得重复创建监听源或因重复取消而导致异常。 + +#### 场景:重复调用 start +- **当** 监听已启动后再次调用 `start` +- **那么** 系统必须避免重复创建 source,内部资源数量保持稳定 + +#### 场景:调用 stop 后再次 start +- **当** 调用 `stop` 取消全部 source 后再次调用 `start` +- **那么** 系统必须重新建立可用 source,且不复用已失效文件描述符 diff --git a/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/specs/router-service/spec.md b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/specs/router-service/spec.md new file mode 100644 index 0000000..dd3ee84 --- /dev/null +++ b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/specs/router-service/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### 需求:RouterService 初始化必须容忍监听器降级 +`RouterService` 在初始化过程中必须容忍 `HelperToolMonitor` 启动失败或部分失败;无论监听器是否可用,`RouterService` 都必须完成初始化并对外提供可用实例。 + +#### 场景:监听器全部失败时完成初始化 +- **当** `HelperToolMonitor.start` 返回“无可用监听源” +- **那么** `RouterService.init()` 必须完成,且应用主界面可正常进入 + +#### 场景:监听器部分成功时完成初始化 +- **当** `HelperToolMonitor.start` 仅创建了部分监听源 +- **那么** `RouterService` 必须按部分可用状态继续运行,不得抛出致命错误 + +### 需求:监听不可用时必须启用状态刷新兜底 +当监听器不可用时,系统必须通过低频主动刷新保持 helper 安装状态可更新,避免状态长期陈旧。 + +#### 场景:初始化后进入轮询兜底 +- **当** `RouterService` 检测到监听不可用 +- **那么** 系统必须启动低频刷新任务,周期性执行状态刷新并更新 `helperStatus` + +#### 场景:应用恢复激活时即时刷新 +- **当** 应用从后台回到前台或重新激活 +- **那么** 系统必须立即触发一次 helper 状态刷新,而不是等待下一次轮询周期 diff --git a/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/tasks.md b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/tasks.md new file mode 100644 index 0000000..b4b87f9 --- /dev/null +++ b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/tasks.md @@ -0,0 +1,20 @@ +## 1. HelperToolMonitor 健壮性修复 + +- [x] 1.1 在 `HelperToolMonitor.start(changeOccurred:)` 中为每个监控目录添加 `open` 返回值校验,仅在 `fd >= 0` 时创建 `DispatchSource`。 +- [x] 1.2 为 `open` 失败路径增加日志与统计(至少记录目录路径、errno、是否触发降级),并确保失败目录不会写入 `dispatchSources`。 +- [x] 1.3 调整 `start/stop` 生命周期为幂等:重复 `start` 不重复注册,`stop` 仅取消已创建 source 并清理映射。 +- [x] 1.4 让 `start` 暴露可用监听源结果(数量或布尔值),供 `RouterService` 判定是否启用降级策略。 + +## 2. RouterService 启动降级与状态兜底 + +- [x] 2.1 在 `RouterService.init()` 接入 `HelperToolMonitor.start` 的返回结果,监听不可用时仍完成初始化,不触发 fatal 行为。 +- [x] 2.2 增加低频 helper 状态刷新兜底任务(例如 5-10 秒),仅在监听不可用时启用。 +- [x] 2.3 在应用重新激活时触发一次即时 `refreshState`,减少轮询带来的状态延迟。 +- [x] 2.4 确保降级刷新与现有 `helperManager` publisher 更新不冲突(避免重复写状态和竞态)。 + +## 3. 测试与回归验证 + +- [x] 3.1 新增/更新单元测试:`open` 失败时 `HelperToolMonitor.start` 不崩溃且返回“不可用监听”状态。 +- [x] 3.2 新增/更新单元测试:部分目录失败时仍创建可用 source,`stop` 后资源被正确清理。 +- [x] 3.3 新增/更新集成测试或可替代验证:`RouterService` 在监听不可用时仍可初始化并持续刷新 `helperStatus`。 +- [x] 3.4 手工回归 macOS 13.4:验证冷启动不崩溃、安装/卸载 helper 后状态可在预期时间内更新。 diff --git a/openspec/specs/helper-monitor-stability/spec.md b/openspec/specs/helper-monitor-stability/spec.md new file mode 100644 index 0000000..a62ea1b --- /dev/null +++ b/openspec/specs/helper-monitor-stability/spec.md @@ -0,0 +1,38 @@ +# helper-monitor-stability 规范 + +## 目的 +定义 Helper 目录监听在异常文件系统条件下的容错与降级行为, +确保应用启动稳定且状态监控可恢复。 +## 需求 +### 需求:HelperToolMonitor 在无效文件描述符场景必须避免崩溃 +系统必须在创建文件系统监听源前校验 `open` 返回值;当文件描述符无效(小于 0)时,禁止调用 `DispatchSource.makeFileSystemObjectSource`,并且启动流程必须继续执行。 + +#### 场景:监控目录不可访问 +- **当** `HelperToolMonitor` 对某个监控目录执行 `open(..., O_EVTONLY)` 返回负值 +- **那么** 系统必须跳过该目录的 source 创建,且应用启动流程不发生崩溃 + +#### 场景:部分目录可监听 +- **当** 两个监控目录中仅有一个能成功打开 +- **那么** 系统必须仅为成功目录创建监听源,并保持监听功能可用 + +### 需求:监听初始化结果必须可观测 +系统必须暴露监听初始化结果(例如可用 source 数量或布尔状态),以便调用方判定是否需要降级策略。 + +#### 场景:至少一个 source 可用 +- **当** `HelperToolMonitor.start` 完成且至少创建一个监听源 +- **那么** 返回结果必须标识监听已可用,调用方不得进入轮询降级路径 + +#### 场景:无 source 可用 +- **当** `HelperToolMonitor.start` 完成且未创建任何监听源 +- **那么** 返回结果必须标识监听不可用,调用方可触发兜底刷新策略 + +### 需求:监听生命周期必须幂等 +系统必须保证 `start/stop` 在重复调用时行为可预测,不得重复创建监听源或因重复取消而导致异常。 + +#### 场景:重复调用 start +- **当** 监听已启动后再次调用 `start` +- **那么** 系统必须避免重复创建 source,内部资源数量保持稳定 + +#### 场景:调用 stop 后再次 start +- **当** 调用 `stop` 取消全部 source 后再次调用 `start` +- **那么** 系统必须重新建立可用 source,且不复用已失效文件描述符 diff --git a/openspec/specs/router-service/spec.md b/openspec/specs/router-service/spec.md index 6335f31..e61bc91 100644 --- a/openspec/specs/router-service/spec.md +++ b/openspec/specs/router-service/spec.md @@ -1,41 +1,56 @@ -## MODIFIED Requirements - -### Requirement: RouterService 统一服务接口 -系统 SHALL 提供 `RouterService` 作为所有路由操作和 Helper 通信的唯一入口。在 macOS 14+ 上 `RouterService` SHALL 遵循 `@Observable`(Observation 框架),通过类型化 `@Environment(RouterService.self)` 注入视图层级。在 macOS 12–13 上 SHALL 遵循 `ObservableObject`,通过 `@EnvironmentObject` 注入视图层级。两条路径 SHALL 暴露相同的公共接口: - -- `helperStatus: HelperInstallStatus` -- `systemRoutes: [SystemRouteEntry]` -- `lastError: RouterError?` -- `func activateRoute(_ rule: RouteRule) async throws`(macOS 14+ 接受 SwiftData RouteRule) -- `func activateRouteMO(_ mo: RouteRuleMO) async throws`(macOS 12–13 接受 Core Data RouteRuleMO) -- `func deactivateRoute(_ rule: RouteRule) async throws`(macOS 14+) -- `func deactivateRouteMO(_ mo: RouteRuleMO) async throws`(macOS 12–13) -- `func refreshSystemRoutes() async throws` -- `func installHelper(method: InstallMethod) async throws -> InstallResult`(**变更**: 接受用户选择的安装方式参数) -- `func uninstallHelper() async throws` - -#### Scenario: 通过 RouterService 安装 Helper(macOS 14+ 用户选择方式) -- **WHEN** 视图层调用 `routerService.installHelper(method: .smAppService)` -- **THEN** RouterService 将请求转发给 `PrivilegedHelperManager.install(method: .smAppService)`,执行对应安装流程,返回 `InstallResult` - -#### Scenario: 通过 RouterService 安装 Helper(macOS 12–13) -- **WHEN** 视图层调用 `routerService.installHelper(method: .smJobBless)` -- **THEN** RouterService 将请求转发给 `PrivilegedHelperManager.install(method: .smJobBless)`,直接执行 SMJobBless 安装 - -#### Scenario: 通过 RouterService 激活路由(macOS 14+) -- **WHEN** 视图层调用 `routerService.activateRoute(rule)`(SwiftData RouteRule) -- **THEN** RouterService 构建 `RouterCommand`,通过 XPC 发送给 Helper,等待回复,成功则返回,失败则抛出 `RouterError` - -#### Scenario: 通过 RouterService 激活路由(macOS 12–13) -- **WHEN** 视图层调用 `routerService.activateRouteMO(mo)`(Core Data RouteRuleMO) -- **THEN** RouterService 构建 `RouterCommand`,通过 XPC 发送给 Helper,等待回复,成功后更新 `mo.isActive = true` 并保存 Core Data 上下文 - -#### Scenario: XPC 通信失败 -- **WHEN** XPC 调用 Helper 时连接失败(Helper 未运行) -- **THEN** RouterService 抛出 `RouterError.helperNotAvailable` 错误,`lastError` 属性更新,视图显示错误提示 - -### REMOVED Requirements - -### Requirement: 移除遗留代码 -**Reason**: upgrade() 原子升级路径不再需要。2.1.0 统一切换安装方式的流程为"卸载 → 重新选择 → 安装",不再支持一键升级。 -**Migration**: 用户通过 StatusBanner(info 样式)引导,在设置页完成卸载后重新选择安装方式。 +## 目的 + +定义 `RouterService` 作为应用中路由操作与 Helper 通信的统一入口, +并保证在不同 macOS 版本下对外接口一致、行为可预测。 + +## 需求 + +### 需求:RouterService 统一服务接口 +系统必须提供 `RouterService` 作为所有路由操作和 Helper 通信的唯一入口。 +在 macOS 14+ 上,`RouterService` 应遵循 `@Observable` 并支持类型化环境注入; +在 macOS 12-13 上,`RouterService` 应遵循 `ObservableObject` 并通过 +`@EnvironmentObject` 注入。两条路径必须暴露一致的公共能力。 + +#### 场景:通过 RouterService 安装 Helper(macOS 14+ 用户选择方式) +- **当** 视图层调用 `routerService.installHelper(method: .smAppService)` +- **那么** RouterService 将请求转发给 `PrivilegedHelperManager.install(method: .smAppService)`,执行对应安装流程并返回 `InstallResult` + +#### 场景:通过 RouterService 安装 Helper(macOS 12-13) +- **当** 视图层调用 `routerService.installHelper(method: .smJobBless)` +- **那么** RouterService 将请求转发给 `PrivilegedHelperManager.install(method: .smJobBless)`,执行 SMJobBless 安装 + +#### 场景:通过 RouterService 激活路由(macOS 14+) +- **当** 视图层调用 `routerService.activateRoute(rule)`(SwiftData RouteRule) +- **那么** RouterService 构建 `RouterCommand`,通过 XPC 发送给 Helper,等待回复并在成功时返回 + +#### 场景:通过 RouterService 激活路由(macOS 12-13) +- **当** 视图层调用 `routerService.activateRouteMO(mo)`(Core Data RouteRuleMO) +- **那么** RouterService 构建 `RouterCommand`,通过 XPC 发送给 Helper,等待回复,成功后更新 `mo.isActive = true` 并保存 Core Data 上下文 + +#### 场景:XPC 通信失败 +- **当** XPC 调用 Helper 时连接失败(例如 Helper 未运行) +- **那么** RouterService 必须抛出 `RouterError.helperNotAvailable` 或映射后的通信错误,并更新 `lastError` 供界面展示 + +### 需求:RouterService 初始化必须容忍监听器降级 +`RouterService` 在初始化过程中必须容忍 `HelperToolMonitor` 启动失败或部分失败; +无论监听器是否可用,`RouterService` 都必须完成初始化并对外提供可用实例。 + +#### 场景:监听器全部失败时完成初始化 +- **当** `HelperToolMonitor.start` 返回“无可用监听源” +- **那么** `RouterService.init()` 必须完成,且应用主界面可正常进入 + +#### 场景:监听器部分成功时完成初始化 +- **当** `HelperToolMonitor.start` 仅创建了部分监听源 +- **那么** `RouterService` 必须按部分可用状态继续运行,不得抛出致命错误 + +### 需求:监听不可用时必须启用状态刷新兜底 +当监听器不可用时,系统必须通过低频主动刷新保持 helper 安装状态可更新, +避免状态长期陈旧。 + +#### 场景:初始化后进入轮询兜底 +- **当** `RouterService` 检测到监听不可用 +- **那么** 系统必须启动低频刷新任务,周期性执行状态刷新并更新 `helperStatus` + +#### 场景:应用恢复激活时即时刷新 +- **当** 应用从后台回到前台或重新激活 +- **那么** 系统必须立即触发一次 helper 状态刷新,而不是等待下一次轮询周期