Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
],
Expand Down
4 changes: 2 additions & 2 deletions Sources/AppState/Application/Application+public.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Foundation
#if !os(Linux) && !os(Windows)
#if canImport(SwiftUI)
import SwiftUI
#endif

Expand Down Expand Up @@ -387,7 +387,7 @@ public extension Application {
}
}

#if !os(Linux) && !os(Windows)
#if canImport(SwiftUI)
// MARK: - SwiftUI Preview Dependency Functions

public extension Application {
Expand Down
16 changes: 7 additions & 9 deletions Sources/AppState/Application/Application.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Logger> {
dependency(Logger(subsystem: "AppState", category: "Application"))
Expand Down Expand Up @@ -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<AnyCancellable> = Set()
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,7 @@ extension Application.DependencySlice where SliceKeyPath == WritableKeyPath<Valu
public var value: SliceValue {
get { dependency.value[keyPath: keyPath] }
set {
#if !os(Linux) && !os(Windows)
Application.shared.notifyChange()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since Application.DependencySlice is not restricted to @MainActor, its setter can be invoked from background threads (e.g., when a background service updates its state). Calling Application.shared.notifyChange() directly on a background thread will trigger the assertion assert(Thread.isMainThread) inside notifyChange() and crash the application.

To prevent this, we should ensure notifyChange() is always dispatched to the main thread.

Alternative: A cleaner approach would be to make Application.notifyChange() itself thread-safe by internally checking Thread.isMainThread and dispatching to DispatchQueue.main if necessary, which would eliminate the need for these checks at every call site.

            if Thread.isMainThread {
                Application.shared.notifyChange()
            } else {
                DispatchQueue.main.async {
                    Application.shared.notifyChange()
                }
            }

#endif
dependency.value[keyPath: keyPath] = newValue
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#if !os(Linux) && !os(Windows)
#if canImport(SwiftUI)
import SwiftUI

extension Application {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ extension Application {
)
}

#if (!os(Linux) && !os(Windows))
#if canImport(ObjectiveC)
if NSClassFromString("XCTest") == nil {
Task {
await MainActor.run {
Expand Down Expand Up @@ -149,6 +149,8 @@ extension Application {
)
}
}

shared.notifyChange()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since FileState.value is not restricted to @MainActor, its setter can be invoked from background threads. Calling shared.notifyChange() directly on a background thread will trigger the assertion assert(Thread.isMainThread) inside notifyChange() and crash the application.

To prevent this, we should ensure notifyChange() is always dispatched to the main thread.

Alternative: A cleaner approach would be to make Application.notifyChange() itself thread-safe by internally checking Thread.isMainThread and dispatching to DispatchQueue.main if necessary, which would eliminate the need for these checks at every call site.

                if Thread.isMainThread {
                    shared.notifyChange()
                } else {
                    DispatchQueue.main.async {
                        shared.notifyChange()
                    }
                }

}
}

Expand Down
20 changes: 18 additions & 2 deletions Sources/AppState/Application/Types/State/Application+State.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ extension Application {
}

public static var emoji: Character {
#if !os(Linux) && !os(Windows)
#if canImport(OSLog)
return "🔄"
#else
return "📦"
Expand All @@ -27,6 +27,21 @@ extension Application {
private let initial: Value

/// The current state value.
///
/// - Note: **Issue #151 — Linux `_arrayForceCast` crash.**
/// The `shared.cache.get(scope.key, as: State<Value>.self)` call below performs
/// an `Any → State<Value>` 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<String, Any>` 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 {
Expand All @@ -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()
Expand All @@ -73,6 +88,7 @@ extension Application {
),
forKey: scope.key
)
shared.notifyChange()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ extension Application {
userDefaults.set(newValue, forKey: scope.key)
}
}

shared.notifyChange()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since StoredState.value is not restricted to @MainActor, its setter can be invoked from background threads. Calling shared.notifyChange() directly on a background thread will trigger the assertion assert(Thread.isMainThread) inside notifyChange() and crash the application.

To prevent this, we should ensure notifyChange() is always dispatched to the main thread.

Alternative: A cleaner approach would be to make Application.notifyChange() itself thread-safe by internally checking Thread.isMainThread and dispatching to DispatchQueue.main if necessary, which would eliminate the need for these checks at every call site.

                if Thread.isMainThread {
                    shared.notifyChange()
                } else {
                    DispatchQueue.main.async {
                        shared.notifyChange()
                    }
                }

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ extension Application {
)
}
}

shared.notifyChange()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since SyncState.value is not restricted to @MainActor, its setter can be invoked from background threads. Calling shared.notifyChange() directly on a background thread will trigger the assertion assert(Thread.isMainThread) inside notifyChange() and crash the application.

To prevent this, we should ensure notifyChange() is always dispatched to the main thread.

Alternative: A cleaner approach would be to make Application.notifyChange() itself thread-safe by internally checking Thread.isMainThread and dispatching to DispatchQueue.main if necessary, which would eliminate the need for these checks at every call site.

