| 1 | # Rastrillo Native |
| 2 | |
| 3 | Optional native components and app starting points in the Rastrillo family. |
| 4 | The first package supports iOS 17+ and macOS 14+. Android and Go Mobile |
| 5 | integration are documented extension points, not tested shipped adapters. |
| 6 | |
| 7 | ## Use the package |
| 8 | |
| 9 | Add `https://amadan.net/rastrillo/native` to your Swift package dependencies, |
| 10 | pinned to a reviewed commit, then depend on its `RastrilloNative` product. |
| 11 | Read [SKILL.md](SKILL.md) before integrating it. |
| 12 | |
| 13 | ```swift |
| 14 | import RastrilloNative |
| 15 | |
| 16 | @MainActor |
| 17 | final class Inbox { |
| 18 | private let refresh = CoalescedRunner() |
| 19 | |
| 20 | func reload() async { |
| 21 | await refresh.run { |
| 22 | // Fetch and publish this inbox's current state here. |
| 23 | } |
| 24 | } |
| 25 | } |
| 26 | ``` |
| 27 | |
| 28 | Use a runner for one operation and account. Calls during a refresh queue one |
| 29 | trailing refresh; every caller waits until shared work completes. Calls |
| 30 | during the trailing refresh can queue another pass. The first caller's |
| 31 | closure supplies every pass, so do not mix different operations in one runner. |
| 32 | Handle errors within that closure. Cancelling a caller does not cancel the |
| 33 | shared refresh. Calling the same runner from its own pass deadlocks. |
| 34 | |
| 35 | ## Start a companion app |
| 36 | |
| 37 | `examples/companion` is a small SwiftUI app with iOS and macOS targets. It |
| 38 | connects to a Rastrillo server's public `/api/version` endpoint. It has no |
| 39 | account linking, credentials, offline data or push enrolment. |
| 40 | |
| 41 | Copy that directory into your app. Replace the local package path in |
| 42 | `project.yml` with the repository URL and a reviewed revision, change the |
| 43 | bundle identifiers, and add your app's screens. Run `xcodegen generate` in |
| 44 | the copied directory and open `Companion.xcodeproj`. Sign with your own team |
| 45 | for physical devices or distribution. |
| 46 | |
| 47 | Prefer fully native UI so navigation, menus, contextual actions, links and |
| 48 | accessibility meet the platform's expectations. For a complex app, keep a |
| 49 | native shell around selected webview screens. A shared app manifest can |
| 50 | describe destinations and commands for separate web and native renderers; |
| 51 | that is a possible architecture, not a compiler supplied by this kit. See |
| 52 | [docs/app-architecture.md](docs/app-architecture.md). Bind shared Go logic only |
| 53 | when it removes duplication: see [docs/go-mobile.md](docs/go-mobile.md). |
| 54 | |
| 55 | ## Validate |
| 56 | |
| 57 | Apple editor clients use `RastrilloApple.NativeHTTP` for ephemeral, origin-bound |
| 58 | requests. `responseDetails` returns body, status and case-insensitive headers for |
| 59 | protocol cursors. `response` and `request` retain the 4 MiB default body bound; |
| 60 | an explicit `maxBytes` allows larger snapshots/exports up to 256 MiB. Oversized |
| 61 | responses fail rather than returning truncated content. |
| 62 | |
| 63 | `stream(path:onOpen:onLine:)` requires a 200 `text/event-stream` response and supplies |
| 64 | UTF-8 lines, including empty event delimiters. It accepts CR, LF and CRLF, |
| 65 | limits each line to 256 KiB and each event to 1 MiB, and honors task cancellation. |
| 66 | The optional `onOpen` callback runs after response validation and completes before |
| 67 | any lines are delivered. A server that subscribes before flushing response headers |
| 68 | allows this callback to fetch an initial snapshot without missing intervening edits. |
| 69 | The consumer parses events, revalidates permissions through its server, and |
| 70 | refetches/reconnects after EOF. An unfinished event at EOF must not be applied. |
| 71 | Streams use the same no-redirect session and origin-bound credentials as ordinary |
| 72 | requests. Responses and streams are not persisted. |
| 73 | |
| 74 | `download(_:to:)` streams an explicit user export into a new file, with the same |
| 75 | origin and redirect restrictions. It requires HTTP 200 and a file URL, applies |
| 76 | 0600 permissions, and refuses to overwrite an existing destination. The caller |
| 77 | owns that temporary export and must remove it after the save panel completes |
| 78 | or is cancelled. Unlike in-memory responses, downloads have no body-size cap. |
| 79 | |
| 80 | ### Typed errors and `NativeTransport` (in-progress primitive) |
| 81 | |
| 82 | This is the first slice of the offline kit (spec: `docs/superpowers/specs/2026-09-23-offline-kit.md` |
| 83 | on `spec/offline-kit`). It stays marked in progress until Sheets has adopted it. |
| 84 | |
| 85 | `NativeClientError` tells an offline failure apart from an ended session: |
| 86 | `.offline(URLError.Code)` covers `notConnectedToInternet`, `networkConnectionLost`, |
| 87 | `cannotFindHost`, `cannotConnectToHost`, `dnsLookupFailed`, `timedOut`, `dataNotAllowed` |
| 88 | and `internationalRoamingOff`. Other transport failures, TLS among them, are |
| 89 | `.transport(code)`, retried as transient and never shown as offline. Statuses map |
| 90 | to `.unauthorized` (401 and 303), `.forbidden`, `.notFound`, `.conflict(body)`, |
| 91 | `.tooLarge`, `.rateLimited(retryAfter:)` (429, or 503 with Retry-After), |
| 92 | `.server(status:)` and `.rejected(status:body:)`. `retryClass` sorts every case into |
| 93 | offline, transient, auth, permanent or conflict. `localizedDescription` keeps the |
| 94 | sentences callers showed before. A cancelled request throws `CancellationError`, |
| 95 | never URLSession's -999. |
| 96 | |
| 97 | `responseDetails` and `response` still return any status; `NativeResponse.requireSuccess()` |
| 98 | throws its typed case. `request` and `stream` throw typed cases, so a 401 on a |
| 99 | stream is `.unauthorized`. `NativeHTTP(origin:credential:observer:)` reports every |
| 100 | response and every offline-class failure to the optional observer, so connectivity |
| 101 | can be learned from real traffic. |
| 102 | |
| 103 | `NativeTransport` is the protocol `NativeHTTP` conforms to. Code that can outlive |
| 104 | a session should take a supplier, `@MainActor () -> NativeTransport?`, rather than |
| 105 | capture one client, because signing in again replaces it. The |
| 106 | `RastrilloTestSupport` product ships `ScriptedTransport`, which answers each call |
| 107 | from a script: a status and body, offline, a hang until cancelled, or "commit, |
| 108 | then lose the response". Scripted streams are framed by the same byte-level parser |
| 109 | as `NativeHTTP`. Use it in test targets in place of fixture servers. |
| 110 | |
| 111 | ### Connectivity and backoff (in-progress primitive) |
| 112 | |
| 113 | `NativeConnectivity.shared` (one per process) combines `NWPathMonitor`'s path |
| 114 | with what real traffic proves: every client `NativeConnection` makes reports its |
| 115 | responses and offline-class failures, so `serverReachable[origin]` is learned |
| 116 | without a probe. `isOffline(origin)` is true while the path is down, or while it |
| 117 | is up and that server just failed to answer. `changes` fires on a path change, |
| 118 | on a server becoming reachable and on return to the foreground. |
| 119 | |
| 120 | A stream loop uses `NativeReconnect(connectivity:)`: call `opened()` from the |
| 121 | stream's `onOpen`, `failed(error)` when an attempt ends in an error, and |
| 122 | `await pause()` before the next attempt. A server's Retry-After (up to 5 minutes) |
| 123 | is a deadline nothing crosses, not even a connectivity change or foreground. |
| 124 | Otherwise the pause waits out `Backoff` (in `RastrilloNative`: 0.5 s doubling to |
| 125 | 10 s with equal jitter), cut short and reset by a connectivity change, with no |
| 126 | timer at all while the path is down. The backoff resets only after a stream that |
| 127 | stayed open longer than 5 s, timed from `opened()`. Lower-level loops can call |
| 128 | `connectivity.wait(upTo:)` and `Backoff.opened(at:)`/`ended(at:)` directly. |
| 129 | `NativeOfflineBanner(origin:)` |
| 130 | is a thin bar shown while that origin is offline; pass durable wording only once |
| 131 | edits survive a relaunch. The kit has no string catalog yet: apps pass their own |
| 132 | localised copy. The catalog, reviewed in English and then translated into the |
| 133 | package's 12 languages, is a requirement of K3, together with `SyncStatus`. |
| 134 | |
| 135 | `tools/bundle-mac.py` includes SwiftPM resource bundles in `Contents/Resources` |
| 136 | and reads the minimum macOS version from the package manifest. A SwiftPM |
| 137 | consumer with resources should first locate its resource bundle with |
| 138 | `Bundle.main.url(forResource: "Package_Target", withExtension: "bundle")`, |
| 139 | then lazily fall back to `Bundle.module` for command-line development builds. |
| 140 | SwiftPM's generated accessor alone looks in the app root and the original build |
| 141 | directory; neither is the signed Mac app's resource directory. The packaging |
| 142 | test relocates a signed app, deletes its build tree, and verifies resource access. |
| 143 | |
| 144 | `make ci` runs the Swift tests and compiles both companion targets. It |
| 145 | requires macOS, Xcode and XcodeGen. Builds are unsigned; this gate does not |
| 146 | claim physical-device, signing or store-distribution validation. |
| 147 | |
| 148 | ## Provenance |
| 149 | |
| 150 | `CoalescedRunner` comes from Eleven/Ocho's `ios/LChatCore` at commit |
| 151 | `000b882c`, also copied into Keymail's `apple/KeymailCore`. The extraction |
| 152 | preserves its implementation and tests the failure it prevented: concurrent |
| 153 | refresh bursts and callers resuming before their new data has been fetched. |
| 154 | Consumer branches replace the copies with public type aliases so existing |
| 155 | call sites keep their API. See the adoption status on amadan before treating |
| 156 | a consumer branch as landed. |
| 157 | |
| 158 | MPL-2.0; see [LICENSE](LICENSE). The extracted source retains that licence. |
| 159 | |