cmux 包架构、重构分层、依赖反转、文件组织、DocC 文档规范、包设计纪律、可测试性以及 Swift 6 并发规则。在新增或大幅重构 Swift 文件、Swift 包、Coordinator(协调器)、Service(服务)、Repository(仓储)或包的 public API 之前使用。
cmux 架构设计指南
包架构设计 (Package Architecture)
cmux 正从单一 app target 迁移为 Packages/ 目录下的多个 Swift Package。每个新拆分的 Package 必须满足以下要求:
- 易用友好 (Ergonomic):默认使用
internal访问级别;仅对下游调用方真正需要的符号声明为public。 - 无环依赖 (Acyclic):Package 之间必须构成严格的有向无环图 (DAG)。如需跨包共享类型,应将其下沉到更底层的 Package,或在调用方定义协议接缝 (protocol seam)。每次新增依赖关系时,都必须重新确认依赖图无环。
- 完整业务域 (Whole-domain):一个 Package 应完整承载一个业务域(如设置、外观、工作区、终端、浏览器、命令面板等)。
CmuxAppearanceMath+CmuxAppearanceTheme+CmuxAppearanceSettings应当是CmuxAppearance内部的文件目录结构,而非模块结构。只有当某个模块被多个调用方复用,或者确实需要构建/测试解耦接缝时,才应该划分模块边界。
拿不准时,优先从叶子节点开始拆分(即无内部依赖的 Package)。Packages/ 下早期创建的现有包早于本规范,切勿将其作为设计参考。
将新 Package 接入 cmux.xcodeproj 时,必须在 cmux 和 cmux-unit 两个 target 中显式配置 pbxproj 条目。详见 references/package-boundaries.md。
Group 目录结构:每个 Package 在物理磁盘上必须归属于唯一的 Group 目录:Packages/Shared/<pkg>(双端共用)、Packages/iOS/<pkg>(仅 iOS)或 Packages/macOS/<pkg>(仅 macOS)。cmux.xcworkspace/contents.xcworkspacedata 的组织方式与物理目录完全保持一致:对应包含三个 Group,以文件夹路径为容器位置,Package 目录作为该 Group 下的 FileRef。磁盘目录是唯一事实来源:移动 Package 时,需先执行 git mv 目录,然后运行 python3 scripts/check-workspace-package-groups.py --write。跨 Group 的 .package(path:) 依赖使用相对路径 ../../<Group>/<Name>。严禁手动修改 workspace group 结构,CI 会运行 python3 scripts/check-workspace-package-groups.py --check 检查偏离情况,如有漂移将直接报错。
Lockfile 锁定文件:切勿对 cmux 自身的 Package.resolved 文件设置 gitignore,必须让 SwiftPM 的依赖解析变更在 PR diff 中清晰可见。需要 Git 追踪根目录的 Xcode lockfile,以及通过独立执行 swift package resolve / swift build / swift test 生成的每个 cmux 自有 Package 的 Package.resolved。Package 本地的 lockfile 是该包独立解析时的唯一事实来源,不会被 cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved 覆盖。引用的第三方目录可保留上游的忽略规则。CI 会运行 python3 scripts/check-package-resolved-policy.py 进行校验。
Feature Flag(特性开关)即远程 PostHog 运行时开关:除非用户明确要求使用编译期开关、本地设置或环境变量,否则一律通过 CmuxFeatureFlags 实现 Feature Flag,且必须包含 PostHog key、显式的不可用降级回退机制(fallback)、注册表元数据、实时更新响应以及针对性的单元测试。本地覆盖(local override)仅用于内部 Dogfood 调试,绝不可作为线上生产环境的控制面。
架构分层 (Layers)
全局划分为 5 个层级,依赖关系只能单向向下:
- 核心层 Core (
CmuxCore):纯Sendable值类型、ID、DTO、错误定义、共享协议接缝。不包含任何 AppKit/SwiftUI/IO 代码。当两个业务域需要共享同个类型时,下沉提取到此层。 - 服务与基础设施层 Services / infrastructure:针对外部世界(进程/PTY、文件系统、Socket、Web API、通知、鉴权)实现核心协议的
actor。按高内聚能力拆分,一能力一 Package。 - 领域与状态层 Domain / state:包含
@MainActor @Observable模型和 Coordinator。每个功能域对应一个 Package,持有该领域的可变状态。标准示例:CmuxSettings。 - 视图层 UI:SwiftUI/AppKit 视图。每个领域 Package 对应一个 UI Package,仅依赖其对应的领域 Package 和 Core,绝不直接依赖 Service。标准示例:
CmuxSettingsUI。 - 可执行层 Executable (
cmuxApp/AppDelegate):极薄的依赖组装胶水层,不包含任何业务逻辑。
提取出的每个实体需按设计意图进行归类:
- Coordinator(协调器):
@MainActor @Observable的编排器,负责调度用户交互流程,持有导航/选中/生命周期状态,并调用 Service 和子 Model。Coordinator 本身不执行任何 IO 操作。 - Service(服务):负责对外执行单一能力的
actor(仅在被 AppKit 主线程 API 强绑定时才使用@MainActor);对外暴露async/await以及AsyncStream;仅持有自身资源句柄,绝不持有 UI 状态。 - Repository(仓储):作为单一持久化事实来源(文件、Defaults、Web API)的中介
actor,对外暴露返回值类型的增删改查(CRUD)异步方法。先例参照:JSONConfigStore、UserDefaultsSettingsStore。
依赖反转 (Dependency Inversion):底层 Package 发布 Protocol;具体 Service/Repository 实现该 Protocol;上层模块仅依赖 any Protocol,决不依赖具体类型,更不允许跨模块访问存储属性。统一采用构造器 (init) 注入:禁止使用全局容器、单例或 static let shared。App 可执行 target 是唯一的依赖组装根节点 (Composition Root),也是唯一可以实例化具体类并装配对象图的地方。SwiftUI 的 Environment 仅用于向视图树下发已构建好的 @Observable 模型(例如 SettingsRuntime),严禁用于组装 Service 依赖。
状态与 SwiftUI:领域状态统一保存在 @MainActor @Observable 模型中,严禁使用 ObservableObject/@Published。庞大的上帝模型 (God Model) 必须拆解为高内聚的子 @Observable 模型,由各自的领域 Package 持有并通过引用进行组合;跨域读取需通过只读协议屏蔽。在 View 中,使用 @State(组件自身持有)、@Bindable 或普通 let(外部传入)、或 @Environment(M.self) + .environment(...)(注入)。严禁使用 @StateObject / @ObservedObject / @EnvironmentObject / .environmentObject(_:)。
App Target 边界控制(通过依赖反转治理,拒绝绕过机制):
@maincmuxApp与AppDelegate留存在 App 可执行 target 中,充当薄薄的装配胶水层。这部分存量代码是符合预期的最终形态,而非技术债务。- 每个类型有且仅在一个模块中声明,底层 Package 无法扩展上层定义的类型,因此
AppDelegate+*/cmuxApp+*/Workspace+*等 Extension 无法直接下沉。正确的做法是将行为抽离为 Coordinator/Service/Repository,由上帝对象持有其实例,并将原本的 Extension 简化为单行方法转发。 - 存储属性无法跨模块边界移动。应将上帝模型的状态拆解为由领域 Package 持有的子
@Observable模型,通过引用组合,并用只读协议暴露跨域读取接口。
文件组织规范 (File Organization)
单个文件仅包含一个主要类型,文件名必须与类型名完全一致(例如 Control.swift、LabeledChoice.swift、ListControl.swift,不得合并写在同一个 SettingControl.swift 中)。本规则适用于 Packages/ 下的所有新代码及 App target 的所有新文件。
- 仅在当前文件内部使用的简单 private 辅助函数、嵌套类型和单行 Extension 可以保留在主类型文件中。任何带有实质性代码块的类型(哪怕是嵌套在其他类型内部的
private final class)都必须独立成文件。 - 为其他模块定义的类型实现协议扩展时,应放在
TypeName+Conformance.swift或TypeName+Feature.swift中,切勿直接塞在调用方的业务功能文件中。 - 类型擦除包装器(Type-erased wrapper)应与被擦除类型物理相邻放置:如
Foo.swift与AnyFoo.swift。 - 制定此规则的核心目的就是为了消除巨型文件(如
ContentView.swift、Workspace.swift、TabManager.swift、cmuxApp.swift)。哪怕拆分后文件数量翻三倍也是完全正确的。文件数量增多的成本微乎其微;而“找不到某个类型在哪里”带来的维护成本却极其高昂。
文档规范 (Documentation)
Packages/ 下新 Package 中的每个 public 符号在编写时必须同步附带 Swift-DocC /// 注释。文档是 API 暴露的一部分,绝不是后续补写的补充工作。
- 首行为单句概述,控制在单行内并以句号结尾。长篇说明段落前需留出一个空的
///行。有参数或抛错的init和func须使用- Parameter name:/- Returns:/- Throws:标签。支持 Markdown 语法。 - 引用代码符号时使用双反引号(如
`CmuxSetting`);非符号代码短语使用单反引号(如UserDefaults.standard)。 - 详细说明类型的代表含义与适用场景、每个 enum case 的具体意义、init 参数默认值及其理由、属性的不变性断言(invariants)、方法行为特征,以及支持哪些泛型
Value/Element约束及其原因。 - 非简单 API 至少应包含一个放在
swift代码围栏里的简短示例,优先使用本项目中的真实声明代码。 - 当设计意图不够直观时,
internal和private符号也应添加单行///注释。对外暴露的 Public 边界则是必须做到 100% 覆盖。 - 修改代码行为或函数签名时,必须在同一 commit 中同步更新文档注释。Doc 注释用于从外部描述契约;行内
//注释仅保留用于解释非显而易见的 设计原因。
主 App target 的存量代码不强制追溯补充文档。
包设计纪律 (Package Design Discipline)
- 禁止提供共享单例访问器:在持有运行时状态的 Package 类型上定义
static let standard/shared/default本质上就是变相使用单例。必须在 App 启动处统一构建并显式注入。static let仅允许用于静态声明(如标识符、Schema 配置、Enum Case),绝不能用于封装运行时行为。 - 禁止使用 Namespace Enum:使用
enum Foo { static func bar() }构造无实例的假命名空间,既无法做依赖注入 (DI),也无法留出测试接缝。如果辅助工具后续可能扩展配置,应优先使用在构造函数中传递的值类型 Struct。 - 禁止维护手动的平行注册表:当某个列表需要映射已声明的项时(例如用
catalog.all映射存储属性),必须通过Mirror反射或 Swift Macro 动态生成。手写两套事实来源极易产生隐蔽的代码偏离漂移。 - 优先依靠编译期类型约束而非运行时 Trap:针对“开发者逻辑错误”的
guard ... else { assertionFailure(...); return default }断言,应当尽量直接建模到类型系统中(如利用 Phantom Types 或拆分为独立的具体类型)。因为运行时断言在 Release 构建下很容易变成静默降级的隐患。 - 禁止使用全局自由函数 (Free Functions):禁止定义顶层
func(包括文件作用域的private func在内的任何可见级别);方法必须收拢归属于承担对应职责的类型实体。唯一允许的特例是因对接 C API 强迫使用的@convention(c)转换函数,且必须附带单行理由说明。
可测试性规范 (Testability)
新增到 Packages/ 中的每个 Public 类型都必须能够在测试 target 中独立进行测试,无需启动主 App target、无需初始化 AppKit、也不依赖用户的真实文件系统或 UserDefaults.standard。
UserDefaults、FileManager、磁盘路径、环境变量以及 Clock 时间源均须通过init参数传入。测试用例传入指定suiteName的测试专用UserDefaults、临时目录 URL 或固定Date。- 任何业务实现严禁硬编码访问
.shared或.standard。 - 禁止静态测试 Hook:类似
nonisolated(unsafe) static var fooForTesting的全局可变覆盖变量(用于测试期替换实现)会导致测试间相互污染,且通常需要额外的锁保障。应当替换为通过init注入的 Protocol 接缝,例如init(commandRunner: any CommandRunning = CommandRunner())。在解耦重构时,删除静态 Hook 及其关联的锁是当前必须完成的任务,而非后续待办事项。 - 优先返回变更后的新值并由调用方持久化,而非在内部直接修改全局状态并返回
Void。 - 优先暴露
AsyncStream作为状态观测途径,以便测试可以直接断言产出的数据序列,避免使用需要 RunLoop 轮询等待的纯NotificationCenter模式。 - 必须在 Package 的
README.md或 DocC Catalog 中展示该包在测试下的实例化范例。
如果一个架构设计难以测试,那它本身就是错的。应该去修改构造函数的参数列表,而不是去造复杂的测试台架。
Swift 6 并发规范 (Swift 6 Concurrency)
Packages/ 下的新代码、App target 的新文件以及重要重构代码,统一使用 actor、async/await、AsyncStream/AsyncSequence、@Observable 和 @MainActor。
未经 PR 描述中书面明确理由,严禁使用以下特性:
- Locks:
NSLock,NSRecursiveLock,os_unfair_lock, `OSAllocatedUn
<!-- truncated for translation batch; full body continues in source -->






