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
12 changes: 12 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,20 @@ jobs:
- name: List available simulators
run: xcrun simctl list devices available

# -parallel-testing-enabled NO runs the Swift Testing suite one test at a
# time. Some ServerEventsTests drive the coordinator's polling and backoff
# through real Task.sleep timers with tiny intervals; under xcodebuild the
# suite otherwise runs in parallel, and on a busy CI runner those timers get
# starved for tens of seconds, tripping the tests' timeout guard. `swift
# test` (the macOS job) already defaults to --no-parallel, which is why only
# the simulator jobs were affected. Serial execution costs a second or two
# here and removes the flakiness entirely.
- name: Run tests on iOS Simulator
run: |
xcodebuild test \
-scheme Rainmaker-Package \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-parallel-testing-enabled NO \
-enableCodeCoverage YES

test-tvos:
Expand All @@ -80,6 +89,7 @@ jobs:
xcodebuild test \
-scheme Rainmaker-Package \
-destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' \
-parallel-testing-enabled NO \
-enableCodeCoverage YES

test-watchos:
Expand All @@ -99,6 +109,7 @@ jobs:
xcodebuild test \
-scheme Rainmaker-Package \
-destination 'platform=watchOS Simulator,name=Apple Watch Series 11 (46mm)' \
-parallel-testing-enabled NO \
-enableCodeCoverage YES

