Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
42fc22f
Add CmuxSettings + CmuxSettingsUI packages and integrate into the app…
azooz2003-bit May 28, 2026
20e2061
Fix empty Settings window + harden JSON store error path
azooz2003-bit May 28, 2026
9109d54
Dogfood: convert first @AppStorage to @Setting
azooz2003-bit May 28, 2026
894a7a8
Convert 18 @AppStorage call sites in SettingsView to @Setting
azooz2003-bit May 28, 2026
7448ba5
Expand package: integrations catalog + AppSection / SidebarSection / …
azooz2003-bit May 28, 2026
4b23125
Bring all section views in CmuxSettingsUI to parity; swap to package UI
azooz2003-bit May 28, 2026
eac302d
Curated settings search index + SettingsNavigationRequest deeplink
azooz2003-bit May 28, 2026
8cde7b9
Replace CuratedSettingEntries namespace-enum with injectable DI
azooz2003-bit May 28, 2026
0c420d9
Major parity pass: AccountFlow, AppIcon grid, palette editor, shortcu…
azooz2003-bit May 28, 2026
0f73ef8
Add SettingsHostActions: clear history, external editor, test notific…
azooz2003-bit May 28, 2026
01663ef
Wire HostAccountFlow + HostSettingsActions into the cmux app target
azooz2003-bit May 28, 2026
eadd898
Two-stroke chord recording + notification authorization request
azooz2003-bit May 28, 2026
708402c
Rebuild Settings UI: legacy single-scroll SettingsCard layout
azooz2003-bit May 28, 2026
7a8f031
Account section trimmed to identity card only (legacy parity)
azooz2003-bit May 28, 2026
4b7b3cb
Row-by-row port: Automation, Terminal, Sidebar, Beta, Global Hotkey, …
azooz2003-bit May 28, 2026
42534e0
Row-by-row port: App, Browser, Workspace Colors, cmux.json
azooz2003-bit May 28, 2026
101e1c9
Review-loop fixes (iters 1-8): catalog encodings, missing rows, local…
azooz2003-bit May 28, 2026
0a7956d
Wire up Settings search + iter 10 fixes
azooz2003-bit May 28, 2026
b5b20c5
Match legacy SettingsRootView layout pixel-for-pixel
azooz2003-bit May 29, 2026
b40a76e
Drive Settings package to legacy parity (Claude)
azooz2003-bit May 29, 2026
48d980b
Settings parity rounds 9-10: sidebar collapse, key display, recorder …
azooz2003-bit May 29, 2026
91d7c7b
Settings: explicit @State models, non-optional deps, correct defaults…
azooz2003-bit May 29, 2026
050bd20
Settings value models: single-writer current, recorder + colorpicker …
azooz2003-bit May 29, 2026
c50777d
Settings: behavioral UI test suite, error-alert localization, fix Bro…
azooz2003-bit May 29, 2026
0b5a16a
Merge remote-tracking branch 'origin/main' into feat-spm-settings
azooz2003-bit May 29, 2026
92e1ab4
CmuxSettingsUI: drop to Swift tools 6.0 so CI (Swift 6.1) can resolve…
azooz2003-bit May 29, 2026
98ad883
Fix Swift 6.1 build: drop nonisolated(unsafe) UserDefaults sync read …
azooz2003-bit May 29, 2026
975f188
Fix Swift 6.1 warning: drop main-actor default arg in HostAccountFlow…
azooz2003-bit May 29, 2026
459d010
Merge remote-tracking branch 'origin/main' into feat-spm-settings
azooz2003-bit May 29, 2026
47d2e51
ci: raise tests-build-and-lag timeout 20->35 for cold-cache builds
azooz2003-bit May 29, 2026
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
74 changes: 74 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,80 @@ Every `public` symbol in any new Swift package under `Packages/` is documented w

This rule applies to all packages under `Packages/`. Code in the main app target is not retroactively required to be documented, but new `public` symbols added to packages must be.

## Package design discipline

These are the recurring design mistakes that have to be caught at the design step, not at code review:

