Skip to content
Merged
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
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
format:
name: Swift Format
runs-on: macos-26
steps:
- uses: actions/checkout@v4

- name: Check Formatting
run: swift format lint --strict --parallel --recursive Sources Tests

build-and-test:
name: ${{ matrix.name }}
runs-on: macos-26
strategy:
fail-fast: false
matrix:
include:
- name: "iOS"
destination: "platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2"
- name: "macOS"
destination: "platform=macOS"
- name: "Mac Catalyst"
destination: "platform=macOS,variant=Mac Catalyst"
- name: "tvOS"
destination: "platform=tvOS Simulator,name=Apple TV,OS=26.2"
- name: "visionOS"
destination: "platform=visionOS Simulator,name=Apple Vision Pro,OS=26.2"
- name: "watchOS"
destination: "platform=watchOS Simulator,name=Apple Watch Series 11 (46mm),OS=26.2"

steps:
- uses: actions/checkout@v4

- name: Build & Test
run: |
xcodebuild ${{ matrix.action }} -scheme ObservationKit-Package -destination '${{ matrix.destination }}'
24 changes: 24 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: release

on:
workflow_dispatch:
inputs:
bump_version_scheme:
type: choice
description: "Bump version scheme"
required: true
default: "patch"
options:
- "patch"
- "minor"
- "major"

jobs:
release-on-push:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: rymndhng/release-on-push-action@master
with:
bump_version_scheme: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'patch' || inputs.bump_version_scheme }}
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
*.log
75 changes: 75 additions & 0 deletions .swift-format
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{
"fileScopedDeclarationPrivacy" : {
"accessLevel" : "private"
},
"indentConditionalCompilationBlocks" : true,
"indentSwitchCaseLabels" : false,
"indentation" : {
"spaces" : 2
},
"lineBreakAroundMultilineExpressionChainComponents" : false,
"lineBreakBeforeControlFlowKeywords" : false,
"lineBreakBeforeEachArgument" : false,
"lineBreakBeforeEachGenericRequirement" : false,
"lineBreakBetweenDeclarationAttributes" : false,
"lineLength" : 100,
"maximumBlankLines" : 1,
"multiElementCollectionTrailingCommas" : true,
"noAssignmentInExpressions" : {
"allowedFunctions" : [
"XCTAssertNoThrow"
]
},
"prioritizeKeepingFunctionOutputTogether" : false,
"reflowMultilineStringLiterals" : "never",
"respectsExistingLineBreaks" : true,
"rules" : {
"AllPublicDeclarationsHaveDocumentation" : false,
"AlwaysUseLiteralForEmptyCollectionInit" : false,
"AlwaysUseLowerCamelCase" : true,
"AmbiguousTrailingClosureOverload" : true,
"AvoidRetroactiveConformances" : true,
"BeginDocumentationCommentWithOneLineSummary" : false,
"DoNotUseSemicolons" : true,
"DontRepeatTypeInStaticProperties" : true,
"FileScopedDeclarationPrivacy" : true,
"FullyIndirectEnum" : true,
"GroupNumericLiterals" : true,
"IdentifiersMustBeASCII" : true,
"NeverForceUnwrap" : false,
"NeverUseForceTry" : false,
"NeverUseImplicitlyUnwrappedOptionals" : false,
"NoAccessLevelOnExtensionDeclaration" : true,
"NoAssignmentInExpressions" : true,
"NoBlockComments" : true,
"NoCasesWithOnlyFallthrough" : true,
"NoEmptyLinesOpeningClosingBraces" : false,
"NoEmptyTrailingClosureParentheses" : true,
"NoLabelsInCasePatterns" : true,
"NoLeadingUnderscores" : false,
"NoParensAroundConditions" : true,
"NoPlaygroundLiterals" : true,
"NoVoidReturnOnFunctionSignature" : true,
"OmitExplicitReturns" : false,
"OneCasePerLine" : true,
"OneVariableDeclarationPerLine" : true,
"OnlyOneTrailingClosureArgument" : true,
"OrderedImports" : true,
"ReplaceForEachWithForLoop" : true,
"ReturnVoidInsteadOfEmptyTuple" : true,
"TypeNamesShouldBeCapitalized" : true,
"UseEarlyExits" : false,
"UseExplicitNilCheckInConditions" : true,
"UseLetInEveryBoundCaseVariable" : true,
"UseShorthandTypeNames" : true,
"UseSingleLinePropertyGetter" : true,
"UseSynthesizedInitializer" : true,
"UseTripleSlashForDocumentationComments" : true,
"UseWhereClausesInForLoops" : false,
"ValidateDocumentationComments" : false
},
"spacesAroundRangeFormationOperators" : false,
"spacesBeforeEndOfLineComments" : 2,
"tabWidth" : 8,
"version" : 1
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>FILEHEADER</key>
<string>
//
// ___FILENAME___
// ___TARGET___
//
// Copyright (c) ___YEAR___ Jacob Fielding
//</string>
</dict>
</plist>
10 changes: 10 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,13 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

---

Portions of this project are derived from the Swift.org open source project
(https://github.com/swiftlang/swift), which is licensed under the
Apache License, Version 2.0, with Runtime Library Exception.

The full text of the Apache License 2.0 is included in LICENSE-APACHE.
See https://github.com/swiftlang/swift/blob/main/LICENSE.txt for the original license including the
Runtime Library Exception.
42 changes: 42 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 64 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// swift-tools-version: 6.2
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "ObservationKit",
platforms: [
.iOS(.v17),
.macCatalyst(.v17),
.macOS(.v14),
.tvOS(.v17),
.visionOS(.v1),
.watchOS(.v10)
],
products: [
.library(
name: "ObservationTesting",
targets: ["ObservationTesting"]
),
.library(
name: "ObservationShim",
targets: ["ObservationShim"]
),
],
dependencies: [
.package(url: "https://github.com/apple/swift-async-algorithms.git", .upToNextMajor(from: "1.1.2")),
.package(url: "https://github.com/pointfreeco/swift-concurrency-extras.git", .upToNextMajor(from: "1.3.2")),

// Testing
// TODO: Make this conditional
.package(url: "https://github.com/apple/swift-numerics.git", .upToNextMajor(from: "1.1.1"))
],
targets: [
.target(
name: "ObservationTesting",
dependencies: [
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "ConcurrencyExtras", package: "swift-concurrency-extras")
]
),
.target(
name: "ObservationShim"
),

// MARK: Testing

.testTarget(
name: "ObservationTestingTests",
dependencies: [
"ObservationTesting",
.product(name: "Numerics", package: "swift-numerics")
]
),
.testTarget(
name: "ObservationShimTests",
dependencies: [
"ObservationShim",
"ObservationTesting"
]
),
],
swiftLanguageModes: [.v6]
)
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# ObservationKit