test-visionos:
Expand All @@ -121,4 +132,5 @@ jobs:
xcodebuild test \
-scheme Rainmaker-Package \
-destination 'platform=visionOS Simulator,name=Apple Vision Pro' \
-parallel-testing-enabled NO \
-enableCodeCoverage YES
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ The human readable introduction of this Swift Package is in [README.md](README.m
- `Sources/Rainmaker/Extensions/` is for implementations of extensions of first-party or platform types. One source code file per extended type and added feature.
- `Sources/Rainmaker/Models` contains Swift source code for data models which are also publicly available types. They do not necessarily mirror the structure and types as returned by the server in responses. They are meant to be as elegant and plausible as possible from a Swift client developer perspective, not necessarily mirroring the server responses exactly.
- `Sources/Rainmaker/Responses/Models` contains Swift source code for data models which actually enable the use of Swift's `Decodable` for server response data. They are not meant to be exposed outside the Swift package module but only as an intermediate representation to simplify deserialization.
- `Sources/Rainmaker/Push/` contains the `notify_push` WebSocket transport and the coordinator behind `Server.events(_:)`, which prefers the WebSocket when the server advertises the capability and falls back to polling otherwise. The mockable WebSocket abstraction protocols (`WebSocketConnecting`, `WebSocketChannel`, `WebSocketFrame`) live alongside `Requesting` in `Sources/Rainmaker/Requests/`, mirroring how the HTTP session is abstracted.
- `Sources/RainmakerCLI/` contains the Swift source code for the accompanying command line utility which enables the usage of the library in a terminal environment without any additional upstream project.
- `Sources/RainmakerCLI/Commands/RecordFixtures.swift` and `Sources/RainmakerCLI/Fixtures/` implement the `record-fixtures` subcommand which automates the creation of test fixtures. It is macOS-only: it deploys ephemeral Nextcloud containers via the `NextcloudContainerManager` package, runs the test suite against them in recording mode to capture real responses into `Tests/RainmakerTests/Responses/`, and verifies the captures replay without a server. All of its Docker-facing code is guarded with `#if os(macOS)` so the simulator builds compile the CLI without it.
- `Sources/RainmakerTestServerTags/` is a small internal module holding `ServerVersion`, the single source of truth for the supported Nextcloud versions. Both the test target and the CLI's fixture recorder depend on it, so the version list is declared once.
- `Sources/Rainmaker/Documentation.docc/` is a DocC documentation catalog to provide additional documentation the one automatically derived from source code comments and symbol documentation in Swift source code. This is the place for documentation articles targeting developers which are using this library and package.
- `Tests/` contains the automated tests per target.
- `Tests/RainmakerTests/Responses/` contains static test fixtures which are the HTTP response bodies of actual server responses. They either are in JSON or XML format.
- `Tests/RainmakerTests/URLTestSession.swift` replays those fixtures during normal test runs, while `Tests/RainmakerTests/URLRecordingSession.swift` is its recording counterpart used by the `record-fixtures` subcommand. Both derive fixture paths through the shared `Tests/RainmakerTests/FixtureLocator.swift` so recording and replay can never diverge, and `Tests/RainmakerTests/FixtureCanonicalizer.swift` normalizes recorded responses (canonical host, redacted volatile fields) so fixtures stay stable. `Tests/RainmakerTests/ServerTesting.swift` selects between the two sessions based on the `RAINMAKER_FIXTURE_RECORD` environment variable.
- The `Server.events(_:)` tests do not use fixtures: they drive hand-authored WebSocket and request doubles (`Tests/RainmakerTests/MockWebSocketConnecting.swift`, `MockWebSocketChannel.swift`, `MockRequesting.swift`) so the WebSocket handshake, frame mapping, polling fallback, and reconnection are exercised deterministically without a server.
- `Rainmaker.png` and `Rainmaker.pxd` are static artwork files for presentation on the web and can be ignored.

## Code Style
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ SUBCOMMANDS:
notifications List the notifications queued for the authenticated user. Requires authentication and the server's notifications app.
poll Poll the status of a previously initiated login flow.
upload Upload a file or directory to a folder on the server.
watch Observe server-side changes over notify_push (or polling when unavailable) and print each event. Runs until interrupted.

See 'rainmaker help <subcommand>' for detailed help.
```
Expand Down
10 changes: 10 additions & 0 deletions Sources/Rainmaker/Documentation.docc/Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,21 @@ This is the only method currently supported.
- ``TrashItem``
- ``User``

### Observing Changes

Observe server-side changes over the `notify_push` WebSocket when available, falling back to polling otherwise, through a single stream of re-fetch hints.

- ``Server/events(_:)``
- ``ServerEvent``
- ``ServerSubject``
- ``ServerEventOptions``

### Capabilities

- ``CapabilitySet``
- ``Capability``
- ``Notifications``
- ``PushNotifications``
- ``Theming``
- ``Trashing``
- ``Version``
Expand Down
10 changes: 10 additions & 0 deletions Sources/Rainmaker/Extensions/URLSession+WebSocketConnecting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// SPDX-FileCopyrightText: 2026 Iva Horn
// SPDX-License-Identifier: MIT

import Foundation

extension URLSession: WebSocketConnecting {
public func channel(for request: URLRequest) -> any WebSocketChannel {
URLSessionWebSocketChannel(task: webSocketTask(with: request))
}
}
52 changes: 52 additions & 0 deletions Sources/Rainmaker/Models/PushNotifications.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: 2026 Iva Horn
// SPDX-License-Identifier: MIT

import Foundation

///
/// The server's push notifications capability, advertised when the `notify_push` app is installed and its high-performance backend is configured.
///
/// Nextcloud can push change signals to connected clients over a WebSocket instead of requiring them to poll. When the `notify_push` app is set up the server advertises this object under the `notify_push` key, which is why ``key`` is `"notify_push"`. Its presence, together with a WebSocket ``Endpoints/websocket`` address, is what lets ``Serving/events(_:)`` prefer the WebSocket over polling.
///
/// When the high-performance backend is not configured the whole `notify_push` key is absent from the capabilities, so a lookup via ``CapabilitySet/get(_:)`` yields `nil` and clients fall back to polling.
/// All fields are kept optional so that a server which omits one of them still decodes successfully.
///
public struct PushNotifications: Capability {
public static let key = "notify_push"

///
/// The subjects the server pushes, e.g. `["files", "activities", "notifications"]`.
///
/// These map onto ``ServerSubject`` by raw value, so the set of subjects that can be observed over the WebSocket is the intersection of this list with the subjects a client requests.
///
public let type: [String]?

///
/// The connection endpoints the push service advertises.
///
public let endpoints: Endpoints?

///
/// The endpoints nested under the `notify_push` capability.
///
public struct Endpoints: Decodable, Sendable {
///
/// The WebSocket URL to connect to, e.g. `wss://cloud.example.com/push/ws`.
///
/// This is absent when the high-performance backend is not wired up, in which case clients fall back to polling.
///
public let websocket: URL?

///
/// The pre-authentication token endpoint for clients which hold a session but not the raw credentials.
///
/// It is decoded for completeness and reserved for a future addition; ``Serving/events(_:)`` authenticates with the ``Server/user`` and ``Server/password`` it already holds and therefore does not need it.
///
public let preAuth: URL?

private enum CodingKeys: String, CodingKey {
case websocket
case preAuth = "pre_auth"
}
}
}
43 changes: 43 additions & 0 deletions Sources/Rainmaker/Models/ServerEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2026 Iva Horn
// SPDX-License-Identifier: MIT

import Foundation

