Repository navigation
Reimplement Settings as CmuxSettings/CmuxSettingsUI SPM packages (Claude) #4975
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 22 commits
42fc22f
20e2061
9109d54
894a7a8
7448ba5
4b23125
eac302d
8cde7b9
0c420d9
0f73ef8
01663ef
eadd898
708402c
7a8f031
4b7b3cb
42534e0
101e1c9
0a7956d
b5b20c5
b40a76e
48d980b
91d7c7b
050bd20
c50777d
0b5a16a
92e1ab4
98ad883
975f188
459d010
47d2e51
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| // swift-tools-version: 6.0 | ||
|
|
||
| import PackageDescription | ||
|
|
||
| let package = Package( | ||
| name: "CmuxSettings", | ||
| platforms: [ | ||
| .macOS(.v14), | ||
| ], | ||
| products: [ | ||
| .library( | ||
| name: "CmuxSettings", | ||
| targets: ["CmuxSettings"] | ||
| ), | ||
| ], | ||
| targets: [ | ||
| .target( | ||
| name: "CmuxSettings" | ||
| ), | ||
| .testTarget( | ||
| name: "CmuxSettingsTests", | ||
| dependencies: ["CmuxSettings"] | ||
| ), | ||
| ] | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,191 @@ | ||
| # CmuxSettings | ||
|
|
||
| Strongly-typed, migratable settings storage for cmux. Zero non-Foundation | ||
| dependencies. Modern Swift 6 throughout: actors, `AsyncStream`, value-typed | ||
| keys, dependency injection. No locks, no KVO, no `@Published`. | ||
|
|
||
| Settings live in one of two stores: | ||
|
|
||
| - **`UserDefaultsSettingsStore`** — wraps `UserDefaults`. User-toggled | ||
| preferences that the Settings UI writes. | ||
| - **`JSONConfigStore`** — wraps `~/.config/cmux/cmux.json`. Structured config | ||
| authored by users (hooks, shortcut bindings) or MDM profiles. | ||
|
|
||
| Each setting is declared once on a `SettingCatalog` instance with the typed | ||
| backend it belongs to. The two stores accept only their respective flavor | ||
| of key — wrong-store mismatches are compile errors, not runtime traps. | ||
|
|
||
| ## On-disk layout | ||
|
|
||
| - **UserDefaults** persists under the normal Apple-managed plist for the | ||
| process's `Bundle.main.bundleIdentifier`, viewable with | ||
| `defaults read <bundle-id>`. | ||
| - **JSON config** lives at `~/.config/cmux/cmux.json` by default | ||
| (`CmuxConfigLocation().userConfigFile`). The store creates the file (and | ||
| parent directory) on first write. File is missing on first launch, in | ||
| which case every read returns the key's default value. | ||
|
|
||
| A populated cmux.json looks like this — pretty-printed, sorted keys, JSONC | ||
| comments tolerated on read but stripped on write: | ||
|
|
||
| ```jsonc | ||
| { | ||
| // Automation socket configuration. | ||
| "automation": { | ||
| "socketPassword": "hunter2" | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| The dotted-id of each JSON-backed key is the JSON path; e.g. | ||
| `SettingCatalog.automation.socketPassword.id == "automation.socketPassword"` | ||
| addresses `root["automation"]["socketPassword"]` above. | ||
|
|
||
| ## Quick start | ||
|
|
||
| ```swift | ||
| import CmuxSettings | ||
|
|
||
| // 1. Construct the catalog at app startup. | ||
| let catalog = SettingCatalog() | ||
|
|
||
| // 2. Construct each store (DI; no shared singletons). | ||
| let userDefaultsStore = UserDefaultsSettingsStore( | ||
| defaults: .standard, | ||
| migrating: catalog.all | ||
| ) | ||
| let jsonConfigStore = JSONConfigStore( | ||
| fileURL: CmuxConfigLocation().userConfigFile | ||
| ) | ||
|
|
||
| // 3. Read / write. | ||
| let mode = await userDefaultsStore.value(for: catalog.app.appearance) | ||
| await userDefaultsStore.set(.dark, for: catalog.app.appearance) | ||
|
|
||
| try await jsonConfigStore.set( | ||
| "hunter2", | ||
| for: catalog.automation.socketPassword | ||
| ) | ||
|
|
||
| // 4. Observe changes. Returns AsyncStream that yields the current value | ||
| // first, then every later change. | ||
| for await newMode in userDefaultsStore.values(for: catalog.app.appearance) { | ||
| applyAppearance(newMode) | ||
| } | ||
| ``` | ||
|
|
||
| ## End-to-end example | ||
|
|
||
| A runnable snippet that exercises read, write, and external-edit | ||
| observation through a temp directory — useful as a copy-paste-and-run | ||
| sanity check or as a starting point for integration tests: | ||
|
|
||
| ```swift | ||
| import CmuxSettings | ||
| import Foundation | ||
|
|
||
| // Scope the JSON store to a temp file so this example is hermetic. | ||
| let tempDir = FileManager.default.temporaryDirectory | ||
| .appending(path: "cmux-readme-\(UUID().uuidString)") | ||
| try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) | ||
| let fileURL = tempDir.appending(path: "cmux.json") | ||
|
|
||
| let catalog = SettingCatalog() | ||
| let json = JSONConfigStore(fileURL: fileURL) | ||
|
|
||
| // Initially missing → default. | ||
| let initial = await json.value(for: catalog.automation.socketPassword) | ||
| assert(initial == "") | ||
|
|
||
| // Write → file is created with the parent directory. | ||
| try await json.set("hunter2", for: catalog.automation.socketPassword) | ||
| assert(await json.value(for: catalog.automation.socketPassword) == "hunter2") | ||
|
|
||
| // Observe → react to external edits (simulated here). | ||
| let task = Task { | ||
| for await value in json.values(for: catalog.automation.socketPassword) { | ||
| print("password is now:", value) | ||
| } | ||
| } | ||
|
|
||
| try Data(#"{"automation":{"socketPassword":"rotated"}}"#.utf8) | ||
| .write(to: fileURL, options: .atomic) | ||
|
|
||
| // ... do other work; cancel when done observing. | ||
| // task.cancel() | ||
| ``` | ||
|
|
||
| ## Handling errors | ||
|
|
||
| `UserDefaultsSettingsStore` reads and writes never throw (UserDefaults is | ||
| fire-and-forget). `JSONConfigStore.set(_:for:)` and `reset(_:)` are | ||
| `throws`: they propagate filesystem errors (permission denied, disk full). | ||
| The store's in-memory cache is **only** updated after the disk write | ||
| succeeds, so a failed write leaves subsequent reads consistent with what | ||
| is actually on disk. | ||
|
|
||
| ## Adding a new setting | ||
|
|
||
| 1. Pick the catalog section under `Sources/CmuxSettings/Keys/` (e.g. | ||
| `AppCatalogSection.swift`). Add a new stored property. | ||
| 2. Choose the backend: | ||
| - `DefaultsKey<Value>` for UserDefaults-backed settings. | ||
| - `JSONKey<Value>` for JSON-config-backed settings. | ||
| 3. Pick the value type. Common cases work out of the box: | ||
| - Primitives (`Bool`, `Int`, `Double`, `String`, `Data`, `URL`) — done. | ||
| - Arrays / `[String: …]` of conforming elements — done. | ||
| - `RawRepresentable` enums whose raw value is `SettingCodable` — done. | ||
| - Custom struct — implement `SettingCodable` (4 methods, one for each | ||
| decode/encode + backend pair). | ||
| 4. `catalog.all` updates automatically via `Mirror` reflection over the | ||
| catalog's stored properties; no parallel list to maintain. | ||
|
|
||
| ## Adding a whole new section | ||
|
|
||
| 1. Create `Sources/CmuxSettings/Keys/FooCatalogSection.swift`: | ||
| ```swift | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | 💤 Low value Add blank lines around fenced code blocks. Static analysis flagged missing blank lines before the code fences. This is a minor markdown formatting convention. 📝 Proposed fix ## Adding a whole new section
1. Create `Sources/CmuxSettings/Keys/FooCatalogSection.swift`:
+
```swift
public struct FooCatalogSection: SettingCatalogSection {
public let bar = DefaultsKey<Bool>(
id: "foo.bar",
defaultValue: false,
userDefaultsKey: "fooBar"
)
public init() {}
}
```
+
2. Add `public let foo = FooCatalogSection()` to `SettingCatalog`.Also applies to: 155-155 🧰 Tools🪛 markdownlint-cli2 (0.22.1)[warning] 146-146: Fenced code blocks should be surrounded by blank lines (MD031, blanks-around-fences) 🤖 Prompt for AI Agents |
||
| public struct FooCatalogSection: SettingCatalogSection { | ||
| public let bar = DefaultsKey<Bool>( | ||
| id: "foo.bar", | ||
| defaultValue: false, | ||
| userDefaultsKey: "fooBar" | ||
| ) | ||
| public init() {} | ||
| } | ||
| ``` | ||
| 2. Add `public let foo = FooCatalogSection()` to `SettingCatalog`. | ||
|
|
||
| Reflection picks it up recursively; `catalog.all` includes every leaf. | ||
|
|
||
| ## Architecture | ||
|
|
||
| - **`SettingCatalog`** — root value-typed registry. Composed of | ||
| `SettingCatalogSection` sub-structs grouped by dotted-id prefix. | ||
| - **`DefaultsKey<V>` / `JSONKey<V>`** — strongly-typed setting handles. Each | ||
| store accepts only its flavor. | ||
| - **`AnySettingKey`** — type-erased view. Used for catalog enumeration | ||
| (`catalog.all`) and legacy-key migration that's still | ||
| type-safe via a captured closure. | ||
| - **`UserDefaultsSettingsStore`** — `actor`. Async reads/writes/observe. | ||
| Observation uses `NotificationCenter.default.notifications(named:)`. | ||
| - **`JSONConfigStore`** — `actor`. Async reads/writes/observe. Owns one | ||
| `JSONConfigFileWatcher` and fans out file-change events to per-subscriber | ||
| bounded signal streams (no `N × parse` work under burst changes). | ||
| - **`JSONConfigFileWatcher`** — `actor` wrapping `DispatchSource` | ||
| file-system events behind an `AsyncStream<Void>`. The only public surface | ||
| is the typed event stream; the underlying dispatch sources never leak. | ||
|
|
||
| ## Testing | ||
|
|
||
| Tests construct `DefaultsKey` / `JSONKey` directly with a temp-suite | ||
| `UserDefaults` or a temp-dir file URL. The catalog isn't a test fixture; it | ||
| is the production registry. See `Tests/CmuxSettingsTests/` for patterns. | ||
|
|
||
| ## Concurrency | ||
|
|
||
| The cmux repo enforces a strict modern-concurrency policy | ||
| (see the cmux `CLAUDE.md` "Modern Swift concurrency" section): no locks, | ||
| no manual KVO, no `@Published`/`ObservableObject`, no `DispatchQueue.main.async`, | ||
| no completion-handler APIs in new code. This package adheres to that policy | ||
| end-to-end and builds clean under | ||
| `-strict-concurrency=complete -warnings-as-errors`. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| # ``CmuxSettings`` | ||
|
|
||
| Strongly-typed, migratable settings storage for cmux. Foundation-only. | ||
|
|
||
| ## Overview | ||
|
|
||
| `CmuxSettings` is the settings layer of cmux. It is built on three | ||
| principles: | ||
|
|
||
| - **Typed at compile time.** Each setting is a value-typed | ||
| ``DefaultsKey`` or ``JSONKey`` declared once on a | ||
| ``SettingCatalog``. Stores accept only their flavor; passing a | ||
| ``JSONKey`` to ``UserDefaultsSettingsStore`` does not compile. | ||
| - **Modern Swift 6 concurrency.** Both stores are `actor`s. | ||
| Observation is exposed as `AsyncStream<Value>`. There are no | ||
| locks, no KVO, no `@Published`/`ObservableObject`, no | ||
| completion-handler APIs. | ||
| - **Dependency-injected.** No shared singletons. The app constructs | ||
| the catalog and the stores at startup and passes them to consumers. | ||
|
|
||
| ## Topics | ||
|
|
||
| ### Getting started | ||
|
|
||
| - ``SettingCatalog`` | ||
| - ``UserDefaultsSettingsStore`` | ||
| - ``JSONConfigStore`` | ||
|
|
||
| ### Declaring settings | ||
|
|
||
| - ``DefaultsKey`` | ||
| - ``JSONKey`` | ||
| - ``SettingCatalogSection`` | ||
| - ``AppCatalogSection`` | ||
| - ``AutomationCatalogSection`` | ||
|
|
||
| ### Value types | ||
|
|
||
| - ``SettingCodable`` | ||
| - ``AppearanceMode`` | ||
| - ``SocketControlMode`` | ||
|
|
||
| ### Type erasure and migration | ||
|
|
||
| - ``AnySettingKey`` | ||
|
|
||
| ### JSON config internals | ||
|
|
||
| - ``JSONConfigFileWatcher`` | ||
| - ``JSONCSanitizer`` | ||
| - ``JSONPath`` | ||
| - ``CmuxConfigLocation`` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import Foundation | ||
|
|
||
| /// Array ``SettingCodable`` conformance for arrays of conforming elements. | ||
| /// | ||
| /// Decode is all-or-nothing: if any element fails to decode, the whole | ||
| /// array returns `nil` and the store falls back to the key's default. | ||
| /// Partial recovery would silently hide single-element corruption. | ||
| extension Array: SettingCodable where Element: SettingCodable { | ||
| public static func decodeFromUserDefaults(_ raw: Any?) -> [Element]? { | ||
| guard let array = raw as? [Any] else { return nil } | ||
| var result: [Element] = [] | ||
| result.reserveCapacity(array.count) | ||
| for element in array { | ||
| guard let value = Element.decodeFromUserDefaults(element) else { return nil } | ||
| result.append(value) | ||
| } | ||
| return result | ||
| } | ||
|
|
||
| public func encodeForUserDefaults() -> Any { map { $0.encodeForUserDefaults() } } | ||
|
|
||
| public static func decodeFromJSON(_ raw: Any?) -> [Element]? { | ||
| guard let array = raw as? [Any] else { return nil } | ||
| var result: [Element] = [] | ||
| result.reserveCapacity(array.count) | ||
| for element in array { | ||
| guard let value = Element.decodeFromJSON(element) else { return nil } | ||
| result.append(value) | ||
| } | ||
| return result | ||
| } | ||
|
|
||
| public func encodeForJSON() -> Any { map { $0.encodeForJSON() } } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial | 💤 Low value
Minor: "runloop" should be two words.
"run loop" is the conventional spelling in Apple documentation and Cocoa terminology.
📝 Suggested fix
📝 Committable suggestion
🧰 Tools
🪛 LanguageTool
[grammar] ~302-~302: Ensure spelling is correct
Context: ...y patterns where the test has to spin a runloop. - Document the test pattern alongs...
(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)
🤖 Prompt for AI Agents