Extract shared native refresh coalescing and companion scaffold
Keymail and Ocho copied the same refresh gate to prevent overlapping requests and premature reads. A shared Swift package preserves that contract while keeping platform tooling optional for web apps.
14 files changed,
+756
−0
.amadan/ci.d/10-test+2 −0.amadan/ci.d/20-companion+2 −0.gitignore+5 −0AGENTS.md+10 −0LICENSE+374 −0Makefile+8 −0Package.swift+12 −0README.md+67 −0SKILL.md+42 −0Sources/RastrilloNative/CoalescedRunner.swift+33 −0Tests/RastrilloNativeTests/CoalescedRunnerTests.swift+79 −0docs/go-mobile.md+38 −0examples/companion/Sources/Companion.swift+54 −0examples/companion/project.yml+30 −0
CI failed — run details
CI log
=== test === swift test Building for debugging... [0/13] Write sources [2/13] Write swift-version--28C01585A1F674EC.txt [4/15] Compiling RastrilloNative CoalescedRunner.swift [5/15] Emitting module RastrilloNative [6/16] Wrapping AST for RastrilloNative for debugging [8/18] Compiling RastrilloNativeTests CoalescedRunnerTests.swift [9/18] Emitting module RastrilloNativeTests [10/19] /home/paulca/.local/state/amadan/runner/repos/rastrillo__native/wt/feat__native-kit/.build/x86_64-unknown-linux-gnu/debug/RastrilloNativePackageDiscoveredTests.derived/all-discovered-tests.swift [11/19] Write sources [12/19] Wrapping AST for RastrilloNativeTests for debugging [14/22] Emitting module RastrilloNativePackageDiscoveredTests [15/22] Compiling RastrilloNativePackageDiscoveredTests RastrilloNativeTests.swift [16/22] Compiling RastrilloNativePackageDiscoveredTests all-discovered-tests.swift [17/23] /home/paulca/.local/state/amadan/runner/repos/rastrillo__native/wt/feat__native-kit/.build/x86_64-unknown-linux-gnu/debug/RastrilloNativePackageTests.derived/runner.swift [18/23] Write sources [19/23] Wrapping AST for RastrilloNativePackageDiscoveredTests for debugging [21/25] Emitting module RastrilloNativePackageTests [22/25] Compiling RastrilloNativePackageTests runner.swift [23/26] Wrapping AST for RastrilloNativePackageTests for debugging [24/26] Write Objects.LinkFileList [25/26] Linking RastrilloNativePackageTests.xctest Build complete! (6.38s) Test Suite 'All tests' started at 2026-09-11 14:59:25.085 Test Suite 'debug.xctest' started at 2026-09-11 14:59:25.094 Test Suite 'CoalescedRunnerTests' started at 2026-09-11 14:59:25.094 Test Case 'CoalescedRunnerTests.testBurstWaitsForTrailingPassAndReusesOriginalOperation' started at 2026-09-11 14:59:25.094 Test Case 'CoalescedRunnerTests.testBurstWaitsForTrailingPassAndReusesOriginalOperation' passed (0.001 seconds) Test Case 'CoalescedRunnerTests.testCancelledWaiterDoesNotCancelSharedRefresh' started at 2026-09-11 14:59:25.095 Test Case 'CoalescedRunnerTests.testCancelledWaiterDoesNotCancelSharedRefresh' passed (0.0 seconds) Test Case 'CoalescedRunnerTests.testSequentialCallsEachRefresh' started at 2026-09-11 14:59:25.095 Test Case 'CoalescedRunnerTests.testSequentialCallsEachRefresh' passed (0.0 seconds) Test Suite 'CoalescedRunnerTests' passed at 2026-09-11 14:59:25.096 Executed 3 tests, with 0 failures (0 unexpected) in 0.001 (0.001) seconds Test Suite 'debug.xctest' passed at 2026-09-11 14:59:25.096 Executed 3 tests, with 0 failures (0 unexpected) in 0.001 (0.001) seconds Test Suite 'All tests' passed at 2026-09-11 14:59:25.096 Executed 3 tests, with 0 failures (0 unexpected) in 0.001 (0.001) seconds ◇ Test run started. ↳ Testing Library Version: 6.2 (3fdabe5392108d8) ↳ Target Platform: x86_64-unknown-linux-gnu ✔ Test run with 0 tests in 0 suites passed after 0.001 seconds. === companion === cd examples/companion && xcodegen generate /bin/sh: 1: xcodegen: not found make: *** [Makefile:6: companion] Error 127 amadan: step companion failed: exit status 2 amadan: job failed at step companion — its output is above, not at the tail of this log
Intent
diff --git a/.amadan/ci.d/10-test b/.amadan/ci.d/10-test| new file mode 100755 |
| index 0000000..758ffe2 |
| --- /dev/null |
| +++ b/.amadan/ci.d/10-test |
| @@ -0,0 +1,2 @@ |
| +#!/bin/sh |
| +exec make test |
diff --git a/.amadan/ci.d/20-companion b/.amadan/ci.d/20-companion| new file mode 100755 |
| index 0000000..a94225c |
| --- /dev/null |
| +++ b/.amadan/ci.d/20-companion |
| @@ -0,0 +1,2 @@ |
| +#!/bin/sh |
| +exec make companion |
diff --git a/.gitignore b/.gitignore| new file mode 100644 |
| index 0000000..3761d4b |
| --- /dev/null |
| +++ b/.gitignore |
| @@ -0,0 +1,5 @@ |
| +.build/ |
| +.swiftpm/ |
| +.claude/ |
| +*.xcodeproj/ |
| +examples/companion/build/ |
diff --git a/AGENTS.md b/AGENTS.md| new file mode 100644 |
| index 0000000..1d54a5d |
| --- /dev/null |
| +++ b/AGENTS.md |
| @@ -0,0 +1,10 @@ |
| +# Working here |
| + |
| +Work and review live at https://amadan.net/rastrillo/native. Use the amadan |
| +agent skill and your agent account. Take a worktree and branch, describe it, |
| +keep tasks current, and land with `amadan branch merge`. Never squash. |
| + |
| +Run `make ci` before pushing. `.amadan/ci.d` calls the same Make targets. |
| +Keep platform-specific dependencies out of the shared Swift target. Add a |
| +component only after proving adoption in existing applications. Comments |
| +explain the failure prevented; review user-facing English before translating. |
diff --git a/LICENSE b/LICENSE| new file mode 100644 |
| index 0000000..570135f |
| --- /dev/null |
| +++ b/LICENSE |
| @@ -0,0 +1,374 @@ |
| +Mozilla Public License Version 2.0 |
| +================================== |
| + |
| +1. Definitions |
| +-------------- |
| + |
| +1.1. "Contributor" |
| + means each individual or legal entity that creates, contributes to |
| + the creation of, or owns Covered Software. |
| + |
| +1.2. "Contributor Version" |
| + means the combination of the Contributions of others (if any) used |
| + by a Contributor and that particular Contributor's Contribution. |
| + |
| +1.3. "Contribution" |
| + means Covered Software of a particular Contributor. |
| + |
| +1.4. "Covered Software" |
| + means Source Code Form to which the initial Contributor has attached |
| + the notice in Exhibit A, the Executable Form of such Source Code |
| + Form, and Modifications of such Source Code Form, in each case |
| + including portions thereof. |
| + |
| +1.5. "Incompatible With Secondary Licenses" |
| + means |
| + |
| + (a) that the initial Contributor has attached the notice described |
| + in Exhibit B to the Covered Software; or |
| + |
| + (b) that the Covered Software was made available under the terms of |
| + version 1.1 or earlier of the License, but not also under the |
| + terms of a Secondary License. |
| + |
| +1.6. "Executable Form" |
| + means any form of the work other than Source Code Form. |
| + |
| +1.7. "Larger Work" |
| + means a work that combines Covered Software with other material, in |
| + a separate file or files, that is not Covered Software. |
| + |
| +1.8. "License" |
| + means this document. |
| + |
| +1.9. "Licensable" |
| + means having the right to grant, to the maximum extent possible, |
| + whether at the time of the initial grant or subsequently, any and |
| + all of the rights conveyed by this License. |
| + |
| +1.10. "Modifications" |
| + means any of the following: |
| + |
| + (a) any file in Source Code Form that results from an addition to, |
| + deletion from, or modification of the contents of Covered |
| + Software; or |
| + |
| + (b) any new file in Source Code Form that contains any Covered |
| + Software. |
| + |
| +1.11. "Patent Claims" of a Contributor |
| + means any patent claim(s), including without limitation, method, |
| + process, and apparatus claims, in any patent Licensable by such |
| + Contributor that would be infringed, but for the grant of the |
| + License, by the making, using, selling, offering for sale, having |
| + made, import, or transfer of either its Contributions or its |
| + Contributor Version. |
| + |
| +1.12. "Secondary License" |
| + means either the GNU General Public License, Version 2.0, the GNU |
| + Lesser General Public License, Version 2.1, the GNU Affero General |
| + Public License, Version 3.0, or any later versions of those |
| + licenses. |
| + |
| +1.13. "Source Code Form" |
| + means the form of the work preferred for making modifications. |
| + |
| +1.14. "You" (or "Your") |
| + means an individual or a legal entity exercising rights under this |
| + License. For legal entities, "You" includes any entity that |
| + controls, is controlled by, or is under common control with You. For |
| + purposes of this definition, "control" means (a) the power, direct |
| + or indirect, to cause the direction or management of such entity, |
| + whether by contract or otherwise, or (b) ownership of more than |
| + fifty percent (50%) of the outstanding shares or beneficial |
| + ownership of such entity. |
| + |
| +2. License Grants and Conditions |
| +-------------------------------- |
| + |
| +2.1. Grants |
| + |
| +Each Contributor hereby grants You a world-wide, royalty-free, |
| +non-exclusive license: |
| + |
| +(a) under intellectual property rights (other than patent or trademark) |
| + Licensable by such Contributor to use, reproduce, make available, |
| + modify, display, perform, distribute, and otherwise exploit its |
| + Contributions, either on an unmodified basis, with Modifications, or |
| + as part of a Larger Work; and |
| + |
| +(b) under Patent Claims of such Contributor to make, use, sell, offer |
| + for sale, have made, import, and otherwise transfer either its |
| + Contributions or its Contributor Version. |
| + |
| +2.2. Effective Date |
| + |
| +The licenses granted in Section 2.1 with respect to any Contribution |
| +become effective for each Contribution on the date the Contributor first |
| +distributes such Contribution. |
| + |
| +2.3. Limitations on Grant Scope |
| + |
| +The licenses granted in this Section 2 are the only rights granted under |
| +this License. No additional rights or licenses will be implied from the |
| +distribution or licensing of Covered Software under this License. |
| +Notwithstanding Section 2.1(b) above, no patent license is granted by a |
| +Contributor: |
| + |
| +(a) for any code that a Contributor has removed from Covered Software; |
| + or |
| + |
| +(b) for infringements caused by: (i) Your and any other third party's |
| + modifications of Covered Software, or (ii) the combination of its |
| + Contributions with other software (except as part of its Contributor |
| + Version); or |
| + |
| +(c) under Patent Claims infringed by Covered Software in the absence of |
| + its Contributions. |
| + |
| +This License does not grant any rights in the trademarks, service marks, |
| +or logos of any Contributor (except as may be necessary to comply with |
| +the notice requirements in Section 3.4). |
| + |
| +2.4. Subsequent Licenses |
| + |
| +No Contributor makes additional grants as a result of Your choice to |
| +distribute the Covered Software under a subsequent version of this |
| +License (see Section 10.2) or under the terms of a Secondary License (if |
| +permitted under the terms of Section 3.3). |
| + |
| +2.5. Representation |
| + |
| +Each Contributor represents that the Contributor believes its |
| +Contributions are its original creation(s) or it has sufficient rights |
| +to grant the rights to its Contributions conveyed by this License. |
| + |
| +2.6. Fair Use |
| + |
| +This License is not intended to limit any rights You have under |
| +applicable copyright doctrines of fair use, fair dealing, or other |
| +equivalents. |
| + |
| +2.7. Conditions |
| + |
| +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted |
| +in Section 2.1. |
| + |
| +3. Responsibilities |
| +------------------- |
| + |
| +3.1. Distribution of Source Form |
| + |
| +All distribution of Covered Software in Source Code Form, including any |
| +Modifications that You create or to which You contribute, must be under |
| +the terms of this License. You must inform recipients that the Source |
| +Code Form of the Covered Software is governed by the terms of this |
| +License, and how they can obtain a copy of this License. You may not |
| +attempt to alter or restrict the recipients' rights in the Source Code |
| +Form. |
| + |
| +3.2. Distribution of Executable Form |
| + |
| +If You distribute Covered Software in Executable Form then: |
| + |
| +(a) such Covered Software must also be made available in Source Code |
| + Form, as described in Section 3.1, and You must inform recipients of |
| + the Executable Form how they can obtain a copy of such Source Code |
| + Form by reasonable means in a timely manner, at a charge no more |
| + than the cost of distribution to the recipient; and |
| + |
| +(b) You may distribute such Executable Form under the terms of this |
| + License, or sublicense it under different terms, provided that the |
| + license for the Executable Form does not attempt to limit or alter |
| + the recipients' rights in the Source Code Form under this License. |
| + |
| +3.3. Distribution of a Larger Work |
| + |
| +You may create and distribute a Larger Work under terms of Your choice, |
| +provided that You also comply with the requirements of this License for |
| +the Covered Software. If the Larger Work is a combination of Covered |
| +Software with a work governed by one or more Secondary Licenses, and the |
| +Covered Software is not Incompatible With Secondary Licenses, this |
| +License permits You to additionally distribute such Covered Software |
| +under the terms of such Secondary License(s), so that the recipient of |
| +the Larger Work may, at their option, further distribute the Covered |
| +Software under the terms of either this License or such Secondary |
| +License(s). |
| + |
| +3.4. Notices |
| + |
| +You may not remove or alter the substance of any license notices |
| +(including copyright notices, patent notices, disclaimers of warranty, |
| +or limitations of liability) contained within the Source Code Form of |
| +the Covered Software, except that You may alter any license notices to |
| +the extent required to remedy known factual inaccuracies. |
| + |
| +3.5. Application of Additional Terms |
| + |
| +You may choose to offer, and to charge a fee for, warranty, support, |
| +indemnity or liability obligations to one or more recipients of Covered |
| +Software. However, You may do so only on Your own behalf, and not on |
| +behalf of any Contributor. You must make it absolutely clear that any |
| +such warranty, support, indemnity, or liability obligation is offered by |
| +You alone, and You hereby agree to indemnify every Contributor for any |
| +liability incurred by such Contributor as a result of warranty, support, |
| +indemnity or liability terms You offer. You may include additional |
| +disclaimers of warranty and limitations of liability specific to any |
| +jurisdiction. |
| + |
| +4. Inability to Comply Due to Statute or Regulation |
| +--------------------------------------------------- |
| + |
| +If it is impossible for You to comply with any of the terms of this |
| +License with respect to some or all of the Covered Software due to |
| +statute, judicial order, or regulation then You must: (a) comply with |
| +the terms of this License to the maximum extent possible; and (b) |
| +describe the limitations and the code they affect. Such description must |
| +be placed in a text file included with all distributions of the Covered |
| +Software under this License. Except to the extent prohibited by statute |
| +or regulation, such description must be sufficiently detailed for a |
| +recipient of ordinary skill to be able to understand it. |
| + |
| +5. Termination |
| +-------------- |
| + |
| +5.1. The rights granted under this License will terminate automatically |
| +if You fail to comply with any of its terms. However, if You become |
| +compliant, then the rights granted under this License from a particular |
| +Contributor are reinstated (a) provisionally, unless and until such |
| +Contributor explicitly and finally terminates Your grants, and (b) on an |
| +ongoing basis, if such Contributor fails to notify You of the |
| +non-compliance by some reasonable means prior to 60 days after You have |
| +come back into compliance. Moreover, Your grants from a particular |
| +Contributor are reinstated on an ongoing basis if such Contributor |
| +notifies You of the non-compliance by some reasonable means, this is the |
| +first time You have received notice of non-compliance with this License |
| +from such Contributor, and You become compliant prior to 30 days after |
| +Your receipt of the notice. |
| + |
| +5.2. If You initiate litigation against any entity by asserting a patent |
| +infringement claim (excluding declaratory judgment actions, |
| +counter-claims, and cross-claims) alleging that a Contributor Version |
| +directly or indirectly infringes any patent, then the rights granted to |
| +You by any and all Contributors for the Covered Software under Section |
| +2.1 of this License shall terminate. |
| + |
| +5.3. In the event of termination under Sections 5.1 or 5.2 above, all |
| +end user license agreements (excluding distributors and resellers) which |
| +have been validly granted by You or Your distributors under this License |
| +prior to termination shall survive termination. |
| + |
| +************************************************************************ |
| +* * |
| +* 6. Disclaimer of Warranty * |
| +* ------------------------- * |
| +* * |
| +* Covered Software is provided under this License on an "as is" * |
| +* basis, without warranty of any kind, either expressed, implied, or * |
| +* statutory, including, without limitation, warranties that the * |
| +* Covered Software is free of defects, merchantable, fit for a * |
| +* particular purpose or non-infringing. The entire risk as to the * |
| +* quality and performance of the Covered Software is with You. * |
| +* Should any Covered Software prove defective in any respect, You * |
| +* (not any Contributor) assume the cost of any necessary servicing, * |
| +* repair, or correction. This disclaimer of warranty constitutes an * |
| +* essential part of this License. No use of any Covered Software is * |
| +* authorized under this License except under this disclaimer. * |
| +* * |
| +************************************************************************ |
| + |
| +************************************************************************ |
| +* * |
| +* 7. Limitation of Liability * |
| +* -------------------------- * |
| +* * |
| +* Under no circumstances and under no legal theory, whether tort * |
| +* (including negligence), contract, or otherwise, shall any * |
| +* Contributor, or anyone who distributes Covered Software as * |
| +* permitted above, be liable to You for any direct, indirect, * |
| +* special, incidental, or consequential damages of any character * |
| +* including, without limitation, damages for lost profits, loss of * |
| +* goodwill, work stoppage, computer failure or malfunction, or any * |
| +* and all other commercial damages or losses, even if such party * |
| +* shall have been informed of the possibility of such damages. This * |
| +* limitation of liability shall not apply to liability for death or * |
| +* personal injury resulting from such party's negligence to the * |
| +* extent applicable law prohibits such limitation. Some * |
| +* jurisdictions do not allow the exclusion or limitation of * |
| +* incidental or consequential damages, so this exclusion and * |
| +* limitation may not apply to You. * |
| +* * |
| +************************************************************************ |
| + |
| +8. Litigation |
| +------------- |
| + |
| +Any litigation relating to this License may be brought only in the |
| +courts of a jurisdiction where the defendant maintains its principal |
| +place of business and such litigation shall be governed by laws of that |
| +jurisdiction, without reference to its conflict-of-law provisions. |
| +Nothing in this Section shall prevent a party's ability to bring |
| +cross-claims or counter-claims. |
| + |
| +9. Miscellaneous |
| +---------------- |
| + |
| +This License represents the complete agreement concerning the subject |
| +matter hereof. If any provision of this License is held to be |
| +unenforceable, such provision shall be reformed only to the extent |
| +necessary to make it enforceable. Any law or regulation which provides |
| +that the language of a contract shall be construed against the drafter |
| +shall not be used to construe this License against a Contributor. |
| + |
| +10. Versions of the License |
| +--------------------------- |
| + |
| +10.1. New Versions |
| + |
| +Mozilla Foundation is the license steward. Except as provided in Section |
| +10.3, no one other than the license steward has the right to modify or |
| +publish new versions of this License. Each version will be given a |
| +distinguishing version number. |
| + |
| +10.2. Effect of New Versions |
| + |
| +You may distribute the Covered Software under the terms of the version |
| +of the License under which You originally received the Covered Software, |
| +or under the terms of any subsequent version published by the license |
| +steward. |
| + |
| +10.3. Modified Versions |
| + |
| +If you create software not governed by this License, and you want to |
| +create a new license for such software, you may create and use a |
| +modified version of this License if you rename the license and remove |
| +any references to the name of the license steward (except to note that |
| +such modified license differs from this License). |
| + |
| +10.4. Distributing Source Code Form that is Incompatible With Secondary |
| +Licenses |
| + |
| +If You choose to distribute Source Code Form that is Incompatible With |
| +Secondary Licenses under the terms of this version of the License, the |
| +notice described in Exhibit B of this License must be attached. |
| + |
| +Exhibit A - Source Code Form License Notice |
| +------------------------------------------- |
| + |
| + This Source Code Form is subject to the terms of the Mozilla Public |
| + License, v. 2.0. If a copy of the MPL was not distributed with this |
| + file, You can obtain one at https://mozilla.org/MPL/2.0/. |
| + |
| +If it is not possible or desirable to put the notice in a particular |
| +file, then You may include the notice in a location (such as a LICENSE |
| +file in a relevant directory) where a recipient would be likely to look |
| +for such a notice. |
| + |
| +You may add additional accurate notices of copyright ownership. |
| + |
| +Exhibit B - "Incompatible With Secondary Licenses" Notice |
| +--------------------------------------------------------- |
| + |
| + This Source Code Form is "Incompatible With Secondary Licenses", as |
| + defined by the Mozilla Public License, v. 2.0. |
| + |
diff --git a/Makefile b/Makefile| new file mode 100644 |
| index 0000000..8f9c720 |
| --- /dev/null |
| +++ b/Makefile |
| @@ -0,0 +1,8 @@ |
| +.PHONY: ci test companion |
| +ci: test companion |
| +test: |
| + swift test |
| +companion: |
| + cd examples/companion && xcodegen generate |
| + xcodebuild -project examples/companion/Companion.xcodeproj -scheme CompanionMac -destination 'platform=macOS' -derivedDataPath examples/companion/build CODE_SIGNING_ALLOWED=NO build |
| + xcodebuild -project examples/companion/Companion.xcodeproj -scheme CompanionIOS -sdk iphonesimulator -derivedDataPath examples/companion/build CODE_SIGNING_ALLOWED=NO build |
diff --git a/Package.swift b/Package.swift| new file mode 100644 |
| index 0000000..dab2708 |
| --- /dev/null |
| +++ b/Package.swift |
| @@ -0,0 +1,12 @@ |
| +// swift-tools-version: 5.9 |
| +import PackageDescription |
| + |
| +let package = Package( |
| + name: "RastrilloNative", |
| + platforms: [.iOS(.v17), .macOS(.v14)], |
| + products: [.library(name: "RastrilloNative", targets: ["RastrilloNative"])], |
| + targets: [ |
| + .target(name: "RastrilloNative"), |
| + .testTarget(name: "RastrilloNativeTests", dependencies: ["RastrilloNative"]), |
| + ] |
| +) |
diff --git a/README.md b/README.md| new file mode 100644 |
| index 0000000..4bdebe9 |
| --- /dev/null |
| +++ b/README.md |
| @@ -0,0 +1,67 @@ |
| +# Rastrillo Native |
| + |
| +Optional native components and app starting points in the Rastrillo family. |
| +The first package supports iOS 17+ and macOS 14+. Android and Go Mobile |
| +integration are documented extension points, not tested shipped adapters. |
| + |
| +## Use the package |
| + |
| +Add `https://amadan.net/rastrillo/native` to your Swift package dependencies, |
| +pinned to a reviewed commit, then depend on its `RastrilloNative` product. |
| +Read [SKILL.md](SKILL.md) before integrating it. |
| + |
| +```swift |
| +import RastrilloNative |
| + |
| +@MainActor |
| +final class Inbox { |
| + private let refresh = CoalescedRunner() |
| + |
| + func reload() async { |
| + await refresh.run { |
| + // Fetch and publish this inbox's current state here. |
| + } |
| + } |
| +} |
| +``` |
| + |
| +Use a runner for one operation and account. Calls during a refresh queue one |
| +trailing refresh; every caller waits until shared work completes. Calls |
| +during the trailing refresh can queue another pass. The first caller's |
| +closure supplies every pass, so do not mix different operations in one runner. |
| +Handle errors within that closure. Cancelling a caller does not cancel the |
| +shared refresh. Calling the same runner from its own pass deadlocks. |
| + |
| +## Start a companion app |
| + |
| +`examples/companion` is a small SwiftUI app with iOS and macOS targets. It |
| +connects to a Rastrillo server's public `/api/version` endpoint. It has no |
| +account linking, credentials, offline data or push enrolment. |
| + |
| +Copy that directory into your app. Replace the local package path in |
| +`project.yml` with the repository URL and a reviewed revision, change the |
| +bundle identifiers, and add your app's screens. Run `xcodegen generate` in |
| +the copied directory and open `Companion.xcodeproj`. Sign with your own team |
| +for physical devices or distribution. |
| + |
| +Use native platform views for navigation and controls. A webview can reuse |
| +an existing web screen where that suits the app. Bind shared Go logic only |
| +when it removes duplication: see [docs/go-mobile.md](docs/go-mobile.md). |
| + |
| +## Validate |
| + |
| +`make ci` runs the Swift tests and compiles both companion targets. It |
| +requires macOS, Xcode and XcodeGen. Builds are unsigned; this gate does not |
| +claim physical-device, signing or store-distribution validation. |
| + |
| +## Provenance |
| + |
| +`CoalescedRunner` comes from Eleven/Ocho's `ios/LChatCore` at commit |
| +`000b882c`, also copied into Keymail's `apple/KeymailCore`. The extraction |
| +preserves its implementation and tests the failure it prevented: concurrent |
| +refresh bursts and callers resuming before their new data has been fetched. |
| +Consumer branches replace the copies with public type aliases so existing |
| +call sites keep their API. See the adoption status on amadan before treating |
| +a consumer branch as landed. |
| + |
| +MPL-2.0; see [LICENSE](LICENSE). The extracted source retains that licence. |
diff --git a/SKILL.md b/SKILL.md| new file mode 100644 |
| index 0000000..c5c145e |
| --- /dev/null |
| +++ b/SKILL.md |
| @@ -0,0 +1,42 @@ |
| +--- |
| +name: rastrillo-native |
| +description: Build native companions for Rastrillo apps using shared Swift components, an Apple app scaffold and optional Go Mobile bindings. |
| +--- |
| + |
| +# Rastrillo Native |
| + |
| +Optional sibling of Rastrillo web. Source and review: |
| +`https://amadan.net/rastrillo/native`. Swift product/module: |
| +`RastrilloNative`, iOS 17+ and macOS 14+. Pin the package to a reviewed |
| +revision and read this file from that checkout. Core Rastrillo does not |
| +import native tooling. |
| + |
| +Start from `examples/companion/project.yml` and `Sources/Companion.swift`. |
| +Copy into the app; replace the local package path with the repository URL |
| +and revision, set app-owned bundle IDs, run `xcodegen generate`. The example |
| +checks `/api/version` only; it supplies no authenticated session. The app |
| +owns linking, navigation, storage, signing and release policy. |
| + |
| +`@MainActor CoalescedRunner.run` serializes one refresh operation. A burst |
| +during a pass queues one trailing pass; callers await its completion. |
| +Trailing passes reuse the first caller's closure. Keep one runner per |
| +operation/account, handle errors inside the closure, and never recursively |
| +call it from the work it runs. Cancelling a waiter does not cancel shared |
| +work. Existing consumers can keep their public API with |
| +`public typealias CoalescedRunner = RastrilloNative.CoalescedRunner`. |
| + |
| +Native platform integrations belong in platform adapters. Share API and |
| +crypto contracts with the web app; replay the same golden vectors before |
| +claiming compatible bytes. Reuse Rastrillo crypto/keyring where compatible; |
| +do not migrate existing envelopes just to use the package. Native secure |
| +storage, browser key storage, Web Push and APNs/FCM have different lifecycle |
| +contracts. Never treat aviso as native push transport. |
| + |
| +For an app with reusable Go logic, read [docs/go-mobile.md](docs/go-mobile.md). |
| +Go Mobile is optional; the shipped Swift component does not require it. |
| +Android bindings and UI are not supplied by this first package. |
| + |
| +Run `make ci`: Swift tests plus unsigned iOS simulator and macOS builds. |
| +Consumer adoption also runs that app's core tests and builds; a package |
| +test alone cannot detect a missing Xcode dependency. Keep release/device |
| +checks distinct from compile checks. |
diff --git a/Sources/RastrilloNative/CoalescedRunner.swift b/Sources/RastrilloNative/CoalescedRunner.swift| new file mode 100644 |
| index 0000000..eab94c4 |
| --- /dev/null |
| +++ b/Sources/RastrilloNative/CoalescedRunner.swift |
| @@ -0,0 +1,33 @@ |
| +import Foundation |
| + |
| +/// Serializes refreshes so bursts of events do not start overlapping fetches. |
| +/// Calls arriving during a pass wait for a trailing pass that starts after them. |
| +/// Use one runner per operation: trailing passes reuse the first caller's closure. |
| +/// Cancelling a waiter does not cancel the shared work. Do not call this runner |
| +/// recursively from its pass, since that would wait for itself. |
| +@MainActor |
| +public final class CoalescedRunner { |
| + private var current: Task<Void, Never>? |
| + private var queued = false |
| + |
| + public init() {} |
| + |
| + public func run(_ pass: @escaping @MainActor () async -> Void) async { |
| + if let current { |
| + queued = true |
| + await current.value |
| + return |
| + } |
| + let task = Task { @MainActor [weak self] in |
| + repeat { |
| + self?.queued = false |
| + await pass() |
| + } while self?.queued == true |
| + // No suspension after clearing: a later caller must never attach |
| + // to completed work and return without a fresh pass. |
| + self?.current = nil |
| + } |
| + current = task |
| + await task.value |
| + } |
| +} |
diff --git a/Tests/RastrilloNativeTests/CoalescedRunnerTests.swift b/Tests/RastrilloNativeTests/CoalescedRunnerTests.swift| new file mode 100644 |
| index 0000000..ad82397 |
| --- /dev/null |
| +++ b/Tests/RastrilloNativeTests/CoalescedRunnerTests.swift |
| @@ -0,0 +1,79 @@ |
| +import XCTest |
| +@testable import RastrilloNative |
| + |
| +@MainActor |
| +final class CoalescedRunnerTests: XCTestCase { |
| + func testSequentialCallsEachRefresh() async { |
| + let runner = CoalescedRunner() |
| + var passes = 0 |
| + for _ in 0..<3 { await runner.run { passes += 1 } } |
| + XCTAssertEqual(passes, 3) |
| + } |
| + |
| + func testBurstWaitsForTrailingPassAndReusesOriginalOperation() async { |
| + let runner = CoalescedRunner() |
| + var passes = 0 |
| + var release: CheckedContinuation<Void, Never>? |
| + var trailingRelease: CheckedContinuation<Void, Never>? |
| + let first = Task { @MainActor in |
| + await runner.run { |
| + passes += 1 |
| + if passes == 1 { |
| + await withCheckedContinuation { release = $0 } |
| + } else { |
| + await withCheckedContinuation { trailingRelease = $0 } |
| + } |
| + } |
| + } |
| + while release == nil { await Task.yield() } |
| + var entered = 0 |
| + var resumed = 0 |
| + let waiters = (0..<5).map { _ in |
| + Task { @MainActor in |
| + entered += 1 |
| + await runner.run { XCTFail("A burst must reuse the active operation") } |
| + resumed += 1 |
| + } |
| + } |
| + // Each task runs on this actor until run suspends, so observing all |
| + // entries proves they have queued without timing-based sleeps. |
| + while entered != 5 { await Task.yield() } |
| + XCTAssertEqual(passes, 1) |
| + XCTAssertEqual(resumed, 0) |
| + release?.resume() |
| + while trailingRelease == nil { await Task.yield() } |
| + XCTAssertEqual(resumed, 0, "Callers must also wait for the trailing pass") |
| + trailingRelease?.resume() |
| + await first.value |
| + for waiter in waiters { await waiter.value } |
| + XCTAssertEqual(passes, 2) |
| + XCTAssertEqual(resumed, 5) |
| + await runner.run { passes += 1 } |
| + XCTAssertEqual(passes, 3, "Completed work must not swallow the next refresh") |
| + } |
| + |
| + func testCancelledWaiterDoesNotCancelSharedRefresh() async { |
| + let runner = CoalescedRunner() |
| + var release: CheckedContinuation<Void, Never>? |
| + var passes = 0 |
| + let first = Task { @MainActor in |
| + await runner.run { |
| + XCTAssertFalse(Task.isCancelled) |
| + passes += 1 |
| + if passes == 1 { await withCheckedContinuation { release = $0 } } |
| + } |
| + } |
| + while release == nil { await Task.yield() } |
| + var entered = false |
| + let waiter = Task { @MainActor in |
| + entered = true |
| + await runner.run { XCTFail("Must not replace the active operation") } |
| + } |
| + while !entered { await Task.yield() } |
| + waiter.cancel() |
| + release?.resume() |
| + await first.value |
| + await waiter.value |
| + XCTAssertEqual(passes, 2) |
| + } |
| +} |
diff --git a/docs/go-mobile.md b/docs/go-mobile.md| new file mode 100644 |
| index 0000000..bece3f5 |
| --- /dev/null |
| +++ b/docs/go-mobile.md |
| @@ -0,0 +1,38 @@ |
| +# Share Go logic with a native companion |
| + |
| +Use Go Mobile when the app already has useful Go logic to share. Keep the |
| +binding surface in a small package independent of HTTP handlers, the server |
| +bootstrap and UI. The app's normal Go module owns its engine and tool pins; |
| +Rastrillo Native does not make Go a dependency of Swift-only consumers. |
| + |
| +Install `gomobile` and `gobind` from the same reviewed `golang.org/x/mobile` |
| +revision, record that revision in the app's build configuration, and run |
| +`gomobile init`. Keep that module in the engine's dependency graph as well. |
| +Do not silently update a binding tool during a release build. |
| + |
| +From the engine module, with its exported binding package at `./mobile`: |
| + |
| +```sh |
| +gomobile bind -target=ios,iossimulator,macos -o AppEngine.xcframework ./mobile |
| +gomobile bind -target=android -o appengine.aar ./mobile |
| +``` |
| + |
| +Apple builds need macOS and Xcode. Android builds need the Java toolchain, |
| +Android SDK and NDK. Use `gomobile help bind` from the pinned toolchain for |
| +its supported targets and flags. Import the XCFramework in the Apple app |
| +and the AAR in the Android app; UI and lifecycle remain platform code. |
| + |
| +These are integration recipes, not outputs validated by this package's CI. |
| +Before adopting a bridge, compile it for each supported target and exercise |
| +it through Swift/Kotlin, including errors, callbacks and cancellation. |
| + |
| +Only a subset of Go types can cross bindings. Prefer a small API carrying |
| +strings, numbers and byte slices; keep complex internal types behind it. |
| +Specify who owns callback lifetimes and which thread receives them, avoid |
| +blocking the UI thread, and batch work where repeated crossings are costly. |
| +Browser clients can keep a WebCrypto or JavaScript implementation pinned by |
| +the same test vectors. Neither a Swift package nor a mobile binding makes |
| +the web client use the Go engine automatically. |
| + |
| +Source: [Go Mobile command documentation](https://pkg.go.dev/golang.org/x/mobile/cmd/gomobile) |
| +and [Go Mobile binding model](https://go.dev/wiki/Mobile). |
diff --git a/examples/companion/Sources/Companion.swift b/examples/companion/Sources/Companion.swift| new file mode 100644 |
| index 0000000..2dfb8d5 |
| --- /dev/null |
| +++ b/examples/companion/Sources/Companion.swift |
| @@ -0,0 +1,54 @@ |
| +import SwiftUI |
| +import RastrilloNative |
| + |
| +@main |
| +struct Companion: App { |
| + var body: some Scene { |
| + WindowGroup { CompanionView() } |
| + } |
| +} |
| + |
| +@MainActor |
| +final class CompanionModel: ObservableObject { |
| + @Published var origin = "https://your-app.example" |
| + @Published var status = "Enter your app's address." |
| + private let refresh = CoalescedRunner() |
| + |
| + func connect() async { |
| + await refresh.run { [self] in |
| + guard let base = URL(string: origin), base.scheme == "https", |
| + base.host != nil, base.user == nil, base.password == nil, |
| + base.query == nil, base.fragment == nil, |
| + base.path.isEmpty || base.path == "/" else { |
| + status = "Enter an HTTPS address without a path." |
| + return |
| + } |
| + status = "Connecting…" |
| + do { |
| + let (_, response) = try await URLSession.shared.data(from: base.appendingPathComponent("api/version")) |
| + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { |
| + status = "Could not connect. Check the address and try again." |
| + return |
| + } |
| + status = "Connected." |
| + } catch { |
| + status = "Could not connect. Check your connection and try again." |
| + } |
| + } |
| + } |
| +} |
| + |
| +struct CompanionView: View { |
| + @StateObject private var model = CompanionModel() |
| + var body: some View { |
| + Form { |
| + TextField("App address", text: $model.origin) |
| + .autocorrectionDisabled() |
| + Button("Connect") { Task { await model.connect() } } |
| + Text(model.status) |
| + .accessibilityLabel(model.status) |
| + } |
| + .padding() |
| + .frame(minWidth: 300, minHeight: 180) |
| + } |
| +} |
diff --git a/examples/companion/project.yml b/examples/companion/project.yml| new file mode 100644 |
| index 0000000..2b174b7 |
| --- /dev/null |
| +++ b/examples/companion/project.yml |
| @@ -0,0 +1,30 @@ |
| +name: Companion |
| +packages: |
| + RastrilloNative: |
| + path: ../.. |
| +settings: |
| + base: |
| + SWIFT_VERSION: '5.9' |
| + GENERATE_INFOPLIST_FILE: YES |
| +targets: |
| + CompanionMac: |
| + type: application |
| + platform: macOS |
| + deploymentTarget: '14.0' |
| + sources: [Sources] |
| + dependencies: |
| + - package: RastrilloNative |
| + settings: |
| + base: |
| + PRODUCT_BUNDLE_IDENTIFIER: org.rastrillo.companion.mac |
| + CompanionIOS: |
| + type: application |
| + platform: iOS |
| + deploymentTarget: '17.0' |
| + sources: [Sources] |
| + dependencies: |
| + - package: RastrilloNative |
| + settings: |
| + base: |
| + PRODUCT_BUNDLE_IDENTIFIER: org.rastrillo.companion.ios |
| + INFOPLIST_KEY_UILaunchScreen_Generation: YES |