///
/// A hint that something changed on the server, delivered by ``Serving/events(_:)``.
///
/// Every case is a hint to re-fetch, never a payload: this mirrors how the `notify_push` WebSocket works and lets the polling fallback synthesize the very same hints on a timer, so a client consumes ``Serving/events(_:)`` identically regardless of which transport is active.
/// A client reacts to an event by re-fetching the relevant state itself, for example calling ``Serving/notifications()`` in response to ``notifications``.
///
public enum ServerEvent: Sendable, Equatable {
///
/// The stream connected or reconnected, so every subscribed subject should be re-fetched to reconcile anything missed while offline.
///
/// This is emitted once when the subscription starts (unless disabled via ``ServerEventOptions/emitConnectedOnStart``) and again after each successful WebSocket (re)connection.
///
case connected

///
/// The notifications queued for the user changed, retrievable via ``Serving/notifications()``.
///
case notifications

///
/// The user's files changed.
///
/// When the change arrived over the WebSocket and the server reported the affected file identifiers, `ids` carries them; otherwise it is `nil`, including for every poll tick.
///
case files(ids: [Int]?)

///
/// The user's activity stream changed.
///
case activities

///
/// A push type Rainmaker does not model natively, forwarded verbatim for future or third-party subjects.
///
/// `type` is the leading token of the WebSocket frame and `body` is the remainder when the frame carried one.
///
case custom(type: String, body: String?)
}
58 changes: 58 additions & 0 deletions Sources/Rainmaker/Models/ServerEventOptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// SPDX-FileCopyrightText: 2026 Iva Horn
// SPDX-License-Identifier: MIT

import Foundation

///
/// Configures which server-side changes ``Serving/events(_:)`` observes and how often it polls.
///
/// The defaults observe every ``ServerSubject`` and poll every 30 seconds when the `notify_push` WebSocket is unavailable, matching the cadence the official Nextcloud clients use.
///
public struct ServerEventOptions: Sendable {
///
/// The subjects to observe.
///
/// Only the intersection of these with the subjects the server advertises under ``PushNotifications/type`` is delivered over the WebSocket; any remaining subject is polled instead.
///
public var subjects: Set<ServerSubject>

///
/// The interval in seconds at which subjects are polled while the WebSocket is unavailable, or for subjects the server does not push.
///
public var pollInterval: TimeInterval

///
/// The interval in seconds of the low-frequency backstop poll that runs even while the WebSocket is connected.
///
/// The `notify_push` documentation recommends that clients keep polling occasionally as a best-effort safety net against missed pushes, which is what this interval governs.
///
public var backstopPollInterval: TimeInterval

///
/// Whether to opt into per-file identifiers by sending `listen notify_file_id` after authenticating, delivering them via ``ServerEvent/files(ids:)`` when the server supports it (`notify_push` 0.4 and later).
///
public var listenFileIDs: Bool

///
/// Whether to emit ``ServerEvent/connected`` immediately when the subscription starts so a client performs an initial fetch without waiting for the first change.
///
public var emitConnectedOnStart: Bool

///
/// Create a new set of options.
///
/// - Parameters:
/// - subjects: The subjects to observe. Defaults to all of them.
/// - pollInterval: The polling interval in seconds used when the WebSocket is unavailable. Defaults to 30.
/// - backstopPollInterval: The backstop polling interval in seconds used while the WebSocket is connected. Defaults to 900.
/// - listenFileIDs: Whether to request per-file identifiers. Defaults to `false`.
/// - emitConnectedOnStart: Whether to emit ``ServerEvent/connected`` on subscription. Defaults to `true`.
///
public init(subjects: Set<ServerSubject> = Set(ServerSubject.allCases), pollInterval: TimeInterval = 30, backstopPollInterval: TimeInterval = 900, listenFileIDs: Bool = false, emitConnectedOnStart: Bool = true) {
self.subjects = subjects
self.pollInterval = pollInterval
self.backstopPollInterval = backstopPollInterval
self.listenFileIDs = listenFileIDs
self.emitConnectedOnStart = emitConnectedOnStart
}
}
44 changes: 44 additions & 0 deletions Sources/Rainmaker/Models/ServerSubject.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2026 Iva Horn
// SPDX-License-Identifier: MIT

import Foundation

///
/// A category of server-side change a client can observe through ``Serving/events(_:)``.
///
/// The raw values deliberately match the strings the server lists under ``PushNotifications/type``, so the subjects observable over the WebSocket are simply the intersection of the requested subjects with the advertised ones, without any mapping table.
///
public enum ServerSubject: String, Sendable, CaseIterable, Hashable {
///
/// The notifications queued for the user changed, retrievable via ``Serving/notifications()``.
///
/// Nextcloud Talk mentions and calls also surface here as notifications with the `spreed` app identifier.
///
case notifications

///
/// The user's files changed.
///
case files

///
/// The user's activity stream changed.
///
case activities

///
/// The ``ServerEvent`` a poll tick emits for this subject.
///
/// Polling cannot know which concrete items changed, so the file event carries no identifiers; that detail is only available over the WebSocket via ``ServerEvent/files(ids:)``.
///
var event: ServerEvent {
switch self {
case .notifications:
.notifications
case .files:
.files(ids: nil)
case .activities:
.activities
}
}
}
Loading
Loading