- **No shared-singleton accessors.** `static let standard` / `shared` / `default` on a package type that holds runtime state is a singleton-by-another-name. Construct the package type at the app's startup site and inject it. `static let` is fine for *declarations* — identifiers, schema entries, enum cases — but not for behavior.
- **No namespace-enums.** `enum Foo { static func bar() }` (a no-case enum used as a namespace) is a fake namespace that fights the rest of the design (no instances, no DI, no test seam). Prefer a value-typed struct passed via constructor when the helper might gain configuration, or a file-scope `private func` for pure helpers internal to one file.
- **No parallel hand-maintained registries.** When a list mirrors a set of declared items (e.g. `catalog.all` mirroring the catalog's stored properties), derive the list via `Mirror` reflection or a macro. Two sources of truth drift silently; the IDE doesn't tell you.
- **Prefer compile-time invariants to runtime traps.** If the pattern is `guard ... else { assertionFailure(...); return default }` for a "programmer error" case, encode it in the type system (phantom types, separate concrete flavors). Runtime traps become silent fallbacks in release builds.
- **File-scope `private func` is not `private static func`.** `private static` inside a type signals "this operation belongs to the type." File-scope `private func` signals "pure helper local to this file, unrelated to any type." Use the latter for recursive helpers that take a sliced view as input and never read host-type state.
- **Nested types still count for the one-major-type-per-file rule.** A `private final class WatcherAttachment` inside `JSONConfigFileWatcher.swift` is a major type. Move it to its own file the moment it has a meaningful body.

## Testability

Every public type added to `Packages/` must be **testable from a test target** without launching the app target, without booting AppKit, and without depending on the user's filesystem or `UserDefaults.standard`. Production-grade designs surface a test seam at every boundary:

- **No global state in package code.** Every public type that needs `UserDefaults`, `FileManager`, an on-disk path, an environment variable, or a clock takes it via initializer parameter. Tests pass a `UserDefaults(suiteName:)` scoped to the test, a temp directory URL, a fixed `Date`, etc.
- **No reliance on `.shared` / `.standard`.** A public type that hardcodes `UserDefaults.standard` or `FileManager.default` inside its implementation cannot be tested without polluting the developer's actual settings. Inject these at the seam.
- **Public APIs return values, not side effects, where possible.** A function that mutates global UserDefaults and returns `Void` is harder to test than one that returns the changed value and lets the caller persist. Prefer pure transformations + thin imperative layers.
- **Asynchronous APIs surface their observation as `AsyncStream`.** Tests can iterate `AsyncStream` deterministically and assert the sequence of yielded values. Avoid `NotificationCenter`-only patterns where the test has to spin a runloop.

Copy link
Copy Markdown

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
-Avoid `NotificationCenter`-only patterns where the test has to spin a runloop.
+Avoid `NotificationCenter`-only patterns where the test has to spin a run loop.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- **Asynchronous APIs surface their observation as `AsyncStream`.** Tests can iterate `AsyncStream` deterministically and assert the sequence of yielded values. Avoid `NotificationCenter`-only patterns where the test has to spin a runloop.
- **Asynchronous APIs surface their observation as `AsyncStream`.** Tests can iterate `AsyncStream` deterministically and assert the sequence of yielded values. Avoid `NotificationCenter`-only patterns where the test has to spin a run loop.
🧰 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` at line 302, Typo: change the single-word "runloop" to the
conventional two-word spelling "run loop" in the sentence mentioning AsyncStream
and NotificationCenter; update the occurrence near the phrase "Avoid
`NotificationCenter`-only patterns where the test has to spin a runloop" to read
"spin a run loop", referencing the terms `AsyncStream` and `NotificationCenter`
so it's easy to locate.

- **Document the test pattern** alongside any non-trivial public surface. The package's `README.md` and any DocC catalog should show how to instantiate the type with test-friendly dependencies.

If a design is hard to test, it is wrong. Reach for the constructor parameter list, not the test bench.

## Modern Swift concurrency

All new code in `Packages/` and any new files added to the app target use Swift 6 concurrency primitives: `actor`, `async`/`await`, `AsyncStream`/`AsyncSequence`, `@Observable`, `@MainActor`. Old primitives — locks, manual KVO, `@Published`, completion handlers, `DispatchQueue` used as a serial lock — are not allowed.

If you find yourself reaching for a lock, the type is the wrong shape. Promote it to an `actor`.

**Forbidden in new code (no exceptions without a written justification in the PR description):**

- **Locks.** `NSLock`, `NSRecursiveLock`, `os_unfair_lock`, `OSAllocatedUnfairLock`, `pthread_mutex_t`, `Synchronization.Mutex`, `DispatchSemaphore` used as a lock. Use `actor` isolation. Mutable shared state belongs in an actor; reads and writes are `async`.
- **KVO via `NSObject` subclassing.** Any `class Foo: NSObject` whose purpose is to override `observeValue(forKeyPath:...)` or call `addObserver(_:forKeyPath:...)`. Replace with `NotificationCenter.default.notifications(named:)` `AsyncSequence`, or the `NSKeyValueObservation` token API at the seam only.
- **`DispatchQueue` used as a synchronization primitive.** A `DispatchQueue(label:)` accessed via `queue.sync { ... }` to serialize mutable state is a lock with different syntax. Use an `actor`. Queues are fine for *event delivery* (e.g. a `DispatchSource` handler), not for protecting state.
- **Combine for change propagation.** No `@Published`, no `ObservableObject`, no `PassthroughSubject`/`CurrentValueSubject`, no `AnyCancellable` for change observation. Use `@Observable` (Observation framework, Swift 5.9+) for SwiftUI state, or `AsyncStream`/`AsyncSequence` for cross-actor change propagation.
- **Completion-handler APIs.** Authoring a new public API with a `(Result<T, Error>) -> Void` or `(T?, Error?) -> Void` callback is forbidden. Use `async throws -> T`. When wrapping a legacy callback at the boundary, use `withCheckedContinuation`/`withCheckedThrowingContinuation` and keep it confined to that one seam.
- **`DispatchQueue.main.async { ... }`.** Annotate the destination with `@MainActor`. Call sites either `await` the main-isolated function or are themselves `@MainActor`.
- **`Task.sleep` in app/runtime code.** Already forbidden by the cmuxterm-hq top-level rule against sleep-based timing. Allowed in tests only.

**Required shape:**

- Mutable shared state → `actor`. Reads/writes/reset are `async`. Observers receive `AsyncStream` returned by the actor.
- SwiftUI view-render-friendly state → `@Observable @MainActor` view-model that subscribes to the actor's `AsyncStream` and projects snapshots. Don't read actor state synchronously from view code.
- Cross-process / cross-thread invariants → expressed via actor isolation, not via locks or queues.
- New public observable surfaces → `AsyncStream` or `AsyncSequence`. Not callbacks, not `@Published`, not raw `NotificationCenter` subscription.

**Acceptable with a one-line justification comment on the declaration:**

These low-level primitives have no async-native replacement. They must be hidden behind an `AsyncStream` or `actor` surface; callers never see them.

- `DispatchSource.makeFileSystemObjectSource` for file watching (no Foundation async equivalent).
- `DispatchSource.makeReadSource`/`makeWriteSource` for low-level socket I/O.
- `NSKeyValueObservation` token (the closure-based API) when wrapping a Foundation/AppKit type that exposes change only via KVO.

**`@unchecked Sendable` and `nonisolated(unsafe)`:**

Both require a comment on the declaration explaining the safety argument. Examples that pass review:

```swift
// Wraps DispatchSourceFileSystemObject; every mutation happens on `queue`.
private final class WatcherAttachment: @unchecked Sendable { ... }

// UserDefaults is Apple-documented thread-safe; OK to read nonisolated.
private nonisolated(unsafe) let defaults: UserDefaults
```

Without a justification comment, the diff is rejected. `@unchecked Sendable` on an entire actor or struct is almost always wrong; prefer `nonisolated(unsafe) let` on the single non-Sendable property.

**Scope and enforcement:**

- Applies to: every new file in `Packages/`, every new file in the app target, every meaningful rewrite of an existing Swift file.
- Existing app target code may continue to use the old primitives until rewritten. Do not retrofit blindly.
- Code review checklist (Codex, CodeRabbit, Greptile, and human reviewers): reject diffs that introduce `NSLock`/`NSRecursiveLock`/`@Published`/`ObservableObject`/`DispatchQueue.main.async`/`addObserver(_:forKeyPath:...)`/`Task.sleep` outside tests in new code. Reject `@unchecked Sendable` or `nonisolated(unsafe)` without a justification comment.

## Test quality policy

- Do not add tests that only verify source code text, method signatures, AST fragments, or grep-style patterns.
Expand Down
25 changes: 25 additions & 0 deletions Packages/CmuxSettings/Package.swift
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"]
),
]
)
191 changes: 191 additions & 0 deletions Packages/CmuxSettings/README.md
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CmuxSettings/README.md` at line 146, Add blank lines before and
after fenced Swift code blocks in the README to satisfy markdown conventions:
ensure there is an empty line immediately above the opening ```swift fence and
an empty line immediately below the closing ``` fence for the example that
defines FooCatalogSection and the subsequent snippet; update the README so the
FooCatalogSection example (references: FooCatalogSection, SettingCatalogSection,
DefaultsKey) and the follow-up instruction to add `public let foo =
FooCatalogSection()` to SettingCatalog are each surrounded by blank lines around
their fenced code blocks.

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() } }
}
Loading