cmux 套件架构、重构分层、依赖反转、档案结构、DocC 文件、套件设计规范、可测试性以及 Swift 6 并发规则。在新增或大幅重写 Swift 档案、Swift Package、Coordinator、Service、Repository 或公开套件 API 之前使用。
cmux 架构
套件架构
cmux 正从单一 App Target 迁移至 Packages/ 底下的 Swift Package。每个新套件必须满足以下原则:
- 易用性(Ergonomic)。 默认使用
internal存取权限;仅对外暴露下游使用者真正需要的public符号。 - 无环依赖(Acyclic)。 套件之间必须形成严格的有向无环图(DAG)。若需共享型别,应将其提升(Lift)至更低层级的套件,或在使用者端定义协定接缝(Protocol seam)。每次新增依赖边缘时,都必须重新检查并确保图形维持无环状态。
- 完整领域(Whole-domain)。 单一套件应掌控一个完整的领域(设定、外观、工作区、终端机、浏览器、命令面板等)。
CmuxAppearanceMath+CmuxAppearanceTheme+CmuxAppearanceSettings属于CmuxAppearance内部的文件夹结构,而非模组结构。建立套件边界的前提是:有多个使用者需要其内容,或者必须存在构建/测试接缝。
遇到疑虑时,优先从叶子节点(Leaf-first)开始抽离:即没有内部依赖的套件。Packages/ 底下已存在的现有套件早于此规范成立,切勿将其作为设计参考。
将新套件接入 cmux.xcodeproj 时,需要在 cmux 与 cmux-unit 两个 Target 的 pbxproj 中加入明确条目。请参考 references/package-boundaries.md。
Group 资料夹。 每个套件在物理层面上必须正好位于一个 Group 目录之下:Packages/Shared/<pkg>(双 App 共享)、Packages/iOS/<pkg>(仅 iOS)或 Packages/macOS/<pkg>(仅 macOS)。cmux.xcworkspace/contents.xcworkspacedata 会同步该资料夹结构,其中包含三个 Group,其容器位置即为上述资料夹,且每个套件目录均作为其 Group 资料夹下的 FileRef。资料夹本身为单一事实来源(Source of truth):若要移动套件,请先对目录执行 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 中保持可见。需追踪根目录 Xcode 的 Lockfile,以及透过独立运行 swift package resolve / swift build / swift test 产生的每个 cmux 本地套件 Package.resolved。本地套件的 Lockfile 是该套件独立解析的单一事实来源,不会被 cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved 替换。第三方 Vendored 目录可保留其上游的 ignore 策略。CI 会运行 python3 scripts/check-package-resolved-policy.py。
功能旗标(Feature flags)代表远端 PostHog 阶段执行期旗标。 除非使用者明确要求使用编译期旗标、本地设定或环境变数,否则功能旗标均应透过 CmuxFeatureFlags 实现,并包含 PostHog Key、明确的不可用回退方案(Fallback)、注册表元资料、即时更新行为以及针对性的测试。本地覆写可用于狗粮测试(Dogfooding),但绝不能作为生产环境的控制平面(Control plane)。
分层架构
分为五个层级,依赖关系仅能向下指向:
- Core (
CmuxCore):纯Sendable值、ID、DTO、错误、共享协定接缝。不包含 AppKit/SwiftUI/I/O。当两个领域需要同一种型别时,此处为提升目标。 - Services / 基础设施:对外部世界(程序/PTY、档案系统、Socket、Web API、通知、验证)实现 Core 协定的
actor。每个内聚功能独立为一个套件。 - Domain / 状态:
@MainActor @Observable模型以及 Coordinator,每个功能领域独立为一个套件,掌控该领域的可变(Mutable)状态。范例:CmuxSettings。 - UI:SwiftUI/AppKit 视图,每个领域套件对应一个 UI 套件,仅依赖其领域套件与 Core,绝不直接依赖 Service。范例:
CmuxSettingsUI。 - Executable (
cmuxApp/AppDelegate):轻量级组合垫片(Composition shim),不含任何业务逻辑。
依据意图划分每个抽离出的实体:
- Coordinator:
@MainActor @Observable协调器,负责编排使用者流程并掌控导航/选择/生命周期状态,呼叫 Service 和子模型。其本身不处理任何 I/O。 - Service:
actor(仅在 AppKit 主线程 API 强制要求时使用@MainActor),执行单一外部世界能力;暴露async/await以及AsyncStream;仅持有自身的资源句柄,不保存 UI 状态。 - Repository:
actor,透过传回值型别(Value types)的 CRUD 型态非同步方法,中介单一持久化事实来源(档案、预设设定、Web API)。先前范例:JSONConfigStore、UserDefaultsSettingsStore。
依赖反转(Dependency inversion)。 低层级套件发布协定;具体 Service/Repository 进行实现;高层级依赖于 any Protocol,绝不依赖具体型别,且绝不让储存属性跨模组存取。仅使用建构子(init)注入:无全局容器、无单例(Singleton)、无 static let shared。Executable App Target 是唯一的组合根(Composition root),也是唯一指名具体型别并建立物件图(Object graph)的地方。SwiftUI 的 Environment 可沿视图树传递已建构完成的 @Observable 模型(如 SettingsRuntime 所示),但绝不能用于 Service 接线。
状态与 SwiftUI。 领域状态存在于 @MainActor @Observable 模型中,绝不使用 ObservableObject/@Published。巨型模型(God model)应拆解为由领域套件掌控、透过引用(Reference)组合的内聚子 @Observable 模型;跨领域读取应置于唯读协定之后。在视图中使用 @State(自有)、@Bindable 或纯 let(传参传入),或是 @Environment(M.self) 配合 .environment(...)(注入)。切勿使用 @StateObject / @ObservedObject / @EnvironmentObject / .environmentObject(_:)。
Executable Target 边界(反转,切勿绕过):
@maincmuxApp与AppDelegate保留在 Executable Target 中作为薄组合垫片。该留存部分是预期的最终状态,而非技术债。- 型别只能在单个模组中宣告,且低层级套件无法扩展高层级拥有的型别,因此
AppDelegate+*/cmuxApp+*/Workspace+*等 Extension 不能向下移动。应将行为抽离至 Coordinator/Service/Repository,让巨型物件持有其实体,并将 Extension 简化为单行转发。 - 储存属性(Stored properties)不能跨越模组边界。将巨型模型的状态拆解为由领域套件拥有的子
@Observable模型,透过引用进行组合,并把横切读取(Cross-cutting reads)置于唯读协定之后。
档案组织
一个档案仅包含一个主要型别,档案名称以该型别命名(例如 Control.swift、LabeledChoice.swift、ListControl.swift,而非共享的 SettingControl.swift)。本规则适用于 Packages/ 中的所有新程序码以及所有新新增的 App Target 档案。
- 仅在档案内部使用的轻量 Private Helper、嵌套型别(Nested types)与单行 Extension 可保留在父型别档案中。任何具有实质内容的主体都必须拆分为独立档案,包括嵌套在其他型别中的
private final class。 - 为在其他地方定义的型别新增 Conformance 的 Extension,应置于
TypeName+Conformance.swift或TypeName+Feature.swift中,而非打包在调用的 Feature 档案里。 - 型别抹除包装器(Type-erased wrappers)应与其抹除的对象并存:例如
Foo.swift与AnyFoo.swift。 - 制定此规则是为了遏止巨型档案(God files,如
ContentView.swift、Workspace.swift、TabManager.swift、cmuxApp.swift)。即使档案数量增加到三倍,坚持一个档案一个型别也是正确的。档案数量成本极低,但「找不到某个型别」的维护成本极高。
文件说明(Documentation)
在 Packages/ 底下的新套件中,每个 public 符号在撰写时都必须附带 Swift-DocC /// 注释。文件是 API 表面的一部分,而非事后补充的工作。
- 第一行为单句摘要,需符合单行长度并以句号结尾。在任何讨论段落之前保留一行空白的
///。在带有参数或可能抛出异常的init与func符号上使用- Parameter name:/- Returns:/- Throws:。支援 Markdown 格式。 - 引用符号时使用双反引号(
CmuxSetting);非符号的程序码使用单反引号(UserDefaults.standard)。 - 需撰写文件说明:型别代表的意义与使用时机、每个 Enum Case 的涵义、
init参数预设值及其原因、属性不变量(Property invariants)、方法行为,以及接受哪些泛型Value/Element结构及其原因。 - 非简单的 API 必须在
swift代码块中包含至少一个简短示例,最好是来自本程序码库的实际宣告。 - 当意图不够明显时,
internal与private符号应附上单行的///注释。公开边界(Public boundary)则是必须达到完整覆盖的部分。 - 在修改行为或签名(Signature)的同一次编辑中同步更新文档注释。DocC 注释从外部描述契约;行内
//则专用于解释不明显的「为什么」。
主 App Target 程序码不追溯要求补齐文件说明。
套件设计规范
- 禁止共享单例(Shared-singleton)存取器。 在持有执行期状态的套件型别上使用
static let standard/shared/default,本质上就是换个名字的单例。应在 App 启动入口处建构并注入。static let仅可用于宣告(标识符、Schema 项、Enum Case),不可用于行为。 - 禁止命名空间列举(Namespace-enums)。
enum Foo { static func bar() }是缺乏实体、无法进行依赖注入(DI)且无测试接缝的假命名空间。当 Helper 未来可能需要设定项时,优先使用透过建构子传递的值型别 Struct。 - 禁止并行且手动维护的注册表(Registries)。 当某个列表镜像反映已宣告的项时(如
catalog.all镜像储存属性),应透过Mirror反射或 Macro 动态衍生。双重事实来源会导致静默偏差。 - 优先采用编译期不变量而非执行期 Trap。 针对「程序员错误」的
guard ... else { assertionFailure(...); return default }应对,应编码至型别系统中(如使用 幻影型别/Phantom types、拆分为具体的不同型别)。执行期 Trap 在 Release 版本中会变成静默的回退(Fallback)。 - 禁止游离函数(Free functions)。 严禁顶层(Top-level)
func宣告(包含文件作用域的private func在内的任何可见度);应将功能归属于承担该职责的实体。唯一被许可的例外是由 C API 强制要求且附带单行理由说明的@convention(c)跳板(Trampoline)。
可测试性
新增至 Packages/ 的每个 Public 型别必须能在 Test Target 中独立测试,无需启动 App Target、引导 AppKit 或依赖使用者的档案系统或 UserDefaults.standard。
UserDefaults、FileManager、磁盘路径、环境变数与时钟均需透过init参数传入。测试案例可传入作用域限于测试本身的UserDefaults(suiteName:)、临时目录 URL 或固定的Date。- 任何实现均不得硬编码
.shared/.standard。 - 禁止静态测试钩子(Static test hooks)。
nonisolated(unsafe) static var fooForTesting(或任何由测试替换的全局可变覆写)会在测试之间泄漏,且通常需要加锁。应将其替换为透过init传入的协定接缝,例如init(commandRunner: any CommandRunning = CommandRunner())。删除静态钩子及其锁是抽离过程的一部分,而非事后处理。 - 优先传回修改后的值并由呼叫方进行持久化,而非突变(Mutate)全局状态并传回
Void。 - 将观察机制包装为
AsyncStream暴露,以便测试断言(Assert)产出的序列,而非使用需要运行 Runloop 的纯NotificationCenter模式。 - 在套件的
README.md或 DocC 目录中展示测试实例化模式。
如果一个设计难以测试,那它就是错的。应检讨建构参数列表,而不是调整测试平台。
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 -->






