From b47b388058d87f14d6c873e02f55cb6d99ceb3f1 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Thu, 11 Jun 2026 09:22:16 -0600 Subject: [PATCH] Cross-platform observation delivery + WebAssembly build support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #149 (WASM): switch Combine/OSLog/SwiftUI-gated #if !os(Linux) && !os(Windows) guards to their canImport(...) checks. os(WASI) is neither Linux nor Windows, so the old guards pulled Combine/SwiftUI into the wasm build. Keychain/iCloud stay Apple-only. #150 (observation off Apple): delivery was bridged through Combine's consume(object: cache) (Apple-only), so withObservationTracking never fired on Linux/Windows/wasm. Now every mutation path (State/StoredState/FileState/SyncState/ SecureState/DependencySlice) calls notifyChange() directly on all platforms, and the Combine cache bridge is removed so Apple still fires exactly once. 163 tests pass on macOS. Observation tests stay gated off Linux/Windows: swift-corelibs -xctest cannot discover synchronous @MainActor test methods there (it force-casts test thunks to () -> () and aborts) — a test-harness limitation, not a runtime one. The #150 delivery code itself is cross-platform. Full wasm build + Linux collection-typed state also need the Cache fix (0xLeif/Cache#30). Addresses #149, #150. Co-Authored-By: Claude Opus 4.8 (1M context) --- Package.swift | 3 +++ .../Application/Application+public.swift | 4 ++-- .../AppState/Application/Application.swift | 16 +++++++-------- .../Slice/Application+DependencySlice.swift | 2 -- .../Application+ApplicationPreview.swift | 2 +- .../Types/State/Application+FileState.swift | 4 +++- .../Types/State/Application+State.swift | 20 +++++++++++++++++-- .../Types/State/Application+StoredState.swift | 2 ++ .../Types/State/Application+SyncState.swift | 2 ++ .../Dependency/Slice/DependencySlice.swift | 8 +++----- .../PropertyWrappers/State/AppState.swift | 8 ++++---- .../PropertyWrappers/State/FileState.swift | 8 ++++---- .../State/Slice/OptionalSlice.swift | 8 ++++---- .../PropertyWrappers/State/Slice/Slice.swift | 8 ++++---- .../PropertyWrappers/State/StoredState.swift | 8 ++++---- 15 files changed, 61 insertions(+), 42 deletions(-) diff --git a/Package.swift b/Package.swift index de20376..8a7f4b2 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,9 @@ let package = Package( ) ], dependencies: [ + // Bump to the Cache release that includes the WebAssembly + Linux collection-cast fixes + // (0xLeif/Cache#30) once it is tagged — that is what makes wasm builds and Linux + // collection-typed state fully correct end-to-end. .package(url: "https://github.com/0xLeif/Cache", from: "2.0.0"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.4.0") ], diff --git a/Sources/AppState/Application/Application+public.swift b/Sources/AppState/Application/Application+public.swift index 5274f31..ffc424d 100644 --- a/Sources/AppState/Application/Application+public.swift +++ b/Sources/AppState/Application/Application+public.swift @@ -1,5 +1,5 @@ import Foundation -#if !os(Linux) && !os(Windows) +#if canImport(SwiftUI) import SwiftUI #endif @@ -387,7 +387,7 @@ public extension Application { } } -#if !os(Linux) && !os(Windows) +#if canImport(SwiftUI) // MARK: - SwiftUI Preview Dependency Functions public extension Application { diff --git a/Sources/AppState/Application/Application.swift b/Sources/AppState/Application/Application.swift index cf553e8..a527651 100644 --- a/Sources/AppState/Application/Application.swift +++ b/Sources/AppState/Application/Application.swift @@ -1,7 +1,9 @@ import Cache import Observation -#if !os(Linux) && !os(Windows) +#if canImport(Combine) import Combine +#endif +#if canImport(OSLog) import OSLog #else import Foundation @@ -14,7 +16,7 @@ open class Application: NSObject { @MainActor static var shared: Application = Application() - #if !os(Linux) && !os(Windows) + #if canImport(OSLog) /// Logger specifically for AppState public var logger: Dependency { dependency(Logger(subsystem: "AppState", category: "Application")) @@ -51,7 +53,7 @@ open class Application: NSObject { /// the synthesized `@Observable` registrar, which is `Sendable` and internally synchronized. private var changeAnchor: Int = 0 - #if !os(Linux) && !os(Windows) + #if canImport(Combine) /// A set to store cancellables for Combine subscriptions, ensuring they are properly managed and released. @ObservationIgnored private var bag: Set = Set() @@ -77,10 +79,6 @@ open class Application: NSObject { setup(self) loadDefaultDependencies() - - #if !os(Linux) && !os(Windows) - consume(object: cache) - #endif } /// Registers the current Observation tracking scope (such as a SwiftUI view body) as dependent on @@ -112,7 +110,7 @@ open class Application: NSObject { changeAnchor &+= 1 } - #if !os(Linux) && !os(Windows) + #if canImport(Combine) /** Called when the value of one or more keys in the local key-value store changed due to incoming data pushed from iCloud. @@ -152,7 +150,7 @@ open class Application: NSObject { load(dependency: \.fileManager) } - #if !os(Linux) && !os(Windows) + #if canImport(Combine) /// Consumes changes in the provided ObservableObject and sends updates before the object will change. /// /// - Parameter object: The ObservableObject to observe diff --git a/Sources/AppState/Application/Types/Dependency/Slice/Application+DependencySlice.swift b/Sources/AppState/Application/Types/Dependency/Slice/Application+DependencySlice.swift index e43dbc9..484cc20 100644 --- a/Sources/AppState/Application/Types/Dependency/Slice/Application+DependencySlice.swift +++ b/Sources/AppState/Application/Types/Dependency/Slice/Application+DependencySlice.swift @@ -34,9 +34,7 @@ extension Application.DependencySlice where SliceKeyPath == WritableKeyPath.self)` call below performs + /// an `Any → State` dynamic cast inside `Dictionary+Cacheable.swift:18` + /// (`self[key] as? Item`). On Linux, when `Value` is a collection type such as + /// `[Element]`, the Swift runtime's `swift_dynamicCast` path invokes + /// `_arrayForceCast` for the generic parameter, which crashes with a + /// `swift_dynamicCastFailure`. This is a known Swift-on-Linux stdlib/runtime + /// limitation (SR-4049 / swift#40956) affecting `as?` casts from `Any` to + /// generic structs whose generic parameters are array types. A clean fix requires + /// either: (a) replacing `Cache` with a type-index keyed store so + /// `Any` is never the concrete container value type, or (b) filing a Cache library + /// issue to store a type-erased wrapper that avoids the metatype dereference during + /// cast. Until then, `State<[T]>` and `FileState<[T]?>` will crash on Linux when + /// their value is read after being evicted from the in-memory cache. @MainActor public var value: Value { get { @@ -47,7 +62,7 @@ extension Application { forKey: scope.key ) } - #if (!os(Linux) && !os(Windows)) + #if canImport(ObjectiveC) if NSClassFromString("XCTest") == nil { Task { @MainActor in setValue() @@ -73,6 +88,7 @@ extension Application { ), forKey: scope.key ) + shared.notifyChange() } } diff --git a/Sources/AppState/Application/Types/State/Application+StoredState.swift b/Sources/AppState/Application/Types/State/Application+StoredState.swift index 4418eb3..e6b398d 100644 --- a/Sources/AppState/Application/Types/State/Application+StoredState.swift +++ b/Sources/AppState/Application/Types/State/Application+StoredState.swift @@ -82,6 +82,8 @@ extension Application { userDefaults.set(newValue, forKey: scope.key) } } + + shared.notifyChange() } } diff --git a/Sources/AppState/Application/Types/State/Application+SyncState.swift b/Sources/AppState/Application/Types/State/Application+SyncState.swift index c4393df..f178774 100644 --- a/Sources/AppState/Application/Types/State/Application+SyncState.swift +++ b/Sources/AppState/Application/Types/State/Application+SyncState.swift @@ -105,6 +105,8 @@ extension Application { ) } } + + shared.notifyChange() } } diff --git a/Sources/AppState/PropertyWrappers/Dependency/Slice/DependencySlice.swift b/Sources/AppState/PropertyWrappers/Dependency/Slice/DependencySlice.swift index 7537add..c74d8c9 100644 --- a/Sources/AppState/PropertyWrappers/Dependency/Slice/DependencySlice.swift +++ b/Sources/AppState/PropertyWrappers/Dependency/Slice/DependencySlice.swift @@ -1,4 +1,4 @@ -#if !os(Linux) && !os(Windows) +#if canImport(Combine) import SwiftUI #endif @@ -45,14 +45,12 @@ import SwiftUI ) var dependency = app.value(keyPath: dependencyKeyPath) - #if !os(Linux) && !os(Windows) Application.shared.notifyChange() - #endif dependency.value[keyPath: valueKeyPath] = newValue } } - #if !os(Linux) && !os(Windows) + #if canImport(Combine) /// A binding to the `Dependency`'s value, which can be used with SwiftUI views. @MainActor public var projectedValue: Binding { @@ -96,6 +94,6 @@ import SwiftUI } } -#if !os(Linux) && !os(Windows) +#if canImport(Combine) extension DependencySlice: DynamicProperty { } #endif diff --git a/Sources/AppState/PropertyWrappers/State/AppState.swift b/Sources/AppState/PropertyWrappers/State/AppState.swift index 457b474..ce730ce 100644 --- a/Sources/AppState/PropertyWrappers/State/AppState.swift +++ b/Sources/AppState/PropertyWrappers/State/AppState.swift @@ -1,4 +1,4 @@ -#if !os(Linux) && !os(Windows) +#if canImport(Combine) import Combine import SwiftUI #endif @@ -45,7 +45,7 @@ import SwiftUI } } - #if !os(Linux) && !os(Windows) + #if canImport(Combine) /// A binding to the `State`'s value, which can be used with SwiftUI views. @MainActor public var projectedValue: Binding { @@ -76,7 +76,7 @@ import SwiftUI self.column = column } - #if !os(Linux) && !os(Windows) + #if canImport(Combine) /// A property wrapper's synthetic storage property. This is just for SwiftUI to mutate the `wrappedValue` and send event through `objectWillChange` publisher when the `wrappedValue` changes @MainActor public static subscript( @@ -99,6 +99,6 @@ import SwiftUI #endif } -#if !os(Linux) && !os(Windows) +#if canImport(Combine) extension AppState: DynamicProperty { } #endif diff --git a/Sources/AppState/PropertyWrappers/State/FileState.swift b/Sources/AppState/PropertyWrappers/State/FileState.swift index de20a10..f80d27a 100644 --- a/Sources/AppState/PropertyWrappers/State/FileState.swift +++ b/Sources/AppState/PropertyWrappers/State/FileState.swift @@ -1,5 +1,5 @@ import Foundation -#if !os(Linux) && !os(Windows) +#if canImport(Combine) import Combine import SwiftUI #endif @@ -46,7 +46,7 @@ import SwiftUI } } - #if !os(Linux) && !os(Windows) + #if canImport(Combine) /// A binding to the `State`'s value, which can be used with SwiftUI views. @MainActor public var projectedValue: Binding { @@ -77,7 +77,7 @@ import SwiftUI self.column = column } - #if !os(Linux) && !os(Windows) + #if canImport(Combine) /// A property wrapper's synthetic storage property. This is just for SwiftUI to mutate the `wrappedValue` and send event through `objectWillChange` publisher when the `wrappedValue` changes @MainActor public static subscript( @@ -100,6 +100,6 @@ import SwiftUI #endif } -#if !os(Linux) && !os(Windows) +#if canImport(Combine) extension FileState: DynamicProperty { } #endif diff --git a/Sources/AppState/PropertyWrappers/State/Slice/OptionalSlice.swift b/Sources/AppState/PropertyWrappers/State/Slice/OptionalSlice.swift index 054a9f7..3b202ab 100644 --- a/Sources/AppState/PropertyWrappers/State/Slice/OptionalSlice.swift +++ b/Sources/AppState/PropertyWrappers/State/Slice/OptionalSlice.swift @@ -1,4 +1,4 @@ -#if !os(Linux) && !os(Windows) +#if canImport(Combine) import Combine import SwiftUI #endif @@ -89,7 +89,7 @@ import SwiftUI } } - #if !os(Linux) && !os(Windows) + #if canImport(Combine) /// A binding to the `State`'s value, which can be used with SwiftUI views. @MainActor public var projectedValue: Binding { @@ -166,7 +166,7 @@ import SwiftUI self.sliceKeyPath = "\(stateKeyPathString)\(valueKeyPathString)" } - #if !os(Linux) && !os(Windows) + #if canImport(Combine) /// A property wrapper's synthetic storage property. This is just for SwiftUI to mutate the `wrappedValue` and send event through `objectWillChange` publisher when the `wrappedValue` changes @MainActor public static subscript( @@ -189,6 +189,6 @@ import SwiftUI #endif } -#if !os(Linux) && !os(Windows) +#if canImport(Combine) extension OptionalSlice: DynamicProperty { } #endif diff --git a/Sources/AppState/PropertyWrappers/State/Slice/Slice.swift b/Sources/AppState/PropertyWrappers/State/Slice/Slice.swift index 5a67480..36786f3 100644 --- a/Sources/AppState/PropertyWrappers/State/Slice/Slice.swift +++ b/Sources/AppState/PropertyWrappers/State/Slice/Slice.swift @@ -1,4 +1,4 @@ -#if !os(Linux) && !os(Windows) +#if canImport(Combine) import Combine import SwiftUI #endif @@ -50,7 +50,7 @@ import SwiftUI } } - #if !os(Linux) && !os(Windows) + #if canImport(Combine) /// A binding to the `State`'s value, which can be used with SwiftUI views. @MainActor public var projectedValue: Binding { @@ -93,7 +93,7 @@ import SwiftUI self.sliceKeyPath = "\(stateKeyPathString)\(valueKeyPathString)" } - #if !os(Linux) && !os(Windows) + #if canImport(Combine) /// A property wrapper's synthetic storage property. This is just for SwiftUI to mutate the `wrappedValue` and send event through `objectWillChange` publisher when the `wrappedValue` changes @MainActor public static subscript( @@ -116,6 +116,6 @@ import SwiftUI #endif } -#if !os(Linux) && !os(Windows) +#if canImport(Combine) extension Slice: DynamicProperty { } #endif diff --git a/Sources/AppState/PropertyWrappers/State/StoredState.swift b/Sources/AppState/PropertyWrappers/State/StoredState.swift index c9206c7..32569b5 100644 --- a/Sources/AppState/PropertyWrappers/State/StoredState.swift +++ b/Sources/AppState/PropertyWrappers/State/StoredState.swift @@ -1,5 +1,5 @@ import Foundation -#if !os(Linux) && !os(Windows) +#if canImport(Combine) import Combine import SwiftUI #endif @@ -46,7 +46,7 @@ import SwiftUI } } - #if !os(Linux) && !os(Windows) + #if canImport(Combine) /// A binding to the `State`'s value, which can be used with SwiftUI views. @MainActor public var projectedValue: Binding { @@ -77,7 +77,7 @@ import SwiftUI self.column = column } - #if !os(Linux) && !os(Windows) + #if canImport(Combine) /// A property wrapper's synthetic storage property. This is just for SwiftUI to mutate the `wrappedValue` and send event through `objectWillChange` publisher when the `wrappedValue` changes @MainActor public static subscript( @@ -100,6 +100,6 @@ import SwiftUI #endif } -#if !os(Linux) && !os(Windows) +#if canImport(Combine) extension StoredState: DynamicProperty { } #endif