From f4bf7379e05d676595f6f25070ac2ade8b9b76ab Mon Sep 17 00:00:00 2001 From: jdjingdian Date: Tue, 24 Mar 2026 11:07:58 +0800 Subject: [PATCH 1/2] fix: startup failed on macOS 13 --- .../Components/HelperToolMonitor.swift | 88 +++++++++++++++---- StaticRouter/Services/RouterService.swift | 73 ++++++++++++--- .../fix-macos13-startup-crash/.openspec.yaml | 2 + .../fix-macos13-startup-crash/design.md | 69 +++++++++++++++ .../fix-macos13-startup-crash/proposal.md | 30 +++++++ .../specs/helper-monitor-stability/spec.md | 34 +++++++ .../specs/router-service/spec.md | 23 +++++ .../fix-macos13-startup-crash/tasks.md | 20 +++++ 8 files changed, 310 insertions(+), 29 deletions(-) create mode 100644 openspec/changes/fix-macos13-startup-crash/.openspec.yaml create mode 100644 openspec/changes/fix-macos13-startup-crash/design.md create mode 100644 openspec/changes/fix-macos13-startup-crash/proposal.md create mode 100644 openspec/changes/fix-macos13-startup-crash/specs/helper-monitor-stability/spec.md create mode 100644 openspec/changes/fix-macos13-startup-crash/specs/router-service/spec.md create mode 100644 openspec/changes/fix-macos13-startup-crash/tasks.md 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/fix-macos13-startup-crash/.openspec.yaml b/openspec/changes/fix-macos13-startup-crash/.openspec.yaml new file mode 100644 index 0000000..2ca4bc8 --- /dev/null +++ b/openspec/changes/fix-macos13-startup-crash/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-24 diff --git a/openspec/changes/fix-macos13-startup-crash/design.md b/openspec/changes/fix-macos13-startup-crash/design.md new file mode 100644 index 0000000..94bf60b --- /dev/null +++ b/openspec/changes/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/fix-macos13-startup-crash/proposal.md b/openspec/changes/fix-macos13-startup-crash/proposal.md new file mode 100644 index 0000000..4c534f3 --- /dev/null +++ b/openspec/changes/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/fix-macos13-startup-crash/specs/helper-monitor-stability/spec.md b/openspec/changes/fix-macos13-startup-crash/specs/helper-monitor-stability/spec.md new file mode 100644 index 0000000..3618db2 --- /dev/null +++ b/openspec/changes/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/fix-macos13-startup-crash/specs/router-service/spec.md b/openspec/changes/fix-macos13-startup-crash/specs/router-service/spec.md new file mode 100644 index 0000000..dd3ee84 --- /dev/null +++ b/openspec/changes/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/fix-macos13-startup-crash/tasks.md b/openspec/changes/fix-macos13-startup-crash/tasks.md new file mode 100644 index 0000000..7418edc --- /dev/null +++ b/openspec/changes/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. 测试与回归验证 + +- [ ] 3.1 新增/更新单元测试:`open` 失败时 `HelperToolMonitor.start` 不崩溃且返回“不可用监听”状态。 +- [ ] 3.2 新增/更新单元测试:部分目录失败时仍创建可用 source,`stop` 后资源被正确清理。 +- [ ] 3.3 新增/更新集成测试或可替代验证:`RouterService` 在监听不可用时仍可初始化并持续刷新 `helperStatus`。 +- [ ] 3.4 手工回归 macOS 13.4:验证冷启动不崩溃、安装/卸载 helper 后状态可在预期时间内更新。 From 0ac1c7deb50659a8d7cf1c0d71c3d1b151b52794 Mon Sep 17 00:00:00 2001 From: jdjingdian Date: Tue, 24 Mar 2026 11:22:19 +0800 Subject: [PATCH 2/2] fix(monitor): prevent startup crash when helper watcher init fails Guard HelperToolMonitor source creation with valid file descriptors and expose start health for degraded monitoring paths. Enable RouterService fallback status polling and refresh on app activation when filesystem watchers are unavailable. Mark OpenSpec tasks done, sync updated specs, and archive 2026-03-24-fix-macos13-startup-crash. --- StaticRouteHelper.xcodeproj/project.pbxproj | 12 +-- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/helper-monitor-stability/spec.md | 0 .../specs/router-service/spec.md | 0 .../tasks.md | 8 +- .../specs/helper-monitor-stability/spec.md | 38 ++++++++ openspec/specs/router-service/spec.md | 97 +++++++++++-------- 9 files changed, 104 insertions(+), 51 deletions(-) rename openspec/changes/{fix-macos13-startup-crash => archive/2026-03-24-fix-macos13-startup-crash}/.openspec.yaml (100%) rename openspec/changes/{fix-macos13-startup-crash => archive/2026-03-24-fix-macos13-startup-crash}/design.md (100%) rename openspec/changes/{fix-macos13-startup-crash => archive/2026-03-24-fix-macos13-startup-crash}/proposal.md (100%) rename openspec/changes/{fix-macos13-startup-crash => archive/2026-03-24-fix-macos13-startup-crash}/specs/helper-monitor-stability/spec.md (100%) rename openspec/changes/{fix-macos13-startup-crash => archive/2026-03-24-fix-macos13-startup-crash}/specs/router-service/spec.md (100%) rename openspec/changes/{fix-macos13-startup-crash => archive/2026-03-24-fix-macos13-startup-crash}/tasks.md (85%) create mode 100644 openspec/specs/helper-monitor-stability/spec.md 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/openspec/changes/fix-macos13-startup-crash/.openspec.yaml b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/.openspec.yaml similarity index 100% rename from openspec/changes/fix-macos13-startup-crash/.openspec.yaml rename to openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/.openspec.yaml diff --git a/openspec/changes/fix-macos13-startup-crash/design.md b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/design.md similarity index 100% rename from openspec/changes/fix-macos13-startup-crash/design.md rename to openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/design.md diff --git a/openspec/changes/fix-macos13-startup-crash/proposal.md b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/proposal.md similarity index 100% rename from openspec/changes/fix-macos13-startup-crash/proposal.md rename to openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/proposal.md diff --git a/openspec/changes/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 similarity index 100% rename from openspec/changes/fix-macos13-startup-crash/specs/helper-monitor-stability/spec.md rename to openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/specs/helper-monitor-stability/spec.md diff --git a/openspec/changes/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 similarity index 100% rename from openspec/changes/fix-macos13-startup-crash/specs/router-service/spec.md rename to openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/specs/router-service/spec.md diff --git a/openspec/changes/fix-macos13-startup-crash/tasks.md b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/tasks.md similarity index 85% rename from openspec/changes/fix-macos13-startup-crash/tasks.md rename to openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/tasks.md index 7418edc..b4b87f9 100644 --- a/openspec/changes/fix-macos13-startup-crash/tasks.md +++ b/openspec/changes/archive/2026-03-24-fix-macos13-startup-crash/tasks.md @@ -14,7 +14,7 @@ ## 3. 测试与回归验证 -- [ ] 3.1 新增/更新单元测试:`open` 失败时 `HelperToolMonitor.start` 不崩溃且返回“不可用监听”状态。 -- [ ] 3.2 新增/更新单元测试:部分目录失败时仍创建可用 source,`stop` 后资源被正确清理。 -- [ ] 3.3 新增/更新集成测试或可替代验证:`RouterService` 在监听不可用时仍可初始化并持续刷新 `helperStatus`。 -- [ ] 3.4 手工回归 macOS 13.4:验证冷启动不崩溃、安装/卸载 helper 后状态可在预期时间内更新。 +- [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 状态刷新,而不是等待下一次轮询周期