Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions StaticRouteHelper.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
88 changes: 70 additions & 18 deletions StaticRouter/Components/HelperToolMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,38 +39,76 @@ 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){
self.constants = constants
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 {
Expand Down Expand Up @@ -88,7 +140,7 @@ class HelperToolMonitor {
}


enum HelperToolInstallationState {
enum HelperToolInstallationState: Equatable {
case installed
case pendingActivation
case needUpgrade
Expand Down
73 changes: 62 additions & 11 deletions StaticRouter/Services/RouterService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import Foundation
import Combine
import AppKit
import SecureXPC
import Blessed
import Authorized
Expand Down Expand Up @@ -104,6 +105,11 @@ final class RouterService: ObservableObject {

/// Combine subscriptions for helperManager state propagation.
private var helperManagerCancellables = Set<AnyCancellable>()
/// 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

Expand All @@ -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.
Expand All @@ -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)

Expand All @@ -159,6 +164,9 @@ final class RouterService: ObservableObject {
}

deinit {
helperMonitor.stop()
helperStatusFallbackPollingCancellable?.cancel()
appDidBecomeActiveCancellable?.cancel()
monitoringTask?.cancel()
}

Expand Down Expand Up @@ -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 事件,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-24
Original file line number Diff line number Diff line change
@@ -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 暴露“监听降级中”的诊断提示(当前计划仅日志可见)。
Original file line number Diff line number Diff line change
@@ -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 后状态刷新
Loading
Loading