Skip to content
Merged

3.0.0 #148

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
4fdbf41
Add SwiftData integration (ModelContainer dependency + ModelState)
claude Jun 9, 2026
549c527
Raise platform floors to iOS 17 / macOS 14 and pin Swift 6 language mode
claude Jun 9, 2026
f5c8719
CI: treat warnings as errors to enforce strict Swift 6 cleanliness
claude Jun 9, 2026
acb5c0d
Adopt explicit existentials and enable the ExistentialAny upcoming fe…
claude Jun 9, 2026
79688e3
Migrate reactivity from ObservableObject to the Observation framework
claude Jun 9, 2026
4ee984a
Fix Observation migration leftovers on Apple platforms
claude Jun 9, 2026
a3fbb09
Docs: add AppState 3.0 upgrade guide
claude Jun 9, 2026
21d7fbf
Docs: translate v3 guides and propagate notifyChange across all langu…
claude Jun 9, 2026
bab8ef9
Add Observation reactivity test for the @Observable bridge
claude Jun 9, 2026
f69fbfb
Add fledge task runner and spec-sync setup (application + swiftdata s…
claude Jun 9, 2026
72b2f6c
spec-sync: author the property-wrappers spec for v3
claude Jun 9, 2026
29c889e
Polish: align ModelState logging and doc wording with house style
claude Jun 9, 2026
db88f9a
Address review: ModelState API, observation isolation, scoped strict CI
claude Jun 9, 2026
e6f50f0
Keep observation anchor nonisolated; document the concurrency invariant
claude Jun 9, 2026
9104788
Docs/specs: sync ModelState references to read-only models / deleteAll()
claude Jun 9, 2026
faf5e33
Docs: justify the @Observable migration; final spec sync
claude Jun 9, 2026
50465e6
Merge pull request #1 from corvid-agent/claude/swiftdata-appstate-int…
corvid-agent Jun 9, 2026
060c649
Fix: address PR #147 review findings
0xLeif Jun 9, 2026
4dc85a7
Add: six SwiftUI example packages + Examples CI
0xLeif Jun 9, 2026
811bc75
Test: drive example SwiftUI to 100% coverage with ViewInspector
0xLeif Jun 10, 2026
7087ae2
Test: expand SwiftData + Observation (3.0.0) test coverage
0xLeif Jun 10, 2026
1f0ce52
Add: runnable demo app, advanced SwiftData example, adversarial test …
0xLeif Jun 10, 2026
31532a3
Fix: correctness bugs surfaced by the adversarial suite
0xLeif Jun 10, 2026
23ad72a
Test: add XCUITest UI suite for the demo app
0xLeif Jun 10, 2026
df0ac7c
Add: non-blocking background SwiftData + responsive stress UI
0xLeif Jun 10, 2026
2a93664
CI: fix cross-platform + SDK-version build failures
0xLeif Jun 10, 2026
83c5bd0
CI: guard adversarial suite to Apple platforms; fix toolchain mismatch
0xLeif Jun 10, 2026
330cb7c
Refactor: make Keychain checked-Sendable (drop @unchecked)
0xLeif Jun 10, 2026
40f854e
Remove examples and ViewInspector to keep 3.0.0 a lean library
0xLeif Jun 10, 2026
a8477fc
Docs + observation: finalize 3.0 docs; single observation path for State
0xLeif Jun 11, 2026
cbf5841
Docs: regenerate de/es/fr/hi/pt/ru/zh-CN translations to match the 3.…
0xLeif Jun 11, 2026
2cfca62
CI: fix DocC Pages deploy to use latest-stable Xcode on macos-15
0xLeif Jun 11, 2026
bce4628
Address #148 review: ModelState strict throwing API + main-thread dep…
0xLeif Jun 11, 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
9 changes: 2 additions & 7 deletions .github/workflows/docc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
environment:
name: github-pages
url: '${{ steps.deployment.outputs.page_url }}'
runs-on: macos-latest
runs-on: macos-15

steps:
- name: Checkout code
Expand All @@ -27,12 +27,7 @@ jobs:
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: 16.0

- name: Set up Swift
uses: swift-actions/setup-swift@v2
with:
swift-version: '6.1.0'
xcode-version: latest-stable

- name: Build and Export DocC
run: |
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/macOS.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ on:

jobs:
build:
runs-on: macos-latest
runs-on: macos-15
steps:
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: 16.0
- name: Set up Swift
uses: swift-actions/setup-swift@v2
with:
swift-version: '6.1.0'
xcode-version: latest-stable
- uses: actions/checkout@v4
- name: Build
run: swift build -v
env:
APPSTATE_STRICT: "1"
- name: Run tests
run: swift test -v
env:
APPSTATE_STRICT: "1"
4 changes: 4 additions & 0 deletions .github/workflows/ubuntu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,9 @@ jobs:
- uses: actions/checkout@v4
- name: Build for release
run: swift build -v -c release
env:
APPSTATE_STRICT: "1"
- name: Test
run: swift test -v
env:
APPSTATE_STRICT: "1"
10 changes: 8 additions & 2 deletions .github/workflows/windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,11 @@ jobs:

# ② Build & test
- run: swift --version # sanity-check
- run: swift build
- run: swift test
- name: Build
run: swift build
env:
APPSTATE_STRICT: "1"
- name: Test
run: swift test
env:
APPSTATE_STRICT: "1"
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
.DS_Store
/.build
.build/
/Packages
xcuserdata/
DerivedData/
.swiftpm/
.netrc
Package.resolved
Package.resolved
# xcodegen-generated demo project + derived data
Examples/DemoApp/*.xcodeproj/
Examples/DemoApp/.build-dd/
3 changes: 3 additions & 0 deletions .specsync/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
backup-3x/
config.local.toml
hashes.json
12 changes: 12 additions & 0 deletions .specsync/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# spec-sync v4 configuration
# Docs: https://github.com/CorvidLabs/spec-sync

specs_dir = "specs"
source_dirs = ["Sources"]
exclude_dirs = []
exclude_patterns = []
required_sections = ["Purpose", "Public API", "Invariants", "Behavioral Examples", "Error Cases", "Dependencies", "Change Log"]
enforcement = "strict"

[lifecycle]
track_history = false
7 changes: 7 additions & 0 deletions .specsync/registry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[registry]
name = "AppState"

[specs]
application = "specs/application/application.spec.md"
property-wrappers = "specs/property-wrappers/property-wrappers.spec.md"
swiftdata = "specs/swiftdata/swiftdata.spec.md"
1 change: 1 addition & 0 deletions .specsync/version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
4.3.1
27 changes: 20 additions & 7 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
// swift-tools-version: 6.0

import Foundation
import PackageDescription

// Opt-in strict build, used by CI only. Treats warnings as errors for *our* targets without
// forcing the flag onto dependencies (e.g. Cache) or onto downstream consumers — `unsafeFlags`
// would otherwise make AppState unusable as a dependency. Enabled when `APPSTATE_STRICT` is set.
let strictSwiftSettings: [SwiftSetting] = ProcessInfo.processInfo.environment["APPSTATE_STRICT"] != nil
? [.unsafeFlags(["-warnings-as-errors"])]
: []

let package = Package(
name: "AppState",
platforms: [
.iOS(.v15),
.watchOS(.v8),
.macOS(.v11),
.tvOS(.v15),
.iOS(.v17),
.watchOS(.v10),
.macOS(.v14),
.tvOS(.v17),
.visionOS(.v1)
],
products: [
Expand All @@ -26,11 +34,16 @@ let package = Package(
name: "AppState",
dependencies: [
"Cache"
]
],
swiftSettings: [
.enableUpcomingFeature("ExistentialAny")
] + strictSwiftSettings
),
.testTarget(
name: "AppStateTests",
dependencies: ["AppState"]
dependencies: ["AppState"],
swiftSettings: strictSwiftSettings
)
]
],
swiftLanguageModes: [.v6]
)
25 changes: 11 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@

Read this in other languages: [French](documentation/README.fr.md) | [German](documentation/README.de.md) | [Hindi](documentation/README.hi.md) | [Portuguese](documentation/README.pt.md) | [Russian](documentation/README.ru.md) | [Simplified Chinese](documentation/README.zh-CN.md) | [Spanish](documentation/README.es.md)

**AppState** is a Swift 6 library designed to simplify the management of application state in a thread-safe, type-safe, and SwiftUI-friendly way. It provides a set of tools to centralize and synchronize state across your application, as well as inject dependencies into various parts of your app.
**AppState** is a Swift 6 library for managing application state in a thread-safe, type-safe, and SwiftUI-friendly way. Centralize and synchronize state across your app; inject dependencies anywhere.

## Requirements

- **iOS**: 15.0+
- **watchOS**: 8.0+
- **macOS**: 11.0+
- **tvOS**: 15.0+
- **iOS**: 17.0+
- **watchOS**: 10.0+
- **macOS**: 14.0+
- **tvOS**: 17.0+
- **visionOS**: 1.0+
- **Swift**: 6.0+
- **Xcode**: 16.0+
Expand All @@ -26,11 +26,12 @@ Read this in other languages: [French](documentation/README.fr.md) | [German](do

## Key Features

**AppState** includes several powerful features to help manage state and dependencies:
**AppState** includes:

- **State**: Centralized state management that allows you to encapsulate and broadcast changes across the app.
- **StoredState**: Persistent state using `UserDefaults`, ideal for saving small amounts of data between app launches.
- **FileState**: Persistent state stored using `FileManager`, useful for storing larger amounts of data securely on disk.
- 🍎 **SwiftData (ModelState)**: Manage SwiftData `@Model` objects through AppState by injecting a shared `ModelContainer` and reading/writing models with `ModelState`.
- 🍎 **SyncState**: Synchronize state across multiple devices using iCloud, ensuring consistency in user preferences and settings.
- 🍎 **SecureState**: Store sensitive data securely using the Keychain, protecting user information such as tokens or passwords.
- **Dependency Management**: Inject dependencies like network services or database clients across your app for better modularity and testing.
Expand All @@ -40,14 +41,10 @@ Read this in other languages: [French](documentation/README.fr.md) | [German](do

## Getting Started

To integrate **AppState** into your Swift project, you’ll need to use the Swift Package Manager. Follow the [Installation Guide](documentation/en/installation.md) for detailed instructions on setting up **AppState**.

After installation, refer to the [Usage Overview](documentation/en/usage-overview.md) for a quick introduction on how to manage state and inject dependencies into your project.
Add **AppState** via Swift Package Manager — see the [Installation Guide](documentation/en/installation.md). Then check the [Usage Overview](documentation/en/usage-overview.md) for a quick introduction.

## Quick Example

Below is a minimal example showing how to define a piece of state and access it from a SwiftUI view:

```swift
import AppState
import SwiftUI
Expand All @@ -70,8 +67,6 @@ struct ContentView: View {
}
```

This snippet demonstrates defining a state value in an `Application` extension and using the `@AppState` property wrapper to bind it inside a view.

## Documentation

Here’s a detailed breakdown of **AppState**'s documentation:
Expand All @@ -85,8 +80,10 @@ Here’s a detailed breakdown of **AppState**'s documentation:
- [Slicing State](documentation/en/usage-slice.md): Access and modify specific parts of the state.
- [StoredState Usage Guide](documentation/en/usage-storedstate.md): How to persist lightweight data using `StoredState`.
- [FileState Usage Guide](documentation/en/usage-filestate.md): Learn how to persist larger amounts of data securely on disk.
- 🍎 [ModelState Usage Guide](documentation/en/usage-modelstate.md): Manage SwiftData `@Model` objects through a shared `ModelContainer`.
- [Keychain SecureState Usage](documentation/en/usage-securestate.md): Store sensitive data securely using the Keychain.
- [iCloud Syncing with SyncState](documentation/en/usage-syncstate.md): Keep state synchronized across devices using iCloud.
- [Upgrading to AppState 3.0](documentation/en/upgrade-to-v3.md): Breaking changes and how to migrate from the 2.x release line.
- [FAQ](documentation/en/faq.md): Answers to common questions when using **AppState**.
- [Constant Usage Guide](documentation/en/usage-constant.md): Access read-only values from your state.
- [ObservedDependency Usage Guide](documentation/en/usage-observeddependency.md): Work with `ObservableObject` dependencies in your views.
Expand All @@ -100,4 +97,4 @@ We welcome contributions! Please check out our [Contributing Guide](documentatio

## Next Steps

With **AppState** installed, you can start exploring its key features by checking out the [Usage Overview](documentation/en/usage-overview.md) and more detailed guides. Get started with managing state and dependencies effectively in your Swift projects! For more advanced usage techniques, like Just-In-Time creation and preloading dependencies, see the [Advanced Usage Guide](documentation/en/advanced-usage.md). You can also review the [Constant](documentation/en/usage-constant.md) and [ObservedDependency](documentation/en/usage-observeddependency.md) guides for additional features.
Start with the [Usage Overview](documentation/en/usage-overview.md). For Just-In-Time creation and preloading, see the [Advanced Usage Guide](documentation/en/advanced-usage.md). The [Constant](documentation/en/usage-constant.md) and [ObservedDependency](documentation/en/usage-observeddependency.md) guides cover additional features.
4 changes: 2 additions & 2 deletions Sources/AppState/Application/Application+internal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ extension Application {
static var cacheDescription: String {
shared.cache.allValues
.map { key, value in
if let value = value as? Loggable {
if let value = value as? any Loggable {
"\t- \(value.logValue)"
} else {
"\t- \(value)"
Expand Down Expand Up @@ -81,7 +81,7 @@ extension Application {
/// Internal log function.
@MainActor
static func log(
error: Error,
error: any Error,
message: String,
fileID: StaticString,
function: StaticString,
Expand Down
7 changes: 6 additions & 1 deletion Sources/AppState/Application/Application+public.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public extension Application {

// Example updating all SyncState in SwiftUI Views.
DispatchQueue.main.async {
self.objectWillChange.send()
self.notifyChange()
}
}
}
Expand Down Expand Up @@ -469,6 +469,11 @@ public extension Application {
_ line: Int = #line,
_ column: Int = #column
) -> ApplicationState where ApplicationState.Value == Value {
// Register the current Observation tracking scope so that reads through this imperative
// accessor (not just through the `@AppState`/`@StoredState`/… property wrappers) drive
// SwiftUI/`withObservationTracking` updates. Outside a tracking scope this is a no-op.
shared.registerObservation()

let appState = shared.value(keyPath: keyPath)

log(
Expand Down
70 changes: 62 additions & 8 deletions Sources/AppState/Application/Application.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Cache
import Observation
#if !os(Linux) && !os(Windows)
import Combine
import OSLog
Expand All @@ -7,6 +8,7 @@ import Foundation
#endif

/// `Application` is a class that can be observed for changes, keeping track of the states within the application.
@Observable
open class Application: NSObject {
/// Singleton shared instance of `Application`
@MainActor
Expand All @@ -28,13 +30,30 @@ open class Application: NSObject {
static var isLoggingEnabled: Bool = false

/// A recursive lock to ensure thread-safe access to shared resources within the Application instance.
@ObservationIgnored
let lock: NSRecursiveLock

/// The underlying cache used to store all state and dependency values.
@ObservationIgnored
let cache: Cache<String, Any>

/// Observation anchor that bridges cache changes to the Observation framework.
///
/// State and dependency values live in the untracked ``cache``. Reading this anchor (via
/// ``registerObservation()``) registers the current Observation tracking scope — e.g. a SwiftUI
/// view body — as dependent on AppState, and mutating it (via ``notifyChange()``) tells those
/// observers to update.
///
/// Thread-safety: every mutation funnels through ``notifyChange()``, which AppState only invokes
/// from its main-actor setters and from the cache observer below — and that observer fires
/// *synchronously* during those same main-actor cache mutations. Reads occur on the main actor
/// (SwiftUI bodies and the `@MainActor` property wrappers). The mutation itself is applied through
/// the synthesized `@Observable` registrar, which is `Sendable` and internally synchronized.
private var changeAnchor: Int = 0

#if !os(Linux) && !os(Windows)
/// A set to store cancellables for Combine subscriptions, ensuring they are properly managed and released.
@ObservationIgnored
private var bag: Set<AnyCancellable> = Set()

deinit { bag.removeAll() }
Expand Down Expand Up @@ -64,6 +83,35 @@ open class Application: NSObject {
#endif
}

/// Registers the current Observation tracking scope (such as a SwiftUI view body) as dependent on
/// AppState. The property wrappers call this when reading their value so that SwiftUI views update
/// when the underlying state or dependencies change.
///
/// The discarded read of ``changeAnchor`` is intentional and load-bearing: the synthesized
/// `@Observable` getter calls the observation registrar's `access`, which is what records the
/// dependency. Do not remove it.
func registerObservation() {
_ = changeAnchor
}

/// Notifies observers (such as SwiftUI views) that an AppState value changed.
///
/// AppState's own setters call this automatically. Call it yourself when you mutate state outside
/// of those setters — for example from a `didChangeExternally(notification:)` override that reacts
/// to incoming iCloud changes. See ``changeAnchor`` for the thread-safety invariant.
///
/// - Important: This must be called on the main thread. SwiftUI tracks `changeAnchor` from view
/// bodies, so publishing a change from a background thread both races the mutation and trips the
/// "Publishing changes from background threads is not supported" runtime warning. `Application` is
/// not `Sendable`, so the change cannot be hopped to the main thread on the caller's behalf — the
/// invariant is instead asserted here so off-main misuse surfaces in debug and CI. `Application`'s
/// setters and the `@MainActor` `didChangeExternally(notification:)` override already satisfy it.
public func notifyChange() {
assert(Thread.isMainThread, "Application.notifyChange() must be called on the main thread.")

changeAnchor &+= 1
}

#if !os(Linux) && !os(Windows)
/**
Called when the value of one or more keys in the local key-value store changed due to incoming data pushed from iCloud.
Expand Down Expand Up @@ -110,22 +158,28 @@ open class Application: NSObject {
/// - Parameter object: The ObservableObject to observe
private func consume<Object: ObservableObject>(
object: Object
) where ObjectWillChangePublisher == ObservableObjectPublisher {
) {
bag.insert(
object.objectWillChange.sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] _ in
self?.objectWillChange.send()
// Deliver synchronously when already on main (preserving the synchronous
// observation contract `withObservationTracking` relies on), and only hop to main
// when a dependency publishes off-main — `notifyChange()` asserts main-thread and
// mutates `changeAnchor`, which must never be touched off the main thread.
if Thread.isMainThread {
self?.notifyChange()
} else {
DispatchQueue.main.async {
MainActor.assumeIsolated {
Application.shared.notifyChange()
}
}
}
}
)
)
}
Comment thread
0xLeif marked this conversation as resolved.
#endif
}

#if !os(Linux) && !os(Windows)
/// Conform `Application` to `ObservableObject` to allow SwiftUI views to subscribe to its changes.
/// This enables the `@ObservedObject` property wrapper to work with `Application.shared` or custom instances,
/// triggering view updates when `objectWillChange` is published.
extension Application: ObservableObject { }
#endif
Loading
Loading