A small library that aims to make it possible for me to adopt the Observation framework now, instead of years in the future.

## ObservationShim

This is a copy of SwiftLang's [`Observations.swift`](https://github.com/phausler/ObservationSequence/blob/main/Sources/ObservationSequence/Observations.swift)
with some small tweaks to enable iOS 17+ compatibility. There are endless online discussions and dozens of projects that aim to enable the use of `AsyncSequence`
and modern concurrency based streaming tools in favor of `Combine`. This one clicked thanks to this Swift Forums post
[iOS 18 support for the Observations struct is being dropped before release?](https://forums.swift.org/t/ios-18-support-for-the-observations-struct-is-being-dropped-before-release/81942/5).

> [!IMPORTANT]
> This is not aimed at replacing `Observations`. It's simply a shim you can use to start making real
> use of the Apple Observations framework in favor of a bunch of bridges to other data sources like `Combine`

This example shows how you'd effectively erase the official and shimmed backport into an
`AsyncStream` to use outside of SwiftUI. Note Pointfree Co's [`ConcurrencyExtras`](https://github.com/pointfreeco/swift-concurrency-extras)
was used to allow erasure into a clean `AsyncStream<Element>`.

```swift
func observableStream(
_ emit: @escaping @isolated(any) @Sendable () -> Element
) -> AsyncStream<Element> {
if #available(iOS 26.0, *) {
let official = Observations(emit)
return AsyncStream(official)
} else {
let backport = ObservationsShim(emit)
return AsyncStream(backport)
}
}
```

### References

- @vanvoorden [iOS 18 support for the Observations struct is being dropped before release?](https://forums.swift.org/t/ios-18-support-for-the-observations-struct-is-being-dropped-before-release/81942/5)
- @phausler [iOS 18 support for the Observations struct is being dropped before release?](https://forums.swift.org/t/ios-18-support-for-the-observations-struct-is-being-dropped-before-release/81942/6)
- [`Observations.swift`](https://github.com/phausler/ObservationSequence/blob/main/Sources/ObservationSequence/Observations.swift)

## ObservationTesting

Swift Testing introduced a new paradigm for async confirmation in [Testing asyncronous code](https://developer.apple.com/documentation/testing/testing-asynchronous-code).
The swift tools are totally acceptable, but can be quite verbose and a bit tricky, especially testing outputs from `AsyncSequences`.
The `ObservationTesting` library enables concise unit tests for `AsyncSequences`. This also works really well with `Observations` and `ObservationsShim`.

This includes verifying `Equatable` elements over time.

```swift
@Test("A value is emitted from the stream")
func basic() async throws {
let stream = AsyncStream { continuation in
continuation.yield("cats")
continuation.yield("dogs")
continuation.yield("lizards")
}

try await stream.fulfillment(of: "cats", "dogs", "lizards")
}
```

Or complex conditions that can't easily be represented as expected value(s).

```swift
@Test("A condition is verified on the stream")
func conditionMet() async throws {
let stream = AsyncStream { continuation in
continuation.yield("cats")
}

try await stream.fulfillment { value in
value == "cats"
}
}
```
25 changes: 25 additions & 0 deletions Sources/ObservationShim/ManagedCriticalState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// ManagedCriticalState.swift
// ObservationShim
//
// Copyright (c) 2026 Jacob Fielding
//

import Foundation

final class _ManagedCriticalState<State: Sendable>: @unchecked Sendable {
private let lock = NSRecursiveLock()
private var state: State

init(_ initial: State) {
state = initial
}

func withCriticalRegion<R>(
_ critical: (inout State) throws -> R
) rethrows -> R {
try lock.withLock {
try critical(&state)
}
}
}
Loading