                if Thread.isMainThread {
                    shared.notifyChange()
                } else {
                    DispatchQueue.main.async {
                        shared.notifyChange()
                    }
                }

}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#if !os(Linux) && !os(Windows)
#if canImport(Combine)
import SwiftUI
#endif

Expand Down Expand Up @@ -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<SliceValue> {
Expand Down Expand Up @@ -96,6 +94,6 @@ import SwiftUI
}
}

#if !os(Linux) && !os(Windows)
#if canImport(Combine)
extension DependencySlice: DynamicProperty { }
#endif
8 changes: 4 additions & 4 deletions Sources/AppState/PropertyWrappers/State/AppState.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#if !os(Linux) && !os(Windows)
#if canImport(Combine)
import Combine
import SwiftUI
#endif
Expand Down Expand Up @@ -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<Value> {
Expand Down Expand Up @@ -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<OuterSelf: ObservableObject>(
Expand All @@ -99,6 +99,6 @@ import SwiftUI
#endif
}

#if !os(Linux) && !os(Windows)
#if canImport(Combine)
extension AppState: DynamicProperty { }
#endif
8 changes: 4 additions & 4 deletions Sources/AppState/PropertyWrappers/State/FileState.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Foundation
#if !os(Linux) && !os(Windows)
#if canImport(Combine)
import Combine
import SwiftUI
#endif
Expand Down Expand Up @@ -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<Value> {
Expand Down Expand Up @@ -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<OuterSelf: ObservableObject>(
Expand All @@ -100,6 +100,6 @@ import SwiftUI
#endif
}

#if !os(Linux) && !os(Windows)
#if canImport(Combine)
extension FileState: DynamicProperty { }
#endif
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#if !os(Linux) && !os(Windows)
#if canImport(Combine)
import Combine
import SwiftUI
#endif
Expand Down Expand Up @@ -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<SliceValue?> {
Expand Down Expand Up @@ -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<OuterSelf: ObservableObject>(
Expand All @@ -189,6 +189,6 @@ import SwiftUI
#endif
}

#if !os(Linux) && !os(Windows)
#if canImport(Combine)
extension OptionalSlice: DynamicProperty { }
#endif
8 changes: 4 additions & 4 deletions Sources/AppState/PropertyWrappers/State/Slice/Slice.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#if !os(Linux) && !os(Windows)
#if canImport(Combine)
import Combine
import SwiftUI
#endif
Expand Down Expand Up @@ -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<SliceValue> {
Expand Down Expand Up @@ -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<OuterSelf: ObservableObject>(
Expand All @@ -116,6 +116,6 @@ import SwiftUI
#endif
}

#if !os(Linux) && !os(Windows)
#if canImport(Combine)
extension Slice: DynamicProperty { }
#endif
8 changes: 4 additions & 4 deletions Sources/AppState/PropertyWrappers/State/StoredState.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Foundation
#if !os(Linux) && !os(Windows)
#if canImport(Combine)
import Combine
import SwiftUI
#endif
Expand Down Expand Up @@ -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<Value> {
Expand Down Expand Up @@ -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<OuterSelf: ObservableObject>(
Expand All @@ -100,6 +100,6 @@ import SwiftUI
#endif
}

#if !os(Linux) && !os(Windows)
#if canImport(Combine)
extension StoredState: DynamicProperty { }
#endif
Loading