diff --git a/.github/AGENTS.md b/.github/AGENTS.md index be99538..285a508 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -6,7 +6,7 @@ GitHub's config for this repo: CI, releases, Pages, dependabot, issue forms, PR | Path | Purpose | | --- | --- | -| `workflows/ci.yml` | push-main + PR gate. A `changes` job diffs the base against the head and gates every other job by area; `ci pass` aggregates them | +| `workflows/ci.yml` | push-main + PR gate. A `changes` job diffs the base against the head and gates the other jobs by area (`docs checks` always runs); `ci pass` aggregates them | | `workflows/release.yml` | on `v*` tag: check the tag against `MARKETING_VERSION` and `CHANGELOG.md`, then (environment `release`) sign, notarize, write the Sparkle appcast, attest, publish. `workflow_dispatch` is a rehearsal that publishes nothing | | `workflows/go-weekly.yml` | Monday run of the Go checks, so new govulncheck findings surface without a code change, plus `go test -fuzz` for 30 s per `Fuzz*` target (a failing input is uploaded as the `fuzz-failures` artifact); files or updates one `security` issue on failure | | `workflows/github-pages.yml` | builds `_site` on PR, also deploys on push to main | diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1116d92..2e33aee 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -23,9 +23,10 @@ harness output: the summary lines and any failures, not the whole log ## Docs - [ ] User-visible change: a line under `## [Unreleased]` in `CHANGELOG.md`, written for someone deciding whether to update. That section becomes the release notes verbatim -- [ ] Durable knowledge: `docs/knowledge/` or an `AGENTS.md` +- [ ] Internals changed: the docs that describe them updated (root `AGENTS.md`, "Docs") - [ ] Defect fixed or accepted: its `docs/known-issues.md` line deleted or added - [ ] Nothing doc-worthy +- [ ] `scripts/verify-docs.sh` passes ## UI changes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbf0bee..b7a5e46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,22 @@ jobs: - run: shellcheck --version && shellcheck -x scripts/*.sh + # Unconditional: renaming or deleting any file can break a path a doc names, + # and the check takes under a second. + docs: + name: docs checks + + runs-on: ubuntu-26.04 + + timeout-minutes: 5 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - run: scripts/verify-docs.sh + # The interop harness against a relay on localhost, never the public relays: # those rate-limit a shared runner address and make the run flaky. The # public-pool scenario stays a local run (docs/BUILDING.md). @@ -152,7 +168,7 @@ jobs: v=$(cd crocmobile && go list -m -f '{{.Version}}' github.com/schollz/croc/v11) GOBIN="$RUNNER_TEMP/croc11" go install "github.com/schollz/croc/v11@$v" - # The R8 scenario's croc v10 peer. + # The croc v10 peer. - run: go install "github.com/schollz/croc/v10@$(cat scripts/croc-v10.version)" # Admission limits raised as a margin: every connection comes from @@ -281,7 +297,7 @@ jobs: pass: name: ci pass - needs: [changes, swift-format, go, contrast, shellcheck, interop, build] + needs: [changes, swift-format, go, contrast, shellcheck, docs, interop, build] if: ${{ always() }} diff --git a/AGENTS.md b/AGENTS.md index 6d38530..5dff664 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,28 @@ Native SwiftUI GUI for the croc file-transfer CLI. Do every change on a new branch and land it through a pull request (merge rules: `.github/AGENTS.md`). GitHub deletes the remote branch when the PR merges. Then delete the local branch: `git switch main && git pull --ff-only && git branch -D BRANCH`. Use `-D`, because a squash merge leaves the branch's commits out of `main`, so `-d` refuses. +## Verification + +A green build is not evidence a transfer works. A change to `crocmobile/session.go`, `CrocKit/Sources/` or `TransferController` needs its matching transfer harness run, with the output quoted (`docs/BUILDING.md`, "Verification harnesses"). Anything not run is reported as "not verified". + +## Docs + +`docs/README.md` indexes the contributor docs and `docs/AGENTS.md` holds their writing rules. A change is finished when the docs that describe it match it: + +| You changed | Bring in step | +| --- | --- | +| `crocmobile/` or `CrocKit/` | `docs/knowledge/crocmobile-bridge.md`, `docs/ARCHITECTURE.md` | +| App models, views or settings | `docs/knowledge/app-ui-architecture.md`, `docs/knowledge/features.md` | +| Anything a user sees | `CHANGELOG.md` (`## [Unreleased]`), the docs site page in `web/docs/content/` | +| `web/landing/` | `docs/knowledge/landing-page.md` | +| Scripts, CI, toolchain pins | `docs/BUILDING.md`, `docs/knowledge/tooling.md` | +| Signing, Sparkle, store builds or listing | `docs/RELEASING.md`, `docs/knowledge/app-store-listing.md` | +| The croc version | `docs/knowledge/croc-upgrade-playbook.md` (it lists the rest) | +| Security-relevant behavior | `docs/THREAT-MODEL.md` | +| A defect found and left, or fixed | `docs/known-issues.md` | + +Run `scripts/verify-docs.sh` after editing markdown; CI runs it. + ## Design Anything a user sees (app views, the landing page, the docs site, release artwork, store copy, UI strings) follows `design/`. Start at `design/AGENTS.md`. Run `scripts/verify-design.sh` after touching any of it; CI runs it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 827a0c2..5005326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,15 +20,15 @@ extra sections where a release needs them, and the project follows ## [1.0.0] - 2026-10-15 -CrocApp 1.0 is on three channels: the iOS and iPadOS App Store, the Mac App Store, and a notarized disk image for Mac. The App Store listings are linked from crocapp.dev. The other device needs croc 11 or newer. +CrocApp 1.0 is a notarized disk image for Mac. The iPhone, iPad and Mac App Store builds are in TestFlight and follow with the first App Store release, so the iPhone and iPad items below reach users then. The other device needs croc 11 or newer. -**iOS 26, iPadOS 26 or macOS 26 or later. The disk image is Apple silicon only.** +**macOS 26 or later, Apple silicon only.** Verify a download with `gh attestation verify --repo bakirgdev/CrocApp`: the release workflow attests the disk image, the update feed and the engine archive after stapling, so the attestation covers the bytes you download. `SHA256SUMS.txt` lists the SHA-256 checksums of the disk image and the engine archive. ### Added -- iPhone and iPad apps through the App Store, and a Mac App Store build. 0.9.9 was macOS only. +- iPhone and iPad apps and a Mac App Store build, in TestFlight until the first App Store release. 0.9.9 was macOS only. - The macOS direct download is now signed with a Developer ID certificate, notarized and stapled, so it opens with only the standard first-launch confirmation. - The direct download updates itself with Sparkle: choose "Check for Updates…" in the app menu. Sparkle installs an update only when its EdDSA signature matches the key built into the app, and the update check sends no system profile. Only this build has Sparkle; the App Store builds update through the App Store. - Receiving a file whose name is already taken now offers Keep Both (saves the new one as `name (1).ext`) or Replace. With auto-accept on, the setting "When a file already exists" under Confirmation chooses; it defaults to Keep Both. @@ -45,7 +45,7 @@ Verify a download with `gh attestation verify --repo bakirgdev/CrocApp`: - The transfer engine is croc v11.5.4. A croc 10 device can no longer complete a transfer with CrocApp, in either direction: the other device needs croc 11 or newer. Relays are picked from croc's public relay pool the way the croc 11 command line does, and a send fails with a clear message when no public relay answers. - Failures say what went wrong: a wrong code, a refused transfer, a lost connection, a peer on an older croc, or no public relay available, instead of one generic message. - History keeps less of a code: the first word of a code shaped like a generated one, only the first two characters of any other code, and only the server's host for Send as link. It never keeps the whole code. -- Every 1.0 screen works at the largest text sizes and with VoiceOver, including the transfer percentage. +- Every 1.0 screen works at the largest text sizes, including the transfer percentage. - The QR code on the sender's waiting screen now encodes a `https://crocapp.dev/r/` link instead of `croc://`, so a phone camera opens CrocApp, or a page that explains what to do when the app is missing. QR codes from 0.9.9 still scan in the app. ### Removed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d7005b..93d87e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,9 @@ git clone https://github.com/bakirgdev/CrocApp.git cd CrocApp ``` -Also needed: `croc` CLI (`brew install croc`) and outbound network access to the public relay. +Also needed: `croc` CLI (`brew install croc`; `croc --version` must match the pin in `crocmobile/go.mod`, otherwise `go install github.com/schollz/croc/v11@`) and outbound network access to the public relay. + +The first build, the full toolchain table and every check are in [`docs/BUILDING.md`](docs/BUILDING.md). How the code fits together: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). ## Commits @@ -86,7 +88,7 @@ govulncheck ./... - **Comments explain why, never what.** Do not delete existing comments unless the code they describe is going away. - **Stay in scope.** No drive-by refactors, no renaming unrelated symbols, no reformatting files your change does not touch. -- **No new third-party dependencies** without agreeing on them in an issue first. New dependency may end up shipped somewhere that moves people's files! +- **No new third-party dependencies** without agreeing on them in an issue first. A dependency ships inside an app that reads and writes people's files. ## Design rules @@ -102,11 +104,45 @@ A failure means your change or `design/` is wrong. Fix one of them, never the ch UI pull requests need **before and after screenshots in both light and dark**, and on both platforms if both are affected. +## Verification + +A build that compiles proves nothing about a transfer. CI runs formatting, lint, the builds, the unit tests and `verify-interop.sh` against a local relay. Everything else that applies to your change is yours to run: + +| What you changed | What to run | +|---|---| +| Swift under `app/` or `CrocKit/` | `scripts/format-swift.sh --lint`, the unit tests, a macOS and an iOS Simulator build | +| Anything in `crocmobile/` | `go test -race ./...`, `golangci-lint run ./...`, then `scripts/build-xcframework.sh` | +| `crocmobile/session.go`, `CrocKit/Sources/` or `TransferController` | the matching transfer harness, every time | +| Anything visual | `scripts/verify-design.sh` | +| Markdown | `scripts/verify-docs.sh` | + +The commands, and what each harness covers and needs, are in [`docs/BUILDING.md`](docs/BUILDING.md) under "Format and lint" and "Verification harnesses". + +### Tests + +Non-UI logic gets unit tests: Go's `testing` in `crocmobile/`, Swift Testing in `CrocKit/Tests/` and `app/CrocAppTests/`. Tests that write settings or secrets use an injected store, never the real `UserDefaults` or Keychain. There is no XCUITest: gestures, the camera and taps are checked by hand, and transfers by the harnesses. A unit test never replaces a harness run. + +**Paste the harness output into the pull request**, the summary lines and any failures. If you could not run something, write **"not verified"** and say why. That is an acceptable answer, and the maintainer runs it before merge. A claim that something passed when it never ran is not. + +## Docs + +Documentation is part of the change. Before you open the pull request: + +| What happened | Where it goes | +|---|---| +| A user-visible change | A line under `## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md), written for someone deciding whether to update | +| You changed how something works inside | The doc that describes it: [`docs/AGENTS.md`](docs/AGENTS.md) says which file owns which fact | +| You fixed a listed defect | Delete its item from [`docs/known-issues.md`](docs/known-issues.md) | +| You found a defect and left it | Add an item to `docs/known-issues.md` | +| You noticed a stale or wrong doc | Fix or delete it in the same pull request | + +Docs state what is true now, without dates or tracking IDs; [`docs/AGENTS.md`](docs/AGENTS.md) has the writing rules. Run `scripts/verify-docs.sh` after editing any markdown. + ## AI-assisted contributions Allowed, and used heavily here. Two conditions: -- **Disclose it.** Own the responsibility to report even if we live in the day and age where it may be considered as norm. Fill in the AI assistance line in the PR template. It tells the reviewer where to look harder, and nobody is judged for it. +- **Disclose it.** Fill in the AI assistance line in the PR template, even though AI use is common. You are responsible for reporting it. It tells the reviewer where to look harder, and nobody is judged for it. - **You own the code.** You understand every line, you can explain why each one is there, and you ran all verifications and checks yourself. "The model said it works" is not verification. Bulk-generated pull requests that the author has not read, does not understand, or cannot verify get closed without review. diff --git a/README.md b/README.md index 4cc5b98..9b90143 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

Send a file to any device, anywhere, with a code phrase.
- A free, open-source native SwiftUI app for iPhone, iPad, and Mac. + A free, open-source native SwiftUI app for Mac, with iPhone and iPad arriving in the App Store release.

@@ -32,7 +32,7 @@ It is a graphical front end for [croc](https://github.com/schollz/croc), the com | Mac (Apple silicon only) | `CrocApp--arm64.dmg` from [GitHub Releases](https://github.com/bakirgdev/CrocApp/releases/latest) | | Mac (Homebrew cask) | Not available yet. Homebrew accepts a cask once the repo shows [enough public interest](https://docs.brew.sh/Package-Acceptance-Policy#notability): 225 stars, 90 watchers, or 90 forks. Starring the repo helps | -The disk image is Developer ID signed, notarized, and updates itself through Sparkle (**Check for Updates…** in the app menu). Verify it with `gh attestation verify --repo bakirgdev/CrocApp` ([`SECURITY.md`](SECURITY.md)). Install guide: [crocapp.dev/docs/getting-started/install](https://crocapp.dev/docs/getting-started/install/). Or [build from source](#build-from-source). +The disk image is Developer ID signed, notarized, and updates itself through Sparkle (**Check for Updates…** in the app menu). Verify it with `gh attestation verify --repo bakirgdev/CrocApp` ([`SECURITY.md`](SECURITY.md)). Install guide: [crocapp.dev/docs/getting-started/install](https://crocapp.dev/docs/getting-started/install/). Or [build from source](docs/BUILDING.md). ## How it works @@ -132,7 +132,7 @@ Transfers are end-to-end encrypted, the code phrase authenticates both devices ( | Exclude patterns, respect `.gitignore` | `--exclude --git` | | Auto-accept transfers, off by default | `--yes` | | Require confirmation on both sides | `--ask` | -| Connection: Automatic or Relay only | `--transport auto\|relay` | +| Connection: Automatic or Relay Only | `--transport auto\|relay` | diff --git a/app/CrocApp/Models/AdvancedSettingsValidation.swift b/app/CrocApp/Models/AdvancedSettingsValidation.swift index 1a48115..095ba76 100644 --- a/app/CrocApp/Models/AdvancedSettingsValidation.swift +++ b/app/CrocApp/Models/AdvancedSettingsValidation.swift @@ -1,7 +1,7 @@ import Foundation import Network -/// Checks for the Advanced fields (R25). Each mirrors what the engine accepts +/// Checks for the Advanced fields. Each mirrors what the engine accepts /// (`crocmobile/network.go`, and croc's own parsing for the upload limit) and /// returns plain-words text, or nil when the value is fine. Blank is always /// fine: it means croc's default. Proxy texts never repeat the value, which can diff --git a/app/CrocApp/Models/AppRouter.swift b/app/CrocApp/Models/AppRouter.swift index 4af7a7f..cc057af 100644 --- a/app/CrocApp/Models/AppRouter.swift +++ b/app/CrocApp/Models/AppRouter.swift @@ -18,7 +18,7 @@ final class AppRouter { /// URLs waiting for SendView to pick up into its staged list. var pendingSendURLs: [URL] = [] /// A receive link waiting for ReceiveView to prefill from it. Opening a - /// link never connects (R22): the user taps Receive. + /// link never connects: the user taps Receive. var pendingReceive: TransferLink? /// Weak: AppRouter.shared is constructed before TransferController diff --git a/app/CrocApp/Models/AppSettings.swift b/app/CrocApp/Models/AppSettings.swift index 837ded1..3a2096a 100644 --- a/app/CrocApp/Models/AppSettings.swift +++ b/app/CrocApp/Models/AppSettings.swift @@ -2,14 +2,14 @@ import CrocKit import Foundation import Observation -/// Persisted power options (F13-F19). String relay fields store "" for +/// Persisted power options. String relay fields store "" for /// "use croc default" and go to the engine as stored; the engine resolves /// defaults and the public relay pool. `persist` gates UserDefaults writes so AutoVerify can override per-run /// without contaminating real settings. didSet does not fire for the /// assignments in init. `relayPassword` is the one exception to "UserDefaults": /// it lives in the Keychain (see `KeychainStore`) and `persist` gates that /// write too, so a harness run's `resetToDefaults()` can't blank the real -/// stored password. The Advanced proxies (R25) are the same: they can carry +/// stored password. The Advanced proxies are the same: they can carry /// credentials, so they live in the Keychain and never in UserDefaults. @MainActor @Observable @@ -24,7 +24,7 @@ final class AppSettings { case publicDefault case custom(String) case localOnly - /// A receive that connects straight to the sender's IP (R25); no relay. + /// A receive that connects straight to the sender's IP; no relay. case directToSender } @@ -50,15 +50,15 @@ final class AppSettings { var transport: Transport { didSet { save(transport.rawValue, "settings.transport") } } - /// Where "Send as link" uploads (R21). The engine refuses `http` except + /// Where "Send as link" uploads. The engine refuses `http` except /// on loopback. var storedServer: String { didSet { save(storedServer, "settings.storedServer") } } static let defaultStoredServer = StoredSendOptions().server - /// macOS menu-bar quick send (R26). On by default; the menu-bar scene is + /// macOS menu-bar quick send. On by default; the menu-bar scene is /// inserted only while this is true. var showInMenuBar: Bool { didSet { save(showInMenuBar, "settings.showInMenuBar") } } - // Advanced (R25). Defaults are croc's, read from `EngineOptions`. + // Advanced. Defaults are croc's, read from `EngineOptions`. static let advancedDefaults = EngineOptions() var curve: Curve { didSet { save(curve.rawValue, "settings.advanced.curve") } } @@ -89,7 +89,7 @@ final class AppSettings { didSet { persistProxy(httpProxy, account: Self.httpProxyAccount) } } - /// Why `text` cannot be a stored-transfer server, or nil when it can (R12e). + /// Why `text` cannot be a stored-transfer server, or nil when it can. /// Mirrors the engine: `https`, or `http` on loopback only, and nothing /// after the host. static func storedServerError(_ text: String) -> String? { diff --git a/app/CrocApp/Models/LoginItem.swift b/app/CrocApp/Models/LoginItem.swift index 2527081..528ae16 100644 --- a/app/CrocApp/Models/LoginItem.swift +++ b/app/CrocApp/Models/LoginItem.swift @@ -12,7 +12,7 @@ protocol LoginItemService { extension SMAppService: LoginItemService {} -/// Open at login (R26): the toggle shows what the system reports, never a +/// Open at login: the toggle shows what the system reports, never a /// stored copy of what we asked for, because the user can change it in System /// Settings > General > Login Items at any time. @MainActor diff --git a/app/CrocApp/Models/MenuBarQuickSend.swift b/app/CrocApp/Models/MenuBarQuickSend.swift index 5d0a000..105e17a 100644 --- a/app/CrocApp/Models/MenuBarQuickSend.swift +++ b/app/CrocApp/Models/MenuBarQuickSend.swift @@ -2,7 +2,7 @@ import AppKit import Observation -/// What the menu-bar window (R26) does on the user's behalf. A thin layer over +/// What the menu-bar window does on the user's behalf. A thin layer over /// the one `TransferController`: it never holds transfer state of its own, so /// the window shows a transfer started anywhere in the app. @MainActor diff --git a/app/CrocApp/Models/OutputFolderStore.swift b/app/CrocApp/Models/OutputFolderStore.swift index 6d47ecc..f169df6 100644 --- a/app/CrocApp/Models/OutputFolderStore.swift +++ b/app/CrocApp/Models/OutputFolderStore.swift @@ -1,7 +1,7 @@ import Foundation import Observation -/// Receive destination folder (F7). Default: app Documents on iOS, +/// Receive destination folder. Default: app Documents on iOS, /// Downloads/CrocApp on macOS. User override persists /// via bookmark: security-scoped on macOS, plain on iOS (fileImporter URLs /// are implicitly provider-scoped there; .withSecurityScope is macOS-only). diff --git a/app/CrocApp/Models/StoredTransferVault.swift b/app/CrocApp/Models/StoredTransferVault.swift index 0b33855..1a8ad2f 100644 --- a/app/CrocApp/Models/StoredTransferVault.swift +++ b/app/CrocApp/Models/StoredTransferVault.swift @@ -2,7 +2,7 @@ import CrocKit import Foundation import OSLog -/// Receipts of this device's stored uploads (R19). Each carries the upload +/// Receipts of this device's stored uploads. Each carries the upload /// token (the right to revoke) and the link (whose fragment is the decryption /// key), so they live only in the Keychain. All receipts share one item as a /// JSON array, because `SecretStore` cannot list accounts. A receipt leaves diff --git a/app/CrocApp/Models/TransferController.swift b/app/CrocApp/Models/TransferController.swift index d7de4da..471a91c 100644 --- a/app/CrocApp/Models/TransferController.swift +++ b/app/CrocApp/Models/TransferController.swift @@ -26,7 +26,7 @@ final class TransferController { private(set) var phase: Phase = .idle private(set) var direction: Direction = .send private(set) var speedBytesPerSec: Double = 0 - /// The transfer goes through a stored-transfer server (R18-R20) rather + /// The transfer goes through a stored-transfer server rather /// than a croc peer. private(set) var isStoredTransfer = false @@ -194,7 +194,7 @@ final class TransferController { } /// Uploads `urls` (regular files only) encrypted to the stored-transfer - /// server in Settings (R18). Ends in `.stored(receipt)`; the receipt also + /// server in Settings. Ends in `.stored(receipt)`; the receipt also /// goes to the vault so History can revoke it. func startStoreSend(urls: [URL], downloads: Int, lifetimeSeconds: Int) { guard !isActive else { return } @@ -280,7 +280,7 @@ final class TransferController { run { try await self.engine.startReceive(code: code, options: options) } } - /// Opens a stored link (R20): the incoming preview, then the same accept + /// Opens a stored link: the incoming preview, then the same accept /// and Keep Both / Replace answer as a live receive. Auto-accept does not /// apply; the engine always waits for `respond`. func startStoreReceive(link: URL, into folder: URL, folderIsScoped: Bool) { @@ -320,7 +320,7 @@ final class TransferController { return vault.receipt(id: id) != nil } - /// Deletes a stored upload from its server and marks its record (R19). A + /// Deletes a stored upload from its server and marks its record. A /// server that no longer has it counts as revoked. Runs alongside any /// active transfer. Throws `EngineFailure`; show /// `friendlyMessage(for:…, stored: true)`. @@ -446,7 +446,7 @@ final class TransferController { } phase = .waiting(code: code) case .connected: - // F19: gate the send behind an explicit confirm. The pipe write + // Both-sides confirm: gate the send behind an explicit confirm. The pipe write // buffers, so confirming before croc reaches its prompt is safe. if direction == .send, sendConfirmArmed { sendConfirmArmed = false diff --git a/app/CrocApp/Models/TransferHistory.swift b/app/CrocApp/Models/TransferHistory.swift index b73407e..4e4ea20 100644 --- a/app/CrocApp/Models/TransferHistory.swift +++ b/app/CrocApp/Models/TransferHistory.swift @@ -3,7 +3,7 @@ import OSLog import Observation import SwiftData -/// One finished (or failed) transfer (F12). Local only — never synced. +/// One finished (or failed) transfer. Local only — never synced. /// Privacy: stores at most `maxNames` file names, a code *hint* /// (`TransferController.codeHint`; only the server host for a stored link), /// and, for sends, bookmarks so history can re-stage the same files. Never @@ -35,7 +35,7 @@ final class TransferRecord { var names: [String] var codeHint: String var bookmarks: [Data] - /// Stored uploads only (R19): the server's id for the upload, which keys + /// Stored uploads only: the server's id for the upload, which keys /// its receipt in `StoredTransferVault`. These three are optional so /// stores written before stored transfers migrate without a schema step. var storedID: String? diff --git a/app/CrocApp/Models/TransferLink.swift b/app/CrocApp/Models/TransferLink.swift index b0622bf..835bb3b 100644 --- a/app/CrocApp/Models/TransferLink.swift +++ b/app/CrocApp/Models/TransferLink.swift @@ -1,12 +1,12 @@ import Foundation -/// R22: the one grammar for everything that can start a receive (QR scan, +/// The one grammar for everything that can start a receive (QR scan, /// paste, URL open, App Intents). Parsing only classifies; nothing here /// connects, and the key of a stored link never leaves the URL. enum TransferLink: Equatable { case code(String) /// A stored-transfer link. The Go engine checks id and key lengths; this - /// validates the shape and, per R12e, the scheme (ATS does not cover Go). + /// validates the shape and the scheme (ATS does not cover Go). case stored(URL) static let webHost = "crocapp.dev" @@ -51,7 +51,7 @@ enum TransferLink: Equatable { } } - /// What the waiting screen's QR and Share encode (R23). The code sits in + /// What the waiting screen's QR and Share encode. The code sits in /// the fragment, which browsers never send to a server. static func webURL(forCode code: String) -> URL? { var parts = URLComponents() diff --git a/app/CrocApp/Support/AppInfo.swift b/app/CrocApp/Support/AppInfo.swift index 75d8db7..13c2aea 100644 --- a/app/CrocApp/Support/AppInfo.swift +++ b/app/CrocApp/Support/AppInfo.swift @@ -1,6 +1,6 @@ import Foundation -/// R38: where the app points people for privacy and support, and the version +/// Where the app points people for privacy and support, and the version /// shown in Settings and the About panel. enum AppInfo { // Literals checked by AppInfoTests, so there is no fallback to write. diff --git a/app/CrocApp/Support/AutoVerify.swift b/app/CrocApp/Support/AutoVerify.swift index f9dc8b1..ed5aa32 100644 --- a/app/CrocApp/Support/AutoVerify.swift +++ b/app/CrocApp/Support/AutoVerify.swift @@ -25,15 +25,16 @@ import ServiceManagement /// record it again (macOS) /// --auto-share-send CODE send whatever's staged in the ShareInbox with custom code CODE /// --local force croc local-only mode (LAN/sandbox listener check) -/// --no-compress disable croc compression (F15) -/// --ask require sender-side confirm before send (F19) -/// --relay ADDR use a custom relay address (F13); also disables local-only race +/// --no-compress disable croc compression +/// --ask require sender-side confirm before send +/// --relay ADDR use a custom relay address; also disables local-only race /// --relay-password PW the custom relay's password (croc's default applies without it) /// --shown-relay default make the trust UI name the public relay whatever --relay says /// --shown-link-origin ORIGIN make a stored link show and encode ORIGIN (scheme and host, e.g. /// https://crocapp.dev) instead of the --store-server's; the upload /// still goes to --store-server /// --throttle RATE cap the upload rate for a send, e.g. 4m (croc's --throttleUpload) +/// --transport auto|relay the Connection setting for this run (croc's --transport) /// --hold park at the incoming prompt instead of auto-accepting /// --when-exists keepBoth|replace answer the incoming prompt with this file policy /// (the setting stays Keep Both, so the late answer must reach croc) diff --git a/app/CrocApp/Views/AboutSection.swift b/app/CrocApp/Views/AboutSection.swift index f02b807..4690d5e 100644 --- a/app/CrocApp/Views/AboutSection.swift +++ b/app/CrocApp/Views/AboutSection.swift @@ -1,6 +1,6 @@ import SwiftUI -/// R38: version and the privacy and support links, last section of Settings +/// Version and the privacy and support links, last section of Settings /// on both platforms. struct AboutSection: View { var body: some View { diff --git a/app/CrocApp/Views/AdvancedSettingsView.swift b/app/CrocApp/Views/AdvancedSettingsView.swift index 6b0d8f6..d32f3b9 100644 --- a/app/CrocApp/Views/AdvancedSettingsView.swift +++ b/app/CrocApp/Views/AdvancedSettingsView.swift @@ -1,7 +1,7 @@ import CrocKit import SwiftUI -/// Settings > Advanced (R25): croc's security, network and performance options. +/// Settings > Advanced: croc's security, network and performance options. /// The defaults are croc's own and right for most people. Every control shows /// its default, says which side it affects and whether the other device has to /// match, and checks its value inline with the same rules as the engine diff --git a/app/CrocApp/Views/AppCommands.swift b/app/CrocApp/Views/AppCommands.swift index cc2b6b8..aaadbf6 100644 --- a/app/CrocApp/Views/AppCommands.swift +++ b/app/CrocApp/Views/AppCommands.swift @@ -6,7 +6,7 @@ import AppKit import UIKit #endif -/// File-menu commands, on the Mac menu bar and the iPadOS 26 menu bar (R28). +/// File-menu commands, on the Mac menu bar and the iPadOS 26 menu bar. /// The receive folder opens in Finder on the Mac and in Files on iPad. struct AppCommands: Commands { let router: AppRouter diff --git a/app/CrocApp/Views/CodeField.swift b/app/CrocApp/Views/CodeField.swift index 92cff1c..211638d 100644 --- a/app/CrocApp/Views/CodeField.swift +++ b/app/CrocApp/Views/CodeField.swift @@ -5,7 +5,7 @@ import SwiftUI /// /// The paste affordance is part of this component's own spec (the wrapper's /// asymmetric padding — 14 leading for text, 6 trailing — exists to seat a -/// 38pt `PasteButton` flush inside the 50pt pill), so it lives here rather +/// system-sized `PasteButton` flush inside the 50pt pill), so it lives here rather /// than beside the field at call sites. The system owns `PasteButton`'s /// chrome for paste-permission-UI reasons and only exposes /// `.buttonBorderShape` / `.tint` / `.labelStyle` — the exact spec colors diff --git a/app/CrocApp/Views/HistoryView.swift b/app/CrocApp/Views/HistoryView.swift index b148097..3c8d943 100644 --- a/app/CrocApp/Views/HistoryView.swift +++ b/app/CrocApp/Views/HistoryView.swift @@ -2,9 +2,9 @@ import CrocKit import SwiftData import SwiftUI -/// F12: local transfer history. Re-send re-stages a past send's files via +/// Local transfer history. Re-send re-stages a past send's files via /// AppRouter (same path as dock drops); records whose files vanished get an -/// explanatory alert instead of a silent no-op. Stored uploads (R19) show when +/// explanatory alert instead of a silent no-op. Stored uploads show when /// their link expires and can be revoked here. struct HistoryView: View { @Environment(TransferController.self) private var controller diff --git a/app/CrocApp/Views/HowItWorksView.swift b/app/CrocApp/Views/HowItWorksView.swift index 32ae027..dc5da59 100644 --- a/app/CrocApp/Views/HowItWorksView.swift +++ b/app/CrocApp/Views/HowItWorksView.swift @@ -1,6 +1,6 @@ import SwiftUI -/// F36: explains the code-phrase / PAKE / untrusted-relay model. +/// Explains the code-phrase / PAKE / untrusted-relay model. /// Facts: docs/knowledge/what-is-croc.md. struct HowItWorksView: View { @Environment(AppSettings.self) private var settings diff --git a/app/CrocApp/Views/MenuBarQuickSendView.swift b/app/CrocApp/Views/MenuBarQuickSendView.swift index 8275b97..5f73c2a 100644 --- a/app/CrocApp/Views/MenuBarQuickSendView.swift +++ b/app/CrocApp/Views/MenuBarQuickSendView.swift @@ -4,7 +4,7 @@ import AppKit import CrocKit import SwiftUI -/// The menu-bar window (R26): drop files or tap a button to send, then the +/// The menu-bar window: drop files or tap a button to send, then the /// code, QR and progress of whatever transfer the shared controller runs. struct MenuBarQuickSendView: View { @Environment(TransferController.self) private var controller diff --git a/app/CrocApp/Views/PowerSettingsSections.swift b/app/CrocApp/Views/PowerSettingsSections.swift index 93d9e9c..1a0edeb 100644 --- a/app/CrocApp/Views/PowerSettingsSections.swift +++ b/app/CrocApp/Views/PowerSettingsSections.swift @@ -1,7 +1,7 @@ import CrocKit import SwiftUI -/// Power-option form sections (F13-F19), shared by the macOS Settings scene +/// Power-option form sections, shared by the macOS Settings scene /// and the iOS settings screen. Empty relay fields mean croc's defaults: with /// no custom relay, the public relay pool. struct PowerSettingsSections: View { diff --git a/app/CrocApp/Views/QRScannerView.swift b/app/CrocApp/Views/QRScannerView.swift index 0b28b4c..128320c 100644 --- a/app/CrocApp/Views/QRScannerView.swift +++ b/app/CrocApp/Views/QRScannerView.swift @@ -4,7 +4,7 @@ import SwiftUI import Vision import VisionKit -/// QR scan sheet (F6). Authorization is resolved first (four distinct +/// QR scan sheet. Authorization is resolved first (four distinct /// states -- see below), then DataScannerViewController.isSupported is /// checked as a separate capability gate: it is false on simulator and /// unsupported hardware regardless of authorization, so both checks stay. diff --git a/app/CrocApp/Views/ReceiveView.swift b/app/CrocApp/Views/ReceiveView.swift index 9c016f2..d339c61 100644 --- a/app/CrocApp/Views/ReceiveView.swift +++ b/app/CrocApp/Views/ReceiveView.swift @@ -1,14 +1,14 @@ import SwiftUI import UniformTypeIdentifiers -/// Receive flow (F4 code entry, F6 QR scan + paste, F7 output folder). +/// Receive flow (code entry, QR scan + paste, output folder). struct ReceiveView: View { @Environment(TransferController.self) private var controller @Environment(OutputFolderStore.self) private var outputFolder @Environment(AppRouter.self) private var router @State private var code = "" - /// A stored link (R20) replaces the code field until cleared. + /// A stored link replaces the code field until cleared. @State private var storedLink: URL? @State private var showScanner = false @State private var showFolderPicker = false diff --git a/app/CrocApp/Views/SendView.swift b/app/CrocApp/Views/SendView.swift index f3aa1bd..36799cf 100644 --- a/app/CrocApp/Views/SendView.swift +++ b/app/CrocApp/Views/SendView.swift @@ -1,7 +1,7 @@ import SwiftUI import UniformTypeIdentifiers -/// Send flow (F1 files, F2 folders, F3 text/clipboard, F5 custom code). +/// Send flow (files, folders, text/clipboard, custom code). struct SendView: View { @Environment(TransferController.self) private var controller @Environment(AppRouter.self) private var router @@ -30,7 +30,7 @@ struct SendView: View { /// The accent border a drag draws over the staged list (DropZone: 2pt). private let dropBorderWidth: CGFloat = 2 - /// How long a stored upload stays on the server (R18). + /// How long a stored upload stays on the server. private enum LinkLifetime: Int, CaseIterable { case hour = 3_600 case day = 86_400 @@ -135,7 +135,7 @@ struct SendView: View { customCode.trimmingCharacters(in: .whitespaces) } - /// Why "Send as link" cannot be used for what is staged, or nil (R18). + /// Why "Send as link" cannot be used for what is staged, or nil. private var linkUnavailableReason: String? { if mode == .text { return "Text can't be sent as a link. Switch to Files." } if pickedURLs.contains(where: Self.isFolder) { diff --git a/app/CrocApp/Views/SettingsView.swift b/app/CrocApp/Views/SettingsView.swift index b1dfd30..e312417 100644 --- a/app/CrocApp/Views/SettingsView.swift +++ b/app/CrocApp/Views/SettingsView.swift @@ -5,7 +5,7 @@ import ServiceManagement import UniformTypeIdentifiers /// macOS Settings scene (⌘,): a General tab (receive folder plus the power -/// options, F13-F19) and an Advanced tab (R25). The Advanced controls with +/// options) and an Advanced tab. The Advanced controls with /// their footnotes would roughly double a form that already scrolls, so they /// get their own tab, the macOS convention for a long settings window. struct SettingsView: View { diff --git a/app/CrocApp/Views/TransferStatusView.swift b/app/CrocApp/Views/TransferStatusView.swift index 5c86111..ab23b6f 100644 --- a/app/CrocApp/Views/TransferStatusView.swift +++ b/app/CrocApp/Views/TransferStatusView.swift @@ -236,7 +236,7 @@ struct TransferStatusView: View { } } - /// R23: croc's browser client only knows the default relays. + /// croc's browser client only knows the default relays. private var browserReceiveNote: String? { switch controller.activeRelay { case .publicDefault, .directToSender: nil @@ -247,7 +247,7 @@ struct TransferStatusView: View { } } - // MARK: - Confirm send (F19 both-sides confirm) + // MARK: - Confirm send (both-sides confirm) private var confirmSendView: some View { VStack(spacing: Spacing.space5) { diff --git a/app/CrocApp/Views/TrustBadge.swift b/app/CrocApp/Views/TrustBadge.swift index c2fa8f8..8d7bf6d 100644 --- a/app/CrocApp/Views/TrustBadge.swift +++ b/app/CrocApp/Views/TrustBadge.swift @@ -1,6 +1,6 @@ import SwiftUI -/// F36: end-to-end encryption badge + active relay indicator, shown on the +/// End-to-end encryption badge + active relay indicator, shown on the /// waiting and transferring screens. Relay kind is captured at transfer /// start (TransferController.activeRelay) so it can't drift mid-transfer. /// design/components.md → TrustBadge. diff --git a/app/CrocAppTests/AppSettingsAdvancedTests.swift b/app/CrocAppTests/AppSettingsAdvancedTests.swift index 75b7785..ee4e3e8 100644 --- a/app/CrocAppTests/AppSettingsAdvancedTests.swift +++ b/app/CrocAppTests/AppSettingsAdvancedTests.swift @@ -4,7 +4,7 @@ import Testing @testable import CrocApp -/// Advanced settings (R25): persistence, the proxies' Keychain-only storage and reset. +/// Advanced settings: persistence, the proxies' Keychain-only storage and reset. @Suite struct AppSettingsAdvancedTests { private let suiteName = "CrocAppTests.\(UUID().uuidString)" private let defaults: UserDefaults diff --git a/app/CrocAppTests/CodeHintTests.swift b/app/CrocAppTests/CodeHintTests.swift index 7588542..8e8ff14 100644 --- a/app/CrocAppTests/CodeHintTests.swift +++ b/app/CrocAppTests/CodeHintTests.swift @@ -3,7 +3,7 @@ import Testing @testable import CrocApp -/// History keeps a code hint, never enough of a code to reuse it (R12d). +/// History keeps a code hint, never enough of a code to reuse it. @Suite struct CodeHintTests { @Test(arguments: [ ("wiry-buzz-grope", "wiry-…"), diff --git a/app/CrocAppTests/HistoryUpgradeTests.swift b/app/CrocAppTests/HistoryUpgradeTests.swift index 336bfa4..0fb3b9a 100644 --- a/app/CrocAppTests/HistoryUpgradeTests.swift +++ b/app/CrocAppTests/HistoryUpgradeTests.swift @@ -4,7 +4,7 @@ import Testing @testable import CrocApp -/// R19: a history written by 0.9.9 must open under the current schema with +/// A history written by 0.9.9 must open under the current schema with /// every record intact. `V099` is 0.9.9's `TransferRecord` verbatim (git tag /// `v0.9.9`, `TransferHistory.swift`); the three fields added since are /// optional, so SwiftData migrates the store by itself. diff --git a/app/CrocAppTests/StoredTransferVaultTests.swift b/app/CrocAppTests/StoredTransferVaultTests.swift index 76aa427..8a53cbe 100644 --- a/app/CrocAppTests/StoredTransferVaultTests.swift +++ b/app/CrocAppTests/StoredTransferVaultTests.swift @@ -5,7 +5,7 @@ import Testing @testable import CrocApp /// Receipts carry the upload token and the link key, so they live only in the -/// Keychain (R12d, R19). Every test runs over an in-memory `SecretStore`. +/// Keychain. Every test runs over an in-memory `SecretStore`. @MainActor @Suite struct StoredTransferVaultTests { private static let account = "storedReceipts" diff --git a/app/CrocAppTests/TransferLinkTests.swift b/app/CrocAppTests/TransferLinkTests.swift index d5ea93b..1d2e958 100644 --- a/app/CrocAppTests/TransferLinkTests.swift +++ b/app/CrocAppTests/TransferLinkTests.swift @@ -3,7 +3,7 @@ import Testing @testable import CrocApp -/// R22: every accepted form parses to the same code; anything else is nil. +/// Every accepted form parses to the same code; anything else is nil. @Suite struct TransferLinkTests { private let code = "7020-alpha-beta-gamma" diff --git a/crocmobile/network_test.go b/crocmobile/network_test.go index a1e68e3..3fab28d 100644 --- a/crocmobile/network_test.go +++ b/crocmobile/network_test.go @@ -162,6 +162,9 @@ func TestNetworkGlobalsRestoredAfterPanic(t *testing.T) { t.Fatalf("StartSend err = %v, want the delegate panic", err) } assertGlobalsRestored(t, before) + if left, _ := filepath.Glob(filepath.Join(o.WorkDir, "croc-stdin-*")); len(left) != 0 { + t.Errorf("text temp file left behind after the panic: %v", left) + } } // A transfer holds its proxy and DNS settings for its whole run, and puts the diff --git a/crocmobile/osv-scanner.toml b/crocmobile/osv-scanner.toml index 703c91c..8e1ea70 100644 --- a/crocmobile/osv-scanner.toml +++ b/crocmobile/osv-scanner.toml @@ -2,4 +2,4 @@ [[IgnoredVulns]] id = "GO-2026-5932" -reason = "golang.org/x/crypto/openpgp is never imported: croc only uses x/crypto/argon2, and govulncheck reports the vulnerability as not called. No fixed x/crypto version exists." +reason = "golang.org/x/crypto/openpgp is never imported: croc imports only x/crypto's argon2, chacha20poly1305, hkdf, pbkdf2 and ssh packages, and govulncheck reports the vulnerability as not called. No fixed x/crypto version exists." diff --git a/crocmobile/session.go b/crocmobile/session.go index b81f95a..de3b50f 100644 --- a/crocmobile/session.go +++ b/crocmobile/session.go @@ -222,10 +222,14 @@ func startSession(sender bool, code string, paths []string, text string, o *Opti restoreNetwork() activeMu.Unlock() } + var tempPath string committed := false defer func() { if !committed { removeTempFiles() + if tempPath != "" { + os.Remove(tempPath) + } restoreWD(origWD) release() } @@ -253,7 +257,6 @@ func startSession(sender bool, code string, paths []string, text string, o *Opti return nil, fmt.Errorf("workdir: %w", err) } } - var tempPath string if text != "" { f, err := os.CreateTemp(".", "croc-stdin-*") if err != nil { diff --git a/design/AGENTS.md b/design/AGENTS.md index 522fabc..4b8d0b6 100644 --- a/design/AGENTS.md +++ b/design/AGENTS.md @@ -84,5 +84,5 @@ A token is live when the app or a site consumes it, or when it is a step in a sc - Values are cross-referenced. Changing one means grepping `design/` and both mirrors for it. - `scripts/build-dmg.sh` takes its geometry from `components.md` → DiskImage; change both together. -- Verified facts carry the date they were checked (contrast ratios, the Lucide version, Apple API names). Re-verifying means moving the date. +- Verified facts name what they were checked against (the Lucide version, the iOS 26 SDK, the tool that computed a contrast ratio), never a date. Re-verifying against something newer means updating that name. - Cite design sections by name (`components.md` → AcceptGate), never by line number. Line numbers rot. diff --git a/design/accessibility.md b/design/accessibility.md index aa3939e..4b458ac 100644 --- a/design/accessibility.md +++ b/design/accessibility.md @@ -17,7 +17,7 @@ Every interactive element shows a visible focus indicator that owes 3:1 (SC 1.4. ## Hit targets -44×44pt (`--hit-target-min`) for anything interactive, including small buttons, icon buttons and inline links in nav bars and footers. +44×44pt (`--hit-target-min`) for anything interactive, including small buttons, icon buttons and inline links in nav bars and footers. A system control that sets its own size (`PasteButton`) is checked with Accessibility Inspector instead. ## Never color alone diff --git a/design/brand.md b/design/brand.md index 1dadc28..99c5ab7 100644 --- a/design/brand.md +++ b/design/brand.md @@ -94,7 +94,7 @@ Apple product names follow Apple's rules: singular, never possessive, never all ## App Store badges -From Apple's [marketing guidelines](https://developer.apple.com/app-store/marketing/guidelines/) (checked 2026-10-17): +From Apple's [marketing guidelines](https://developer.apple.com/app-store/marketing/guidelines/) (re-check before shipping new artwork): - Use only Apple's badge artwork. Never modify, recolor, invert, angle or animate it; the gray border of the black badge is part of the artwork. - The black badge is preferred, in both themes. Use Apple's white badge only where the black one is visually heavy, and never when another platform's badge sits beside it. diff --git a/design/colors.md b/design/colors.md index d679c0a..c6a5475 100644 --- a/design/colors.md +++ b/design/colors.md @@ -141,4 +141,4 @@ Dark, for the record: accent 9.44 on base, 7.65 on card, 7.72 on its own tint; ` 6. **`--color-text-link` is `--color-accent-text`, not the accent**, and follows rule 1: a link on `--color-surface-grouped` or a tint takes `--color-accent-text-on-tint`, or sits on a card. -7. **The focus ring owes 3:1 and is not decoration.** It is two 2px bands, `--color-surface-base` inside and `--color-accent` outside, precisely so one of them always has a 3.41:1 edge against whatever it surrounds, including the accent-filled button. A tint cannot do this job: `--glass-tint-accent` at 12% composites to 1.14:1 on base, which is how the ring shipped invisible before 2026-07-27. Non-text contrast (1.4.11) binds focus indicators; a calm-looking ring that nobody can see is a failure, not a style. +7. **The focus ring owes 3:1 and is not decoration.** It is two 2px bands, `--color-surface-base` inside and `--color-accent` outside, precisely so one of them always has a 3.41:1 edge against whatever it surrounds, including the accent-filled button. A tint cannot do this job: `--glass-tint-accent` at 12% composites to 1.14:1 on base, which leaves the ring invisible. Non-text contrast (1.4.11) binds focus indicators; a calm-looking ring that nobody can see is a failure, not a style. diff --git a/design/components.md b/design/components.md index 7575bcb..0b82009 100644 --- a/design/components.md +++ b/design/components.md @@ -97,7 +97,7 @@ Monospaced entry for `word-word-word`, used everywhere a code phrase is typed, i Wrapper: height `--control-height-lg`, padding `0 6px 0 14px`, fill `--color-surface-base`, `--radius-md`, `--border-hairline` border: `--color-separator` idle, `--color-accent` focused, `--color-status-error` invalid. Disabled dims to `--opacity-disabled-container`. -Input: `--font-mono` at `--type-code-body`, `--tracking-code`, no autocapitalization, autocorrection or spell check. Trailing system `PasteButton` (it owns its glyph). Invalid shows a one-line `--type-footnote` message under the field, never the word "invalid". +Input: `--font-mono` at `--type-code-body`, `--tracking-code`, no autocapitalization, autocorrection or spell check. Trailing system `PasteButton`, seated in the wrapper's 6px trailing inset. The system owns its glyph and its size: SwiftUI exposes only its border shape, label style and tint. Invalid shows a one-line `--type-footnote` message under the field, never the word "invalid". ### CodePhraseDisplay and QRFrame diff --git a/design/iconography.md b/design/iconography.md index 6165f48..8cfd019 100644 --- a/design/iconography.md +++ b/design/iconography.md @@ -75,7 +75,7 @@ - **Canonical `circle-*` names, never the legacy `*-circle` spellings.** Most legacy names are byte-identical aliases, but `check-circle` is a different glyph (an open arc with an oversized tick). `circle-check` is the match for `checkmark.circle`. - The page inlines one `` sprite and references it with ``; the package is never bundled. The sprite comment names the Lucide version it was copied from. -Names verified against lucide-static 1.59.0 on 2026-10-17. SF Symbol names are checked in the SF Symbols app before first use. +Names verified against lucide-static 1.59.0. SF Symbol names are checked in the SF Symbols app before first use. ## Rendering diff --git a/design/spacing-layout.md b/design/spacing-layout.md index 4806505..757750c 100644 --- a/design/spacing-layout.md +++ b/design/spacing-layout.md @@ -49,7 +49,7 @@ What legitimately differs per device is `platforms.md`. | Token | Value | Meaning | |---|---|---| | `--control-height-lg` | 50px | `.controlSize(.large)` buttons, the code field | -| `--control-height-md` | 38px | medium buttons, the paste button | +| `--control-height-md` | 38px | medium buttons | | `--control-height-sm` | 30px | small buttons | | `--hit-target-min` | 44px | HIG minimum target: the floor for anything interactive | | `--icon-size-sm` | 17px | inline and row icons | diff --git a/design/swiftui.md b/design/swiftui.md index ffee1ae..7f273da 100644 --- a/design/swiftui.md +++ b/design/swiftui.md @@ -2,7 +2,7 @@ How the tokens reach the app. `app/CrocApp/Support/DesignTokens.swift` holds the constants and color aliases; `app/CrocApp/Assets.xcassets` holds the color sets. `scripts/verify-design.sh` checks both against `tokens.css`. -The iOS 26 APIs here were checked against Apple's documentation on 2026-07-26. If one fails to compile, re-check the docs rather than working around it. +The iOS 26 APIs here were checked against Apple's documentation for the iOS 26 SDK. If one fails to compile, re-check the docs rather than working around it. ## Colors diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..7ebc519 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,34 @@ +# docs/ + +Contributor and internals documentation: how CrocApp is built, how it works inside, how it ships, and what is known to be wrong with it. `README.md` indexes every file. How to use the app lives on the docs site (`web/docs/content/`); every visual and copy rule lives in `design/`. + +## Where a fact goes + +| Fact | File | +| --- | --- | +| How the layers fit together, who owns what state | `ARCHITECTURE.md` | +| Toolchain, first build, lint, tests, transfer harnesses | `BUILDING.md` | +| Signing, notarization, Sparkle, App Store uploads, the release checklist | `RELEASING.md` | +| A term a newcomer cannot infer | `GLOSSARY.md` | +| What is planned and what is not | `ROADMAP.md` | +| Why the security claims hold | `THREAT-MODEL.md` | +| A defect, limitation or risk the project lives with | `known-issues.md` | +| Deep reference on one subsystem or process | `knowledge/.md` | + +One fact has one home. Everywhere else links to that home instead of restating it. Code, config files and `--help` output own their own values: a doc records the reason, the constraint and the trap, which no file shows. + +## Writing rules + +- **State what is true now.** Facts, constraints, reasons and direction. Leave out dates, run IDs, plan or ticket IDs and "first we tried" stories: git history keeps them, and they go stale. A fact checked against an upstream names the version it was checked against ("croc v11.5.4", "Xcode 27 SDK"). +- **Check before writing.** Read the code, config or upstream source a claim is about. Mark anything not checked as "not verified", "inferred" or "unknown", in those words. +- **Cite by file and heading text**, as in `BUILDING.md`, "Verification harnesses". Never by section number or line number. +- **Plain English for a developer new to this repo.** One idea per sentence. A bullet stays a few lines; split anything longer, and use a table when rows compare. Define a term on first use or link `GLOSSARY.md`. Commas, colons or parentheses, not em dashes. +- **UI words match the app exactly**: the control's label in bold, in the app's capitalization (`design/content.md`, "Capitalization"). +- **Headings are stable.** Other files cite them, so renaming one means updating every citation (`git grep` the old text). + +## Keeping docs true + +- A change is finished when the docs describing it match it. The root `AGENTS.md`, "Docs", maps each area of code to the docs it must keep in step. +- A stale or wrong fact you notice gets fixed or deleted in the same change. +- `known-issues.md` gains an item when a defect is found and left; the item goes when the defect is fixed. +- `scripts/verify-docs.sh` checks every relative link and repo path in the markdown docs, and CI runs it. Run it after any docs edit. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2510680..396b0c4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,185 +1,315 @@ # Architecture -Top-down map of CrocApp. Start here, then follow the links at the bottom for depth. Doubles as a Swift/SwiftUI learning trail through the repo: every type named below is real and worth opening. +Top-down map of CrocApp. Start here, then follow the links at the end for depth. Terms like PAKE, relay and gobind are in `docs/GLOSSARY.md`. ## System summary -CrocApp is a native SwiftUI app (iOS 26 + macOS 26, one multiplatform target) that embeds [croc](https://github.com/schollz/croc) as a Go library rather than shelling out to a CLI. There is **one process**: the app binary. croc's transfer engine runs in-process as linked Go code (via a `gomobile`-built `.xcframework`), not as a subprocess and not as a daemon — iOS forbids spawning processes, so a subprocess model was never an option. A single Swift actor, `CrocEngine`, is the only thing in the app that calls into that Go code, and `TransferController` is the only thing in the app that calls `CrocEngine`. Everything above that is SwiftUI views reacting to one `@Observable` state machine. +CrocApp is a native SwiftUI app for iOS, iPadOS and macOS 26. It embeds [croc](https://github.com/schollz/croc) as a Go library instead of running the croc command. + +- There is one process: the app. croc's transfer engine runs inside it as linked Go code, built into an `.xcframework` with `gomobile`. +- It is not a subprocess and not a daemon. iOS does not allow spawning processes, so a subprocess design was never possible. +- One Swift actor, `CrocEngine`, is the only code that calls the Go code. +- `TransferController` is the only code that calls `CrocEngine`. +- Everything above that is SwiftUI views reacting to one `@Observable` state machine. + +The SwiftUI sources build three targets: + +| Target | Platforms | Channel | +| --- | --- | --- | +| `CrocApp` | iOS and macOS | App Store builds (status: `docs/knowledge/project-overview.md`, "Status") | +| `CrocApp Direct` | macOS, arm64 | Direct download (notarized DMG with Sparkle updates) | +| `CrocShare` | iOS | Share extension | ## Layer diagram ``` -croc v11.5.4 (schollz/croc, go.mod pin) - │ Go function calls (croc.NewCtx, Client polling) +croc v11.5.4 (schollz/croc, pinned in crocmobile/go.mod) + │ ▼ -crocmobile/ (Go wrapper package) - │ gobind: scalars, strings, bools, interfaces, struct pointers only - │ (no []string, no unsigned ints, no struct slices — paths/ports/excludes - │ cross as newline- or comma-joined strings; fileList/progress/done cross - │ as JSON strings) +crocmobile/ Go wrapper package + │ ▼ -gomobile bind -target ios,iossimulator,macos/arm64 +gomobile bind ios, iossimulator, macos/arm64 + │ ▼ -CrocKit/Croc.xcframework (gitignored build artifact) - │ ObjC-bridged Go symbols: CrocmobileOptions, CrocmobileTransfer, - │ CrocmobileDelegateProtocol; import Croc +CrocKit/Croc.xcframework (gitignored build output) + │ ▼ -CrocKit (Swift package: CrocEngine actor, DelegateBridge, Models.swift) - │ AsyncStream; delegate callbacks arrive on Go threads, - │ DelegateBridge only yields into the stream — no MainActor hop here +CrocKit Swift package: CrocEngine, DelegateBridge, Models.swift + │ AsyncStream ▼ -TransferController (@MainActor @Observable, app/CrocApp/Models/) - │ Phase enum; intents (startSend, startReceive, startStoreSend, - │ startStoreReceive, respond, cancel, revoke) +TransferController @MainActor @Observable, app/CrocApp/Models/ + │ Phase enum and intent methods ▼ -SwiftUI views (app/CrocApp/Views/) - switch over controller.phase; call intents; nothing else touches CrocKit +SwiftUI views app/CrocApp/Views/ ``` +Notes on the arrows: + +- Go calls croc directly (`croc.NewCtx`, then polling the client). +- gobind only passes scalars, strings, bools, interfaces and struct pointers. Lists cross as newline- or comma-joined strings. File lists, progress and the final summary cross as JSON strings. +- Delegate callbacks arrive on Go threads. `DelegateBridge` only yields into the stream and does not hop to `MainActor`. + ## Per-layer detail -### croc (vendored dependency, not this repo's code) +### croc (dependency, not this repo's code) + +- `schollz/croc/v11` v11.5.4, pinned in `crocmobile/go.mod`. +- CrocApp does not fork or reimplement it (see `docs/knowledge/project-overview.md`). +- Upgrades are a version bump plus a rebuild of the xcframework: `docs/knowledge/croc-upgrade-playbook.md`. + +### `crocmobile/`: Go wrapper + +Job: adapt croc's command-line-shaped library (process-global `os.Chdir`, stdin prompts, text receive through `os.Stdout`) so a GUI can call it repeatedly and safely. + +Files: + +- `crocmobile.go`: public API (`Options`, the `Delegate` interface, the `Transfer` handle, `StartSend`, `StartReceive`). +- `session.go`: the engine (the `session` struct, `startSession`, `startReceiveSession`, the prompt pipe on fd 0, the 10 Hz poller). +- `store.go`: stored transfers (`StartStoreSend`, `StartStoreReceive`, `RevokeStored`), run on the same `Transfer` handle. +- `errors.go`: turns croc errors into failure codes. +- `network.go`: validates Advanced options and sets croc's process-wide proxy and DNS globals. +- `relay.go`: public relay selection. +- `doc.go`: the gobind constraint comment. +- `cmd/croctest/main.go`: a command-line test harness, not linked into the app. + +Key types: + +- `Options` mirrors croc's command-line flags, using gobind-safe scalar fields only. +- `Delegate` has 8 callbacks: `OnCodeReady`, `OnConnected`, `OnFileList`, `OnProgress`, `OnText`, `OnDone`, `OnError`, `OnStored`. +- `Transfer` has `Cancel()` and `Respond(accept bool, policy string)`. The policy is `PolicyKeepBoth` or `PolicyReplace`. -`schollz/croc/v11` v11.5.4, pinned in `crocmobile/go.mod`. PAKE-secured, relay-assisted, end-to-end encrypted transfer protocol. CrocApp does not fork or reimplement it (project non-goal, `docs/knowledge/project-overview.md`); upgrades are a version bump + rebind, tracked in `docs/knowledge/croc-upgrade-playbook.md`. +Errors: -### `crocmobile/` — Go wrapper +- `OnError` receives JSON `{"code","message"}`. `StartSend` and `StartReceive` return the same JSON as their error text, because gobind gives Swift only the text. +- `errors.go` maps croc's sentinel errors, this package's `codedError` and croc v11.5.4 error text to these codes: `peerVersion`, `relayUnreachable`, `noPublicRelay`, `declined`, `refused`, `roomFull`, `badPassword`, `wrongCode`, `cancelled`, `connectionLost`, `storeLimit`, `storeExpired`, `invalidSetting`, `other`. +- The app picks its wording from the code and never reads croc's text. -Files: `crocmobile.go` (public API: `Options`, `Delegate` interface, `Transfer` handle, `StartSend`/`StartReceive`), `session.go` (the engine: `session` struct, `startSession`/`startReceiveSession`, the fd0 prompt pipe, the 10 Hz poller), `doc.go` (package-level gobind constraint comment), `cmd/croctest/main.go` (CLI harness, not linked into the app). +Network settings: -Responsibility: adapt croc's CLI-shaped library API (process-global `os.Chdir`, stdin-bound prompts, `os.Stdout`-piped text receive) into something callable repeatedly and concurrently-safely from a GUI. Key types: `Options` (mirrors croc CLI flags, gobind-safe scalar fields only), `Delegate` (8-method callback interface: `OnCodeReady`, `OnConnected`, `OnFileList`, `OnProgress`, `OnText`, `OnDone`, `OnError`, `OnStored`), `Transfer` (`Cancel()`, `Respond(accept bool, policy string)`, policy `PolicyKeepBoth` or `PolicyReplace`). `OnError` receives a JSON `{"code","message"}` payload, and `StartSend`/`StartReceive` return the same JSON as their error text (gobind hands Swift only the text); `errors.go` classifies croc and package errors into codes (`peerVersion`, `relayUnreachable`, `noPublicRelay`, `declined`, `refused`, `roomFull`, `badPassword`, `wrongCode`, `cancelled`, `connectionLost`, `storeLimit`, `storeExpired`, `invalidSetting`, `other`) from croc's sentinels, this package's `codedError`, and croc v11.5.4 error text, so the app never reads croc's wording. `store.go` adds stored transfers (`StartStoreSend`, `StartStoreReceive`, `RevokeStored`) over croc's `storeclient`, on the same `Transfer` handle and `activeMu` (`docs/knowledge/crocmobile-bridge.md`). `network.go` validates the Advanced options (`invalidSetting`) and sets croc's process-wide proxy and DNS globals for the length of one transfer, restored before `activeMu` is released; stored transfers get an explicit proxied HTTP client instead, because `RevokeStored` runs outside the lock. +- `network.go` sets croc's proxy and DNS globals for the length of one transfer and restores them before `activeMu` is released. +- Stored transfers get their own proxied HTTP client instead, because `RevokeStored` runs outside the lock. -Deliberately does **not**: run more than one transfer at a time (package-global `activeMu` mutex — second `Start*` call returns `"another transfer is active"`), expose every croc knob (`RelayPorts`, `Curve`, `HashAlgorithm`, `ThrottleUpload`, `NoMultiplexing` are settable in `Options` but unwired above CrocKit — F20-F29 backlog, `docs/knowledge/features.md`), or let a Go panic cross the gobind boundary uncaught: `StartSend`/`StartReceive`/`Cancel`/`Respond` each carry their own `recover()`, `run()` recovers around `xfer()` and again around the terminal `OnError`/`OnText`/`OnDone` calls, and `poll()` recovers per tick around `OnConnected`/`OnFileList`/`OnProgress` (`session.go`'s `reportPanic`; a recovered panic reports code `other`). `xfer()` itself has no watchdog, though — a hang there past context cancel still holds `activeMu` forever (`docs/known-issues.md`). +What it deliberately does not do: -Gobind surface: the xcframework's ObjC module is `Croc` (`import Croc`); Go symbols get an `Crocmobile`-prefixed ObjC name (`CrocmobileOptions`, `CrocmobileStartSend`, protocol `CrocmobileDelegateProtocol` → Swift `CrocmobileDelegateProtocol`). Full type-restriction list and the JSON field-by-field contract: `docs/knowledge/crocmobile-bridge.md`. +- Run more than one transfer at a time. A package-global `activeMu` mutex makes a second `Start*` call return `"another transfer is active"`. +- Let a Go panic cross into Swift. `StartSend`, `StartReceive`, `Cancel` and `Respond` each recover. `run()` recovers around `xfer()` and around the final `OnError`, `OnText` and `OnDone` calls. `poll()` recovers on every tick. A recovered panic reports code `other` (`reportPanic` in `session.go`). +- Guard against a hang inside `xfer()` past context cancel. That would hold `activeMu` for the life of the process (`docs/known-issues.md`, "Accepted"). -### `Croc.xcframework` — the gomobile bind +Gobind naming: the xcframework's module is `Croc` (`import Croc`). Go symbols get a `Crocmobile` prefix in Objective-C: `CrocmobileOptions`, `CrocmobileTransfer`, `CrocmobileStartSend`. The Go interface `Delegate` is the Objective-C protocol `CrocmobileDelegate`, which Swift imports as `CrocmobileDelegateProtocol`. The full type rules and the JSON field list are in `docs/knowledge/crocmobile-bridge.md`. -Built by `scripts/build-xcframework.sh` (`gomobile bind -target ios,iossimulator,macos/arm64`), one output feeding both platforms. Gitignored; not part of this repo's source of truth (see Build-time architecture below). +### `Croc.xcframework` -### `CrocKit/` — Swift package +- Built by `scripts/build-xcframework.sh` with `gomobile bind -target ios,iossimulator,macos/arm64`. One output serves both platforms. +- Gitignored. See "Build-time architecture" below. -`Package.swift`: swift-tools 6.0, platforms `.iOS("26.0")`, `.macOS("26.0")`, targets `Croc` (the binaryTarget), `CrocKit` (depends on `Croc`), `crockit-verify` (executable harness, depends on `CrocKit`). +### `CrocKit/`: Swift package -- `CrocEngine.swift` — `public actor CrocEngine`. Owns the one `CrocmobileTransfer` handle at a time (`activeTransfer`), exposes `startSend`/`startReceive` (return `EngineTransfer { id: TransferID, events: AsyncStream }`), `respond(accept:policy:)`, `cancel(_ id: TransferID)` (a stale ID is ignored, so a late cancel cannot reach the next transfer). `crocOptions(from:)` maps Swift `EngineOptions` to `CrocmobileOptions` — this is the actual list of knobs the GUI can reach; anything in Go's `Options` not copied here is inert from the app's perspective. `CrocEngineError`: `.transferActive`, `.startFailed(EngineFailure)`. `Models.swift` defines `EngineFailure { code: Code, message: String }` (`Code` mirrors the Go list; an unknown code or a malformed payload decodes as `.other` with the raw text) and `TransferEvent.failed(EngineFailure)`. -- `DelegateBridge.swift` — `final class DelegateBridge: NSObject, CrocmobileDelegateProtocol, @unchecked Sendable`. Bridges Go-thread callbacks into `AsyncStream.Continuation.yield`. Decodes the JSON event payloads into `Models.swift` structs; a malformed `fileList` or `done` payload becomes `.failed(...)` (code `other`) and finishes the stream; finishing runs `CrocEngine`'s `onTermination`, which cancels that transfer by its ID, so the consumer needs no cancel call. -- `Models.swift` — `EngineOptions` (Swift-side settings struct), `TransferEvent` enum (`codeReady`, `connected`, `fileList`, `progress`, `text`, `done`, `failed`), `FileList`, `TransferProgress`, `Summary` (all `Codable, Sendable`). -- `crockit-verify` — standalone executable (`send`/`receive`/`twice` subcommands), macOS-only interop harness, not shipped in the app. +`Package.swift`: swift-tools 6.0, platforms iOS 26 and macOS 26. Targets: `Croc` (the binary target), `CrocKit`, `crockit-verify` (executable) and `CrocKitTests`. -Deliberately does **not**: hop to `MainActor` itself. `DelegateBridge` only yields; the consumer (`TransferController`) does the hop, because `@MainActor @Observable` state can only be mutated there. +- `CrocEngine.swift`: `public actor CrocEngine`. + - Holds the one active `CrocmobileTransfer` (`activeTransfer`). + - `startSend` and `startReceive` return an `EngineTransfer` with an `id: TransferID` and `events: AsyncStream`. + - `startStoreSend`, `startStoreReceive` and `revokeStored` do the same for stored transfers. + - `respond(accept:policy:)` answers the accept prompt. + - `cancel(_ id: TransferID)` ignores a stale ID, so a late cancel cannot reach the next transfer. + - `crocOptions(from:)` maps Swift `EngineOptions` to `CrocmobileOptions`. It is the list of settings the app can reach. A Go option not copied there is unused by the app. + - `CrocEngineError` is `.transferActive` or `.startFailed(EngineFailure)`. +- `DelegateBridge.swift`: `final class DelegateBridge: NSObject, CrocmobileDelegateProtocol, @unchecked Sendable`. + - Turns Go-thread callbacks into `AsyncStream.Continuation.yield`. + - Decodes the JSON payloads. A malformed file list, receipt or summary becomes `.failed` with code `other`, and the stream finishes. + - When the stream finishes, `CrocEngine`'s `onTermination` cancels that transfer by ID, so the consumer needs no cancel call. +- `Models.swift`: `EngineOptions`, `FilePolicy`, `Transport`, `Curve`, `HashAlgorithm`, `ProxySettings`, `FileList`, `TransferProgress`, `Summary`, `StoredReceipt`, `EngineFailure`. + - `TransferEvent` cases: `codeReady`, `connected`, `fileList`, `progress`, `text`, `stored`, `done`, `failed`. + - `EngineFailure.Code` mirrors the Go codes. An unknown code or a malformed payload decodes as `.other` with the raw text. +- `crockit-verify`: a macOS-only executable (`send`, `receive` and `twice` subcommands) that tests interop with the croc command. It is not shipped. -### `TransferController` — app state machine +CrocKit does not hop to `MainActor`. `DelegateBridge` only yields. `TransferController` does the hop, because its `@MainActor` state can only change there. -`app/CrocApp/Models/TransferController.swift`. `@MainActor @Observable final class`. The only consumer of `CrocKit` in the app. Owns a private `CrocEngine`, a `BackgroundCoordinator`, and the `Phase` enum every view switches on. Full event-handling invariants (why `.progress` must not clobber `.incoming`, the `transferActive` retry window, copy precedence): `docs/knowledge/app-ui-architecture.md`. +### `TransferController`: app state machine + +File: `app/CrocApp/Models/TransferController.swift`. A `@MainActor @Observable final class`. + +- The only caller of `CrocEngine`. It owns a private `CrocEngine`, a `BackgroundCoordinator` and the `Phase` enum that every view switches on. +- Intents: `startSend`, `startSendText`, `startStoreSend`, `startReceive`, `startStoreReceive`, `respond`, `cancel`, `revoke`. +- `Phase` cases: `idle`, `starting`, `waiting`, `connecting`, `confirmSend`, `incoming`, `transferring`, `done`, `stored`, `failed`. +- Event-handling rules (why progress must not overwrite `incoming`, the retry window after `transferActive`, copy precedence): `docs/knowledge/app-ui-architecture.md`, "TransferController". ### SwiftUI views -`app/CrocApp/Views/`. `HomeView` is the sole `NavigationStack` (value-based `AppRouter.Route` links). `SendView`/`ReceiveView` show an input form when `!controller.isActive`, else `TransferStatusView`. `TransferStatusView` renders every non-idle `Phase`; `IncomingRequestView` (in the same file) is the accept gate. `SettingsScreen` (iOS route) and `SettingsView` (macOS `Settings` scene) both embed the shared `PowerSettingsSections`. `HistoryView`, `HowItWorksView`, `OnboardingView`, `StagedFilesSheet`, `QRCodeView`, `QRScannerView`, `TrustBadge` round out the view layer. `Models/TransferLink.swift` is the one parser for every way a code arrives (bare code, `crocapp://`, `crocapp.dev/r/`, `getcroc.com/?code=`, `croc://`); `AppRouter.open(_:)` hands it to Receive, which prefills the code and never connects by itself. Views never import `Croc` or `CrocKit` — they only read `TransferController.phase` and call its intent methods. +Folder: `app/CrocApp/Views/`. + +- `HomeView` is the only `NavigationStack`. It uses value-based `AppRouter.Route` links. +- `SendView` and `ReceiveView` show an input form when `controller.isActive` is false, otherwise `TransferStatusView`. +- `TransferStatusView` draws every non-idle `Phase`. `IncomingRequestView` (same file) is the accept screen. +- `SettingsScreen` (iOS, a route) and `SettingsView` (macOS `Settings` scene) both embed `PowerSettingsSections`. +- Others: `HistoryView`, `HowItWorksView`, `OnboardingView`, `StagedFilesSheet`, `QRCodeView`, `QRScannerView`, `TrustBadge`. +- `Models/TransferLink.swift` is the one parser for every way a link or code arrives: a bare code, a stored-transfer link, `crocapp://receive?code=`, `https://crocapp.dev/r/#`, `https://getcroc.com/?code=` and `croc://`. +- `AppRouter.open(_:)` passes the parsed link to Receive, which fills in the code and never connects by itself. +- Views may import `CrocKit` for its value types (`Summary`, `FilePolicy`, `Transport`). They never import `Croc` and never call `CrocEngine`. They read `TransferController.phase` and call its intent methods. ## Event and control flow -Real event names are the `TransferEvent` cases; `Phase` is `TransferController.Phase`. +Event names are the `TransferEvent` cases. `Phase` is `TransferController.Phase`. ### Send, happy path -1. `SendView` calls `controller.startSend(urls:customCode:)`. `phase = .starting`; `BackgroundCoordinator.transferStarted` wraps the transfer (iOS only); `engine.startSend` launches the Go session. -2. Go delegate fires `OnCodeReady` → Swift `.codeReady(code)` → `phase = .waiting(code:)`. The waiting view shows the code, a QR (`QRCodeView`) of the `https://crocapp.dev/r/#` link, and a Share Link button. -3. Peer connects: `OnConnected` → `.connected`. If `settings.bothSidesConfirm` (croc `--ask`, F19) is on, `phase = .confirmSend` instead of `.connecting` — this is the sender-side "both sides confirm" gate; the user's accept calls `respond(accept: true)`, decline calls `cancel()`. -4. Progress ticks arrive at ~10 Hz as `OnProgress` → `.progress(TransferProgress)`, `step` cycling `waiting` → `connected` → `transferring`. `TransferController` ignores `step == "waiting"` ticks and, critically, ignores all progress while `phase` is `.confirmSend` (same guard protects the receive side's `.incoming`) — croc is blocked in `GetInput` waiting for `respond()`, and a clobbered phase there means the UI can never call it. -5. Transfer completes: `OnDone` → `.done(Summary)` → `phase = .done(summary, receivedText: nil)`. `finishRecord` writes a `TransferRecord` to `HistoryStore`. +1. `SendView` calls `controller.startSend(urls:customCode:)`. Phase becomes `.starting`. On iOS, `BackgroundCoordinator.transferStarted` wraps the transfer. `engine.startSend` starts the Go session. +2. Go calls `OnCodeReady`, Swift yields `.codeReady(code)`, and phase becomes `.waiting(code:)`. The screen shows the code, a QR code (`QRCodeView`) of the `https://crocapp.dev/r/#` link, and a **Share Link** button. +3. The peer connects: `OnConnected` gives `.connected`. + - If the **Confirm on Both Sides** setting (`settings.bothSidesConfirm`, croc's `--ask`) is on, phase becomes `.confirmSend` instead of `.connecting`. Accept calls `respond(accept: true)`. **Cancel** calls `cancel()`. +4. `OnProgress` ticks arrive about 10 times a second as `.progress(TransferProgress)`. The `step` field goes `waiting`, `connected`, `transferring`. + - `TransferController` ignores `waiting` ticks. + - It also ignores all progress while phase is `.confirmSend` or `.incoming`. croc is blocked in `GetInput` waiting for `respond()`, and a phase overwritten there leaves the UI with nothing to call. +5. `OnDone` gives `.done(Summary)`, and phase becomes `.done(summary, receivedText: nil)`. `finishRecord` writes a `TransferRecord` to `HistoryStore`. ### Receive, happy path (with accept prompt) -1. `ReceiveView` calls `controller.startReceive(code:into:folderIsScoped:)`. `options.whenFileExists = settings.whenFileExists` (Keep Both by default; the policy is only used as-is under auto-accept, otherwise the incoming sheet's choice goes to `respond(accept:policy:)`); `options.autoAccept = settings.autoAccept && !settings.bothSidesConfirm` (Ask always wins over auto-accept, since engine-level `AutoAccept` answers croc's prompts with `n` and closes the pipe, which would decline instead of accept). -2. `OnFileList` → `.fileList(FileList)`. If auto-accept is off, `phase = .incoming(list, conflicts: [], blocked: [...])` immediately (unsafe names, via `ReceivedName.isUnsafe`, are computed synchronously); a detached task then stats the output folder for name collisions and back-fills `conflicts` if the phase is still `.incoming`. Progress keeps ticking underneath (guarded, per above) because croc is blocked in `utils.GetInput`, reading the dup2'd fd0 pipe (`crocmobile/session.go`), waiting on `Respond`. -3. User accepts: `TransferController.respond(accept: true, policy:)` calls `engine.respond(accept:policy:)` → `CrocmobileTransfer.respond` → Go sets the file policy on the croc client, writes `y\n` to the pipe (once, for receive; per-file for a sender-side Ask) followed by the policy's answers to croc's other stdin prompts (see `docs/knowledge/crocmobile-bridge.md`), and closes it. When the conflict scan found existing files the sheet offers Decline, Replace and Keep Both; otherwise a single Accept, which is Keep Both. `phase = .connecting`. -4. `OnProgress` resumes normally, `phase = .transferring(TransferProgress)`. -5. `OnDone` → `.done`. For a text transfer, `OnText` arrives just before `OnDone`; `receivedText` is threaded into `Phase.done(_, receivedText:)`. +1. `ReceiveView` calls `controller.startReceive(code:into:folderIsScoped:)`. + - `options.whenFileExists` comes from the settings (Keep Both by default). It applies directly only under auto-accept. Otherwise the incoming sheet's choice goes to `respond(accept:policy:)`. + - `options.autoAccept = settings.autoAccept && !settings.bothSidesConfirm`. Confirm on Both Sides wins, because engine-level auto-accept closes the prompt pipe, which croc reads as "no". +2. `OnFileList` gives `.fileList(FileList)`. With auto-accept off, phase becomes `.incoming(list, conflicts: [], blocked: [...])` at once. + - Unsafe names (`ReceivedName.isUnsafe`) are checked synchronously. + - A detached task then checks the output folder for name clashes and fills in `conflicts` if the phase is still `.incoming`. + - Progress keeps ticking underneath (and is ignored, see above) because croc is blocked in `utils.GetInput`, reading the pipe that replaced fd 0 (`crocmobile/session.go`). +3. The user accepts: `TransferController.respond(accept: true, policy:)` calls `engine.respond`, then `CrocmobileTransfer.respond`. + - Go sets the file policy on the croc client and writes `y\n` to the pipe (once for a receive, once per file for a sender-side confirm). It then writes the policy's answers to croc's other stdin prompts and closes the pipe (`docs/knowledge/crocmobile-bridge.md`). + - If the check found existing files, the sheet offers **Decline**, **Replace** and **Keep Both**. Otherwise it offers one **Accept** button, which means Keep Both. + - Phase becomes `.connecting`. +4. `OnProgress` resumes, and phase becomes `.transferring(TransferProgress)`. +5. `OnDone` gives `.done`. For a text transfer, `OnText` arrives just before `OnDone`, and the text goes into `Phase.done(_, receivedText:)`. ### Decline and cancel -- User declines `.incoming`: `respond(accept: false)` writes `n\n`, sets `declineRequested = true`. croc reports `refused files` locally (code `refused`) and `peer error: refusing files` to the peer (code `declined`). `TransferController.friendlyMessage(for:cancelRequested:declineRequested:)` maps by code, not text: "You declined the transfer." locally, "The other side declined or cancelled the transfer." on the peer's controller (a peer's cancel and a peer's decline are indistinguishable on the wire). -- User cancels mid-transfer: `controller.cancel()` sets `cancelRequested = true` and calls `engine.cancel(transferID)` (a cancel tapped before the engine returned the ID is applied as soon as it exists), which calls the Go `Transfer.Cancel()` → `session.cancel()`: context-cancels *and* closes the prompt pipe (a bare context cancel does not unblock a pending `GetInput` read, so `cancel()` must also touch the pipe). -- Any terminal event finishes the event stream, and the stream's `onTermination` cancels and clears that transfer by its ID, which frees the engine for the next transfer. `TransferController.handle(.failed)` does not call `engine.cancel`. +- The user declines `.incoming`: `respond(accept: false)` writes `n\n` and sets `declineRequested`. + - Locally, croc reports `refused files` (code `refused`). The peer gets `refusing files` (code `declined`). + - `TransferController.friendlyMessage(for:cancelRequested:declineRequested:stored:)` picks text by code: "You declined the transfer." locally, and "The other device declined or cancelled the transfer." on the peer. + - A peer's cancel and a peer's decline look the same on the wire. +- The user cancels mid-transfer: `controller.cancel()` sets `cancelRequested` and calls `engine.cancel(transferID)`. A cancel tapped before the ID exists is applied once it does. + - This reaches Go `Transfer.Cancel()`, then `session.cancel()`, which cancels the context and also closes the prompt pipe. A context cancel alone does not unblock a pending `GetInput` read. +- Any final event finishes the stream. The stream's `onTermination` cancels and clears that transfer by ID, which frees the engine. `TransferController.handle(.failed)` does not call `engine.cancel`. -Full JSON field lists, the `fileSent`-is-per-current-file wrinkle, and sub-100ms transfers that skip straight to `done`: `docs/knowledge/crocmobile-bridge.md`. +JSON field lists, the per-file `fileSent` detail, and transfers under 100 ms that jump straight to `done`: `docs/knowledge/crocmobile-bridge.md`. ## State ownership -`CrocAppApp.init()` (`app/CrocApp/CrocAppApp.swift`) builds, in order: +`CrocAppApp.init()` (`app/CrocApp/CrocAppApp.swift`) creates, in this order: -1. `AppSettings()` — must exist first; `TransferController` takes it as a constructor dependency. -2. `HistoryStore(container:)` — `ModelContainer` is in-memory when `AutoVerify.isHarnessRun`, on-disk otherwise. -3. `TransferController(settings:)`, then `controller.history = history` set explicitly (not a constructor argument, since `HistoryStore` depends on the harness check that also gates settings). -4. `OutputFolderStore()`, `LocalNetworkChecker()`, `AppRouter.shared` — order among these three doesn't matter; none depend on the others. +1. `AppSettings()`. `TransferController` takes it as a constructor argument. +2. `HistoryStore(container:)`. The `ModelContainer` is in memory when `AutoVerify.isHarnessRun` is true (see `AutoVerify harness` in `docs/GLOSSARY.md`), on disk otherwise. +3. `StoredTransferVault`, backed by the Keychain (or an in-memory store under the harness). +4. `TransferController(settings:vault:)`, then `controller.history = history`. +5. `OutputFolderStore()` and `LocalNetworkChecker()`. +6. On macOS, `MenuBarQuickSend(controller:)`. +7. `AppDependencyManager.shared.add(dependency:)` for the controller and output folder, so Shortcuts actions work right after launch. +8. `AppRouter.shared.attach(controller:)`. `AppRouter.shared` already exists before `init` runs, so the controller is attached afterwards. -`WindowGroup` content gets all six as `.environment(...)`, plus `.modelContainer(history.container)` for `@Query` in `HistoryView`. The macOS `Settings` scene is deliberately narrower: only `outputFolder` and `settings` are injected — no `router`, no `controller`, no `localNetwork` (Settings has no transfer to be busy with). +Why `history` is set after construction: not verified. The code does it, with no comment saying why. -`@MainActor @Observable` types: `AppSettings`, `TransferController`, `HistoryStore`, `OutputFolderStore`, `LocalNetworkChecker`, `AppRouter`, `ShareInbox` (all of them — the module default actor isolation is `MainActor`, see Concurrency below). +Injection: + +- `WindowGroup` content gets `controller`, `settings`, `outputFolder`, `localNetwork`, `router` and `history` through `.environment(...)`, plus `.modelContainer(history.container)` for `@Query` in `HistoryView`. +- The macOS `Settings` scene gets only `outputFolder` and `settings`. It has no transfer to be busy with. + +`@MainActor @Observable` types: `AppSettings`, `TransferController`, `HistoryStore`, `OutputFolderStore`, `LocalNetworkChecker`, `AppRouter`, `ShareInbox`, `MenuBarQuickSend`, `LoginItem`. `StoredTransferVault` and `BackgroundCoordinator` are `@MainActor` but not `@Observable`. See "Concurrency model". Persistence: | What | Where | Notes | -|---|---|---| -| Power settings (F13-F19, F41) | `UserDefaults`, keys prefixed `settings.` | `AppSettings`, `didSet`-triggered, gated by a `persist` flag | -| Output folder choice | `UserDefaults` (security-scoped bookmark) | `OutputFolderStore` | -| Transfer history (F12) | SwiftData, `TransferRecord` `@Model` | `HistoryStore`, in-memory under `AutoVerify` | -| Stored-upload receipts (R19) | Keychain, one item `storedReceipts` | `StoredTransferVault`; purged after expiry or revoke; in memory under `AutoVerify` | -| Share-extension handoff | App Group container (`group.com.bakirgdev.CrocApp`) | `ShareInbox` reads, `ShareStager` writes; `ShareInbox/batch-/` + `manifest.json` | -| Onboarding seen flag | `UserDefaults` via `@AppStorage` | `onboarding.seen`, read in `ContentView` | +| --- | --- | --- | +| Power settings (relay, local-only, compression and the like) | `UserDefaults`, keys prefixed `settings.` | `AppSettings`; each property saves in `didSet`; a `persist` flag turns saving off | +| Advanced settings | `UserDefaults`, keys `settings.advanced.*` | Proxies go to the Keychain instead (accounts `advanced.socks5Proxy`, `advanced.httpProxy`) | +| Output folder | `UserDefaults` key `outputFolderBookmark` | `OutputFolderStore`; security-scoped bookmark on macOS | +| Transfer history | SwiftData, `TransferRecord` `@Model` | `HistoryStore`; in memory under the harness | +| Stored-upload receipts | Keychain, one item `storedReceipts` | `StoredTransferVault`; removed on revoke and when expired; in memory under the harness | +| Share-extension handoff | App Group container `group.com.bakirgdev.CrocApp` | `ShareStager` (extension) writes `ShareInbox/batch-/` plus `manifest.json`; `ShareInbox` (app) reads | +| Onboarding seen flag | `UserDefaults` via `@AppStorage("onboarding.seen")` | Read in `ContentView` | ## Platform split -One SwiftUI target, `#if os(iOS)` / `#if os(macOS)` isolated to dedicated files where real divergence exists: +One shared source tree, with `#if os(iOS)` and `#if os(macOS)` kept to dedicated files where behavior really differs. | Concern | iOS | macOS | -|---|---|---| -| Backgrounding | `BackgroundCoordinator` wraps transfers in `BGContinuedProcessingTask`; system Live Activity | no-op (all `BackgroundCoordinator` bodies are `#if os(iOS)`) | -| External send entry point | Share extension (`CrocShare` appex) + `ShareInbox`/`StagedFilesSheet` | Dock-icon drop + window drop, via `AppDelegate.application(_:open:)` and `AppRouter.shared` | -| Local-network probe | `LocalNetworkChecker` (Bonjour self-probe) | same file, same probe — macOS 15 added its own Local Network privacy pane, enforced as a Network Extension packet filter rather than through TCC | -| Receive links | `.onOpenURL` in `CrocAppApp` (`crocapp://`, universal links) | `AppDelegate.application(_:open:)`; file URLs still go to Send. Both parse with `TransferLink` and call `AppRouter.open(_:)` | -| QR scan | `QRScannerView` (VisionKit `DataScannerViewController`), whole file `#if os(iOS)` | not offered (QR *generation*, `QRCodeView`, is cross-platform) | -| Settings surface | `SettingsScreen` (`Route.settings`, gear toolbar icon) | `SettingsView` (native `Settings` scene, ⌘,) | -| Menu commands | n/a | `AppCommands` (Send ⌘1, Receive ⌘2, Show Receive Folder ⇧⌘R); in `CrocApp Direct` only, "Check for Updates…" in the app menu right after "About CrocApp" | +| --- | --- | --- | +| Backgrounding | `BackgroundCoordinator` wraps transfers in `BGContinuedProcessingTask`, with a system Live Activity | Nothing; the `BackgroundCoordinator` bodies are `#if os(iOS)` | +| Sending from outside the app | Share extension (`CrocShare`) plus `ShareInbox` and `StagedFilesSheet` | Dock icon drop and window drop, through `AppDelegate.application(_:open:)` and `AppRouter.shared` | +| Local-network check | `LocalNetworkChecker` (Bonjour self-probe) | Same file, same probe | +| Receive links | `.onOpenURL` in `CrocAppApp` | `.onOpenURL` too, plus `AppDelegate.application(_:open:)`; file URLs go to Send; both parse with `TransferLink` and call `AppRouter.open(_:)` | +| QR scan | `QRScannerView` (VisionKit `DataScannerViewController`); whole file is `#if os(iOS)` | Not offered (QR generation in `QRCodeView` works on both) | +| Settings | `SettingsScreen` (`Route.settings`, gear icon in the toolbar) | `SettingsView` (native `Settings` scene, command-comma) | +| Menu commands | None | `AppCommands`: **Send…** (command-1), **Receive…** (command-2), **Show Receive Folder in Finder** or **Show Receive Folder in Files** (shift-command-R); in `CrocApp Direct` only, **Check for Updates…** right after **About CrocApp** | +| Menu bar | None | `MenuBarExtra` window (`MenuBarQuickSendView`), shown while **Show in Menu Bar** is on | -Two entitlement sets for the main app target, selected by SDK in the pbxproj (`CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]` etc.), plus one for `CrocApp Direct`: +macOS also enforces a Local Network privacy prompt of its own. How it is enforced (a Network Extension packet filter instead of TCC) is inferred from Apple's behavior, not verified here. See `docs/knowledge/apple-platform-constraints.md`. -- `app/CrocApp/CrocApp.entitlements` (macOS): `com.apple.security.app-sandbox`, `network.client`, `network.server` (needed for the local-only relay listener), `files.user-selected.read-write`, `files.downloads.read-write` (default output folder is `~/Downloads/CrocApp`), and `com.apple.developer.associated-domains` (`applinks:crocapp.dev`). Associated Domains needs a provisioning profile, so a signed local build needs team `HBSU359M33` (`docs/BUILDING.md`, section 5). -- `app/CrocApp/CrocApp-iOS.entitlements`: `com.apple.security.application-groups` (`group.com.bakirgdev.CrocApp`, for the share-extension handoff) and `com.apple.developer.associated-domains` (`applinks:crocapp.dev`). -- `app/CrocAppDirect/CrocAppDirect.entitlements`: the macOS App Store set above, plus Sparkle's mach-lookup exceptions `$(PRODUCT_BUNDLE_IDENTIFIER)-spks` and `-spki`. -- `app/CrocShare/CrocShare.entitlements`: same App Group group, nothing else (the extension has no network, no sandbox exception beyond the group). +### Entitlements -Four `Info.plist` sources (`app/Config/CrocApp-Info.plist` for iOS, `CrocApp-macOS-Info.plist` for macOS, `CrocApp-Direct-Info.plist` for `CrocApp Direct`, `CrocShare-Info.plist` for the extension) merged over Xcode's `GENERATE_INFOPLIST_FILE=YES` output; `NSCameraUsageDescription` and `NSLocalNetworkUsageDescription` are plain `INFOPLIST_KEY_*` build settings in the pbxproj, not SDK-scoped, so they apply to every platform (macOS 15 and later also gates local network access, so the local-network string is needed there too). `UIFileSharingEnabled` and `LSSupportsOpeningDocumentsInPlace` are SDK-scoped `INFOPLIST_KEY_*[sdk=...]` settings; `UIFileSharingEnabled` is also declared in `CrocApp-Info.plist`, and that plist copy is the one that takes effect (see `docs/known-issues.md`). `NSBonjourServices` is declared only in `CrocApp-Info.plist`, which applies to the iOS SDKs only. +The main app target picks its entitlements file by SDK in the project (`CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]` and the simulator variant), with the macOS file as the default. -### `CrocApp Direct`: Developer ID target +- `app/CrocApp/CrocApp.entitlements` (macOS): + - `com.apple.security.app-sandbox` + - `network.client` + - `network.server`, so croc's local-only relay can listen on a port inside the sandbox + - `files.user-selected.read-write` + - `files.downloads.read-write`, for the default output folder `~/Downloads/CrocApp` + - `com.apple.developer.associated-domains` (`applinks:crocapp.dev`) +- `app/CrocApp/CrocApp-iOS.entitlements`: `application-groups` (`group.com.bakirgdev.CrocApp`, for the share-extension handoff) and `associated-domains` (`applinks:crocapp.dev`). +- `app/CrocAppDirect/CrocAppDirect.entitlements`: the macOS set above, plus Sparkle's mach-lookup exceptions `$(PRODUCT_BUNDLE_IDENTIFIER)-spks` and `-spki`. +- `app/CrocShare/CrocShare.entitlements`: the same App Group and nothing else. -A second macOS-only, arm64 app target for the direct-download (DMG) channel: bundle ID `com.bakirgdev.CrocApp.direct`, product `CrocApp.app` (the same product name as the App Store target, so in one shared DerivedData each scheme's build replaces the other's). Its sources are the `app/CrocApp/` synchronized group plus `app/CrocAppDirect/`, which only this target compiles. It builds with the Swift condition `DIRECT`. It is the only target that links Sparkle 2.10.0 (SwiftPM): `app/CrocAppDirect/AppUpdater.swift` owns an `SPUStandardUpdaterController` and adds "Check for Updates…" to the app menu. `app/Config/CrocApp-Direct-Info.plist` carries `SUFeedURL`, `SUPublicEDKey`, `SUEnableInstallerLauncherService` and `SUEnableSystemProfiling` (from the `SPARKLE_FEED_URL` and `SPARKLE_PUBLIC_ED_KEY` build settings; the key is the owner's release public key). The shared scheme `CrocApp Direct` has no test action. Release channel details: `docs/BUILDING.md`, section 7. +Associated Domains needs a provisioning profile, so signing a local macOS build needs the team set in the project. See `docs/BUILDING.md`, "Verification harnesses". -Sandbox boundaries that shaped the design, concretely: +### Info.plist -- Security-scoped URLs: every send/receive intent in `TransferController` calls `startAccessingSecurityScopedResource()` on user-picked URLs and tracks them in `scopedURLs` for release at stream end; `OutputFolderStore` and `HistoryView`'s bookmark-resolve path do the same, on both platforms, before any `fileExists`/`stat` call (the sandbox denies `stat` on an unopened scope — a defect caught separately on each platform, per `docs/knowledge/app-ui-architecture.md`). -- App Group container: the only channel between `CrocShare` (a separate process, ~120 MB memory cap, no long-running work) and the main app. The extension copies attachment bytes in `loadFileRepresentation`'s synchronous callback (the source temp file is deleted once that callback returns) and never deletes existing batches — a batch may be mid-send. -- Output folder: iOS defaults to the app's own Documents (Files-app visible via `UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace`); macOS defaults to `~/Downloads/CrocApp`, requiring the `files.downloads.read-write` entitlement — this is why that entitlement exists at all. -- `network.server` on macOS exists solely so croc's local relay listener can bind a port inside the sandbox (`--local`/`onlyLocal`); this was proven, not assumed. +Four files in `app/Config/` are merged over Xcode's generated plist: `CrocApp-Info.plist` (iOS), `CrocApp-macOS-Info.plist`, `CrocApp-Direct-Info.plist` and `CrocShare-Info.plist`. -## Concurrency model +- `NSCameraUsageDescription` and `NSLocalNetworkUsageDescription` are plain `INFOPLIST_KEY_*` build settings, not tied to an SDK, so they apply on every platform. +- `UIFileSharingEnabled` and `LSSupportsOpeningDocumentsInPlace` are SDK-scoped `INFOPLIST_KEY_*` settings. `UIFileSharingEnabled` is also in `CrocApp-Info.plist`, and that copy is the one that works (`docs/known-issues.md`, "Accepted"). +- `NSBonjourServices` is only in `CrocApp-Info.plist`, so it applies to the iOS SDKs only. -Swift 6 language mode (`SWIFT_VERSION = 6.0`), `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` and `SWIFT_APPROACHABLE_CONCURRENCY = YES` set on every target (`CrocApp`, `CrocApp Direct`, `CrocShare`, `CrocAppTests`), Debug and Release. Practical effect: any type in the app or share extension that doesn't opt out is implicitly `@MainActor` — this is why `AppSettings`, `TransferController`, `HistoryStore`, `OutputFolderStore`, `LocalNetworkChecker`, `AppRouter`, `ShareInbox`, and `BackgroundCoordinator` all carry an explicit `@MainActor` (belt-and-suspenders documentation of what the compiler already enforces) rather than needing it to compile. +### `CrocApp Direct`: the direct-download target + +- macOS only, arm64. Bundle ID `com.bakirgdev.CrocApp.direct`. Product name `CrocApp.app`, the same as the App Store target, so two schemes sharing one DerivedData replace each other's build. +- Builds `app/CrocApp/` plus `app/CrocAppDirect/` with the Swift condition `DIRECT`. Only this target compiles `app/CrocAppDirect/`. +- Only this target links Sparkle 2.10.0 (SwiftPM). `app/CrocAppDirect/AppUpdater.swift` owns an `SPUStandardUpdaterController` and adds **Check for Updates…** to the app menu. +- `CrocApp-Direct-Info.plist` sets `SUFeedURL` and `SUPublicEDKey` from the `SPARKLE_FEED_URL` and `SPARKLE_PUBLIC_ED_KEY` build settings, plus `SUEnableInstallerLauncherService` (on) and `SUEnableSystemProfiling` (off). +- Its shared scheme has an empty test action; `CrocApp` is the scheme with tests. +- How it ships: `docs/RELEASING.md`, "Direct download (Developer ID)". + +### Sandbox limits that shaped the design + +- Security-scoped URLs: every send or receive intent in `TransferController` calls `startAccessingSecurityScopedResource()` on URLs the user picked and tracks them in `scopedURLs` until the stream ends. `OutputFolderStore` and `HistoryView`'s bookmark path do the same before any `fileExists` or `stat` call, because the sandbox denies `stat` on a scope that is not open. +- App Group container: the only channel between `CrocShare` and the app. `CrocShare` is a separate process with a memory cap of about 120 MB and no room for long work. + - It copies attachment bytes inside the synchronous `loadFileRepresentation` callback, because the source temp file is deleted when the callback returns. + - It never deletes existing batches, because one may be mid-send. +- Output folder: iOS defaults to the app's Documents folder, visible in Files through `UIFileSharingEnabled` and `LSSupportsOpeningDocumentsInPlace`. macOS defaults to `~/Downloads/CrocApp`, which is why the `files.downloads.read-write` entitlement exists. +- `network.server` on macOS exists so croc's local relay can bind a port inside the sandbox (the **Local Network Only** setting). Not verified; see `docs/knowledge/apple-platform-constraints.md`. + +## Concurrency model -`CrocEngine` is the one deliberate exception: it's a plain `actor`, isolated to its own executor, not `MainActor`. `CrocmobileDelegate` callbacks (`OnCodeReady`, `OnProgress`, etc.) arrive on arbitrary Go threads — `crocmobile`'s poller goroutine and the transfer goroutine both call the delegate directly, with no serialization guarantee about which Swift/Go thread runs which callback. `DelegateBridge` (`@unchecked Sendable`, because `NSObject` subclasses conforming to an ObjC protocol can't be verified `Sendable` by the compiler) receives those calls and does exactly one thing: `continuation.yield(...)` into the `AsyncStream`. `AsyncStream.Continuation.yield` is safe to call from any thread. The hop to `MainActor` happens implicitly at the `for await event in stream` loop inside `TransferController.run(_:)`, because that method (and the whole class) is `@MainActor`. +- Swift 6 language mode (`SWIFT_VERSION = 6.0`), `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` and `SWIFT_APPROACHABLE_CONCURRENCY = YES`. Set on every app target (`CrocApp`, `CrocApp Direct`, `CrocShare`, `CrocAppTests`), in Debug and Release. +- Effect: any type in the app or the share extension is `@MainActor` unless it opts out. The explicit `@MainActor` on `AppSettings`, `TransferController` and the others documents what the compiler already enforces. +- `CrocEngine` is the deliberate exception. It is a plain `actor` on its own executor. +- Delegate callbacks (`OnCodeReady`, `OnProgress` and the rest) arrive on arbitrary Go threads. The poller goroutine and the transfer goroutine both call the delegate directly, with no ordering between threads. +- `DelegateBridge` is `@unchecked Sendable`, because the compiler cannot check an `NSObject` subclass that conforms to an Objective-C protocol. It only calls `continuation.yield(...)`, which is safe from any thread. +- The hop to `MainActor` happens at the `for await event in transfer.events` loop in `TransferController.run(_:)`, because that class is `@MainActor`. ## Build-time architecture -`CrocKit/Package.swift` declares `Croc` as a `.binaryTarget(path: "Croc.xcframework")`; that path is gitignored. A fresh clone therefore builds nothing in `CrocKit` or the app until `scripts/build-xcframework.sh` has run once. App version numbers come from one file, `app/Config/Version.xcconfig`, the base configuration of the project-level Debug and Release configurations, so every target inherits `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION`. Full toolchain requirements, CI wiring, and the manual steps: `docs/BUILDING.md`. +- `CrocKit/Package.swift` declares `Croc` as `.binaryTarget(path: "Croc.xcframework")`, and that path is gitignored. A fresh clone cannot build `CrocKit` or the app until `scripts/build-xcframework.sh` has run once. +- App version numbers live in `app/Config/Version.xcconfig`, which every target inherits (`docs/BUILDING.md`, "Build settings that must not change casually"). +- Toolchain, CI wiring and manual steps: `docs/BUILDING.md`, "First build". ## Where to go next | Question | Document | -|---|---| -| Exact event JSON fields, croc gotchas, verification harnesses | `docs/knowledge/crocmobile-bridge.md` | -| View-by-view UI invariants, settings/trust details, platform layer specifics | `docs/knowledge/app-ui-architecture.md` | -| iOS/macOS platform limits (background, multicast, sandbox, App Store review) | `docs/knowledge/apple-platform-constraints.md` | -| Feature status (shipped / planned / skipped) | `docs/knowledge/features.md` | -| Fresh-clone build steps | `docs/BUILDING.md` | -| Design tokens, component specs, SF Symbols mapping | `design/AGENTS.md` | -| Term definitions (croc, PAKE, relay, etc.) | `docs/GLOSSARY.md` | -| Known defects, accepted papercuts | `docs/known-issues.md` | +| --- | --- | +| Event JSON fields, croc gotchas, verification harnesses | `docs/knowledge/crocmobile-bridge.md` | +| View-by-view UI rules, settings, platform layer | `docs/knowledge/app-ui-architecture.md` | +| iOS and macOS limits (background, multicast, sandbox, App Store review) | `docs/knowledge/apple-platform-constraints.md` | +| What is planned and what is not | `docs/ROADMAP.md` | +| Fresh-clone build steps | `docs/BUILDING.md`, "First build" | +| Signing, notarization, releases | `docs/RELEASING.md` | +| Design tokens, components, SF Symbols mapping | `design/AGENTS.md` | +| Term definitions | `docs/GLOSSARY.md` | +| Known defects and accepted limits | `docs/known-issues.md` | diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 4a0e4df..d9a4485 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -1,22 +1,22 @@ # Building CrocApp from source -Deep reference for the exact toolchain and the exact steps from a fresh clone to a running app. `README.md` ("Build from source") and `CONTRIBUTING.md` ("Setup") carry the short version and link here for detail; this file grounds every version, flag, and path they use, it does not restate them. +The exact toolchain and the steps from a fresh clone to a running app, plus how to lint, test and verify a change. Signing, notarization, Sparkle and App Store uploads are in `RELEASING.md`. -## 1. Prerequisites +## Prerequisites | Tool | Required version | Pinned in | Install (macOS) | |---|---|---|---| -| Host OS | macOS 26 | README, CONTRIBUTING.md | — | -| Xcode | `27.0` | `.xcode-version` | Apple Developer downloads, or `xcodes install 27.0` | +| Host OS | macOS 26 | `CONTRIBUTING.md`, "Setup" | n/a | +| Xcode | the version in `.xcode-version` | `.xcode-version` | Apple Developer downloads, or `xcodes install $(cat .xcode-version)` | | Go | `>= 1.27.2` | `crocmobile/go.mod` (`go 1.27.2`) | `brew install go` | -| gomobile / gobind | pinned by the `tool` block in `crocmobile/go.mod` (`golang.org/x/mobile`) | `scripts/build-xcframework.sh` rebuilds both into `.tools/` on every run | nothing to install by hand | -| swift-format | ships with the Xcode 27.1 toolchain (`docs/knowledge/tooling.md` records the bundled toolchain as Swift 6.4) | `.swift-format` (formatting rules; the binary itself is not separately versioned in this repo) | none, invoke via `scripts/format-swift.sh` | -| golangci-lint | `2.x`; CI pins `v2.15.0` exactly | `.github/workflows/reusable-go.yml` (`golangci-lint-action`, `version: v2.15.0`); rule config in `crocmobile/.golangci.yml` (`version: "2"` schema) | `brew install golangci-lint` | -| govulncheck | pinned by the `tool` block in `crocmobile/go.mod` (`golang.org/x/vuln`) | `.github/workflows/reusable-go.yml` (called by `ci.yml`, `release.yml`, `go-weekly.yml`) | `cd crocmobile && go tool govulncheck ./...` | -| create-dmg | unpinned | `.github/workflows/release.yml` installs it with `brew` | `brew install create-dmg`. Needed only by `scripts/build-dmg.sh`, not for a plain build | -| croc CLI | `v11.5.4`, to match the embedded engine | `crocmobile/go.mod` (`github.com/schollz/croc/v11 v11.5.4`) | `brew install croc` (lands at `$HOMEBREW_PREFIX/bin/croc`, the harnesses' default). Needed only for `scripts/verify-*.sh` and `crockit-verify`, not for a plain build | +| gomobile, gobind | the `golang.org/x/mobile` version in the `tool` block of `crocmobile/go.mod` | `scripts/build-xcframework.sh` rebuilds both into `.tools/` on every run | nothing to install by hand | +| swift-format | bundled with the pinned Xcode (Swift 6.4 toolchain), not versioned separately | `.swift-format` holds the rules | none, run `scripts/format-swift.sh` | +| golangci-lint | `2.x`; CI pins `v2.15.0` | `.github/workflows/reusable-go.yml`; rules in `crocmobile/.golangci.yml` (`version: "2"` schema) | `brew install golangci-lint` | +| govulncheck | the `golang.org/x/vuln` version in the `tool` block of `crocmobile/go.mod` | `.github/workflows/reusable-go.yml` | `cd crocmobile && go tool govulncheck ./...` | +| create-dmg | unpinned | `.github/workflows/release.yml` installs it with `brew` | `brew install create-dmg`; only `scripts/build-dmg.sh` needs it | +| croc CLI | `v11.5.4`, the version the embedded engine uses | `crocmobile/go.mod` (`github.com/schollz/croc/v11`) | `brew install croc` (lands at `$HOMEBREW_PREFIX/bin/croc`, the harnesses' default). `croc --version` must match the pin; if the bottle lags, use `go install github.com/schollz/croc/v11@` and point `CROC` at it; only `scripts/verify-*.sh` and `crockit-verify` need it | -## 2. First build from a fresh clone +## First build Nothing Swift builds before the xcframework exists. `CrocKit/Package.swift` declares: @@ -24,31 +24,35 @@ Nothing Swift builds before the xcframework exists. `CrocKit/Package.swift` decl .binaryTarget(name: "Croc", path: "Croc.xcframework"), ``` -`CrocKit/Croc.xcframework/` is gitignored (`.gitignore`, "gomobile build output: xcframework not committed"), so a fresh clone has no file there and every Swift build fails until one exists. `scripts/build-xcframework.sh` is step one, not optional. +`CrocKit/Croc.xcframework/` is gitignored (gomobile build output), so a fresh clone has no file there and every Swift build fails until one exists. `scripts/build-xcframework.sh` is step one. ```bash -# from the repo root git clone https://github.com/bakirgdev/CrocApp.git cd CrocApp scripts/build-xcframework.sh # Go engine -> CrocKit/Croc.xcframework open app/CrocApp.xcodeproj ``` -The shared schemes `CrocApp` (builds the app, tests `CrocAppTests`), `CrocApp Direct` (the Developer ID build, section 7, no test action) and `CrocShare` are committed under `app/CrocApp.xcodeproj/xcshareddata/xcschemes/`, so `xcodebuild -scheme CrocApp` works on a fresh clone without opening Xcode. A new target needs its scheme committed there too; per-user schemes in `xcuserdata/` stay gitignored. +The shared schemes `CrocApp`, `CrocApp Direct` and `CrocShare` are committed under `app/CrocApp.xcodeproj/xcshareddata/xcschemes/`, so `xcodebuild -scheme CrocApp` works on a fresh clone without opening Xcode. -Command-line builds, exactly as CI runs them: +- `CrocApp` builds the app and tests `CrocAppTests`. +- `CrocApp Direct` is the Developer ID build (`RELEASING.md`, "Release channels"). It has no testables. +- A new target needs its scheme committed there too. Per-user schemes in `xcuserdata/` stay gitignored. + +CI builds with these commands (run from `app/`). Add the `SWIFT_TREAT_WARNINGS_AS_ERRORS` and `GCC_TREAT_WARNINGS_AS_ERRORS` flags (see "Build settings that must not change casually") to reproduce a CI failure: ```bash -# from app/ -xcodebuild -scheme CrocApp -destination 'platform=macOS' -derivedDataPath /tmp/dd-mac build -xcodebuild -scheme CrocApp -destination "platform=iOS Simulator,name=$SIM" -derivedDataPath /tmp/dd-sim build +xcodebuild -scheme CrocApp -destination 'platform=macOS' \ + -derivedDataPath "$TMPDIR/dd" CODE_SIGNING_ALLOWED=NO build +xcodebuild -scheme CrocApp -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$TMPDIR/dd" CODE_SIGNING_ALLOWED=NO build ``` -`SIM` defaults to `iPhone 17 Pro` (used the same way in `scripts/verify-app-sim.sh`). List installed runtimes with `xcrun simctl list devices`. +The iOS Simulator destination needs the SDK, not an installed simulator runtime. `scripts/verify-app-sim.sh` is the only script that boots a simulator. -## 3. What build-xcframework.sh actually does +## What build-xcframework.sh does -From the repo root, the script `cd`s into `crocmobile/`, runs `go mod download`, then: +The script builds gomobile and gobind from the `tool` block into `.tools/` (prepended to its own `PATH`), then `cd`s into `crocmobile/`, runs `go mod download`, and runs: ```bash gomobile bind \ @@ -58,15 +62,15 @@ gomobile bind \ . ``` -- **Targets**: `ios`, `iossimulator`, `macos/arm64`. Only an arm64 macOS slice, no `macos/amd64`. -- **`-iosversion` / `-macosversion`**: both `26.0`, matching the deployment target everywhere else in the repo (section 6). -- **Output**: `CrocKit/Croc.xcframework`, removed and rebuilt on every run (`rm -rf "$OUT"` before binding). -- **Why arm64-only on macOS**: [golang/go#73119](https://github.com/golang/go/issues/73119), gomobile cannot currently produce a multi-arch macOS bind. `Croc.xcframework` therefore has no `x86_64` slice, and Intel Macs that can otherwise run macOS 26 (the 2019 MacBook Pro 16", 2020 iMac, and 2019 Mac Pro) cannot run CrocApp. The pbxproj pins `ARCHS[sdk=macosx*] = arm64` for this reason; removing that pin does not restore Intel support, it just breaks the link. -- **iOS device archives**: [golang/go#66500](https://github.com/golang/go/issues/66500) is a device-slice layout issue that affects App Store archives specifically, not simulator or local device builds. It is closed (fixed in x/mobile CL 574055 and 575115, included in our pin), and the first App Store upload on 2026-10-14 passed validation (section 7, "App Store builds"). +- **Targets**: `ios`, `iossimulator`, `macos/arm64`. There is no `macos/amd64` slice. +- **`-iosversion` and `-macosversion`**: both `26.0`, matching the deployment target everywhere else. +- **Output**: `CrocKit/Croc.xcframework`, removed and rebuilt on every run. +- **Why arm64 only on macOS**: [golang/go#73119](https://github.com/golang/go/issues/73119), gomobile cannot produce a multi-arch macOS bind. The xcframework has no `x86_64` slice, so Intel Macs that can otherwise run macOS 26 (the 2019 MacBook Pro 16", 2020 iMac, 2019 Mac Pro) cannot run CrocApp. The pbxproj pins `ARCHS[sdk=macosx*] = arm64` for this reason. Removing that pin does not restore Intel support, it breaks the link. +- **iOS device archives**: [golang/go#66500](https://github.com/golang/go/issues/66500) is a device-slice layout issue that affects App Store archives. x/mobile fixes it (CL 574055 and 575115), and the pinned x/mobile includes the fix. The framework `Info.plist` keeps gomobile's `MinimumOSVersion = 100.0` (an SwiftPM workaround in x/mobile) and App Store Connect accepts it, so the script does not patch it. -`gomobile`/`gobind` are built from the `golang.org/x/mobile` version in the `tool` block of `crocmobile/go.mod`. If a future release regresses the bind, pin `x/mobile` to the last good version there and record it in `docs/knowledge/tooling.md`. +If a new x/mobile release regresses the bind, pin `golang.org/x/mobile` to the last good version in `crocmobile/go.mod` and record it in `docs/knowledge/tooling.md`. -## 4. Format and lint +## Format and lint Exact command CI runs, from the repo root: @@ -74,15 +78,15 @@ Exact command CI runs, from the repo root: scripts/format-swift.sh --lint ``` -That is the only Swift check the `swift-format` CI job runs. Locally, drop the flag to rewrite in place first; the script lints afterwards either way: +It is the only Swift lint check CI runs. Locally, run it without the flag to rewrite files first; the script lints afterwards either way: ```bash scripts/format-swift.sh ``` -The script owns the list of paths swift-format covers (`app/CrocApp`, `app/CrocAppDirect`, `app/CrocShare`, `app/CrocAppTests`, `CrocKit/Sources`, `CrocKit/Tests`, `CrocKit/Package.swift`). A new Swift target goes in there, so CI and every doc pick it up at once. `CrocKit` is listed file-by-file because a recursive run there descends into `.build/` and `Croc.xcframework/`. +The script owns the list of paths swift-format covers: `app/CrocApp`, `app/CrocAppDirect`, `app/CrocShare`, `app/CrocAppTests`, `CrocKit/Sources`, `CrocKit/Tests`, `CrocKit/Package.swift` and `scripts/screenshots`. Add a new Swift target there, so CI and every doc pick it up at once. `CrocKit` is listed path by path because a recursive run on it descends into `.build/` and `Croc.xcframework/`. -**Trap**: `format --in-place` does not fix lint-only rules, several enabled rules only flag and never rewrite (for example `ReplaceForEachWithForLoop`, `OnlyOneTrailingClosureArgument`, `AlwaysUseLowerCamelCase`). A clean `format` run is not evidence `lint --strict` will pass, which is why the script always runs both. Full list and rationale: `docs/knowledge/tooling.md`. +**Trap**: `format --in-place` does not fix lint-only rules. Several enabled rules only flag and never rewrite (for example `ReplaceForEachWithForLoop`, `OnlyOneTrailingClosureArgument`, `AlwaysUseLowerCamelCase`). A clean `format` run is not evidence that `lint --strict` passes, which is why the script always runs both. The full list is in `docs/knowledge/tooling.md`, "swift-format traps". Go, from `crocmobile/`: @@ -91,225 +95,140 @@ go build ./... go vet ./... env -u GOOS -u GOARCH go test -race ./... golangci-lint run ./... -govulncheck ./... +go tool govulncheck ./... ``` -Unit tests for Swift, after `scripts/build-xcframework.sh`: +Swift unit tests, after `scripts/build-xcframework.sh`: ```bash swift test --package-path CrocKit (cd app && xcodebuild test -scheme CrocApp -destination 'platform=macOS') ``` -`go build`/`go vet` are what CI's go job runs alongside tests, lint and vuln, under `GOOS=darwin GOARCH=arm64` (the job analyzes the platform the code ships on, on a cheap Linux runner). If you install `govulncheck` yourself in a shell that already has `GOOS`/`GOARCH` set, see the troubleshooting table below, `go install` will silently misplace the binary. - -## 5. Verification harnesses - -A green build is **not** evidence a transfer works. CI runs only `verify-interop.sh`, against a local relay (job `interop (local relay)`, `docs/knowledge/tooling.md`). The other harnesses and the public-pool scenario need a public relay or a device and are flaky on shared runners, so running them is on whoever touched the transfer path. - -| Harness | Proves | Needs | Env vars | -|---|---|---|---| -| `scripts/verify-interop.sh` | 15 scenarios, crocmobile (via its `croctest` CLI) ↔ croc CLI: file/folder/text both directions, decline, cancel both directions, forced relay, LAN-only, a croc v10 peer in both directions (R8: crocmobile reports failure code `peerVersion`), every public pool relay in both directions (LAN off, asserting which relay each side used), and the receive file policy (12: a file onto an existing one with Keep Both and Replace, answered at the prompt and preset with `-yes`, then a zipped folder received twice with each policy and accept mode), and the transport (13: a 130 MiB send in each of `auto` and `relay`, both directions, asserting byte equality and that the CLI `--debug` log shows a `tailcat-offer` under `auto` and none under `relay`), and stored transfers (14: against a loopback `croctest store-server`, a wrapper upload downloaded by the CLI, a CLI upload downloaded by the wrapper, a revoke after which the download fails, and an existing name with Keep Both, Replace and a declined preview), and the Advanced options that change the wire (15, each against the CLI with the matching flag: curve, hash, base port with transfers and the sender IP, multicast group, SOCKS5 and HTTP proxy, internal DNS; the curve and hash cases assert which side's setting the other adopts, a mismatch included). With `RELAY` set every transfer uses that relay and the pool (11), transport (13), proxy and internal DNS (15) scenarios are skipped; 14 and the rest of 15 need no public relay and still run | croc CLI, croc v10 CLI (`scripts/croc-v10.version`), outbound network (public relay) unless `RELAY` is set | `CROC`, `CROC_V10`, `RELAY` | -| `scripts/verify-app-mac.sh` | macOS app, 12 scenarios: CLI→app receive (plus SwiftData history), app→CLI send, `--local`, custom self-hosted relay, `--no-compress`, `--ask` (both sides confirm), and a CLI send onto an existing file received with `--when-exists keepBoth|replace` (prints `MAC-COLLIDE-OK`), and a 130 MiB transfer in each direction under `--transport auto` and `--transport relay` (prints `MAC-TRANSPORT-OK`); then stored transfers (9, `MAC-STORE-OK`), a send started through the menu-bar view model with a file and with clipboard text (10, `MAC-MENUBAR-OK`; it saves and restores the text on the pasteboard), Open at login following `SMAppService.status` (11, `MAC-LOGIN-OK`; it registers the login item and unregisters it again), and the three Shortcuts actions run through their `perform()` against the CLI (12, `MAC-INTENT-OK`; the receive saves into the real `~/Downloads/CrocApp` and deletes the file); in the app-as-sender scenarios the CLI receiver starts only after the app writes `verify-code-ready.txt` | outbound network (scenario 8 also reaches `tailcat.dev`), croc CLI, a macOS app build | `CROC` | -| `scripts/verify-app-sim.sh` | iOS simulator, 2 scenarios, each gated on the app's result file and a byte-identical diff: CLI→app via the `--auto-receive` launch argument (prints `SIM-INTEROP-OK`), then the share-extension handoff: files staged into the App Group container the way `CrocShare` does, app launched with `--auto-share-send`, received by the croc CLI (prints `SHARE-SIM-OK`; the receiver starts only after the app writes `verify-code-ready.txt`, since a receiver that joins the room first fails) | outbound network, croc CLI, an iOS Simulator | `CROC`, `SIM` | -| `crockit-verify` (run via `swift run --package-path CrocKit crockit-verify send\|receive\|twice ...`) | Swift-layer send/receive, cancel fired mid-wire, and two transfers back-to-back in one process (proves fd0/stdout/cwd/mutex restoration composes) | outbound network, croc CLI, macOS only | optional `CROC_RELAY`, `CROC_PASS` to point at a private relay | - -`CROC` overrides the CLI path (default `$HOMEBREW_PREFIX/bin/croc`, falling back to `/opt/homebrew/bin/croc`); `SIM` picks the simulator (default `iPhone 17 Pro`). `CROC_V10` is the croc v10 binary for `verify-interop.sh` scenario 10 (default `$(go env GOPATH)/bin/croc`; install with `go install github.com/schollz/croc/v10@$(cat scripts/croc-v10.version)`), and the script fails at start if it is not the version in `scripts/croc-v10.version`. `RELAY=host:port` sends every transfer of `verify-interop.sh` through that relay instead of the public pool. CI raises the local relay's admission limits as a margin, because every connection comes from 127.0.0.1 and croc v11's relay admits 30 joins a minute per address by default (a run on default limits passed on 2026-10-13): +Design and docs, no toolchain needed: ```bash -croc relay --host 127.0.0.1 --ports 9309,9310,9311,9312,9313 --source-join-limit 1000 --room-join-limit 1000 -RELAY=127.0.0.1:9309 scripts/verify-interop.sh +scripts/verify-design.sh # token mirrors, contrast, no literals (design/AGENTS.md) +scripts/verify-docs.sh # links, repo paths and stale markers in the markdown (docs/AGENTS.md) ``` -Local signed macOS builds of `CrocApp` need a provisioning profile, because `app/CrocApp/CrocApp.entitlements` carries Associated Domains (`applinks:crocapp.dev`). `scripts/verify-app-mac.sh` and `scripts/capture-screenshots.sh` therefore pass `-allowProvisioningUpdates` to their macOS `xcodebuild`, and Xcode needs an account in team `HBSU359M33`. The first time on a new Mac, also pass `-allowProvisioningDeviceRegistration` once so the Mac is registered as a development device. A contributor outside that team cannot sign these builds; unsigned builds (`CODE_SIGNING_ALLOWED=NO`, what CI does) still work. - -A harness case that tests an answer given mid-transfer must start the transfer with the opposite or default setting, or it proves only the start options: `croctest -policy` sets the Options only under `-yes`, and `AutoVerify --when-exists` answers the sheet without touching `settings.whenFileExists`. +CI runs the Go checks with `GOOS=darwin GOARCH=arm64` set for the whole job, so `go build` and `go vet` analyze the platform the code ships on while running on a cheap Linux runner. Only `go test` unsets them. See "Troubleshooting" for the `go install` trap this causes. -Any change to `crocmobile/session.go`, `CrocKit/Sources/`, or `TransferController` needs the matching harness run, its output quoted, not summarized. If a harness could not be run, say "not verified" and why. +## Tests -## 6. Build settings that must not be casually changed +Every suite is a unit test with no network. Transfers are proven by the harnesses below. -- **Swift 6 language mode everywhere.** `SWIFT_VERSION = 6.0` on every target (`CrocApp`, `CrocApp Direct`, `CrocShare`, `CrocAppTests`, all build configurations, `app/CrocApp.xcodeproj/project.pbxproj`) and `CrocKit`'s `swift-tools-version: 6.0`. On top of that, `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` and `SWIFT_APPROACHABLE_CONCURRENCY = YES`: types are MainActor-isolated by default, `nonisolated` is the explicit opt-out, and strict concurrency violations are build errors, not warnings. -- **CI treats compiler warnings as errors.** `ci.yml` passes `SWIFT_TREAT_WARNINGS_AS_ERRORS=YES GCC_TREAT_WARNINGS_AS_ERRORS=YES` to every `xcodebuild` and `-Xswiftc -warnings-as-errors` to `swift test`, so a warning fails the PR. The project files leave both off, so a local build with a warning still runs. Reproduce CI with the same flags before pushing. -- **Deployment target is `26.0` everywhere, never a minor.** `IPHONEOS_DEPLOYMENT_TARGET = 26.0` and `MACOSX_DEPLOYMENT_TARGET = 26.0` in the pbxproj, `platforms: [.iOS("26.0"), .macOS("26.0")]` in `CrocKit/Package.swift`, and `-iosversion 26.0 -macosversion 26.0` in `scripts/build-xcframework.sh`. All four must move together. -- **macOS is arm64-only.** `"ARCHS[sdk=macosx*]" = arm64` in the pbxproj. `Croc.xcframework` has no `x86_64` macOS slice (golang/go#73119, section 3), so this pin exists to fail the link cleanly rather than let an Intel archive attempt one that cannot succeed. -- **One version source, `app/Config/Version.xcconfig`.** It holds `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` and is the base configuration of the project-level Debug and Release configurations, so every target inherits it. Do not set either in a target's build settings. The build number is bumped in its own commit before each upload or tag, and `release.yml`'s tag check reads `MARKETING_VERSION` from this file. A tag run also fails when the build number is not above every build in the published `appcast.xml`, because Sparkle only offers a higher `CFBundleVersion`; a rehearsal only warns. -- **Go toolchain pin, `>= 1.27.2` in `crocmobile/go.mod`.** Bumping it means rerunning `scripts/build-xcframework.sh` to confirm the bind still works on the new toolchain before trusting anything downstream. - -## 7. Distribution builds - -`scripts/build-devid.sh [out-dir]` builds the signed, notarized, stapled macOS disk image (default out-dir `dist`, output `CrocApp--arm64.dmg`). It does, in order: +| Suite | Run with | Covers | +|---|---|---| +| `crocmobile/*_test.go` (Go `testing`, plus `Fuzz*` targets) | `go test -race ./...` in `crocmobile/` | `session_test.go`: option mapping, prompt answers and policy, progress and file-list JSON, name handling. `errors_test.go`: error classification. `network_test.go`: Advanced option validation and the process-global network state. `relay_test.go`: public relay pool and code-to-relay mapping. `store_test.go`: stored transfers (send, receive, revoke, limits) | +| `CrocKit/Tests/CrocKitTests` (Swift Testing) | `swift test --package-path CrocKit` | `CrocOptionsMappingTests`: Swift options to the Go struct. `DelegateBridgeTests`: callbacks to the event stream. `EngineFailureTests`: failure codes. `SendPathValidationTests`: refused send paths. `StoreEngineTests`: the stored-transfer wrappers. Cancel behavior is in `CrocEngineCancelTests` | +| `app/CrocAppTests` (Swift Testing, scheme `CrocApp`) | `xcodebuild test -scheme CrocApp` | Settings persistence and validation, relay-password migration, stored-server validation, `TransferLink` parsing, code hints, friendly failure messages, received names and file location, History upgrade, the stored-transfer vault, the Show in Menu Bar setting, app info | -1. Resolves the "Developer ID Application" identity for team `HBSU359M33` by SHA-1 and archives the `CrocApp Direct` scheme (Release, arm64) with manual signing and the provisioning profile "CrocApp Direct Developer ID". Prints `archive: OK`. -2. Exports with `Config/ExportOptions-DevID.plist` and runs `codesign --verify --strict --deep`. Prints `DEVID-EXPORT-OK`. -3. Zips the app, notarizes it (`xcrun notarytool submit --wait`), staples it, and runs `syspolicy_check distribution` on the stapled app. Prints `DEVID-APP-NOTARIZED-OK`. -4. Builds the DMG with `scripts/build-dmg.sh` (prints `DMG-OK`), signs it with a timestamp, notarizes and staples it, then runs `stapler validate` and `spctl -a -vv -t open --context context:primary-signature`. Prints `DEVID-NOTARIZED-OK`. +## Verification harnesses -The app and the DMG are notarized separately, so an app copied out of the image opens offline on first launch. The script exits 1 when no Developer ID identity is present (it used to print `DEVID-PENDING-CERT` and exit 0). `syspolicy_check notary-submission` is not used: on macOS 27 it fails every app that is not yet notarized ("Gatekeeper rejected this file", spctl `source=Unnotarized Developer ID`), so a real notarization problem is read from the notary log instead. `syspolicy_check distribution` needs a stapled app, which is why it runs after the app's notarization. A local run passed with both submissions `Accepted`, both staples validated and spctl reporting `accepted, source=Notarized Developer ID`. +A green build is not evidence that a transfer works. CI runs only `scripts/verify-interop.sh`, against a local relay (job `interop (local relay)`, see `docs/knowledge/tooling.md`, "CI gotchas"). The other harnesses and the public-pool scenario need a public relay or a device and are flaky on shared runners, so running them is on whoever touched the transfer path. -To run it locally you need: +| Harness | Proves | Needs | Env vars | +|---|---|---|---| +| `scripts/verify-interop.sh` | crocmobile (through its `croctest` CLI) and the croc CLI interoperate, 15 scenarios (list below) | croc CLI, croc v10 CLI (`scripts/croc-v10.version`), outbound network unless `RELAY` is set | `CROC`, `CROC_V10`, `RELAY` | +| `scripts/verify-app-mac.sh` | the macOS app, 12 scenarios (list below) | outbound network (scenario 8 also reaches `tailcat.dev`), croc CLI, a macOS app build | `CROC` | +| `scripts/verify-app-sim.sh` | the iOS simulator, 2 scenarios (list below) | outbound network, croc CLI, an iOS Simulator | `CROC`, `SIM` | +| `crockit-verify` (`swift run --package-path CrocKit crockit-verify send\|receive\|twice ...`) | Swift-layer send and receive, cancel fired mid-wire, and two transfers back to back in one process (proves fd0, stdout, cwd and mutex restoration compose) | outbound network, croc CLI, macOS only | optional `CROC_RELAY`, `CROC_PASS` for a private relay | + +**`verify-interop.sh` scenarios** + +1. Wrapper sends a file, CLI receives. +2. CLI sends a file, wrapper receives. +3. Folder, both directions. +4. Text, both directions. +5. Decline notifies the sender. +6. Receiver cancels mid-transfer. +7. Sender cancels mid-transfer. +8. Forced relay (LAN off on both sides). +9. LAN only. +10. A croc v10 peer in both directions: crocmobile reports failure code `peerVersion`. +11. Every public pool relay in both directions with LAN off, asserting which relay each side used. +12. Receive file policy: a file onto an existing one with Keep Both and with Replace, answered at the prompt and preset with `-yes`, then a zipped folder received twice with each policy and accept mode. +13. Transport: a 130 MiB send under `auto` and under `relay`, both directions, asserting byte equality. The CLI `--debug` log must show a `tailcat-offer` under `auto` and none under `relay`. +14. Stored transfers against a loopback `croctest store-server`: a wrapper upload downloaded by the CLI, a CLI upload downloaded by the wrapper, a revoke after which the download fails, and an existing name with Keep Both, Replace and a declined preview. +15. Advanced options that change the wire, each against the CLI with the matching flag: curve, hash, base port (with transfers and the sender IP), multicast group, SOCKS5 and HTTP proxy, internal DNS. The curve and hash cases assert which side's setting the other adopts, a mismatch included. + +With `RELAY` set, every transfer uses that relay, and scenarios 11, 13, and the proxy and internal DNS parts of 15 are skipped. Scenario 14 and the rest of 15 need no public relay and still run. + +**`verify-app-mac.sh` scenarios** + +1. CLI to app receive, plus SwiftData history. +2. App to CLI send. +3. `--local`. +4. Custom self-hosted relay. +5. `--no-compress`. +6. `--ask`, both sides confirm. +7. A CLI send onto an existing file, received with `--when-exists keepBoth` and `replace` (prints `MAC-COLLIDE-OK`). +8. A 130 MiB transfer in each direction under `--transport auto` and `--transport relay` (prints `MAC-TRANSPORT-OK`). +9. Stored transfers (prints `MAC-STORE-OK`). +10. A send started through the menu-bar view model, with a file and with clipboard text (prints `MAC-MENUBAR-OK`). It saves and restores the text on the pasteboard. +11. Open at login following `SMAppService.status` (prints `MAC-LOGIN-OK`). It registers the login item and unregisters it again. +12. The three Shortcuts actions run through their `perform()` against the CLI (prints `MAC-INTENT-OK`). The receive saves into the real `~/Downloads/CrocApp` and deletes the file. + +In the app-as-sender scenarios the CLI receiver starts only after the app writes `verify-code-ready.txt`. + +**`verify-app-sim.sh` scenarios**, each gated on the app's result file and a byte-identical diff: + +1. CLI to app through the `--auto-receive` launch argument (prints `SIM-INTEROP-OK`). +2. The share-extension handoff: files staged into the App Group container the way `CrocShare` does, app launched with `--auto-share-send`, received by the CLI (prints `SHARE-SIM-OK`). The receiver starts only after the app writes `verify-code-ready.txt`, because a receiver that joins the room first fails. + +**Environment variables** + +- `CROC`: the CLI path. Default `$HOMEBREW_PREFIX/bin/croc`, falling back to `/opt/homebrew/bin/croc`. +- `SIM`: the simulator for `verify-app-sim.sh`. Default `iPhone 17 Pro`. List installed devices with `xcrun simctl list devices`. +- `CROC_V10`: the croc v10 binary for `verify-interop.sh` scenario 10. Default `$(go env GOPATH)/bin/croc`. Install it with `go install github.com/schollz/croc/v10@$(cat scripts/croc-v10.version)`. The script fails at start if the binary is not the version in `scripts/croc-v10.version`. +- `RELAY=host:port`: sends every transfer of `verify-interop.sh` through that relay instead of the public pool. + +CI raises the local relay's admission limits as a margin, because every connection comes from 127.0.0.1 and a croc v11 relay admits 30 joins a minute per address by default. To do the same locally, use the v11 binary explicitly, because a v10 `croc` first on `PATH` rejects these flags (`docs/known-issues.md`, "Harnesses"): -- a "Developer ID Application" identity for team `HBSU359M33` in the keychain search list (created at developer.apple.com, Certificates) -- the "CrocApp Direct Developer ID" profile in `~/Library/Developer/Xcode/UserData/Provisioning Profiles/` -- the notary credentials as a keychain profile, by default `crocapp-notary`: `xcrun notarytool store-credentials crocapp-notary --key --key-id --issuer `. Set `NOTARY_KEYCHAIN_PROFILE` to use another name, or `NOTARY_KEY`, `NOTARY_KEY_ID` and `NOTARY_ISSUER` (the CI form) to pass the App Store Connect key directly -- `brew install create-dmg` +```bash +"${CROC:-$(brew --prefix)/bin/croc}" relay --host 127.0.0.1 --ports 9309,9310,9311,9312,9313 \ + --source-join-limit 1000 --room-join-limit 1000 +RELAY=127.0.0.1:9309 scripts/verify-interop.sh +``` -`DEVID_WORK` sets the scratch directory (archive, DerivedData, logs). +**Signing for local macOS harness builds.** `app/CrocApp/CrocApp.entitlements` carries Associated Domains (`applinks:crocapp.dev`), so a signed local macOS build of `CrocApp` needs a provisioning profile. -`.github/workflows/release.yml` runs the same script. It starts on a `v*` tag push or on `workflow_dispatch`. A tag run checks the tag against `MARKETING_VERSION` and the dated `CHANGELOG.md` heading, builds, signs, notarizes, writes a signed `appcast.xml` (Sparkle's `generate_appcast`, seeded with the latest release's appcast, the CHANGELOG section embedded as release notes), attests the stapled files, and publishes the DMG, `appcast.xml`, the `Croc.xcframework` zip and `SHA256SUMS.txt`. A `workflow_dispatch` run is a rehearsal: it skips the tag and CHANGELOG checks and the publish job, and publishes nothing. The build job runs in the `release` environment (required reviewer, deployment policy limited to branch `main` and tags `v*`), which holds seven secrets: +- `scripts/verify-app-mac.sh` and `scripts/capture-screenshots.sh` pass `-allowProvisioningUpdates` to their macOS `xcodebuild`. +- Xcode needs an account in team `HBSU359M33`. +- On a new Mac, also pass `-allowProvisioningDeviceRegistration` once, so the Mac is registered as a development device. +- A contributor outside that team cannot sign these builds. Unsigned builds (`CODE_SIGNING_ALLOWED=NO`, what CI does) still work. -| Secret | Holds | -|---|---| -| `DEVID_P12_BASE64` | Developer ID Application certificate and key, base64 `.p12` | -| `DEVID_P12_PASSWORD` | `.p12` password | -| `DEVID_PROFILE_BASE64` | "CrocApp Direct Developer ID" profile, base64 | -| `ASC_KEY_P8` | App Store Connect API key (`.p8` text) used by `notarytool` | -| `ASC_KEY_ID` | that key's ID | -| `ASC_ISSUER_ID` | that key's issuer ID | -| `SPARKLE_ED_PRIVATE_KEY` | Sparkle Ed25519 release private key, passed to `generate_appcast` on stdin | +**Trap**: a harness case that tests an answer given mid-transfer must start the transfer with the opposite or default setting, or it proves only the start options. `croctest -policy` sets the Options only under `-yes`, and `AutoVerify --when-exists` answers the sheet without touching `settings.whenFileExists`. -Before it signs the appcast, the workflow checks that `SPARKLE_ED_PRIVATE_KEY` matches the built app's `SUPublicEDKey`, and after, that the new item carries `sparkle:edSignature`: on a mismatched key `generate_appcast` only warns, exits 0 and writes the item unsigned, which every client rejects. +Any change to `crocmobile/session.go`, `CrocKit/Sources/` or `TransferController` needs the matching harness run, with its output quoted, not summarized. If a harness could not be run, say "not verified" and why. -A dispatch rehearsal can only run once this workflow is on `main`, because dispatch needs the workflow on the default branch and the environment allows `main`. It has not run yet (`docs/known-issues.md`, "Harnesses"). +## Build settings that must not change casually -Three export option files, all under `app/Config/`: +- **Swift 6 language mode everywhere.** `SWIFT_VERSION = 6.0` on every target (`CrocApp`, `CrocApp Direct`, `CrocShare`, `CrocAppTests`, all configurations in `app/CrocApp.xcodeproj/project.pbxproj`) and `swift-tools-version: 6.0` in `CrocKit`. Also `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` and `SWIFT_APPROACHABLE_CONCURRENCY = YES`: types are MainActor-isolated by default, `nonisolated` is the explicit opt-out, and strict concurrency violations are build errors. +- **CI treats compiler warnings as errors.** `ci.yml` passes `SWIFT_TREAT_WARNINGS_AS_ERRORS=YES GCC_TREAT_WARNINGS_AS_ERRORS=YES` to its `xcodebuild` calls and `-Xswiftc -warnings-as-errors` to `swift test`, so a warning fails the PR. The project files leave both off, so a local build with a warning still runs. Pass the same flags before pushing. +- **The deployment target is `26.0` everywhere, never a minor.** `IPHONEOS_DEPLOYMENT_TARGET` and `MACOSX_DEPLOYMENT_TARGET` in the pbxproj, `platforms: [.iOS("26.0"), .macOS("26.0")]` in `CrocKit/Package.swift`, and `-iosversion 26.0 -macosversion 26.0` in `scripts/build-xcframework.sh`. All four move together. +- **macOS is arm64 only.** `"ARCHS[sdk=macosx*]" = arm64` in the pbxproj. The xcframework has no `x86_64` macOS slice (golang/go#73119), so the pin makes an Intel archive fail the link cleanly instead of attempting a link that cannot succeed. +- **One version source, `app/Config/Version.xcconfig`.** It holds `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` and is the base configuration of the project-level Debug and Release configurations, so every target inherits it. Never set either in a target's build settings. `RELEASING.md`, "Each release", covers when to bump them. +- **The Go toolchain pin is `>= 1.27.2` in `crocmobile/go.mod`.** After bumping it, rerun `scripts/build-xcframework.sh` to confirm the bind still works before trusting anything downstream. -| File | `method` | Channel | -|---|---|---| -| `ExportOptions-DevID.plist` | `developer-id` | Direct-download DMG: manual signing, `signingCertificate` "Developer ID Application", profile "CrocApp Direct Developer ID" for `com.bakirgdev.CrocApp.direct` (the `CrocApp Direct` target); exercised by `scripts/build-devid.sh` | -| `ExportOptions-iOS.plist` | `app-store-connect` | iOS App Store and TestFlight, from the `CrocApp` scheme on `generic/platform=iOS`; automatic signing; exercised by `scripts/build-appstore.sh` | -| `ExportOptions-MAS.plist` | `app-store-connect` | Mac App Store and TestFlight, from the `CrocApp` scheme on `generic/platform=macOS`; same keys as the iOS file; exercised by `scripts/build-appstore.sh` | - -All three share `teamID` `HBSU359M33` and `destination` `export`. The two App Store files also set `signingStyle` `automatic` and `manageAppVersionAndBuildNumber` false. `build-appstore.sh --upload` leaves them alone: it sets `destination` to `upload` in a scratch copy, so the committed files always describe an export to disk. - -The `CrocApp Direct` target is the Developer ID channel. It is macOS only and arm64, with bundle ID `com.bakirgdev.CrocApp.direct` and product `CrocApp.app` (the same product name as the App Store target). Its sources are the `app/CrocApp/` synchronized group plus `app/CrocAppDirect/`, built with the Swift condition `DIRECT`. It links Sparkle 2.10.0 (SwiftPM, this target only) and shows "Check for Updates…" in the app menu. Because both targets produce `CrocApp.app`, in one shared DerivedData each scheme's build replaces the other's `CrocApp.app`; the harness scripts and CI use their own `-derivedDataPath`, or build Direct last. - -`SPARKLE_PUBLIC_ED_KEY` on the Direct target is set to the owner's release public key. The key was made as plain text with `openssl genpkey -algorithm ed25519` (the base64 raw 32-byte private seed and public key, the format step 1 of the procedure below makes), not with Sparkle's `generate_keys`, which stores it in the keychain; the owner keeps the private key and it is the `SPARKLE_ED_PRIVATE_KEY` secret. `SPARKLE_FEED_URL` is `https://github.com/bakirgdev/CrocApp/releases/latest/download/appcast.xml`. - -### Testing a Sparkle update locally - -Proves a Release `CrocApp Direct` build installs a newer one. It uses a throwaway Ed25519 key, never the release key, and a feed on localhost (Sparkle accepts an `http` localhost feed and only logs a warning about HTTPS; no ATS exception is needed). - -Sparkle's tools come with the package: `/SourcePackages/artifacts/sparkle/Sparkle/bin/`. Work in a scratch directory with a `feed/` folder. - -1. Throwaway key (raw 32-byte seed and public key, base64): - ```bash - openssl genpkey -algorithm ed25519 -out key.pem - openssl pkey -in key.pem -outform DER | tail -c 32 | base64 > priv.b64 - openssl pkey -in key.pem -pubout -outform DER | tail -c 32 | base64 > pub.b64 - ``` -2. Build twice, as build 1 and build 2, from `app/`: - ```bash - for N in 1 2; do - xcodebuild -scheme 'CrocApp Direct' -configuration Release -destination 'platform=macOS' \ - -derivedDataPath "dd$N" -allowProvisioningUpdates CURRENT_PROJECT_VERSION=$N \ - "SPARKLE_PUBLIC_ED_KEY=$(cat pub.b64)" SPARKLE_FEED_URL=http://127.0.0.1:8765/appcast.xml build - done - ``` -3. Zip build 2 into `feed/` and write the signed appcast: - ```bash - ditto -c -k --sequesterRsrc --keepParent dd2/Build/Products/Release/CrocApp.app feed/CrocApp-2.zip - generate_appcast --ed-key-file - --download-url-prefix http://127.0.0.1:8765/ feed < priv.b64 - ``` -4. Serve it: `cd feed && python3 -m http.server 8765 --bind 127.0.0.1`. -5. Copy build 1 somewhere writable (`ditto dd1/Build/Products/Release/CrocApp.app apps/CrocApp.app`), launch it, choose "Check for Updates…", then "Install Update", then "Install and Relaunch" in Sparkle's standard UI. From the second launch on, Sparkle first asks about automatic checks. -6. Expect the old process to exit, a new process to start, and `apps/CrocApp.app` to report `CFBundleVersion` 2. - -4 of 5 runs of this procedure passed; the first run hung after "Install and Relaunch" (`docs/known-issues.md`, "Harnesses"). - -### App Store builds - -`scripts/build-appstore.sh [--upload] [ios|macos]...` builds the `CrocApp` scheme (Release) for the App Store channels. No platform argument means both. For each platform it: - -1. Archives for `generic/platform=iOS` or `generic/platform=macOS` and prints ` archive: OK`. -2. Runs `xcodebuild -exportArchive` with `ExportOptions-iOS.plist` or `ExportOptions-MAS.plist`. - - Without `--upload`, it writes `CrocApp.ipa` (iOS) or `CrocApp.pkg` (macOS) to the scratch directory and prints `APPSTORE-EXPORT-OK `. Nothing leaves the Mac. - - With `--upload`, it sets `destination` to `upload` in a scratch copy of the plist and sends the build to App Store Connect (TestFlight). It prints `APPSTORE-UPLOAD-OK `. - -`APPSTORE_WORK` sets the scratch directory (archives, DerivedData, per-step logs); the script deletes it first. On failure the script prints the tail of the failing log and its path. - -To run it you need: - -- Xcode signed in to the Apple ID that belongs to team `HBSU359M33` (Settings > Accounts). Signing is automatic and cloud-managed through `-allowProvisioningUpdates`, so no Apple Distribution or Mac Installer certificate has to be in the keychain. -- `CrocKit/Croc.xcframework` built (`scripts/build-xcframework.sh`). -- The App Store Connect record "CrocApp" (iOS and macOS on one record, bundle ID `com.bakirgdev.CrocApp`). -- A build number App Store Connect has not seen for this version. The build number is always `CURRENT_PROJECT_VERSION` in `app/Config/Version.xcconfig` (`manageAppVersionAndBuildNumber` is false), and App Store Connect refuses a number it has already seen for that `MARKETING_VERSION`. Bump it in its own commit before every upload, then run the script without `--upload` first if the change is in doubt. - -First upload: 2026-10-14, iOS and macOS 1.0.0 (1). Both ended `** EXPORT SUCCEEDED **` and `Uploaded package is processing`. Things it taught: - -- **`MinimumOSVersion = 100.0`.** gomobile writes this into the framework `Info.plist` (x/mobile `bind_iosapp.go`, a SwiftPM workaround). The upload accepted it, so `build-xcframework.sh` is not patched. [golang/go#66500](https://github.com/golang/go/issues/66500) is closed, fixed in x/mobile CL 574055 and 575115, which our pinned x/mobile includes. -- **"Upload Symbols Failed".** The only warning, for `Croc.framework` (iOS) and `A` (macOS). Xcode embeds a 50 KB stub `Croc.framework` dylib with no dSYM, because the gomobile xcframework is a static archive linked into the app binary. It is not an ITMS warning (`docs/known-issues.md`, "Accepted"). -- **App category.** `LSApplicationCategoryType` is `public.app-category.utilities`, set as `INFOPLIST_KEY_LSApplicationCategoryType` on `CrocApp` and `CrocApp Direct`. Without it the macOS archive warned "No App Category is set". -- **Privacy manifest.** `app/CrocApp/PrivacyInfo.xcprivacy` declares required-reason APIs the Go engine reaches: `FileTimestamp` (`C617.1`, `3B52.1`) and `SystemBootTime` (`35F9.1`), besides `UserDefaults` (`CA92.1`). The engine is linked statically into the app binary and imports `stat`, `fstat`, `fstatat`, `lstat` and `mach_absolute_time` (`nm -u`); it does not import `statfs`, `statvfs` or `getattrlist`, so there is no `DiskSpace` entry. Whether the processed builds trigger an ITMS-91053 email is not known until the owner checks the developer-account mail (Owner steps, below). -- **Export compliance** failed the first attempt; see the next section. - -### Export compliance - -Both app `Info.plist` files (`app/Config/CrocApp-Info.plist`, `CrocApp-macOS-Info.plist`) set `ITSAppUsesNonExemptEncryption` to `false`. The first upload attempt, with `true` and no `ITSEncryptionExportComplianceCode`, failed with `Invalid Export Compliance Code. The export compliance key value [] in the app's Info.plist doesn't match the key value of the app's export compliance documentation.` - -`false` is a legal position, not only a way past the check: croc's encryption (AES-256-GCM, PAKE) is standard and its source is public, so the app relies on the open-source exemption (EAR 742.15(b), the owner's 1.0 decision). Apple's own table makes `false` coherent only while France is excluded, so 1.0 ships without France in its availability, and a `future` issue adds France after an ANSSI declaration (the key then becomes `true` with an issued code). The research, the exact answers to give if App Store Connect still asks, and the open SIEC question are in `docs/knowledge/app-store-listing.md`, "Export compliance"; the owner's two actions are in `docs/known-issues.md`, "Release". If the exemption does not hold, set the key to `true` and add the `ITSEncryptionExportComplianceCode` that App Store Connect issues after the documentation is approved. - -### Owner steps - -Everything below is done by a person, not by a script. It is written so the next release can repeat it. IDs in brackets match the release plan. Each step is marked done (with the date) or remaining. - -1. **Agreements, tax and banking.** Done 2026-10-12. In App Store Connect, accept the Free Apps and Paid Apps agreements, fill in tax and bank details, and declare EU Digital Services Act trader status with public contact details. Before each release, check that no agreement shows as pending or needing re-acceptance. -2. **Developer ID Application certificate [O1].** Done 2026-10-12. - 1. At developer.apple.com, Certificates, create a "Developer ID Application" certificate (a certificate signing request comes from Keychain Access, Certificate Assistant). Download it and double-click it to install into the login keychain. - 2. Check it: `security find-identity -v -p codesigning` lists "Developer ID Application" for team `HBSU359M33`. - 3. In Keychain Access, export the certificate with its private key as a `.p12` with a password. Keep the `.p12` and the password in the password manager. They become the `DEVID_P12_BASE64` and `DEVID_P12_PASSWORD` secrets (step 7). -3. **Notarization API key [O2].** Done 2026-10-12, verified 2026-10-14. In App Store Connect, create a team API key named "CrocApp notarization" with the Developer role (the role is enough for `notarytool`). Download the `.p8` (Apple offers it once) and note the Key ID and Issuer ID; keep all three in the password manager. Then, in a terminal: - ```bash - xcrun notarytool store-credentials crocapp-notary --key --key-id --issuer - xcrun notarytool history --keychain-profile crocapp-notary - ``` - The second command answering confirms the key works. The three values are also the CI secrets `ASC_KEY_P8`, `ASC_KEY_ID` and `ASC_ISSUER_ID`. -4. **Bundle IDs and the App Store Connect record [O4].** Done. - 1. `com.bakirgdev.CrocApp` and `com.bakirgdev.CrocApp.CrocShare` exist (2026-10-12), and the App Store Connect record "CrocApp" exists: iOS and macOS on one record (universal purchase), bundle ID `com.bakirgdev.CrocApp`, name reserved. - 2. `com.bakirgdev.CrocApp.direct` was created on 2026-10-13 by Xcode automatic signing. The same run registered this Mac as a development device and signed both app IDs with Associated Domains. On a new Mac, build once with `-allowProvisioningUpdates -allowProvisioningDeviceRegistration`. -5. **Developer ID provisioning profile [O10].** Done 2026-10-14. At developer.apple.com, Profiles, create a Developer ID profile named "CrocApp Direct Developer ID" for `com.bakirgdev.CrocApp.direct` with Associated Domains. It expires in 2044. Download it and copy it into `~/Library/Developer/Xcode/UserData/Provisioning Profiles/` (`scripts/build-devid.sh` needs it there). Its base64 is the `DEVID_PROFILE_BASE64` secret. -6. **Sparkle release key [O6].** Done 2026-10-14. In your own terminal, so the key material never goes through anyone else: - ```bash - openssl genpkey -algorithm ed25519 -out k.pem && echo "private: $(openssl pkey -in k.pem -outform DER | tail -c 32 | base64)" && echo "public: $(openssl pkey -in k.pem -pubout -outform DER | tail -c 32 | base64)" && rm k.pem - ``` - Save the `private:` line in the password manager (it becomes `SPARKLE_ED_PRIVATE_KEY`, step 7). Put the `public:` line in `SPARKLE_PUBLIC_ED_KEY` on the `CrocApp Direct` target. It is `7vFYkIXDHGPg4FHsWDCe3Qz546DfdyQbRKQZOO8zYsE=` and is public. The key is deliberately plain text that the owner keeps, not a keychain item, so Sparkle's `generate_keys` is not used. -7. **GitHub `release` environment [O7].** Done 2026-10-12 and 2026-10-14. The environment `release` exists with required reviewer `bakirgdev`, and its deployment policy allows branch `main` and tags `v*`. To create or rotate one of its secrets (table in this section, above), pipe the value from a file so it is never printed or typed: - ```bash - gh secret set DEVID_P12_PASSWORD --env release < password.txt - base64 < cert.p12 | gh secret set DEVID_P12_BASE64 --env release - ``` - Use the `gh` account that matches the repo owner. All seven secrets were set on 2026-10-14, after checking locally that the Sparkle private key derives the committed public key, the password opens the `.p12`, and the `.p8` with its key ID and issuer authenticates `notarytool history`. -8. **Approve a release run.** Remaining, each release. A `v*` tag push or a `workflow_dispatch` pauses `release.yml` at the `release` environment. Open the run in GitHub Actions and approve the deployment as the required reviewer (or `gh api -X POST repos///actions/runs//pending_deployments -F 'environment_ids[]=' -f state=approved -f comment=...`). Done for 1.0.0 on 2026-10-15: the `workflow_dispatch` rehearsal from `main` (run 38750570268) ended `success` with the `release` job skipped, then tag `v1.0.0` (run 38751704094) published the DMG, `SHA256SUMS.txt`, the xcframework zip and `appcast.xml`; the DMG passes `stapler validate` and `spctl` ("Notarized Developer ID"). -9. **Physical iPhone [O8].** Done: an iPhone on iOS 26 or later, Developer Mode on, shown in Xcode's Devices and Simulators window, and Xcode signed in with the Apple ID that `scripts/build-appstore.sh` uses. Remaining: run the device checklist whenever a slice asks for it. -10. **Multicast entitlement [O3].** Remaining, pending. The request went in on 2026-10-12 at developer.apple.com/contact/request/networking-multicast, Apple request ID WA7J9P8GM7. Check the developer-account email for Apple's reply. If it is granted, Apple adds "Multicast Networking" as a capability on `com.bakirgdev.CrocApp`, and the `com.apple.developer.networking.multicast` entitlement can then be added to the iOS target. If it is not granted by submission, ship without it, hide the iOS multicast setting, and file a `future` issue quoting WA7J9P8GM7. -11. **croc maintainer [O5].** Done 2026-10-14. The maintainer was emailed on 2026-10-12 about the name, the public relay pool default, the Tailcat/DERP `auto` default, getcroc.com stored transfers and the browser link. The owner treats the reply as a yes to all of them (owner's word, the reply text is not in the repo), so no default changes. Ask the owner once more before Submit for Review (R41). -12. **Upload to TestFlight.** Done for 1.0.0 (1) on 2026-10-14 and 1.0.0 (2) on 2026-10-15. For each later build: bump `CURRENT_PROJECT_VERSION` in `app/Config/Version.xcconfig` and commit, then run `scripts/build-appstore.sh --upload`. It prints `APPSTORE-UPLOAD-OK ios` and `APPSTORE-UPLOAD-OK macos`. -13. **After each upload, check the result.** Done for build 1 on 2026-10-14 (no ITMS warning email; the iOS and macOS builds processed). Repeat for every upload. - 1. Open the developer-account email. Apple sends ITMS warnings or errors there after processing (for example ITMS-91053, a missing required-reason API declaration). Fix what it names in `PrivacyInfo.xcprivacy` and upload a new build number. - 2. In App Store Connect, open the app's TestFlight tab and confirm both the iOS and the macOS build finished processing and are not marked invalid. -14. **Internal testers.** Done for build 1 on 2026-10-14 (an internal group exists with the owner in it, and both builds are in TestFlight). In TestFlight, create an internal group (or add yourself as an internal tester), assign build 1 to it, then install it from TestFlight on the iPhone and on the Mac and open it once on each. -15. **Universal link in the Direct build.** Done 2026-10-14: the owner reported that the Direct build's universal link passed (the details are not recorded). Before that, with the stapled Direct app from `build-devid.sh` (2026-10-14, all checks passed), LaunchServices returned no app link for `https://crocapp.dev/r/...`, for the Direct build and for the dev `CrocApp` build alike, and the Mac was locked so Safari could not be tried. Check it by hand: on an unlocked Mac, install the Direct app in /Applications, click a `https://crocapp.dev/r/...` link in Safari or Notes, and see whether CrocApp opens. If it does not, Direct relies on the `crocapp://` button. -16. **App Store Connect listing [O9].** Remaining. Set pricing and availability (France excluded, see Export compliance), pasting the fields from `docs/knowledge/app-store-listing.md`, the privacy answers, age rating, the export compliance answers, screenshots and metadata. Then Submit for Review. Before submitting, repeat steps 10 and 11 and re-check the export compliance question. - -Reminders to give the owner: ask for the croc maintainer's reply before the first 1.0 feature build; check for Apple's reply to the multicast request before building the multicast setting; and before Submit for Review do both again. - -## 8. Troubleshooting +## Troubleshooting | Symptom | Cause | Fix | |---|---|---| -| `conflicting nullability specifier on return types` warning while building `CrocKit` | gomobile-generated `Croc.framework/Headers/Crocmobile.objc.h` declares `- (nullable instancetype)init;` on `CrocmobileOptions`, clashing with `NSObject`'s `nonnull init` | Expected, ignorable. Regenerated on every `build-xcframework.sh` run, cannot be fixed in Swift (`docs/knowledge/tooling.md`) | -| Xcode or `xcodebuild` fails immediately, missing binary target / no such module `Croc` | `CrocKit/Croc.xcframework` does not exist (gitignored, absent on a fresh clone) | Run `scripts/build-xcframework.sh` first | -| `scripts/build-xcframework.sh` exits `error: go not installed` | Go not on `PATH` | `brew install go` | -| `gomobile: command not found` right after the script claims to have installed it | `$(go env GOPATH)/bin` not on this shell's `PATH` | The script exports it only for its own run; for a direct `gomobile` invocation afterward, `export PATH="$PATH:$(go env GOPATH)/bin"` | -| macOS build in CI is unsigned | CI's `build (macOS)` job passes `CODE_SIGNING_ALLOWED=NO` (no signing identity on the runner) | Expected in CI. Locally, a signed build needs a `Mac Development` identity and a provisioning profile (section 5, Associated Domains), so pass `-allowProvisioningUpdates` | -| `go install .../cmd@latest` (gomobile, gobind, govulncheck) produces a binary you cannot find or run | `GOOS`/`GOARCH` set in the shell (as CI's go job sets `darwin`/`arm64`) makes `go install` drop the binary at `$GOPATH/bin/darwin_arm64/…` instead of `$GOPATH/bin/…` | Install with `env -u GOOS -u GOARCH go install ...`, then run the tool with the original env intact if the analysis itself needs to target darwin (`docs/knowledge/tooling.md`) | -| A hand-rolled `swift-format format --in-place` reports clean, but `lint --strict` (what CI runs) still fails | Several enabled rules are lint-only and never rewrite | Use `scripts/format-swift.sh`, which always lints after formatting; see the rule list in `docs/knowledge/tooling.md` | - -## 9. When the toolchain changes - -- **Xcode bump**: update `.xcode-version` (single line). Confirm the `xcode-27` GitHub runner image actually carries that exact version first (`docs/knowledge/tooling.md` notes the image carries 27.0 (default), 27.1 and 27.2 beta). Rerun `scripts/build-xcframework.sh` to confirm gomobile still binds clean, then the full build matrix, then at least one `verify-*.sh` harness live. -- **Go bump**: update `go 1.x.y` in `crocmobile/go.mod`, rerun `scripts/build-xcframework.sh`. -- **croc bump**: `go get github.com/schollz/croc/v11@vX.Y.Z` in `crocmobile/` (`brew upgrade croc` for the test CLI), rebind, then update every place that names the version by hand: `README.md` FAQ, `CONTRIBUTING.md`'s croc-install line, this file's prerequisites table, and `docs/knowledge/crocmobile-bridge.md`. Check `docs/knowledge/croc-upgrade-playbook.md` for the full procedure. -- **golangci-lint bump**: change the `version:` input on the `golangci-lint-action` step in `.github/workflows/reusable-go.yml`; update `crocmobile/.golangci.yml`'s schema `version:` if the new major changes it. -- **gomobile/gobind**: pinned by the `tool` block in `crocmobile/go.mod`; bump with `go get golang.org/x/mobile@latest`. If a bind regresses on a new release, pin the last good version there and record it in `docs/knowledge/tooling.md`. -- Whatever changed, re-check whether [golang/go#73119](https://github.com/golang/go/issues/73119) (macOS multi-arch bind) has been fixed upstream before assuming it still blocks Intel Mac support; it is a live gate on real behavior, not just a build warning. [golang/go#66500](https://github.com/golang/go/issues/66500) (iOS device archive layout) is closed and the first App Store upload passed (section 7). +| `conflicting nullability specifier on return types` warning while building `CrocKit` | gomobile turns any Go func named `New` into a constructor and builds the Objective-C selector from what follows the prefix. A func whose suffix is empty (`NewOptions` on `Options`) yields a bare `- (nullable instancetype)init`, which clashes with `NSObject`'s `nonnull init`. `crocmobile` avoids it with `NewOptionsDefault` (selector `initDefault`) | Should not appear. If it returns, a `New` func with an empty suffix was added in `crocmobile/`; rename it (`docs/knowledge/tooling.md`, "CI gotchas") | +| Xcode or `xcodebuild` fails immediately: missing binary target, or no such module `Croc` | `CrocKit/Croc.xcframework` does not exist (gitignored, absent on a fresh clone) | Run `scripts/build-xcframework.sh` | +| `scripts/build-xcframework.sh` exits `error: go not installed` | Go is not on `PATH` | `brew install go` | +| `gomobile: command not found` when you run it by hand | The script builds gomobile and gobind into `.tools/` and puts that directory on `PATH` for its own run only | `export PATH="$PWD/.tools:$PATH"` from the repo root, after one run of the script | +| macOS build in CI is unsigned | CI's `build (macOS)` job passes `CODE_SIGNING_ALLOWED=NO` (the runner has no signing identity) | Expected in CI. A local signed build needs a `Mac Development` identity and a provisioning profile (see "Verification harnesses", signing paragraph), so pass `-allowProvisioningUpdates` | +| `go install ...@latest` (for example `gomobile`, `gobind`, `govulncheck`) produces a binary you cannot find or run | `GOOS` and `GOARCH` set in the shell (CI's Go job sets `darwin` and `arm64`) make `go install` write to `$GOPATH/bin/darwin_arm64/` instead of `$GOPATH/bin/` | Install with `env -u GOOS -u GOARCH go install ...`. Better, use the pinned tools: `go tool govulncheck`, or `scripts/build-xcframework.sh` for gomobile (`docs/knowledge/tooling.md`, "CI gotchas") | +| A hand-run `swift-format format --in-place` reports clean, but `lint --strict` (what CI runs) still fails | Several enabled rules are lint-only and never rewrite | Run `scripts/format-swift.sh`, which always lints after formatting (`docs/knowledge/tooling.md`, "swift-format traps") | + +## When the toolchain changes + +- **Xcode bump**: update `.xcode-version` (a single line). First confirm the `xcode-27` GitHub runner image carries that exact version (`docs/knowledge/tooling.md`, "In use", lists what the image carries). Rerun `scripts/build-xcframework.sh` to confirm gomobile still binds, then the full build matrix, then at least one `verify-*.sh` harness live. +- **Go bump**: update `go 1.x.y` in `crocmobile/go.mod`, then rerun `scripts/build-xcframework.sh`. +- **croc bump**: `go get github.com/schollz/croc/v11@vX.Y.Z` in `crocmobile/` (and `brew upgrade croc` for the test CLI), rebind, then update every place that names the version by hand: the croc table row above, `docs/knowledge/crocmobile-bridge.md`, and any README or CONTRIBUTING text that names it. `docs/knowledge/croc-upgrade-playbook.md` has the full procedure. +- **golangci-lint bump**: change the `version:` input on the `golangci-lint-action` step in `.github/workflows/reusable-go.yml`. Update the schema `version:` in `crocmobile/.golangci.yml` if the new major changes it. +- **gomobile, gobind and govulncheck**: all three are pinned in the `tool` block of `crocmobile/go.mod`. Dependabot does not bump them, because tool requirements are `// indirect`. From `crocmobile/`, `go get golang.org/x/mobile@latest` bumps gomobile and gobind, and `go get tool` bumps every tool in the block. Pin the last good version if a bind regresses, and record it in `docs/knowledge/tooling.md`. +- **Always**: check whether [golang/go#73119](https://github.com/golang/go/issues/73119) (macOS multi-arch bind) is fixed upstream before assuming it still blocks Intel Mac support. It decides real behavior, not just a build warning. diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 862e0a5..62df04f 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -1,89 +1,87 @@ # Glossary -Terms a new contributor or a fresh AI session hits in this repo and cannot infer from the name alone: croc protocol vocabulary, the Go/gomobile bridge, the SwiftUI app, and repo-local jargon. One dense line each; see `docs/knowledge/README.md` for the full-length files these definitions are summarized from. +Terms a new contributor hits in this repo and cannot infer from the name: croc vocabulary, the Go bridge, the app, and Apple platform and repo terms. Each entry is one or two short sentences plus where to look. Longer detail lives in the file linked from the entry. ## croc | Term | Means | -|---|---| -| `--ask` | croc flag requiring confirmation on both sides before a transfer starts, not just the receiver's normal accept prompt; bridged into the engine via the same fd0 pipe trick as receive (`docs/knowledge/crocmobile-bridge.md`). | -| classic mode | croc's `--classic` flag, the only mode where `--code` accepts a custom secret directly; insecure by design and deliberately unsupported here (F39, `docs/knowledge/features.md`). | -| code phrase / secret | the `word-word-word` string (three random words from the EFF short wordlist) both devices use to find each other and derive the session key; custom via `CROC_SECRET`, min 6 chars (`docs/knowledge/what-is-croc.md`). "Secret" is croc's internal term for it: the UI never says it, only "code phrase" (`design/content.md` → Terminology). | -| compression | croc DEFLATE-compresses (HuffmanOnly) file data by default; `--no-compress` disables it (F15). | -| `CROC_SECRET` | env var for a custom code phrase; the only way non-classic croc accepts one, on both send and receive (`docs/knowledge/what-is-croc.md`). | -| curve | the elliptic curve PAKE runs over, default P-256 (`--curve`, `crocmobile.Options.Curve`). | -| hash algorithm | integrity check for transferred files, default xxhash; alternatives imohash, md5, highway via `--hash` (`crocmobile.Options.HashAlgorithm`). | -| `--ignore-stdin` | croc CLI flag needed for non-interactive runs; the Go library equivalent (`Options.IgnoreStdin`) is always set `true` in `crocmobile/session.go`, since the app supplies its own prompt pipe instead of a real terminal. | -| LAN peer discovery | UDP multicast peer discovery (`schollz/peerdiscovery`, ~500 ms window) that lets same-network transfers skip the relay; not guaranteed to work on every network, see `docs/knowledge/what-is-croc.md` §6. | -| `--local` / only-local | forces a transfer to stay on the LAN, refusing the public relay; maps to `crocmobile.Options.OnlyLocal` / `AppSettings.onlyLocal` (F14). | -| sender IP | the receive-side Advanced setting (`AppSettings.peerIP`, `EngineOptions.peerIP`, croc `--ip`): the sender's `ip:port`, IPv4 or bracketed IPv6. The receiver connects straight to the sender's local relay, skipping relays, discovery and the public pool; the trust badge reads `RelayKind.directToSender`. | -| Advanced settings | R25 (F20-F28): `AdvancedSettingsView`, `AppSettings` keys `settings.advanced.*`, proxies in the Keychain (accounts `advanced.socks5Proxy`, `advanced.httpProxy`); croc's curve, hash, upload throttle, ports, multicast, proxies, built-in DNS and multiplexing (`docs/knowledge/app-ui-architecture.md`, "Advanced settings (R25)"). | -| menu-bar quick send | R26 (F31), macOS only: a `MenuBarExtra` (window style) showing `MenuBarQuickSendView`, driven by `MenuBarQuickSend`, on the one `TransferController`; shown while `AppSettings.showInMenuBar` is on. `LoginItem` wraps `SMAppService.mainApp` for Open at login (`docs/knowledge/app-ui-architecture.md`, "Menu bar and Shortcuts (R26-R28)"). | -| App Shortcut / App Intent | R27 (F34): `SendFilesIntent`, `SendTextIntent`, `ReceiveIntent` in `app/CrocApp/Intents/`, offered by `CrocAppShortcuts` (`AppShortcutsProvider`) with Siri and Spotlight phrases; they run in the foreground and depend on `TransferController` and `OutputFolderStore` through `AppDependencyManager` (`docs/knowledge/app-ui-architecture.md`, "Menu bar and Shortcuts (R26-R28)"). | -| PAKE | Password-Authenticated Key Exchange (`github.com/schollz/pake/v3`, Boneh-Shoup construction); turns the weak code phrase into a strong session key without the relay ever learning it (`docs/knowledge/what-is-croc.md`). | -| relay | the rendezvous/transport server both sides connect to when a direct LAN path is not available; sees ciphertext only. With no custom relay the default is croc's public relay pool (`1.getcroc.com:9009` to `4.getcroc.com:9009`), picked by the code; the old single default `croc.schollz.com:9009` survives as `models.DEFAULT_RELAY`, and a relay field equal to it means "not custom". | -| room | the rendezvous channel on the relay, named `SHA-256(selector + "croc")`, where the selector is the first word of a three-word code (the first 4 characters for a legacy code like `NNNN-word`); two codes sharing it on the same relay collide into the same room ("room full"). | -| shared secret prefix collision | the failure mode above: because the room name only hashes the code's selector (first word, or first 4 characters of a legacy code), concurrent transfers or test scripts using codes with the same selector collide (`docs/knowledge/crocmobile-bridge.md`). | -| stored transfer | croc's upload-and-link mode (`croc send --store`, default server `https://getcroc.com`): files are encrypted client-side, uploaded to a server, and fetched later from a link `https:///s/#v1.` whose fragment is the key; no peer, relay or code phrase involved. CrocApp's UI calls it Send as link (F43, `docs/knowledge/app-ui-architecture.md`, "Stored transfers"). | -| Tailcat | croc v11's peer-to-peer data path: two native clients carry TCP streams over an in-process userspace WireGuard network that starts on DERP relays and moves to direct UDP when NAT traversal works. Under transport `auto` the sender offers it only for a send of 128 MiB or more and races it against the relay; setup contacts `tailcat.dev` and the `*.ipn.dev` DERP relays. Whether it stays in the build is decided on a physical iPhone (`docs/knowledge/device-test-checklist.md`, `docs/knowledge/crocmobile-bridge.md`). | -| transport / Connection | croc's sender-only `--transport` (`auto`, `relay`, `derp`), shown in the app as the Connection setting: Automatic (`auto`, default) or Relay only (`relay`); the app never offers `derp`. A receive and a local-only send are always `auto` (`crocmobile.Options.Transport`, `CrocKit.Transport`, `AppSettings.transport`, F41). | +| --- | --- | +| `--ask` | croc flag that makes both sides confirm before a transfer starts. The app calls it **Confirm on Both Sides**. It uses the same prompt-pipe trick as receive (`docs/knowledge/crocmobile-bridge.md`). | +| classic mode | croc's `--classic` flag. It is the only mode where `--code` takes a custom secret directly, and it is insecure by design. CrocApp does not support it. | +| code phrase | The `word-word-word` string (three random words from the EFF short wordlist) that both devices use to find each other and derive the session key. A custom one comes from `CROC_SECRET`, at least 6 characters (`docs/knowledge/what-is-croc.md`). croc calls it the "secret"; the UI says only "code phrase" (`design/content.md`, "Terminology"). | +| compression | croc compresses file data with DEFLATE (HuffmanOnly) by default. `--no-compress` turns it off. | +| `CROC_SECRET` | Environment variable that sets a custom code phrase. Outside classic mode it is the only way to do that, for both send and receive. | +| curve | The elliptic curve PAKE runs over. Default P-256. Go: `crocmobile.Options.Curve`; Swift: `CrocKit.Curve`. | +| hash algorithm | The integrity check for transferred files. Default xxhash; also imohash, md5, highway (`--hash`). Go: `Options.HashAlgorithm`. | +| `--ignore-stdin` | croc flag for non-interactive runs. The library option `IgnoreStdin` is always true in `crocmobile/session.go`, because the app supplies its own prompt pipe. | +| LAN peer discovery | Finding a same-network peer by UDP multicast (`schollz/peerdiscovery`, 500 ms cap) so the transfer skips the relay. It does not work on every network (`docs/knowledge/what-is-croc.md`, "Local path"). | +| `--local` / local only | Keeps a transfer on the local network and refuses the public relay. The app setting is **Local Network Only** (`AppSettings.onlyLocal`, `crocmobile.Options.OnlyLocal`). | +| sender IP | Receive-side Advanced setting (`AppSettings.peerIP`, `EngineOptions.peerIP`, croc `--ip`). It is the sender's `ip:port` (IPv4 or bracketed IPv6). The receiver connects straight to the sender's local relay, skipping relays, discovery and the public pool. The trust badge then shows `RelayKind.directToSender`. | +| Advanced settings | The Advanced page for croc's curve, hash, upload limit, ports, multicast address, proxies, built-in DNS and multiplexing. Code: `AdvancedSettingsView`, keys `settings.advanced.*` (`AppSettings`), proxies in the Keychain. See `docs/knowledge/app-ui-architecture.md`, "Advanced settings". | +| menu-bar quick send | macOS only. A `MenuBarExtra` window (`MenuBarQuickSendView`, driven by `MenuBarQuickSend`) that sends on the one shared `TransferController`. It shows while **Show in Menu Bar** (`AppSettings.showInMenuBar`) is on. `LoginItem` wraps `SMAppService.mainApp` for **Open at login**. See `docs/knowledge/app-ui-architecture.md`, "Menu bar and Shortcuts". | +| App Shortcut / App Intent | `SendFilesIntent`, `SendTextIntent` and `ReceiveIntent` in `app/CrocApp/Intents/`, offered by `CrocAppShortcuts` with Siri and Spotlight phrases. They run in the foreground and reach `TransferController` and `OutputFolderStore` through `AppDependencyManager`. See `docs/knowledge/app-ui-architecture.md`, "Menu bar and Shortcuts". | +| PAKE | Password-Authenticated Key Exchange (`github.com/schollz/pake/v3`). It turns the weak code phrase into a strong session key, and the relay never learns it. | +| relay | The server both sides connect to when no direct local path works. It sees only ciphertext. With no custom relay, croc's public pool is used (`1.getcroc.com:9009` to `4.getcroc.com:9009`), chosen from the code. A relay field equal to croc's single-server default `croc.schollz.com:9009` (`models.DEFAULT_RELAY`) means "not custom". | +| room | The channel on the relay where two peers meet. Its name is a hash of a selector taken from the code (`docs/knowledge/what-is-croc.md`, "Rendezvous"). | +| room collision | Two codes with the same selector on the same relay land in the same room, and the second gets "room full". Concurrent transfers and test scripts must use codes with different selectors (`docs/knowledge/what-is-croc.md`, "Rendezvous"). | +| stored transfer | croc's upload-and-link mode (`croc send --store`, default server `https://getcroc.com`). Files are encrypted on the device, uploaded, and fetched later from `https:///s/#v1.`, where the fragment is the key. There is no peer, relay or code phrase. The app calls it **Send as link** (`docs/knowledge/app-ui-architecture.md`, "Stored transfers"). | +| Tailcat | croc v11's peer-to-peer data path (`docs/knowledge/what-is-croc.md`, "Transport"). Whether it stays in the build is decided on a physical iPhone (`docs/knowledge/crocmobile-bridge.md`, `docs/knowledge/device-test-checklist.md`). | +| transport / Connection | croc's sender-only `--transport` (`auto`, `relay`, `derp`). The app shows it as **Connection**: **Automatic** (`auto`, default) or **Relay Only** (`relay`). It never offers `derp`. A receive and a local-only send are always `auto`. Code: `crocmobile.Options.Transport`, `CrocKit.Transport`, `AppSettings.transport`. | ## Go bridge | Term | Means | -|---|---| -| `binaryTarget` | the `Package.swift` declaration (`CrocKit/Package.swift`) pointing `CrocKit` at the gitignored `Croc.xcframework`; nothing builds until `scripts/build-xcframework.sh` produces it. | -| delegate bridge | `DelegateBridge` (`CrocKit/Sources/CrocKit/DelegateBridge.swift`), the `NSObject` implementing gobind's `CrocmobileDelegateProtocol` and turning its callbacks (arrived on arbitrary Go threads) into `AsyncStream` yields. | -| event stream | the `AsyncStream` returned by `CrocEngine.startSend`/`startReceive`; abandoning it cancels the underlying Go session (`onTermination`). | -| failure code | the `code` in the `{"code","message"}` JSON that `OnError` and failed starts carry (`crocmobile/errors.go`), decoded in Swift as `EngineFailure.Code`; the app picks its copy from the code and never from croc's text (`docs/knowledge/crocmobile-bridge.md`). | -| gobind | the Go tool (part of gomobile) that generates the ObjC/Swift binding layer from the `crocmobile` package; source of the `Crocmobile`-prefixed symbols. | -| gomobile | the Go toolchain used to bind the `crocmobile` package into `Croc.xcframework` for iOS/macOS (`scripts/build-xcframework.sh`). | -| ObjC symbol prefix `Crocmobile` | gobind derives this from the Go package name `crocmobile`: `CrocmobileOptions`, `CrocmobileTransfer`, `CrocmobileStartSend`/`StartReceive`, protocol `CrocmobileDelegate` → Swift `CrocmobileDelegateProtocol` (`docs/knowledge/crocmobile-bridge.md`). | -| prompt pipe / fd0 dup2 | the mechanism bridging croc's stdin-read accept/decline prompt into Swift: a pipe's read end is `syscall.Dup2`'d onto fd 0 because croc's cached `bufio.Reader` was bound to fd 0 at package init and a plain `os.Stdin` variable swap does not reach it (`crocmobile/session.go`). | -| session | the Go `session` struct (`crocmobile/session.go`) that owns one active transfer's `croc.Client`, prompt pipe, and cleanup; `crocmobile` allows exactly one at a time via a package-level mutex. | -| `TransferEvent` | the Swift enum (`CrocKit/Sources/CrocKit/Models.swift`) carrying every event the engine can emit: `.codeReady`, `.connected`, `.fileList`, `.progress`, `.text`, `.done`, `.failed`. | -| xcframework | `CrocKit/Croc.xcframework`, the gomobile build output binding `crocmobile` for iOS, iOS Simulator, and macOS; gitignored, produced by `scripts/build-xcframework.sh`. | +| --- | --- | +| `binaryTarget` | The `CrocKit/Package.swift` declaration that points `CrocKit` at the gitignored `Croc.xcframework`. Nothing builds until `scripts/build-xcframework.sh` produces it. | +| delegate bridge | `DelegateBridge` (`CrocKit/Sources/CrocKit/DelegateBridge.swift`). It implements gobind's `CrocmobileDelegateProtocol` and turns callbacks from Go threads into `AsyncStream` yields. | +| event stream | The `AsyncStream` that `CrocEngine.startSend` and `startReceive` return. Letting it end (`onTermination`) cancels the Go session. | +| failure code | The `code` in the `{"code","message"}` JSON that `OnError` and failed starts carry (`crocmobile/errors.go`). Swift decodes it as `EngineFailure.Code`. The app picks its wording from the code, never from croc's text. | +| gobind | The gomobile tool that generates the Objective-C and Swift binding layer from the `crocmobile` package. It is the source of the `Crocmobile`-prefixed symbols. | +| gomobile | The Go toolchain that binds `crocmobile` into `Croc.xcframework` (`scripts/build-xcframework.sh`). | +| `Crocmobile` prefix | gobind adds it to the Go package name `crocmobile`: `CrocmobileOptions`, `CrocmobileTransfer`, `CrocmobileStartSend`. The Go interface `Delegate` is the Objective-C protocol `CrocmobileDelegate`, imported in Swift as `CrocmobileDelegateProtocol`. | +| prompt pipe / fd0 dup2 | How the app answers croc's accept prompt, which reads stdin. A pipe's read end is `syscall.Dup2`'d onto fd 0, because croc's cached `bufio.Reader` is tied to fd 0 and swapping `os.Stdin` would not reach it (`crocmobile/session.go`). | +| session | The Go `session` struct (`crocmobile/session.go`). It owns one transfer's `croc.Client`, prompt pipe and cleanup. A package-level mutex allows one at a time. | +| `TransferEvent` | The Swift enum in `CrocKit/Sources/CrocKit/Models.swift` for everything the engine reports: `.codeReady`, `.connected`, `.fileList`, `.progress`, `.text`, `.stored`, `.done`, `.failed`. | +| xcframework | `CrocKit/Croc.xcframework`, the gomobile output that binds `crocmobile` for iOS, iOS Simulator and macOS. Gitignored. Built by `scripts/build-xcframework.sh`, the required first build step on a fresh clone. | ## App | Term | Means | -|---|---| -| `AppRouter` | `@Observable` singleton (`app/CrocApp/Models/AppRouter.swift`) holding the navigation path (both platforms), pending drop/dock URLs and a pending receive link (`pendingReceive`), needed because `AppDelegate` and menu commands sit outside the SwiftUI environment graph. | -| App Group | `group.com.bakirgdev.CrocApp`, the shared container the iOS share extension stages files into (`ShareInbox/batch-/` + `manifest.json`) for the main app to pick up (`app/CrocApp/Models/ShareInbox.swift`). | -| `AutoVerify` harness | `app/CrocApp/Support/AutoVerify.swift`, launch-argument-driven code path (`--auto-receive`, `--auto-send`, `--ask`, etc.) that drives the real `TransferController` state machine instead of the engine directly, so verify scripts exercise the path users actually take. | -| `BGContinuedProcessingTask` | iOS 26+ API wrapping a transfer so it survives backgrounding and gets a system Live Activity; `app/CrocApp/Platform/BackgroundCoordinator.swift`, no macOS equivalent (`docs/knowledge/apple-platform-constraints.md`). | -| `CrocApp Direct` / `DIRECT` | the Developer ID (DMG) macOS target, bundle ID `com.bakirgdev.CrocApp.direct`, arm64 only; builds the `app/CrocApp/` sources plus `app/CrocAppDirect/` (Sparkle updater) under the Swift condition `DIRECT`. Product is `CrocApp.app`, same name as the App Store target (`docs/BUILDING.md` section 7). | -| output folder store | `OutputFolderStore` (`app/CrocApp/Models/OutputFolderStore.swift`), tracks the receive destination folder and its security-scope bookmark; defaults to `~/Downloads/CrocApp` on macOS, app Documents on iOS. | -| Keep Both / Replace | the receive file policy (`CrocKit.FilePolicy`, `AppSettings.whenFileExists`, Go consts `PolicyKeepBoth`/`PolicyReplace`) for a file whose name is taken: Keep Both saves the new one as `name (1).ext` (croc `--rename`), Replace overwrites and resumes (croc `--overwrite`). Chosen on the incoming sheet, or by the setting under auto-accept (`docs/knowledge/crocmobile-bridge.md`). | -| `Phase` | the enum on `TransferController` driving every view: `idle` → `starting` → `waiting`/`connecting` → `confirmSend` or `incoming` → `transferring` → `done`, `stored` (a stored upload finished) or `failed` (`app/CrocApp/Models/TransferController.swift`). | -| security-scoped resource | an Apple sandbox URL (from `fileImporter` or a bookmark) that needs `startAccessingSecurityScopedResource()` before use; both `TransferController` and `HistoryView` call it before touching a URL. | -| share extension (appex) | `CrocShare`, the iOS-only app extension (`app/CrocShare/`) that stages shared files into the App Group for later send; ~120 MB memory cap, no long-running work (`docs/knowledge/app-ui-architecture.md`). | -| receipt | `StoredReceipt` (CrocKit), what a stored upload returns: id, origin, upload token, link, expiry, download count. Held only in `StoredTransferVault` (Keychain); history keeps just the id, host and expiry. | -| revoke | deleting a stored upload from its server with its upload token (`TransferController.revoke`, History's Revoke Link). A server that no longer has it counts as success. Only possible while the receipt is in the vault. | -| `StoredTransferVault` | the Keychain store of stored-upload receipts (`app/CrocApp/Models/StoredTransferVault.swift`): upload token and link, which History needs to revoke and must never hold itself. | -| `SwiftData` `TransferRecord` | the `@Model` type (`app/CrocApp/Models/TransferHistory.swift`) persisting one finished/failed transfer for local-only history (F12); never stores file contents or the full code phrase. | -| `TransferLink` | the one parser (`app/CrocApp/Models/TransferLink.swift`) for every form a code arrives in: bare code, `crocapp://receive?code=`, `croc://`, `https://crocapp.dev/r/#`, `https://getcroc.com/?code=`. `TransferLink.webURL(forCode:)` builds the `crocapp.dev/r/` link. Opening one prefills Receive and never connects. | -| trust badge | `TrustBadge` view (`app/CrocApp/Views/TrustBadge.swift`), shown in waiting/transferring/confirm-send screens, reading `TransferController.activeRelay` captured at transfer start (F36). | -| web link / link page | `https://crocapp.dev/r/#`, the waiting-screen QR and Share Link payload, served by `web/landing/r/index.html`. The code sits in the fragment, so browsers never send it to a server; the page makes no network request. It offers Open in CrocApp, the App Store, the DMG, and Receive in your browser (croc's own web app at getcroc.com, which serves the JavaScript that decrypts). | -| `TransferController` | `@MainActor @Observable` (`app/CrocApp/Models/TransferController.swift`), the sole consumer of `CrocKit` in the app; owns `Phase` and all engine event handling. | +| --- | --- | +| `AppRouter` | `@Observable` singleton (`app/CrocApp/Models/AppRouter.swift`). It holds the navigation path, URLs dropped on the window or Dock, and a pending receive link (`pendingReceive`). It is a singleton because `AppDelegate` and menu commands sit outside the SwiftUI environment. | +| App Group | `group.com.bakirgdev.CrocApp`, the shared container where the iOS share extension stages files for the app (`ShareInbox/batch-/` plus `manifest.json`). App side: `app/CrocApp/Models/ShareInbox.swift`. | +| `AutoVerify` harness | `app/CrocApp/Support/AutoVerify.swift`. Launch arguments (`--auto-receive`, `--auto-send`, `--ask` and others) drive the real `TransferController`, so verification scripts take the path users take. | +| `BGContinuedProcessingTask` | The iOS 26 API that keeps a transfer running in the background and shows a system Live Activity. Code: `app/CrocApp/Platform/BackgroundCoordinator.swift`. macOS has no equivalent (`docs/knowledge/apple-platform-constraints.md`). | +| `CrocApp Direct` / `DIRECT` | The macOS Developer ID target for the direct download: bundle ID `com.bakirgdev.CrocApp.direct`, arm64 only. It builds `app/CrocApp/` plus `app/CrocAppDirect/` (the Sparkle updater) under the Swift condition `DIRECT`. Its product is `CrocApp.app`, the same name as the App Store target. See `docs/RELEASING.md`, "Direct download (Developer ID)". | +| output folder store | `OutputFolderStore` (`app/CrocApp/Models/OutputFolderStore.swift`). It tracks the receive folder and its security-scoped bookmark. Default: `~/Downloads/CrocApp` on macOS, the app's Documents folder on iOS. | +| Keep Both / Replace | The receive policy for a file whose name is taken (`CrocKit.FilePolicy`, `AppSettings.whenFileExists`, Go `PolicyKeepBoth` and `PolicyReplace`). Keep Both saves `name (1).ext` (croc `--rename`). Replace overwrites and resumes (croc `--overwrite`). The user picks on the incoming sheet, or the setting applies under auto-accept. | +| `Phase` | The enum on `TransferController` that every view switches on (cases: `docs/ARCHITECTURE.md`, "TransferController"). | +| security-scoped resource | A sandboxed URL (from `fileImporter` or a bookmark) that needs `startAccessingSecurityScopedResource()` before use. `TransferController` and `HistoryView` both call it. | +| share extension (appex) | `CrocShare` (`app/CrocShare/`), the iOS-only extension that stages shared files in the App Group. Memory cap about 120 MB, no long work. | +| receipt | `StoredReceipt` (CrocKit): what a stored upload returns (id, origin, upload token, link, expiry, download count). Only `StoredTransferVault` (Keychain) holds it. History keeps just the id, host and expiry. | +| revoke | Deleting a stored upload from its server with the upload token (`TransferController.revoke`, **Revoke Link** in History). A server that no longer has it counts as success. Possible only while the receipt is in the vault. | +| `StoredTransferVault` | The Keychain store of stored-upload receipts (`app/CrocApp/Models/StoredTransferVault.swift`). It holds the upload token and link that History needs to revoke and must never hold itself. | +| `TransferRecord` | The SwiftData `@Model` (`app/CrocApp/Models/TransferHistory.swift`) for one finished or failed transfer in the local-only history. It never stores file contents or the full code phrase. | +| `TransferLink` | The one parser (`app/CrocApp/Models/TransferLink.swift`) for every form a code or link arrives in: bare code, stored link, `crocapp://receive?code=`, `croc://`, `https://crocapp.dev/r/#`, `https://getcroc.com/?code=`. `TransferLink.webURL(forCode:)` builds the `crocapp.dev/r/` link. Opening a link fills in Receive and never connects. | +| trust badge | The `TrustBadge` view (`app/CrocApp/Views/TrustBadge.swift`). It shows how the transfer is routed and that it is end-to-end encrypted, on Home, How It Works and the waiting, confirm, incoming and transferring screens. It reads `TransferController.activeRelay`, captured when the transfer starts. | +| web link / link page | `https://crocapp.dev/r/#`, the payload of the waiting-screen QR code and **Share Link** (page: `web/landing/r/index.html`). The code is in the fragment, so browsers do not send it to a server, and the page makes no network request. It offers Open in CrocApp, the App Store, the DMG, and Receive in your browser (croc's own web app at getcroc.com). | +| `TransferController` | `@MainActor @Observable` (`app/CrocApp/Models/TransferController.swift`). The only caller of `CrocEngine`. It owns `Phase` and handles every engine event. | ## Apple platform | Term | Means | -|---|---| -| entitlement | an Apple capability grant declared in a `.entitlements` file (`app/CrocApp/CrocApp.entitlements`, `CrocApp-iOS.entitlements`, `app/CrocAppDirect/CrocAppDirect.entitlements`, `app/CrocShare/CrocShare.entitlements`); e.g. `network.client`/`network.server` for the sandboxed Mac build, `files.downloads.read-write` for the default output folder. | -| MAS vs Developer ID | the two macOS distribution paths: Mac App Store (sandbox mandatory) vs. direct-download Developer ID (notarized DMG, sandbox optional); separate `Config/ExportOptions-{MAS,DevID}.plist` (iOS has its own `ExportOptions-iOS.plist`; `scripts/build-appstore.sh` uses the iOS and MAS ones). | -| notarization | Apple's malware scan required before a Developer ID build runs without warnings on a fresh Mac; `scripts/build-devid.sh` submits the app and the disk image with `notarytool submit --wait` (`docs/BUILDING.md`, section 7). | -| `PrivacyInfo.xcprivacy` | Apple's required privacy manifest, present in both `app/CrocApp/` and `app/CrocShare/`; declares accessed API categories (UserDefaults, FileTimestamp, SystemBootTime) and no tracking/collection. | -| Sparkle | the macOS auto-update framework (2.10.0, SwiftPM), linked into `CrocApp Direct` only; checks `SPARKLE_FEED_URL` for an appcast signed with an Ed25519 key (`SPARKLE_PUBLIC_ED_KEY`, the owner's release public key). | -| stapling | attaching Apple's notarization ticket to a build (`xcrun stapler staple`) so first launch works offline; `scripts/build-devid.sh` staples the app and the disk image. | +| --- | --- | +| entitlement | A capability grant in a `.entitlements` file: `app/CrocApp/CrocApp.entitlements`, `CrocApp-iOS.entitlements`, `app/CrocAppDirect/CrocAppDirect.entitlements`, `app/CrocShare/CrocShare.entitlements`. Example: `files.downloads.read-write` for the default Mac output folder. | +| MAS vs Developer ID | The two macOS distribution paths. Mac App Store: sandbox required. Developer ID: a notarized DMG you host yourself. Export options live in `app/Config/ExportOptions-MAS.plist` and `ExportOptions-DevID.plist`; iOS has `ExportOptions-iOS.plist` (`docs/RELEASING.md`, "Release channels"). | +| notarization | Apple's malware scan, required before a Developer ID build opens without warnings on a fresh Mac. `scripts/build-devid.sh` submits the app and the disk image with `notarytool submit --wait`. | +| `PrivacyInfo.xcprivacy` | Apple's privacy manifest. The app's declares the API categories it uses (UserDefaults, FileTimestamp, SystemBootTime), no tracking and no collected data. `CrocShare`'s declares none of those. | +| Sparkle | The macOS auto-update framework (2.10.0, SwiftPM), linked into `CrocApp Direct` only. It reads `SPARKLE_FEED_URL` for an appcast signed with an Ed25519 key (`SPARKLE_PUBLIC_ED_KEY`, the public half). | +| stapling | Attaching Apple's notarization ticket to a build (`xcrun stapler staple`) so first launch works offline. `scripts/build-devid.sh` staples the app and the disk image. | ## Repo conventions | Term | Means | -|---|---| -| design token | a named value (color, spacing, type, motion) in `design/`, the only source of such values allowed in app or web code; no raw hex/px (`design/AGENTS.md`). | -| harness | one of the `scripts/verify-*.sh` scripts, or `crockit-verify`; proves a real transfer works against the croc CLI, since a green build alone is not evidence. | -| self-heal | the end-of-session rule (root `AGENTS.md`, "Session end report") that `docs/knowledge/` and `docs/known-issues.md` are updated to match reality before a session ends. | -| verify script | any `scripts/verify-*.sh` harness; needs outbound network to the public relay plus a `croc` CLI; CI runs only `verify-interop.sh`, against a local relay (`docs/knowledge/tooling.md`, `CONTRIBUTING.md`). | -| xcframework build step | `scripts/build-xcframework.sh`, the required first build step on a fresh clone: Go compile + gomobile bind into `CrocKit/Croc.xcframework`. | +| --- | --- | +| design token | A named value (color, spacing, type, motion) in `design/`. It is the only allowed source of such values in app or web code: no raw hex or px (`design/AGENTS.md`). | +| harness | A script or tool that proves a real transfer works against the croc command: `scripts/verify-interop.sh`, `scripts/verify-app-mac.sh`, `scripts/verify-app-sim.sh` and `crockit-verify`. A green build alone is not evidence. `docs/BUILDING.md`, "Verification harnesses". | +| verify script | Any `scripts/verify-*.sh`. The transfer ones need outbound network to the public relay plus a `croc` command. CI runs `verify-interop.sh` against a local relay and `verify-design.sh`, which needs no network (`docs/knowledge/tooling.md`, `CONTRIBUTING.md`). | diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..9e7b298 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,44 @@ +# CrocApp docs + +How CrocApp is built, how it works inside, and how it ships. For using the app, see the docs site at [crocapp.dev/docs](https://crocapp.dev/docs/) (source in `web/docs/content/`). For anything visual or any UI copy, start at [`design/AGENTS.md`](../design/AGENTS.md). Writing or editing a doc here follows [`AGENTS.md`](AGENTS.md). + +## Start here + +| Read | When | +| --- | --- | +| [`ARCHITECTURE.md`](ARCHITECTURE.md) | First. The layers from croc to the SwiftUI views, who owns which state, how a transfer flows | +| [`BUILDING.md`](BUILDING.md) | Before your first build, and before running any check or harness | +| [`GLOSSARY.md`](GLOSSARY.md) | A term you cannot infer: PAKE, relay, gobind, prompt pipe, stored transfer | +| [`known-issues.md`](known-issues.md) | Before reporting or fixing a bug: it may be known, or accepted on purpose | + +## Project + +| Read | When | +| --- | --- | +| [`knowledge/project-overview.md`](knowledge/project-overview.md) | You need the goals, constraints and non-goals on one screen | +| [`ROADMAP.md`](ROADMAP.md) | You want to know what is planned and what will not be | +| [`knowledge/features.md`](knowledge/features.md) | You need to know whether a feature exists, on which platform, and which croc option it maps to | +| [`knowledge/prior-art.md`](knowledge/prior-art.md) | You are making a UX, positioning or distribution call | +| [`THREAT-MODEL.md`](THREAT-MODEL.md) | You change anything that touches files, secrets, the network or the release pipeline | + +## Subsystems + +| Read | Before you touch | +| --- | --- | +| [`knowledge/what-is-croc.md`](knowledge/what-is-croc.md) | Anything that depends on croc's protocol, crypto, relays or CLI | +| [`knowledge/crocmobile-bridge.md`](knowledge/crocmobile-bridge.md) | `crocmobile/`, `CrocKit/Sources/`, or the event and error contract between them | +| [`knowledge/app-ui-architecture.md`](knowledge/app-ui-architecture.md) | `TransferController` or any view that observes it | +| [`knowledge/apple-platform-constraints.md`](knowledge/apple-platform-constraints.md) | iOS backgrounding, sandboxing, entitlements, local network access | +| [`knowledge/tooling.md`](knowledge/tooling.md) | CI, a linter, a formatter, or a tool that was deliberately skipped | +| [`knowledge/docs-site.md`](knowledge/docs-site.md) | `web/docs/` | +| [`knowledge/landing-page.md`](knowledge/landing-page.md) | `web/landing/`: the home page, the `/r/` link page, `.well-known/`, `llms.txt` | +| [`knowledge/screenshots.md`](knowledge/screenshots.md) | Any app screenshot: capturing, adding or moving one | + +## Processes + +| Read | When | +| --- | --- | +| [`RELEASING.md`](RELEASING.md) | Signing, notarization, Sparkle, App Store uploads, cutting a release | +| [`knowledge/app-store-listing.md`](knowledge/app-store-listing.md) | Filling in App Store Connect: listing copy, review notes, privacy, export compliance | +| [`knowledge/device-test-checklist.md`](knowledge/device-test-checklist.md) | Testing on a real iPhone, iPad or Mac, before every App Store submission | +| [`knowledge/croc-upgrade-playbook.md`](knowledge/croc-upgrade-playbook.md) | Upstream croc released a new version | diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..2ebf520 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,212 @@ +# Releasing CrocApp + +How CrocApp is signed, notarized, updated and uploaded, and the checklist for cutting a release. The toolchain, first build and verification harnesses are in `BUILDING.md`. + +## Release channels + +| Channel | What ships | Built by | +|---|---|---| +| Direct download | A Developer ID signed, notarized, stapled disk image for Apple silicon Macs. The app updates itself through Sparkle. Published on GitHub Releases. | `scripts/build-devid.sh`, run by `.github/workflows/release.yml` | +| App Store | iOS, iPadOS and macOS on one App Store Connect record (universal purchase), bundle ID `com.bakirgdev.CrocApp`. Builds go to TestFlight; a release goes through Submit for Review. | `scripts/build-appstore.sh` | + +- The direct download is the `CrocApp Direct` target: macOS only, arm64, bundle ID `com.bakirgdev.CrocApp.direct`, product `CrocApp.app`. Its sources are the `app/CrocApp/` synchronized group plus `app/CrocAppDirect/`, built with the Swift condition `DIRECT`. It links Sparkle (SwiftPM, this target only) and shows **Check for Updates…** in the app menu. +- The App Store builds come from the `CrocApp` target and scheme. Its product is also `CrocApp.app`. +- Both targets produce `CrocApp.app`. In one shared DerivedData, each scheme's build replaces the other's. The harness scripts and CI use their own `-derivedDataPath`; otherwise build Direct last. +- Which channels have shipped: `knowledge/project-overview.md`, "Status". + +## Direct download (Developer ID) + +`scripts/build-devid.sh [out-dir]` builds the signed, notarized, stapled disk image. The default out-dir is `dist` and the output is `CrocApp--arm64.dmg`. In order, it: + +1. Finds the "Developer ID Application" identity for team `HBSU359M33` by SHA-1 and archives the `CrocApp Direct` scheme (Release, arm64) with manual signing and the provisioning profile "CrocApp Direct Developer ID". Prints `archive: OK`. +2. Exports with `app/Config/ExportOptions-DevID.plist` and runs `codesign --verify --strict --deep`. Prints `DEVID-EXPORT-OK`. +3. Zips the app, notarizes it (`xcrun notarytool submit --wait`), staples it, and runs `syspolicy_check distribution` on the stapled app. Prints `DEVID-APP-NOTARIZED-OK`. +4. Builds the disk image with `scripts/build-dmg.sh` (prints `DMG-OK`), signs it with a timestamp, notarizes and staples it, then runs `stapler validate` and `spctl -a -vv -t open --context context:primary-signature`. Prints `DEVID-NOTARIZED-OK`. + +Notes on the script: + +- The app and the disk image are notarized separately, so an app copied out of the image opens offline on first launch. +- The script exits 1 when no Developer ID identity is present. +- It does not use `syspolicy_check notary-submission`. On macOS 27 that check fails every app that is not yet notarized ("Gatekeeper rejected this file"), so a real notarization problem is read from the notary log instead. +- `syspolicy_check distribution` needs a stapled app, which is why it runs after the app's notarization. +- `DEVID_WORK` sets the scratch directory (archive, DerivedData, logs). + +To run it locally you need: + +- A "Developer ID Application" identity for team `HBSU359M33` in the keychain search list. +- The "CrocApp Direct Developer ID" profile in `~/Library/Developer/Xcode/UserData/Provisioning Profiles/`. +- Notary credentials, one of: + - a keychain profile, default `crocapp-notary` (set `NOTARY_KEYCHAIN_PROFILE` to use another name); + - `NOTARY_KEY` (path to the `.p8`), `NOTARY_KEY_ID` and `NOTARY_ISSUER`, the form CI uses. +- `brew install create-dmg`. + +### The release workflow + +`.github/workflows/release.yml` runs the same script. It starts on a `v*` tag push or on `workflow_dispatch`. + +- **A tag run** runs `swift-format` and the Go checks, then the build job. + - It checks the tag against `MARKETING_VERSION` and against a dated heading in `CHANGELOG.md` (see "Each release"). + - It builds, signs and notarizes, then writes a signed `appcast.xml` with Sparkle's `generate_appcast`, seeded with the latest release's appcast and with the CHANGELOG section embedded as release notes. + - It attests the stapled files, then publishes the disk image, `appcast.xml`, the `Croc.xcframework` zip and `SHA256SUMS.txt`. + - It fails when the build number is not above every build in the published `appcast.xml`, because Sparkle only offers a higher `CFBundleVersion`. +- **A `workflow_dispatch` run** is a rehearsal. It skips the tag and CHANGELOG checks and the publish job, only warns about the build number, and publishes nothing. It can only run once the workflow is on `main`, because dispatch needs the workflow on the default branch and the environment allows `main`. +- **The build job** runs in the `release` environment: a required reviewer, and a deployment policy limited to branch `main` and tags `v*`. + +Before it signs the appcast, the workflow checks that `SPARKLE_ED_PRIVATE_KEY` matches the built app's `SUPublicEDKey`. After signing, it checks that the new item carries `sparkle:edSignature`. With a mismatched key `generate_appcast` only warns, exits 0 and writes the item unsigned, which every client rejects. + +The `release` environment holds seven secrets: + +| Secret | Holds | +|---|---| +| `DEVID_P12_BASE64` | Developer ID Application certificate and key, base64 `.p12` | +| `DEVID_P12_PASSWORD` | the `.p12` password | +| `DEVID_PROFILE_BASE64` | the "CrocApp Direct Developer ID" profile, base64 | +| `ASC_KEY_P8` | App Store Connect API key (`.p8` text) used by `notarytool` | +| `ASC_KEY_ID` | that key's ID | +| `ASC_ISSUER_ID` | that key's issuer ID | +| `SPARKLE_ED_PRIVATE_KEY` | Sparkle Ed25519 release private key, passed to `generate_appcast` on stdin | + +### Export options + +Three export option files live under `app/Config/`. All share `teamID` `HBSU359M33` and `destination` `export`. + +| File | `method` | Channel | +|---|---|---| +| `ExportOptions-DevID.plist` | `developer-id` | Direct download. Manual signing, `signingCertificate` "Developer ID Application", profile "CrocApp Direct Developer ID" for `com.bakirgdev.CrocApp.direct`. Used by `scripts/build-devid.sh`. | +| `ExportOptions-iOS.plist` | `app-store-connect` | iOS App Store and TestFlight, from the `CrocApp` scheme on `generic/platform=iOS`. Automatic signing. Used by `scripts/build-appstore.sh`. | +| `ExportOptions-MAS.plist` | `app-store-connect` | Mac App Store and TestFlight, from the `CrocApp` scheme on `generic/platform=macOS`. Same keys as the iOS file. Used by `scripts/build-appstore.sh`. | + +The two App Store files also set `signingStyle` `automatic` and `manageAppVersionAndBuildNumber` false. `build-appstore.sh --upload` leaves the committed files alone: it sets `destination` to `upload` in a scratch copy, so they always describe an export to disk. + +### Sparkle configuration + +Two build settings on the `CrocApp Direct` target configure Sparkle: + +- `SPARKLE_PUBLIC_ED_KEY` is the release public key (public by design). Read the current value from the pbxproj or the built app's `SUPublicEDKey`. +- `SPARKLE_FEED_URL` is `https://github.com/bakirgdev/CrocApp/releases/latest/download/appcast.xml`. + +The release key is plain text that the owner keeps (made with `openssl`, see "One-time setup"), not a keychain item, so Sparkle's `generate_keys` is not used. The owner's copy is the `SPARKLE_ED_PRIVATE_KEY` secret. + +## Testing a Sparkle update locally + +This proves that a Release `CrocApp Direct` build installs a newer one. It uses a throwaway Ed25519 key, never the release key, and a feed on localhost. Sparkle accepts an `http` localhost feed and only logs a warning about HTTPS, so no ATS exception is needed. + +Sparkle's tools come with the package, at `/SourcePackages/artifacts/sparkle/Sparkle/bin/`. Work in a scratch directory with a `feed/` folder. + +1. Make a throwaway key (raw 32-byte seed and public key, base64): + ```bash + openssl genpkey -algorithm ed25519 -out key.pem + openssl pkey -in key.pem -outform DER | tail -c 32 | base64 > priv.b64 + openssl pkey -in key.pem -pubout -outform DER | tail -c 32 | base64 > pub.b64 + ``` +2. Build twice, as build 1 and build 2, from `app/`: + ```bash + for N in 1 2; do + xcodebuild -scheme 'CrocApp Direct' -configuration Release -destination 'platform=macOS' \ + -derivedDataPath "dd$N" -allowProvisioningUpdates CURRENT_PROJECT_VERSION=$N \ + "SPARKLE_PUBLIC_ED_KEY=$(cat pub.b64)" SPARKLE_FEED_URL=http://127.0.0.1:8765/appcast.xml build + done + ``` +3. Zip build 2 into `feed/` and write the signed appcast: + ```bash + ditto -c -k --sequesterRsrc --keepParent dd2/Build/Products/Release/CrocApp.app feed/CrocApp-2.zip + generate_appcast --ed-key-file - --download-url-prefix http://127.0.0.1:8765/ feed < priv.b64 + ``` +4. Serve it: `cd feed && python3 -m http.server 8765 --bind 127.0.0.1`. +5. Copy build 1 somewhere writable (`ditto dd1/Build/Products/Release/CrocApp.app apps/CrocApp.app`) and launch it. Choose **Check for Updates…**, then **Install Update**, then **Install and Relaunch** in Sparkle's standard UI. From the second launch on, Sparkle first asks about automatic checks. +6. Expect the old process to exit, a new process to start, and `apps/CrocApp.app` to report `CFBundleVersion` 2. + +A known hang after **Install and Relaunch** on the first launch of the Direct bundle ID on a Mac is tracked in `known-issues.md`, "Release". + +## App Store builds + +`scripts/build-appstore.sh [--upload] [ios|macos]...` builds the `CrocApp` scheme (Release) for the App Store channels. No platform argument means both. For each platform it: + +1. Archives for `generic/platform=iOS` or `generic/platform=macOS` and prints ` archive: OK`. +2. Runs `xcodebuild -exportArchive` with `ExportOptions-iOS.plist` or `ExportOptions-MAS.plist`. + - Without `--upload`, it writes `CrocApp.ipa` (iOS) or `CrocApp.pkg` (macOS) to the scratch directory and prints `APPSTORE-EXPORT-OK `. Nothing leaves the Mac. + - With `--upload`, it sets `destination` to `upload` in a scratch copy of the plist and sends the build to App Store Connect (TestFlight). It prints `APPSTORE-UPLOAD-OK `. + +`APPSTORE_WORK` sets the scratch directory (archives, DerivedData, per-step logs). The script deletes it first. On failure it prints the tail of the failing log and its path. + +To run it you need: + +- Xcode signed in to the Apple ID that belongs to team `HBSU359M33` (Settings, Accounts). Signing is automatic and cloud-managed through `-allowProvisioningUpdates`, so no Apple Distribution or Mac Installer certificate has to be in the keychain. +- `CrocKit/Croc.xcframework` built (`scripts/build-xcframework.sh`). +- The App Store Connect record "CrocApp" (iOS and macOS on one record, bundle ID `com.bakirgdev.CrocApp`). +- A build number App Store Connect has not seen for this version. The build number is always `CURRENT_PROJECT_VERSION` in `app/Config/Version.xcconfig` (`manageAppVersionAndBuildNumber` is false), and App Store Connect refuses a number it has already seen for that `MARKETING_VERSION`. Bump it in its own commit before every upload. Run the script without `--upload` first if the change is in doubt. + +Facts about the upload that look like problems but are not: + +- **`MinimumOSVersion = 100.0`.** gomobile writes this into the framework `Info.plist` (x/mobile `bind_iosapp.go`, an SwiftPM workaround). App Store Connect accepts it, so `build-xcframework.sh` does not patch it. +- **"Upload Symbols Failed".** This is the only warning, for `Croc.framework` (iOS) and `A` (macOS). Xcode embeds a 50 KB stub `Croc.framework` dylib with no dSYM, because the gomobile xcframework is a static archive linked into the app binary. It is not an ITMS warning (`known-issues.md`, "Accepted"). +- **App category.** `LSApplicationCategoryType` is `public.app-category.utilities`, set as `INFOPLIST_KEY_LSApplicationCategoryType` on `CrocApp` and `CrocApp Direct`. Without it the macOS archive warns "No App Category is set". + +### Privacy manifest + +`app/CrocApp/PrivacyInfo.xcprivacy` declares the required-reason APIs the Go engine reaches: `FileTimestamp` (`C617.1`, `3B52.1`) and `SystemBootTime` (`35F9.1`), besides `UserDefaults` (`CA92.1`). The engine is linked statically into the app binary and imports `stat`, `fstat`, `fstatat`, `lstat` and `mach_absolute_time` (checked with `nm -u`). It does not import `statfs`, `statvfs` or `getattrlist`, so there is no `DiskSpace` entry. Re-run `nm -u` after a croc or Go bump. + +### Checking the result of an upload + +After every upload: + +1. Open the developer-account email. Apple sends ITMS warnings or errors there after processing (for example ITMS-91053, a missing required-reason API declaration). Fix what it names in `PrivacyInfo.xcprivacy` and upload a new build number. +2. In App Store Connect, open the app's TestFlight tab and confirm that both the iOS and the macOS build finished processing and are not marked invalid. +3. Install the build from TestFlight on an iPhone and on a Mac, and open it once on each. An internal TestFlight group with the owner in it is enough. + +## Export compliance + +Both app `Info.plist` files (`app/Config/CrocApp-Info.plist`, `app/Config/CrocApp-macOS-Info.plist`) set `ITSAppUsesNonExemptEncryption` to `false`. Setting it to `true` without an `ITSEncryptionExportComplianceCode` fails the upload with `Invalid Export Compliance Code`. + +- `false` is a position, not only a way past the check. croc's encryption (AES-256-GCM, PAKE) is standard and its source is public, so the app relies on the open-source exemption (EAR 742.15(b)). +- Apple's own table makes `false` coherent only while France is excluded. The first App Store release therefore ships without France in its availability. A later release adds France after an ANSSI declaration, and the key then becomes `true` with an issued code. +- Still open: whether croc's selectable `siec` PAKE curve counts as non-standard cryptography, which would need a BIS and NSA notification email or hiding the curve. +- If the exemption does not hold, set the key to `true` and add the `ITSEncryptionExportComplianceCode` that App Store Connect issues after the documentation is approved. + +The research, the exact answers to give if App Store Connect asks, and the SIEC question are in `docs/knowledge/app-store-listing.md`, "Export compliance". The owner's open actions are in `known-issues.md`, "Release". + +## One-time setup + +These steps are done by a person, not by a script. Follow them on a new machine, or to rotate one item. Keep every secret in a password manager, and never paste key material into a shared terminal or chat. + +1. **Agreements, tax and banking.** In App Store Connect, accept the Free Apps and Paid Apps agreements, fill in tax and bank details, and declare EU Digital Services Act trader status with public contact details. Before each release, check that no agreement shows as pending or needing re-acceptance. +2. **Developer ID Application certificate.** + 1. At developer.apple.com, Certificates, create a "Developer ID Application" certificate. The certificate signing request comes from Keychain Access, Certificate Assistant. Download the certificate and double-click it to install it into the login keychain. + 2. Check it: `security find-identity -v -p codesigning` lists "Developer ID Application" for team `HBSU359M33`. + 3. In Keychain Access, export the certificate with its private key as a `.p12` with a password. These become the `DEVID_P12_BASE64` and `DEVID_P12_PASSWORD` secrets. +3. **Notarization API key.** In App Store Connect, create a team API key (for example named "CrocApp notarization") with the Developer role, which is enough for `notarytool`. Download the `.p8` (Apple offers it once) and note the Key ID and Issuer ID. Then: + ```bash + xcrun notarytool store-credentials crocapp-notary --key --key-id --issuer + xcrun notarytool history --keychain-profile crocapp-notary + ``` + The second command answering confirms the key works. The three values are also the CI secrets `ASC_KEY_P8`, `ASC_KEY_ID` and `ASC_ISSUER_ID`. +4. **Bundle IDs and the App Store Connect record.** Three bundle IDs exist: `com.bakirgdev.CrocApp`, `com.bakirgdev.CrocApp.CrocShare` and `com.bakirgdev.CrocApp.direct`. Xcode automatic signing creates them on the first signed build. On a new Mac, build once with `-allowProvisioningUpdates -allowProvisioningDeviceRegistration`, which registers the Mac as a development device and signs the app IDs with Associated Domains. The App Store Connect record "CrocApp" holds iOS and macOS on one record (universal purchase) under `com.bakirgdev.CrocApp`. +5. **Developer ID provisioning profile.** At developer.apple.com, Profiles, create a Developer ID profile named "CrocApp Direct Developer ID" for `com.bakirgdev.CrocApp.direct` with Associated Domains. Download it and copy it into `~/Library/Developer/Xcode/UserData/Provisioning Profiles/`, where `scripts/build-devid.sh` needs it. Its base64 is the `DEVID_PROFILE_BASE64` secret. Renew the profile before it expires. +6. **Sparkle release key.** In your own terminal: + ```bash + openssl genpkey -algorithm ed25519 -out k.pem && echo "private: $(openssl pkey -in k.pem -outform DER | tail -c 32 | base64)" && echo "public: $(openssl pkey -in k.pem -pubout -outform DER | tail -c 32 | base64)" && rm k.pem + ``` + Save the `private:` line in the password manager. It becomes `SPARKLE_ED_PRIVATE_KEY`. Put the `public:` line in `SPARKLE_PUBLIC_ED_KEY` on the `CrocApp Direct` target. Changing the key breaks updates for every installed copy, because clients only accept items signed by the key their app carries. +7. **GitHub `release` environment and secrets.** The environment `release` has a required reviewer and a deployment policy that allows branch `main` and tags `v*`. To create or rotate a secret, pipe the value from a file so it is never printed or typed: + ```bash + gh secret set DEVID_P12_PASSWORD --env release < password.txt + base64 < cert.p12 | gh secret set DEVID_P12_BASE64 --env release + ``` + Use the `gh` account that matches the repo owner. After setting the secrets, check locally that the Sparkle private key derives the committed public key, the password opens the `.p12`, and the `.p8` with its key ID and issuer authenticates `notarytool history`. +8. **Physical devices.** An iPhone on iOS 26 or later with Developer Mode on, shown in Xcode's Devices and Simulators window, and Xcode signed in with the Apple ID that `scripts/build-appstore.sh` uses. + +## Each release + +1. **Bump the version.** In `app/Config/Version.xcconfig`, set `MARKETING_VERSION` for a new version, and raise `CURRENT_PROJECT_VERSION` for every upload or tag. Do this in its own pull request or commit. A tag run fails when the build number is not above every build in the published `appcast.xml`. App Store Connect refuses a build number it has already seen for that version. +2. **Write the CHANGELOG heading.** Rename `## [Unreleased]` and date it as `## [X.Y.Z] - YYYY-MM-DD` (Keep a Changelog). `release.yml` requires exactly that heading for the tagged version, requires the section to be non-empty, and publishes the section as the release notes and the Sparkle notes. `X.Y.Z` must be a plain dotted version. +3. **Rehearse.** With the change on `main`, start the workflow from GitHub Actions with `workflow_dispatch`. Approve the `release` environment deployment as the required reviewer (in the run's page, or with `gh api -X POST repos///actions/runs//pending_deployments -F 'environment_ids[]=' -f state=approved -f comment=...`). The rehearsal builds, signs, notarizes and writes the appcast, and publishes nothing. +4. **Tag.** Tag the squashed commit on `main` as `vX.Y.Z` and push the tag. The tag must equal `MARKETING_VERSION`. Approve the `release` environment again. +5. **Verify the published DMG.** Download it from the release and run `xcrun stapler validate ` and `spctl -a -vv -t open --context context:primary-signature `. Expect "Notarized Developer ID". Compare it against `SHA256SUMS.txt`, and run `gh attestation verify --repo bakirgdev/CrocApp`. +6. **Check Sparkle.** Confirm the new item in `appcast.xml` carries `sparkle:edSignature`, and that an older installed build offers the update. +7. **Upload App Store builds** when the release includes the App Store: run `scripts/build-appstore.sh --upload`, then follow "Checking the result of an upload". It prints `APPSTORE-UPLOAD-OK ios` and `APPSTORE-UPLOAD-OK macos`. +8. **Run the device checklist** in `docs/knowledge/device-test-checklist.md` on a physical iPhone whenever a change calls for it. +9. **Before Submit for Review** (the first App Store release, 1.1.0), finish the open owner items: + - The App Store Connect listing: pricing, availability without France, privacy answers, age rating, export compliance answers and screenshots. The field text is in `docs/knowledge/app-store-listing.md`. + - An Accessibility Inspector and VoiceOver audit. + - Apple's answer on the multicast entitlement request. If it is granted, Apple adds "Multicast Networking" as a capability on `com.bakirgdev.CrocApp`, and the `com.apple.developer.networking.multicast` entitlement can be added to the iOS target. If it is not granted by submission, ship without it, hide the iOS multicast setting, and file a `future` issue. + - A check-in with the croc maintainer about the name, the public relay pool default, the Tailcat `auto` default, getcroc.com stored transfers and the browser link. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5e87ef9..c2cbe5d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,8 +1,12 @@ # Roadmap -What CrocApp intends to do and not do, through 2027. This is intent, not a promise: priorities change, and one maintainer sets the pace (see [`GOVERNANCE.md`](../GOVERNANCE.md)). The plan changes by pull request to this file. To propose something, open a [discussion](https://github.com/bakirgdev/CrocApp/discussions) or a [feature request](https://github.com/bakirgdev/CrocApp/issues/new?template=feature_request.yml). +What CrocApp intends to do and not do. This is intent, not a promise: priorities change, and one maintainer sets the pace (see [`GOVERNANCE.md`](../GOVERNANCE.md)). The plan changes by pull request to this file. To propose something, open a [discussion](https://github.com/bakirgdev/CrocApp/discussions) or a [feature request](https://github.com/bakirgdev/CrocApp/issues/new?template=feature_request.yml). -Last reviewed: 2026-10-18. Shipped work is in [`CHANGELOG.md`](../CHANGELOG.md); CrocApp 1.0.0 shipped 2026-10-15. +Shipped work is in [`CHANGELOG.md`](../CHANGELOG.md). Release status is in [`knowledge/project-overview.md`](knowledge/project-overview.md), "Status". + +## Next milestone + +**1.1.0 is the first App Store release**, for iPhone, iPad and Mac. What gates Submit for Review is listed in [`known-issues.md`](known-issues.md), "Release", and the steps are in [`RELEASING.md`](RELEASING.md), "App Store builds". ## Ongoing @@ -16,26 +20,35 @@ Last reviewed: 2026-10-18. Shipped work is in [`CHANGELOG.md`](../CHANGELOG.md); ## Planned -All of these are deferred until after 1.0 and carry the [`future` label](https://github.com/bakirgdev/CrocApp/issues?q=is%3Aissue+is%3Aopen+label%3Afuture). +Everything here is deferred until after the App Store release and carries the [`future` label](https://github.com/bakirgdev/CrocApp/issues?q=is%3Aissue+is%3Aopen+label%3Afuture). | Area | Item | |---|---| -| Distribution | [#10](https://github.com/bakirgdev/CrocApp/issues/10) Homebrew cask, once the repo meets homebrew/cask's acceptance policy (see the README Download table) | -| Languages | [#11](https://github.com/bakirgdev/CrocApp/issues/11) Localize the app and docs, starting with Bosnian, with Crowdin for community translations | -| System integration | [#22](https://github.com/bakirgdev/CrocApp/issues/22) macOS Finder Share extension | -| | [#23](https://github.com/bakirgdev/CrocApp/issues/23) Control Center control for sending and receiving | -| | [#24](https://github.com/bakirgdev/CrocApp/issues/24) Spotlight indexing of transfer history (never code phrases or link keys) | -| | [#25](https://github.com/bakirgdev/CrocApp/issues/25) Widgets for recent transfers and quick actions | -| Web and upstream | [#9](https://github.com/bakirgdev/CrocApp/issues/9) Talk to the croc maintainer about infrastructure and a web version | -| | [#8](https://github.com/bakirgdev/CrocApp/issues/8) Web app for sending and receiving from a browser (to be reviewed; may be closed) | +| Distribution | [Publish a Homebrew cask once the repo meets homebrew/cask requirements](https://github.com/bakirgdev/CrocApp/issues/10) (see the README Download table) | +| Languages | [Localize the app and docs, with Crowdin for community translations](https://github.com/bakirgdev/CrocApp/issues/11), starting with Bosnian. The app uses plain string literals today (`knowledge/app-ui-architecture.md`, "Structure") | +| System integration | [macOS Finder Share extension](https://github.com/bakirgdev/CrocApp/issues/22) | +| | [Control Center control for sending and receiving](https://github.com/bakirgdev/CrocApp/issues/23) | +| | [Spotlight indexing of transfer history](https://github.com/bakirgdev/CrocApp/issues/24) (never code phrases or link keys) | +| | [Widgets for recent transfers and quick actions](https://github.com/bakirgdev/CrocApp/issues/25) | +| Web and upstream | [Talk to the croc maintainer about infrastructure and a web version](https://github.com/bakirgdev/CrocApp/issues/9) | +| | [Web app: send and receive files with croc from a browser](https://github.com/bakirgdev/CrocApp/issues/8) (to be reviewed; may be closed) | + +## Approved, not started -Also approved in [`knowledge/features.md`](knowledge/features.md) but not yet started: running your own relay from the Mac app (F29), saved codes and favorite peers (F33), a relay health check and diagnostics screen (F35). A Wi-Fi Aware direct path (F37) is listed as "Later". +No issue tracks these yet. + +| Item | Note | +|---|---| +| Run your own relay from the Mac app | A menu bar relay server (`croc relay`) | +| Saved codes and favorite peers | App-native | +| Relay health check and diagnostics screen | App-native | +| Wi-Fi Aware direct path | Waits on Apple: available on iOS and iPadOS 26, absent on macOS 26. A fast path between two CrocApp devices | ## Not planned | Not planned | Why | |---|---| | Anything in the [Unlikely to be merged](../CONTRIBUTING.md#unlikely-to-be-merged) table: iOS or macOS below 26, telemetry, a paid tier, a UIKit or AppKit rewrite, bundled fonts or icon sets, changes to croc's protocol or crypto | Recorded decisions, with the reason for each in that table | -| Windows, Linux or Android clients | Non-goal for now ([project overview](knowledge/project-overview.md)). Interest can be shown in an [issue](https://github.com/bakirgdev/CrocApp/issues) | +| Windows, Linux or Android clients | Non-goal ([project overview](knowledge/project-overview.md)). Interest can be shown in an [issue](https://github.com/bakirgdev/CrocApp/issues) | | Forking or reimplementing croc | CrocApp wraps croc | -| `--stdout` piping, `--classic` mode, `--remember` | Skipped in [`knowledge/features.md`](knowledge/features.md): CLI-only, insecure by design, and redundant with persistent settings | +| `--stdout` piping, `--classic` mode, `--remember` | Skipped in [`knowledge/features.md`](knowledge/features.md), "Skipped": CLI-only, insecure by design, and redundant with persistent settings | diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index 4cd5b3a..7778800 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -1,30 +1,30 @@ # Threat model and assurance case -Why CrocApp's security requirements are met, for a reviewer who wants to check. Scope: CrocApp 1.0 (iOS, iPadOS and macOS), the `crocmobile/` Go bridge, the `CrocKit` package, the share extension, and the release pipeline. The transfer protocol belongs to [croc](https://github.com/schollz/croc) v11.5.4 (pinned in `crocmobile/go.mod`); this document says what CrocApp relies on there and what it adds. To report a vulnerability, see [`SECURITY.md`](../SECURITY.md). +Why CrocApp's security requirements are met, for a reviewer who wants to check. Scope: CrocApp on iOS, iPadOS and macOS (release status: `knowledge/project-overview.md`, "Status"), the `crocmobile/` Go bridge, the `CrocKit` package, the share extension, and the release pipeline. The transfer protocol belongs to [croc](https://github.com/schollz/croc) v11.5.4 (pinned in `crocmobile/go.mod`); this document says what CrocApp relies on there and what it adds. To report a vulnerability, see [`SECURITY.md`](../SECURITY.md). **No third-party security audit of croc or CrocApp has been done.** Everything below is the maintainer's own analysis, backed by the code and tests it names. Where a claim is read from croc's source and not run here, it says so. -## 1. Security requirements +## Security requirements The user-facing claims are the approved list in `design/content.md` ("Claims"). Each row names the mechanism that meets it and the section that argues it. -| # | Requirement (as published) | Met by | Argued in | -|---|---|---|---| -| R1 | Transfers are end-to-end encrypted. | PAKE-derived transfer key, AES-256-GCM, in croc; CrocApp implements no cryptography | 4.1 | -| R2 | The code phrase authenticates both devices (PAKE). | PAKE2 plus HMAC-SHA256 key confirmation, in croc | 4.1 | -| R3 | The relay only ever sees ciphertext. | Relay forwards the encrypted stream; the relay hop has its own authentication | 4.2 | -| R4 | Send as link: files are encrypted on your device before they reach the link server, and the key travels in the link's `#` fragment, which browsers never send to a server. | croc `storecrypto` (croc-store-v1) | 4.4 | -| R5 | When the two devices connect over the local network, the data stays on that network. | croc's local connection; Local network only setting | 4.2 | -| R6 | No accounts, analytics, ads or tracking; history stays on the device. | No such code or endpoints; local SwiftData history | 5 | +| Requirement (as published) | Met by | Argued in | +|---|---|---| +| **End-to-end encryption**: Transfers are end-to-end encrypted. | PAKE-derived transfer key, AES-256-GCM, in croc; CrocApp implements no cryptography | Key agreement and data protection | +| **Authenticated devices**: The code phrase authenticates both devices (PAKE). | PAKE2 plus HMAC-SHA256 key confirmation, in croc | Key agreement and data protection | +| **Relay sees ciphertext**: The relay only ever sees ciphertext. | Relay forwards the encrypted stream; the relay hop has its own authentication | Relay and local network | +| **Send as Link encryption**: Send as Link: files are encrypted on your device before they reach the link server, and the key travels in the link's `#` fragment, which browsers never send to a server. | croc `storecrypto` (croc-store-v1) | Send as Link | +| **Local network stays local**: When the two devices connect over the local network, the data stays on that network. | croc's local connection; Local Network Only setting | Relay and local network | +| **No tracking**: No accounts, analytics, ads or tracking; history stays on the device. | No such code or endpoints; local SwiftData history | Privacy | Requirements CrocApp adds on top of the protocol, because the app is where files meet the user's disk: -| # | Requirement | -|---|---| -| R7 | A received file name cannot write outside the chosen output folder or inject extra paths. | -| R8 | Nothing is written for a transfer the user declines, and receive does not overwrite existing files unless the user chooses Replace. | -| R9 | Secrets the user enters (relay password, proxy credentials, Send as link receipts) are not stored in plaintext preferences. | -| R10 | A release that fails signature, notarization or update-signing checks does not ship; a user can verify what they run. | +| Requirement | +|---| +| **Safe received names**: A received file name cannot write outside the chosen output folder or inject extra paths. | +| **No unconsented writes**: Nothing is written for a transfer the user declines, and receive does not overwrite existing files unless the user chooses Replace. | +| **Secrets out of preferences**: Secrets the user enters (relay password, proxy credentials, Send as Link receipts) are not stored in plaintext preferences. | +| **Verifiable releases**: A release that fails signature, notarization or update-signing checks does not ship; a user can verify what they run. | ### Non-goals @@ -32,68 +32,68 @@ CrocApp does not defend against, and does not claim to: - **A compromised device.** Malware or a debugger on either device sees the files and the code phrase. - **Someone who has the code phrase.** Whoever holds it can complete the exchange. Share it over a channel you trust. A receive shows the file list and waits for the user to accept (auto-accept is off by default), and the optional both-sides-confirm setting (croc `--ask`) adds a confirmation on the sender. -- **Metadata visible to the network and the relay.** IP addresses, timing and the volume of data are visible to the relay operator and on-path observers. Whether the relay also sees anything derived from the code phrase (croc picks the relay room from its first word: `src/codephrase/codephrase.go:194-222`, read from source per `docs/known-issues.md`) is not analysed here. Unknown. +- **Metadata visible to the network and the relay.** IP addresses, timing and the volume of data are visible to the relay operator and on-path observers. Whether the relay also sees anything derived from the code phrase (croc picks the relay room from its first word: `src/codephrase/codephrase.go:194-222`, read from source per `known-issues.md`) is not analysed here. Unknown. - **Anonymity.** No claim is made. - **A weak custom code phrase.** The app accepts any code of at least 6 bytes (`minCodeLength`, `crocmobile/crocmobile.go`). A code that is easy to guess is easy to guess online. The entropy of generated codes is croc's and is not assessed here. - **A malicious or compromised link server's availability.** It holds ciphertext and can delete it or refuse service. - **The browser receive path.** "Receive in Your Browser" opens croc's own web app at `getcroc.com`, which serves the JavaScript that decrypts the file and receives the code in the query string. It is outside the claims (see `web/docs/content/security-and-privacy.md`). -## 2. Assets +## Assets | Asset | Where it lives | Why it matters | |---|---|---| | File and text contents | Sender and receiver devices; ciphertext in transit | Confidentiality, integrity | | Code phrase | Screen, QR and `https://crocapp.dev/r/#` link; never stored in full in History (first word or two characters only) | Whoever has it can join the transfer | | Transfer key | Memory of the Go engine, one session | Decrypts the transfer | -| Send as link key | The link's `#` fragment; the receipt in the Keychain | Anyone with the whole link decrypts the files | +| Send as Link key | The link's `#` fragment; the receipt in the Keychain | Anyone with the whole link decrypts the files | | Relay password, proxy credentials | Keychain (`app/CrocApp/Support/KeychainStore.swift`) | Access to the user's own relay or proxy | | Received files on disk | The output folder the user chose | Must not be written outside it | | App binary and updates | GitHub releases, App Store | Integrity of what users run | | Signing identities and keys | GitHub `release` environment secrets | Control over what ships as CrocApp | -## 3. Actors and threats +## Actors and threats | Actor | Capability assumed | Threat | Response | |---|---|---|---| -| Network attacker (on path) | Reads, drops, modifies traffic | Read or alter a transfer; learn metadata | Encrypted and authenticated end to end (4.1). Metadata exposure accepted (non-goals). | -| Relay operator (public pool or custom) | Sees everything the relay sees; can drop or delay | Read data; impersonate a device | Sees ciphertext only. The relay is not trusted for confidentiality or for who the other device is (4.2). Can refuse service. | -| Link server operator | Stores ciphertext and metadata it is given | Read files; serve altered files | Key is not sent to it (4.4). Files and manifest are authenticated by AES-GCM; the link server cannot forge a valid chunk without the key (croc `storecrypto`, not re-derived here). | -| Someone guessing the code phrase | Can contact the relay and try codes | Join a transfer | PAKE lets one run test one guess and a wrong guess fails the exchange (4.1). Captured traffic gives no offline guessing material (property of PAKE, strong inference). A public relay limits admission to 30 connections a minute per address (`docs/known-issues.md`), which is relay policy, not an app control. | -| The other device, malicious | Completed the exchange legitimately | Send hostile names, huge sizes, a changed list; abuse prompts | Section 7, rows W1 to W6. | +| Network attacker (on path) | Reads, drops, modifies traffic | Read or alter a transfer; learn metadata | Encrypted and authenticated end to end (Key agreement and data protection). Metadata exposure accepted (non-goals). | +| Relay operator (public pool or custom) | Sees everything the relay sees; can drop or delay | Read data; impersonate a device | Sees ciphertext only. The relay is not trusted for confidentiality or for who the other device is (Relay and local network). Can refuse service. | +| Link server operator | Stores ciphertext and metadata it is given | Read files; serve altered files | Key is not sent to it (Send as Link). Files and manifest are authenticated by AES-GCM; the link server cannot forge a valid chunk without the key (croc `storecrypto`, not re-derived here). | +| Someone guessing the code phrase | Can contact the relay and try codes | Join a transfer | PAKE lets one run test one guess and a wrong guess fails the exchange (Key agreement and data protection). Captured traffic gives no offline guessing material (property of PAKE, strong inference). A public relay limits admission to 30 connections a minute per address (`known-issues.md`), which is relay policy, not an app control. | +| The other device, malicious | Completed the exchange legitimately | Send hostile names, huge sizes, a changed list; abuse prompts | The first six rows of Common weaknesses countered (path traversal, injection, overflow, time-of-check, authentication, resource use). | | Malicious files received | Opened later by the user | Content-level attacks (malware, parser bugs in other apps) | Out of scope: CrocApp never opens or executes received files. | -| Other apps and users on the device | Same user account, or a different app | Read secrets or staged files | Sandbox, Keychain, App Group scoping (section 4.3, W7). | -| Supply chain / update channel attacker | Compromise a dependency, a CI runner, a release asset or the feed | Ship a modified app | Section 4.5, W11 and W12. | +| Other apps and users on the device | Same user account, or a different app | Read secrets or staged files | Sandbox, Keychain, App Group scoping (App sandbox and entitlements; cleartext credentials in Common weaknesses countered). | +| Supply chain / update channel attacker | Compromise a dependency, a CI runner, a release asset or the feed | Ship a modified app | Build, update and release; update integrity and outdated components in Common weaknesses countered. | -## 4. Trust boundaries +## Trust boundaries ``` Device A (sandboxed app) Device B +----------------------------------------------+ +--------------+ - | SwiftUI app --B1--> CrocKit --B2--> Go | | CrocApp or | + | SwiftUI app ------> CrocKit ------> Go | | CrocApp or | | (TransferController) (actor) crocmobile | | croc CLI | | ^ + croc v11.5.4| +------^-------+ - | |B3 | | | - | Share extension (App Group) | B4 | | B5 (PAKE + AES-GCM, + | | | | | + | Share extension (App Group) | | | (PAKE + AES-GCM, +--------|-----------------------------|---------+ | end to end) | v | other apps / Files Relay (pool or own) <----------------+ - Link server (Send as link) B6 - Update channel: GitHub releases + appcast.xml --B7--> Direct (DMG) build only - CI: GitHub Actions `release` environment --B8--> signed, notarized, attested assets + Link server (Send as Link) + Update channel: GitHub releases + appcast.xml ------> Direct (DMG) build only + CI: GitHub Actions `release` environment ------> signed, notarized, attested assets ``` | Boundary | Crosses | What is trusted on the far side | Control | |---|---|---|---| -| B1 SwiftUI to `CrocKit` | Typed Swift calls | Nothing from the network; the layer above is the user | `CrocEngine` is the single caller of Go; `TransferController` the single caller of `CrocEngine` (`docs/ARCHITECTURE.md`) | -| B2 `CrocKit` to Go (gomobile) | Scalars and strings; file lists and progress as JSON; paths joined by `\n` | Go side trusts the joined path list | `CrocEngine.joinedPaths` refuses line breaks; Go `splitPaths` keeps names exact; malformed JSON becomes a failure (`DelegateBridge`) | -| B3 App to share extension, via App Group | Staged files and `manifest.json` | The manifest is data, not trusted | `ShareInbox.refresh` filters names with `ReceivedName.isUnsafe`; the extension has only the App Group entitlement and no network | -| B4 Go engine to the network and filesystem | Sockets, files, process-global `Chdir`, stdin | Everything on the wire | Section 7 | -| B5 Device to the other device | Encrypted stream, direct or via relay | Only a device that proved knowledge of the code phrase; still treated as untrusted for names and sizes | PAKE, key confirmation, per-message AES-GCM; names and sizes validated on receipt (W1 to W4) | -| B6 Device to relay or link server | Ciphertext, plus metadata | Neither is trusted with plaintext | Separate relay-hop authentication; link server gets ciphertext only (4.2, 4.4) | -| B7 Update channel | `appcast.xml` and update archives | Not trusted until the EdDSA signature matches the key in the app | Sparkle 2, `SUPublicEDKey` (4.5) | -| B8 CI to published assets | Build, sign, notarize, attest | Runner is trusted only inside an approved `release` run | 4.5 | +| **SwiftUI to `CrocKit`** | Typed Swift calls | Nothing from the network; the layer above is the user | `CrocEngine` is the single caller of Go; `TransferController` the single caller of `CrocEngine` (`ARCHITECTURE.md`, "Layer diagram") | +| **`CrocKit` to Go (gomobile)** | Scalars and strings; file lists and progress as JSON; paths joined by `\n` | Go side trusts the joined path list | `CrocEngine.joinedPaths` refuses line breaks; Go `splitPaths` keeps names exact; malformed JSON becomes a failure (`DelegateBridge`) | +| **App to share extension, via App Group** | Staged files and `manifest.json` | The manifest is data, not trusted | `ShareInbox.refresh` filters names with `ReceivedName.isUnsafe`; the extension has only the App Group entitlement and no network | +| **Go engine to the network and filesystem** | Sockets, files, process-global `Chdir`, stdin | Everything on the wire | Common weaknesses countered | +| **Device to the other device** | Encrypted stream, direct or via relay | Only a device that proved knowledge of the code phrase; still treated as untrusted for names and sizes | PAKE, key confirmation, per-message AES-GCM; names and sizes validated on receipt (path traversal, injection, overflow and time-of-check rows of Common weaknesses countered) | +| **Device to relay or link server** | Ciphertext, plus metadata | Neither is trusted with plaintext | Separate relay-hop authentication; link server gets ciphertext only (Relay and local network, Send as Link) | +| **Update channel** | `appcast.xml` and update archives | Not trusted until the EdDSA signature matches the key in the app | Sparkle 2, `SUPublicEDKey` (Build, update and release) | +| **CI to published assets** | Build, sign, notarize, attest | Runner is trusted only inside an approved `release` run | Build, update and release | -### 4.1 Key agreement and data protection (croc) +### Key agreement Facts verified against croc v11.5.4 and pake v3.2.0 source: @@ -104,13 +104,13 @@ Facts verified against croc v11.5.4 and pake v3.2.0 source: CrocApp implements none of this. It passes the code phrase and options to croc and shows the result. -### 4.2 Relay and local network +### Relay and local network The relay hop is authenticated with its own PAKE using the relay password (an outbound credential), separate from the end-to-end exchange; the relay sees ciphertext only. The default relay password is croc's built-in value, which is public in croc's source and provides no secrecy; it gates the relay, not the data. With a custom relay, the user sets the password in Settings and it is held in the Keychain. -Local network only (`OnlyLocal`) keeps the connection on the local network. With Connection = Automatic, a send of 128 MiB or more can also use Tailcat (`tailcat.dev`, `*.ipn.dev`); the data is end-to-end encrypted on either path, and Relay Only never contacts them (`Options.Transport`). The trust badge names the configured route, not the path that carried the data (`docs/known-issues.md`, Accepted). +Local Network Only (`OnlyLocal`) keeps the connection on the local network. With Connection = Automatic, a large send can also use Tailcat (`tailcat.dev`, `*.ipn.dev`; threshold in `knowledge/what-is-croc.md`, "Transport"); the data is end-to-end encrypted on either path, and Relay Only never contacts them (`Options.Transport`). The trust badge names the configured route, not the path that carried the data (`known-issues.md`, Accepted). -### 4.3 App sandbox and entitlements +### App sandbox and entitlements | Target | Entitlements | Why | |---|---|---| @@ -119,24 +119,28 @@ Local network only (`OnlyLocal`) keeps the connection on the local network. With | Direct (DMG) build (`app/CrocAppDirect/CrocAppDirect.entitlements`) | The macOS set plus two `temporary-exception.mach-lookup.global-name` entries for Sparkle's installer services (`-spks`, `-spki`) | Sandboxed Sparkle updates | | Share extension (`app/CrocShare/CrocShare.entitlements`) | App Group only | Copies files into the group container; no network | -`ENABLE_APP_SANDBOX` and `ENABLE_HARDENED_RUNTIME` are set to `YES` on the app targets in `app/CrocApp.xcodeproj/project.pbxproj`. User-picked URLs are accessed through security-scoped bookmarks and released at the end of the transfer (`docs/ARCHITECTURE.md`, "Sandbox boundaries"). +`ENABLE_APP_SANDBOX` and `ENABLE_HARDENED_RUNTIME` are set to `YES` on the app targets in `app/CrocApp.xcodeproj/project.pbxproj`. User-picked URLs are accessed through security-scoped bookmarks and released at the end of the transfer (`ARCHITECTURE.md`, "Platform split"). -### 4.4 Send as link +### Send as Link -`crocmobile/store.go` uses croc's `storecrypto` (croc-store-v1): a 32-byte random key, HKDF subkeys, AES-GCM-encrypted chunks and manifest. The link server receives ciphertext and the transfer ID; the key stays in the link's `#` fragment. The link server origin must be `https`, or `http` on loopback only: enforced in Go (`validateStoreOrigin`, `store.go`), because App Transport Security does not cover Go sockets, and again at parse time (`TransferLink`, `AppSettings.storedServerError`). The copied link goes to the iOS pasteboard as `localOnly` so it stays off Universal Clipboard (`app/CrocApp/Support/Clipboard.swift`). A receipt in the Keychain lets the sender revoke the link; anyone holding the whole link can decrypt until it expires or is revoked. +- `crocmobile/store.go` uses croc's `storecrypto` (croc-store-v1): a 32-byte random key, HKDF subkeys, AES-GCM-encrypted chunks and manifest. +- The link server receives ciphertext and the transfer ID; the key stays in the link's `#` fragment. +- The link server origin must be `https`, or `http` on loopback only: enforced in Go (`validateStoreOrigin`, `store.go`), because App Transport Security does not cover Go sockets, and again at parse time (`TransferLink`, `AppSettings.storedServerError`). +- The copied link goes to the iOS pasteboard as `localOnly` so it stays off Universal Clipboard (`app/CrocApp/Support/Clipboard.swift`). +- A receipt in the Keychain lets the sender revoke the link; anyone holding the whole link can decrypt until it expires or is revoked. -### 4.5 Build, update and release +### Build, update and release - **Signing:** the DMG app is signed with a Developer ID certificate, hardened runtime and timestamp; the app and the DMG are notarized and stapled (`.github/workflows/release.yml`, `web/docs/content/verify-downloads.md`). App Store builds go through Apple's signing and review. - **Updates (Direct build only):** Sparkle 2 over HTTPS (`SPARKLE_FEED_URL` build setting in `app/CrocApp.xcodeproj/project.pbxproj`, read by `SUFeedURL`). The app carries the EdDSA public key (`SUPublicEDKey`) and installs an update only if the signature matches. `release.yml` fails the run if the appcast entry lacks `sparkle:edSignature` (around line 228). `SUEnableSystemProfiling` is `false` (`app/Config/CrocApp-Direct-Info.plist`). App Store builds contain no updater. - **Provenance:** a GitHub build provenance attestation covers the DMG, the engine archive and `appcast.xml`, made after stapling (`actions/attest-build-provenance` in `release.yml`). Verification commands are in `web/docs/content/verify-downloads.md`. - **Pipeline hygiene:** third-party actions are pinned to commit SHAs; checkouts set `persist-credentials: false`; the release job runs in the `release` environment, which needs maintainer approval and allows only `main` and `v*` tags; the signing material is removed from the runner at the end of the job. Dependabot covers GitHub Actions, Go modules and Swift packages (`.github/dependabot.yml`). -## 5. Privacy (R6) +## Privacy CrocApp has no account system and no analytics or advertising code. History is a local SwiftData store (`TransferRecord`, `app/CrocApp/Models/TransferHistory.swift`) holding up to 20 file names per record, counts, sizes, a code hint and status, never the full code phrase or file contents (`web/docs/content/guide/history.md`). The servers the app can contact are listed in the [privacy policy](https://crocapp.dev/privacy/). The link page at `crocapp.dev/r/` makes no network request and sets a `no-referrer` policy. -## 6. Design principles applied +## Design principles applied Saltzer and Schroeder, with the evidence for each. @@ -150,55 +154,55 @@ Saltzer and Schroeder, with the evidence for each. | Least privilege | App sandbox; only the file-access and network entitlements listed in 4.3; share extension has no network; secrets in the Keychain with `kSecAttrAccessibleAfterFirstUnlock`, not in `UserDefaults`; workflow `permissions` scoped per job. | | Least common mechanism | One transfer at a time (`activeMu`), because croc mutates process-global state (`Chdir`, stdin and stdout). Network globals are restored on error and panic (`TestNetworkGlobalsRestoredAfterPanic`). | | Psychological acceptability | A receive shows the file list and waits for the user. The trust badge names the route. Blocked names show a reason instead of failing silently. | -| Compromise recording | Build provenance attestations, notarization, signed updates; Send as link receipts allow revoke. | - -## 7. Common weaknesses countered - -| ID | Weakness (CWE) | Countermeasure | Where | Pinned by | -|---|---|---|---|---| -| W1 | Path traversal in received names (CWE-22) | Names with an absolute path, `..` component, backslash, NUL or control character block the transfer; croc applies its own receive-path policy as well (it refuses, for example, a name with a trailing space) | `app/CrocApp/Support/ReceivedName.swift`, `TransferController` | `ReceivedNameTests` | -| W2 | Argument and command injection through names (CWE-88, CWE-74) | A name with a line break cannot become extra send paths: refused in Swift before the join and split exactly in Go. `*` in a name is escaped so croc's glob sends only that file | `CrocEngine.joinedPaths`; `splitPaths`, `sendFilesInfo` in `crocmobile/` | `SendPathValidationTests`, `TestSplitPathsKeepsNamesExact`, `TestSendFilesInfoTakesPathsLiterally`, `FuzzSplitPaths` | -| W3 | Integer overflow and wraparound (CWE-190) | Declared sizes and sums are clamped to 1 PiB before they reach Swift; `relayPorts` checks the range without overflow (found by fuzzing, #32) | `addReported`, `maxReportedBytes` in `session.go`; `relayPorts` in `network.go` | `TestProgressSnapshotCapsDeclaredSizes`, `TestFileListJSONCapsDeclaredSizes`, `FuzzRelayPorts` | -| W4 | Time-of-check changes after consent (CWE-367 style) | After an accepted receive, a reconnecting sender cannot get a new file list accepted: the queued stdin answer is "n", so the receive ends. Existing files inside a zipped folder are kept even under Replace | `keptPromptAnswers`, `promptPolicyAnswers` in `session.go` | `TestAnswerUngatedPromptsNeverAccepts`, `TestPolicyAnswersFitPipeBuffer`, `TestDrainPromptInputClearsLeftoverAnswers` | -| W5 | Missing authentication (CWE-306) | The code phrase gates the exchange (PAKE); the relay hop has its own authentication; the receiver confirms the file list | croc (4.1, 4.2); `IncomingRequestView` | Interop harnesses (`scripts/verify-interop.sh`) | -| W6 | Unrestricted resource use (CWE-400) | One transfer at a time; stored sends capped by file count and size before anything is sent; reported sizes clamped; History capped at 20 names and 200 bookmarks; the share extension activates for at most 25 files, images or movies of each type. No cap on total received size is added by CrocApp | `store.go` limits; `TransferHistory.swift`; `app/Config/CrocShare-Info.plist` | `TestStoreSendLimitsFailBeforeUpload`, `TestStoreSendOversizeFailsBeforeHashing` | -| W7 | Cleartext storage and exposure of credentials (CWE-312, CWE-532) | Relay password, proxy credentials and Send as link receipts live in the Keychain; Keychain failures log the `OSStatus` only; proxy validation errors never quote the value; History keeps a code hint, never the full code. A legacy plaintext relay password is migrated into the Keychain | `KeychainStore.swift`, `AppSettings.swift`, `StoredTransferVault.swift`, `network.go` | `RelayPasswordMigrationTests`, `StoredTransferVaultTests`, `FuzzProxyURL` | -| W8 | Weak or broken cryptography (CWE-327) | No custom cryptography. AES-256-GCM, HKDF-SHA256, HMAC-SHA256 and PAKE2 come from croc and pake; `crypto/rand` for secrets. The integrity hash is not used for security | croc v11.5.4, pake v3.2.0 | Not tested by CrocApp; relies on upstream | -| W9 | Insecure transport for link server (CWE-319) | `https` required, `http` allowed on loopback only; checked in Go and in Swift | `validateStoreOrigin`; `TransferLink`; `AppSettings.storedServerError` | `StoredServerValidationTests`, `TransferLinkTests`, `TestStoreReceiveRejectsBadLinks` | -| W10 | Panic or crash from untrusted input (CWE-248, CWE-476) | Exported Go entry points recover and report an error; delegate callbacks survive a panicking delegate; malformed JSON from the bridge becomes a failure event | `crocmobile.go`, `store.go`, `DelegateBridge.swift` | `TestStoreCallbacksSurviveAPanickingDelegate`, `TestReportPanicPayloadIsOther`, `TestNetworkGlobalsRestoredAfterPanic`, `DelegateBridgeTests` | -| W11 | Update fetched without integrity check (CWE-494) | Sparkle EdDSA verification over an HTTPS feed; release job fails without `sparkle:edSignature`; notarization; attestations | 4.5 | `verify-downloads` steps; CI release rehearsal | -| W12 | Vulnerable and outdated components (CWE-1104) | `govulncheck`, `go vet`, `go test -race`, `golangci-lint` on every Go change (`.github/workflows/reusable-go.yml`); weekly 30 s run of each Go fuzz target (`go-weekly.yml`); CodeQL default setup; secret scanning with push protection; Dependabot | `.github/` | CI | -| W13 | Unsafe handling of untrusted staged files (CWE-73) | Share extension copies files with a unique batch directory per share and never reads payload into memory; the app re-validates manifest names | `ShareStager.swift`, `ShareInbox.swift` | Not unit-tested for the manifest filter. Unknown. | - -The 1.0.0 security fixes in `CHANGELOG.md` correspond to W1, W2, W3 and W4: glob `*` in names, line breaks in names, trailing-space names, reconnect swapping the file list after accept, and impossible declared sizes. Receive does not overwrite existing files by default. - -## 8. Residual risks and accepted issues +| Compromise recording | Build provenance attestations, notarization, signed updates; Send as Link receipts allow revoke. | + +## Common weaknesses countered + +| Weakness (CWE) | Countermeasure | Where | Pinned by | +|---|---|---|---| +| **Path traversal in received names** (CWE-22) | Names with an absolute path, `..` component, backslash, NUL or control character block the transfer; croc applies its own receive-path policy as well (it refuses, for example, a name with a trailing space) | `app/CrocApp/Support/ReceivedName.swift`, `TransferController` | `ReceivedNameTests` | +| **Argument and command injection through names** (CWE-88, CWE-74) | A name with a line break cannot become extra send paths: refused in Swift before the join and split exactly in Go. `*` in a name is escaped so croc's glob sends only that file | `CrocEngine.joinedPaths`; `splitPaths`, `sendFilesInfo` in `crocmobile/` | `SendPathValidationTests`, `TestSplitPathsKeepsNamesExact`, `TestSendFilesInfoTakesPathsLiterally`, `FuzzSplitPaths` | +| **Integer overflow and wraparound** (CWE-190) | Declared sizes and sums are clamped to 1 PiB before they reach Swift; `relayPorts` checks the range without overflow (found by fuzzing) | `addReported`, `maxReportedBytes` in `session.go`; `relayPorts` in `network.go` | `TestProgressSnapshotCapsDeclaredSizes`, `TestFileListJSONCapsDeclaredSizes`, `FuzzRelayPorts` | +| **Time-of-check changes after consent** (CWE-367 style) | After an accepted receive, a reconnecting sender cannot get a new file list accepted: the queued stdin answer is "n", so the receive ends. Existing files inside a zipped folder are kept even under Replace | `keptPromptAnswers`, `promptPolicyAnswers` in `session.go` | `TestAnswerUngatedPromptsNeverAccepts`, `TestPolicyAnswersFitPipeBuffer`, `TestDrainPromptInputClearsLeftoverAnswers` | +| **Missing authentication** (CWE-306) | The code phrase gates the exchange (PAKE); the relay hop has its own authentication; the receiver confirms the file list | croc (Key agreement and data protection, Relay and local network); `IncomingRequestView` | Interop harnesses (`scripts/verify-interop.sh`) | +| **Unrestricted resource use** (CWE-400) | One transfer at a time; stored sends capped by file count and size before anything is sent; reported sizes clamped; History capped per record and in total (`knowledge/app-ui-architecture.md`, "History"); the share extension activates for at most 25 files, images or movies of each type. No cap on total received size is added by CrocApp | `store.go` limits; `TransferHistory.swift`; `app/Config/CrocShare-Info.plist` | `TestStoreSendLimitsFailBeforeUpload`, `TestStoreSendOversizeFailsBeforeHashing` | +| **Cleartext storage and exposure of credentials** (CWE-312, CWE-532) | Relay password, proxy credentials and Send as Link receipts live in the Keychain; Keychain failures log the `OSStatus` only; proxy validation errors never quote the value; History keeps a code hint, never the full code. A legacy plaintext relay password is migrated into the Keychain | `KeychainStore.swift`, `AppSettings.swift`, `StoredTransferVault.swift`, `network.go` | `RelayPasswordMigrationTests`, `StoredTransferVaultTests`, `FuzzProxyURL` | +| **Weak or broken cryptography** (CWE-327) | No custom cryptography. AES-256-GCM, HKDF-SHA256, HMAC-SHA256 and PAKE2 come from croc and pake; `crypto/rand` for secrets. The integrity hash is not used for security | croc v11.5.4, pake v3.2.0 | Not tested by CrocApp; relies on upstream | +| **Insecure transport for link server** (CWE-319) | `https` required, `http` allowed on loopback only; checked in Go and in Swift | `validateStoreOrigin`; `TransferLink`; `AppSettings.storedServerError` | `StoredServerValidationTests`, `TransferLinkTests`, `TestStoreReceiveRejectsBadLinks` | +| **Panic or crash from untrusted input** (CWE-248, CWE-476) | Exported Go entry points recover and report an error; delegate callbacks survive a panicking delegate; malformed JSON from the bridge becomes a failure event | `crocmobile.go`, `store.go`, `DelegateBridge.swift` | `TestStoreCallbacksSurviveAPanickingDelegate`, `TestReportPanicPayloadIsOther`, `TestNetworkGlobalsRestoredAfterPanic`, `DelegateBridgeTests` | +| **Update fetched without integrity check** (CWE-494) | Sparkle EdDSA verification over an HTTPS feed; release job fails without `sparkle:edSignature`; notarization; attestations | Build, update and release | `verify-downloads` steps; CI release rehearsal | +| **Vulnerable and outdated components** (CWE-1104) | `govulncheck`, `go vet`, `go test -race`, `golangci-lint` on every Go change (`.github/workflows/reusable-go.yml`); weekly 30 s run of each Go fuzz target (`go-weekly.yml`); CodeQL default setup; secret scanning with push protection; Dependabot | `.github/` | CI | +| **Unsafe handling of untrusted staged files** (CWE-73) | Share extension copies files with a unique batch directory per share and never reads payload into memory; the app re-validates manifest names | `ShareStager.swift`, `ShareInbox.swift` | Not unit-tested for the manifest filter. Unknown. | + +The path traversal, injection, overflow and time-of-check rows cover glob `*` in names, line breaks in names, trailing-space names, a reconnect swapping the file list after accept, and impossible declared sizes. Receive does not overwrite existing files by default. + +## Residual risks and accepted issues | Risk | Status | |---|---| | No third-party audit of croc or CrocApp | Open. Stated on every surface that discusses security at length. | -| Protocol-level weaknesses in croc, PAKE or the relay design | Upstream scope (`SECURITY.md`). CrocApp tracks croc releases (`docs/knowledge/croc-upgrade-playbook.md`). | -| croc prints the code phrase, and a non-default relay password, to stderr on every send | Accepted. Nothing in the app captures stderr; a Finder, Dock or Home Screen launch connects it to `/dev/null` (strong inference, not measured on a device). A harness or Xcode debug session does capture it. `docs/known-issues.md`. | +| Protocol-level weaknesses in croc, PAKE or the relay design | Upstream scope (`SECURITY.md`). CrocApp tracks croc releases (`knowledge/croc-upgrade-playbook.md`). | +| croc prints the code phrase, and a non-default relay password, to stderr on every send | Accepted. Nothing in the app captures stderr; a Finder, Dock or Home Screen launch connects it to `/dev/null` (strong inference, not measured on a device). A harness or Xcode debug session does capture it. `known-issues.md`. | | `release.yml` passes the signing `.p12` and keychain passwords on `security` argv | Accepted for a single-tenant runner. Do not run on a shared runner. | -| `create-dmg` is installed unpinned during the release build | Accepted for 1.0. `docs/known-issues.md`. | -| One maintainer approves releases, so there is no second reviewer on the `release` environment | Accepted. Disclosed in `verify-downloads.md`. | +| `create-dmg` is installed unpinned during the release build | Accepted. `known-issues.md`. | +| One maintainer approves releases, so there is no second reviewer on the `release` environment | Accepted. Disclosed in `web/docs/content/verify-downloads.md`. | | Compromise of the Sparkle EdDSA private key or the `release` secrets | Would allow signed malicious updates or releases. Mitigation is limited to approval gating and attestation, which a compromised pipeline could also satisfy. | | `manifest.batch` in the share extension's manifest is joined to the App Group path without a name check (`ShareInbox.refresh`) | Only a process with the same App Group can write it, so the writer is already inside the same-developer trust domain. Not countered by code; noted, not exploited. | | A sender can offer a very large total size; CrocApp adds no disk quota | The accept sheet shows the file list; the user decides. | -| `poll()` reads croc client fields without synchronization | Worst case is a wrong progress number; bounds-checked. `docs/known-issues.md`. | -| `golang.org/x/crypto` GO-2026-5932 (`openpgp`) with no fixed version | Not imported or called (`govulncheck`). `docs/known-issues.md`. | -| Metadata (IP, timing, volume) visible to relay and network | Accepted (section 1). | +| `poll()` reads croc client fields without synchronization | Worst case is a wrong progress number; bounds-checked. `known-issues.md`. | +| `golang.org/x/crypto` GO-2026-5932 (`openpgp`) with no fixed version | Not imported or called (`govulncheck`). `known-issues.md`. | +| Metadata (IP, timing, volume) visible to relay and network | Accepted (Non-goals). | | The trust badge names the configured route, not the path used | Accepted; the claim that the relay sees ciphertext holds on every path. | -| Entitlement exceptions: `network.server`, Sparkle mach-lookup (Direct build) | Each has a stated reason (4.3). | +| Entitlement exceptions: `network.server`, Sparkle mach-lookup (Direct build) | Each has a stated reason (App sandbox and entitlements). | -## 9. Keeping this current +## Keeping this current -Update this file in the same pull request when any of these change: +Follow `AGENTS.md`. Update this file in the same pull request when any of these change: -- the croc or pake version, curve defaults, or the Send as link format; +- the croc or pake version, curve defaults, or the Send as Link format; - an entitlement, the App Group, the share extension's inputs, or the sandbox settings; - where secrets are stored; - the release workflow: signing, notarization, attestation, Sparkle keys or feed; -- a new weakness class gets a fix: add a row to section 7 with its test. +- a new weakness class gets a fix: add a row to Common weaknesses countered with its test. -Related: [`SECURITY.md`](../SECURITY.md), [`docs/ARCHITECTURE.md`](ARCHITECTURE.md), [`docs/known-issues.md`](known-issues.md), `web/docs/content/security-and-privacy.md`, `web/docs/content/verify-downloads.md`. +Docs rules are in [`AGENTS.md`](AGENTS.md). Related: [`SECURITY.md`](../SECURITY.md), [`ARCHITECTURE.md`](ARCHITECTURE.md), [`known-issues.md`](known-issues.md), `web/docs/content/security-and-privacy.md`, `web/docs/content/verify-downloads.md`. diff --git a/docs/knowledge/README.md b/docs/knowledge/README.md deleted file mode 100644 index da70e97..0000000 --- a/docs/knowledge/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# docs/knowledge - -Evergreen project knowledge: what this project is, what croc is and how it works, and the invariants that outlive any individual feature. Written dense, for AI-session consumption first and humans second. - -What does not belong here: decisions and their rationale (commit messages and PR descriptions carry those), open defects and deferred work (`../known-issues.md`), and anything that is a snapshot of work in progress. Stale facts get fixed or deleted in the session that notices them (root `AGENTS.md`, "Session end report"). - -## Files - -| File | Read it when | -|---|---| -| `project-overview.md` | you need the goals, constraints, and non-goals in one screen | -| `what-is-croc.md` | you need croc's transfer protocol, crypto, CLI surface, or security history | -| `features.md` | you need the F-number for a feature, or whether it has shipped | -| `crocmobile-bridge.md` | **before touching** `crocmobile/session.go`, `CrocKit/Sources/`, or the event contract | -| `app-ui-architecture.md` | **before touching** any view that observes `TransferController` | -| `apple-platform-constraints.md` | you are up against iOS backgrounding, sandboxing, entitlements, or App Store review | -| `tooling.md` | CI is failing, a linter is complaining, or you want to know why a tool was skipped | -| `croc-upgrade-playbook.md` | upstream croc released a new version | -| `device-test-checklist.md` | you are about to test on real iOS hardware | -| `prior-art.md` | you are making a UX, positioning, or distribution call | -| `docs-site.md` | **before touching** `web/docs/` | -| `screenshots.md` | you need to re-capture, add, or move an app screenshot | -| `app-store-listing.md` | you prepare a store submission: listing copy, review notes, export compliance | - -Anything visual — app views, landing page, docs site — starts at `design/AGENTS.md` instead. diff --git a/docs/knowledge/app-store-listing.md b/docs/knowledge/app-store-listing.md index 59acfd2..5f52568 100644 --- a/docs/knowledge/app-store-listing.md +++ b/docs/knowledge/app-store-listing.md @@ -1,10 +1,14 @@ # App Store listing -The copy for CrocApp's App Store Connect record, written to be pasted field by field and reused every release (spec R40). One app record holds iOS/iPadOS and macOS (universal purchase, bundle ID `com.bakirgdev.CrocApp`). Written 2026-10-15 for 1.0.0. +The copy for CrocApp's App Store Connect record, written to be pasted field by field and reused for every App Store release. One app record holds iOS, iPadOS and macOS (universal purchase, bundle ID `com.bakirgdev.CrocApp`). The record has not been submitted yet: the first App Store release is the first submission, and it goes out as 1.1.0 (`../RELEASING.md`, "App Store builds"). -Every fenced block under a field heading is the exact text to paste. Character counts were measured with a script on 2026-10-15 (Python `len()` for characters, `len(s.encode())` for the two byte-limited fields). When you change a block, recount it. +Every fenced block under a field heading is the exact text to paste. Character counts were measured with a script (Python `len()` for characters, `len(s.encode())` for the two byte-limited fields). When you change a block, recount it. -How to read the claims: **documented** means an Apple, ANSSI or eCFR page said it and the URL is in "Sources". **Verified locally** means read in this repo or its Go module cache. **Inference** and **unknown** are labelled where they appear. +How to read the claims: + +- **Documented**: an Apple, ANSSI or eCFR page says it. The URL is in "Sources". +- **Verified locally**: read in this repo or in its Go module cache. +- **Inference** and **unknown**: labelled where they appear. ## Field limits @@ -15,14 +19,14 @@ Limits come from Apple's App Store Connect Help (URLs in "Sources"). Two are byt | Name | 30 characters (min 2) | 7 | App information page | | Subtitle | 30 characters | 29 | App information page | | Promotional text | 170 characters | 152 | Platform version information page | -| Description | 4000 characters | 2932 | Platform version information page | +| Description | 4000 characters | see "Description" | Platform version information page | | Keywords | 100 **bytes** (each keyword more than two characters) | 98 | Platform version information page | -| What's New | 4000 characters | 1436 | Platform version information page | -| App Review notes | 4000 **bytes** | 3318 | Platform version information page | +| What's New | 4000 characters | see "What's New" | Platform version information page | +| App Review notes | 4000 **bytes** | see "Review notes" | Platform version information page | | Support URL, Marketing URL, Privacy Policy URL | no limit stated; full URL with protocol | n/a | Platform version information page, App information page | | Copyright | no limit stated; year first, then the holder; Apple adds the copyright symbol | n/a | Platform version information page | -Apple's pages state no character limit for Support URL, Marketing URL, Privacy Policy URL or Copyright. Unknown whether a hidden limit exists; these values are short. +Apple's pages state no limit for the URL and copyright fields. Unknown whether a hidden limit exists; these values are short. ## Name @@ -30,7 +34,7 @@ Apple's pages state no character limit for Support URL, Marketing URL, Privacy P CrocApp ``` -One word, capital C and A (`design/brand.md`, Naming). Not "Croc App", never "CrocAPP". +One word, capital C and A (`design/brand.md`, "Naming"). Not "Croc App", never "CrocAPP". ## Subtitle @@ -48,6 +52,8 @@ Free, open source, no account. Send files, folders or text to another device wit ## Description +Length: 2921 characters. The security statements follow the approved list in `design/content.md`, "Claims". + ```text CrocApp sends files, folders and text from one device to another with a short code phrase such as cape-junky-widow. No account, no sign-up, and the two devices do not need to be on the same network. @@ -74,7 +80,7 @@ WHAT YOU CAN DO - Use your own relay, force local-network-only transfers, or route through a proxy, from Settings. - iPhone and iPad: send from any app with the share sheet, follow a transfer in a Live Activity, find received files in the Files app, and use Shortcuts and Siri actions. - Mac: drag and drop, a menu bar window for quick sends, and Shortcuts actions. -- Light and dark appearance, Dynamic Type and VoiceOver. +- Light and dark appearance and Dynamic Type. WHAT TO KNOW By default CrocApp uses the public relays run by the croc project to connect two devices, and the croc project's link server at getcroc.com for links. Your files are encrypted before they leave your device. You can point CrocApp at relays and a link server you run yourself. Nearby transfers over the local network depend on what your network and device allow. @@ -84,23 +90,30 @@ CrocApp is an independent project. It is unofficial and not affiliated with or e Free to use, with no account. Questions and bug reports: https://crocapp.dev/support/ ``` +The description does not claim VoiceOver support. Add it only after the accessibility audit in "Open checks before submitting" has passed. + ## Keywords -Comma separated, no spaces. Words already in the name or subtitle (`crocapp`, `send`, `files`, `code`, `phrase`) and the category (`utilities`) are left out because Apple already indexes them. `croc` is a deliberate keep: the name is one token, and the protocol name is what people search. `airdrop` is left out on purpose (another company's trademark). `secure`, `p2p` and `peer` are search terms people type; keywords are never shown, so `design/content.md` → Terminology, which binds displayed copy, does not apply to them. +- Comma separated, no spaces. +- Words already in the name or subtitle (`crocapp`, `send`, `files`, `code`, `phrase`) and the category (`utilities`) are left out because Apple already indexes them. +- `croc` is a deliberate keep: the name is one token, and the protocol name is what people search. +- `airdrop` is left out on purpose (another company's trademark). +- `secure`, `p2p` and `peer` are search terms people type; keywords are never shown, so `design/content.md`, "Terminology", which binds displayed copy, does not apply to them. ```text croc,transfer,share,encrypted,secure,private,qr,p2p,wormhole,folder,lan,nearby,e2ee,drop,peer,text ``` -## What's New in 1.0.0 +## What's New + +Not verified: whether App Store Connect shows or requires this field for an app's first version. Apple's version-information page, the only Apple source recorded here, does not say. Paste the text below if the field appears, and leave it empty if it does not. Later releases replace it with that release's changes, taken from the `[x.y.z]` section of `CHANGELOG.md`. -Whether App Store Connect shows this field for a first version is unknown (Apple's page does not say); the text is here so the field is ready from the second release on. It follows `CHANGELOG.md` `[1.0.0]`. +Length: 1217 characters. ```text -CrocApp 1.0 is the first App Store release. +CrocApp is on the App Store for iPhone, iPad and Mac. -New -- iPhone, iPad and Mac from one App Store listing. +Highlights - Send as Link: share encrypted files by link or QR code with someone who is not online. You choose the lifetime and the number of downloads, and can revoke the link from History. - Links: opening a CrocApp link, a crocapp.dev/r/ link or a getcroc.com link fills in the code on the Receive screen. Nothing connects until you tap Receive. - A Keep Both or Replace choice when a received file has the same name as one you already have. @@ -108,16 +121,16 @@ New - Mac menu bar quick send, and Shortcuts and Siri actions for sending and receiving. - iPad File menu: Show Receive Folder in Files (Shift Command R). -Changed -- The transfer engine is now croc 11.5.4. A device running croc 10 can no longer transfer with CrocApp; it needs croc 11 or newer. +Good to know +- The transfer engine is croc 11.5.4. A device running croc 10 cannot transfer with CrocApp; it needs croc 11 or newer. - Errors say what went wrong: a wrong code, a refused transfer, a lost connection, a device on an older croc, or no public relay available. - History keeps less of a code than before, and never the whole code. -- Every screen works at the largest text sizes and with VoiceOver. -- The QR code on the sender's screen now opens CrocApp, or a page that explains what to do when the app is missing. CrocApp is free and open source. It is unofficial and not affiliated with croc or its author. ``` +Update the highlights to match the release you submit. If the first App Store release has other changes, list those. + ## URLs, category, price | Field | Value | @@ -126,14 +139,14 @@ CrocApp is free and open source. It is unofficial and not affiliated with croc o | Marketing URL | `https://crocapp.dev` | | Privacy Policy URL | `https://crocapp.dev/privacy/` | | Primary category | Utilities (matches `LSApplicationCategoryType` `public.app-category.utilities` in the build settings, verified locally) | -| Secondary category | Productivity (optional; a judgement call, drop it if unsure). Unknown whether the iOS and macOS platforms of one record share categories; check the App Information page shows both. | +| Secondary category | Productivity (optional; a judgement call, drop it if unsure). Unknown whether the iOS and macOS platforms of one record share categories; check that the App Information page shows both. | | Copyright | `2026 Bakir Gracić` (the holder in `LICENSE` is "Bakir Gracić", with the accent; Apple adds the symbol) | | Price | Free (USD 0), no In-App Purchases | -| Version | 1.0.0 (`app/Config/Version.xcconfig`, `MARKETING_VERSION`); attach the iOS and macOS builds that processed in TestFlight | +| Version | `MARKETING_VERSION` in `app/Config/Version.xcconfig`; attach the iOS and macOS builds that finished processing in TestFlight | | Privacy choices URL | leave empty (optional per Apple) | | Sign-in required (App Review) | off; the app has no account | -`https://crocapp.dev/support/` and `https://crocapp.dev/privacy/` did not exist in `web/landing/` when this file was written (spec R31 and R38 own them). Both must answer HTTP 200 before you press Submit. +The support and privacy pages exist in `web/landing/` (`support/`, `privacy/`). The privacy page must name every server the app talks to and what each one sees, because the App Privacy label says nothing about them. Both URLs must answer HTTP 200 on the live site before you submit. ## Availability @@ -142,11 +155,11 @@ Recommended: **All countries or regions except France.** In App Store Connect: y Basis (details and uncertainty in "Export compliance" below): - Apple documents that a French encryption declaration is required for an app that uses an industry-standard algorithm not provided by the Apple operating system, "only if you're distributing your app on the App Store in France." -- CrocApp's engine implements its own AES-256-GCM, P-256 and related primitives (verified locally), so it is in that row of Apple's table. -- The owner's Q4 decision already says: leave France out of 1.0 if App Store Connect requires the French declaration. The research below finds that it does, by Apple's own table. So France is out of 1.0. -- A `future` issue should track filing the ANSSI declaration (spec concern 9 asks for it). It is not filed from this file. +- croc, linked into the app, implements the cryptography (AES-256-GCM, P-256 and related primitives, verified locally); CrocApp adds none. The app still falls in that row of Apple's table, because the algorithms are not provided by the operating system. +- The owner decided to leave France out if App Store Connect requires the French declaration. Apple's table says it does, so France is out of the first release. +- Adding France later needs an ANSSI declaration. Track it as a `future` item; it is not filed from this file. -Unknown: whether availability set here applies to the iOS and macOS platforms together. Apple's availability page does not say. After saving, check both platform pages show France as unavailable. +Unknown: whether availability set here applies to the iOS and macOS platforms together. Apple's availability page does not say. After saving, check that both platform pages show France as unavailable. ## App Privacy @@ -157,16 +170,19 @@ Unknown: whether availability set here applies to the iOS and macOS platforms to Does `PrivacyInfo.xcprivacy` agree? Verified locally (`app/CrocApp/PrivacyInfo.xcprivacy`): `NSPrivacyTracking` is false, `NSPrivacyTrackingDomains` is empty, `NSPrivacyCollectedDataTypes` is empty. The three required-reason entries (UserDefaults `CA92.1`, FileTimestamp `C617.1` and `3B52.1`, SystemBootTime `35F9.1`) are API declarations, not collection, and do not conflict with the answer. -Why "not collected" holds (spec Q3, concern 3): +Why "not collected" holds: - Documented definition: "Collect" means "transmitting data off the device in a way that allows you and/or your third-party partners to access it for a period longer than what is necessary to service the transmitted request in real time." "Third-party partners" are "analytics tools, advertising networks, third-party SDKs, or other external vendors whose code you've added to your app." - Inference: the developer receives nothing. The relays and getcroc.com are servers the user's transfer talks to, not code in the app, and they hold ciphertext only. This is a reading of the definition, not an Apple statement. Apple's page says nothing about end-to-end encryption or ciphertext. -- Residual risk: with Send as link, getcroc.com keeps ciphertext for the lifetime the user chooses (up to 7 days). Apple could read that as collection. The conservative fallback if App Review disagrees is to declare "Other User Content", not linked to the user, not used for tracking, purpose "App Functionality". Signal's label omits queued encrypted messages (spec concern 3), which is a precedent, not a rule. -- The privacy policy must name every server and what each sees (spec R38), because the label says nothing about them. +- Residual risk: with Send as Link, getcroc.com keeps ciphertext for the lifetime the user chooses (up to 7 days). Apple could read that as collection. The fallback if App Review disagrees is to declare "Other User Content", not linked to the user, not used for tracking, purpose "App Functionality". Signal's label omits queued encrypted messages, which is a precedent, not a rule. ## Age rating -Apple's questionnaire is answered in App Information, Age Ratings, Set Up Age Ratings (documented steps). It has in-app controls and capabilities (select those that apply), a frequency (None, Infrequent, Frequent) for each content descriptor, Yes/No for Gambling and Loot Boxes, and a final step for age category and override. Category names below are from Apple's current "Age ratings values and definitions" page, which lists 4+, 9+, 13+, 16+, 18+. The exact on-screen question wording was not fetchable; the mapping below is by category name, so read each screen as you go. +- Apple's questionnaire is answered in App Information, Age Ratings, Set Up Age Ratings (documented steps). +- It has in-app controls and capabilities (select those that apply), a frequency (None, Infrequent, Frequent) for each content descriptor, Yes/No for Gambling and Loot Boxes, and a final step for age category and override. +- Category names below are from Apple's "Age ratings values and definitions" page, which lists 4+, 9+, 13+, 16+, 18+. +- The exact on-screen question wording was not available, so the mapping below is by category name. +- Read each screen as you go. | Section | Item | Answer for CrocApp | | :--- | :--- | :--- | @@ -189,16 +205,16 @@ Apple's questionnaire is answered in App Information, Age Ratings, Set Up Age Ra Expected result: **4+**. In Apple's table, 4+ is the tier whose allowed content includes messaging and chat, user-generated content, advertising and parental controls, so the Messaging and Chat decision below does not change the rating. -Note on Messaging and Chat (judgement): Apple defines it as users who "directly communicate with one another through features within the app" (text, voice, video, messaging, posting). CrocApp can send a text snippet, but it is a one-shot transfer with no conversation, contacts or accounts. "Not selected" is the honest reading; ticking it would also leave the rating at 4+. The residual risk is a reviewer who disagrees; the review notes say what the text feature is. +Note on Messaging and Chat (judgement): Apple defines it as users who "directly communicate with one another through features within the app" (text, voice, video, messaging, posting). CrocApp can send a text snippet, but it is a one-shot transfer with no conversation, contacts or accounts. "Not selected" is the honest reading, and ticking it would also leave the rating at 4+. The residual risk is a reviewer who disagrees; the review notes say what the text feature is. -Sending arbitrary files to another person is not content the app hosts or shows. The recipient sees a file list and accepts or declines each transfer (`F9`). +Sending arbitrary files to another person is not content the app hosts or shows. The recipient sees a file list and accepts or declines each transfer. ## Review notes -Paste into App Review Information, Notes (4000 bytes; this block is 3306). Contact name, phone (international format with a plus sign) and email are yours to fill in; they are not in this repo. +Paste into App Review Information, Notes. Length: 3304 bytes of the 4000 allowed. Contact name, phone (international format with a plus sign) and email are yours to fill in; they are not in this repo. ```text -CrocApp is a free, open-source file-transfer app: it sends files, folders and text to another device with a short code phrase, end-to-end encrypted. It is a native front end for the open-source croc tool (https://github.com/schollz/croc, MIT, by Zack Scholl). CrocApp is unofficial and unaffiliated with croc or its author; the croc maintainer was informed by email on 2026-10-14. Source: https://github.com/bakirgdev/CrocApp +CrocApp is a free, open-source file-transfer app: it sends files, folders and text to another device with a short code phrase, end-to-end encrypted. It is a native front end for the open-source croc tool (https://github.com/schollz/croc, MIT, by Zack Scholl). CrocApp is unofficial and unaffiliated with croc or its author; the croc maintainer was informed by email. Source: https://github.com/bakirgdev/CrocApp ACCOUNT: none. There is no login, sign-up, purchase or subscription, so there is no demo account. Sign-in required is off. @@ -219,7 +235,7 @@ TEST WITHOUT A SECOND DEVICE: use croc's own browser client at https://getcroc.c b. In CrocApp tap Receive, type the code (or paste the getcroc.com link; the app keeps only the code), and tap Receive. c. The file list appears. Tap Accept. The file is saved to the receive folder (on iPhone and iPad, On My iPhone, CrocApp, in the Files app). 3. Link flow: tap the link https://crocapp.dev/r/#cape-junky-widow in Notes or Messages with CrocApp installed (iOS does not open the app for a URL typed into Safari), or open that page in a browser and press Open in CrocApp. CrocApp opens on Receive with the code filled in and does not connect until you tap Receive. -4. Optional, Send as link: in Send choose files, turn on "Send as Link", then tap Send as Link. A link and QR code appear. Open the link in a browser to get the file. In History, Revoke Link makes it stop working. +4. Optional, Send as Link: in Send choose files, turn on "Send as Link", then tap Send as Link. A link and QR code appear. Open the link in a browser to get the file. In History, Revoke Link makes it stop working. The browser UI at getcroc.com is the croc project's and may change wording; the flow stays: one device shows a code, the other enters it. @@ -228,11 +244,11 @@ Please keep CrocApp in the foreground during a transfer on iPhone and iPad. A tr Questions: https://crocapp.dev/support/ ``` -Before pasting, check three things by hand (see "Open checks" at the end): getcroc.com's current labels, the browser-to-app direction, and the contact lines. +Check the notes by hand before pasting, as listed in "Open checks before submitting". ## Export compliance -Status: written 2026-10-15 after re-reading Apple's, ANSSI's and eCFR's current pages. The App Store Connect encryption screens themselves could not be fetched, so the exact on-screen wording is third-party reported and flagged as such. +Status: written after re-reading Apple's, ANSSI's and eCFR's pages as listed in "Sources". The App Store Connect encryption screens themselves could not be read, so the on-screen wording below comes from third parties and is flagged as such. ### Recommended answers (what to click) @@ -242,10 +258,10 @@ Keep `ITSAppUsesNonExemptEncryption` = `false` in both App Store Info.plists, an | :--- | :--- | | 1 | Pricing and Availability: remove France (see "Availability"). | | 2 | Open each uploaded build (iOS, macOS). With the key set to `false`, Apple documents that the encryption questions are bypassed. Expect no "Missing Compliance" status. If a build does show it, go to step 3. | -| 3 | Fallback only if App Store Connect still asks. Answers, using the question flow third parties reported for 2019 to 2026 (wording may differ today, so read each question): "Does your app use encryption?" Yes. "Does your app qualify for any of the exemptions provided in Category 5, Part 2 of the U.S. Export Administration Regulations?" No (the listed exemptions are medical end-use, IP and copyright protection, authentication/signature/decryption only, banking, fixed data compression; file transfer is none of these). "Is your app going to be available on the App Store in France?" No. Type of algorithms: "standard encryption algorithms instead of, or in addition to, using or accessing the encryption within Apple's operating system", not "proprietary". | +| 3 | Fallback only if App Store Connect still asks. Answers, using the question flow third parties report (wording may differ today, so read each question): "Does your app use encryption?" Yes. "Does your app qualify for any of the exemptions provided in Category 5, Part 2 of the U.S. Export Administration Regulations?" No (the listed exemptions are medical end-use, IP and copyright protection, authentication/signature/decryption only, banking, fixed data compression; file transfer is none of these). "Is your app going to be available on the App Store in France?" No. Type of algorithms: "standard encryption algorithms instead of, or in addition to, using or accessing the encryption within Apple's operating system", not "proprietary". | | 4 | If, after step 3, App Store Connect demands documentation upload for a "standard algorithm" answer with France excluded, stop and do not guess: that contradicts Apple's own table (documented) and means the flow changed. Ask Apple Developer Support. | -**Must anything be filed before submitting?** For a submission that excludes France: no declaration is needed by Apple's own rule (documented: the French form "is only required if you're distributing your app on the App Store in France"). For the US side the owner's position is the open-source exemption (below); no BIS email is required on the standard-algorithm reading, but see the SIEC risk. **With France included:** a French declaration would be needed first (see "France"). +**Must anything be filed before submitting?** For a submission that excludes France: no declaration is needed by Apple's own rule (documented: the French form "is only required if you're distributing your app on the App Store in France"). For the US side the owner's position is the open-source exemption (below). No BIS email is required on the standard-algorithm reading, but see the SIEC risk. **With France included:** a French declaration would be needed first (see "Documented facts (France)"). ### Documented facts (Apple) @@ -259,20 +275,26 @@ Keep `ITSAppUsesNonExemptEncryption` = `false` in both App Store Info.plists, an - 15 CFR 742.15(b): publicly available encryption source code (ECCN 5D002) "is not subject to the EAR". If that code provides or performs "non-standard cryptography", you must notify BIS and the NSA ENC coordinator by email (`crypt@bis.doc.gov`, `enc@nsa.gov`) with the code's internet location, and again when the location changes or cryptographic functionality is updated. - 15 CFR 734.3 note: publicly available encryption object code is not subject to the EAR when its source meets the 742.15(b) criteria. -- The eCFR pages I read carry amendment citations up to 89 FR 23885 (Apr. 4, 2024) (742.15) and 61 FR 12746 (734.3). Whether later amendments changed these paragraphs is unknown. +- The eCFR pages read carry amendment citations up to 89 FR 23885 (for 742.15) and 61 FR 12746 (for 734.3). Whether later amendments changed these paragraphs is unknown. ### Documented facts (France) - ANSSI (cyber.gouv.fr): "L'utilisation d'un moyen de cryptologie est libre. En revanche, la fourniture, l'importation, le transfert intracommunautaire et l'exportation d'un moyen de cryptologie sont soumis, sauf exception, à déclaration ou à demande d'autorisation." The duty falls on "le fournisseur ou le primo-importateur". -- The ANSSI pages say procedures now run on the demarches.numeriques platform; a form "Déclaration relative à un moyen de cryptologie" is linked there. An older ANSSI page lists the file contents (company presentation, Kbis under 3 months or foreign equivalent, brochure, technical description, guides) and the address `controle@ssi.gouv.fr`. -- Décret 2007-663 (original 2 May 2007 text, read in full): Art. 3 puts the supply of cryptology means that are not exclusively authentication or integrity under prior declaration unless Annex 1 dispenses them. Art. 4: file "un mois au moins avant l'opération"; ANSSI issues a receipt with a registration number. Art. 5: after one month of silence the declarant may proceed; ANSSI may issue an attestation sooner. Art. 6: a supplier's declaration also covers the intermediaries that distribute the means (this is why Apple, as distributor, asks for it). Art. 7: within a year the Prime Minister may ask for the technical characteristics and the source code, and two copies for up to six months. Annex 1 (dispensed) lists smart cards, broadcast receivers, banking equipment, mobile phones' network encryption, copy-protection, and similar; general file-transfer or secure-communication software is not in it. +- The ANSSI pages say procedures run on the demarches.numeriques platform, where a form "Déclaration relative à un moyen de cryptologie" is linked. An older ANSSI page lists the file contents (company presentation, Kbis under 3 months or foreign equivalent, brochure, technical description, guides) and the address `controle@ssi.gouv.fr`. +- Décret 2007-663 (original 2 May 2007 text, read in full): + - Art. 3 puts the supply of cryptology means that are not exclusively authentication or integrity under prior declaration, unless Annex 1 dispenses them. + - Art. 4: file "un mois au moins avant l'opération"; ANSSI issues a receipt with a registration number. + - Art. 5: after one month of silence the declarant may proceed; ANSSI may issue an attestation sooner. + - Art. 6: a supplier's declaration also covers the intermediaries that distribute the means (this is why Apple, as distributor, asks for it). + - Art. 7: within a year the Prime Minister may ask for the technical characteristics and the source code, and two copies for up to six months. + - Annex 1 (dispensed) lists smart cards, broadcast receivers, banking equipment, mobile phones' network encryption, copy-protection, and similar. General file-transfer or secure-communication software is not in it. - This is the 2007 text as published. A later consolidated version was not readable (Légifrance returned 403). Annex 1 may have been amended; unknown. ### Verified locally (project facts) -- Both app Info.plists (`app/Config/CrocApp-Info.plist`, `app/Config/CrocApp-macOS-Info.plist`) set `ITSAppUsesNonExemptEncryption` to `false`, with a comment giving the open-source exemption as the reason. The Direct build plist and the share extension plist do not set it. The first upload with `true` and no code failed with `Invalid Export Compliance Code` (`docs/BUILDING.md` section 7). -- Engine: croc v11.5.4 with `pake/v3` v3.2.0. Traffic encryption is AES-256-GCM (`crypt.go`, `crypto/aes`), with ChaCha20-Poly1305, PBKDF2 and Argon2 from `golang.org/x/crypto` in the same package; the session key comes from PAKE2 with a selectable curve, then HKDF-SHA256. Default curve p256 (`cli.go`). Available curves in `pake.AvailableCurves()`: p521, p256, p384, **siec**, ed25519. Tailcat (large sends) adds Tailscale's WireGuard (`tailscale/wireguard-go`) over DERP servers. TLS to getcroc.com uses the platform or Go stack (inference; not traced). -- CrocApp exposes the curve choice, including SIEC (`CrocKit/Sources/CrocKit/Models.swift:37`, `AdvancedSettingsView.swift:291`), as a user setting. Default is p256. +- The two App Store Info.plists set `ITSAppUsesNonExemptEncryption` to `false` (`../RELEASING.md`, "Export compliance"). The Direct build plist and the share extension plist do not set it. +- Engine: croc v11.5.4 with `pake/v3` v3.2.0. Traffic encryption is AES-256-GCM (`crypt.go`, `crypto/aes`), with ChaCha20-Poly1305, PBKDF2 and Argon2 from `golang.org/x/crypto` in the same package. The session key comes from PAKE2 with a selectable curve, then HKDF-SHA256. Default curve p256 (`cli.go`). Available curves in `pake.AvailableCurves()`: p521, p256, p384, **siec**, ed25519. Tailcat (large sends) adds Tailscale's WireGuard (`tailscale/wireguard-go`) over DERP servers. TLS to getcroc.com uses the platform or Go stack (inference; not traced). +- CrocApp exposes the curve choice, including SIEC, as a user setting (`CrocKit/Sources/CrocKit/Models.swift`, enum `Curve`; `app/CrocApp/Views/AdvancedSettingsView.swift`). Default is p256. - The app source is public under MIT, so "publicly available" holds for the app's own code (inference that the whole build qualifies, since the Go modules are public too). ### Inference @@ -285,41 +307,47 @@ Keep `ITSAppUsesNonExemptEncryption` = `false` in both App Store Info.plists, an ### Unknown (not found, not guessed) -- The exact current on-screen App Store Connect questions. Apple's help pages describe them but do not list them. The question text above comes from forum threads (2019 to 2026) and a September 2026 GitHub pull request, not from Apple. -- Whether App Store Connect accepts a French declaration receipt, an attestation, or only the form; whether Apple's review rejects `false` when France is available. -- Whether SIEC (and CrocApp's PAKE composition and WireGuard use) count as "non-standard cryptography" for 742.15(b)(2) notification. SIEC is published open source but not from a standards body. WireGuard is published but not an IETF RFC. The P-256, AES-GCM, ChaCha20-Poly1305, HKDF, HMAC, PBKDF2 and Argon2 parts are standard. If it counts, the open-source route needs the BIS and NSA notification email (cheap and reversible), and Apple's "proprietary or not accepted by standards bodies" question would be "Yes", which requires a CCATS per Apple's table. This is the largest open risk. +- The exact current on-screen App Store Connect questions. Apple's help pages describe them but do not list them. The question text above comes from third-party forum threads and a GitHub pull request, not from Apple. +- Whether App Store Connect accepts a French declaration receipt, an attestation, or only the form, and whether Apple's review rejects `false` when France is available. +- Whether SIEC (and CrocApp's PAKE composition and WireGuard use) count as "non-standard cryptography" for 742.15(b)(2) notification. This is the largest open risk. + - SIEC is published open source but not from a standards body. WireGuard is published but not an IETF RFC. + - The P-256, AES-GCM, ChaCha20-Poly1305, HKDF, HMAC, PBKDF2 and Argon2 parts are standard. + - If SIEC counts, the open-source route needs the BIS and NSA notification email (cheap and reversible). Apple's "proprietary or not accepted by standards bodies" question would then be "Yes", which requires a CCATS per Apple's table. - Whether other countries have import rules that matter. Apple's pages mention only France. Apple removes or restricts regions on its own for legal reasons ("Your app may not be available ... due to legal or regulatory requirements"). - The current consolidated French decree and ANSSI's current filing steps, delays and fees. ### Conflicts between sources - Apple's plist wording ("exempt from export compliance documentation requirements") and Apple's own table (standard non-OS algorithms need the French declaration) conflict when France is available. They agree when France is excluded. -- Third-party guides (2025 to 2026) say a one-month wait applies and France stays out until a complete file is received; ANSSI's pages I read do not state it, but Décret 2007-663 Art. 4 and 5 do. Consistent, but the decree text I read is 2007. -- A 2016 developer blog (Cryptomator, found by search, not fetched) says a US ERN may also be needed; Apple's current table names CCATS only for proprietary algorithms and mentions no ERN. Not resolved, and tied to the SIEC question above. +- Third-party guides say a one-month wait applies and France stays out until a complete file is received. The ANSSI pages read do not state it, but Décret 2007-663 Art. 4 and 5 do. Consistent, but the decree text read is the 2007 one. +- A developer blog (Cryptomator, found by search, not read) says a US ERN may also be needed. Apple's current table names CCATS only for proprietary algorithms and mentions no ERN. Not resolved, and tied to the SIEC question above. ### What to do and when -1. Before Submit: remove France from availability, keep the key `false`, confirm no build shows "Missing Compliance". Nothing else must be filed for 1.0. -2. Open a `future` issue: file the ANSSI declaration (demarches.numeriques platform), then switch the key to `true` with the issued code, upload new builds, and add France back. Allow at least a month from a complete file (Décret 2007-663 Art. 4, 2007 text). -3. Decide on SIEC: either hide the curve from the picker (engineering, out of this file's scope), or send the notification email to `crypt@bis.doc.gov` and `enc@nsa.gov` with `https://github.com/bakirgdev/CrocApp`, or take advice. This is the owner's judgement; Apple states the owner is responsible for the classification. +1. Before submitting: remove France from availability, keep the key `false`, and confirm no build shows "Missing Compliance". Nothing else must be filed for the first App Store release. +2. Track a `future` item: file the ANSSI declaration (demarches.numeriques platform), switch the key to `true` with the issued code, upload new builds, and add France back. Allow at least a month from a complete file (Décret 2007-663 Art. 4, 2007 text). +3. Decide on SIEC. Either hide the curve from the picker (an engineering change, out of this file's scope), send the notification email to `crypt@bis.doc.gov` and `enc@nsa.gov` with `https://github.com/bakirgdev/CrocApp`, or take advice. This is the owner's judgement; Apple states the owner is responsible for the classification. -This is research, not legal advice. +This is research, not legal advice. `../known-issues.md`, "Release", tracks the two owner actions. -## Open checks before Submit +## Open checks before submitting -| Check | Why | -| :--- | :--- | -| Send a file from getcroc.com in a browser and receive it in the app on a real device | Verified so far: "Receive in your browser" from an app sender (`device-test-checklist.md`, Links and web receive, item 6, informational). The browser-sends, app-receives direction in review note step 2 is not verified anywhere in the repo. | -| Read getcroc.com's current labels (Send, Receive, Choose files, "Croc code or stored link", Copy code) | Taken from the page's bundled script on 2026-10-15; the UI is the croc project's and can change. | -| Contact name, phone and email in App Review Information | Not in the repo. The developer-account address in `plan.md` is not necessarily the public support contact. | -| `crocapp.dev/support/` and `crocapp.dev/privacy/` return 200 | They did not exist in `web/landing/` when this was written. | -| Decide whether to add the croc maintainer's written reply to the review notes | Plan O5: the reply text is not in the repo. The notes above say only that the maintainer was informed. Spec concern 10 says written permission, if given, goes here. Remind the owner (R41) before Submit. | -| `https://getcroc.com` loads Umami (`umami.schollz.com`) and Google AdSense scripts on the page (fetched 2026-10-15) | The reviewer opens this page and the `/r/` page links to it. The app does not load it (no web view), but the privacy policy should say getcroc.com is the croc project's site and runs its own analytics and ads. | -| Screenshots | Out of scope here; see `screenshots.md`. | +Still to do: + +- Run the Accessibility Inspector and VoiceOver audit on iOS and macOS (`../known-issues.md`, "Release"). Until it passes, keep VoiceOver and "largest text sizes" claims out of the description and What's New. +- Send a file from getcroc.com in a browser and receive it in the app on a real device. The browser-sends, app-receives direction in the review notes (step 2) is not verified anywhere in the repo. The other direction, "Receive in your browser" from an app sender, is covered in `device-test-checklist.md`, "Links and web receive". +- Read getcroc.com's current labels (Send, Receive, Choose files, "Croc code or stored link", Copy code) and compare them with the review notes. The page belongs to the croc project and can change. +- Fill in the contact name, phone and email in App Review Information. They are not in the repo, and the developer-account address is not necessarily the public support contact. +- Confirm `https://crocapp.dev/support/` and `https://crocapp.dev/privacy/` return HTTP 200. +- Decide whether to add the croc maintainer's written reply to the review notes. The reply text is not in the repo; the notes say only that the maintainer was informed. Ask the owner before submitting. +- State on the privacy page that getcroc.com is the croc project's site and runs its own analytics (Umami) and ads (Google AdSense), as seen in the page source. The reviewer opens that page, and the `/r/` page links to it. The app does not load it (no web view). Re-check the page before relying on this. +- Check whether the multicast entitlement request has been answered. If it is granted, add the entitlement and the setting. If not, ship without it (`apple-platform-constraints.md`, "LAN discovery and multicast"). +- Make the Export compliance decisions above: France out of availability, and SIEC. +- Take the screenshots (`screenshots.md`). ## Sources -Fetched 2026-10-15. +Apple's pages change. Re-read them before you rely on a limit or a rule. - App information (name 30, subtitle 30, privacy URL): https://developer.apple.com/help/app-store-connect/reference/app-information/app-information - Platform version information (promo 170, description 4000, keywords 100 bytes, What's New 4000, notes 4000 bytes, copyright, URLs): https://developer.apple.com/help/app-store-connect/reference/app-information/platform-version-information @@ -330,7 +358,7 @@ Fetched 2026-10-15. - Overview of export compliance: https://developer.apple.com/help/app-store-connect/manage-app-information/overview-of-export-compliance - Export compliance documentation for encryption (the table): https://developer.apple.com/help/app-store-connect/reference/export-compliance-documentation-for-encryption - Determine and upload app encryption documentation: https://developer.apple.com/help/app-store-connect/manage-app-information/determine-and-upload-app-encryption-documentation -- Complying with encryption export regulations (key semantics; read from the JSON behind the page): https://developer.apple.com/documentation/security/complying-with-encryption-export-regulations +- Complying with encryption export regulations (key semantics, read from the JSON behind the page): https://developer.apple.com/documentation/security/complying-with-encryption-export-regulations - Provide export compliance information for beta builds: https://developer.apple.com/help/app-store-connect/test-a-beta-version/provide-export-compliance-information-for-beta-builds - Manage availability: https://developer.apple.com/help/app-store-connect/manage-your-apps-availability/manage-availability-for-your-app-on-the-app-store - ANSSI, contrôle relatif à un moyen de cryptologie: https://cyber.gouv.fr/reglementation/reglementation-identite-confiance-numerique/controles-reglementaires-cryptographie/controle-moyen-de-cryptologie/ @@ -340,7 +368,7 @@ Fetched 2026-10-15. - 15 CFR 742.15: https://www.ecfr.gov/current/title-15/part-742/section-742.15 - 15 CFR 734.3: https://www.ecfr.gov/current/title-15/part-734/section-734.3 - 15 CFR 772.1 (non-standard cryptography): https://www.ecfr.gov/current/title-15/part-772/section-772.1 -- Third party, ASC question wording 2019 to 2026 (not Apple): https://developer.apple.com/forums/thread/127505 and https://github.com/straff2002/OpenGlasses/pull/472 +- Third party, App Store Connect question wording (not Apple): https://developer.apple.com/forums/thread/127505 and https://github.com/straff2002/OpenGlasses/pull/472 - Third party, French filing practice: https://developer.apple.com/forums/thread/741830 and https://gist.github.com/safaiyeh/f6ee55b19eeaa81354275046803e69af -- getcroc.com page and bundled script (labels, Umami and AdSense): https://getcroc.com -- Local: `app/Config/*.plist`, `app/CrocApp/PrivacyInfo.xcprivacy`, `app/CrocApp.xcodeproj/project.pbxproj` (purpose strings, category), `LICENSE`, croc v11.5.4 and pake v3.2.0 in the Go module cache, `docs/known-issues.md`, `docs/BUILDING.md` section 7. +- getcroc.com page and its bundled script (labels, Umami, AdSense): https://getcroc.com +- Local: `app/Config/*.plist`, `app/CrocApp/PrivacyInfo.xcprivacy`, `app/CrocApp.xcodeproj/project.pbxproj` (purpose strings, category), `LICENSE`, croc v11.5.4 and pake v3.2.0 in the Go module cache, `../known-issues.md`, `../RELEASING.md`. diff --git a/docs/knowledge/app-ui-architecture.md b/docs/knowledge/app-ui-architecture.md index 634c8ec..90fe41e 100644 --- a/docs/knowledge/app-ui-architecture.md +++ b/docs/knowledge/app-ui-architecture.md @@ -1,212 +1,321 @@ # App UI architecture -How the SwiftUI app is wired and which of its behaviours are load-bearing. Most of what follows was learned by breaking it. Read the relevant section before editing a view that observes `TransferController`. +How the SwiftUI app is wired and which of its behaviours are load-bearing. Read the relevant section before editing a view that observes `TransferController`. -Engine contract: `crocmobile-bridge.md`. Open defects: `../known-issues.md`. +Engine contract: `crocmobile-bridge.md`. Open defects: `docs/known-issues.md`. ## Structure ``` CrocAppApp owns @State TransferController + AppSettings + OutputFolderStore - + HistoryStore + LocalNetworkChecker + AppRouter.shared + + HistoryStore + LocalNetworkChecker + AppRouter.shared (+ MenuBarQuickSend on macOS) → .environment on WindowGroup content (macOS Settings scene gets only outputFolder + settings, no router/controller/localNetwork) ContentView → HomeView (sole NavigationStack, path: $router.path, value-based links) → SendView | ReceiveView | SettingsScreen | HistoryView | HowItWorksView - each transfer screen: controller.isActive ? TransferStatusView() : input form + each transfer screen: controller.isActive and a matching direction ? TransferStatusView() : input form TransferStatusView renders every non-idle phase; IncomingRequestView = accept gate Shared pieces (Views/): ButtonVariants, DropZone, CodePhraseDisplay, DirectionHeader, ForwardProgressBar, PhoneHaptics; Support/Inflected.swift QRCodeView (cross-platform gen) / QRScannerView.swift (whole file #if os(iOS), VisionKit) ``` -Construction order in `CrocAppApp.init` matters: `AppSettings` first, then `TransferController(settings:)`, then the SwiftData container, then `controller.history = history`. +Localization: UI strings are plain literals in the views. There is no String Catalog (`*.xcstrings`), and the project knows only the `en` and `Base` regions. The plan is in `docs/ROADMAP.md`. + +Construction order in `CrocAppApp.init` matters: `AppSettings`, then the `HistoryStore` container (in memory for a harness run), then `StoredTransferVault`, then `TransferController(settings:vault:)`, then `controller.history = history`. ## TransferController -`@MainActor @Observable`, and the only consumer of `CrocKit` in the app. +`@MainActor @Observable`. It is the only code that uses `CrocEngine`. Many other files `import CrocKit` for its types, but only the controller starts and answers transfers. + +`Phase`: `idle` → `starting` → `waiting(code)` / `connecting` → `confirmSend` | `incoming(FileList, conflicts, blocked)` → `transferring(TransferProgress)` → `done(Summary, receivedText?)` | `stored(StoredReceipt)` | `failed(String)`. Views switch on it. `reset()` has no guard, but the UI offers it only from a terminal phase. + +### Event handling -`Phase`: `idle` → `starting` → `waiting(code)` / `connecting` → `confirmSend` | `incoming(FileList, conflicts, blocked)` → `transferring(TransferProgress)` → `done(Summary, receivedText?)` | `stored(StoredReceipt)` | `failed(String)`. Views switch on it. `reset()` is unconditional — no guard — but is only meant to be called from a terminal phase, which is the only place the UI offers it. +- **`.progress` ticks (about 10 Hz) keep arriving while the accept prompt is unanswered, and must not clobber `.incoming`.** If they do, croc blocks in `GetInput` forever. The same guard protects `.confirmSend`. Ticks with `step == "waiting"` are ignored. +- **Failure copy is picked by `EngineFailure.code`, never by text.** `friendlyMessage(for:cancelRequested:declineRequested:stored:)` checks `cancelRequested` ("Transfer cancelled.") and `declineRequested` ("You declined the transfer.") first, then maps the code. +- It never shows `failure.message`: croc's wording changes per release, and a recovered panic carries a stack trace. +- A peer's decline arrives as `.declined` ("The other device declined or cancelled the transfer."). A peer's cancel looks the same. This side's own decline, or a prompt that hits EOF, arrives as `.refused`. +- **`transferActive` on start: retry once after about 300 ms.** The engine still holds the previous transfer for a short time after `done`. +- `handle(.failed)` does not call the engine. The stream's `onTermination` cancels and clears the transfer by its ID. +- `TransferController` keeps that `transferID`. Every cancel (user, background expiry, auto-accept block) targets it. A cancel tapped before `run()` has the ID is applied as soon as `startSend` or `startReceive` returns. +- Copy precedence when several conditions apply, highest first: `blockedAutoAccept`, `backgroundExpired`, the auto-accept-versus-remote-`--ask` case, then the cancel and decline mapping. The `catch` in `run()` repeats the `backgroundExpired` step, so a thrown expiry keeps the resume hint. +- `blockedAutoAccept` is checked in `.done` as well as `.failed`. `cancel()` is fire-and-forget, so a small transfer can finish before the cancellation reaches the engine. Both paths record `.failed`, because the history schema has no blocked case and "Cancelled" reads as a user action. +- `TransferStatusView` shows a "taking longer than usual" hint after 25 s (`slowPhaseThreshold`) in `.starting` or `.connecting`. A `.task(id: isInEarlyPhase)` resets the timer whenever that flips. The clock survives the `.starting` to `.connecting` handoff, because the user sees one stall. -### Event handling, learned the hard way +### Code `refused` under auto-accept is not a peer decline -- **`.progress` ticks (~10 Hz) keep arriving while the accept prompt is unanswered, and must not clobber `.incoming`.** If they do, croc blocks in `GetInput` forever. Found by harness, fixed in d278c2e. Same guard protects `.confirmSend`. Also ignore `step == "waiting"` ticks. -- **Failure copy is picked by `EngineFailure.code`, never by text.** `friendlyMessage(for:cancelRequested:declineRequested:)` checks `cancelRequested` ("Transfer cancelled.") and `declineRequested` ("You declined the transfer.") first, then maps the code; it never shows `failure.message` (croc's wording changes per release and a recovered panic carries a stack trace). A peer's decline arrives as `.declined` ("The other side declined or cancelled the transfer."; a peer's cancel is indistinguishable), this side's own decline or prompt EOF as `.refused`. -- **`transferActive` on start: retry once after ~300 ms.** There is a release window after `done` where the next start still throws. -- `handle(.failed)` does not call the engine: the stream's `onTermination` cancels and clears the transfer by its ID. `TransferController` keeps that `transferID`; every cancel (user, background expiry, auto-accept block) targets it, and a cancel tapped before `run()` has the ID is applied as soon as `startSend`/`startReceive` returns. -- Copy precedence when several conditions collide, highest first: `blockedAutoAccept`, `backgroundExpired`, the auto-accept-vs-remote-`--ask` case, then cancel/decline mapping. `run()`'s catch mirrors the `backgroundExpired` step so a thrown expiry keeps the resume hint. -- **Code `refused` under auto-accept is not a peer decline.** croc forces the receiver prompt whenever the *remote sender* ran `--ask` (`croc.go`: `!NoPrompt || Ask || senderInfo.Ask`), and engine auto-accept has already closed the prompt pipe, so `GetInput` hits EOF and croc reports its local `refused files`. `autoAcceptActive && !cancelRequested && failure.code == .refused` discriminates it (after the `blockedAutoAccept` and `backgroundExpired` branches): `respond()` is never called on the auto-accept path, so `declineRequested` cannot be set. Under AutoAccept the pipe is not empty: crocmobile queues a batch of `n` answers for croc's other un-gated prompts (empty-folder, unzip member) and then closes it (`crocmobile-bridge.md`). The forced remote-`--ask` prompt reads the first `n` and ends as `refused` under either policy; the batch is `n` even for Replace for exactly this reason, and after a manual accept too, where a croc v11 reconnect re-runs the accept prompt (`verify-interop.sh` scenario 12 checks it). -- `blockedAutoAccept` is checked in `.done` as well as `.failed`: `cancel()` is fire-and-forget, so a small transfer can finish before the cancellation reaches the engine. Both paths record `.failed` (the schema has no blocked case, and "Cancelled" reads as a user action). -- `TransferStatusView` shows a "taking longer than usual" hint after 25 s (`slowPhaseThreshold`) spent in `.starting`/`.connecting`. A `.task(id: isInEarlyPhase)` resets the timer whenever that flips, so the hint never keeps counting once a transfer moves on, but it does survive the `.starting → .connecting` handoff — same stall to the user, no reason to reset the clock. +croc forces the receiver prompt whenever the remote sender ran `--ask` (upstream `croc.go`, not re-read here). Engine auto-accept has already closed the prompt pipe, so `GetInput` hits EOF and croc reports its local `refused files`. + +`autoAcceptActive && !cancelRequested && failure.code == .refused` identifies this case. The check runs after the `blockedAutoAccept` and `backgroundExpired` branches. `respond()` is never called on the auto-accept path, so `declineRequested` cannot be set. + +Under auto-accept the pipe is not empty. `crocmobile` queues a batch of `n` answers for croc's other prompts (empty folder, unzip member) and then closes it (`crocmobile-bridge.md`, "Engine behavior invariants"). The forced remote-`--ask` prompt reads the first `n` answers and ends as `refused` under either file policy. The batch is `n` even for Replace for that reason. A manual accept behaves the same, because a croc v11 reconnect re-runs the accept prompt (`scripts/verify-interop.sh` checks it). ### Options, security scope, progress -- `baseOptions()` builds `EngineOptions` from `AppSettings`; receive also passes `whenFileExists` (`AppSettings.whenFileExists`, UserDefaults key `settings.whenFileExists`, default Keep Both, an unknown stored value reads as Keep Both). Receive passes `autoAccept && !bothSidesConfirm` — Ask wins, because engine-level autoAccept answers croc's prompts with `n` and closes the pipe, which would decline. -- Intent methods start security scope on send URLs and the user-picked output folder, track them in `scopedURLs`, and release at stream end. `startAccessingSecurityScopedResource() == false` is fine for non-scoped drops — keep the call anyway. -- Speed is an EMA (0.25/0.75) over `bytesFinished + fileSent` deltas, spaced ≥0.2 s. Overall progress is `(bytesFinished + fileSent) / totalSize`; `fileSent` is per-current-file. +- `baseOptions()` builds `EngineOptions` from `AppSettings`. Receive also passes `whenFileExists` (`AppSettings.whenFileExists`, key `settings.whenFileExists`, default Keep Both; an unknown stored value reads as Keep Both). +- Receive passes `autoAccept && !bothSidesConfirm`, so confirmation wins. Engine auto-accept closes the prompt pipe, which croc reads as a decline. +- Intent methods start security scope on send URLs and on the user-picked output folder, track them in `scopedURLs`, and release them at stream end. `startAccessingSecurityScopedResource() == false` is fine for non-scoped drops. Keep the call anyway. +- Speed is an exponential moving average (0.25 new, 0.75 old) over `bytesFinished + fileSent` deltas, sampled at most every 0.2 s. Overall progress is `(bytesFinished + fileSent) / totalSize`. `fileSent` is per current file. - `activeRelay` is captured at start, for the trust badge. - `harnessDisableLocal` is a harness-only flag, not a user setting. ### History capture -- A `PendingRecord` snapshot is set in all three start intents. Send: names capped at 20, bookmarks all-or-nothing up to 200, built while security scope is held. Receive: filled from `.fileList`. `codeHint` is backfilled at `.codeReady` for generated codes. -- `finishRecord(status:summary:)` runs at `.done`, `.failed`, and the startup catch. It sets `pendingRecord = nil` first so a double terminal event is idempotent, and must run while `receivedText` is still set so `isText` backfills. -- Status mapping: `done` → completed or failed per the summary; `failed` + `cancelRequested` → cancelled, + `declineRequested` → declined. +- A `PendingRecord` snapshot is set in all start intents. Send: names capped at 20, bookmarks all-or-nothing up to 200, built while the security scope is held. Receive: filled from `.fileList`. `codeHint` is backfilled at `.codeReady` for generated codes. +- `finishRecord(status:summary:)` runs at `.done`, `.failed` and the startup `catch`. It sets `pendingRecord = nil` first, so a double terminal event is harmless. It must run while `receivedText` is still set, so `isText` backfills. +- Status mapping: `done` becomes completed or failed per the summary. `failed` plus `cancelRequested` becomes cancelled. `failed` plus `declineRequested` becomes declined. ## Settings and trust -`Models/AppSettings.swift` — `@MainActor @Observable`, `UserDefaults` keys prefixed `settings.`, `didSet` persistence gated by a `persist` flag (the harness override channel). +`Models/AppSettings.swift` is `@MainActor @Observable`. Its `UserDefaults` keys start with `settings.`. A `didSet` persists each value, gated by a `persist` flag (the harness override channel). -`relayPassword` is the one setting that is **not** in `UserDefaults`: it lives in the Keychain via `Support/KeychainStore.swift`, migrated out of the plist on first launch. Its `didSet` goes through `persistRelayPassword()`, which honours the same `persist` gate — that gate is load-bearing here in a way it is not for the other settings, because `resetToDefaults()` assigns `relayPassword = ""` and an ungated write would blank a real user's stored password on any machine that has ever run a verify script. +`relayPassword` is the one setting that is not in `UserDefaults`. It lives in the Keychain via `Support/KeychainStore.swift` and is migrated out of the plist on first launch. Its `didSet` goes through `persistRelayPassword()`, which honours the same `persist` gate. The gate matters here more than for other settings: `resetToDefaults()` assigns `relayPassword = ""`, and an ungated write would blank a real user's stored password on any machine that has run a verify script. -Relay strings use `""` to mean "croc default" (the `TextField` prompt reads "Public relay pool") and reach the engine as stored, password included. Go resolves them (`crocmobile/relay.go`): blank means croc's default, and with no custom relay and local-only off the relay comes from upstream's public pool by code. `relayKind` (`publicDefault` / `custom` / `localOnly`) is display-only and classifies as custom if either address is set to something other than `RelayDefaults` (CrocKit, read from Go). +Relay strings use `""` for "croc default" (the `TextField` prompt reads "Public relay pool") and reach the engine as stored, password included. Go resolves them (`crocmobile/relay.go`): blank means croc's default, and with no custom relay and Local Network Only off, the relay comes from upstream's public pool by code. -`Views/PowerSettingsSections.swift` holds the shared `Form` sections — relay, connection (the Automatic / Relay Only `Transport` picker, disabled while Local Network Only is on), transfer toggles, exclusions, confirmation with the Auto-Accept Transfers warning footer — embedded in both the macOS `SettingsView` (Settings scene, ⌘,) and the iOS `SettingsScreen` (gear toolbar → `Route.settings`). `TrustBadge(relay:)` appears in the waiting, transferring, and confirm-send views. `HowItWorksView` is the plain-language explainer; its facts come from `what-is-croc.md`. +`relayKind` is display-only: `publicDefault`, `custom`, `localOnly` or `directToSender`. It is `custom` if either address is set to something other than `RelayDefaults` (CrocKit, read from Go). `receiveRelayKind` returns `directToSender` when a sender IP is set. -**About and privacy link (R38).** `Views/AboutSection.swift` is the last `Form` section of the macOS General tab and the iOS `SettingsScreen`: version (`CFBundleShortVersionString` and `CFBundleVersion`), Privacy Policy and Support `Link`s. The URLs and the version string live in `Support/AppInfo.swift`; `AppInfoTests` pins their scheme, host and path. On macOS `AppCommands` replaces the `.appInfo` group with an "About CrocApp" button that calls `NSApplication.orderFrontStandardAboutPanel(options:)` with a linked "Privacy Policy" `.credits` string (the Direct build's `UpdateCommands` still attaches after that group). +`Views/PowerSettingsSections.swift` holds the shared `Form` sections: relay, connection, transfer toggles, exclusions, and confirmation with the **Auto-Accept Transfers** warning footer. The connection section has the **Connection** picker (**Automatic** or **Relay Only**), which is disabled while **Local Network Only** is on. Both the macOS `SettingsView` (Settings scene, ⌘,) and the iOS `SettingsScreen` (gear toolbar, `Route.settings`) embed these sections. -`Phase.confirmSend` is the sender side of croc `--ask`: entered at `.connected` when `bothSidesConfirm` is on, progress-guarded like `.incoming`, accept is `respond(true)`, decline is `cancel()`; its Cancel button is `.destructiveTinted`. +`TrustBadge(relay:)` appears in the waiting, transferring and confirm-send views. `HowItWorksView` is the plain-language explainer. Its facts come from `what-is-croc.md`. -### Stored transfers (R18-R21) +**About and privacy link.** `Views/AboutSection.swift` is the last `Form` section of the macOS General tab and of the iOS `SettingsScreen`. It shows the version (`CFBundleShortVersionString` and `CFBundleVersion`) and **Privacy Policy** and **Support** links. The URLs and the version string live in `Support/AppInfo.swift`, and `AppInfoTests` pins their scheme, host and path. -`isStoredTransfer` marks a transfer that goes through a stored-transfer server instead of a croc peer; it reuses `run()`, `handle(_:)`, the `BGContinuedProcessingTask` wrap and the history record. -- `startStoreSend(urls:downloads:lifetimeSeconds:)` uploads to `AppSettings.storedServer` (UserDefaults `settings.storedServer`, default `https://getcroc.com`). The engine sends `.progress`, then `.stored(receipt)` just before `.done`; the controller puts the receipt in the vault, keeps its id and expiry for the record, and turns `.done` into `Phase.stored(receipt)`. -- `startStoreReceive(link:into:folderIsScoped:)` takes the same path as a live receive: `.incoming` with the conflict scan, then `respond(accept:policy:)`. Auto-accept never applies; the engine always waits. -- Views. `SendView`: a "Send as Link" toggle (disabled with a reason line for text or a staged folder), "Link Lasts" picker (1 hour / 1 day default / 7 days), a downloads stepper (1-100, default 1) and a server note; with it on, the custom code field is hidden and the primary button reads Send as Link (content.md → Terminology: still sending, never Upload). It checks `AppSettings.storedServerError` and blocks Send as Link on an invalid server. `TransferStatusView`: `Phase.stored(receipt)` renders `storedView` (Link ready, expiry and download count, QR, link text, Copy Link via `Clipboard.copy(_, thisDeviceOnly: true)`, Share Link, Done); `DirectionHeader` reads Sending / Receiving for stored transfers too (never Uploading / Downloading) and the view shows a caption instead of `TrustBadge` when `isStoredTransfer`. `ReceiveView`: `router.pendingReceive` carrying `.stored(url)` sets `storedLink`, which replaces the code field with a "Receive from Link" row and a Clear button, and Receive calls `startStoreReceive`. `HistoryView`: stored rows add a "Link expires / expired / revoked" line, and Revoke Link (context menu; "Revoke" swipe on iOS) sits behind a confirmation dialog, with an alert on failure. `PowerSettingsSections`: a "Send as link" section (header, sentence case) with the Link Server field, inline `storedServerError` banner and a Reset button shown only when the value differs from the default. -- `AppSettings.storedServerError(_:)` accepts `https`, or `http` on loopback only, and nothing after the host. `TransferLink.parse` accepts a stored link only over `https`, or `http` on loopback, and the engine checks the id and key lengths. -- `Clipboard.copy(_, thisDeviceOnly:)` sets `UIPasteboard` with `.localOnly` on iOS so a link (its fragment is the key) stays off Universal Clipboard; the macOS pasteboard has no such option, so Copy Link there is an ordinary copy. The Camera app does not open CrocApp for a stored link: the Associated Domains entitlement and the AASA file cover only `crocapp.dev/r/*`. Receiving in the app is Paste or Scan QR Code. -- A stored transfer cannot resume, so no failure copy offers it. A background expiry reads "Sending was interrupted … Try again" (send) or "Receiving was interrupted … Open the link again" (receive), concern 4. `friendlyMessage(…, stored: true)` names the transfer server for `relayUnreachable` and `connectionLost`, which is how a store call reports a network failure. -- `revoke(_ record:)` (throws `EngineFailure`) calls `CrocEngine.revokeStored`, which needs no free engine slot, then drops the receipt and sets `revokedAt`. `canRevoke(_:)` is true for an unexpired, unrevoked record whose receipt this device still holds. +On macOS, `AppCommands` replaces the `.appInfo` group with an **About CrocApp** button. It calls `NSApplication.orderFrontStandardAboutPanel(options:)` with a linked "Privacy Policy" `.credits` string. The Direct build's `UpdateCommands` still attaches after that group. -`Models/StoredTransferVault.swift` keeps receipts (id, origin, upload token, link, expiry) in the Keychain, all in one item (`storedReceipts`, a JSON array), because `SecretStore` cannot list accounts. Expired receipts are purged when the vault opens (each launch). A failed Keychain write is logged, never lost silently, and leaves the receipts already stored untouched; the new receipt then lasts until the app quits. Harness runs and previews use `MemorySecretStore`. History never holds the token or link: a stored record's `codeHint` is the server host (`codeHint(storedLink:)`). +`Phase.confirmSend` is the sender side of croc `--ask`. It is entered at `.connected` when **Confirm on Both Sides** is on, and it is progress-guarded like `.incoming`. Accept is `respond(true)`, decline is `cancel()`, and its Cancel button is `.destructiveTinted`. -### Advanced settings (R25) +### Stored transfers -Croc's own options, edited in `Views/AdvancedSettingsView.swift`: iOS pushes it from `SettingsScreen` (`Route.advancedSettings`, `--screen advanced`); macOS Settings is a `TabView` with General (the old form) and Advanced, because the Advanced controls and their footnotes would double a form that already scrolls. -- `AppSettings` has one property per control under `settings.advanced.` (curve, hashAlgorithm, throttleUpload, noMultiplexing, basePort, transfers, multicastAddress, peerIP, internalDNS). Defaults come from `EngineOptions()`, so they are croc's. `resetAdvancedToDefaults()` backs the Reset to Defaults button (persists); the harness `resetToDefaults()` calls it with `persist` off. -- **The two proxies are Keychain-only** (`SecretStore` accounts `advanced.socks5Proxy`, `advanced.httpProxy`, deleted when blank, same `persist` gate as `relayPassword`). They can carry credentials: `ProxySettings`, `EngineOptions` and `StoredSendOptions` describe themselves without the value; the Go engine never quotes a proxy in an error; nothing logs them. `AppSettingsAdvancedTests` asserts UserDefaults holds no proxy key or value. -- `Models/AdvancedSettingsValidation.swift` holds the inline checks (`socks5ProxyError`, `httpProxyError`, `multicastAddressError`, `senderIPError`, `portsError`, `throttleError`). They mirror `crocmobile/network.go` (tables in `AdvancedSettingsValidationTests` match `network_test.go`); two are slightly stricter than the engine on purpose: a proxy or sender port outside 1-65535, and a one-character upload limit (croc ignores it silently). Blank always means the default. -- `TransferController.baseOptions()` passes every setting; `startReceive` adds `peerIP`; store send, store receive and revoke pass `ProxySettings`. Multicast reaches the engine on macOS only (`#if os(macOS)`): iOS lacks the multicast entitlement (concern 8), so the control is hidden and the engine keeps its default. The engine reports a bad proxy, port, multicast address or sender IP as `invalidSetting`; it does not classify a bad upload limit, so `requireUsableUploadLimit()` fails a send with the same code first. Copy for `invalidSetting` points at Settings > Advanced. -- A receive with a sender IP shows `RelayKind.directToSender` in the trust badge (`AppSettings.receiveRelayKind`), never the public pool. +`isStoredTransfer` marks a transfer that goes through a stored-transfer server instead of a croc peer. It reuses `run()`, `handle(_:)`, the `BGContinuedProcessingTask` wrap and the history record. + +- `startStoreSend(urls:downloads:lifetimeSeconds:)` uploads to `AppSettings.storedServer` (key `settings.storedServer`, default `https://getcroc.com`). +- The engine sends `.progress`, then `.stored(receipt)` just before `.done`. The controller puts the receipt in the vault, keeps its id and expiry for the record, and turns `.done` into `Phase.stored(receipt)`. +- `startStoreReceive(link:into:folderIsScoped:)` takes the same path as a live receive: `.incoming` with the conflict scan, then `respond(accept:policy:)`. Auto-accept never applies, and the engine always waits. +- `AppSettings.storedServerError(_:)` accepts `https`, or `http` on loopback only, and nothing after the host. `TransferLink.parse` accepts a stored link only over `https`, or `http` on loopback. The engine checks the id and key lengths. +- A stored transfer cannot resume, so no failure copy offers it. A background expiry reads "Sending was interrupted: iOS stopped it in the background. Try again." for a send, or "Receiving was interrupted: iOS stopped it in the background. Open the link again." for a receive. +- `friendlyMessage(…, stored: true)` names the link server for `relayUnreachable` and `connectionLost`, which is how a store call reports a network failure. +- `revoke(_ record:)` (throws `EngineFailure`) calls `CrocEngine.revokeStored`, which needs no free engine slot. It then drops the receipt and sets `revokedAt`. `canRevoke(_:)` is true for an unexpired, unrevoked record whose receipt this device still holds. +- `Clipboard.copy(_, thisDeviceOnly:)` sets `UIPasteboard` with `.localOnly` on iOS, so a link (its fragment is the key) stays off Universal Clipboard. The macOS pasteboard has no such option, so **Copy Link** there is an ordinary copy. +- The Camera app does not open CrocApp for a stored link. The Associated Domains entitlement and the apple-app-site-association file cover only `crocapp.dev/r/*`. Receiving in the app means **Paste** or **Scan QR Code**. + +Views for stored transfers: + +- `SendView`: a **Send as Link** toggle, disabled with a reason line for text or a staged folder. With it on, the screen shows a **Link Lasts** picker (1 hour, 1 day by default, 7 days), a downloads stepper (1 to 100, default 1) and a server note. The custom code field is hidden and the primary button reads **Send as Link**. The screen blocks sending when `storedServerError` reports an invalid server. +- `TransferStatusView`: `Phase.stored(receipt)` renders `storedView`: "Link ready", expiry and download count, QR, link text, **Copy Link** (`thisDeviceOnly: true`), **Share Link** and **Done**. +- `DirectionHeader` reads Sending or Receiving for stored transfers too, never Uploading or Downloading. The view shows a caption instead of `TrustBadge` when `isStoredTransfer`. +- `ReceiveView`: `router.pendingReceive` carrying `.stored(url)` sets `storedLink`. That replaces the code field with a **Receive from Link** row (the server host underneath) and a **Clear** button. **Receive** then calls `startStoreReceive`. +- `HistoryView`: stored rows add a "Link expires", "Link expired" or "Link revoked" line. **Revoke Link** (context menu) and **Revoke** (swipe on iOS) sit behind a confirmation dialog, with an alert on failure. +- `PowerSettingsSections`: a "Send as link" section (the header really reads that way, in sentence case) with the Link Server field, an inline `storedServerError` banner, and a **Reset** button shown only when the value differs from the default. + +`Models/StoredTransferVault.swift` keeps receipts (id, origin, upload token, link, expiry) in the Keychain, all in one item (`storedReceipts`, a JSON array), because `SecretStore` cannot list accounts. Expired receipts are purged when the vault opens, which is each launch. A failed Keychain write is logged, never lost silently, and leaves the receipts already stored untouched. The new receipt then lasts until the app quits. Harness runs and previews use `MemorySecretStore`. + +History never holds the token or the link. A stored record's `codeHint` is the server host (`codeHint(storedLink:)`). -### Menu bar and Shortcuts (R26-R28) +### Advanced settings -Neither surface owns transfer state. Both drive the one `TransferController`, so they show and respect a transfer started anywhere, and a second start is refused while `isActive`. -- **Menu bar (macOS, R26).** `CrocAppApp` declares a `MenuBarExtra` in window style, inserted while `AppSettings.showInMenuBar` is on (`settings.showInMenuBar`, default true). `Views/MenuBarQuickSendView.swift` switches on `controller.phase`; `Models/MenuBarQuickSend.swift` holds the actions (`send(urls:)`, `sendClipboardText()`, `chooseFiles()`) and only a `notice` string. Sends use a generated code. `chooseFiles()` uses a modal `NSOpenPanel`: the window-style extra closes when focus moves to a SwiftUI file importer's sheet. `.incoming` shows "Open CrocApp", because the accept prompt lives in the main window. A drop lands only on the open window, never on the status item. -- **Open at login.** `Models/LoginItem.swift` wraps `SMAppService.mainApp` behind the `LoginItemService` protocol (injected in tests). The toggle reads `status`, never a stored copy, since the user can change it in System Settings > General > Login Items; `SettingsView` refreshes on appear and when the app becomes active, and shows an Open Settings button (`SMAppService.openSystemSettingsLoginItems()`) for `.requiresApproval`. -- **Intents (R27).** `Intents/` holds `SendFilesIntent`, `SendTextIntent`, `ReceiveIntent` (all `supportedModes: .foreground`, because a transfer outlives the ~30 s iOS gives a background action) and `CrocAppShortcuts`. The intents take `TransferController` and `OutputFolderStore` with `@Dependency`; `CrocAppApp.init` registers both in `AppDependencyManager.shared` before any window, since an action can run right after launch. -- `TransferController.wait(for:)` (`TransferIntentSupport.swift`) suspends until a phase transform yields a value, re-arming `withObservationTracking` on `phase` in the same main-actor turn as the check so no change slips between them. `awaitCode()` ends at `.waiting` (the send actions return there, not at the end of the transfer) and `awaitReceive()` at `.done`; `.failed` becomes `TransferIntentError.failed`. -- `ReceivedFileLocator` finds what a receive wrote. croc keeps the sender's modification dates, so it tells new files apart by creation date: `name.ext` or its Keep Both copy `name (n).ext` created since the action started. `controller.offeredFileNames` gives the names. -- `IntentStaging` writes files that arrive without a usable file URL, and a received text as `Received text.txt`, under `ShortcutsFiles` in the temporary directory. A send outlives its action, so nothing is deleted on return; `SendFilesIntent` sweeps the folder at the start of the next send. -- A shortcut does not answer the accept prompt (auto-accept applies as usual), and Keep Both or Replace follows the setting. -- **iPad (R28).** `Views/AppCommands.swift` shows Send… (⌘1), Receive… (⌘2) and Show Receive Folder (⇧⌘R) in the File menu on both platforms. On iPadOS the last one opens `shareddocuments://` plus the percent-encoded receive-folder path with `UIApplication.open`, which is the Files app's scheme for a local folder. Not verified on a device. +croc's own options are edited in `Views/AdvancedSettingsView.swift`. iOS pushes it from `SettingsScreen` (`Route.advancedSettings`, `--screen advanced`). macOS Settings is a `TabView` with **General** and **Advanced** tabs, because the Advanced controls and their footnotes would double a form that already scrolls. + +- `AppSettings` has one property per control under `settings.advanced.`: `curve`, `hashAlgorithm`, `throttleUpload`, `noMultiplexing`, `basePort`, `transfers`, `multicastAddress`, `peerIP`, `internalDNS`. Defaults come from `EngineOptions()`, so they are croc's. +- `resetAdvancedToDefaults()` backs the **Reset to Defaults** button and persists. The harness `resetToDefaults()` calls it with `persist` off. +- **The two proxies are Keychain-only.** The `SecretStore` accounts are `advanced.socks5Proxy` and `advanced.httpProxy`. They are deleted when blank and use the same `persist` gate as `relayPassword`. +- Proxies can carry credentials. `ProxySettings`, `EngineOptions` and `StoredSendOptions` describe themselves without the value. The Go engine never quotes a proxy in an error, and nothing logs them. `AppSettingsAdvancedTests` asserts that `UserDefaults` holds no proxy key or value. +- `Models/AdvancedSettingsValidation.swift` holds the inline checks: `socks5ProxyError`, `httpProxyError`, `multicastAddressError`, `senderIPError`, `portsError`, `throttleError`. They mirror `crocmobile/network.go`, and the tables in `AdvancedSettingsValidationTests` match `network_test.go`. Blank always means the default. +- Two checks are stricter than the engine on purpose: a proxy or sender port outside 1 to 65535, and a one-character upload limit (croc ignores it silently). +- `TransferController.baseOptions()` passes every setting. `startReceive` adds `peerIP`. Store send, store receive and revoke pass `ProxySettings`. +- Multicast reaches the engine on macOS only (`#if os(macOS)`). iOS lacks the multicast entitlement, so the control is hidden and the engine keeps its default. +- The engine reports a bad proxy, upload limit, port, multicast address or sender IP as `invalidSetting` (`validateNetworkOptions` in `crocmobile/network.go`). Copy for `invalidSetting` points at Settings > Advanced. +- A receive with a sender IP shows `RelayKind.directToSender` in the trust badge (`AppSettings.receiveRelayKind`), never the public pool. + +### Menu bar and Shortcuts + +Neither surface owns transfer state. Both drive the one `TransferController`, so they show and respect a transfer started anywhere. A second start is refused while `isActive`. + +**Menu bar (macOS).** +- `CrocAppApp` declares a `MenuBarExtra` in window style, inserted while `AppSettings.showInMenuBar` is on (`settings.showInMenuBar`, default true). The toggle is **Show in Menu Bar**. +- `Views/MenuBarQuickSendView.swift` switches on `controller.phase`. `Models/MenuBarQuickSend.swift` holds the actions (`send(urls:)`, `sendClipboardText()`, `chooseFiles()`) and a `notice` string. Sends use a generated code. +- `chooseFiles()` uses a modal `NSOpenPanel`, because the window-style extra closes when focus moves to a SwiftUI file importer's sheet. +- `.incoming` shows **Open CrocApp**, because the accept prompt lives in the main window. +- A drop lands only on the open window, never on the status item. + +**Open at Login.** +- `Models/LoginItem.swift` wraps `SMAppService.mainApp` behind the `LoginItemService` protocol, which tests inject. +- The **Open at Login** toggle reads `status`, never a stored copy, because the user can change it in System Settings > General > Login Items. `SettingsView` refreshes on appear and when the app becomes active. +- For `.requiresApproval` it shows an **Open Settings** button (`SMAppService.openSystemSettingsLoginItems()`). + +**Intents.** +- `Intents/` holds `SendFilesIntent`, `SendTextIntent`, `ReceiveIntent` and `CrocAppShortcuts`. All three intents set `supportedModes: .foreground`, because a transfer outlives the roughly 30 s iOS gives a background action. +- The intents take `TransferController` (and, for receive, `OutputFolderStore`) with `@Dependency`. `CrocAppApp.init` registers both in `AppDependencyManager.shared` before any window, because an action can run right after launch. +- `TransferController.wait(for:)` (`TransferIntentSupport.swift`) suspends until a phase transform yields a value. It re-arms `withObservationTracking` on `phase` in the same main-actor turn as the check, so no change slips between them. +- `awaitCode()` ends at `.waiting`, so the send actions return there and not at the end of the transfer. `awaitReceive()` ends at `.done`. `.failed` becomes `TransferIntentError.failed`. +- `ReceivedFileLocator` finds what a receive wrote. croc keeps the sender's modification dates, so it tells new files apart by creation date: `name.ext`, or its Keep Both copy `name (n).ext`, created since the action started. `controller.offeredFileNames` gives the names. +- `IntentStaging` writes files that arrive without a usable file URL, and a received text as `Received text.txt`, under `ShortcutsFiles` in the temporary directory. A send outlives its action, so nothing is deleted on return. `SendFilesIntent` sweeps the folder at the start of the next send. +- A shortcut does not answer the accept prompt. Auto-accept applies as usual, and Keep Both or Replace follows the setting. + +**File menu.** +- `Views/AppCommands.swift` shows **Send…** (⌘1), **Receive…** (⌘2) and a Show Receive Folder item (⇧⌘R) in the File menu on both platforms. +- On macOS the item is **Show Receive Folder in Finder**. +- On iPadOS it is **Show Receive Folder in Files**. It opens `shareddocuments://` plus the percent-encoded receive-folder path with `UIApplication.open`, which is the Files app's scheme for a local folder. +- The iPad hand-off is not verified on a device. A user-picked provider folder (such as iCloud Drive) is expected to do nothing, because that scheme resolves local paths only. ## Transfer screen presentation -The shared views route onto `design/`; a screen picks one instead of styling ad hoc. +The shared views route onto `design/`. A screen picks one of these instead of styling ad hoc. -- `Views/ButtonVariants.swift`: `.buttonVariant(_:)` with `prominent`, `secondary`, `destructive`, `destructiveTinted`, `plain` (design/components.md → Button; design/swiftui.md → Components). A `PrimitiveButtonStyle` so the label color sits on the label itself. Disabled buttons get only the system dimming, never an extra opacity on the label. Filled `.destructive` is for Decline on the accept gate and the Clear History confirmation only; Cancel (waiting, transferring, confirm-send, menu bar) and Discard (`StagedFilesSheet`) are `.destructiveTinted`. -- `DropZone`, `CodePhraseDisplay` (eyebrow, code, Copy Code; `compact` for the 300-wide menu bar column), `DirectionHeader` (arrow plus Sending / Receiving), `ForwardProgressBar` (never moves backwards, `resetKey` snaps when the measured thing changes; design/motion.md → Choreography). `Support/Inflected.swift` resolves `^[3 file](inflect: true)` markup into a plain `String`. -- **Accept gate.** `.incoming` offers Decline only, no Cancel: Cancel would answer the sender on the user's behalf (`TransferStatusView.showsCancel` is false there; design/components.md → AcceptGate). Leaving the screen answers nothing and stays silent. `HomeView` shows a "Files are waiting for your answer" `StatusBanner` with Review (`router.path = [.receive]`) while the phase is `.incoming`, so a live request is never lost. -- **Phase cross-fade.** `TransferStatusView` animates on `phaseKind` (the phase case without its payload), not on progress ticks, so only a real phase change cross-fades; it is `nil` under Reduce Motion (design/motion.md → Choreography). -- **Haptics live in `ContentView`**, one `.phoneHaptic(trigger: TransferHaptic(controller.phase))` on the root. `TransferHaptic` maps a phase change to one of four events and everything else stays silent; iPhone only (design/motion.md → Haptics and sound). Views never fire their own. -- **Wording.** Status titles are sentence case ("Link ready"), controls Title Case ("Send as Link", "Local Network Only", "Auto-Accept Transfers"); section headers, footers and descriptions stay sentence case (design/content.md → Capitalization). A stored transfer still reads Sending / Receiving, never Uploading / Downloading, and "incoming" is not user-facing (design/content.md → Terminology). +- `Views/ButtonVariants.swift`: `.buttonVariant(_:)` with `prominent`, `secondary`, `destructive`, `destructiveTinted`, `plain` (`design/components.md`, "Button"; `design/swiftui.md`, "Components"). It is a `PrimitiveButtonStyle`, so the label color sits on the label itself. Disabled buttons get only the system dimming, never an extra opacity on the label. +- Filled `.destructive` is for Decline on the accept gate and the Clear History confirmation only. Cancel (waiting, transferring, confirm-send, menu bar) and Discard (`StagedFilesSheet`) are `.destructiveTinted`. +- `DropZone`, `CodePhraseDisplay` (eyebrow, code, **Copy Code**; `compact` for the 300-wide menu bar column), `DirectionHeader` (arrow plus Sending or Receiving). +- `ForwardProgressBar` never moves backwards, and `resetKey` snaps it when the measured thing changes (`design/motion.md`, "Choreography"). +- `Support/Inflected.swift` resolves `^[3 file](inflect: true)` markup into a plain `String`. +- **Accept gate.** `.incoming` offers Decline only, with no Cancel. Cancel would answer the sender on the user's behalf, so `TransferStatusView.showsCancel` is false there (`design/components.md`, "AcceptGate"). Leaving the screen answers nothing and stays silent. +- `HomeView` shows a "Files are waiting for your answer" `StatusBanner` with **Review** (`router.path = [.receive]`) while the phase is `.incoming`, so a live request is never lost. +- **Phase cross-fade.** `TransferStatusView` animates on `phaseKind` (the phase case without its payload), not on progress ticks, so only a real phase change cross-fades. It is `nil` under Reduce Motion (`design/motion.md`, "Choreography"). +- **Haptics.** Phase haptics live in `ContentView`: one `.phoneHaptic(trigger: TransferHaptic(controller.phase, endedByUser: controller.endedByUser))` on the root. `TransferHaptic` has three cases (completed, failed, incoming), and a failure the user asked for stays silent. The fourth event, `.selection` for a copied or scanned code, fires from `CodePhraseDisplay` and `ReceiveView`. iPhone only (`design/motion.md`, "Haptics and sound"). +- **Wording.** Status titles are sentence case ("Link ready"). Controls are Title Case (**Send as Link**, **Local Network Only**, **Auto-Accept Transfers**). Section headers, footers and descriptions stay sentence case (`design/content.md`, "Capitalization"). A stored transfer still reads Sending or Receiving, never Uploading or Downloading, and "incoming" is not user-facing (`design/content.md`, "Terminology"). ## History -`Models/TransferHistory.swift` — `TransferRecord` is a SwiftData `@Model` (fields including `statusRaw` string storage for the enum). Stored uploads add optional `storedID`, `expiresAt` and `revokedAt`; optional so a store written by an older build opens without a migration step. `HistoryStore` is `@MainActor @Observable`, wraps `container.mainContext`, exposes `add` / `delete` / `clear` / `recordCount` / `markRevoked`. The container is in-memory when `AutoVerify.isHarnessRun`, and is injected twice: as `.environment(history)` and as `.modelContainer(history.container)`. +`Models/TransferHistory.swift`: `TransferRecord` is a SwiftData `@Model` (including `statusRaw`, a string storage for the enum). Stored sends add optional `storedID`, `expiresAt` and `revokedAt`. They are optional so a store written by an older build opens without a migration step. + +Caps: a record keeps at most 20 names (`maxNames`) and 200 bookmarks (`maxBookmarks`), and the store keeps at most 300 records (`maxRecords`). `HistoryStore.add()` deletes the oldest records beyond that. + +`HistoryStore` is `@MainActor @Observable` and wraps `container.mainContext`. It exposes `add`, `delete`, `clear`, `recordCount` and `markRevoked`. The container is in memory when `AutoVerify.isHarnessRun`. It is injected twice: as `.environment(history)` and as `.modelContainer(history.container)`. -`Views/HistoryView.swift` (`Route.history`, clock toolbar icon on both platforms): `@Query` sorted date-descending, swipe and context delete, Clear behind a confirmation, and "Send Again" for send records that carry bookmarks — resolve bookmarks, then `router.openSend`. +`Views/HistoryView.swift` (`Route.history`, clock toolbar icon on both platforms): +- `@Query` sorted newest first. +- Swipe and context delete. +- **Clear** behind a confirmation. +- **Send Again** for send records that carry bookmarks: resolve the bookmarks, then `router.openSend`. -**Both platform probe branches must call `startAccessingSecurityScopedResource()` before `fileExists`.** The sandbox denies `stat` on an unopened scope. Each platform's omission was a separately caught defect. +**Both platform probe branches must call `startAccessingSecurityScopedResource()` before `fileExists`.** The sandbox denies `stat` on an unopened scope. -## Support/ helpers +## Support helpers -- `Support/SecurityScopedBookmark.swift` — bookmark create/resolve/resolveIfReachable, shared by `OutputFolderStore` and `HistoryView`'s resend path (previously written four times over, one per platform per call site). `resolveIfReachable` starts the security scope before the existence probe — the sandbox denies `stat` on an unopened scope. -- `Support/EngineConstraints.swift` — `minCodeLength = 6`, mirroring the Go engine's `len(secret) < 6` check in `crocmobile/session.go` and `crocmobile.go`. UI-side validation only, for inline feedback before a doomed `startSend`/`startReceive` call; no shared source of truth across the Go/Swift boundary, kept in sync by hand. -- `Support/DesignTokens.swift` — the app's routing layer onto `design/`: `Spacing`, `Radius`, `BorderWidth`, `ControlHeight`, `IconSize`, `LayoutCap`, `ComponentMetrics`, `Motion`, plus `Color` aliases. The one file that splits `#if os(iOS)`/`#if os(macOS)` for UIKit/AppKit-backed colors. Where a system semantic already matches a token exactly, the alias routes there instead of duplicating an asset-catalog entry. +- `Support/SecurityScopedBookmark.swift`: bookmark create, resolve and resolveIfReachable. `OutputFolderStore` and `HistoryView`'s resend path share it. `resolveIfReachable` starts the security scope before the existence probe, for the reason above. +- `Support/EngineConstraints.swift`: `minCodeLength = 6`, mirroring `minCodeLength` in `crocmobile/crocmobile.go` (also used in `session.go`). The Go side counts bytes and Swift counts characters, so the UI check is the stricter one for non-ASCII codes. It exists for inline feedback before a doomed `startSend` or `startReceive` call. There is no shared source of truth across the Go/Swift boundary, so keep the two in sync by hand. +- `Support/DesignTokens.swift`: the app's routing layer onto `design/`. It holds `Spacing`, `Radius`, `BorderWidth`, `ControlHeight`, `IconSize`, `LayoutCap`, `Opacity`, `Tracking`, `ComponentMetrics`, `Motion` and `Shadow`, plus `Color` aliases. Where a system semantic already matches a token exactly, the alias routes there instead of duplicating an asset-catalog entry. ## Conflict scan -Asynchronous. `.incoming` is shown immediately with `conflicts: []`; `Self.scanConflicts` runs `Task.detached` and stats off the main actor; the write-back happens only when non-empty, only `if case .incoming`, and only if `transferGeneration` still matches the value captured at `.fileList`. The shape check alone was not enough: a scan can outlive a whole fast transfer and land on the *next* one's `.incoming`. Doing the scan synchronously froze the accept UI on large file lists. With conflicts, the sheet offers Decline, Replace and Keep Both (Keep Both prominent) and a banner; without conflicts, or while the scan runs, a single Accept, which means Keep Both. A zipped folder arrives as one `.zip` name, so the scan sees none of its members (`docs/known-issues.md`, Accepted). +The scan is asynchronous. `.incoming` is shown immediately with `conflicts: []`. `Self.scanConflicts` runs in `Task.detached` and stats off the main actor. The write-back happens only when the result is non-empty, only `if case .incoming`, and only if `transferGeneration` still matches the value captured at `.fileList`. -`startSend`'s up-to-200 `bookmarkData` calls run off the main actor the same way, writing back into `pendingRecord`. If a transfer somehow terminates first, `pendingRecord` is already nil and the record carries no bookmarks — the same outcome as the pre-existing all-or-nothing fallback, so "Send Again" hides rather than breaks. `SecurityScopedBookmark.create` is `nonisolated` for this. +The shape check alone is not enough: a scan can outlive a whole fast transfer and land on the next one's `.incoming`. A synchronous scan froze the accept UI on large file lists. -10k-file smoke tests pass in both directions: receive 427 s, send 358 s. +With conflicts, the sheet offers Decline, Replace and Keep Both (Keep Both prominent) and a banner. Without conflicts, or while the scan runs, it offers a single Accept, which means Keep Both. A zipped folder arrives as one `.zip` name, so the scan sees none of its members (`docs/known-issues.md`, "Accepted"). -## Receive links (R22) +`startSend`'s bookmark creation (up to 200 `bookmarkData` calls) runs off the main actor the same way and writes back into `pendingRecord`. If a transfer terminates first, `pendingRecord` is already nil and the record carries no bookmarks, so **Send Again** hides instead of breaking. `SecurityScopedBookmark.create` is `nonisolated` for this. -- `Models/TransferLink.swift` is the one parser. `TransferLink.parse(_:)` takes a bare code, `crocapp://receive?code=`, `croc://` (0.9.9 QR codes), `https://crocapp.dev/r/#` or `https://getcroc.com/?code=`, and yields `.code`. It also parses a stored-transfer link `https:///s/#v1.` (`http` only for localhost, 127.0.0.1 and ::1) into `.stored(URL)`; `ReceiveView` takes it (see "Stored transfers (R18-R21)"). `TransferLink.webURL(forCode:)` builds the `crocapp.dev/r/` link. -- `AppRouter.open(_:)` stores the link in `pendingReceive` and calls `openReceive()` unless `isBusy`; mid-transfer the link is kept and the user is not moved. `ReceiveView` consumes `pendingReceive` (`onChange(initial: true)` on the form) and fills `code`. Opening a link never starts a receive: the user taps Receive. -- Entry points, all through `TransferLink.parse` then `router.open`: the QR scan sheet (iOS), the Paste button, typed or pasted text containing `://` in the code field, `.onOpenURL` in `CrocAppApp` (iOS universal links and `crocapp://`), and `AppDelegate.application(_:open:)` on macOS (file URLs still go to Send). -- The `crocapp` scheme is in all three Info.plists (`CrocApp-Info.plist`, `CrocApp-macOS-Info.plist`, `CrocApp-Direct-Info.plist`). Universal links for `crocapp.dev/r/*` need the Associated Domains entitlement and `web/landing/.well-known/apple-app-site-association`. -- `TransferStatusView.waitingView` encodes the QR as `TransferLink.webURL(forCode:)` and adds a Share Link button. `browserReceiveNote` adds a caption when the active relay is custom or local-only (R23): croc's browser client knows only the default relays. +## Receive links + +- `Models/TransferLink.swift` is the one parser. `TransferLink.parse(_:)` takes a bare code, `crocapp://receive?code=`, `croc://` (the QR payload of older CrocApp builds), `https://crocapp.dev/r/#` or `https://getcroc.com/?code=`, and yields `.code`. +- It also parses a stored-transfer link `https:///s/#v1.` into `.stored(URL)`. `http` is accepted only for `localhost`, `127.0.0.1` and `::1`. `ReceiveView` takes it (see "Stored transfers"). +- `TransferLink.webURL(forCode:)` builds the `crocapp.dev/r/` link. +- `AppRouter.open(_:)` stores the link in `pendingReceive` and calls `openReceive()` unless `isBusy`. Mid-transfer the link is kept and the user is not moved. +- `ReceiveView` consumes `pendingReceive` (`onChange(initial: true)` on the form) and fills `code`. Opening a link never starts a receive: the user taps **Receive**. +- Entry points all go through `TransferLink.parse` and then `router.open`: the QR scan sheet (iOS), the **Paste** button, typed or pasted text containing `://` in the code field, `.onOpenURL` in `CrocAppApp` (iOS universal links and `crocapp://`), and `AppDelegate.application(_:open:)` on macOS (file URLs still go to Send). +- The `crocapp` scheme is in the iOS, macOS and Direct Info plists in `app/Config/`. +- Universal links for `crocapp.dev/r/*` need the Associated Domains entitlement (all three app entitlements files carry it) and `web/landing/.well-known/apple-app-site-association`, which lists both app IDs. +- `TransferStatusView.waitingView` encodes the QR as `TransferLink.webURL(forCode:)` and adds a **Share Link** button. +- `browserReceiveNote` adds a caption when the active relay is custom or local-only, because croc's browser client knows only the default relays. ## Platform layer, iOS -- `Platform/BackgroundCoordinator.swift` — cross-platform class with `#if os(iOS)` bodies (no-ops on macOS). Wraps a transfer in a `BGContinuedProcessingTask` and holds `isIdleTimerDisabled`. Controller hooks: `transferStarted` in `run()`, `progressChanged` inside the accepted `.progress` path only (the `.incoming` guard stays untouched), `transferEnded` at `.done` / `.failed` / startup catch, idempotent. Expiration sets `backgroundExpired` and cancels, producing dedicated "iOS paused the transfer…" copy. A generation token rejects stale late task launches. -- `Platform/LocalNetworkChecker.swift` — Bonjour self-probe on `_crocapp._tcp`, iOS only (the macOS branch is a no-op stub, `../known-issues.md`). `checkIfNeeded()` probes once per process, triggered by `ContentView.onChange(controller.isActive)`; `recheckIfDenied()` re-probes from `.denied` on `ContentView.onChange(scenePhase)` going `.active` (e.g. the user granted access in Settings and returned), gated so it never re-probes `.granted`/unresolved and never stacks a second in-flight probe. Denial shows a banner plus Open Settings in `TransferStatusView`. Denial only resolves at the 8 s timeout, because a first `.waiting` may just be the pending permission prompt. -- `Models/ShareInbox.swift` + `Views/StagedFilesSheet.swift` — App Group pickup of batches staged by the share extension (`ShareInbox/batch-/` plus `manifest.json`). The whole `scenePhase` refresh is gated on `!controller.isActive`; an ungated refresh once purged a live batch mid-send, caught by harness. The manifest is consumed on the user's decision, the batch files outlive the send, and `purgeStaleBatches` runs at idle. -- `app/CrocShare/` — iOS-only appex. The pbxproj entry is hand-built; `platformFilters=(ios,)` on the dependency and the embed keep macOS clean. File-copy staging happens inside the `loadFileRepresentation` handler because of the ~120 MB extension memory cap, and never wipes existing batches. Both `Info.plist` files live in `app/Config/`, outside the synced folders, to avoid a generated-plist collision; the app's `INFOPLIST_FILE` is sdk-scoped to iOS. -- Files-app visibility comes from `UIFileSharingEnabled` and `LSSupportsOpeningDocumentsInPlace`. **Those two do not work the same way, despite both being declared as sdk-scoped `INFOPLIST_KEY_*` settings in the pbxproj.** `LSSupportsOpeningDocumentsInPlace` is injected into the built `Info.plist` from the build setting alone; `UIFileSharingEnabled` is not, and only lands because `app/Config/CrocApp-Info.plist` also declares it literally. Measured, not inferred: delete the two plist lines and `plutil -p` on the built `CrocApp.app/Info.plist` shows `UIFileSharingEnabled` gone while `LSSupportsOpeningDocumentsInPlace`, `NSCameraUsageDescription` and `NSLocalNetworkUsageDescription` all survive — even though `xcodebuild -showBuildSettings` resolves `INFOPLIST_KEY_UIFileSharingEnabled = YES`. So the plist declaration is load-bearing and the pbxproj pair is inert for that one key. Do not "deduplicate" by deleting the plist lines; that silently removes the app from the Files app. "Open in Files" on receive-done uses the `shareddocuments://` scheme — community standard, no public API. +- `Platform/BackgroundCoordinator.swift` is a cross-platform class with `#if os(iOS)` bodies (no-ops on macOS). It wraps a transfer in a `BGContinuedProcessingTask` and holds `isIdleTimerDisabled`. +- Controller hooks: `transferStarted` in `run()`, `progressChanged` inside the accepted `.progress` path only (the `.incoming` guard stays untouched), and `transferEnded` at `.done`, `.failed` and the startup `catch`. `transferEnded` is idempotent. +- Expiration sets `backgroundExpired` and cancels, which produces the dedicated "iOS paused the transfer…" copy. A generation token rejects stale late task launches. +- `Platform/LocalNetworkChecker.swift` is cross-platform. It is a Bonjour self-probe on `_crocapp._tcp`: seeing its own service means access is granted, and a browser stuck in `.waiting` means denied. +- `checkIfNeeded()` probes once per process, triggered by `ContentView.onChange(controller.isActive)`. +- `recheckIfDenied()` re-probes from `.denied` when `scenePhase` goes `.active` (for example after the user granted access in Settings). It never re-probes `.granted` or unresolved states and never stacks a second in-flight probe. +- Denial shows a banner plus **Open Settings** in `TransferStatusView`. Denial only resolves at the 8 s timeout, because a first `.waiting` may just be the pending permission prompt. +- Testing a macOS denial is awkward (`docs/known-issues.md`, "Accepted"). +- `Models/ShareInbox.swift` and `Views/StagedFilesSheet.swift` pick up batches staged by the share extension from the App Group (`ShareInbox/batch-/` plus `manifest.json`). The manifest is consumed on the user's decision, the batch files outlive the send, and `purgeStaleBatches` runs at idle. +- The whole `scenePhase` refresh is gated on `!controller.isActive`. An ungated refresh once purged a live batch mid-send. +- `app/CrocShare/` is an iOS-only app extension. The pbxproj entry is hand-built, and `platformFilter = ios` on the dependency and the embed keeps macOS clean. +- File-copy staging happens inside the `loadFileRepresentation` handler, because of the roughly 120 MB extension memory cap. It never wipes existing batches. +- The Info plists live in `app/Config/`, outside the synced folders, to avoid a generated-plist collision. The app's `INFOPLIST_FILE` is sdk-scoped. +- Files-app visibility comes from `UIFileSharingEnabled` and `LSSupportsOpeningDocumentsInPlace`. **The two do not behave the same way**, although both are declared as sdk-scoped `INFOPLIST_KEY_*` settings in the pbxproj. +- `LSSupportsOpeningDocumentsInPlace` is injected into the built `Info.plist` from the build setting alone. `UIFileSharingEnabled` is not, and only lands because `app/Config/CrocApp-Info.plist` also declares it literally. +- Measured with Xcode: delete the two plist lines and `plutil -p` on the built `CrocApp.app/Info.plist` shows `UIFileSharingEnabled` gone, while `LSSupportsOpeningDocumentsInPlace`, `NSCameraUsageDescription` and `NSLocalNetworkUsageDescription` survive. `xcodebuild -showBuildSettings` still resolves `INFOPLIST_KEY_UIFileSharingEnabled = YES`. +- So the plist declaration is load-bearing and the pbxproj key is inert for that one setting. Do not deduplicate by deleting the plist lines: that silently removes the app from the Files app. +- **Open in Files** on the receive-done screen uses the `shareddocuments://` scheme, which is a community convention with no public API. The button is shown only when the receive folder is the app's own default folder, because the scheme resolves only paths inside the app container. ## Platform layer, macOS -- `Models/AppRouter.swift` — `@Observable` singleton (`shared`). `path: [Route]` drives `HomeView`'s `NavigationStack`. `pendingSendURLs` is consumed by `SendView` (`onAppear` plus `onChange`, deduplicated against `pickedURLs`, then cleared). `isBusy` is a computed read of `controller.isActive` through a weak controller reference that `CrocAppApp.init` attaches with `attach(controller:)`, so a dock drop mid-transfer queues without navigating. It is a singleton because `AppDelegate` and `Commands` sit outside the environment graph. -- Three drop entry points, one guard policy: the window drop (`ContentView`, macOS-only) refuses while active and filters `\.isFileURL`; the `SendView` list drop filters too; the dock drop (`Platform/AppDelegate.swift`, `application(_:open:)`) always queues and navigates only when idle. -- Dock drop works because `Config/CrocApp-macOS-Info.plist` declares `public.item` / Viewer / rank None document types. Side effect: the app is listed in Finder's "Open With" for everything, and `INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace[sdk=macosx*]=YES` is required or Xcode auto-injects an invalid `NO`. -- `Views/SettingsView.swift` — a General tab (output-folder change/reset plus Show in Finder, the power sections) and an Advanced tab. `Views/AppCommands.swift` — Send ⌘1, Receive ⌘2 (disabled while active), Show Receive Folder ⇧⌘R. Window: `.defaultSize(560×700)`, content `minWidth 480` / `minHeight 560`. -- `CrocApp Direct` only (`#if DIRECT`, `app/CrocAppDirect/AppUpdater.swift`): a "Check for Updates…" item in the app menu right after "About CrocApp", backed by Sparkle's `SPUStandardUpdaterController`. The App Store target has no such item. -- Default output folder is `~/Downloads/CrocApp`, needing the `files.downloads.read-write` entitlement; the folder is auto-created in `defaultFolder` and `defaultDisplayName` supplies the UI label. Receive-done offers "Show in Finder" via `NSWorkspace.activateFileViewerSelecting`. -- Distribution: `scripts/build-appstore.sh [--upload] [ios|macos]` archives `CrocApp` and exports or uploads the iOS and Mac App Store builds with `Config/ExportOptions-{iOS,MAS}.plist` (automatic cloud signing). `scripts/build-devid.sh` archives the `CrocApp Direct` scheme with `ARCHS=arm64` and manual Developer ID signing, exports with `Config/ExportOptions-DevID.plist`, notarizes and staples the app, then builds, signs, notarizes and staples the DMG (it exits 1 without a Developer ID identity). `Config/ExportOptions-{iOS,MAS,DevID}.plist` carry the per-channel settings — no extra pbxproj configurations. +- `Models/AppRouter.swift` is an `@Observable` singleton (`shared`). `path: [Route]` drives `HomeView`'s `NavigationStack`. +- `pendingSendURLs` is consumed by `SendView` (`onAppear` plus `onChange`, deduplicated against `pickedURLs`, then cleared). +- `isBusy` reads `controller.isActive` through a weak controller reference that `CrocAppApp.init` attaches with `attach(controller:)`, so a dock drop mid-transfer queues without navigating. +- It is a singleton because `AppDelegate` and `Commands` sit outside the environment graph. +- Three drop entry points, one guard policy: + - The window drop (`ContentView`, macOS only) refuses while active and filters `\.isFileURL`. + - The `SendView` list drop filters too. + - The dock drop (`Platform/AppDelegate.swift`, `application(_:open:)`) always queues and navigates only when idle. +- The dock drop works because `Config/CrocApp-macOS-Info.plist` declares `public.item` document types with the Viewer role and rank None. +- Side effect: the app is listed in Finder's "Open With" for everything. `INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace[sdk=macosx*]=YES` is required, or Xcode auto-injects an invalid `NO`. +- `Views/SettingsView.swift` has a **General** tab (output-folder **Change…** and **Reset**, **Show in Finder**, the menu bar section, the power sections, About) and an **Advanced** tab. +- `Views/AppCommands.swift` provides **Send…** ⌘1 and **Receive…** ⌘2 (both disabled while a transfer is active), and the Show Receive Folder item ⇧⌘R. +- Window: `.defaultSize(560×700)`, content `minWidth 480` and `minHeight 560`. +- `CrocApp Direct` only (`#if DIRECT`, `app/CrocAppDirect/AppUpdater.swift`): a **Check for Updates…** item in the app menu right after **About CrocApp**, backed by Sparkle's `SPUStandardUpdaterController`. The App Store target has no such item. +- The default output folder is `~/Downloads/CrocApp`. It needs the `files.downloads.read-write` entitlement. The folder is auto-created in `defaultFolder`, and `defaultDisplayName` supplies the UI label. The receive-done screen offers **Show in Finder** via `NSWorkspace.activateFileViewerSelecting`. +- Build and release scripts are described in `docs/RELEASING.md`. ## Onboarding and store compliance -- `OnboardingView` is a sheet from `ContentView.task`, gated on `!onboardingSeen && !AutoVerify.isHarnessRun`; `onboarding.seen` is written in `onDismiss`. The staged-files sheet yields to it (`&& !showOnboarding`) and is re-offered when onboarding dismisses. -- Onboarding also primes the two system permission prompts it now names in its copy: `onDismiss` (iOS only) awaits `CameraPermission.requestIfNeeded()` then calls `localNetwork.checkIfNeeded()`, sequenced rather than simultaneous so the user never sees two system prompts stack. Camera and local-network are never requested during an `AutoVerify` harness run. -- `Platform/CameraPermission.swift` (iOS only) wraps `AVCaptureDevice.authorizationStatus/requestAccess(for: .video)`. `QRScannerSheet` resolves its own authorization first, as a separate gate from `DataScannerViewController.isSupported`, and renders distinct copy for all four `AVAuthorizationStatus` cases: `.authorized` shows the scanner, `.notDetermined` requests then re-reads status, `.denied` offers an Open Settings button, `.restricted` does not (Settings cannot fix a restriction). -- `PrivacyInfo.xcprivacy` sits in both synced target folders — app declares UserDefaults CA92.1, FileTimestamp C617.1 + 3B52.1 and SystemBootTime 35F9.1 (the Go engine's `stat` family and `mach_absolute_time` imports), extension declares an empty accessed-API list, both declare no tracking and no collection. Bundle inclusion was verified in the build products. -- `ITSAppUsesNonExemptEncryption=false` in both Config plists: croc bundles its own AES-256-GCM/PAKE rather than calling Apple OS crypto, but its source is public, so the app relies on the open-source exemption (EAR 742.15(b), owner decision for 1.0). `true` without an `ITSEncryptionExportComplianceCode` made App Store Connect refuse the first upload (2026-10-14). To be confirmed before Submit for Review, and France left out of 1.0 if App Store Connect requires the French declaration (`../known-issues.md`, Blocking release). -- Two `.glassEffect()` call sites: the code card in `waitingView`, and the macOS-only "Drop to send" full-window overlay in `ContentView` (design/components.md → DropZone macOS overlay). +- `OnboardingView` is a sheet from `ContentView.task`. It is shown when `!onboardingSeen && !AutoVerify.isHarnessRun`, or when a harness run asks for it with `--screen onboarding` (`AutoVerify.forcesOnboarding`). `onboarding.seen` is written in `onDismiss`. +- The staged-files sheet yields to onboarding (`&& !showOnboarding`) and is offered again when onboarding dismisses. +- Onboarding also primes the two system permission prompts its copy names. On iOS, `onDismiss` awaits `CameraPermission.requestIfNeeded()` and then calls `localNetwork.checkIfNeeded()`. They run in sequence so the user never sees two system prompts stacked. Neither is requested during an `AutoVerify` harness run. +- `Platform/CameraPermission.swift` (iOS only) wraps `AVCaptureDevice.authorizationStatus` and `requestAccess(for: .video)`. +- `QRScannerSheet` resolves its own authorization first, as a gate separate from `DataScannerViewController.isSupported`, and renders distinct copy for all four `AVAuthorizationStatus` cases. `.authorized` shows the scanner. `.notDetermined` requests access, then re-reads the status. `.denied` offers **Open Settings**. `.restricted` does not, because Settings cannot fix a restriction. +- `PrivacyInfo.xcprivacy` sits in both synced target folders. The app declares UserDefaults CA92.1, FileTimestamp C617.1 and 3B52.1, and SystemBootTime 35F9.1 (the Go engine's `stat` family and `mach_absolute_time` imports). The extension declares an empty accessed-API list. Both declare no tracking and no collection. +- `ITSAppUsesNonExemptEncryption` is set in `CrocApp-Info.plist` (iOS) and `CrocApp-macOS-Info.plist`, the two plists App Store builds use. The value, the reason and the open decisions are in `docs/RELEASING.md`, "Export compliance". +- Two `.glassEffect()` call sites exist: the code card in `CodePhraseDisplay`, which `waitingView` uses, and the macOS-only **Drop to Send** full-window overlay in `ContentView` (`design/components.md`, "DropZone"). ## SwiftUI and Xcode API facts -Verified against Xcode 26, not inferred: +Checked against the Xcode 26 SDK: -- `fileImporter` **without** `allowsMultipleSelection:` gives the completion a `Result`; **with** it — at any value — a `Result<[URL], Error>`. Code that mixes the two does not compile. +- `fileImporter` without `allowsMultipleSelection:` gives the completion a `Result`. With it, at any value, the completion gets a `Result<[URL], Error>`. Code that mixes the two does not compile. - `PasteButton`'s closure is `nonisolated`. Hop to the main actor before touching `@State`. -- `.qr` symbology needs `import Vision` alongside VisionKit. `DataScannerViewController.isSupported` is `false` on the simulator, so the sheet shows a fallback; `startScanning` failures surface through `onStartFailure` as "Camera unavailable". -- QR generation: `CIFilter.qrCodeGenerator` with a static shared `CIContext`, cache the `CGImage` in `@State` keyed by `.task(id: content)`, render with `Image(decorative:scale:).interpolation(.none)`. +- The `.qr` symbology needs `import Vision` alongside VisionKit. +- `DataScannerViewController.isSupported` is `false` on the simulator, so the sheet shows a fallback. `startScanning` failures surface through `onStartFailure` as "Camera unavailable". +- QR generation: `CIFilter.qrCodeGenerator` with a static shared `CIContext`. Cache the `CGImage` in `@State` keyed by `.task(id: content)`, and render it with `Image(decorative:scale:).interpolation(.none)`. - Using a type across files in the same module still needs a per-file `import CrocKit`. -- The camera key is `INFOPLIST_KEY_NSCameraUsageDescription`, and it must be in both pbxproj configurations. -- `app/CrocApp/AppIcon.icon` is an Icon Composer bundle; it is picked up by synced-folder auto-include as long as its name matches `ASSETCATALOG_COMPILER_APPICON_NAME`, and compiles for both platforms with no pbxproj edit. +- The camera key is `INFOPLIST_KEY_NSCameraUsageDescription`. It must be set in every app-target build configuration (CrocApp and CrocApp Direct, Debug and Release). +- `app/CrocApp/AppIcon.icon` is an Icon Composer bundle. Synced-folder auto-include picks it up as long as its name matches `ASSETCATALOG_COMPILER_APPICON_NAME`. It compiles for both platforms with no pbxproj edit. ## AutoVerify harness -Launch arguments that drive the real controller, not the engine directly — the point is that the verified path is the one users take. +Launch arguments drive the real controller, not the engine directly, so the verified path is the one users take. -`AutoVerify`'s bodies are `#if DEBUG`; in Release, `isHarnessRun` is a hardcoded `false` and `runIfRequested` is a no-op, so the harness itself cannot run in a store build. The type still exists in Release because `CrocAppApp.swift` and `ContentView.swift` call it unconditionally. The verify scripts build Debug, so they are unaffected. +`AutoVerify.swift`'s header comment lists every flag. Keep it complete when adding one. Behaviours the header does not spell out: -| Argument | Effect | -|---|---| -| `--auto-receive CODE` | receive into Documents, auto-accept via `respond(true)` on `.incoming` unless `--hold` is passed | -| `--when-exists keepBoth\|replace` | the file policy `AutoVerify` answers the incoming sheet with (the setting stays Keep Both, so the late answer must reach croc); default Keep Both | -| `--auto-send PATH --code CODE` | send with a custom code; on macOS the source must be container-resident (sandbox). Needs `--code`: without it nothing happens | -| `--auto-share-send CODE` | read the share inbox, then custom-code send | -| `--local` | sets `settings.onlyLocal` unpersisted (croc `onlyLocal`) | -| `--transport auto\|relay` | sets `settings.transport` unpersisted (sender-side Connection, F41); an unknown value is ignored | -| `--relay ADDR` | custom relay, plus `harnessDisableLocal` to kill the LAN race | -| `--no-compress` | disable compression | -| `--ask` | sets `settings.bothSidesConfirm` unpersisted (sender-side confirm, F19). The `.confirmSend` auto-answer happens in every harness mode, with or without this flag | +- The header does not list `--transport auto|relay`. It sets `settings.transport` unpersisted (sender-side Connection). An unknown value is ignored. +- `--auto-send PATH --code CODE` needs both flags. Without `--code`, nothing happens. +- On macOS the `--auto-send` source must be container-resident (sandbox). +- The `.confirmSend` auto-answer happens in every harness mode, with or without `--ask`. +- `--relay ADDR` also sets `harnessDisableLocal`, to kill the LAN race. +- `--when-exists` is the policy `AutoVerify` answers the incoming sheet with. The setting itself stays Keep Both, so the late answer must reach croc. +- The screenshot flags (`--screen`, `--stage`, `--seed-history`, `--hold`) are documented in `screenshots.md`. + +`AutoVerify`'s bodies are `#if DEBUG`. In Release, `isHarnessRun` is a hardcoded `false` and `runIfRequested` is a no-op, so the harness cannot run in a store build. The type still exists in Release because `CrocAppApp.swift` and `ContentView.swift` call it unconditionally. The verify scripts build Debug. -Settings overrides apply in any harness run (`isHarnessRun` is true for `--auto-receive`, `--auto-send`, `--auto-share-send` or `--screen`) and start from `resetToDefaults()`, with `persist = false` set first so real `UserDefaults` are never touched. +`isHarnessRun` is true for any `--auto-*` mode or `--screen`. Settings overrides apply in any harness run. They start from `resetToDefaults()`, with `persist = false` set first, so real `UserDefaults` are never touched. -The screenshot flags (`--screen`, `--stage`, `--seed-history`, `--hold`) are documented in `docs/knowledge/screenshots.md`. +Result files, all written to the container's Documents folder (hardcoded, independent of `OutputFolderStore.defaultFolder`): + +| File | Content | +|---|---| +| `verify-result.txt` | `ok success=` or `error ` | +| `verify-code-ready.txt` | `ready`, written once when the phase first reaches `.waiting` (a send has its code); both verify scripts wait for it | +| `verify-code.txt` | the generated or custom code | +| `verify-history.txt` | `records=N`, written when the transfer is done or failed (`scripts/verify-app-mac.sh` reads it) | +| `verify-phase.txt` | the current phase label | +| `verify-ready.txt` | written once a `--screen` request has settled | -The harness writes `verify-result.txt` (`ok success=` or `error `) to Documents. It also writes `verify-code-ready.txt` (contents `ready`, written once, when the phase first reaches `.waiting`, i.e. a send has its code; `verify-app-sim.sh` and `verify-app-mac.sh` wait for it) and `verify-history.txt` (`records=N`, written when the transfer is done or failed; `verify-app-mac.sh` reads it). The result contract is shared by `scripts/verify-app-sim.sh` and `verify-app-mac.sh`. AutoVerify's Documents path is hardcoded, decoupled from `OutputFolderStore.defaultFolder`. +The result contract is shared by `scripts/verify-app-sim.sh` and `scripts/verify-app-mac.sh`. Launch gotchas, both macOS: - Once `CFBundleDocumentTypes` exists, bare adjacent argv tokens are document-open candidates and AppKit skips creating the default window. Use flag-style arguments only. - Scripted launches need `-ApplePersistenceIgnoreState YES` or they hang in window restoration. -On the CLI side of a verify run, a receiving `croc` needs `CROC_SECRET=` in the environment rather than a positional code: croc refuses custom positional codes in non-classic mode for receive as well as send. +On the CLI side of a verify run, a receiving `croc` needs `CROC_SECRET=` in the environment instead of a positional code. croc refuses custom positional codes in non-classic mode for receive as well as send. diff --git a/docs/knowledge/apple-platform-constraints.md b/docs/knowledge/apple-platform-constraints.md index fcd7b7d..c97a22c 100644 --- a/docs/knowledge/apple-platform-constraints.md +++ b/docs/knowledge/apple-platform-constraints.md @@ -1,27 +1,78 @@ # Apple platform constraints -Apple-side rules the app has to live inside, and what each one cost to establish. Research digest 2026-07-22, items marked **verified** were then proven in code. Training data lags Apple releases — re-check any API here against current docs (context7/web) before writing against it. +Apple-side rules the app has to live inside. Each item says how it was checked: + +- **Verified in this repo**: proven by code or a build here. +- **From Apple docs, not re-checked**: taken from Apple documentation. Apple changes these, so re-read the current docs (context7 or the web) before you write code against them. ## Hard constraints -1. **iOS background = raw TCP dies.** No background mode for arbitrary sockets; background URLSession is HTTP-only. Lifeline: `BGContinuedProcessingTask` (iOS 26+, not macOS) — user-initiated in foreground, system Live Activity progress UI mandatory, `Progress` reporting mandatory, identifier in `BGTaskSchedulerPermittedIdentifiers`, best-effort runtime (killed under pressure, low-progress killed first, field reports of unpredictable expiry). Transfers must be resumable + checkpoint on expiry. Foreground: use `isIdleTimerDisabled`. **Verified:** system renders the Live Activity itself (title/subtitle/progress/cancel — custom ActivityKit widget redundant); NO `UIBackgroundModes` needed; permitted-identifiers wildcard (`prefix.*`) supported for continued-processing; handlers register dynamically right before submit (no didFinishLaunching constraint); simulator `submit` always throws (foreground-only fallback mandatory). Implementation: `BackgroundCoordinator`. -2. **croc LAN discovery = UDP multicast** (schollz/peerdiscovery) → needs restricted entitlement `com.apple.developer.networking.multicast` on iOS. Apply via Apple form (~days-2 weeks, per-team, must be enabled per profile type incl. TestFlight). Alternative: Bonjour (`NWBrowser`/`NWListener`) needs no entitlement but stock croc CLI peers stay undiscoverable (protocol mismatch). Without entitlement croc's relay path still works — LAN race just loses. -3. **Share extension:** ~120 MB memory cap, no long-running work, no BGContinuedProcessingTask. Pattern: `NSItemProvider.loadFileRepresentation` → stream-copy to App Group container → main app runs transfer. -4. **iOS cannot spawn processes** → croc linked as library only. -5. **Mac App Store = sandbox mandatory:** `com.apple.security.network.client` + `.server` (server needed for local relay listener), plus `files.user-selected.read-write` and `files.downloads.read-write` (default output folder `~/Downloads/CrocApp`). Direct DMG: notarization + hardened runtime, sandbox optional. -6. **Wi-Fi Aware** (iOS/iPadOS 26): AirDrop-class direct Wi-Fi, absent on macOS 26 → optional iOS↔iOS fast path only, never primary transport. +### iOS background execution + +- Raw TCP stops when the app is backgrounded. iOS has no background mode for arbitrary sockets, and background `URLSession` is HTTP only. (From Apple docs, not re-checked.) +- The lifeline is `BGContinuedProcessingTask` (iOS 26 and later, not macOS). The user starts it from the foreground, a system Live Activity is mandatory, `Progress` reporting is mandatory, and the identifier must be in `BGTaskSchedulerPermittedIdentifiers`. Runtime is best effort: the system kills tasks under pressure, low-progress ones first. (From Apple docs, not re-checked.) +- A transfer must therefore be resumable, and checkpoint when the task expires. +- In the foreground, set `isIdleTimerDisabled`. +- Verified in this repo: the system draws the Live Activity itself (title, subtitle, progress, cancel), so a custom ActivityKit widget is redundant. +- Verified in this repo: no `UIBackgroundModes` key is needed. +- Verified in this repo: `BGTaskSchedulerPermittedIdentifiers` accepts a wildcard (`prefix.*`) for continued-processing tasks. +- Verified in this repo: the handler registers dynamically just before submit, so nothing is needed in `didFinishLaunching`. +- Verified in this repo: the simulator always throws on `submit`, so a foreground-only fallback is mandatory. +- The code is `BackgroundCoordinator`. + +### LAN discovery and multicast + +- croc finds LAN peers with UDP multicast (schollz/peerdiscovery). On iOS that needs the restricted entitlement `com.apple.developer.networking.multicast`, which Apple grants per team and per profile type, TestFlight included. (From Apple docs, not re-checked.) +- The entitlement has been requested and is pending. The iOS entitlements file does not have it yet, so 1.0 ships without it, and the iOS multicast setting is hidden. +- Bonjour (`NWBrowser`, `NWListener`) needs no entitlement, but the stock croc command line cannot discover a Bonjour peer because the protocols differ. +- Without the entitlement the relay path still works. Only the LAN race is lost. + +### Other hard limits + +- **Share extension**: about 120 MB memory, no long-running work, no `BGContinuedProcessingTask`. The pattern is `NSItemProvider.loadFileRepresentation`, a stream copy into the App Group container, then the main app runs the transfer. (From Apple docs, not re-checked.) +- **No process spawning on iOS**, so croc is linked as a library only. +- **Mac App Store requires the sandbox.** The app uses `com.apple.security.network.client` and `.server` (the server entitlement is for the local relay listener), plus `files.user-selected.read-write` and `files.downloads.read-write`. Verified in this repo (`app/CrocApp/CrocApp.entitlements`). The direct download needs notarization and the hardened runtime; the sandbox is optional there. +- **Wi-Fi Aware** (iOS and iPadOS 26) gives AirDrop-class direct Wi-Fi but does not exist on macOS 26. It could only be an optional iOS-to-iOS fast path, never the main transport. (From Apple docs, not re-checked.) ## Papercuts -- Local network privacy prompt (`NSLocalNetworkUsageDescription` + `NSBonjourServices` required in Info.plist): triggers on first LAN op; relay traffic (WAN) doesn't trigger it. Denial fails silently; known flakiness (no re-prompt after reinstall, granted-but-broken until toggle/reboot) → ship pre-flight/diagnostics UX. TN3179 is the authority. **Verified:** NO official authorization-check API exists (incl. iOS 26); detection = Bonjour self-probe (`LocalNetworkChecker`) — browser stuck `.waiting` until timeout ⇒ denied; the probe itself raises the prompt, so never treat first `.waiting` as denial. -- Security-scoped URLs from `fileImporter`: `startAccessingSecurityScopedResource`, keep bookmarks for resume-across-launch. -- Received files visible in Files app: `UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace`. -- gomobile breaks occasionally on new Xcode majors (history: golang issues #53316, #55028, #66500) — budget fiddling time. Status 2026-10: Xcode 27.0 and 27.1 + Go 1.27.2 + x/mobile 20261011 bound clean; macOS slice must be arm64-only (#73119); #66500 (device framework layout vs App Store validation) closed and the first App Store upload passed (2026-10-14, `../BUILDING.md` section 7). -- BGContinuedProcessingTask lowers QoS in background → slower transfers, fine. -- Liquid Glass: standard SwiftUI controls restyle free on Xcode 26; custom chrome needs `.glassEffect()`; compatibility opt-out flag dies next major OS. -- New in 26, prefer: Network framework Swift API (`NetworkConnection`/`NetworkListener`/`NetworkBrowser`, structured concurrency) over NWConnection callbacks. -- **Verified (macOS):** declaring `CFBundleDocumentTypes` makes AppKit treat bare launch argv tokens as document-open candidates (two adjacent bare args ⇒ no default window; harness must use flag-style args) and makes Xcode auto-inject `LSSupportsOpeningDocumentsInPlace = NO` (build-invalid — override with `INFOPLIST_KEY_...[sdk=macosx*] = YES`). Headless/scripted app launches hang in window restoration without `-ApplePersistenceIgnoreState YES`. `xcodebuild archive` for generic macOS ignores `ONLY_ACTIVE_ARCH` — pass `ARCHS=arm64` while the xcframework macOS slice is arm64-only (#73119). Sandbox `network.server` verified: croc's local relay listener binds TCP 9009 in-sandbox (lsof-proven, `MAC-LOCAL-SEND-OK`). `syspolicy_check distribution` = notarization pre-check without submitting. +### Local network permission + +- Needs `NSLocalNetworkUsageDescription` and `NSBonjourServices` in `Info.plist`. The prompt appears on the first LAN operation. Relay (WAN) traffic does not trigger it. Apple's TN3179 is the authority. +- Denial fails silently, and the OS has known flakiness: no re-prompt after reinstall, or "granted" yet broken until a toggle or reboot. The app needs a pre-flight check and diagnostics. (From Apple docs, not re-checked.) +- Verified in this repo: no official API reports the authorization state, including on iOS 26. `LocalNetworkChecker` runs a Bonjour self-probe instead: a browser stuck in `.waiting` until timeout means denied. +- The probe itself raises the prompt, so never treat the first `.waiting` as a denial. + +### Files and storage + +- Files from `fileImporter` are security-scoped URLs. Call `startAccessingSecurityScopedResource`, and keep bookmarks to resume across launches. +- Received files show in the Files app only with `UIFileSharingEnabled` and `LSSupportsOpeningDocumentsInPlace`. + +### Build tooling + +- gomobile breaks now and then on new Xcode major versions. Budget time for it. +- Verified in this repo: Xcode 27.0 and 27.1 with Go 1.27.2 build cleanly. The macOS slice of the xcframework must be arm64 only (golang/go#73119). The device framework layout problem (golang/go#66500) is fixed in the x/mobile version pinned here, and App Store Connect accepted the upload (`../RELEASING.md`, "App Store builds"). + +### macOS app behavior (verified in this repo) + +- Declaring `CFBundleDocumentTypes` makes AppKit treat bare launch arguments as documents to open. Two adjacent bare arguments leave the app without a default window, so test harnesses must pass flag-style arguments. +- Declaring `CFBundleDocumentTypes` also makes Xcode inject `LSSupportsOpeningDocumentsInPlace = NO`, which fails validation. Override it with `INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace[sdk=macosx*] = YES`. +- A scripted launch hangs in window restoration unless you pass `-ApplePersistenceIgnoreState YES`. +- `xcodebuild archive` for generic macOS ignores `ONLY_ACTIVE_ARCH`. Pass `ARCHS=arm64` while the xcframework macOS slice is arm64 only. +- With the sandbox on, croc's local relay listener binds TCP 9009 (confirmed with `lsof`). +- `syspolicy_check distribution` checks notarization readiness without submitting. + +### Other + +- `BGContinuedProcessingTask` lowers QoS in the background, so transfers run slower. That is acceptable. +- Liquid Glass: standard SwiftUI controls restyle for free on the Xcode 26 and later SDKs. Custom chrome needs `.glassEffect()`. The compatibility opt-out flag goes away in the next major OS. (From Apple docs, not re-checked.) +- The iOS 26 Network framework has a Swift API (`NetworkConnection`, `NetworkListener`, `NetworkBrowser`) that is preferred over `NWConnection` callbacks. (From Apple docs, not re-checked.) ## App Store review -Category proven: iCroc (croc client, iOS+macOS store), Destiny, LocalSend, wormhole-william-mobile. Go runtime fine. Residual low risks: explain code-phrase model + default public relay in review notes; avoid implying official croc endorsement without schollz blessing (naming/branding, guideline 5.2). +Other file-transfer apps built on the same ideas (croc and wormhole clients, LocalSend) are on the App Store, and an embedded Go runtime is allowed. This is inferred from those apps, not from a ruling by Apple. + +Two low risks remain: + +- Explain the code phrase model and the default public relay in the review notes (`app-store-listing.md`, "Review notes"). +- Do not imply that croc's author endorses the app. Naming and branding fall under guideline 5.2. diff --git a/docs/knowledge/croc-upgrade-playbook.md b/docs/knowledge/croc-upgrade-playbook.md index fed9b5c..1ad8bdc 100644 --- a/docs/knowledge/croc-upgrade-playbook.md +++ b/docs/knowledge/croc-upgrade-playbook.md @@ -1,30 +1,39 @@ # croc upgrade playbook -How to bump the pinned croc version (go.mod). Run this as one session whenever upstream releases. +How to bump the pinned croc version in `crocmobile/go.mod`. Run it as one session whenever upstream releases. **Pinned: v11.5.4.** ## Steps -1. **Read upstream delta**: changelog/release notes + `git diff vOLD..vNEW` of `src/croc/croc.go`, `src/croc/ctx.go`, `src/cli/cli.go`, `src/models/constants.go`, `src/utils/utils.go`, `src/codephrase`, `src/publicrelay`, `src/pakekey`. Look specifically for: - - New callback/hook APIs for confirm prompts or output → may obsolete the fd-0 prompt pipe (the shim shrinks to nothing then) - - `Options` struct field changes (new flags → new `crocmobile.Options` fields; renamed/removed → compile errors, good) - - Changes to `Client` progress fields our poller reads (`TotalSent`, step bools, `FilesToTransfer`, `FilesToTransferCurrentNum`) - - Prompt sites (`utils.GetInputContext` callers) added/removed — each maps to a workaround in `docs/knowledge/crocmobile-bridge.md` - - Wire/protocol changes (major version bump = incompatible peers) -2. **Cross-check every documented workaround** in `crocmobile-bridge.md` §"croc v11.5.4 gotchas" (rename the heading to the new version) against the delta: for each, decide keep / delete / adapt. Delete obsolete shims — do not leave dead workarounds. -3. **Bump**: `cd crocmobile && go get github.com/schollz/croc/v11@vNEW && go mod tidy` (a new major changes the `/vN` import path everywhere); then `brew upgrade croc` so the test CLI matches the pin (`brew install croc` on a fresh machine). The harnesses default `CROC` to `${HOMEBREW_PREFIX:-/opt/homebrew}/bin/croc`. -4. **Build**: `go build ./... && go vet ./...`, then `./scripts/build-xcframework.sh`, then `cd CrocKit && swift build`, then app build both platforms. -5. **Verify**, in this order — each one covers ground the previous does not: - - `crockit-verify twice` — Swift layer, and the two-transfer case proves fd0/stdout/cwd/mutex restoration still composes - - `./scripts/verify-interop.sh` — all 15 crocmobile↔CLI scenarios - - `./scripts/verify-app-mac.sh` — the 12 macOS app scenarios; the widest coverage of the option surface - - `./scripts/verify-app-sim.sh` — iOS simulator receive and share-extension handoff - - Any failure → root-cause it against the upstream delta before touching wrapper semantics. -6. **Docs self-heal**: update `crocmobile-bridge.md`, `what-is-croc.md` (version header + changed facts), and this file's pinned-version line. +1. **Read the upstream delta.** Read the changelog and release notes, then `git diff vOLD..vNEW` of these paths: `src/croc/croc.go`, `src/croc/ctx.go`, `src/cli/cli.go`, `src/models/constants.go`, `src/utils/utils.go`, `src/codephrase`, `src/publicrelay`, `src/pakekey`. Look for: + - New callback or hook APIs for confirm prompts or output. They may make the fd 0 prompt pipe obsolete (the shim would shrink to nothing). + - `Options` struct changes. New flags need new `crocmobile.Options` fields. Renamed or removed fields fail to compile, which is good. + - Changes to the `Client` progress fields the poller reads: `TotalSent`, the step booleans, `FilesToTransfer`, `FilesToTransferCurrentNum`. + - Prompt sites (`utils.GetInputContext` callers) added or removed. Each one maps to a workaround in `crocmobile-bridge.md`. + - Wire or protocol changes. A major version bump means incompatible peers. +2. **Cross-check every documented workaround.** Take each one in `crocmobile-bridge.md`, "croc v11.5.4 gotchas (verified against source)", and decide keep, delete or adapt against the delta. Delete obsolete shims; do not leave dead workarounds. Rename that heading to the new version in step 6. +3. **Bump.** + - Run `cd crocmobile && go get github.com/schollz/croc/v11@vNEW && go mod tidy`. A new major changes the `/vN` import path everywhere. + - Then run `brew upgrade croc` so the test CLI matches the pin (`brew install croc` on a fresh machine). The harnesses default `CROC` to `${HOMEBREW_PREFIX:-/opt/homebrew}/bin/croc`. +4. **Build.** Run `go build ./... && go vet ./...` in `crocmobile/`, then `./scripts/build-xcframework.sh`, then `cd CrocKit && swift build`, then build the app for both platforms. +5. **Verify**, in this order. Each one covers ground the previous one does not (`BUILDING.md`, "Verification harnesses" has the commands): + - `crockit-verify twice`: the Swift layer. The two-transfer case proves the fd 0, stdout, cwd and mutex restoration still composes. + - `./scripts/verify-interop.sh`: all 15 crocmobile to CLI scenarios. + - `./scripts/verify-app-mac.sh`: the 12 macOS app scenarios. This is the widest coverage of the option surface. + - `./scripts/verify-app-sim.sh`: iOS simulator receive and the share-extension handoff. + + Any failure: find the root cause against the upstream delta before touching wrapper semantics. +6. **Update docs.** + - Run `git grep -n "v11.5.4"` (the old version string) and update every hit. The hits include this file's "Pinned" line, `what-is-croc.md` (the version header and any changed facts), `crocmobile-bridge.md` (the version in the layers diagram, the "croc v11.5.4 gotchas" heading and the Advanced options text), `known-issues.md`, and comments in `crocmobile/`. + - Also check any other file the grep turns up, and update facts that changed even where the version string is absent. + - Check the names in `known-issues.md` and `docs/GLOSSARY.md` that the delta touched. + - Run `scripts/verify-docs.sh`. ## Notes -- A new major needs both sides upgraded. v10 and v11 peers cannot complete a transfer: v11 rejects a PAKE message that lacks protocol version 2 (`pakekey.ProtocolVersion`), and v10.5.0's message type has no version field. The peers often never meet (different room derivation and default relay), so `peerVersion` fires only on a shared room. `~/go/bin/croc` v10.5.0 is kept only as `CROC_V10` for the v10-peer message test. Coordinate app release timing with upstream adoption. -- Watch croc's README/issues for a callback-based embedder API; that is the single change that most simplifies crocmobile. +- A new major needs both sides upgraded. v10 and v11 peers cannot complete a transfer: v11 rejects a PAKE message that lacks protocol version 2 (`pakekey.ProtocolVersion`), and v10.5.0's message type has no version field. +- The peers often never meet, because the room derivation and the default relay differ. So `peerVersion` fires only on a shared room. +- `~/go/bin/croc` (v10.5.0, pinned in `scripts/croc-v10.version`) is kept only as `CROC_V10`, for the v10-peer scenario. If it is first on `PATH`, plain `croc` runs v10, so call Homebrew's `croc` by full path (`known-issues.md`, "Harnesses"). +- Coordinate app release timing with upstream adoption. +- Watch croc's README and issues for a callback-based embedder API. That is the single change that would most simplify crocmobile. diff --git a/docs/knowledge/crocmobile-bridge.md b/docs/knowledge/crocmobile-bridge.md index f5bb12b..4e942aa 100644 --- a/docs/knowledge/crocmobile-bridge.md +++ b/docs/knowledge/crocmobile-bridge.md @@ -1,6 +1,6 @@ # crocmobile / CrocKit engine bridge -The engine contract between Go and Swift, and the croc behaviours it has to work around. UI side: `app-ui-architecture.md`. +The engine contract between Go and Swift, and the croc behaviours it has to work around. UI side: `app-ui-architecture.md`. Terms: `../GLOSSARY.md`. ## Layers @@ -12,78 +12,366 @@ CrocKit Swift package (CrocEngine actor, AsyncStream) → app ## gobind surface (naming) -- xcframework: `Croc.xcframework`; framework MODULE is `Croc` (from `-o` basename) → `import Croc`. ObjC symbol prefix is `Crocmobile` (from package name): `CrocmobileOptions`, `CrocmobileTransfer`, `CrocmobileStartSend/StartReceive` (NSError**), protocol `CrocmobileDelegate` → Swift `CrocmobileDelegateProtocol`. Don't use `initWithRef:`. -- gobind type rules: no `[]string`/unsigned/struct slices. Paths newline-joined, ports comma-joined, structured payloads as JSON strings. -- Delegate callbacks arrive on Go threads; DelegateBridge only yields into AsyncStream; consumers hop to MainActor. - -## Event/JSON contract (Go session.go ↔ Swift Models.swift) - -- fileList `{"files":[{"name","size"}],"emptyFolders","totalSize"}` -- progress `{"currentFile","totalFiles","fileName","fileSent","fileSize","bytesFinished","totalSize","step"}`, step ∈ waiting|connected|transferring; `fileSent` is per-current-file (croc `TotalSent` resets per file) -- summary `{"success","files","totalSize"}`; a malformed/undecodable `done` summary yields `.failed` (code `other`, message `malformed done payload`) and finishes the stream. Malformed `progress` payloads are dropped silently (advisory; next 10 Hz tick retries). -- Sub-100ms transfers may deliver `done` with no `connected`/`progress`/`fileList` events — UI keys off `done`/`failed` only. -- `progress` ticks are deduplicated: `poll()` samples at 10 Hz but only emits when a field actually changed, so idle and plateau stretches (notably the unanswered accept prompt, where nothing moves) go quiet instead of repeating. Cadence only — the JSON schema is unchanged, and no consumer may treat a tick as a heartbeat. Consumers must still not let a tick clobber their prompt state (croc blocks in GetInput until `Respond`). -- `Respond(accept bool, policy string)` returns immediately and answers on its own goroutine. `policy` is `PolicyKeepBoth` (`"keepBoth"`) or `PolicyReplace` (`"replace"`), only read on a receive accept; Swift mirrors the strings as `FilePolicy` and `CrocEngine.respond(accept:policy:)`. An accept writes one `y\n` per file for a sender-side Ask, which can outgrow the pipe buffer and block until croc drains it; the caller is CrocKit's `CrocEngine` actor, so a synchronous write would queue a following `Cancel` behind it. Do not make it synchronous again for "ordering": `session.respond` serialises on `promptM` and `closePrompt` is idempotent. -- error `{"code","message"}` (`crocmobile/errors.go`): `OnError` gets this JSON, and `StartSend`/`StartReceive` errors carry the same JSON as their text (gobind exposes only the text, as `NSError.localizedDescription`), which CrocKit decodes into `EngineFailure`. `code` is one of `peerVersion`, `relayUnreachable`, `noPublicRelay`, `declined`, `refused`, `roomFull`, `badPassword`, `wrongCode`, `cancelled`, `connectionLost`, `storeLimit`, `storeExpired`, `invalidSetting`, `other`; an unknown code or a non-JSON payload decodes as `other` with the raw text. `message` is croc's text for logs only. A recovered panic reports `other`. -- `.failed` semantics: classification uses croc's sentinels (`croc.ErrRelayConnection`, `croc.ErrDERPConnection`, `tcp.ErrAdmissionLimited`) via `errors.Is`, a `codedError` for this package's own errors (`noPublicRelay`, from `generatePublicCode`), and croc v11.5.4 text otherwise (`errors_test.go` pins the live ones against real croc clients and an in-process relay). `declined` is the peer's `peer error: refusing files`, and a peer's cancel is indistinguishable from a peer's decline on the wire. `refused` is this side's `refused files` (prompt answered no, or EOF). `roomFull` also covers the control connection's `relay admission rejected`. `badPassword` is the relay password rejected (`bad password`), not the code. `wrongCode` is peers that met with different codes (`password mismatch` on the sender, `could not secure channel` on the receiver, also `pake not successful`, `message authentication failed`). `cancelled` is `errors.Is(err, context.Canceled)`; croc returns the context error unwrapped, which `errors_test.go` checks with a live cancel. A local `Cancel()` in the accept-prompt window cancels the context before it closes the prompt pipe, and `GetInputContext` returns the context error whenever the context is done, so by source reading that failure is `cancelled`, not `refused` (not re-tested live). Map wording in the UI layer, off the code. -- Stored transfers (`store.go`): `StartStoreSend(paths, *StoreOptions, Delegate)`, `StartStoreReceive(link, outDir, Delegate)` and the blocking `RevokeStored(origin, id, token)` wrap croc v11's `storeclient`. A send ends with `OnStored(receiptJSON)` then `OnDone`; the receipt (`id`, `origin`, `uploadToken`, `link`, `expiresAt` RFC 3339 UTC, `downloads`) is the only copy of the revoke token and of the key in `link`, so the app must keep it in the Keychain. A receive reads the manifest without claiming it, raises `OnFileList` (same payload as a live receive), and waits for `Respond`; decline ends with `refused` and leaves the transfer unclaimed. Both use the same `Transfer` handle and `activeMu` as live transfers, but no cwd, stdin or stdout. `RevokeStored` takes no lock and treats 404/410 as success; it passes a zero key because storeclient validates a whole share and revoke authenticates by token only (the croc CLI does the same). Progress `step` is `preparing` (hashing) then `transferring`; storeclient's callbacks run on its worker goroutines, so each is recovered. -- Stored receive conflicts: storeclient installs each file with a rename that replaces whatever exists, so Replace needs nothing and Keep Both is settled before the download by renaming manifest entries to `name (1).ext` (`keepBothNames`; the names are local, the server and chunk encryption never see them). A failed or cancelled receive leaves storeclient's resume state (`.croc-store-.json`, `..croc-.part`) in `outDir`, which is the only way back in within the server's 30-minute claim lock; resuming after Keep Both renamed an installed file starts a fresh state and a new claim, which the lock refuses until it expires. -- Stored limits: `StartStoreSend` refuses more than 100 files, more than 1 GiB or more than 100 downloads before hashing (`storeLimit`; croc's store defaults, so a server with lower limits still refuses at upload time). The server's HTTP refusals map in `storeHTTPCode`: 404/410 `storeExpired`; 413, 429, 507 and 400 "declaration" `storeLimit`. Network failures reuse `relayUnreachable`, so the app words that copy by context. -- `os.Chdir` restore failures are logged (`restoreWD` in `session.go`), and an `os.Getwd` failure fails the start instead of being ignored. +- The xcframework is `Croc.xcframework`. The framework module is `Croc` (from the `-o` basename), so Swift writes `import Croc`. +- The ObjC symbol prefix is `Crocmobile` (from the Go package name): `CrocmobileOptions`, `CrocmobileTransfer`, `CrocmobileStartSend` and `CrocmobileStartReceive` (both take an `NSError**`). The protocol `CrocmobileDelegate` becomes `CrocmobileDelegateProtocol` in Swift. +- gobind type rules: no `[]string`, no unsigned types, no struct slices. Paths are newline-joined, ports are comma-joined, structured payloads are JSON strings. +- Delegate callbacks arrive on Go threads. `DelegateBridge` only yields into the `AsyncStream`; consumers hop to the main actor. + +## Event and JSON contract + +Go side: `crocmobile/session.go`. Swift side: `CrocKit/Sources/CrocKit/Models.swift`. + +| Event | Payload | +|---|---| +| fileList | `{"files":[{"name","size"}],"emptyFolders","totalSize"}` | +| progress | `{"currentFile","totalFiles","fileName","fileSent","fileSize","bytesFinished","totalSize","step"}` | +| summary (`done`) | `{"success","files","totalSize"}` | +| error | `{"code","message"}` (see "Failure codes") | +| stored receipt | `{"id","origin","uploadToken","link","expiresAt","downloads"}` (see "Stored transfers") | + +Rules that follow from the payloads: + +- `step` is `waiting`, `connected` or `transferring`. A stored upload also reports `preparing` while it hashes files. +- `fileSent` counts bytes of the current file only, because croc resets `TotalSent` for each file. +- An undecodable `done` summary becomes a `.failed` event (code `other`, message `malformed done payload`) and finishes the stream. +- An undecodable `progress` payload is dropped silently. Progress is advisory and the next tick retries. +- A transfer under about 100 ms can deliver `done` with no `connected`, `progress` or `fileList` event. The UI keys off `done` and `failed` only. + +### Progress cadence + +- `poll()` samples at 10 Hz but emits only when a field changed. Idle stretches stay quiet, notably an unanswered accept prompt where nothing moves. +- The schema is unchanged, and no consumer may treat a tick as a heartbeat. +- A tick must never overwrite a consumer's prompt state: croc blocks in `GetInput` until `Respond`. + +### Respond + +`Respond(accept bool, policy string)` returns at once and answers on its own goroutine. + +- `policy` is `PolicyKeepBoth` (`"keepBoth"`) or `PolicyReplace` (`"replace"`). It is read only on a receive accept. Swift mirrors the strings as `FilePolicy` and `CrocEngine.respond(accept:policy:)`. +- A sender-side Ask accept writes one `y\n` per file. A large batch can fill the pipe buffer and block until croc drains it over the network. +- The caller is the `CrocEngine` actor, which runs queued work to completion. A synchronous write would hold up a following `Cancel`. +- Do not make it synchronous for "ordering": `session.respond` serialises on `promptM`, and `closePrompt` is idempotent. + +## Failure codes + +`OnError` receives `{"code","message"}` JSON (`crocmobile/errors.go`). `StartSend` and `StartReceive` errors carry the same JSON as their text, because gobind exposes only the text (as `NSError.localizedDescription`). CrocKit decodes both into `EngineFailure`. + +- `code` is one of `peerVersion`, `relayUnreachable`, `noPublicRelay`, `declined`, `refused`, `roomFull`, `badPassword`, `wrongCode`, `cancelled`, `connectionLost`, `storeLimit`, `storeExpired`, `invalidSetting`, `other`. +- An unknown code, or a payload that is not JSON, decodes as `other` with the raw text. +- `message` is croc's text and is for logs only. The UI maps wording from the code. +- A recovered panic reports `other`. + +How `classify` picks a code: + +| Code | Trigger | +|---|---| +| `peerVersion` | text `unsupported PAKE protocol version` | +| `declined` | the peer's `peer error: refusing files`. A peer's cancel looks the same on the wire. | +| `refused` | this side's `refused files` (prompt answered no, or EOF) | +| `cancelled` | `errors.Is(err, context.Canceled)`. croc returns the context error unwrapped. | +| `roomFull` | `room full`, and the control connection's `relay admission rejected` | +| `badPassword` | the relay rejected the relay password (`bad password`), not the code phrase | +| `wrongCode` | peers met with different codes: `password mismatch` (sender), `could not secure channel` (receiver), `pake not successful`, `message authentication failed` | +| `relayUnreachable` | `croc.ErrRelayConnection`, `croc.ErrDERPConnection`, `tcp.ErrAdmissionLimited`, rate limiting, dial and DNS failures | +| `connectionLost` | unexpected EOF, reset, broken pipe, `transfer disconnected` | +| `noPublicRelay` | this package's own `codedError`, raised by `generatePublicCode` | +| `storeLimit`, `storeExpired` | stored-transfer HTTP refusals (see "Stored transfers") | +| `invalidSetting` | an Advanced option failed validation | + +Notes: + +- Order matters in `classify`: a peer's refusal also cancels croc's context, and a bad relay password is reported as `could not connect to`. +- croc exports sentinels for relay failures only, so the rest is matched on croc v11.5.4 text. `errors_test.go` pins the live ones against real croc clients and an in-process relay. +- A local `Cancel()` during the accept prompt cancels the context before it closes the prompt pipe, and `GetInputContext` returns the context error whenever the context is done. By reading the source that failure is `cancelled`, not `refused`. Not verified live. + +## Stored transfers + +Code: `crocmobile/store.go`. They wrap croc v11's `storeclient`. + +- API: `StartStoreSend(paths, *StoreOptions, Delegate)`, `StartStoreReceive(link, outDir, *StoreOptions, Delegate)` and the blocking `RevokeStored(origin, id, token, *StoreOptions)`. For receive and revoke, `StoreOptions` carries only the proxies. +- A stored transfer uses the same `Transfer` handle and `activeMu` as a live one, but no cwd, stdin or stdout. +- A send ends with `OnStored(receiptJSON)` then `OnDone`. The receipt is the only copy of the revoke token and of the key inside `link`, so the app keeps it in the Keychain. `expiresAt` is RFC 3339 UTC. +- A receive reads the manifest without claiming it, raises `OnFileList` (same payload as a live receive) and waits for `Respond`. A decline ends with `refused` and leaves the transfer unclaimed. +- `RevokeStored` takes no lock and treats HTTP 404 and 410 as success. It passes a zero key: `storeclient` validates a whole share, and revoke authenticates by token only (the croc CLI does the same). +- `storeclient`'s callbacks run on its worker goroutines, so each is wrapped in a recover. + +Conflicts on receive: + +- `storeclient` installs each file with a rename that replaces whatever exists, so Replace needs no work. +- Keep Both is settled before the download, by renaming manifest entries to `name (1).ext` (`keepBothNames`). The names are local: the server and the chunk encryption never see them. +- A failed or cancelled receive leaves `storeclient`'s resume state (`.croc-store-.json` and `..croc-.part`) in `outDir`. That is the only way back in within the server's 30-minute claim lock. +- Resuming after Keep Both renamed an installed file starts fresh state and a new claim. The lock refuses it until it expires. + +Limits and error mapping: + +- `StartStoreSend` refuses more than 100 files or more than 1 GiB before hashing (`storeLimit`). These match croc's store defaults, so a server with lower limits still refuses at upload time. +- It also refuses more than 100 downloads. That cap is crocmobile's own: the croc store default is 1 download. +- Server refusals map in `storeHTTPCode`: 404 and 410 give `storeExpired`; 413, 429, 507 and 400 with "declaration" in the message give `storeLimit`. +- Network failures reuse `relayUnreachable`, so the app words that copy by context. ## Engine behavior invariants -- One transfer at a time; second start → `CrocEngineError.transferActive`. Brief window after `done` where the next start can still throw — retry once. -- Receiver MUST set `outDir`; sender sets `workDir` (writable, for text/zip temp files; iOS cwd is `/`). -- Abandoning the event stream cancels the Go session (`onTermination`); `Cancel()` cancels the context and also closes the prompt pipe: after a context cancel, `GetInputContext` returns but its read goroutine stays blocked until the pipe closes, so skipping the close would leave it hanging. -- `Options.Ask` is exposed: sender sessions with Ask install the same dup2 fd0 pipe as receive (sender-only helper), and `Respond(true)` writes one `y\n` PER FILE (`promptAnswers = len(filesInfo)`) — croc's sender prompt fires per `TypeRecipientReady`, once per file; a single answer starves multi-file sends at file 2 (EOF ⇒ "refusing files"). Decline/cancel close the pipe; EOF at the Ask prompt refuses safely (croc checks the input error before its default-yes). -- File policy (`filePolicy`, `session.go`): `keepBoth` or `replace`, what a receive does with a file whose name is taken. `buildCrocOptions` always hands croc exactly one of `Rename` and `Overwrite`: neither set, or both set, means keep both (Rename wins); an unknown policy string also means keep both. croc's third state (both false) is never passed: it prompts `Overwrite ...? (y/N)` on stdin, which on our closed pipe skips the file, and on a zipped-folder unzip aborts the transfer. On accept, `respond` sets the policy on the live `croc.Client` before writing `y` (croc reads `Rename`/`Overwrite` only once the accept prompt is answered), then queues 4096 more answers (`promptPolicyAnswers`) for the stdin prompts that ignore both flags: the empty-folder prompt and the per-member unzip prompt `Overwrite ''? (y/N)`. Every queued answer is `n` under either policy (`keptPromptAnswers`), which makes the unzip skip an existing member and keep the local copy, so Replace overwrites files through `Overwrite` but never a zip member. Never `y`: croc v11 re-runs the accept prompt on the sender's new file list after each reconnect, and a queued `y` would accept a list the user never saw (security scan F2, 2026-10-15). The same `n` therefore refuses any reconnect after a manual accept (`docs/known-issues.md`, Accepted). 4096 two-byte lines fit a fresh pipe buffer on Darwin and Linux, so the write never blocks. With `AutoAccept` there is no accept prompt: the batch is written at session start and the pipe is closed (`answerUngatedPrompts`). That batch is the same `n` batch: croc still forces the accept prompt when the remote sender ran `--ask`, the prompt reads the first queued line, and `y` would accept a transfer the sender wanted confirmed by hand (no line means "no" to the accept prompt and "yes" to the unzip prompt). Past 4096 colliding members an unzip hits EOF and fails, leaving the received zip (`docs/known-issues.md`, Accepted). A file that already exists with identical content is left as is under either policy (croc counts it unchanged). Keep Both names come from `utils.UnusedFilename`: `name (1).ext`, `name (2).ext`. -- `Quiet` stays false (true replaces `os.Stderr` process-wide with /dev/null); croc progress bar noise on stderr is accepted. -- Go `crocmobile.Options` exports more knobs than Swift wires: `RelayPorts`, `Curve`, `HashAlgorithm`, `ThrottleUpload`, `NoMultiplexing` are settable from Go/`croctest` only — `EngineOptions`/`crocOptions(from:)` never set them. Exposing them in the GUI means plumbing CrocKit first. `Options.Rename` exists next to `Overwrite` but Swift never sets either: `EngineOptions.whenFileExists` (`FilePolicy`, default `.keepBoth`) is the one knob, see the file policy item under Engine behavior invariants. -- Transport: `Options.Transport` is croc v11's sender-only `Transport`. `TransportAuto` (`"auto"`, also the empty string) and `TransportRelay` (`"relay"`) are the values the app uses; Swift mirrors them as the `Transport` enum (`auto`, `relay`) on `EngineOptions.transport`, stored as `AppSettings.transport` (`settings.transport`, an unknown stored value reads as auto). `sessionTransport` (`session.go`) forces auto on a receive and on a local-only send, because croc rejects anything else there (`transport selection is sender-only`, and `--transport` with `--local` unless auto), and passes any other value through croc's `ParseTransportMode`, so an unknown value is a start error. croc's `derp` (Tailcat only, no relay fallback) parses and reaches croc, but only `croctest -transport auto|relay|derp` and Go callers can set it; the app never offers it. The Settings picker is disabled while Local network only is on. See "croc v11.5.4 gotchas" for what auto does. -- Relay values: Swift passes the settings as stored; `crocmobile/relay.go` resolves them for send and receive alike, mirroring the v11 CLI (`cli.go` `usesPublicRelay`, `assignPublicRelayForCode`, `selectPublicRelay`). A relay setting equal to `models.DEFAULT_RELAY` / `DEFAULT_RELAY6` (`croc.schollz.com:9009`, `croc6.schollz.com:9009`) counts as not custom, and `models` no longer resolves relay DNS at import, so those are plain strings. With no custom relay and `OnlyLocal` off, the relay is `publicrelay.Relays()[codephrase.RelayIndex(code, 4)]` with v6 blanked and `PublicRelay` set; a generated code is drawn with `codephrase.GenerateForRelay` for the first relay to answer a ping, cached in memory and forgotten after a send fails with `croc.ErrRelayConnection`. If no relay answers the ping, the send fails like the CLI with code `noPublicRelay` (the app shows its own copy pointing to Local network only; a draw-anyway fallback was removed: croc waits only about 10 s for a LAN peer after the relay dial fails, `defaultAlternateSenderRouteTimeout`, so a code shown offline would expire before the receiver could type it). `relayKind` and `resolveRelay` both treat `croc.schollz.com:9009` as not custom (the CLI uses that host when `--relay` names it explicitly). A custom relay is passed as set and the other side blanked; a custom pair keeps both (known-issues, Accepted). Blank password means croc's default. `RelayForCode` exposes the code's pool relay. +- One transfer at a time. A second start throws `CrocEngineError.transferActive`. A start right after `done` can still throw for a moment, because the lock is released after the final delegate call. Retry once. +- A receiver must set `outDir`. A sender sets `workDir` (writable, for text and zip temp files; the iOS cwd is `/`). +- `os.Chdir` restore failures are logged (`restoreWD` in `session.go`), and an `os.Getwd` failure fails the start. +- `Quiet` stays false, because `Quiet = true` replaces `os.Stderr` process-wide with `/dev/null`. croc's progress bar noise on stderr is accepted. + +### Cancel and teardown + +- Abandoning the event stream cancels the Go session (`onTermination`). +- `Cancel()` cancels the context and also closes the prompt pipe. After a context cancel, `GetInputContext` returns, but its read goroutine stays blocked until the pipe closes. + +### Ask (both sides confirm) + +- `Options.Ask` is exposed. A sender session with Ask installs the same `dup2` fd 0 pipe as a receive. +- `Respond(true)` writes one `y\n` per file (`promptAnswers = len(filesInfo)`), because croc's sender prompt fires once per file. A single answer starves a multi-file send at file 2: EOF reads as "refusing files". +- Decline and cancel close the pipe. EOF at the Ask prompt refuses safely, because croc checks the input error before its default of yes. + +### File policy + +What a receive does with a file whose name is taken: `keepBoth` or `replace` (`filePolicy`, `session.go`). + +- `buildCrocOptions` always hands croc exactly one of `Rename` and `Overwrite`. Neither set, both set, or an unknown policy string all mean keep both (Rename wins). +- croc's third state (both false) is never passed. It prompts `Overwrite ...? (y/N)` on stdin, which on a closed pipe skips the file and, during a zipped-folder unzip, aborts the transfer. +- A file that already exists with identical content is left as is under either policy (croc counts it unchanged). +- Keep Both names come from `utils.UnusedFilename`: `name (1).ext`, `name (2).ext`. + +Answers queued for croc's stdin prompts: + +- On accept, `respond` sets the policy on the live `croc.Client` before writing `y`, because croc reads `Rename` and `Overwrite` only once the accept prompt is answered. +- It then queues 4096 more answers (`promptPolicyAnswers`) for the prompts that ignore both flags: the empty-folder prompt and the per-member unzip prompt `Overwrite ''? (y/N)`. +- Every queued answer is `n`, under either policy (`keptPromptAnswers`). The unzip then skips an existing member and keeps the local copy. So Replace overwrites files through `Overwrite` but never a zip member. +- Never `y`: croc v11 re-runs the accept prompt on the sender's new file list after each reconnect, and a queued `y` would accept a list the user never saw. As a result, a reconnect after a manual accept is refused (`../known-issues.md`, "Accepted"). +- 4096 two-byte lines fit a fresh pipe buffer on Darwin and Linux, so the write never blocks. +- With `AutoAccept` there is no accept prompt. The same `n` batch is written at session start and the pipe is closed (`answerUngatedPrompts`). It must be `n` because croc still forces the accept prompt when the remote sender ran `--ask`. That prompt reads the first queued line, and `y` would accept a transfer the sender wanted confirmed by hand. +- Past 4096 colliding zip members an unzip hits EOF and fails, leaving the received zip (`../known-issues.md`, "Accepted"). + +### Options that Swift wires + +- `crocOptions(from:)` sets every `crocmobile.Options` field. `Rename` and `Overwrite` both come from the one knob `EngineOptions.whenFileExists` (`FilePolicy`, default `.keepBoth`). +- Advanced options (curve, hash, throttle, no-multiplexing, ports, multicast group, sender IP, proxies, internal DNS) are all settable from the app. See "Advanced options on the wire". +- `croctest` can set the same fields from the command line, plus `-transport derp`. + +### Transport + +`Options.Transport` is croc v11's sender-only `Transport`. "croc v11.5.4 gotchas" explains what `auto` does. + +- The app uses `TransportAuto` (`"auto"`, also the empty string) and `TransportRelay` (`"relay"`). Swift mirrors them as the `Transport` enum on `EngineOptions.transport`, stored as `AppSettings.transport` (an unknown stored value reads as auto). +- `sessionTransport` (`session.go`) forces auto on a receive and on a local-only send, because croc rejects anything else there (`transport selection is sender-only`, and `--transport` with `--local` unless auto). +- Any other value goes through croc's `ParseTransportMode`, so an unknown value is a start error. +- croc's `derp` (Tailcat only, no relay fallback) parses and reaches croc, but only `croctest -transport auto|relay|derp` and Go callers can set it. The app never offers it. +- The **Connection** picker is disabled while **Local Network Only** is on. + +### Relay values + +Swift passes the relay settings as stored. `crocmobile/relay.go` resolves them for send and receive alike, mirroring the v11 CLI (`cli.go`: `usesPublicRelay`, `assignPublicRelayForCode`, `selectPublicRelay`). + +- A relay setting that is blank or equals `models.DEFAULT_RELAY` or `DEFAULT_RELAY6` (`croc.schollz.com:9009`, `croc6.schollz.com:9009`) is not custom (`customRelays`). `models` no longer resolves relay DNS at import, so those are plain strings. +- With no custom relay and **Local Network Only** off, the relay is `publicrelay.Relays()[codephrase.RelayIndex(code, len(relays))]`, with v6 blanked and `PublicRelay` set. +- A custom relay is passed as set and the other side is blanked. A custom pair keeps both (`../known-issues.md`, "Accepted"). +- A blank password means croc's default. +- `RelayForCode` exposes the code's pool relay. + +Generated codes on the public pool: + +- The code is drawn with `codephrase.GenerateForRelay` for the first relay to answer a ping. The choice is cached in memory and forgotten after a send fails with `croc.ErrRelayConnection`. +- If no relay answers, the send fails like the CLI, with `noPublicRelay`. The app shows its own copy pointing to **Local Network Only**. +- There is deliberately no "draw a code anyway" fallback. After the relay dial fails, croc waits only about 10 s for a LAN peer (`defaultAlternateSenderRouteTimeout`), so a code shown offline would expire before the receiver could type it. ## croc v11.5.4 gotchas (verified against source) -- Relay room = `SHA-256(room selector + "croc")`, where the selector is the first word of a three-word code (`codephrase.Parse`). Two codes with the same first word collide into one room ("room full"). A generated code is three random words from a 1296-word list, so two concurrent codes share a first word about once in 1296, and only collide if they also map to the same pool relay. Four lowercase words select on the first two; any other code (for example `NNNN-word`) takes the legacy split: first 4 chars as the selector, `code[5:]` as the PAKE passphrase. Custom codes and test scripts must vary the selector. -- Transport `auto` uses Tailcat only for a big send. When the total send size is at least 128 MiB (`autoTailcatThresholdBytes`), the sender prepares Tailcat (it fetches the DERP map from `https://tailcat.dev/derpmap.json`, which today lists `tc301a.ipn.dev` to `tc304a.ipn.dev`), sends a `tailcat-offer`, and races Tailcat against the relay, which starts 350 ms later. Whichever connects first carries the data; if Tailcat fails, the relay carries it. Below 128 MiB, or with `relay`, the sender never starts Tailcat and neither side contacts those servers. A receiver handed a `tailcat-offer` joins Tailcat whatever its own setting. Every data chunk is encrypted with the session key (AEAD) on either path. A receiver follows the sender and does not choose. Tailcat's own data path has not been seen working on the development Mac (`docs/known-issues.md`, Harnesses), and whether it stays in the build is decided on a physical iPhone (`device-test-checklist.md`, "Transport and Tailcat gate"). If it goes, the engine is built with `-tags croc_no_tailcat` in `scripts/build-xcframework.sh`, croc then downgrades `derp` to `relay`, and the Connection setting is removed. -- CLI: custom codes need `CROC_SECRET=...` env on BOTH send and receive. On Unix, `send --code X` without it prints instructions and exits 0 without sending, and a receive code given as an argument without it prints a message and returns; `croc --classic` mode lifts both. Non-tty CLI runs need global `--ignore-stdin` before the subcommand, otherwise a piped stdin is read as the thing to send. -- UDP multicast peer discovery can be dead on a LAN (returns zero peers). Seen once on one AP-isolated network with the previous croc pin, not re-tested on v11. Fallbacks: relay (automatic, the receiver races discovery against the relay dial) or `--ip` direct. iOS physical devices additionally need the restricted multicast entitlement (constraints doc §2). -- croc retries a dropped transfer up to 10 times (backoff from 100 ms, doubling, capped at 5 s) and only then fails with `transfer disconnected after 10 reconnect attempts`, so peer death surfaces slowly; scripts must bound with timeouts. -- Sender-first start ordering matters. On joining the room the receiver sends a `pake1` local probe and waits 500 ms for the reply (`localProbeResponseTimeout`). If the sender is not there yet, the probe times out, the receiver carries on with its main PAKE and enters the transfer loop; when the sender arrives it answers the old `pake1`, the receiver decodes that frame as a transfer message and fails with `problem with decoding: decompress message: decompress data: flate: corrupt input before offset 6`. Reproduced 3/3 with the receiver started about 0.8 s before the app sender, 11/11 pass with the sender first. Generated-code GUI flows are sender-first, but custom codes are not: a receiver who types an agreed code before the sender taps Send, or an app receiver waiting on a CLI sender using `CROC_SECRET`, hits it (`docs/known-issues.md`, Engine). Harnesses start the receiver only after the sender reported its code (the app-as-sender scenarios of `verify-app-sim.sh` and `verify-app-mac.sh` wait for the `verify-code-ready.txt` marker that `AutoVerify` writes when a send reaches its code, plus 3 s; `verify-interop.sh` waits for croctest's `EVENT code` line, plus 3 s, before a CLI receiver joins, and a fixed 3 s when the CLI sends). -- Prompts beyond the accept prompt: the empty-folder overwrite prompt and the unzip overwrite prompt (zipped-folder receive, one `Overwrite ''? (y/N)` per archive member that already exists) ignore `Rename`/`Overwrite` and read stdin, so crocmobile answers them from the policy batch (file policy item above). Without an answer, the empty-folder prompt takes EOF as "no" and the existing-file prompt skips that file, but the unzip prompt returns the read error, and croc v11 treats that as an extraction error (v10 skipped the member): the transfer ends `received archive failed validation or extraction` with the received `.zip` left in the out dir. That was the failure before the batch existed; tested live 2026-10-12 with `croctest` receiving a CLI `send --zip` twice into one out dir. Now covered by `verify-interop.sh` scenario 12. -- `utils.GetInputContext` reads from a package-level `bufio.Reader` built over fd 0 at init (`stdinReader`), so replacing `os.Stdin` later changes nothing: the fd 0 `dup2` stays required. -- That reader is process-wide and buffers ahead, so answers croc never asked for (the policy batch, extra sender-Ask `y` lines) would sit in it and answer the next transfer's accept prompt: a Go unit test showed the next transfer reading `n`, a decline. `drainPromptInput` (`session.go`) therefore reads `stdinReader` to EOF through `utils.GetInputContext`, with a 2 s timeout, at session end after the prompt pipe closes and before fd 0 is restored. This also covers the older sender-Ask path, where leftover `y` lines were once judged harmless. -- A zipped-folder send writes `.zip` into the send cwd (`Options.WorkDir`) and marks it with `utils.MarkFileForRemoval`. The library deletes it itself only after a successful send; the CLI's `main.go` sweeps the rest with `utils.RemoveMarkedFiles()` on exit. crocmobile calls `RemoveMarkedFiles` after every send (success, failure, cancel) and when session setup fails after the zip was made. +### Rooms and codes + +- How a code selects the room, and when two codes collide: `what-is-croc.md`, "Rendezvous". In the bridge, `codephrase.Parse` does the split, and the legacy split makes `code[5:]` the PAKE passphrase. +- Custom codes and test scripts must vary the selector. + +### Transport and Tailcat + +How Tailcat works, its 128 MiB threshold and the servers it contacts: `what-is-croc.md`, "Transport". What matters in the bridge: + +- The sender sends a `tailcat-offer` when it races Tailcat. If Tailcat fails, the relay carries the data. +- A receiver handed a `tailcat-offer` joins Tailcat whatever its own setting. The receiver follows the sender and does not choose. +- Every data chunk is encrypted with the session key (AEAD) on either path. +- Tailcat's data path has been seen working on a physical iPhone. On the development Mac, `croc --transport derp` CLI to CLI does not complete, cause unknown (`../known-issues.md`, "Harnesses"). +- croc has a `croc_no_tailcat` build tag that removes Tailcat and downgrades `derp` to `relay`. The app build does not use it. Passing it through `gomobile bind -tags` is inferred, not verified. + +### CLI behaviour that bites scripts + +- Custom codes need `CROC_SECRET=...` in the environment on both send and receive. On Unix, `send --code X` without it prints instructions and exits 0 without sending. A receive code given as an argument without it prints a message and returns. `croc --classic` lifts both. +- A non-tty CLI run needs the global `--ignore-stdin` before the subcommand. Otherwise a piped stdin is read as the thing to send. + +### LAN discovery + +- UDP multicast peer discovery can be dead on a LAN and return zero peers. It was seen on one AP-isolated network, with the previous croc pin, and not re-tested on v11. Root cause not isolated. +- Fallbacks: the relay (automatic: the receiver races discovery against the relay dial) or `--ip` for a direct connection. +- Physical iOS devices also need the restricted multicast entitlement (`apple-platform-constraints.md`, "Hard constraints", item on multicast). + +### Reconnects + +croc retries a dropped transfer up to 10 times (backoff from 100 ms, doubling, capped at 5 s). Only then it fails with `transfer disconnected after 10 reconnect attempts`. Peer death therefore surfaces slowly, and scripts must bound their waits with timeouts. + +### Start order: sender first + +- On joining the room, the receiver sends a `pake1` local probe and waits 500 ms for the reply (`localProbeResponseTimeout`). +- If the sender is not there yet, the probe times out and the receiver carries on with its main PAKE and enters the transfer loop. +- When the sender arrives, it answers the old `pake1`. The receiver decodes that frame as a transfer message and fails with `problem with decoding: decompress message: decompress data: flate: corrupt input before offset 6`. +- The failure was observed with the receiver started about 0.8 s before the app sender. The chain from the probe timeout to the decode error is inferred from reading the source. +- Generated-code GUI flows are sender-first. Custom codes are not: a receiver who types an agreed code before the sender taps Send, or an app receiver waiting on a CLI sender using `CROC_SECRET`, hits it (`../known-issues.md`, "Accepted"). +- Harnesses start the receiver only after the sender reported its code: see "Verification harnesses". + +### Prompts beyond the accept prompt + +The empty-folder overwrite prompt and the unzip overwrite prompt ignore `Rename` and `Overwrite` and read stdin. The unzip prompt appears once per archive member that already exists, in a zipped-folder receive. crocmobile answers both from the policy batch (see "File policy"). + +Without an answer: + +- The empty-folder prompt takes EOF as "no". +- The existing-file prompt skips that file. +- The unzip prompt returns the read error, and croc v11 treats that as an extraction error. The transfer ends `received archive failed validation or extraction`, with the received `.zip` left in the out dir. croc v10 skipped the member instead. + +The batch fixes this. `verify-interop.sh` scenario 12 covers it. + +### Stdin plumbing + +- `utils.GetInputContext` reads from a package-level `bufio.Reader` built over fd 0 at init (`stdinReader`). Replacing `os.Stdin` later changes nothing, so the fd 0 `dup2` stays required. +- That reader is process-wide and buffers ahead. Answers croc never asked for (the policy batch, extra sender-Ask `y` lines) would sit in it and answer the next transfer's accept prompt. A Go unit test showed the next transfer reading `n`, a decline. +- `drainPromptInput` (`session.go`) therefore reads `stdinReader` to EOF through `utils.GetInputContext`, with a 2 s timeout. It runs at session end, after the prompt pipe closes and before fd 0 is restored. + +### Zipped-folder sends + +- A zipped-folder send writes `.zip` into the send cwd (`Options.WorkDir`) and marks it with `utils.MarkFileForRemoval`. +- The library deletes it itself only after a successful send. The CLI's `main.go` sweeps the rest with `utils.RemoveMarkedFiles()` on exit. +- crocmobile calls `RemoveMarkedFiles` after every send (success, failure, cancel) and when session setup fails after the zip was made. ## Advanced options on the wire -`Options.Curve`, `HashAlgorithm`, `BasePort`/`Transfers`, `MulticastAddress`, `PeerIP`, `Socks5Proxy`, `HTTPProxy` and `InternalDNS` map one to one onto croc's flags of the same name (`buildCrocOptions`, `network.go`; `croctest` takes `-curve -hash -port -transfers -multicast -ip -socks5 -connect -internal-dns`). `verify-interop.sh` scenario 15 runs each against the croc v11.5.4 CLI. Observed there, with the croc source (`src/croc/croc.go`, `comm/comm.go`) read for the reason: +`Options.Curve`, `HashAlgorithm`, `BasePort` and `Transfers`, `MulticastAddress`, `PeerIP`, `Socks5Proxy`, `HTTPProxy` and `InternalDNS` map one to one onto croc's flags of the same name (`buildCrocOptions` and `network.go`). `croctest` takes `-curve -hash -port -transfers -multicast -ip -socks5 -connect -internal-dns`. + +`verify-interop.sh` scenario 15 runs each against the croc v11.5.4 CLI. The table records what that scenario observed, with the croc source (`src/croc/croc.go`, `comm/comm.go`) read for the reason. | Option | Which side it matters on | Peer must match? | Mismatch | |---|---|---|---| -| Curve | The **receiver** chooses it: its PAKE message carries the curve name and the sender adopts it (sender `--debug`: `using curve p384`). A sender's own curve is never read. | No | Never an error, either direction. wrapper send `siec` / CLI receive `p384` and CLI send `siec` / wrapper receive `p384` both complete, on the receiver's curve. A name croc does not know fails at the receiver start: `EVENT error {"code":"other","message":"no such curve"}` (the app picks from a list, so it cannot happen there) | -| Hash | The **sender** chooses it and the receiver adopts it (receiver `--debug`: `using hash algorithm: md5`). `imohash` goes out as `imohash-v2`. | No | Never an error: a receiver set to `highway` takes the sender's `md5`. An unknown name fails at the sender start: `{"code":"other","message":"unsupported algorithm: bogus"}` | -| Base port, Transfers | **Sender** only: its local relay listens on the base port and the next `Transfers` ports (the CLI's `--port`/`--transfers`). The announced banner lists the data ports, e.g. `banner: 26993,26994`. | The receiver needs the base port, through discovery (which announces it) or `PeerIP` | A receiver pointed at the wrong port fails, see PeerIP. If a port is busy, croc moves to the next open ones (`utils.FindOpenPorts`, read from source, not observed), so the announced port can differ from the setting | -| PeerIP | **Receiver** only (`--ip host:port`): connect straight to the sender's local relay and skip public relay and discovery, so it works only with a sender whose local relay is on (not `NoLocal`) and reachable | The sender must listen there | Nothing listening: fails at once, `EVENT error {"code":"relayUnreachable","message":"could not connect to 127.0.0.1:: ... connection refused"}`, no fallback to a relay | -| Multicast address | Both sides of **LAN discovery**: a sender announces `croc` on the group, a receiver announces `ok` and listens (default `239.255.255.250`, UDP port 9999). | Yes, for discovery: peers on different groups never hear each other (a listener on another group heard nothing from the sender). The relay path still works | Silent: discovery finds nobody, then the relay or `PeerIP` path carries the transfer. A transfer across two hosts on a shared group is not covered by the harness: croc drops discovery packets from the host's own addresses (`peerdiscovery` `AllowSelf` false), so two peers on one host never find each other, not even two CLIs | -| SOCKS5, HTTP proxy | The local side's own dials only (CONNECT, SOCKS5 before HTTP). Invisible to the peer | No | croc bypasses the proxy for local addresses (`utils.IsLocalIP`: loopback, link-local, private ranges), so a proxy affects only a public relay or Tailcat dial. With a local relay or a LAN transfer it is never used, with no error. Verified: with the public pool, 2 (send) to 9 (receive) connections went through a `croctest` proxy per transfer | -| Internal DNS | The local side's lookup of the relay name (built-in resolver against public DNS servers instead of the system's) | No | A transfer over the public pool works with it on. The harness cannot observe which resolver ran; `network_test.go` pins that the option reaches `models.INTERNAL_DNS` | +| Curve | The **receiver** chooses it. Its PAKE message carries the curve name and the sender adopts it (sender `--debug`: `using curve p384`). A sender's own curve is never read. | No | Never an error, in either direction: the transfer runs on the receiver's curve. A name croc does not know fails at the receiver start with `{"code":"other","message":"no such curve"}`. The app picks from a list, so that cannot happen there. | +| Hash | The **sender** chooses it and the receiver adopts it (receiver `--debug`: `using hash algorithm: md5`). `imohash` goes out as `imohash-v2`. | No | Never an error: a receiver set to `highway` takes the sender's `md5`. An unknown name fails at the sender start: `{"code":"other","message":"unsupported algorithm: bogus"}`. | +| Base port, Transfers | **Sender** only. Its local relay listens on the base port and the next `Transfers` ports (the CLI's `--port` and `--transfers`). The banner lists the data ports, for example `banner: 26993,26994`. | The receiver needs the base port, through discovery (which announces it) or `PeerIP`. | A receiver pointed at the wrong port fails (see PeerIP). If a port is busy, croc moves to the next open ones (`utils.FindOpenPorts`, read from source, not observed), so the announced port can differ from the setting. | +| PeerIP | **Receiver** only (`--ip host:port`). It connects straight to the sender's local relay and skips the public relay and discovery. It works only with a sender whose local relay is on (not `NoLocal`) and reachable. | The sender must listen there. | Nothing listening: it fails at once with `relayUnreachable` (`could not connect to 127.0.0.1:: ... connection refused`), with no fallback to a relay. | +| Multicast address | Both sides of **LAN discovery**. A sender announces `croc` on the group, a receiver announces `ok` and listens. The default is `239.255.255.250`, UDP port 9999. | Yes, for discovery: peers on different groups never hear each other. The relay path still works. | Silent: discovery finds nobody, then the relay or `PeerIP` path carries the transfer. | +| SOCKS5, HTTP proxy | The local side's own dials only (CONNECT; SOCKS5 before HTTP). Invisible to the peer. | No | croc bypasses the proxy for local addresses (`utils.IsLocalIP`: loopback, link-local, private ranges). A proxy therefore affects only a public relay or Tailcat dial. With a local relay or a LAN transfer it is never used, with no error. Observed: with the public pool, connections went through a `croctest` proxy on both send and receive. | +| Internal DNS | The local side's lookup of the relay name (built-in resolver against public DNS servers instead of the system's). | No | A transfer over the public pool works with it on. The harness cannot observe which resolver ran. `network_test.go` pins that the option reaches `models.INTERNAL_DNS`. | + +Two limits of the harness: -Not an option mismatch but easy to confuse: `NoMultiplexing` and `ThrottleUpload` are sender-side only (scenarios 6, 7 and 11 use them). +- A transfer across two hosts on a shared multicast group is not covered. croc drops discovery packets from the host's own addresses (`peerdiscovery` `AllowSelf` false), so two peers on one host never find each other, not even two CLIs. +- `NoMultiplexing` and `ThrottleUpload` are sender-side only. They are not a mismatch case; scenarios 6, 7 and 11 use them. ## Verification harnesses -- `scripts/lib.sh` — sourced by every `verify-*.sh`, `capture-screenshots.sh` and the App Store and Developer ID build scripts: the `timeout(1)` shim, `fail`/`pass`, `logged` (runs a build step into a log and shows its tail on failure), `require_croc`, `kill_jobs` (EXIT trap, so a failed assertion cannot leak a croc/relay/app process), `await_file`, `local_ip`. -- `scripts/verify-interop.sh`: 15 scenarios crocmobile↔CLI (file/folder/text both ways, decline, cancel both ways mid-wire via `--throttleUpload 200k`, forced relay, LAN via `--ip`, a croc v10 peer both ways expecting failure code `peerVersion`, every public pool relay). `RELAY=host:port` routes every transfer through one relay and skips the transport (13), proxy and internal DNS (15) and pool (11) scenarios. Scenario 15 (Advanced options) is described under "Advanced options on the wire" above; its curve, hash, port and multicast cases run under `RELAY` too. Scenario 14 (stored transfers) needs neither a relay nor the internet, so it runs under `RELAY` too: it starts `croctest store-server` on a loopback port and checks wrapper upload → CLI download, wrapper revoke → CLI download fails, CLI upload → wrapper download, CLI revoke → `storeExpired`, and an existing name with Keep Both, Replace and a declined preview; the CLI's revoke receipts go to a throwaway `CROC_CONFIG_DIR`. Cancel scenarios assert received-file mismatch (unfakeable by log noise). Scenario 11 covers each of the 4 pool relays: a custom code that maps to it sent wrapper→CLI and CLI→wrapper, and a wrapper-generated code forced onto it with `croctest send -relay-index N`; plus one CLI-generated code (the CLI caches its probed relay, so that covers one index). LAN is off on both sides (`-no-local` / `send --no-local`) so only matching relays meet, and it asserts croctest's `EVENT relay ` and the CLI `--debug` line `public relay index N selected: `. It waits 60 s first, runs with multiplexing off (`-no-multi` / `--no-multi`) and makes one pass over the pool per kind of transfer, because a relay admits 30 connections a minute from one address (`docs/known-issues.md`). Scenario 12 is the file policy: a CLI sends a file onto an existing one and `croctest` receives with Keep Both and Replace, answered at the prompt and preset with `-yes` (4 cases), then a zipped folder (`croc send --zip`) received twice into one folder with each policy and each accept mode (4 cases); it runs in `RELAY` mode too, so CI covers it. `croctest receive` takes `-policy keepBoth|replace` (default `keepBoth`, other values exit 2). Scenario 13 is the transport: a 130 MiB send in each of `auto` and `relay`, wrapper→CLI and CLI→wrapper (4 cases), against public infrastructure only (Tailcat bootstraps from tailcat.dev, so it is skipped with `RELAY`). It asserts byte equality, and in the CLI `--debug` log a `tailcat-offer` under auto (or `selected=derp`, the CLI's size decision, when the CLI sends) and none under relay. `croctest send` takes `-transport auto|relay|derp` (default `auto`). `croctest` also has `relay CODE` and `relays`; the relay cache lives in `crocmobile/internal/relaycache`, so croctest can seed it without adding to the gobind API. -- `scripts/verify-app-sim.sh` — boots sim, installs app, CLI→app via `--auto-receive CODE` launch arg, then the share-extension handoff via `--auto-share-send CODE`; each gates on exact `ok success=true` + byte diff. The launch args drive the real UI state machine (`AutoVerify` → `TransferController`, accept via `respond(true)`), not engine-level autoAccept — the point is that the verified path is the one users take. -- `scripts/verify-app-mac.sh` — macOS app both directions plus local-only: CLI→app (`--auto-receive`), app→CLI (`--auto-send PATH --code CODE`, custom-code path; source file must live in the app container — sandbox), app→CLI with `--local` (sandboxed relay-listener proof, CLI receives via `--ip`), app→CLI via custom self-hosted relay (`croc relay --ports 9021,9022,9023` + `--relay localhost:9021`), `--no-compress` path, `--ask` both-sides-confirm (auto-answered via `AutoVerify` `.confirmSend`), and scenario 7, which prints `MAC-COLLIDE-OK`: the CLI sends onto an existing file in the app's Documents and the app receives with `--when-exists keepBoth|replace` (the answer `AutoVerify` gives the incoming sheet; the setting stays Keep Both), each diffed. Scenario 8 prints `MAC-TRANSPORT-OK`: a 130 MiB transfer in each direction under `--transport auto` and `--transport relay` (the `AutoVerify` flag that sets `settings.transport`), with the same offer assertions as interop scenario 13; public internet only. The app declares document types, so bare adjacent argv tokens are document-open candidates — flags only (`--code CODE`), and every launch needs `-ApplePersistenceIgnoreState YES` (headless window-restoration hang). -- `crockit-verify` (CrocKit executable) — Swift-layer send/receive/cancel-after-ms/`twice` (two transfers one process, the second receiving with `.replace` — proves fd0/stdout/cwd/mutex restoration composes). -- macOS app verified via same `--auto-receive` route; container Documents at `~/Library/Containers/com.bakirgdev.CrocApp/Data/Documents/`. +Each harness checks a different layer. `BUILDING.md`, "Verification harnesses" says how to run them. Shell traps are in `tooling.md`, "Shell and harness traps". -## Toolchain +### `scripts/lib.sh` + +Sourced by `verify-interop.sh`, `verify-app-mac.sh`, `verify-app-sim.sh`, `capture-screenshots.sh`, `build-appstore.sh` and `build-devid.sh`. It provides: + +- the `timeout(1)` shim; +- `fail` and `pass`; +- `logged`, which runs a build step into a log and shows its tail on failure; +- `require_croc`; +- `kill_jobs`, run from an EXIT trap so a failed assertion cannot leak a croc, relay or app process; +- `await_file` and `local_ip`. + +### `scripts/verify-interop.sh` + +15 scenarios, crocmobile against the croc CLI. + +| Scenario | What it covers | +|---|---| +| 1 to 4 | File, folder and text, in both directions | +| 5 | Decline notifies the sender | +| 6, 7 | Receiver cancels, then sender cancels, mid-wire (`--throttleUpload 200k`). They assert a received-file mismatch, which log noise cannot fake. | +| 8 | Forced relay (LAN off on both sides) | +| 9 | LAN, via `--ip` | +| 10 | A croc v10 peer, both ways, expecting `peerVersion` | +| 11 | Every public pool relay (see below) | +| 12 | File policy (see below) | +| 13 | Transport (see below) | +| 14 | Stored transfers (see below) | +| 15 | Advanced options, described in "Advanced options on the wire" | -- Go ≥1.27.2 (brew; `crocmobile/go.mod` pins `go 1.27.2`), gomobile+gobind built by the build script from the `tool` block in `crocmobile/go.mod`. Xcode 27.0 and 27.1 bind clean (no #53316-class friction). macOS slice arm64-only (golang/go#73119); iOS device App Store layout issue golang/go#66500 is closed, and the first App Store upload passed validation (2026-10-14; gomobile still writes `MinimumOSVersion = 100.0`, which App Store Connect accepted). -- CI notes: verify scripts need outbound network (public relay) + `CROC`/`SIM` env; fresh clone must run `scripts/build-xcframework.sh` before any Swift build (binaryTarget points at gitignored artifact). +`RELAY=host:port` routes every transfer through one relay. It skips the transport scenario (13), the pool scenario (11), and the proxy and internal DNS cases of scenario 15. Scenario 15's curve, hash, port and multicast cases still run. + +Start order in the harness (see "Start order: sender first"): + +- With a croctest sender, it waits for croctest's `EVENT code` line, plus 3 s, before a CLI receiver joins. +- When the CLI sends, it waits a fixed 3 s. + +Scenario 11 (pool): + +- Each of the 4 pool relays gets a custom code that maps to it, sent wrapper to CLI and CLI to wrapper, and a wrapper-generated code forced onto it with `croctest send -relay-index N`. One CLI-generated code is added (the CLI caches its probed relay, so that covers one index). +- LAN is off on both sides (`-no-local` and `send --no-local`) so only matching relays meet. It asserts croctest's `EVENT relay ` and the CLI `--debug` line `public relay index N selected: `. +- It waits 60 s first, runs with multiplexing off (`-no-multi`, `--no-multi`), and makes one pass over the pool per kind of transfer. A relay admits 30 connections a minute from one address (`../known-issues.md`, "Accepted"). + +Scenario 12 (file policy), which also runs under `RELAY`, so CI covers it: + +- A CLI sends a file onto an existing one and `croctest` receives with Keep Both and with Replace, answered at the prompt and preset with `-yes` (4 cases). +- Then a zipped folder (`croc send --zip`) is received twice into one folder with each policy and each accept mode (4 cases). +- `croctest receive` takes `-policy keepBoth|replace` (default `keepBoth`, other values exit 2). + +Scenario 13 (transport): + +- A 130 MiB send in each of `auto` and `relay`, wrapper to CLI and CLI to wrapper (4 cases). It uses public infrastructure only, because Tailcat bootstraps from `tailcat.dev`. +- It asserts byte equality. In the CLI `--debug` log it asserts a `tailcat-offer` under `auto` (or `selected=derp`, the CLI's size decision, when the CLI sends) and none under `relay`. +- `croctest send` takes `-transport auto|relay|derp` (default `auto`). + +Scenario 14 (stored transfers), which needs neither a relay nor the internet, so it runs under `RELAY` too: + +- It starts `croctest store-server` on a loopback port. +- It checks wrapper upload then CLI download, wrapper revoke then CLI download fails, CLI upload then wrapper download, and CLI revoke giving `storeExpired`. +- It checks an existing name with Keep Both, Replace and a declined preview. +- The CLI's revoke receipts go to a throwaway `CROC_CONFIG_DIR`. + +`croctest` also has `relay CODE` and `relays`. The relay cache lives in `crocmobile/internal/relaycache`, so `croctest` can seed it without adding to the gobind API. + +### `scripts/verify-app-sim.sh` + +Boots the simulator and installs the app. It runs two scenarios: CLI to app via the `--auto-receive CODE` launch argument, then the share-extension handoff via `--auto-share-send CODE`. Each gates on an exact `ok success=true` plus a byte diff. + +The launch arguments drive the real UI state machine (`AutoVerify` and `TransferController`, accepting through `respond(true)`), not engine-level auto-accept. The point is that the verified path is the one users take. + +### `scripts/verify-app-mac.sh` + +12 scenarios, the macOS app through launch arguments. Every transfer gates on a byte-identical diff and the app's `verify-result.txt`. + +- CLI to app receive (with history) +- app to CLI send with a custom code (`--auto-send PATH --code CODE`; the source file must live in the app container because of the sandbox) +- local-only send (`--local`; a sandboxed relay-listener proof, the CLI receives via `--ip`) +- send through a custom self-hosted relay (`croc relay --ports 9021,9022,9023`, then `--relay localhost:9021`) +- `--no-compress` send +- `--ask` both-sides confirm (auto-answered by `AutoVerify` through `.confirmSend`) +- receive onto an existing file in the app's Documents, with `--when-exists keepBoth|replace` (the answer `AutoVerify` gives the incoming sheet; prints `MAC-COLLIDE-OK`) +- a 130 MiB transfer in each direction under `--transport auto` and `--transport relay` (prints `MAC-TRANSPORT-OK`; same offer assertions as interop scenario 13; needs the public internet) +- stored transfers against a local store server +- menu-bar quick send +- Open at login +- the Shortcuts actions + +Traps: + +- The app declares document types, so bare adjacent argv tokens are document-open candidates. Pass flags only (`--code CODE`). +- Every launch needs `-ApplePersistenceIgnoreState YES`, or a headless window restoration hangs. +- App-as-sender scenarios wait for the `verify-code-ready.txt` marker that `AutoVerify` writes when a send reaches its code, then 3 s (see "Start order: sender first"). +- The container Documents folder is `~/Library/Containers/com.bakirgdev.CrocApp/Data/Documents/`. + +### `crockit-verify` + +A CrocKit executable for the Swift layer. Subcommands: `send`, `receive` (optionally answering `y` or `n` and cancelling after a number of milliseconds) and `twice`. + +`twice` runs two transfers in one process, the second receiving with `.replace`. It proves that the restoration of fd 0, stdout, cwd and the mutex composes across transfers. + +## Toolchain -Shell and build-tool traps that bite these harnesses live in `tooling.md`. +- Go: `crocmobile/go.mod` pins `go 1.27.2` (install with Homebrew). The build script builds gomobile and gobind from the `tool` block in `crocmobile/go.mod`. +- Xcode 27.0 and 27.1 bind cleanly. +- The macOS slice is arm64 only (golang/go#73119). +- gomobile still writes `MinimumOSVersion = 100.0` into the iOS framework. App Store Connect accepted it on upload validation. The iOS device layout issue golang/go#66500 is closed. +- A fresh clone must run `scripts/build-xcframework.sh` before any Swift build, because the `binaryTarget` points at the gitignored artifact. +- Verify scripts need outbound network (public relay) and the `CROC` and `SIM` environment variables. diff --git a/docs/knowledge/device-test-checklist.md b/docs/knowledge/device-test-checklist.md index 24f63a8..6eb7c6e 100644 --- a/docs/knowledge/device-test-checklist.md +++ b/docs/knowledge/device-test-checklist.md @@ -1,86 +1,104 @@ # Physical-device test checklist -The simulator cannot verify any of these. Run them on a real iOS 26 device before store submission — this is the hardware gate. Run so far (owner, 2026-10-14, on a physical iPhone): "Links and web receive" 1-6 and "Transport and Tailcat gate" (a)-(d), all passed. Every other section, including "Stored transfers (R18-R21)", is still unrun. +A repeatable procedure for what the simulator and the verify scripts cannot check: real iPhone, iPad and Mac hardware. This file records no results. Tick items on your own copy or in the pull request. -## Background continuation (BGContinuedProcessingTask) -- [ ] Start multi-GB receive, background the app → system Live Activity shows title + progress + cancel -- [ ] Transfer completes in background (best-effort; low-progress kills expected under pressure) -- [ ] Force-expire (long background under memory pressure) → reopen app shows "iOS paused the transfer…" message; restarting same transfer and choosing Replace resumes partial file (croc resume; Keep Both saves a new copy instead) -- [ ] Cancel from the system Live Activity → app shows cancelled state -- [ ] Wildcard identifier registers OK on-device (fallback static id is the backstop; check console for register() failures) +Run the whole list before each App Store submission, and run the matching section after any change to that area (background handling, share extension, links, stored transfers, menu bar, Shortcuts, transport). + +Setup unless a section says otherwise: an iPhone or iPad on iOS or iPadOS 26 or later, a Mac on macOS 26 or later with CrocApp, and the croc CLI v11 for the other end. Hash files with `shasum -a 256`. To hash a file received on the iPhone, copy it to the Mac first. + +## Background continuation + +- [ ] Start a multi-GB receive, then background the app. The system Live Activity shows the title, progress and a cancel control. +- [ ] The transfer completes in the background. This is best effort: iOS may kill low-progress transfers under pressure. +- [ ] Force an expiry (a long time in the background under memory pressure), then reopen the app. It shows "iOS paused the transfer…". Start the same transfer again and choose Replace: the partial file resumes. With Keep Both the app saves a new copy instead. +- [ ] Cancel from the system Live Activity. The app shows the cancelled state. +- [ ] The wildcard task identifier registers on the device. The fallback static identifier is the backstop, so check the console for `register()` failures. ## Share extension -- [ ] Photos → share sheet → CrocApp: stages, "Open CrocApp" instruction shown -- [ ] Files app → share multiple files → CrocApp: all staged -- [ ] Open app afterwards → staged sheet appears → Send completes to another device -- [ ] Large video (>500 MB) stages without extension being killed (memory cap) + +- [ ] Photos > share sheet > CrocApp: the files are staged and the "Open CrocApp" instruction shows. +- [ ] Files app > share several files > CrocApp: all are staged. +- [ ] Open the app afterwards. The staged sheet appears, and Send completes to another device. +- [ ] A large video (over 500 MB) stages without the extension being killed (memory cap). ## Files app visibility -- [ ] "On My iPhone → CrocApp" appears after first receive; received files visible -- [ ] "Open in Files" button lands in the right folder -- [ ] "Open in Files" with a user-picked provider folder (iCloud Drive etc.): known silent no-op (shareddocuments:// resolves local paths only) — confirm no crash, consider hiding button there later + +- [ ] "On My iPhone > CrocApp" appears after the first receive, and the received files are visible. +- [ ] **Open in Files** on the receive-done screen lands in the right folder. +- [ ] Choose a provider folder (such as iCloud Drive) as the output folder and receive a file. The **Open in Files** button is not shown. ## Local network -- [ ] First transfer triggers local-network permission prompt -- [ ] Deny → banner appears with Settings shortcut; relay transfer still succeeds -- [ ] Multicast entitlement still pending Apple approval → LAN discovery expected to lose the race even when granted (constraint doc §2) + +- [ ] The first transfer triggers the local-network permission prompt. +- [ ] Deny it. A banner with a Settings shortcut appears, and a relay transfer still succeeds. +- [ ] LAN discovery on iOS depends on the multicast entitlement (`apple-platform-constraints.md`, "Hard constraints", item 2). Without it, expect the LAN path to lose the race to the relay. ## Idle timer -- [ ] Screen stays awake during a long foreground transfer; sleeps normally after - -## Links and web receive (R22, R23) -Owner-run on a physical iPhone, with a Mac running CrocApp as the sender (start a send and leave it on the waiting screen). The simulator has no camera and cannot resolve universal links. -- [x] 1. CrocApp installed on the iPhone. Point the Camera app at the waiting-screen QR on the Mac. Pass: CrocApp opens on Receive with the code filled in, and nothing connects until you tap Receive; after you tap Receive the transfer completes -- [x] 2. Delete CrocApp from the iPhone (or use an iPhone without it) and scan the same QR with the Camera app. Pass: Safari opens `https://crocapp.dev/r/`, the page shows the code, and it has the "Open in CrocApp", "Get CrocApp" (the download section of crocapp.dev), "Mac disk image" and "Receive in your browser" buttons -- [x] 3. Install CrocApp again, open the page from step 2 (scan the QR once more, long-press the link and open it in Safari, or open it from Notes), and tap "Open in CrocApp". Pass: CrocApp opens on Receive with the code filled in and does not connect by itself -- [x] 4. In CrocApp, tap Scan QR Code and scan an old-style `croc://` QR (make one from any QR generator with text `croc://word-word-word`). Pass: the code field fills with `word-word-word` and nothing connects -- [x] 5. Copy `https://getcroc.com/?code=` (a link the croc CLI shows with `--qrcode`, or typed by hand), tap Paste on Receive. Pass: the code field shows only ``, not the link -- [x] 6. On the page from step 2, tap "Receive in your browser" while the Mac sender uses default relays. Pass: Safari opens `https://getcroc.com/?code=` and the croc web app offers the transfer. Informational: this is croc's own web app, not CrocApp - -## Stored transfers (R18-R21) -Owner-run on a physical iPhone, with the default server (`https://getcroc.com`) and the public internet. Items 3 and 4 need a second phone (3) and any device that can hash the received file (4). Use `shasum -a 256` on the Mac; copy a file received on the iPhone to the Mac to hash it. -- [ ] 1. Send as link a 1-2 MB file (Send > Files > Send as link > Upload). Background the app mid-upload, then return. Pass: the upload completes and shows "Link ready", or it shows "Upload interrupted: iOS stopped it in the background. Try again." and sending again completes -- [ ] 2. On the iPhone tap Copy Link on the result screen, then paste on a Mac signed in to the same iCloud account (Handoff and Universal Clipboard on). Pass: nothing pastes on the Mac, because the link is copied for this device only -- [ ] 3. On a second phone with CrocApp, open Receive > Scan QR Code and scan the result-screen QR of a stored link. Pass: Receive shows "Download link from " in place of the code field, and nothing downloads until you tap Receive. Then, on the same QR, point the Camera app of a phone with CrocApp at it and of a phone without CrocApp. Informational: the stored link points at the server, not at `crocapp.dev/r/`, so the Camera is expected to open the server's page in the browser on both phones, not CrocApp. Record what each phone does and what the page shows -- [ ] 4. In CrocApp, receive the link (tap Receive). Pass: the incoming preview lists the file, Accept saves it, and its SHA-256 equals the source file's -- [ ] 5. In History, open the upload's row, choose Revoke Link (swipe or touch and hold) and confirm. Pass: the row reads "Link revoked", and reopening the link in a browser reports the link as gone - -## Menu bar on a Mac (R26) -Owner-run on a real Mac, with the croc CLI as the receiver (`croc --yes ` in an empty folder). `verify-app-mac.sh` scenarios 10 and 11 already cover a menu-bar send of a file and of clipboard text received by the CLI, and Open at login following the system status; these items cover the real gestures and the real login. Compare SHA-256 with `shasum -a 256`. VoiceOver on the menu-bar window is part of owner step O12, not repeated here. -- [ ] 1. Click the menu-bar icon, then drag a file from Finder onto "Drop files to send". Pass: the window shows the code, a QR and Copy Code, the CLI receives the file, and its SHA-256 equals the source's. Also try dropping onto the icon itself: expected to do nothing (`../known-issues.md`) -- [ ] 2. Click Choose Files…, pick a file and a folder in the open panel. Pass: the panel stays open until you confirm, the send starts, and the CLI receives both with equal SHA-256 -- [ ] 3. Copy some text, click Send Clipboard Text. Pass: the CLI receives the text. With an empty clipboard the window says "The clipboard has no text to send." -- [ ] 4. Start a send from the main window. Pass: the menu-bar window shows the same transfer, and its drop zone and buttons are not offered until it ends -- [ ] 5. Settings > General > Show in menu bar off, then on. Pass: the icon disappears and returns without a relaunch -- [ ] 6. Turn Open at login on, log out and back in. Pass: CrocApp starts at login. Then remove it in System Settings > General > Login Items and reopen CrocApp Settings. Pass: the toggle is off -- [ ] 7. If macOS asks for approval (the toggle is on and the setting says to allow CrocApp in Login Items), click Open Settings. Pass: System Settings opens at Login Items; after you allow CrocApp the note disappears - -## Shortcuts actions (R27) -Owner-run on a Mac and on an iPhone, each against the croc CLI (`croc` v11). Hash with `shasum -a 256`; copy a file received on the iPhone to the Mac to hash it. The actions are not verified on a device yet. -- [ ] 1. Mac, Shortcuts app: run "Send Files with CrocApp" with a file. Pass: CrocApp comes to the front, the action returns the code and shows "The code is X. Keep CrocApp open until the transfer ends.", `croc --yes ` receives the file, and the SHA-256 equals the source's -- [ ] 2. iPhone: same as 1. Pass: the same, with CrocApp in the foreground for the whole transfer -- [ ] 3. Mac and iPhone: run "Send Text with CrocApp" with a short text. Pass: the code comes back and `croc --yes ` prints the text -- [ ] 4. Mac and iPhone: start `croc send ` on the CLI and run "Receive with CrocApp" with its code. Pass: the incoming prompt appears in the app (or is skipped with Auto-accept on), the action waits for the end, returns the file, shows "Received 1 item.", and the SHA-256 equals the source's -- [ ] 5. Receive with a `crocapp.dev/r/#` link and with a Send as link link. Pass: both work as in 4 -- [ ] 6. Receive a text sent with `croc send --text hi`. Pass: the action returns a file named "Received text.txt" -- [ ] 7. Receive a file whose name already exists in the receive folder, once with Keep Both and once with Replace. Pass: the returned file is the new copy (`name (1).ext` for Keep Both) + +- [ ] The screen stays awake during a long foreground transfer and sleeps normally afterwards. + +## Links and web receive + +Use an iPhone and a Mac running CrocApp as the sender (start a send and leave it on the waiting screen). The simulator has no camera and cannot resolve universal links. + +- [ ] 1. With CrocApp installed on the iPhone, point the Camera app at the waiting-screen QR on the Mac. Pass: CrocApp opens on Receive with the code filled in, and nothing connects until you tap **Receive**. After you tap it, the transfer completes. +- [ ] 2. Delete CrocApp from the iPhone (or use an iPhone without it) and scan the same QR with the Camera app. Pass: Safari opens `https://crocapp.dev/r/`, the page shows the code, and it has the **Open in CrocApp**, **Get CrocApp**, **Mac Disk Image** and **Receive in your browser** buttons. +- [ ] 3. Install CrocApp again, open the page from step 2 (scan again, long-press the link and open it in Safari, or open it from Notes), and tap **Open in CrocApp**. Pass: CrocApp opens on Receive with the code filled in and does not connect by itself. +- [ ] 4. In CrocApp, tap **Scan QR Code** and scan a `croc://` QR (make one with any QR generator, text `croc://word-word-word`). Pass: the code field fills with `word-word-word` and nothing connects. +- [ ] 5. Copy `https://getcroc.com/?code=` (a link the croc CLI shows with `--qrcode`, or typed by hand) and tap **Paste** on Receive. Pass: the code field shows only ``, not the link. +- [ ] 6. On the page from step 2, tap **Receive in your browser** while the Mac sender uses the default relays. Pass: Safari opens `https://getcroc.com/?code=` and the croc web app offers the transfer. This is croc's own web app, not CrocApp. + +## Stored transfers + +Use an iPhone with the default server (`https://getcroc.com`) and the public internet. Item 3 needs a second phone, and item 4 needs a way to hash the received file. + +- [ ] 1. Send a 1 to 2 MB file as a link: Send > Files > turn on **Send as Link** > **Send as Link**. Background the app mid-send, then return. Pass: the send completes and shows "Link ready", or it shows "Sending was interrupted: iOS stopped it in the background. Try again." and sending again completes. +- [ ] 2. Tap **Copy Link** on the result screen, then paste on a Mac signed in to the same iCloud account (Handoff and Universal Clipboard on). Pass: nothing pastes on the Mac, because the link is copied for this device only. +- [ ] 3. On a second phone with CrocApp, open Receive > **Scan QR Code** and scan the result-screen QR of a stored link. Pass: Receive shows a **Receive from Link** row with the server host in place of the code field, and nothing downloads until you tap **Receive**. +- [ ] 3b. Point the Camera app at the same QR on a phone with CrocApp and on a phone without it. The stored link points at the server, not at `crocapp.dev/r/`, so the Camera is expected to open the server's page in the browser on both phones, not CrocApp. Note what each phone does and what the page shows. +- [ ] 4. In CrocApp, receive the link (tap **Receive**). Pass: the incoming preview lists the file, **Accept** saves it, and its SHA-256 equals the source file's. +- [ ] 5. In History, open the sent row and choose **Revoke** (swipe) or **Revoke Link** (touch and hold), then confirm. Pass: the row reads "Link revoked", and reopening the link in a browser reports the link as gone. + +## Menu bar on a Mac + +Use a real Mac with the croc CLI as the receiver (`croc --yes ` in an empty folder). `scripts/verify-app-mac.sh` already covers a menu-bar send of a file and of clipboard text received by the CLI, and Open at Login following the system status. These items cover the real gestures and the real login. Compare SHA-256 values. VoiceOver in the menu-bar window belongs to the accessibility audit in `docs/known-issues.md`, "Release". + +- [ ] 1. Click the menu-bar icon, then drag a file from Finder onto "Drop files to send". Pass: the window shows the code, a QR code and **Copy Code**, the CLI receives the file, and its SHA-256 equals the source's. Dropping onto the icon itself is expected to do nothing (`docs/known-issues.md`, "Accepted"). +- [ ] 2. Click **Choose Files…** and pick a file and a folder in the open panel. Pass: the panel stays open until you confirm, the send starts, and the CLI receives both with equal SHA-256. +- [ ] 3. Copy some text and click **Send Clipboard Text**. Pass: the CLI receives the text. With an empty clipboard the window says "The clipboard has no text to send." +- [ ] 4. Start a send from the main window. Pass: the menu-bar window shows the same transfer, and its drop zone and buttons are not offered until it ends. +- [ ] 5. Turn Settings > General > **Show in Menu Bar** off, then on. Pass: the icon disappears and returns without a relaunch. +- [ ] 6. Turn **Open at Login** on, then log out and back in. Pass: CrocApp starts at login. Then remove it in System Settings > General > Login Items and reopen CrocApp Settings. Pass: the toggle is off. +- [ ] 7. If macOS asks for approval (the toggle is on and a note says to allow CrocApp in Login Items), click **Open Settings**. Pass: System Settings opens at Login Items, and after you allow CrocApp the note disappears. + +## Shortcuts actions + +Run on a Mac and on an iPhone, each against the croc CLI (v11). + +- [ ] 1. Mac, Shortcuts app: run **Send Files with CrocApp** with a file. Pass: CrocApp comes to the front, the action returns the code and shows "The code is X. Keep CrocApp open until the transfer ends.", `croc --yes ` receives the file, and the SHA-256 equals the source's. +- [ ] 2. iPhone: same as 1. Pass: the same, with CrocApp in the foreground for the whole transfer. +- [ ] 3. Mac and iPhone: run **Send Text with CrocApp** with a short text. Pass: the code comes back and `croc --yes ` prints the text. +- [ ] 4. Mac and iPhone: start `croc send ` on the CLI and run **Receive with CrocApp** with its code. Pass: the incoming prompt appears in the app (or is skipped with Auto-Accept Transfers on), the action waits for the end, returns the file, shows "Received 1 item.", and the SHA-256 equals the source's. +- [ ] 5. Receive with a `crocapp.dev/r/#` link and with a Send as Link link. Pass: both work as in 4. +- [ ] 6. Receive a text sent with `croc send --text hi`. Pass: the action returns a file named "Received text.txt". +- [ ] 7. Receive a file whose name already exists in the receive folder, once with Keep Both and once with Replace. Pass: the returned file is the new copy (`name (1).ext` for Keep Both). - [ ] 8. Start a transfer, then run any of the three actions. Pass: it fails with "CrocApp is already sending or receiving. Wait for it to finish." -- [ ] 9. Run "Send files with CrocApp" and "Receive with CrocApp" from Siri and Spotlight. Pass: each starts the action -- [ ] 10. Mac: build a shortcut that uses "Send Files with CrocApp" and enable it as a Quick Action in Finder (shortcut details > Use as Quick Action). Pass: it appears in Finder's Quick Actions for selected files and returns the code. Inferred to work, never run +- [ ] 9. Run the send-files and receive actions from Siri and from Spotlight ("Send files with CrocApp", "Receive with CrocApp"). Pass: each starts its action. +- [ ] 10. Mac: build a shortcut that uses **Send Files with CrocApp** and enable it as a Quick Action in Finder (shortcut details > Use as Quick Action). Pass: it appears in Finder's Quick Actions for selected files and returns the code. This is inferred to work and has not been run. + +## iPad File menu + +Use an iPad on iPadOS 26 with a keyboard, or the pointer to open the menu bar. -## iPad File menu (R28) -Owner-run on a real iPad with iPadOS 26 and a keyboard or the pointer to open the menu bar. -- [ ] 1. Open the menu bar, File. Pass: Send… (⌘1), Receive… (⌘2) and Show Receive Folder in Files (⇧⌘R) are listed -- [ ] 2. After a receive, choose Show Receive Folder in Files, or press ⇧⌘R. Pass: the Files app opens at the CrocApp receive folder. Record what happens when the receive folder is a user-picked folder from a provider such as iCloud Drive: the `shareddocuments://` scheme resolves local paths only, so a silent no-op is expected there (see "Files app visibility") +- [ ] 1. Open the menu bar, then File. Pass: **Send…** (⌘1), **Receive…** (⌘2) and **Show Receive Folder in Files** (⇧⌘R) are listed. +- [ ] 2. After a receive, choose **Show Receive Folder in Files** or press ⇧⌘R. Pass: the Files app opens at the CrocApp receive folder. +- [ ] 3. Repeat with a user-picked provider folder (such as iCloud Drive) as the receive folder. The `shareddocuments://` scheme resolves local paths only, so a silent no-op is expected. Note what happens and confirm there is no crash. -## Transport and Tailcat gate -Owner-run on a physical iPhone, against the Mac CLI (`/opt/homebrew/bin/croc`, v11). Use a file of at least 130 MiB, because croc starts Tailcat only for sends of 128 MiB or more (`crocmobile-bridge.md`, "Transport"). Under Automatic croc races Tailcat against its relay, so the CLI `--debug` log shows which side offered it. Report each SHA-256 (`shasum -a 256` on the Mac; copy a file received on the iPhone to the Mac to hash it) so Claude can compare them with the source file's. Pass for the gate is (a) to (c) complete with equal SHA-256 and no crash or hang. (d) is informational. -- [x] (a) Settings > Connection = Automatic. Send the file from the iPhone to `croc --debug --yes` on the Mac, once with the app in the foreground and once switching to another app mid-transfer and back. Pass: both transfers complete, the SHA-256 matches the source, and the CLI log contains `tailcat-offer` -- [x] (b) Connection = Relay only. Same send, foreground. Pass: the transfer completes, the SHA-256 matches, and the CLI log has no `tailcat-offer` -- [x] (c) On the Mac run `croc --debug --ignore-stdin send --transport auto ` and receive on the iPhone, once in the foreground and once backgrounded mid-transfer. Pass: both complete and the SHA-256 matches -- [x] (d) Informational: on the Mac run `croc --debug --ignore-stdin send --transport derp ` and receive on the iPhone. Note whether it completes. This records whether Tailcat's own data path works on iOS; on the development Mac the same flag failed CLI to CLI (`../known-issues.md`, Harnesses) -- Archived iOS app size, with Tailcat (measured 2026-10-14 with `scripts/build-appstore.sh ios`, export only): `CrocApp.ipa` 18.5 MB (18,471,600 bytes); `CrocApp.app` in the archive 25 MB, of which the main executable (Go engine linked in) is 20.3 MB (20,332,352 bytes) +## Transport and Tailcat -(a) to (d) passed on 2026-10-14 (the owner reported "all pass"; the SHA-256 values were not sent to Claude, so the hash matches rest on the owner's check). The gate passes: the Connection setting stays and no `croc_no_tailcat` build is needed. (d) passing means Tailcat's own data path completed on iOS. +Use an iPhone and the Mac CLI at `/opt/homebrew/bin/croc` (v11). Use a file of at least 130 MiB, because croc starts Tailcat only for sends of 128 MiB or more (`crocmobile-bridge.md`, "Engine behavior invariants"). Under Automatic, croc races Tailcat against its relay, so the CLI `--debug` log shows which side offered it. -If (a) to (c) had failed: build the engine with `-tags croc_no_tailcat` in `scripts/build-xcframework.sh`, remove the Connection setting and the "Direct path for large files" item in How it works (its relay item stays), and file a `future` issue. A build without Tailcat downgrades croc's `derp` to `relay` (`what-is-croc.md`). +- [ ] 1. Settings > **Connection** = **Automatic**. Send the file from the iPhone to `croc --debug --yes` on the Mac, once with the app in the foreground and once switching to another app mid-transfer and back. Pass: both complete, the SHA-256 matches the source, and the CLI log contains `tailcat-offer`. +- [ ] 2. **Connection** = **Relay Only**. Same send, foreground. Pass: the transfer completes, the SHA-256 matches, and the CLI log has no `tailcat-offer`. +- [ ] 3. On the Mac run `croc --debug --ignore-stdin send --transport auto ` and receive on the iPhone, once in the foreground and once backgrounded mid-transfer. Pass: both complete and the SHA-256 matches. +- [ ] 4. On the Mac run `croc --debug --ignore-stdin send --transport derp ` and receive on the iPhone. This checks whether Tailcat's own data path works on iOS. CLI to CLI the same flag can fail on some Macs (`docs/known-issues.md`, "Harnesses"), so a failure here alone is not a CrocApp defect. Note the outcome. diff --git a/docs/knowledge/docs-site.md b/docs/knowledge/docs-site.md index 15e5e9c..bc68f19 100644 --- a/docs/knowledge/docs-site.md +++ b/docs/knowledge/docs-site.md @@ -28,25 +28,25 @@ scripts/assemble-site.sh # writes ./_site/, landing at /, docs at /docs New file under `web/docs/content/` (not `docs/`, that name is taken by the repository's own contributor docs). Frontmatter needs `title`, `description`, and `sidebar_position`. The sidebar is autogenerated from the directory tree, so nothing is ever added to `sidebars.ts`: order comes from `sidebar_position`, and a new directory needs a `_category_.json`. `routeBasePath: '/'`, so `content/guide/foo.md` serves at `/docs/guide/foo/`. -The docs site is English only. The `bs`, `de`, `es`, `fr` and `ru` drafts were removed for 1.0 (#11); git history keeps them. +The docs site is English only: `docusaurus.config.ts` configures the single locale `en`. ## Cutting a version -Not needed yet. `current` is labelled `1.0` in `docusaurus.config.ts` (`versions.current.label`) and no `versioned_docs/` exists: no snapshot of the earlier 0.9.9 build is cut, because nothing of it needs its own docs. When a later release changes behavior that the old version's readers still need described, run `pnpm --dir web/docs run docusaurus docs:version 1.0` first, then relabel `current` for the new release. `web/docs/package.json` carries the release version (`1.0.0`). Until a second version exists, the navbar's `docsVersionDropdown` renders nothing, that is Docusaurus hiding it below two versions, not a misconfiguration. +No second version exists. `current` is labelled `1.0` in `docusaurus.config.ts` (`versions.current.label`) and no `versioned_docs/` exists. When a later release changes behavior that the old version's readers still need described, run `pnpm --dir web/docs run docusaurus docs:version 1.0` first, then relabel `current` for the new release. `web/docs/package.json` carries the release version (`1.0.0`). Until a second version exists, the navbar's `docsVersionDropdown` renders nothing. Docusaurus hides it below two versions, so this is not a misconfiguration. ## Broken links fail the build -`onBrokenLinks`, `onBrokenAnchors`, `markdown.hooks.onBrokenMarkdownLinks`, and `markdown.hooks.onBrokenMarkdownImages` are all `throw`. A bad internal link or anchor fails `pnpm run build`, which fails `scripts/assemble-site.sh`, which fails `github-pages.yml`. `github-pages.yml` runs the same assembly on `pull_request` (build only, no deploy), so this is caught before merge, not only after a push to `main`. +`onBrokenLinks`, `onBrokenAnchors`, `markdown.hooks.onBrokenMarkdownLinks`, and `markdown.hooks.onBrokenMarkdownImages` are all `throw`. A bad internal link or anchor fails `pnpm run build`, which fails `scripts/assemble-site.sh`, which fails `github-pages.yml`. `github-pages.yml` runs the same assembly on `pull_request` (build only, no deploy), so a broken link is caught before merge. ## One workflow publishes both surfaces -`github-pages.yml` calls `scripts/assemble-site.sh` and uploads the whole `_site/`. Editing only `web/docs/**` still rebuilds the Docusaurus site *and* re-copies `web/landing/` verbatim, and editing only `web/landing/**` triggers the reverse — GitHub Pages replaces the entire artifact on every deploy, so a single workflow always ships both. +`github-pages.yml` calls `scripts/assemble-site.sh` and uploads the whole `_site/`. Editing only `web/docs/**` still rebuilds the Docusaurus site and re-copies `web/landing/` verbatim, and editing only `web/landing/**` does the reverse. GitHub Pages replaces the entire artifact on every deploy, so a single workflow always ships both. The workflow triggers on `web/landing/**`, `web/docs/**`, `design/tokens.css`, `scripts/assemble-site.sh` and its own file. ## What `assemble-site.sh` asserts -After copying both surfaces it fails the build unless `index.html`, `404.html`, `.well-known/security.txt`, `.well-known/apple-app-site-association` (listing both app IDs) and `docs/index.html` exist. It also checks the link page `web/landing/r/index.html` (R23): it must exist, set `` (the code lives in the URL fragment), and load no remote `script`, `link`, `img` or `iframe`. Keep that page free of third-party resources; the one network hop it offers is a user-clicked link to getcroc.com. +The script fails the build unless the landing files and the receive-link page are in place. The list is in `landing-page.md`, "How it ships". It also requires `index.html` under the `docs` folder. ## Traps - **Frontmatter values containing a colon must be quoted.** `description: Fixes for X: stalled transfers` breaks the build with "bad indentation of a mapping entry". Quote the whole value: `description: "Fixes for X: stalled transfers"`. Several pages under `content/` already need this, check before adding a colon to any frontmatter field. -- **`showLastUpdateTime` renders nothing for a file that is not yet committed.** It reads git history per file (`fetch-depth: 0` in both Pages workflows makes this work in CI); a new page with only uncommitted local edits shows no "last updated" line until it lands in a commit. Not a bug, just confusing while drafting. +- **`showLastUpdateTime` renders nothing for a file that is not yet committed.** It reads git history per file (`fetch-depth: 0` in `github-pages.yml` makes this work in CI); a new page with only uncommitted local edits shows no "last updated" line until it lands in a commit. This is not a bug, only confusing while drafting. diff --git a/docs/knowledge/features.md b/docs/knowledge/features.md index 70516bf..05824b2 100644 --- a/docs/knowledge/features.md +++ b/docs/knowledge/features.md @@ -1,85 +1,85 @@ -# Feature roadmap +# Features -Approved 2026-07-22 by Bakir. Tier = target release. Numbers are stable — reference them as F1..F44 in plans, issues, and code comments (several views already carry them). +What CrocApp does, what it deliberately skips, and which croc option each feature maps to. Plans live in `docs/ROADMAP.md`. -Status is verified against the code, not against intent. **Shipped** means the feature exists and works in the app; it does not mean it has been tested on physical hardware (`device-test-checklist.md`) or that it has no open defects (`../known-issues.md`). +Status is checked against the code, not against intent. **Shipped** means the feature exists and works in the app. It does not mean it was tested on physical hardware (`device-test-checklist.md`) or that it has no open defects (`docs/known-issues.md`). -## V1 (approved, must ship) +Statuses: **shipped** (in the code) and **skipped** (deliberately not offered). -### Core +## Core -| # | Feature | croc flag / origin | Status | +| Feature | croc option or origin | Platform | Status | |---|---|---|---| -| F1 | Send files (multi) | positional; drag-drop + fileImporter | shipped | -| F2 | Send folders | positional | shipped | -| F3 | Send text/clipboard | `--text` | shipped | -| F4 | Receive via code | positional | shipped | -| F5 | Custom code phrase (min 6 chars) | `--code` | shipped | -| F6 | QR show on send + QR scan on receive | `--qrcode` + GUI-native | shipped | -| F7 | Choose output folder; iOS default = Files-visible app folder | `--out` | shipped | -| F8 | Keep Both / Replace on receive, via confirm sheets; resume with Replace | `--rename` / `--overwrite` + resume | shipped | -| F9 | Incoming file list preview + accept/decline | interactive prompt equivalent | shipped | -| F10 | Progress, speed, cancel; Live Activity on iOS | polled from engine | shipped | -| F11 | LAN + relay auto race | croc default | shipped | -| F12 | Transfer history (local only) | GUI-native | shipped | - -### Power options (settings screen, sane defaults) - -| # | Feature | croc flag | Status | +| Send files (several at once) | positional; drag and drop plus file picker | all | shipped | +| Send folders | positional | all | shipped | +| Send text or clipboard | `--text` | all | shipped | +| Receive with a code | positional | all | shipped | +| Custom code phrase (at least 6 characters) | `--code` | all | shipped | +| QR code on send, QR scan on receive | `--qrcode` plus app-native scanner | all; scanning is iOS and iPadOS only | shipped | +| Choose the output folder (iOS default is the Files-visible app folder) | `--out` | all | shipped | +| Keep Both or Replace on receive, answered on the incoming sheet; Replace resumes a partial file | `--rename`, `--overwrite`, resume | all | shipped | +| Incoming file list preview with Accept and Decline | croc's interactive prompt | all | shipped | +| Progress, speed and Cancel; system Live Activity while backgrounded | engine progress events | Live Activity on iOS and iPadOS | shipped | +| LAN and relay race | croc default | all | shipped | +| Transfer history (local only) | app-native | all | shipped | + +## Power options + +| Feature | croc option | Platform | Status | |---|---|---|---| -| F13 | Custom relay + password + IPv6 | `--relay --relay6 --pass` | shipped | -| F14 | Force local-only | `--local` | shipped | -| F15 | Disable compression | `--no-compress` | shipped | -| F16 | Zip folder before send | `--zip` | shipped | -| F17 | Exclude patterns / respect .gitignore | `--exclude --git` | shipped | -| F18 | Auto-accept toggle (off by default, warning shown) | `--yes` | shipped | -| F19 | Both-sides confirm | `--ask` | shipped | -| F41 | Connection: Automatic or Relay only (sender-side; a receive follows) | `--transport auto\|relay` | shipped | -| F20 | SOCKS5 proxy (Settings > Advanced) | `--socks5` | shipped | -| F21 | HTTP proxy | `--connect` | shipped | -| F22 | Upload speed limit (sender-side) | `--throttleUpload` | shipped | -| F23 | Curve choice (the receiver's wins; the sender adopts it) | `--curve` | shipped | -| F24 | Hash algorithm choice (sender-side) | `--hash` | shipped | -| F25 | Sender IP, direct connect (receive-side) | `--ip` | shipped | -| F26 | Custom multicast address (macOS only: iOS has no multicast entitlement) | `--multicast` | shipped | -| F27 | Base port, parallel connections, multiplexing toggle (sender-side) | `--port --transfers --no-multi` | shipped | -| F28 | Built-in DNS resolver | `--internal-dns` | shipped | - -### GUI-native - -| # | Feature | Status | -|---|---|---| -| F30 | Share extension (send from any app) | shipped, iOS only | -| F32 | Receive links: `crocapp://receive?code=`, `https://crocapp.dev/r/#` universal links, `https://getcroc.com/?code=`, and `croc://` (0.9.9 QR codes); one parser, `TransferLink`. Opening a link prefills Receive and never connects. Universal links in the Developer ID build are unverified (`../known-issues.md`) | shipped | -| F36 | Trust UI: E2E badge, "how it works", active-relay indicator | shipped | -| F42 | Waiting-screen QR and Share Link encode `https://crocapp.dev/r/#`; `web/landing/r/` page reads the code from the fragment (no network request) and offers Open in CrocApp, Get CrocApp (the landing page download section, until the store listing is live), the DMG, and receive in a browser through croc's own web app at getcroc.com (needs the default relays) | shipped | -| F43 | Send as link (stored transfers, R18-R21): files only, up to 100 files / 1 GiB, lifetime 1 hour / 1 day / 7 days, 1-100 downloads, to the server in Settings (`https://getcroc.com` by default); link and QR result screen, receive from a pasted or scanned link through the normal incoming preview, History expiry and Revoke. Needs the device checks in `device-test-checklist.md` | shipped | -| F31 | macOS menu bar quick send (R26): `MenuBarExtra` window with a drop zone, Choose Files… and Send Clipboard Text, then the code, QR and progress of the one shared transfer; generated codes only; settings Show in menu bar (default on) and Open at login (default off, `SMAppService`). Verified by unit tests and `verify-app-mac.sh` scenarios 10 and 11; the owner's Mac checks are in `device-test-checklist.md` | shipped, macOS only | -| F34 | Shortcuts and Siri actions (R27, App Intents): Send Files, Send Text and Receive with CrocApp, also offered as App Shortcuts. They run in the foreground; the send actions return the code, Receive returns the files. Not verified on a device; the Finder Quick Action is inferred (`device-test-checklist.md`) | shipped | -| F44 | iPad File menu (R28): Show Receive Folder in Files (⇧⌘R) beside Send… and Receive…. The Files hand-off is not verified on a device | shipped, iPadOS only | - -The whole V1 set is implemented. What stands between it and release is in `../known-issues.md` under "Blocking release", not in this table. - -## V1.x (approved, post-V1 fast follow) - -The Advanced options (F20-F28) are in the power options table above: `EngineOptions` carries them, `AppSettings` stores them (the proxies in the Keychain), `Views/AdvancedSettingsView.swift` edits them (`app-ui-architecture.md`, "Advanced settings (R25)"). The menu bar quick send (F31) and App Intents (F34) shipped and moved to the GUI-native table above (`app-ui-architecture.md`, "Menu bar and Shortcuts (R26-R28)"). Nothing else in this table has been started. - -| # | Feature | croc flag / origin | -|---|---|---| -| F29 | Run own relay from Mac app (menu-bar relay server) | `croc relay` | -| F33 | Saved codes / favorite peers | GUI-native | -| F35 | Relay health check + diagnostics screen | GUI-native | - -## Later - -| # | Feature | Note | -|---|---|---| -| F37 | Wi-Fi Aware direct path | iOS/iPadOS 26 only, absent on macOS 26 — app↔app fast path | - -## Skip - -| # | Feature | Reason | +| Custom relay, relay password, IPv6 relay | `--relay`, `--relay6`, `--pass` | all | shipped | +| Local Network Only | `--local` | all | shipped | +| Disable Compression | `--no-compress` | all | shipped | +| Zip folders before sending | `--zip` | all | shipped | +| Exclude patterns, respect `.gitignore` | `--exclude`, `--git` | all | shipped | +| Auto-Accept Transfers (off by default, warning shown) | `--yes` | all | shipped | +| Confirm on Both Sides | `--ask` | all | shipped | +| Connection: Automatic or Relay Only (sender side; a receive follows the sender) | `--transport auto\|relay` | all | shipped | +| SOCKS5 proxy (Settings > Advanced) | `--socks5` | all | shipped | +| HTTP proxy | `--connect` | all | shipped | +| Upload speed limit (sender side) | `--throttleUpload` | all | shipped | +| Curve choice (the receiver's choice wins and the sender adopts it) | `--curve` | all | shipped | +| Hash algorithm (sender side) | `--hash` | all | shipped | +| Sender IP, direct connect (receive side) | `--ip` | all | shipped | +| Custom multicast address | `--multicast` | macOS only; iOS has no multicast entitlement | shipped | +| Base port, parallel connections, multiplexing toggle (sender side) | `--port`, `--transfers`, `--no-multi` | all | shipped | +| Built-in DNS resolver | `--internal-dns` | all | shipped | + +## App-native features + +| Feature | Platform | Status | |---|---|---| -| F38 | `--stdout` piping | CLI-only concept | -| F39 | `--classic` mode | insecure by design | -| F40 | `--remember` | GUI settings persist anyway | +| Share extension (send from any app) | iOS and iPadOS | shipped | +| Receive links: `crocapp://receive?code=`, `https://crocapp.dev/r/#` (universal link), `https://getcroc.com/?code=`, and `croc://` (QR payload of older CrocApp builds). One parser, `TransferLink`. Opening a link prefills Receive and never connects. | all | shipped | +| Trust UI: encryption badge, "How it works", active-relay indicator | all | shipped | +| Waiting-screen QR and **Share Link** encode `https://crocapp.dev/r/#` (see below) | all | shipped | +| Send as Link (stored transfers; see below) | all | shipped | +| Menu bar quick send (see below) | macOS only | shipped | +| Shortcuts and Siri actions (see below) | all | shipped | +| **Show Receive Folder in Finder** (macOS) or **Show Receive Folder in Files** (iPadOS) in the File menu | iPadOS (Files) and macOS (Finder) | shipped | + +Details: + +- **Link page.** The `web/landing/r/` page reads the code from the URL fragment, so no request carries it. It offers **Open in CrocApp**, **Get CrocApp** (the download section of the landing page), **Mac Disk Image**, and **Receive in your browser**. The last one goes through croc's own web app at getcroc.com and needs the default relays. +- **Send as Link.** + - Files only, up to 100 files and 1 GiB. + - The link lasts 1 hour, 1 day or 7 days, with 1 to 100 downloads, on the server set in Settings (`https://getcroc.com` by default). + - The result screen shows the link and a QR code. Receiving from a pasted or scanned link goes through the normal incoming preview. + - History shows the expiry and offers **Revoke Link** (context menu) and **Revoke** (swipe on iOS). + - Device checks are in `device-test-checklist.md`, "Stored transfers". +- **Menu bar quick send.** + - A `MenuBarExtra` window with a drop zone, **Choose Files…** and **Send Clipboard Text**, then the code, QR and progress of the one shared transfer. Codes are always generated. + - The **Show in Menu Bar** setting is on by default and **Open at Login** (`SMAppService`) is off by default. + - Covered by unit tests and by `scripts/verify-app-mac.sh` (menu-bar send, clipboard send, Open at Login). The checks that need a real Mac are in `device-test-checklist.md`, "Menu bar on a Mac". +- **Shortcuts and Siri.** **Send Files with CrocApp**, **Send Text with CrocApp** and **Receive with CrocApp** (App Intents), also offered as App Shortcuts. They run in the foreground. The send actions return the code and Receive returns the files. Not verified on a device. That the Finder Quick Action works is inferred, not tested (`device-test-checklist.md`, "Shortcuts actions"). +- **File menu item.** On iPadOS the hand-off to the Files app is not verified on a device. A user-picked provider folder is expected to do nothing there. + +## Skipped + +| Feature | Reason | +|---|---| +| `--stdout` piping | CLI-only concept | +| `--classic` mode | insecure by design | +| `--remember` | app settings persist anyway | + +What stands between the shipped feature set and the first App Store release is listed in `docs/known-issues.md`, "Release". diff --git a/docs/knowledge/landing-page.md b/docs/knowledge/landing-page.md new file mode 100644 index 0000000..0a405df --- /dev/null +++ b/docs/knowledge/landing-page.md @@ -0,0 +1,41 @@ +# Landing page + +Operational knowledge for `web/landing/`, the plain HTML site at `https://crocapp.dev/`. The Docusaurus site under `/docs` is `docs-site.md`. + +## What is in it + +| Path | Purpose | +|---|---| +| `index.html`, `styles.css`, `app.js` | The landing page. `app.js` only enhances (theme toggle, FAQ, nav); the page reads with JavaScript off | +| `r/index.html` | The receive-link page behind `https://crocapp.dev/r/#`. It offers to open the app, get the app or receive in a browser | +| `privacy/`, `support/` | Static pages | +| `.well-known/apple-app-site-association` | Universal links for `/r/*`, for both app IDs (`com.bakirgdev.CrocApp` and `.direct`, each with the team prefix) | +| `.well-known/security.txt` | The address `SECURITY.md` hands out | +| `llms.txt`, `robots.txt`, `sitemap.xml` | Crawler and assistant files. `robots.txt` lists this sitemap and the docs one | +| `CNAME`, `404.html` | Custom domain and not-found page | +| `web/landing/assets/img/` | Store badges, favicons and the web manifest | + +## How it ships + +`scripts/assemble-site.sh` copies `web/landing/` to the site root, copies `design/tokens.css` beside it (the only copy that ships; `index.html` links `tokens.css`, which is never committed under `web/landing/`) and puts the built Docusaurus site under `/docs`. `.github/workflows/github-pages.yml` runs it on a pull request (build only) and on a push to `main` (build and deploy). One deploy always ships both surfaces: `docs-site.md`, "One workflow publishes both surfaces". + +The script fails the build unless these hold: + +- `CNAME`, `index.html` and `404.html` exist at the root. +- `.well-known/security.txt` exists. It is a dot-path, so it reaches production only while `github-pages.yml` keeps `include-hidden-files: true`. +- `.well-known/apple-app-site-association` exists and lists both app IDs. +- `r/index.html` sets `` and loads no remote `script`, `link`, `img` or `iframe`. The code lives in the URL fragment, so the page must not leak it. Its one network hop is a link the user clicks, to getcroc.com. + +## Rules + +- Visual and copy rules are `design/web.md` and `design/content.md`. Colors, spacing and type come from the tokens, with no literals in the page or its stylesheet (`scripts/verify-design.sh` checks). +- Run `scripts/verify-design.sh` after touching `styles.css` or the page, and `scripts/verify-docs.sh` after touching a path this file names. + +## Hand-maintained + +Nothing checks these. They are listed in `docs/known-issues.md`, "Landing page": + +- `llms.txt` release status and the store cards (version, channels, "coming soon" wording) change on every release. +- The store badges stay unlinked until the apps are on the App Store. +- `security.txt` has an `Expires` field to renew. +- The AASA `/r/*` component is wider than the one page that exists. diff --git a/docs/knowledge/prior-art.md b/docs/knowledge/prior-art.md index bbf0c86..a1c0a93 100644 --- a/docs/knowledge/prior-art.md +++ b/docs/knowledge/prior-art.md @@ -1,25 +1,35 @@ -# Prior art & growth notes +# Prior art and growth notes -Research digest 2026-07-22. +Competitor facts here change. Re-check any of them (status, price, popularity) before you quote or rely on it. ## Landscape -Apple-native croc niche effectively empty: only iCroc (paid, closed-source, stale since 2024, embeds croc v10.0.8). Android: crocgui ([howeyc/crocgui](https://github.com/howeyc/crocgui), Go+Fyne, 314★), croc-app ([Dking08/croc-app](https://github.com/Dking08/croc-app), Kotlin/Compose, 87★, best mobile UX reference — but subprocess architecture, not portable to iOS). Desktop: crock ([KD-MM2/crock](https://github.com/KD-MM2/crock), 35★, best desktop UX reference). Adjacent: LocalSend (85k★, LAN-only — breaks on guest/mDNS-hostile networks and iOS backgrounding; croc's relay model is the differentiator), PairDrop, Rymdport, Destiny (wormhole Flutter, stale), Winden, Blip. +No other well-maintained, open-source Apple-native app for croc was known when this was written. What exists: -## UX patterns to adopt +| Project | Platform | Notes | +| --- | --- | --- | +| iCroc | iOS and macOS | Paid, closed source, embeds an old croc (10.x). The only Apple-native croc client found. | +| [crocgui](https://github.com/howeyc/crocgui) | Android, desktop | Go with Fyne. | +| [croc-app](https://github.com/Dking08/croc-app) | Android | Kotlin and Compose. Best mobile UX reference, but it runs the croc binary as a subprocess, which iOS forbids. | +| [crock](https://github.com/KD-MM2/crock) | Desktop | Best desktop UX reference. | +| LocalSend | All | Local network only, so it fails on guest networks and when iOS suspends the app. croc's relay model is the difference. | +| PairDrop, Rymdport, Destiny, Winden, Blip | Various | Adjacent tools, not croc clients. | -- Two-verb home: giant Send / Receive, nothing else. -- QR both directions; clipboard code auto-detect on receive. -- Clipboard/text snippet transfer = top real-world use case (croc-app author's #1). -- Transfer history + saved codes for repeat pairs. -- Custom relay first-class + relay indicator (trust anxiety is real; explain PAKE/E2E in-app). -- Receive preview (file list, sizes) before accept; sanitize filenames; explicit overwrite confirm — answers croc's 2023 audit class of issues, marketing point. -- Deeplink `croc://` encoded in QR (crock does this). -- Positioning: "works when AirDrop/LocalSend don't — different networks, different continents." +## UX patterns worth following -## Growth playbook (LocalSend-derived) +- Two-verb home: one big Send and one big Receive. +- QR code in both directions, and clipboard code detection on Receive. +- Clipboard and text transfer is the most common real use (croc-app's author says so). +- Transfer history for repeat partners. +- Custom relay as a first-class setting, with a visible relay indicator. Users worry about trust, so explain PAKE and end-to-end encryption in the app. +- File list and sizes before accepting, sanitized file names, and an explicit overwrite choice. This answers the class of issues in croc's public security audit, and is a selling point. +- Links that carry the code. The QR code encodes a `https://crocapp.dev/r/` link, which opens the app or a page that explains what to do. The old `croc://` form is still accepted when scanned. +- Positioning: it works when AirDrop and LocalSend do not, across networks and continents. -- Repo description = pitch one-liner; screenshots/recordings top of README; store badges. -- Ship minimal fast, iterate in public; donations only, no monetization. -- Distribution breadth: App Store + TestFlight public link + DMG. -- Post-launch: PR adding CrocApp to croc README GUI section (how crocgui/croc-app get found); HN, r/selfhosted, r/opensource, Mac blogs (MacStories et al.); translations early. +## Growth ideas + +- Repo description is the pitch line. Screenshots at the top of the README, plus store badges. +- Ship small, iterate in public. Donations only. +- Distribute through the App Store, TestFlight and the direct download. +- Ask for a CrocApp entry in croc's README list of GUIs, which is how the other GUIs get found. +- Announce on Hacker News, r/selfhosted, r/opensource and Mac blogs. Translate early (`../ROADMAP.md`). diff --git a/docs/knowledge/project-overview.md b/docs/knowledge/project-overview.md index 5b2a2e7..fc39bbc 100644 --- a/docs/knowledge/project-overview.md +++ b/docs/knowledge/project-overview.md @@ -1,30 +1,34 @@ -# Project Overview +# Project overview ## What -CrocApp: free, open-source, native SwiftUI GUI wrapper for [croc](https://github.com/schollz/croc), the file-transfer CLI. Targets Apple devices: iOS and macOS. Covers all/most croc features behind a simple GUI. +CrocApp is a free, open-source, native SwiftUI app for [croc](https://github.com/schollz/croc), the file-transfer command line tool. It runs on iOS, iPadOS and macOS, and covers most of croc's features behind a simple interface. + +## Status + +Version 1.0.0 is out as a notarized direct download for the Mac (Sparkle updates). The iOS, iPadOS and Mac App Store builds exist in TestFlight only and are not submitted. The first App Store release will be 1.1.0. `../ROADMAP.md` lists what comes next, and `../RELEASING.md` covers the channels. ## Why -- croc has clever architecture (PAKE-secured, relay-assisted, end-to-end encrypted transfers) but is CLI-only — inaccessible to basic users. -- A polished native GUI brings croc's design to a broader audience. -- No official or well-maintained native Apple GUI exists. +- croc has a strong design (PAKE-secured, relay-assisted, end-to-end encrypted transfers), but it is command line only, so most people cannot use it. +- A native app brings that design to a wider audience. +- No official, maintained native Apple app for croc exists (see `prior-art.md`). ## Goals -1. Real, working, shippable app — not a demo. -2. Successful OSS project: stars, users, contributors, healthy repo hygiene. -3. Author learns Apple development (Swift, SwiftUI, Xcode) and adjacent tech along the way. +1. A real, working, shippable app, not a demo. +2. A healthy open-source project: users, contributors, good repo hygiene. +3. The author learns Apple development (Swift, SwiftUI, Xcode) along the way. -## Constraints & context +## Constraints and context -- Sole developer: Bakir Gracic. Public repo at `github.com/bakirgdev/CrocApp`. -- AI-first development: built almost entirely with Claude Code (Claude Max plan), minimal manual coding. Tooling and docs are token-optimized. -- Toolchain: macOS 26, Xcode 27, iOS 26. Minimum OS = iOS 26 / macOS 26. +- One developer: Bakir Gracic. The repo is public at `github.com/bakirgdev/CrocApp`. +- Built almost entirely with Claude Code, with minimal manual coding. Tooling and docs are written to keep AI sessions cheap and accurate. +- Minimum OS: iOS 26.0, iPadOS 26.0 and macOS 26.0. Toolchain: Xcode 27 (`tooling.md`). - License: MIT. -## Non-goals (for now) +## Non-goals -- Windows/Linux/Android clients. -- Reimplementing or forking croc itself — CrocApp wraps it. -- Paid features. App stays free; monetization limited to optional sponsorship. +- Windows, Linux or Android clients. +- Reimplementing or forking croc. CrocApp wraps it. +- Paid features. The app stays free; the only money is optional donations. diff --git a/docs/knowledge/screenshots.md b/docs/knowledge/screenshots.md index da3ee1d..d11f790 100644 --- a/docs/knowledge/screenshots.md +++ b/docs/knowledge/screenshots.md @@ -21,7 +21,10 @@ scripts/capture-screenshots.sh mac history # devices and screens combine; bo scripts/capture-screenshots.sh --copy-web # refresh the web copies, no display needed ``` -A run takes over the display and flips the system appearance twice, restoring it on exit including on failure. The terminal that runs it needs Screen Recording, Accessibility and Automation of System Events; the preflight names whichever is missing before anything is built. It creates simulators named "CrocApp Shots " (iPhone and iPad for the main set, then iPhone 17 Pro and iPhone Duo one at a time for the store-only classes, each deleted as soon as its pass ends), starts a croc relay and, for `stored-link`, a store server, and removes all of them on exit. Needs Go (the store server comes from `crocmobile/cmd/croctest`) and a v11 `croc` (default `/opt/homebrew/bin/croc`, override with `CROC`; crocmobile pins v11.5.4). +- A run takes over the display and flips the system appearance twice, restoring it on exit including on failure. +- The terminal that runs it needs Screen Recording, Accessibility and Automation of System Events; the preflight names whichever is missing before anything is built. +- It creates simulators named "CrocApp Shots " (iPhone and iPad for the main set, then iPhone 17 Pro and iPhone Duo one at a time for the store-only classes, each deleted as soon as its pass ends), starts a croc relay and, for `stored-link`, a store server, and removes all of them on exit. +- Needs Go (the store server comes from `crocmobile/cmd/croctest`) and a v11 `croc` (default `/opt/homebrew/bin/croc`, override with `CROC`; crocmobile embeds v11.5.4). After a run, refresh the web copies with `--copy-web`. It overwrites only the files already present under `web/docs/static/img/screenshots/` and `web/landing/assets/img/screenshots/` (same `mac/`, `iphone/`, `ipad/` subfolders); to publish a new shot, copy its file there once. @@ -41,7 +44,7 @@ One vocabulary across devices, kebab-case, named for the state shown. | `receive-conflict` | incoming preview with Keep Both / Replace | | `receiving` | mid-transfer, 40%, 6 MB/s | | `receive-done` | "Transfer complete" | -| `stored-link` | "Link ready" after Send as link | +| `stored-link` | "Link ready" after Send as Link | | `history` | ten seeded records | | `how-it-works` | the trust screen | | `settings` | Settings (macOS: General tab) | @@ -53,19 +56,24 @@ The App Store subset is `send-files`, `send-code`, `sending`, `receive-incoming` ## App Store sizes -From Apple's screenshot specifications, https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications (checked 2026-10-17). `.png`, `.jpg` or `.jpeg`, no alpha channel, up to ten per size. The page names iPhone classes by display type, not inches. +From Apple's screenshot specifications, https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications (not re-verified; recheck before an upload). `.png`, `.jpg` or `.jpeg`, no alpha channel, up to ten per size. The page names iPhone classes by display type, not inches. | Class dir | Pixels | Apple's class | Source | |---|---|---|---| | `iphone-6.3` | 1206x2622 | Dynamic Island (medium), required | iPhone 17 Pro simulator | | `iphone-6.9` | 1320x2868 | Dynamic Island (large), optional | iPhone 17 Pro Max simulator, same captures as `iphone/` | -| `iphone-duo-outer` | 1398x2034 | iPhone Duo (outer), mandatory from April 2027 for apps built with the iOS 27.1 SDK | iPhone Duo simulator, display 1 | +| `iphone-duo-outer` | 1398x2034 | iPhone Duo (outer), required for apps using the iOS 27.1 SDK or later (Apple's page gives the start date; checked against it) | iPhone Duo simulator, display 1 | | `ipad-13` | 2064x2752 | 13" display, required because the app runs on iPad | iPad Pro 13-inch (M5) simulator | | `mac` | 2880x1800 | 16:10, one of four accepted sizes | the window composed on a canvas | All are native simulator pixels: nothing is scaled or cropped to reach a size. -**iPhone Duo.** `simctl io enumerate` lists two panels, screen 1 (`LCD`, 1398x2034, the outer display) and screen 3 (`LCD-1`, 2007x2853, the inner display). `screenshot --display=1` captures the outer one at exactly Apple's size (set `DUO_OUTER_DISPLAY` if a runtime numbers them differently). The inner display stays black: SpringBoard only drives it after an unfold event, nothing in `simctl` sends one (`screenConfig power` switches a panel on but not the content), and this Xcode ships no Simulator.app whose Device menu could. So there is no `iphone-duo-inner` class yet; the 2007x2853 size is also only needed from April 2027. The outer display lays the app out sideways (status bar and back button in a strip along one edge); that is how the simulator renders it, and the shot is kept as is. The Duo runs on the newest runtime (iOS 27.1 here), the others on whichever newest runtime supports their device type. +- **iPhone Duo.** `simctl io enumerate` lists two panels, screen 1 (`LCD`, 1398x2034, the outer display) and screen 3 (`LCD-1`, 2007x2853, the inner display). + - `screenshot --display=1` captures the outer one at exactly Apple's size (set `DUO_OUTER_DISPLAY` if a runtime numbers them differently). + - The inner display stays black: SpringBoard only drives it after an unfold event, nothing in `simctl` sends one (`screenConfig power` switches a panel on but not the content), and this Xcode ships no Simulator.app whose Device menu could. + - So there is no `iphone-duo-inner` class yet; Apple's page lists the 2007x2853 inner size under the same iOS 27.1 SDK condition. + - The outer display lays the app out sideways (status bar and back button in a strip along one edge); that is how the simulator renders it, and the shot is kept as is. + - The Duo runs on the newest runtime (iOS 27.1), the others on whichever newest runtime supports their device type. **Mac.** A window capture is typically 1120x1400 (a 560x700 pt window at 2x), not 16:10, and a stretched window would be dishonest. `imgtool compose` (`scripts/screenshots/imgtool.swift`) draws the untouched window at 1x, centred, with a soft shadow, on a gradient canvas of exactly 2880x1800: pale blue-grey for light shots, deep blue-grey for dark ones. Needs a Retina main display so the capture comes out at 2x. @@ -102,9 +110,13 @@ The one fixed wait left is `settle` (one second): a view needs a frame or two to ## The constraints that shaped it -**A local relay.** `start_relay` runs `croc relay` on `127.0.0.1` with a random block of five ports and a random password (in the environment as `CROC_PASS`, in the app's launch arguments as `--relay-password`; both die with the run). Every transfer shot goes through it, so a run needs no internet for the transfers and cannot collide with anyone's relay room. The croc CLI peers still make their own update check to GitHub. The relay runs with `--debug` because its "room has first peer" line is the signal that a sender is registered; the receiver must join after that, or the PAKE handshake is corrupted. This leans on croc v11's log wording, so the script refuses another major version. +- **A local relay.** `start_relay` runs `croc relay` on `127.0.0.1` with a random block of five ports and a random password (in the environment as `CROC_PASS`, in the app's launch arguments as `--relay-password`; both die with the run). +- Every transfer shot goes through it, so a run needs no internet for the transfers and cannot collide with anyone's relay room. +- The croc CLI peers still make their own update check to GitHub. +- The relay runs with `--debug` because its "room has first peer" line is the signal that a sender is registered; the receiver must join after that, or the PAKE handshake is corrupted. +- This leans on croc v11's log wording, so the script refuses another major version. -**Codes are random.** `new_code` builds `NNNN-word-word-word` from `/dev/urandom` with three distinct words. The relay hashes a code's first four characters into a room name, so the four digits are unique within a run. +**Codes are random.** `new_code` builds `NNNN-word-word-word` from `/dev/urandom` with three distinct words. The relay derives the room from the first four characters of this legacy shape (`what-is-croc.md`, "Rendezvous"), so the four digits are unique within a run. **Mid-transfer shots.** Localhost moves 150 MB in under a second, so nothing is photographable unthrottled. The sender is throttled (`--throttle 6m` on the app, `--throttleUpload 6m` on the CLI), and at 40% the script sends `SIGSTOP` to the peer. Both ends then stand still, so the bar and the rate stay as they are. `sending` has the app as sender and a CLI receiver; `receiving` is the mirror. @@ -118,19 +130,26 @@ The one fixed wait left is `settle` (one second): a view needs a frame or two to **Documents is the receive folder.** Files a previous shot received make the next incoming preview carry an "already exist" warning, so `clean_docs` removes the payload names before every shot. `receive-conflict` then pre-places two of them on purpose. -**Simulators inherit the host's preferences.** This host has a global `AppleICUTimeFormatStrings = {1 = "HH:mm:ss";}`, so the status bar read "09:41:00" whatever `status_bar --time` said. `create_sim` writes `AppleICUTimeFormatStrings -dict 1 "h:mm"`, `AppleLocale en_US` and `AppleLanguages (en)` into the booted simulator's global domain, then shuts down and boots it again (the preferences only take effect after a reboot; restarting SpringBoard with `launchctl kickstart` left the status bar blank). `status_bar --time` takes an ISO instant with fractional seconds, in UTC, shown in the host's zone; the clock then reads "9:41". The script is host-independent this way, and the status bar override also fixes full wifi and cellular bars and a charged battery. +**Simulators inherit the host's preferences.** A host with a global `AppleICUTimeFormatStrings = {1 = "HH:mm:ss";}` made the status bar read "09:41:00" whatever `status_bar --time` said. + +- `create_sim` writes `AppleICUTimeFormatStrings -dict 1 "h:mm"`, `AppleLocale en_US` and `AppleLanguages (en)` into the booted simulator's global domain. +- It then shuts the simulator down and boots it again, because the preferences only take effect after a reboot. Restarting SpringBoard with `launchctl kickstart` left the status bar blank. +- `status_bar --time` takes an ISO instant with fractional seconds, in UTC, shown in the host's zone. The clock then reads "9:41". +- The status bar override also fixes full wifi and cellular bars and a charged battery. + +The script is host-independent this way. **iPad multitasking.** The iPad simulator opens in windowed multitasking, which adds an app-name label to the status bar and a resize glyph in the bottom-right corner. `create_sim` writes `SBMedusaMultitaskingEnabled` and `SBChamoisWindowingEnabled` as `NO` to `com.apple.springboard` before the same reboot, which gives a plain full-screen app. -**Simulator runtime.** A newer runtime can lack older device types (iOS 27.1 here has neither the iPhone 17 Pro Max nor the iPad Pro 13-inch M5), and `simctl create` without a runtime picks the oldest. The script tries installed runtimes newest first. +**Simulator runtime.** A newer runtime can lack older device types (the iOS 27.1 runtime has neither the iPhone 17 Pro Max nor the iPad Pro 13-inch M5), and `simctl create` without a runtime picks the oldest. The script tries installed runtimes newest first. -**A long-lived simulator stops reaching the relay.** Not an issue now that the run creates and deletes its own simulators; if a manual simulator sits on "Starting..." forever, `simctl shutdown` then `boot` clears it. +**A long-lived simulator can stop reaching the relay.** The script creates and deletes its own simulators, so a run is not affected. If a manual simulator sits on "Starting..." forever, `simctl shutdown` then `boot` clears it. ## Mock data and showcase conventions For any future screenshot work: -- **Real files, generated at run time, never committed.** `scripts/screenshots/payload.swift` renders a three-page PDF (`Quarterly-Report.pdf`) and a JPEG (`Ridge-Sunrise.jpg`); `Meeting-Notes.md` is a heredoc; `Design-Assets.zip` comes from `ditto`. File rows show a symbol by file type, so real extensions matter more than real content. Large transfer payloads (`Project-Archive.zip`, `Field-Recordings.zip`, 150 MB) are random bytes under a believable name, because only the name is ever shown. +- **Real files, generated at run time, never committed.** `scripts/screenshots/payload.swift` renders a three-page PDF (`Quarterly-Report.pdf`) and a JPEG (`Ridge-Sunrise.jpg`); the Markdown note is a heredoc; `Design-Assets.zip` comes from `ditto`. File rows show a symbol by file type, so real extensions matter more than real content. Large transfer payloads (`Project-Archive.zip`, `Field-Recordings.zip`, 150 MB) are random bytes under a believable name, because only the name is ever shown. - **Neutral, pleasant names.** No personal names, no real people or companies, no real hosts beyond the project's own (`crocapp.dev` for the stored-link record). Names read like work files. - **History is a showcase list** (`seedHistory` in `AutoVerify.swift`): sends and receives, completed, declined, failed and cancelled, a text snippet, a stored link with its expiry, file counts of one to four, ages from twelve minutes to five weeks. Names match the payload names where a payload exists, and sizes match the real payloads, so the incoming preview and the history agree. - **The code on screen is random per run** and never a real one. @@ -140,17 +159,29 @@ For any future screenshot work: ## Known soft spots -- **`ipad/` is the phone layout stretched.** See `../known-issues.md`. -- **No `iphone-duo-inner`, and `iphone-duo-outer` is not upload-ready.** See "iPhone Duo" above. The outer shots lay out sideways and clip the bottom of taller screens (`02-send-code-dark` loses "Waiting for the receiver…"). Recapture before April 2027, once a runtime or Simulator.app can drive the Duo properly. +- **`ipad/` is the phone layout stretched.** See `../known-issues.md`, "Design system". +- **No `iphone-duo-inner`, and `iphone-duo-outer` is not upload-ready.** See "iPhone Duo" above. The outer shots lay out sideways and clip the bottom of taller screens (`02-send-code-dark` loses "Waiting for the receiver…"). Recapture before an upload that needs them, once a runtime or Simulator.app can drive the Duo properly. - **No menu-bar shot.** The menu-bar popover cannot be captured reliably: macOS hides the status item when the menu bar is full, so no script can click it. - **`mac/onboarding` is not captured** (see the table above). ## Where they are used -Seven pairs are referenced; the rest and the whole `app-store/` set are captured and unused by the web (`../known-issues.md`). +Seven pairs are referenced; the rest and the whole `app-store/` set are captured and unused by the web (`../known-issues.md`, "Docs site"). - Root `README.md`: `mac/send-code`, `iphone/receive-incoming`, `mac/settings`, via `` elements that swap on the viewer's GitHub theme. -- Docs site: copies under `web/docs/static/img/screenshots/`, one pair per page: `mac/send-code` (first-transfer), `mac/send-files` (sending), `iphone/receive-incoming` (receiving), `mac/settings` (settings-and-trust), `iphone/settings` (power-options), `mac/history` (history), `iphone/onboarding` (index). `.md` there is CommonMark (`docusaurus.config.ts` sets `markdown.format: 'detect'`), so `` is unavailable; the pages use GitHub's `#gh-light-mode-only` / `#gh-dark-mode-only` fragments and `src/css/custom.css` supplies the rule that makes them mean anything. That keys off Docusaurus's theme toggle rather than the OS setting. +- Docs site: copies under `web/docs/static/img/screenshots/`, one pair per page: + + | Pair | Page | + |---|---| + | `mac/send-code` | first-transfer | + | `mac/send-files` | sending | + | `iphone/receive-incoming` | receiving | + | `mac/settings` | settings-and-trust | + | `iphone/settings` | power-options | + | `mac/history` | history | + | `iphone/onboarding` | index | + + `.md` there is CommonMark (`docusaurus.config.ts` sets `markdown.format: 'detect'`), so `` is unavailable. The pages use GitHub's `#gh-light-mode-only` and `#gh-dark-mode-only` fragments, and `src/css/custom.css` supplies the rule that makes them mean anything. That rule follows Docusaurus's theme toggle, not the OS setting. - Landing hero: `web/landing/assets/img/screenshots/mac/send-code-*`, two `` elements swapped by CSS. Not a `` with `prefers-color-scheme`, because the header's toggle pins `[data-theme]` and a media query would ignore it. A copy is a copy: changing a shot means re-running the script and `scripts/capture-screenshots.sh --copy-web`. diff --git a/docs/knowledge/tooling.md b/docs/knowledge/tooling.md index e3556be..e294068 100644 --- a/docs/knowledge/tooling.md +++ b/docs/knowledge/tooling.md @@ -15,42 +15,69 @@ What is wired up, what was deliberately skipped, and the traps in each. | xcbeautify | none | CI only (`ci.yml`; `release.yml` does not use it) | | create-dmg 1.3.0 (Homebrew; CI installs it unpinned with `brew install create-dmg`) | none; called by `scripts/build-dmg.sh` | local + `release.yml` | | croc v10 CLI (the v10 peer of `verify-interop.sh` scenario 10) | `scripts/croc-v10.version` (read by the script and the `interop` job) | local + CI | -| Node 24 (latest LTS) | `web/docs/.nvmrc` (pinned exact), `engines` in `web/docs/package.json` | docs site, local + CI | +| Node 24 | `web/docs/.nvmrc` (pinned exact), `engines` in `web/docs/package.json` | docs site, local + CI | | pnpm 12 | `packageManager` in `web/docs/package.json` (CI: `pnpm/action-setup` reads it) | docs site, local + CI | | Docusaurus 3.10, React 19.3, TypeScript 7 | `web/docs/package.json`, `pnpm-lock.yaml`; `pnpm-workspace.yaml` holds `overrides` for transitive advisories | docs site, local + CI (`pnpm run typecheck` in `github-pages.yml`) | | Sparkle 2.10.0 (SwiftPM, `CrocApp Direct` only; auto-update for the direct-download build) | `Package.resolved` under `app/CrocApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/`, minimum in the `XCRemoteSwiftPackageReference` in `project.pbxproj`; Dependabot (`swift`, `/app`) reads the Xcode project's `Package.resolved` and opens the bump | `CrocApp Direct` build, local + CI (unsigned build step) | -| MCP servers `xcode` (`xcrun mcpbridge`) and `gopls` (`gopls mcp`) for AI sessions | `.mcp.json` (Claude Code asks each user to approve them); `gopls` starts in `crocmobile/` because the Go module lives there, not at the repo root | local AI sessions | +| MCP servers `xcode` (`xcrun mcpbridge`) and `gopls` (`gopls mcp`) for AI sessions | `.mcp.json` (Claude Code asks each user to approve them); `gopls` starts in `crocmobile/` because the Go module lives there, not at the repo root | local AI sessions. Use `xcode` to build, run tests, render previews and read diagnostics; it needs the project open in Xcode with MCP enabled, and never replaces the harnesses (`BUILDING.md`, "Verification harnesses"). Use `gopls` for symbol search and references in `crocmobile/` | | `scripts/verify-design.sh` (pure bash and awk, no toolchain) | none; holds the token parity, contrast (the ratios `design/colors.md` claims, which must also appear in its Contrast section) and no-literals tables | local + CI (`contrast` job, shown as `design checks`) | +| `scripts/verify-docs.sh` (pure bash and awk) | none; the rules it enforces are in `docs/AGENTS.md` | local + CI (`docs checks`, on every push and pull request, because renaming any file can break a path a doc names) | | ShellCheck 0.11 (Homebrew locally; preinstalled on `ubuntu-26.04`) | none (`shellcheck -x scripts/*.sh`, default checks) | local + CI (`shellcheck` job) | -| GitHub Actions | `.github/workflows/ci.yml` | push to main, every PR; a `changes` job skips the jobs whose area is untouched; `interop (local relay)` runs `scripts/verify-interop.sh` on `macos-26` against a local `croc relay` | +| GitHub Actions | `.github/workflows/ci.yml` | push to main, every PR; a `changes` job skips the jobs whose area is untouched (see "CI gotchas"); `interop (local relay)` runs `scripts/verify-interop.sh` on `macos-26` against a local `croc relay` | | GitHub Actions | `.github/workflows/go-weekly.yml` | Mondays 06:17 UTC + manual | -| GitHub Actions | `.github/workflows/github-pages.yml` | PRs build only; push to main (`web/landing/**`, `web/docs/**`, `design/tokens.css`) also deploys | +| GitHub Actions | `.github/workflows/github-pages.yml` | Triggers on changes to `web/landing/**`, `web/docs/**`, `design/tokens.css`, `scripts/assemble-site.sh` and the workflow file. PRs build only; a push to main also deploys | | GitHub Actions | `.github/workflows/release.yml` | `v*` tag publishes; `workflow_dispatch` is a rehearsal that publishes nothing | -Xcode baseline lives in `.xcode-version` (plain text, one line). CI reads it into `maxim-lobanov/setup-xcode`; `xcodes` and `mise` read the same file. Jobs that need Xcode 27 run on the `xcode-27` runner label (a public preview; `macos-26` does not carry Xcode 27). Its image carries 27.0 (default), 27.1 and 27.2 beta, so the pin must name a version that image actually has. Only 27.0 gets simulator runtimes on that image (`install_runtimes` in `images/macos/toolsets/toolset-xcode-27.json` of `actions/runner-images`); CI builds the iOS leg for `generic/platform=iOS Simulator`, which needs the SDK, not a runtime. - -## Build configuration you cannot casually change - -- **Swift 6 language mode everywhere.** App and extension are `SWIFT_VERSION = 6.0`, `CrocKit` is swift-tools 6.0. On top of that, `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` and `SWIFT_APPROACHABLE_CONCURRENCY = YES`: types are MainActor-isolated by default and `nonisolated` is the explicit case. Strict concurrency is an error, not a warning. -- **Deployment targets are `26.0` everywhere** — pbxproj, `Package.swift`, and `gomobile -iosversion`/`-macosversion`. Never a minor pin. -- **macOS is arm64 only** (`ARCHS[sdk=macosx*] = arm64`). `Croc.xcframework` has no x86_64 slice because gomobile cannot produce one (golang/go#73119), so Intel Macs cannot run the app. Removing the pin does not add Intel support, it breaks the link. -- **Version numbers live in `app/Config/Version.xcconfig`** (`MARKETING_VERSION`, `CURRENT_PROJECT_VERSION`), the base configuration of the project-level Debug and Release configurations; no target sets either. `release.yml`'s tag check reads `MARKETING_VERSION` from it. -- **Go toolchain ≥ 1.27.2**, pinned in `crocmobile/go.mod`. `scripts/build-xcframework.sh` builds gomobile and gobind from the `golang.org/x/mobile` version in the same file's `tool` block; pin x/mobile to the last good version if a bind ever regresses. - -CI gotchas: - -- **`ci.yml` has no workflow-level `paths:`.** A filtered-out workflow never reports, so a required check would stay pending. Instead a `changes` job diffs the PR base (or the push's `before`) against the head with plain `git diff --name-only` and sets one output per area (`swift`, `go`, `build`, `design`, `interop`; `design` covers `design/`, app Swift, the asset catalog, web CSS and both verify scripts); each job gates on its output with `if:`. A skipped job counts as passed. Adding a file CI *does* consume (a new script, a new dotfile config) means adding it to the matching regex in the `changes` job. An unknown base (new branch, force push) runs everything. A skipped matrix job reports under its unexpanded name and stays pending, so branch protection requires only the `ci pass` aggregate job, never the individual jobs. Unit tests ride on existing jobs: `go test -race` in `reusable-go.yml` (with `GOOS`/`GOARCH` unset for that step, since the job-level `darwin` would build a test binary the Linux runner cannot run), and `swift test` plus `xcodebuild test` on the macOS leg of `build`. The `interop (local relay)` job (`macos-26`) runs `scripts/verify-interop.sh` against a `croc relay` on 127.0.0.1 with raised admission limits (the public relays rate-limit a shared runner address); it installs the v11 CLI with `go install` at the version in `crocmobile/go.mod` (Homebrew's bottle lagged at 11.5.0, which has no `croc store`) and the v10 peer with `go install` at the version in `scripts/croc-v10.version`. The other harnesses and the public-pool scenario are not run by CI. -- **govulncheck under the go job's cross-compile env.** The go job sets `GOOS=darwin GOARCH=arm64` job-wide so vet/build analyse the shipping target. `go install` honours that and drops a darwin binary into `$GOPATH/bin/darwin_arm64/` (unrunnable on the Linux host, and not where `$(go env GOPATH)/bin/…` looks) → exit 127. Install the tool with `env -u GOOS -u GOARCH` so the binary is host-native in `$GOPATH/bin`, then run it with the job env intact so the *analysis* still targets darwin. -- **`conflicting nullability specifier on return types` was fixed on the Go side, not suppressed.** It used to fire on every macOS/iOS build: gomobile treats any `New` func as a constructor and builds the ObjC selector from whatever *follows* that prefix (`bind/gen.go`, `bind/genobjc.go`), so `NewOptions` on type `Options` left an empty suffix and emitted a bare `- (nullable instancetype)init` on `CrocmobileOptions`, clashing with `NSObject`'s `nonnull init`. Renaming the Go constructor to `NewOptionsDefault` keeps it recognised and yields `initDefault`. Do not rename it back, and do not add a `New` func whose suffix is empty. +- Xcode baseline lives in `.xcode-version` (plain text, one line). +- CI reads it into `maxim-lobanov/setup-xcode`; `xcodes` and `mise` read the same file. +- Jobs that need Xcode 27 run on the `xcode-27` runner label (a public preview; `macos-26` does not carry Xcode 27, not verified here). +- The pin must name a version that image carries (see the `actions/runner-images` toolset file; not verified for the current pin). +- Simulator runtimes on that image depend on the Xcode version (`install_runtimes` in `images/macos/toolsets/toolset-xcode-27.json`; not verified for the current pin). +- CI builds the iOS leg for `generic/platform=iOS Simulator`, which needs the SDK, not a runtime. + +The build settings that must not change casually (Swift 6 mode, deployment targets, arm64-only macOS, the version source, the Go pin) are in `../BUILDING.md`, "Build settings that must not change casually". + +## CI gotchas + +- **`ci.yml` has no workflow-level `paths:`.** A filtered-out workflow never reports, so a required check would stay pending. Jobs skip through `if:` instead (see the next bullet). +- **The `changes` job decides which jobs run.** + - It diffs the PR base (or the push's `before`) against the head with `git diff --name-only` and sets one output per area: `swift`, `go`, `build`, `design`, `interop` and `scripts`. Each job gates on its output with `if:`. + - The regexes in the `changes` job of `ci.yml` are the source of truth for what each area covers; the rule for new files is in `.github/AGENTS.md`. + - An unknown base (a new branch, a force push) runs everything. +- **Branch protection requires only `ci pass`** (`.github/AGENTS.md`). The reason: a skipped matrix job reports under its unexpanded name and stays pending, while `ci pass` treats `success` and `skipped` as passing. +- **Unit tests ride on existing jobs.** + - `go test -race` runs in `reusable-go.yml` with `GOOS` and `GOARCH` unset for that step, because the job-level `darwin` would build a test binary the Linux runner cannot run. + - `swift test` and `xcodebuild test` run on the macOS leg of `build`. +- **The `interop (local relay)` job (`macos-26`) runs `scripts/verify-interop.sh` against a local relay.** + - It starts `croc relay` on 127.0.0.1 with raised admission limits, because the public relays rate-limit a shared runner address. + - It installs the v11 CLI with `go install` at the version in `crocmobile/go.mod`, because Homebrew's bottle can lag that pin and the store scenario needs `croc store`. It installs the v10 peer the same way, at the version in `scripts/croc-v10.version`. + - CI does not run the other harnesses or the public-pool scenario. +- **`go install` under the go job's cross-compile env.** The go job sets `GOOS=darwin GOARCH=arm64` job-wide so `go build` and `go vet` analyze the shipping target. + - `go install` honours that and drops a darwin binary into `$GOPATH/bin/darwin_arm64/`, which is unrunnable on the Linux host and not where `$(go env GOPATH)/bin/` looks (exit 127). + - CI therefore builds govulncheck with `env -u GOOS -u GOARCH go build -o "$RUNNER_TEMP/govulncheck" golang.org/x/vuln/cmd/govulncheck`, then runs it with the job env intact so the analysis still targets darwin. + - Do the same for any other tool you install in that job. +- **The `conflicting nullability specifier on return types` warning is avoided on the Go side, not suppressed.** + - gomobile treats any func named `New` as a constructor and builds the Objective-C selector from what follows that prefix (`bind/gen.go`, `bind/genobjc.go`). + - A bare `NewOptions` on type `Options` leaves an empty suffix and emits `- (nullable instancetype)init` on `CrocmobileOptions`, which clashes with `NSObject`'s `nonnull init`. + - The constructor is named `NewOptionsDefault`, which yields the selector `initDefault`. Do not rename it back, and do not add a `New` func whose suffix is empty. - **macOS app build has no signing identity.** The runner has no `Mac Development` cert, so the `build (macOS)` job passes `CODE_SIGNING_ALLOWED=NO` to `xcodebuild`. The iOS Simulator destination never signs, so it needs nothing. - **A cancelled Pages run strands the `github-pages` environment lock**, and the next push queues behind a deployment that never completes. `github-pages.yml` has an `if: cancelled()` step calling `POST /repos/:repo/pages/deployments/:sha/cancel` to release it, and sets `cancel-in-progress` only for pull requests, so deploys on `main` queue instead of cancelling each other. - **Scheduled workflows are disabled after 60 days of repo inactivity.** `go-weekly.yml` stops firing silently; `workflow_dispatch` restarts it. ## swift-format traps -- **The config holds only deviations from swift-format's defaults**, and there are two: `indentation: 4` (swift-format defaults to 2, Xcode to 4, and Xcode's editor indents from its own text-editing prefs, not `.swift-format`) and `indentConditionalCompilationBlocks: false` (the `#if os(macOS)` bodies all over `Platform/` stay unindented, as Xcode leaves them). Measured against the 77 files in the source set: pure defaults give 7,940 findings, `indentation: 4` alone 1,362, both 0. Every other rule and knob is swift-format's default, so `xcrun swift-format dump-configuration` prints the full effective set. Add a key only for a deliberate, agreed deviation; restating a default hides the real decisions and freezes the default against upstream changes on an Xcode bump. The formatter binary itself is pinned by `.xcode-version`, which the CI job reads, so a changed default surfaces as a `lint --strict` failure on that bump, not silently. -- **Several enabled rules are lint-only**: they flag but never rewrite, so `format --in-place` leaves them and they must be fixed by hand. CI runs `lint --strict`, which turns every such warning into a build failure, so none may be left behind. Verified lint-only: `ReplaceForEachWithForLoop` (`.forEach { }` → for-in loop), `OnlyOneTrailingClosureArgument` (a call mixing a closure argument such as `onDismiss:` with a trailing closure → pass the trailing closure as an explicit `content:` argument), `AlwaysUseLowerCamelCase`, `TypeNamesShouldBeCapitalized`, `DontRepeatTypeInStaticProperties`, `NoBlockComments`, `AvoidRetroactiveConformances`. A clean `format` run is not evidence CI will pass; run `lint --strict` too. -- **The source set lives in `scripts/format-swift.sh`, not in the config.** swift-format has no "which files" setting, so the path list used to be copy-pasted into CI, the PR template, `README.md`, `CONTRIBUTING.md` and here — and drifted: `CrocKit/Package.swift` was never linted by any of them. One script now owns it. `CrocKit` is enumerated file-by-file rather than passed as a directory, because `--recursive` there descends into `.build/` and `Croc.xcframework/`. +- **The config holds only deviations from swift-format's defaults**, and there are two: + - `indentation: 4`. swift-format defaults to 2, Xcode to 4, and Xcode's editor indents from its own text-editing prefs, not `.swift-format`. + - `indentConditionalCompilationBlocks: false`. The `#if os(macOS)` bodies all over `Platform/` stay unindented, as Xcode leaves them. + - Measured when the config was written, pure defaults gave thousands of findings, `indentation: 4` alone about a thousand, and both none. + - Every other rule and knob is swift-format's default, so `xcrun swift-format dump-configuration` prints the full effective set. + - Add a key only for a deliberate, agreed deviation; restating a default hides the real decisions and freezes the default against upstream changes on an Xcode bump. + - The formatter binary itself is pinned by `.xcode-version`, which the CI job reads, so a changed default surfaces as a `lint --strict` failure on that bump, not silently. +- **Several enabled rules are lint-only**: they flag but never rewrite, so `format --in-place` leaves them and they must be fixed by hand. + - CI runs `lint --strict`, which turns every such warning into a build failure, so none may be left behind. + - Verified lint-only: `ReplaceForEachWithForLoop` (`.forEach { }` → for-in loop), `OnlyOneTrailingClosureArgument` (a call mixing a closure argument such as `onDismiss:` with a trailing closure → pass the trailing closure as an explicit `content:` argument), `AlwaysUseLowerCamelCase`, `TypeNamesShouldBeCapitalized`, `DontRepeatTypeInStaticProperties`, `NoBlockComments`, `AvoidRetroactiveConformances`. + - A clean `format` run is not evidence CI will pass; run `lint --strict` too. +- **The source set lives in `scripts/format-swift.sh`, not in the config.** swift-format has no "which files" setting. One script owns the path list, so CI, the PR template and the docs do not each keep a copy. `CrocKit` is listed path by path, because `--recursive` on the directory descends into `.build/` and `Croc.xcframework/`. ## golangci-lint traps @@ -65,15 +92,16 @@ These cost real debugging time on this machine. All are environment quirks, not - **`rtk xcodebuild` truncates its output before shell redirection sees it**, losing the final `BUILD SUCCEEDED` line. Use `rtk proxy xcodebuild` for anything whose log you intend to read. Redirect build logs to a file, never into context. - **`rtk find | wc -l` mis-truncates** on directories with ~10k entries. Use `/usr/bin/find` when the count has to be right. - **No GNU `timeout` in a bare zsh here.** `scripts/lib.sh` shims it for the harnesses; ad-hoc zsh one-liners do not, so a hung verify run will hang forever. -- **bash points a background job's stdin at `/dev/null`** unless the job carries an explicit redirect. Any `timeout` shim that backgrounds its command therefore eats a piped answer — `lib.sh` adds `<&0` so a redirect on the `timeout` call still reaches the command (the `--ask` direction of `verify-app-mac.sh` depends on this). +- **bash points a background job's stdin at `/dev/null`** unless the job carries an explicit redirect. Any `timeout` shim that backgrounds its command therefore eats a piped answer (`lib.sh` adds `<&0` so a redirect on the `timeout` call still reaches the command), which the `--ask` direction of `verify-app-mac.sh` depends on. - **zsh's `=word` expansion breaks bare `===` arguments** to `echo` and friends. Quote them. -- **A passing harness used to exit 1.** `lib.sh`'s `kill_jobs` ran `kill $pids` on whatever `jobs -p` listed, but `jobs -p` still lists jobs that have already exited, and `kill` fails on those. Under the caller's `set -e`, that failing `kill` aborted the EXIT trap mid-way, skipping the `rm -rf "$TMP"` beside it, so a successful run leaked its temp dir *and* returned 1, indistinguishable from a real failure. `verify-interop.sh` and `verify-app-sim.sh` both register that trap, so both reported failure on every successful run until `kill $pids 2>/dev/null || true` fixed it. The `interop (local relay)` job trusts these scripts' exit codes, so know their history: a green run and this bug looked identical from the outside. +- **A harness trap must not fail a passing run.** `lib.sh`'s `kill_jobs` runs `kill $pids` on whatever `jobs -p` lists, but `jobs -p` still lists jobs that have already exited, and `kill` fails on those. + - Under the caller's `set -e`, a failing `kill` aborts the EXIT trap midway, skips the `rm -rf "$TMP"` beside it, and turns a successful run into exit 1. + - `verify-interop.sh` and `verify-app-sim.sh` both register that trap, so `kill $pids 2>/dev/null || true` must stay. + - The `interop (local relay)` job trusts these scripts' exit codes. ## Deferred, with the trigger for revisiting | Tool | Revisit when | |---|---| -| Periphery | pre-1.0 dead-code sweep. Can use the committed `CrocApp` scheme; false-positives on gomobile bindings + SwiftUI reflection | -| Danger | after main is PR-only | -| Tuist / XcodeGen | first real `.pbxproj` merge conflict (Kintsugi is the lighter answer) | -| fastlane | App Store / TestFlight upload. Notarization stays a separate step, but the credentials can be shared: `notarytool` accepts an App Store Connect API key via `--key` / `--key-id` / `--issuer`, alongside `--apple-id`/`--password` and `--keychain-profile` | +| Periphery | Before a release that wants a dead-code sweep. It can use the committed `CrocApp` scheme, and it false-positives on gomobile bindings and SwiftUI reflection. | +| Tuist or XcodeGen | The first real `.pbxproj` merge conflict (Kintsugi is the lighter answer). | diff --git a/docs/knowledge/what-is-croc.md b/docs/knowledge/what-is-croc.md index d4d3e67..365b277 100644 --- a/docs/knowledge/what-is-croc.md +++ b/docs/knowledge/what-is-croc.md @@ -1,35 +1,114 @@ # What is croc -Facts verified against croc **v11.5.4**, the pinned version: source read in the Go module cache, CLI behaviour checked in `src/cli`. Run `croc-upgrade-playbook.md` before trusting any of this against a newer croc. +Facts checked against croc **v11.5.4**, the pinned version: the source read in the Go module cache, and CLI behaviour read in `src/cli`. Before trusting any of this against a newer croc, follow `croc-upgrade-playbook.md`. Terms: `../GLOSSARY.md`. Source: https://github.com/schollz/croc ## Identity -CLI tool to "easily and securely send things from one computer to another": any file/folder/text, between any two machines, via a short code phrase. Author Zack Scholl (schollz). Go, MIT license, actively maintained. No port forwarding or local server needed. Inspired by magic-wormhole; adds resumability, multi-file transfers, and full-duplex relay comms. A browser client lives at getcroc.com. +- A CLI tool to "easily and securely send things from one computer to another": any file, folder or text, between any two machines, with a short code phrase. +- Author Zack Scholl (schollz). Go, MIT license. +- No port forwarding or local server needed. +- Its README credits magic-wormhole as the idea. It adds resumable and multi-file transfers. +- A browser client lives at getcroc.com. ## How a transfer works -1. **Code phrase**: a generated code is three random words from the EFF short wordlist #1 (1296 words), joined by hyphens (`codephrase.Generate`). Custom via `--code` (min 6 chars) or `CROC_SECRET` env var. Older `NNNN-word-word` style codes still parse (legacy split, see step 2). **On Unix `send --code X` in the default (non-classic) mode exits without sending and tells you to use `CROC_SECRET=X croc send ...`; a receive code given as an argument is refused the same way** (`croc --classic` lifts both). Always prefer `CROC_SECRET` for scripting. The receiver takes the code as one argument, or as three or four arguments joined with hyphens. -2. **Rendezvous**: both sides connect to a relay and join a room named `SHA-256(selector + "croc")`. The selector is the first word of a three-word code, the first two words of a four-word code, and the first 4 characters for any other code (legacy). Practical implication: two concurrent transfers whose codes share the selector, on the same relay, collide into one room ("room full"). With generated codes that is about 1 in 1296 per relay; custom codes and test scripts must differ in the selector. **Relays**: with no `--relay`/`--relay6` (or `CROC_RELAY`/`CROC_RELAY6`), no `--local` and no `--ip`, the CLI uses a public pool, `1.getcroc.com:9009` to `4.getcroc.com:9009` (`publicrelay.Relays`). Both sides pick the relay from the code: `RelayIndex` = SHA-256 of the whole code as a big-endian integer, modulo 4. A sender with no custom code uses the relay cached in a `best-relay` file in croc's config dir, or else probes all four (1 s timeout) and caches the first to answer a ping, then generates codes until one maps to that relay (`GenerateForRelay`); a send on a generated code that fails with a relay connection error clears the cache. The pool path blanks the IPv6 relay. The old single default still exists: `models.DEFAULT_RELAY` `croc.schollz.com:9009` and `DEFAULT_RELAY6` `croc6.schollz.com:9009` are what `--relay`/`--relay6` default to. Transfer ports default to 9009-9013 (control port first). -3. **PAKE**: the passphrase part of the code (the final two words of a three-word code) feeds PAKE2 (`github.com/schollz/pake/v3` v3.2.0, Boneh-Shoup construction) through `src/pakekey`, which binds the exchange to the purpose, room, and role of each side. Default curve P-256 (`--curve`: p256, p384, p521, siec, ed25519). Yields a strong session key from the weak phrase; relay never learns it. The peer PAKE protocol is versioned (`pakekey.ProtocolVersion` = 2) and fails closed on a mismatch. -4. **Encryption**: end-to-end. The PAKE session key goes through HKDF-SHA256 with a 32-byte salt and a transcript of both PAKE messages, the room, curve, and identities (`pakekey.Derive`), giving a 32-byte traffic key plus two key-confirmation keys; each side proves it holds the key (HMAC-SHA256 tags) before data flows. Everything on the wire after that is **AES-256-GCM** (`crypt.NewAESGCM`, random 12-byte nonce prepended per message). `crypt.New` (PBKDF2-HMAC-SHA256, 100 iterations) now only protects the client-to-relay password handshake (`src/tcp`), not transfer data. `crypt.NewArgon2` (Argon2id → XChaCha20-Poly1305) and `EncryptChaCha`/`DecryptChaCha` exist in the same file but have **no non-test call site in v11.5.4**, so do not describe croc as Argon2- or ChaCha-based. Relay sees ciphertext only: untrusted by design. -5. **Transport**: files DEFLATE-compressed (HuffmanOnly, `--no-compress` to disable; a file whose first 256 KB shrinks by under 2% is sent uncompressed), chunked at 32 KB. Two paths for the data, chosen by the sender with `--transport` (`auto` default, `derp`, `relay`; receivers always follow the sender): - - **relay**: chunks are claimed from a shared queue by up to 8 parallel TCP connections, one per relay transfer port. This is croc's classic speed trick. - - **Tailcat** (`auto` offers it only when the total send is at least 128 MiB, `autoTailcatThresholdBytes`, and races it against the relay, which starts 350 ms later; `derp` requires it): two native v11 clients build PAKE-bound node identities and carry TCP streams over an in-process userspace WireGuard network that starts on DERP and moves to direct UDP when NAT traversal works. A browser peer, an older client, or a failed setup falls back to the relay ports; `derp` disables that fallback, and `--transport` cannot be combined with `--local` unless it is `auto`. A build without Tailcat downgrades `derp` to `relay`. Setup fetches the DERP map from `https://tailcat.dev/derpmap.json` (today `tc301a.ipn.dev` to `tc304a.ipn.dev`), so below the threshold, or with `relay`, none of those servers is contacted. CrocApp exposes only `auto` and `relay` (`crocmobile-bridge.md`, "Transport"). -6. **Local path**: sender also starts a local relay and broadcasts its port over UDP multicast (`schollz/peerdiscovery`, default group 239.255.255.250 via `--multicast`, IPv4 and IPv6). The receiver races discovery (200 ms search, 500 ms cap) against the relay dial, 75 ms staggered; whichever connection completes first wins, and if the relay wins the receiver still asks the sender for its local IPs and tries them directly. Same-LAN transfers never touch the internet. **Discovery is not guaranteed**: seen once on an AP/client-isolation-style network where multicast discovery returned zero peers every time, for both the stock CLI and crocmobile (previous croc pin, not re-tested on v11); root cause not isolated. Workaround: `--ip :9009` on the receiver connects directly to the sender's local relay control port (the first local relay port, 9009 unless busy) and skips discovery entirely, without touching the public relay. -7. **Resume + integrity**: interrupted transfers resume (receiver scans the partial file for zeroed chunks); an existing file of the same size is hashed and skipped when equal. Hash: xxhash default; imohash (sampled), md5, highway via `--hash`. -8. **Reconnect**: a dropped transfer is retried up to 10 times with a backoff of 100 ms doubling to a 5 s cap, each attempt in a fresh random room; after that it fails with `transfer disconnected after 10 reconnect attempts`. - -## Feature surface (GUI must eventually cover) - -- Send: files, folders, multiple files, raw text (`--text`), piped stdin, zip-before-send (`--zip`), exclusions (`--exclude`, `--exclude-file`, `--git`) -- Receive: `--yes` auto-accept, `--overwrite`, `--rename` (receive an existing name as `name (1).ext` instead of prompting), output dir (`--out`), `--stdout` -- Code: random, custom (`--code`), env (`CROC_SECRET`), QR code of the web receive URL `https://getcroc.com/?code=...` (`--qrcode`), code copied to the clipboard by default (`--disable-clipboard`) -- Transport: custom relay (`--relay`, `--relay6`, `--pass`), self-hosted relay (`croc relay`, min 2 TCP ports, `--ports`, `--transfers`, admission limits), data path (`--transport auto|derp|relay`), SOCKS5 proxy incl. Tor (`--socks5`, relay DNS resolved by the proxy), HTTP proxy (`--connect`), upload throttle (`--throttleUpload`), curve + hash selection, local only (`--local`) -- Stored transfers: `croc store` (or `send --store`) uploads client-side-encrypted files to a service (default `https://getcroc.com`) for a limited number of downloads (`--downloads`, default 1) and lifetime (`--expiration`, default 1d); receive from the printed link or token, revoke with `--revoke`. Separate from the live PAKE protocol. -- Shared terminal: `croc ssh` hosts or joins a terminal with six-word invitations over a Tailcat path, relay fallback. -- IPv6-first (50 ms head start), IPv4 fallback +### 1. Code phrase + +- A generated code is three random words from the EFF short wordlist #1 (1296 words), joined by hyphens (`codephrase.Generate`). +- A custom code comes from `--code` (at least 6 characters) or the `CROC_SECRET` environment variable. Older `NNNN-word-word` codes still parse (legacy split, see step 2). +- On Unix, `send --code X` in the default (non-classic) mode exits without sending and tells you to use `CROC_SECRET=X croc send ...`. A receive code given as an argument is refused the same way. `croc --classic` lifts both. Prefer `CROC_SECRET` for scripting. +- The receiver takes the code as one argument, or as three or four arguments joined with hyphens. + +### 2. Rendezvous + +Both sides connect to a relay and join a room named `SHA-256(selector + "croc")`. + +- The selector is the first word of a three-word code, the first two words of a four-word code, and the first 4 characters of any other code (legacy). +- Two concurrent transfers whose codes share the selector, on the same relay, collide into one room ("room full"). With generated codes that is about 1 in 1296 per relay. Custom codes and test scripts must differ in the selector. + +Relay choice: + +- With no `--relay` or `--relay6` (or `CROC_RELAY`, `CROC_RELAY6`), no `--local` and no `--ip`, the CLI uses a public pool: `1.getcroc.com:9009` to `4.getcroc.com:9009` (`publicrelay.Relays`). +- Both sides pick the relay from the code: `RelayIndex` is the SHA-256 of the whole code read as a big-endian integer, modulo 4. +- A sender with no custom code uses the relay cached in a `best-relay` file in croc's config dir. Otherwise it probes all four (1 s timeout), caches the first to answer a ping, and generates codes until one maps to that relay (`GenerateForRelay`). A send on a generated code that fails with a relay connection error clears the cache. +- The pool path blanks the IPv6 relay. +- The old single default still exists: `models.DEFAULT_RELAY` (`croc.schollz.com:9009`) and `DEFAULT_RELAY6` (`croc6.schollz.com:9009`) are what `--relay` and `--relay6` default to. + +Ports: transfer ports default to 9009-9013, control port first. + +### 3. PAKE + +- The passphrase part of the code (the final two words of a three-word code) feeds PAKE2 (`github.com/schollz/pake/v3` v3.2.0, Boneh-Shoup construction) through `src/pakekey`. `pakekey` binds the exchange to the purpose, room and role of each side. +- The default curve is P-256 (`--curve`: p256, p384, p521, siec, ed25519). +- It turns the weak phrase into a strong session key. The relay never learns it. +- The peer PAKE protocol is versioned (`pakekey.ProtocolVersion` = 2) and fails closed on a mismatch. + +### 4. Encryption + +Everything is end to end. + +- The PAKE session key goes through HKDF-SHA256 with a 32-byte salt and a transcript of both PAKE messages, the room, the curve and the identities (`pakekey.Derive`). That yields a 32-byte traffic key plus two key-confirmation keys. +- Each side proves it holds the key (HMAC-SHA256 tags) before data flows. +- After that, everything on the wire is **AES-256-GCM** (`crypt.NewAESGCM`, a random 12-byte nonce prepended to each message). +- `crypt.New` (PBKDF2-HMAC-SHA256, 100 iterations) protects only the client-to-relay password handshake (`src/tcp`), not transfer data. +- `crypt.NewArgon2` (Argon2id with XChaCha20-Poly1305) and `EncryptChaCha` / `DecryptChaCha` exist in the same file but have no non-test call site in v11.5.4. Do not describe croc as Argon2- or ChaCha-based. +- The relay sees ciphertext only. It is untrusted by design. + +### 5. Transport + +Data is DEFLATE-compressed (HuffmanOnly; `--no-compress` disables it) and chunked at 32 KB. + +- A file whose first 256 KB shrinks by under 2% is sent uncompressed. +- The sender chooses the data path with `--transport` (`auto` default, `derp`, `relay`). Receivers always follow the sender. + +**relay**: chunks are claimed from a shared queue by up to 8 parallel TCP connections, one per relay transfer port. This is croc's classic speed trick. + +**Tailcat**: two native v11 clients build PAKE-bound node identities and carry TCP streams over an in-process userspace WireGuard network. It starts on DERP and moves to direct UDP when NAT traversal works. + +- `auto` offers Tailcat only when the total send is at least 128 MiB (`autoTailcatThresholdBytes`), and races it against the relay, which starts 350 ms later. `derp` requires Tailcat. +- A browser peer, an older client or a failed setup falls back to the relay ports. `derp` disables that fallback. +- `--transport` cannot be combined with `--local` unless it is `auto`. +- A build without Tailcat downgrades `derp` to `relay`. +- Setup fetches the DERP map from `https://tailcat.dev/derpmap.json` at runtime. Below the threshold, or with `relay`, none of those servers is contacted. +- CrocApp exposes only `auto` and `relay` (`crocmobile-bridge.md`, "Engine behavior invariants", "Transport"). + +### 6. Local path + +- The sender also starts a local relay and broadcasts its port over UDP multicast (`schollz/peerdiscovery`, default group 239.255.255.250 via `--multicast`, IPv4 and IPv6). +- The receiver races discovery (200 ms search, 500 ms cap) against the relay dial, staggered by 75 ms. Whichever connection completes first wins. If the relay wins, the receiver still asks the sender for its local IPs and tries them directly. +- Same-LAN transfers never touch the internet. +- Discovery is not guaranteed. It returned zero peers every time on one AP-isolated (client isolation) network, for both the stock CLI and crocmobile. That was seen with the previous croc pin and not re-tested on v11. Root cause not isolated. +- Workaround: `--ip :9009` on the receiver connects directly to the sender's local relay control port (the first local relay port, 9009 unless busy). It skips discovery and never touches the public relay. + +### 7. Resume and integrity + +- Interrupted transfers resume: the receiver scans the partial file for zeroed chunks. +- An existing file of the same size is hashed and skipped when equal. +- The hash is xxhash by default. `--hash` offers imohash (sampled), md5 and highway. + +### 8. Reconnect + +A dropped transfer is retried up to 10 times, with a backoff of 100 ms doubling to a 5 s cap. Each attempt uses a fresh random room. After that it fails with `transfer disconnected after 10 reconnect attempts`. + +## Feature surface + +- **Send**: files, folders, multiple files, raw text (`--text`), piped stdin, zip before send (`--zip`), exclusions (`--exclude`, `--exclude-file`, `--git`). +- **Receive**: `--yes` auto-accept, `--overwrite`, `--rename` (receive an existing name as `name (1).ext` instead of prompting), output dir (`--out`), `--stdout`. +- **Code**: random, custom (`--code`), env (`CROC_SECRET`), a QR code of the web receive URL `https://getcroc.com/?code=...` (`--qrcode`), and the code copied to the clipboard by default (`--disable-clipboard`). +- **Transport**: + - custom relay (`--relay`, `--relay6`, `--pass`); + - self-hosted relay (`croc relay`, at least 2 TCP ports, `--ports`, `--transfers`, admission limits); + - data path (`--transport auto|derp|relay`); + - SOCKS5 proxy including Tor (`--socks5`, relay DNS resolved by the proxy); + - HTTP proxy (`--connect`); + - upload throttle (`--throttleUpload`); + - curve and hash selection; + - local only (`--local`). +- **Stored transfers**: `croc store` (or `send --store`) uploads client-side-encrypted files to a service (default `https://getcroc.com`). It sets a limited number of downloads (`--downloads`, default 1) and a lifetime (`--expiration`, default 1d). The receiver uses the printed link or token. The sender revokes with `--revoke`. This is separate from the live PAKE protocol. +- **Shared terminal**: `croc ssh` hosts or joins a terminal with six-word invitations over a Tailcat path, with a relay fallback. +- **IPv6**: tried first (50 ms head start), IPv4 as fallback. ```bash croc send file.txt # -> prints word-word-word @@ -37,14 +116,25 @@ CROC_SECRET=word-word-word croc # receiver (Unix) croc --relay myserver:9009 send f # self-hosted relay ``` -## Security history +## Known CVEs -2023 (v9.6.5 and earlier): a security review led to CVE-2023-43616..43621, covering dangerous file overwrites and zip extraction among others, and the secret visible in the process list to local users (CVE-2023-43621, why Unix needs `CROC_SECRET`). Fixed in v9.6.16, a breaking protocol change that upstream re-released as croc 10.0.0. The v11 repo references no formal audit. GUI implication: croc's threat model assumes an untrusted relay and trusted endpoints, so CrocApp should surface receive confirmations and never invent auto-accept defaults. +- A security review of croc led to the CVEs CVE-2023-43616 to CVE-2023-43621. One of them is the secret visible in the process list to local users (CVE-2023-43621), which is why Unix needs `CROC_SECRET`. CVE-2023-43620 is terminal escapes in file names (per the module's `packaging/distributions/security-evidence.md`). +- The descriptions of the other CVEs, and the exact version that fixed each one, are not verified here. The module's Alpine package file lists the six CVEs under the 10.0.11 package, and says nothing more. +- The breaking protocol change that followed shipped as croc 10.0.0. +- The v11 repository references no formal audit. +- GUI implication: croc's threat model assumes an untrusted relay and trusted endpoints. CrocApp therefore surfaces receive confirmations and never invents auto-accept defaults. ## Facts relevant to CrocApp -- Importable as Go library: `github.com/schollz/croc/v11/src/croc` (`croc.New(croc.Options{...})`, or `croc.NewCtx(ctx, opts)` to pass a context, which crocmobile uses). Pure Go + x/crypto, gomobile binding works. New `Options` in v11: `Transport`, `Rename`, `PublicRelay` (set when the relay came from the pool; it only changes the receive hint the sender prints). -- Peers need croc 11 or newer. A v10 and a v11 peer cannot complete a transfer: v11 rejects a PAKE message without protocol version 2 (`peer uses unsupported PAKE protocol version 0; upgrade both croc clients`), and v10.5.0's `message.Message` has no version field at all. The v11 key schedule differs from v10's as well. The peers often never meet at all (v10 derives the relay room from the code's first 4 characters, v11 from the first word, and v10's default relay is not always the pool relay a v11 code maps to), so `peerVersion` fires only when both reach the same room. -- croc's README lists a community native Kotlin client (`croc-app`, F-Droid) that is not in-repo; `docs/knowledge/prior-art.md` covers its subprocess architecture, unusable on iOS. The README also lists `croc-desktop` (Wails, in-process croc) with experimental iOS/Android; no dedicated free native iOS client is listed, so CrocApp fills a real gap. -- Likely iOS friction points: UDP multicast discovery (needs `com.apple.developer.networking.multicast` entitlement) and listening sockets for the local relay. -- Default relay password constant `"pass123"`; `TCP_BUFFER_SIZE` 64 KB; chunk size 32 KB. +- **Library use**: croc is importable as `github.com/schollz/croc/v11/src/croc`. Use `croc.New(croc.Options{...})`, or `croc.NewCtx(ctx, opts)` to pass a context, which crocmobile does. It is pure Go plus `x/crypto`, so a gomobile binding works. +- **New `Options` in v11**: `Transport`, `Rename`, and `PublicRelay` (set when the relay came from the pool; it only changes the receive hint the sender prints). +- **Peers need croc 11 or newer.** A v10 and a v11 peer cannot complete a transfer: + - v11 rejects a PAKE message without protocol version 2 (`peer uses unsupported PAKE protocol version 0; upgrade both croc clients`). + - v10.5.0's `message.Message` has no version field at all. + - The v11 key schedule also differs from v10's. +- **v10 peers often never meet.** v10 derives the relay room from the code's first 4 characters, and v11 from the first word. v10's default relay is not always the pool relay a v11 code maps to. So `peerVersion` fires only when both reach the same room. +- **Other clients**: croc's README lists a community native Kotlin client (`croc-app`, on F-Droid) that is not in the croc repo. `prior-art.md` covers its subprocess architecture, which is unusable on iOS. The README also lists `croc-desktop` (Wails, croc in process) with experimental iOS and Android builds. No dedicated free native iOS client is listed, so CrocApp fills a real gap. +- **iOS friction points**: + - UDP multicast discovery needs the `com.apple.developer.networking.multicast` entitlement. + - The local relay needs listening sockets. +- **Constants**: default relay password `"pass123"`, `TCP_BUFFER_SIZE` 64 KB, chunk size 32 KB. diff --git a/docs/known-issues.md b/docs/known-issues.md index 6799197..6cb83ce 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -1,91 +1,228 @@ # Known issues -Triaged defects, accepted papercuts, and deferred work in the shipped app. Every item here was found during review and consciously not fixed. None block V1 unless marked. +Defects, limitations and risks the project knowingly lives with. Each item says what is wrong, why it was left, and what would change that. -This exists so a session does not re-discover and re-report the same thing. If you fix one, delete the line. If you decide one is permanent, move it to "Accepted" and say why. +An item is added when a defect is found and left, and deleted when the defect is fixed. If a limitation is permanent, it goes under "Accepted" with the reason. -Engine and bridge invariants live in `knowledge/crocmobile-bridge.md`; UI state-machine facts in `knowledge/app-ui-architecture.md`. +Engine and bridge invariants are in `knowledge/crocmobile-bridge.md`. UI state-machine facts are in `knowledge/app-ui-architecture.md`. -## Blocking release +## Release -None. +These items gate Submit for Review of the first App Store release (`knowledge/project-overview.md`, "Status"). + +- **Export compliance needs two owner actions in App Store Connect.** + - The position (`ITSAppUsesNonExemptEncryption` is `false`, France excluded from availability) is in `RELEASING.md`, "Export compliance". Owner actions: remove France from availability, and file a `future` issue to add it back after an ANSSI declaration. + - Also undecided: croc's `siec` curve is selectable in Advanced settings and may count as non-standard cryptography. That would need a BIS and NSA notification email, or hiding the curve. + - The research behind this is in `knowledge/app-store-listing.md`, "Export compliance". It is not legal advice, and the exact App Store Connect questions could not be fetched. +- **The Accessibility Inspector and VoiceOver audit has not run on any 1.0 view.** + - It needs Accessibility Inspector and VoiceOver on iOS and macOS, so no automated session can do it. + - Views to cover: the Keep Both / Replace sheet, the **When a File Already Exists** and **Connection** pickers, the waiting screen's QR code, Share Link and relay note, Send as Link and its result screen, the stored-link row on Receive, History (Revoke and the row layout), the stored-server field, the Advanced settings page, and the menu-bar window. + - Checked so far without the tools: a code pass over every 1.0 view against `design/accessibility.md`, and the iPhone 17 Pro simulator at the largest accessibility text size. The sheet's buttons stack, status screens scroll, the code wraps, and the pickers read in full. Long names in the incoming list are middle-truncated by design. + - Still to check by ear: whether VoiceOver reads an Advanced field's hint and its visible footnote or error twice, whether a decorative QR image with a label is exposed as an image, and Full Keyboard Access focus in the menu-bar window when its content changes. + - Not looked at: the macOS layout of the changed views. + - Not measured: the rendered size of the code field's system `PasteButton` against the 44 pt hit target. SwiftUI lets the app set only its border shape, label style and tint, so the system decides its size. +- **`release.yml` puts the signing passwords on `security` argv.** + - The `.p12` password and the throwaway keychain password appear in `security import ... -P`, `security create-keychain -p`, `security unlock-keychain -p` and `security set-key-partition-list -k`. + - `security` has no stdin option for them, so other processes on the runner can read them while the command runs. + - Acceptable only on a single-tenant runner. Never run this workflow on a shared runner. +- **`release.yml` installs `create-dmg` unpinned** (`brew install create-dmg`). + - `scripts/build-dmg.sh` runs it while the signing keychain is unlocked and the notary key is on disk. A compromised or broken formula update would run with access to both. + - Homebrew has no simple version pin for a formula, and a version check would fail every release after an upstream bump. + - If this changes, pin it in a version file like `scripts/croc-v10.version`. +- **A local Sparkle update test once hung after "Install and Relaunch".** + - The test is in `RELEASING.md`, "Testing a Sparkle update locally". On the first launch of the `com.bakirgdev.CrocApp.direct` bundle ID on that Mac, the update downloaded and prepared. Then the app never terminated, its Quit menu item was ignored, and the installer kept waiting. + - Later runs passed, some with Sparkle's first-launch defaults deleted, so first-launch state alone does not explain it. The cause is unknown. + - If it recurs, watch `log stream` for `org.sparkle-project.Sparkle` and for the app's termination, and repeat the release rehearsal from a clean macOS account. +- **App Store uploads report "Upload Symbols Failed" for `Croc.framework` (iOS) and `A` (macOS).** + - Xcode embeds a 50 KB stub `Croc.framework` dylib with no dSYM, because the gomobile xcframework is a static archive linked into the app binary. There is nothing to symbolicate there. + - It is a warning of Xcode's upload step, not an ITMS warning, and the build still processes. +- **The App Store and direct-download builds cannot both be installed in /Applications.** + - Both are named `CrocApp.app` (`PRODUCT_NAME = CrocApp` on `CrocApp Direct`, so the menu bar, About and Quit say "CrocApp"). + - The separate bundle ID keeps their data apart, but dragging the DMG copy into /Applications replaces the App Store copy. + - What the App Store does when a direct-download `CrocApp.app` is already there is untested. Use one channel per Mac. ## Harnesses -- **No harness proves a LAN-discovery transfer on a custom multicast address.** croc drops discovery packets from its own host's addresses, so two peers on one machine never find each other (CLI to CLI included). `verify-interop.sh` scenario 15 instead listens on the group and asserts what each side announces. A real two-device check on a shared non-default group is not done. -- **A delegate panic in `OnCodeReady` leaves a text send's temp file behind.** `startSession`'s uncommitted cleanup removes the zip, cwd and lock but not `tempPath` (`crocmobile/session.go`). Found by `TestNetworkGlobalsRestoredAfterPanic`; harmless in the app (the file sits in the temporary directory), not fixed in B19. -- **`BUILDING.md` §5's `croc relay` runs whichever `croc` is first on `PATH`.** On a machine set up with `CROC_V10` at `~/go/bin/croc` ahead of Homebrew, that is v10, which rejects `--source-join-limit`, and every scenario then fails `connection refused`. Run `"$(brew --prefix)/bin/croc" relay ...` there. -- **On the development Mac, `croc --transport derp` CLI to CLI does not complete.** 130 MiB failed twice with `DERP connection failed: peer connection: dial Tailcat streams: context deadline exceeded` after 10 s, while all four `*.ipn.dev` DERP hosts answered HTTPS (200). Cause unknown; both peers on one host is a suspect, not proven. On a physical iPhone `derp` completed (owner, 2026-10-14, `knowledge/device-test-checklist.md`, "Transport and Tailcat gate" (d)), so Tailcat's data path works on iOS and the gate passed. Under `auto` on the Mac the relay carries the data, so transfers succeed, and `verify-interop.sh` scenario 13 and `verify-app-mac.sh` scenario 8 only assert the `tailcat-offer`, not that Tailcat carried the bytes. - -- **`release.yml` passes the `.p12` password and the throwaway keychain password on `security` argv** (`security import ... -P`, `security create-keychain -p`, `unlock-keychain -p`, `set-key-partition-list -k`). `security` has no stdin option for them, so other processes on the runner can read them while the command runs. Acceptable only on a single-tenant runner. Do not run this workflow on a shared runner. -- **`release.yml` installs `create-dmg` unpinned** (`brew install create-dmg`), and `scripts/build-dmg.sh` runs it while the signing keychain is unlocked and the notary `.p8` is on disk. A compromised or broken formula update would run with access to both. Accepted for 1.0: Homebrew offers no simple version pin for a formula, and a version check would fail every release after an upstream bump. If that changes, pin it in a version file like `scripts/croc-v10.version`. -- **The first local Sparkle update test hung after "Install and Relaunch".** In the update test (`docs/BUILDING.md`, "Testing a Sparkle update locally"), run 1, the very first launch of the `com.bakirgdev.CrocApp.direct` bundle ID on that Mac, downloaded and prepared the update, but after "Install and Relaunch" the app never terminated. Its own Quit menu item was then ignored too (main thread idle), and the installer kept waiting. The next 4 runs passed, 3 of them with Sparkle's first-launch defaults deleted, so first-launch state alone does not explain it. Cause unknown. If it recurs, watch `log stream` for `org.sparkle-project.Sparkle` and for the app's termination, and repeat the release rehearsal from a clean macOS account. - -## Release - -- **Export compliance: two owner actions before Submit for Review (O9).** Researched in B26, 2026-10-15; the full findings and sources are in `docs/knowledge/app-store-listing.md`, "Export compliance". Both app `Info.plist` files keep `ITSAppUsesNonExemptEncryption` `false` (open-source exemption, EAR 742.15(b)). Apple's own table makes that coherent only while France is excluded, so (1) remove France from availability in App Store Connect and file a `future` issue to add it back after an ANSSI declaration (the key then becomes `true` with an issued code); (2) decide on SIEC: croc's `siec` PAKE curve is selectable in Advanced settings and may count as non-standard cryptography, which would need a BIS and NSA notification email or hiding the curve. The research is not legal advice, and the exact App Store Connect questions could not be fetched. -- **The R29 Accessibility Inspector and VoiceOver audit has not run on any 1.0 view.** Owner step O12, before Submit for Review: audit every view new or changed since 0.9.9 with Accessibility Inspector and VoiceOver, on iOS and macOS: the Keep Both / Replace sheet, the "When a file already exists" and Connection pickers, the waiting screen's QR, Share Link and relay note, Send as link and its result screen, the stored-link row on Receive, History (Revoke, the row layout), the stored-server field, the Advanced settings page, and the menu-bar window. No session can drive Inspector: computer use was not available in B23 (2026-10-15). What was checked instead: - - **2026-10-14:** iPhone 17 Pro simulator at accessibility-extra-extra-extra-large, screenshots only. The sheet's buttons stack, the status screens scroll, the code wraps inside its glass shape, and the Connection and file-policy pickers read in full. A long file name in the incoming list is middle-truncated by design (one line). - - **2026-10-15 (B23):** a code pass over every 1.0 view against `design/accessibility.md`, with its findings fixed, then the same simulator size for Send (empty and staged), Receive, Settings, Advanced, History and How it works. History and Send had overflowing layouts, now fixed. Send's staged-file list keeps its 280 pt height cap and scrolls inside the page at that size. - - **To check by ear:** whether VoiceOver reads the hint and the visible footnote or error of an Advanced field twice (the hint is there so the text is read with the field), whether a `decorative` QR image with a label is exposed as an image, and Full Keyboard Access focus in the menu-bar window when its content changes. - - **Not looked at:** macOS layout of the changed views. -- **The App Store and direct-download builds cannot both be installed in /Applications.** Both are named `CrocApp.app` (`PRODUCT_NAME = CrocApp` on `CrocApp Direct`, so the menu bar, About and Quit say "CrocApp"). The separate bundle ID keeps their data apart, but dragging the DMG copy into /Applications replaces the App Store copy. What the App Store does when a direct-download `CrocApp.app` is already there is untested. One channel per Mac. +- **No harness proves a LAN-discovery transfer on a custom multicast address.** + - croc drops discovery packets from its own host's addresses, so two peers on one machine never find each other (CLI to CLI included). + - The multicast case in `scripts/verify-interop.sh` instead listens on the group and asserts what each side announces. + - A real two-device check on a shared non-default group has not been done. +- **On the development Mac, `croc --transport derp` CLI to CLI does not complete.** + - A 130 MiB send failed twice with `DERP connection failed: peer connection: dial Tailcat streams: context deadline exceeded`, while all four `*.ipn.dev` DERP hosts answered HTTPS. + - The cause is unknown. Both peers on one host is a suspect, not proven. + - On a physical iPhone `derp` completed (`knowledge/device-test-checklist.md`, "Transport and Tailcat gate"), so Tailcat's data path works on iOS. + - Under `auto` on the Mac the relay carries the data, so transfers succeed. The transport cases in `verify-interop.sh` and `verify-app-mac.sh` assert only the `tailcat-offer` in the CLI log, not that Tailcat carried the bytes. +- **The harnesses fail at random against the public relays when they rate-limit.** + - A croc v11 relay admits 30 connections a minute from one address (`DEFAULT_SOURCE_JOIN_LIMIT`, `DEFAULT_JOIN_LIMIT_WINDOW`). A multiplexed transfer opens 9 per peer (1 control, 8 data). With both peers on one machine, two transfers on the same pool relay inside a minute can be refused with `relay admission rate limited`. + - `verify-interop.sh`, `verify-app-mac.sh` and `verify-app-sim.sh` then fail at whichever step hits the limit. The pool-relay case in `verify-interop.sh` waits 60 s and turns multiplexing off for this reason. + - CI avoids it: the `interop (local relay)` job sets `RELAY` to a local `croc relay` with raised admission limits (`BUILDING.md`, "Verification harnesses"). + - The other harnesses have no local-relay mode. Use a local relay for a deterministic result, leave a minute between public-relay runs, and treat a failure as a regression only after finding the cause in its log. ## Design system -- **iPad shows the iPhone layout stretched; there is no iPad-specific layout.** The app is one `NavigationStack` and `TARGETED_DEVICE_FAMILY` is `1,2`, so on an iPad Pro 13-inch the screens are the phone screens at tablet size, with content capped by `LayoutCap.contentMaxWidth` and wide empty margins. The `ipad/` screenshots and the 13-inch App Store set show exactly that. A split view or a two-column Send/Receive layout would be new work; until then App Store listing copy should not promise an iPad-optimised experience. -- **The paste button in `CodeField` is 38 pt, below the 44 pt hit-target rule.** The component spec in `CodeField.swift` sets it, and `design/accessibility.md` says 44 pt for anything interactive. One of the two documents has to change. Found in the B23 accessibility pass, not changed there. -- **Small status glyphs use fixed point sizes.** `StatusBanner`, `StatusBlock`, `TrustBadge`, `FileRow`, `OnboardingView`, `ContentView` and the direction arrow in `TransferStatusView` set `.font(.system(size:))`, so they do not track Dynamic Type, against `design/accessibility.md` ("App glyphs size by text style"). They are decorative or repeat a word next to them, so nothing is lost at large sizes. Predates 1.0; found in the B23 accessibility pass. -- **`FileRow` stats each file for its size label inside `body`.** `List` is lazy so only visible rows pay, but it is still synchronous I/O on the main actor during layout, and a network or iCloud volume could make it hitch. Not measured. +- **iPad shows the iPhone layout stretched; there is no iPad-specific layout.** + - The app is one `NavigationStack` and `TARGETED_DEVICE_FAMILY` is `1,2`. On an iPad Pro 13-inch the screens are the phone screens at tablet size, with content capped by `LayoutCap.contentMaxWidth` and wide empty margins. + - The `ipad/` screenshots and the 13-inch App Store set show exactly that. + - A split view or a two-column Send/Receive layout would be new work. Until then, App Store listing copy must not promise an iPad-optimised experience. +- **`FileRow` looks up each staged file's size in `init(url:)`** (`app/CrocApp/Views/FileRow.swift`). + - It is synchronous file I/O on the main actor, run by `SendView` when it builds the row. A network or iCloud volume could make it hitch. + - Not measured. A fix would read the size once when files are staged. ## Landing page -- **The primary CTA's two store badges still resolve to nothing.** "Download" in the hero scrolls to three channels; the GitHub releases link works now that v0.9.9 is published, but the two store badges stay unlinked until the apps ship. The release it reaches is ad-hoc signed, so Gatekeeper refuses it on first launch until notarization is real — the release notes carry the Privacy & Security route. -- **`--content-max-width` on the hero mockup.** `design/web.md` now carves an explicit exception for elements that depict the app, so this is documented rather than fixed — but the exception is narrow and easy to over-apply. -- **Store badges are inverted in dark mode with a CSS filter.** Apple ships black and white variants; the repo has black only. Inverting a black-on-transparent badge produces the white variant, but it is a filter over supplied artwork, not the supplied artwork. -- **`llms.txt`'s release status is hand-maintained and nothing checks it.** It has to be edited by hand on every release. The JSON-LD `dateModified` used to sit beside it and has been dropped: it rotted on every commit that touched the page, templating it from `git log` puts a moving part inside a structured-data block where a broken template corrupts silently rather than failing loudly, and `datePublished` alone is true forever. `og.jpg` is a second hand-made asset but a deliberate one, fixed rather than rotting. -- **The landing and docs pages set no Content-Security-Policy.** GitHub Pages cannot send headers, so only a `` CSP is possible, and it needs a hash for each inline script (the theme script on every page, the code parser in `r/index.html`, Docusaurus's own inline scripts) that must be regenerated on every edit. No injection sink exists today: `r/index.html` and `app.js` write with `textContent` and build links with `encodeURIComponent`, and nothing loads third-party script (`web/` review, 2026-10-15, W1). Recorded rather than fixed for 1.0: a wrong hash breaks the page silently in the browser, and `scripts/assemble-site.sh` does not yet check it. Clickjacking of the `/r/` buttons stays open either way, since `frame-ancestors` is ignored in a meta tag. -- **`/r/` accepts any fragment of six or more characters without whitespace as a code**, so `/r/#download` offers to open "download" in CrocApp. Display only; the text is escaped and the app validates the code (W5). -- **The AASA file claims `/r/*`**, wider than the one page that exists. `/r/x` is a Pages 404, so no real link is taken (W6). `security.txt` expires 2027-08-01 and needs renewing before then (W7). +- **The two store badges are not links.** + - The hero **Download** button scrolls to three channels. The direct-download card links to the latest GitHub release. + - The App Store and Mac App Store cards say "at launch" and show unlinked badges, because those apps are not released. + - They become links when the apps ship, with real alt text (the markup carries a comment saying so). +- **The hero mockup uses `--content-max-width`.** + - `design/web.md` allows this for an element that depicts the app. + - The exception is narrow and easy to over-apply. +- **`llms.txt` release status is hand-maintained and nothing checks it.** + - It has to be edited on every release. + - The JSON-LD carries only `datePublished`, which stays true. A `dateModified` would go stale on every commit, and templating it from `git log` would put a moving part inside a structured-data block, where a broken template corrupts silently. + - `og.jpg` is a second hand-made asset, but it does not change. +- **The landing and docs pages set no Content-Security-Policy.** + - GitHub Pages cannot send headers, so only a `` CSP is possible. It would need a hash for each inline script (the theme script on every page, the code parser in `r/index.html`, Docusaurus's own inline scripts), regenerated on every edit. + - A wrong hash breaks the page silently in the browser, and `scripts/assemble-site.sh` does not check it. + - No injection sink exists today: `r/index.html` and `app.js` write with `textContent`, links are built with `encodeURIComponent`, and nothing loads third-party script. + - Clickjacking of the `/r/` buttons stays open either way, because `frame-ancestors` is ignored in a meta tag. +- **`/r/` accepts any fragment of six or more characters without whitespace as a code.** + - So `/r/#download` offers to open "download" in CrocApp. + - It is display only: the text is escaped and the app validates the code. + - Tightening the check would reject valid custom codes, so it stays loose. +- **The AASA file claims `/r/*`**, wider than the one page that exists. + - `/r/x` is a GitHub Pages 404, so no real link is taken. + - Narrowing the component to `/r/` would match the page. Apple caches the file, so the change needs a universal-link re-test on a device and in the Direct build. +- **`security.txt` has an `Expires` field** (`web/landing/.well-known/security.txt`). Renew it before that date passes. ## Docs site -- **No site search, on purpose.** A local search plugin is a new dependency and Algolia DocSearch is an external account plus a third-party request from a page that makes a privacy claim. Sixteen pages with a full sidebar and browser find-in-page is enough for now. Revisit if the page count grows. -- **The version dropdown is invisible.** Docusaurus hides `docsVersionDropdown` while only one version exists; it stays hidden until the first `docusaurus docs:version` cut. -- **A landing-only change rebuilds the docs site too, and vice versa.** `github-pages.yml` calls `scripts/assemble-site.sh`, which builds both surfaces regardless of which one changed. -- **Thirty-seven of the 44 screenshot pairs are unused.** `scripts/capture-screenshots.sh` writes 44 light/dark pairs under `assets/screenshots/{mac,iphone,ipad}/`; the README, six docs pages and the landing hero between them reference seven (`mac/send-code`, `mac/send-files`, `mac/settings`, `mac/history`, `iphone/receive-incoming`, `iphone/settings`, `iphone/onboarding`), and the whole `app-store/` set is for App Store Connect only. The rest are deliberate headroom, not waste: adding a usage is cheap, re-capturing a deleted one is not. -- **The two sitemaps stay separate.** `web/landing/sitemap.xml` is hand-maintained and lists the one landing URL; `robots.txt` advertises the Docusaurus-generated `/docs/sitemap.xml` alongside it, so crawlers reach both. Not a defect with a single landing route — the `lastmod` that used to rot there has been dropped. -- **The docs build is not fully pinned.** `packageManager` in `web/docs/package.json` has no `+sha512` hash, so the pnpm that `github-pages.yml` installs is pinned by version only, not by hash (`corepack use pnpm@` writes it); the four `overrides` in `pnpm-workspace.yaml` are open-ended `>=` floors; and `pnpm audit --prod` reports one High advisory with no fix, `braces@3.0.3` (GHSA-vfj7-8cjw-p6xm, stack exhaustion), reached through chokidar and micromatch inside Docusaurus core. All build-time only; no attacker input reaches the glob handling (`web/` review, 2026-10-15, W3, W4, W9). Re-run the audit on each Docusaurus upgrade. +- **No site search, on purpose.** + - A local search plugin is a new dependency. Algolia DocSearch is an external account plus a third-party request from a page that makes a privacy claim. + - Twenty-one pages with a full sidebar and browser find-in-page are enough at this size. Revisit if the page count grows. +- **The version dropdown is invisible.** Docusaurus hides `docsVersionDropdown` while only one version exists. It stays hidden until the first `docusaurus docs:version` cut. +- **A landing-only change rebuilds the docs site too, and the reverse.** `github-pages.yml` calls `scripts/assemble-site.sh`, which builds both surfaces regardless of which one changed. +- **Thirty-seven of the 44 screenshot pairs are unused.** + - `scripts/capture-screenshots.sh` writes 44 light/dark pairs under `assets/screenshots/{mac,iphone,ipad}/`. + - Seven are referenced, by the README, seven docs pages and the landing hero: `mac/send-code`, `mac/send-files`, `mac/settings`, `mac/history`, `iphone/receive-incoming`, `iphone/settings`, `iphone/onboarding`. + - The `app-store/` set is for App Store Connect only. + - The rest are headroom on purpose: adding a usage is cheap, and re-capturing a deleted one is not. +- **The two sitemaps stay separate.** + - `web/landing/sitemap.xml` is hand-maintained and lists the landing, support and privacy pages. + - `robots.txt` advertises it next to the Docusaurus-generated `/docs/sitemap.xml`, so crawlers reach both. +- **The docs build is not fully pinned.** + - `packageManager` in `web/docs/package.json` has no `+sha512` hash. `github-pages.yml` installs pnpm with `pnpm/action-setup`, so pnpm is pinned by version only. + - The four `overrides` in `web/docs/pnpm-workspace.yaml` are open-ended `>=` floors. + - `pnpm audit --prod` reports one High advisory with no fix: `braces@3.0.3` (GHSA-vfj7-8cjw-p6xm, stack exhaustion), reached through `chokidar` and `micromatch` inside Docusaurus core. It is build-time only, and no attacker input reaches the glob handling. + - Re-run the audit on each Docusaurus upgrade. ## Accepted -- **"Upload Symbols Failed" for `Croc.framework` (iOS) and `A` (macOS) on every App Store upload.** Xcode embeds a 50 KB stub `Croc.framework` dylib with no dSYM, because the gomobile xcframework is a static archive linked into the app binary, so there is nothing to symbolicate there. It is a warning of Xcode's upload step, not an ITMS warning, and the build still processes. Seen 2026-10-14, first upload. -- **When both relay addresses are customised, both are sent.** croc's own CLI blanks `RelayAddress6` whenever v4 is customised, without checking whether v6 was customised too (`src/cli/cli.go`, send and receive alike), so it silently discards a user's custom v6 relay. Keeping both is more correct, not less. The divergence is deliberate and is not going to be "fixed" toward the CLI's behaviour. -- **`dropDestination(for:action:isTargeted:)` stays at `ContentView.swift` and `SendView.swift`.** The Xcode 27 SDK marks it deprecated only for a future OS (`deprecated: 100000.0`), so builds emit no warning. The replacement `dropDestination(for:isEnabled:action:)` takes a `Void` action (a drop cannot be rejected) and has no `isTargeted`; its highlight API `onDropSessionUpdated` is iOS 27 / macOS 26 only, and the deployment target is iOS 26.0. Revisit when the minimum OS reaches 27. -- **`OutputFolderStore.select` silently no-ops if bookmarking fails.** Rare, and the alternative is an error path for a condition the user cannot act on. -- **A zipped-folder receive that collides with more than 4096 existing members still fails.** crocmobile answers croc's per-member `Overwrite ''? (y/N)` prompts from a batch of 4096 lines written up front (`promptPolicyAnswers`), sized to fit a fresh pipe buffer on Darwin and Linux so the write never blocks. Past that, croc's unzip reads EOF and fails with `received archive failed validation or extraction`, leaving the received `.zip`. Accepted: a larger batch could block the write (`knowledge/crocmobile-bridge.md`). -- **The incoming sheet cannot preview which members of a zipped folder already exist.** The file list shows one `.zip` name, so the conflict scan sees none of its members and offers only Accept (Keep Both). The existing members are kept and the matching ones in the archive skipped under every policy, Replace and auto-accept included: the stdin answers croc's unzip prompt reads are always "n", because a queued "y" would also answer the accept prompt croc re-runs after a reconnect or forces when the sender ran `--ask` (`knowledge/crocmobile-bridge.md`). Accepted: the archive's contents are only known to croc after it unpacks, and keeping what is there loses nothing. -- **A file whose name ends in a space cannot be sent to a croc v11 peer.** CrocApp sends the exact name (crocmobile `splitPaths`, B22), but croc 11.5.4 receivers, CLI and crocmobile alike, refuse it: `unsafe receive path in "report.pdf ": Windows-trimmed trailing character`; the sender sees `cancelled`. Accepted: the refusal is croc's receive-path policy, and before B22 the send silently picked up the sibling file without the space. Rename the file to send it. -- **A croc v11 reconnect after a manually accepted receive ends as `refused`.** croc re-runs the accept prompt on the sender's file list after each reconnect (`resetForReconnectAttempt`, then `processMessageFileInfo`), and the next queued stdin answer is "n", so the receive fails instead of resuming. Accepted: answering "y" there would accept a list the user never saw (security scan F2, 2026-10-15), and crocmobile cannot tell that prompt from the unzip prompt on the same pipe. From reading croc v11.5.4; no harness forces a reconnect. -- **The trust badge names the configured route, not the path that carried the data.** `TrustBadge` reads the relay setting, so a transfer that croc moved over the LAN, or over Tailcat under Connection = Automatic, still shows "Via the public croc relay" (or the custom relay). croc does not report the winning path to the engine. Accepted: the badge's claim that the server sees only encrypted data holds on every path. +Permanent or deferred, each with its reason. + +### Build and CI + +- **`braces` <= 3.0.3 (GHSA-vfj7-8cjw-p6xm) has no patched release.** + - It is reached only at docs build time, through `chokidar` and `micromatch` over our own paths, never from user input. No override can lift it. + - `pnpm audit` lists it, and any Dependabot alert for it is dismissed with this reason. + - `web/docs/osv-scanner.toml` ignores it so OpenSSF Scorecard's Vulnerabilities check does not count it. Remove this item and that entry when `braces` publishes a fix. +- **`golang.org/x/crypto` carries GO-2026-5932 (the unmaintained `openpgp` package) with no fixed version.** + - The advisory covers the whole module, but nothing imports `openpgp`. In non-test code croc v11.5.4 imports only the `argon2`, `chacha20poly1305`, `hkdf`, `pbkdf2` and `ssh` packages of `x/crypto`, and `go tool govulncheck ./...` in `crocmobile/` reports it as required but not called. + - `crocmobile/osv-scanner.toml` ignores it for Scorecard. Remove both if `x/crypto` drops `openpgp` or a dependency starts importing it. +- **Three OpenSSF Scorecard alerts are dismissed as "won't fix".** Scorecard opens a code-scanning alert for every check below 10, and these cannot reach 10 with one maintainer. + - **Branch-Protection:** 10 needs two approving reviewers with admins included, so nothing could merge. `main` keeps pull requests, the `ci pass` check, linear history, and no force push or deletion. + - **Code-Review:** needs approvals from someone other than the author. + - **CII-Best-Practices:** the project holds the passing badge, which scores below the maximum (exact score not verified). 10 needs gold, and gold needs several developers. + - Revisit Branch-Protection and Code-Review if a second maintainer joins. + +### App and UI + +- **`dropDestination(for:action:isTargeted:)` stays at `ContentView.swift`, `SendView.swift` and `MenuBarQuickSendView.swift`.** + - The Xcode 27 SDK marks it deprecated only for a future OS (`deprecated: 100000.0`), so builds emit no warning. + - The replacement `dropDestination(for:isEnabled:action:)` takes a `Void` action (a drop cannot be rejected) and has no `isTargeted`. Its highlight API `onDropSessionUpdated` is iOS 27 / macOS 26 only, and the deployment target is iOS 26.0. + - Revisit when the minimum OS reaches 27. +- **`OutputFolderStore.select` silently does nothing if bookmarking fails.** It is rare, and the alternative is an error path for a condition the user cannot act on. - **Receive list identity is by file name.** Duplicate names within one transfer are a croc-level concern, not a UI one. -- **Gesture-level flows are not machine-verified.** Drag-and-drop, camera QR, and taps have no XCUITest coverage, by decision: unit tests cover non-UI logic (Go `go test -race`, Swift Testing in `CrocKitTests` and `CrocAppTests`, all in CI), transfers are covered by the harnesses, and UI flows are verified by hand. The rule is in `CONTRIBUTING.md` (Verification, Tests). -- **`braces` <= 3.0.3 (GHSA-vfj7-8cjw-p6xm) has no patched release.** Stack exhaustion on deeply nested brace patterns; it is reached only at build time through `chokidar` and `fast-glob` over our own docs paths, never from user input, and no override can lift it. `pnpm audit` lists it, and any Dependabot alert for it is dismissed with this reason. `web/docs/osv-scanner.toml` ignores it so OpenSSF Scorecard's Vulnerabilities check does not count it. Remove this item, and that entry, when `braces` publishes a fix. -- **`golang.org/x/crypto` carries GO-2026-5932 (the unmaintained `openpgp` package) with no fixed version.** The advisory covers the whole module, but nothing imports `openpgp`: croc uses only `x/crypto/argon2`, and `go tool govulncheck ./...` in `crocmobile/` reports it as required but not called. `crocmobile/osv-scanner.toml` ignores it for Scorecard. Remove both if x/crypto drops `openpgp` or a dependency starts importing it. -- **Four OpenSSF Scorecard alerts are dismissed as "won't fix".** Scorecard opens a code-scanning alert for every check below 10, and these cannot reach 10 with one maintainer: Branch-Protection (#1; 10 needs two approving reviewers with admins included, so nothing could merge; `main` keeps PRs, the `ci pass` check, linear history, and no force push or deletion), Code-Review (#5; needs approvals from someone other than the author), Maintained (#4; fires only while the repo is under 90 days old, created 2026-07-21), and CII-Best-Practices (#6; the project holds the passing badge since 2026-10-17, which scores 5; 10 needs gold, and gold needs several developers). Dismissed 2026-10-17. Revisit Branch-Protection and Code-Review if a second maintainer joins. -- **`xfer()` has no watchdog.** If croc ever blocks past context cancel and prompt-pipe close, `activeMu` is held for the process's lifetime and every later transfer returns "another transfer is active". Accepted: forcing the lock open while croc is still running would let two sessions mutate the same process-global state (`os.Chdir`, stdin/stdout swaps). -- **`poll()` reads `croc.Client` scalar and slice fields with no synchronization** (croc's own goroutines write them unguarded). Degrades to inconsistent progress numbers; bounds-checked (`session.go`'s `progressSnapshot`) so it cannot panic. Accepted: the writes are croc's, so a fix needs upstream locking, and the worst case is a progress number that is briefly wrong. -- **The harnesses fail at random against the public relays when they rate-limit.** Accepted: the limit is relay policy, and CI does not depend on it. A croc v11 relay admits 30 connections a minute from one address (`DEFAULT_SOURCE_JOIN_LIMIT`, `DEFAULT_JOIN_LIMIT_WINDOW`), and a multiplexed transfer opens 9 per peer (1 control, 8 data), so with both peers on one machine two transfers on the same pool relay inside a minute can be refused with `relay admission rate limited`. `verify-interop.sh`, `verify-app-mac.sh` and `verify-app-sim.sh` then fail at whichever scenario hits it; on 2026-10-12 back-to-back `verify-interop.sh` runs failed at scenarios 6, 7 and 10 (then the pool scenario, now 11) this way, and the pre-v11 runs saw the same thing from `croc.schollz.com`. `verify-interop.sh` scenario 11 waits 60 s and turns multiplexing off for that reason. CI avoids this: its `interop (local relay)` job runs `verify-interop.sh` with `RELAY` set to a local `croc relay` with raised admission limits (`docs/BUILDING.md`, section 5). The other harnesses have no local-relay mode yet, so use a local relay for a deterministic result, leave a minute between public-relay runs, and treat a failure as a regression only after finding the cause in its log. -- **A generated-code send with no internet or no reachable pool relay fails before a code is shown.** With default settings, no pool relay answers the 1 s probe (`crocmobile/relay.go` `generatePublicCode`), so the send fails with code `noPublicRelay` (croc's `no public relay available`, the same as the v11 CLI). A "draw a code anyway and let a LAN peer join" fallback was tried and removed: after the relay dial fails, croc waits for a LAN peer for only about 10 s (`defaultAlternateSenderRouteTimeout`, unexported, `croc.go:58`), and a `croctest send -relay 127.0.0.1:1` with no receiver printed its code and failed about 11 s later. Workaround: turn on **Local network only** (`troubleshooting.md` "Relay unreachable"). The app shows "No public relay answered. Check your internet connection, or turn on Local network only in Settings if both devices are on the same network." Accepted: the same behaviour as the v11 CLI, now with its own message. -- **A receiver that joins before the sender fails with `flate: corrupt input before offset 6`.** The receiver's `pake1` local probe times out after 500 ms, it carries on with the main PAKE, and the sender's late reply to the old probe is decoded as a transfer message (`knowledge/crocmobile-bridge.md`, croc gotchas). Generated codes are sender-first, but custom codes are not: a receiver who types an agreed code before the sender taps Send, or an app receiver waiting on a CLI sender using `CROC_SECRET`, hits it. The user sees the generic failure copy. Workaround: start the sender first, then the receiver. No upstream issue found in `schollz/croc` (searched 2026-10-13); a draft is not filed. Accepted until croc fixes it upstream: the probe timeout and the frame decoding are croc's. `verify-app-sim.sh` and `verify-app-mac.sh` start their CLI receiver 3 s after the app's `verify-code-ready.txt` marker for the same reason, and `verify-interop.sh` 3 s after croctest's `EVENT code`. -- **A transfer with a croc 10 peer usually shows no error and waits until the user cancels.** The app reports `peerVersion` ("peer uses unsupported PAKE protocol version 0; upgrade both croc clients") only when both peers reach the same relay room. Cause: croc v10 derives the room from the code's first 4 characters (`src/croc/croc.go:249`, `SharedSecret[:4]`), v11 from the first word of a three-word code (`src/codephrase/codephrase.go:194-222`, `Parse`, both read from source, not run), so an app-generated code lands in a different room than a v10 peer computes unless the first word has exactly 4 letters. The v10 CLI's default relay `croc.schollz.com:9009` is the same server as pool relay `1.getcroc.com` (142.132.189.179) but not pools 2-4, so only codes that map to pool relay 1 meet. Verified 2026-10-13 by runs: over a shared relay with a legacy-format code (for example `7020-alpha-beta-gamma`) the app gets `peerVersion` in both directions, and the v10 CLI prints `could not secure channel` (as sender) or `broken pipe` (as receiver); a v10 send on default settings reached the app via 1.getcroc.com and got `peerVersion`. Accepted, not fixable on our side: a v10 peer cannot be changed and the room and relay derivation are croc's. Workaround: the v10 side upgrades to croc 11. -- **Cancel during a generated-code send's relay probe waits up to 1 s.** The probe in `startSession` runs on `context.Background()` (`publicrelay.ProbeTimeout`), so Cancel takes effect when it ends. Cancelling through `startSession` needs a cancellable context threaded in. Accepted: the wait is bounded by the probe timeout. -- **`HistoryStore.clear()` has not been watched emptying the list without a relaunch.** It uses `delete(model:)` on `container.mainContext` and saves immediately, from a `@MainActor` type. Apple documents that deletion via that method takes effect on the next save, which is satisfied, and the known `@Query` refresh asymmetry is about *inserts* from a background `ModelActor` context, not main-context deletes. Accepted: correct by construction, and no harness drives History. Fallback if it ever misbehaves is a fetch-and-delete loop. Save and fetch failures are logged (`os.Logger`, category `history`). -- **`UIFileSharingEnabled` is declared twice**, in `app/Config/CrocApp-Info.plist` and as a pair of sdk-scoped `INFOPLIST_KEY_*` settings in the pbxproj. Accepted: the plist declaration is the one that works. Removing it drops the key from the built `Info.plist` entirely and takes the app out of the Files app, even though `xcodebuild -showBuildSettings` resolves the build setting to `YES` (measured on a simulator build). The pbxproj pair is inert for this key and harmless; drift risk only, since the values agree. -- **The macOS local-network denial path is hard to exercise during development.** Accepted: a development-procedure note, not an app defect. Enforcement reportedly only engages for builds resident in `/Applications` (single developer-forum thread FB16077972, not Apple documentation — strong inference, not verified here), so a DerivedData-launched Debug build can sit at `.ready` and read as falsely granted. Copy the app to `/Applications` before trying to reproduce a denial. -- **`AppRouter` is a singleton with no reset hook, so multiple macOS windows share one navigation path.** Accepted: per-window navigation needs `@FocusedValue` plumbing to reach `AppDelegate` and `Commands`, which sit outside the environment graph, and multi-window reachability has never actually been observed. Revisit if someone reports it. -- **croc prints the code phrase, and a non-default relay password, to stderr on every send.** croc builds the "On the other computer run" line (`croc `, plus `--pass ` when the password is not the default) with `Quiet=false`, which crocmobile needs (`knowledge/crocmobile-bridge.md`), and its debug logger prints file names and paths. Nothing in the app captures stderr: a build launched from Finder, the Dock or the Home Screen has it connected to `/dev/null` (strong inference, not measured on a device), so it reaches no log store or crash report. A harness run or an Xcode debug session does capture it (`verify-app-mac.sh` writes it to its temp logs). Accepted for 1.0; revisit if croc gains a way to silence that line without `Quiet`. -- **Deleting a stored upload's History row removes the only Revoke button.** The receipt stays in the Keychain until the link expires, but nothing lists receipts without a record. Deferred: revoke before deleting the row. Revisit if users lose links this way. -- **The Camera app cannot open CrocApp for a stored link.** The stored link points at the transfer server, and Associated Domains covers only `crocapp.dev/r/*`, so a camera scan opens the server's page in the browser. In-app Scan QR Code and Paste work. Accepted for 1.0. -- **The menu-bar window cannot take a drop on the icon itself.** `MenuBarQuickSendView`'s drop zone exists only while the window is open, so a user has to click the icon first and then drop. A `MenuBarExtra` has no drop target on its status item. Accepted: the window opens with one click and the "Choose Files…" button needs no drag. -- **A Shortcuts send action returns at the code, not at the end of the transfer.** `SendFilesIntent` and `SendTextIntent` finish when `awaitCode()` sees `.waiting`, so a shortcut that goes on to quit CrocApp, or a user who closes it, ends the transfer before the receiver has the files. The dialog says "Keep CrocApp open until the transfer ends." Accepted: the code has to reach the receiver while the sender is still waiting, so the action cannot wait for the end. Receive does wait for the end. Neither the actions nor the Finder Quick Action have been run on a device (`knowledge/device-test-checklist.md`, "Shortcuts actions (R27)"). +- **Gesture-level flows are not machine-verified.** + - Drag-and-drop, camera QR and taps have no XCUITest coverage, by decision. + - Unit tests cover non-UI logic (Go `go test -race`, Swift Testing in `CrocKitTests` and `CrocAppTests`, all in CI). The harnesses cover transfers. UI flows are verified by hand. + - See `CONTRIBUTING.md`. +- **The trust badge names the configured route, not the path that carried the data.** + - `TrustBadge` reads the relay setting. A transfer that croc moved over the LAN, or over Tailcat under **Connection** = Automatic, still shows "Via the public croc relay" (or the custom relay). + - croc does not report the winning path to the engine. + - The badge's claim that the server sees only encrypted data holds on every path. +- **`HistoryStore.clear()` has not been watched emptying the list without a relaunch.** + - It uses `delete(model:)` on `container.mainContext` and saves immediately, from a `@MainActor` type. + - Apple documents that deletion through that method takes effect on the next save, which is satisfied. The known `@Query` refresh asymmetry concerns inserts from a background `ModelActor` context, not main-context deletes. + - No harness drives History. If it ever misbehaves, fall back to a fetch-and-delete loop. Save and fetch failures are logged (`os.Logger`, category `history`). +- **`UIFileSharingEnabled` is declared twice**, in `app/Config/CrocApp-Info.plist` and as a pair of sdk-scoped `INFOPLIST_KEY_*` settings in the pbxproj. + - The plist declaration is the one that works. Removing it drops the key from the built `Info.plist` and takes the app out of the Files app, even though `xcodebuild -showBuildSettings` resolves the build setting to `YES` (measured on a simulator build). + - The pbxproj pair is inert for this key and harmless. The only risk is drift, and the values agree. +- **`AppRouter` is a singleton with no reset hook, so multiple macOS windows share one navigation path.** + - Per-window navigation needs `@FocusedValue` plumbing to reach `AppDelegate` and `Commands`, which sit outside the environment graph. + - Multi-window reachability has never been observed. Revisit if someone reports it. +- **Deleting a stored upload's History row removes the only Revoke button.** The receipt stays in the Keychain until the link expires, but nothing lists receipts without a record. Revoke before deleting the row. Revisit if users lose links this way. +- **The Camera app cannot open CrocApp for a stored link.** + - The stored link points at the transfer server, and Associated Domains covers only `crocapp.dev/r/*`, so a camera scan opens the server's page in the browser. + - In-app **Scan QR Code** and Paste work. +- **The menu-bar window cannot take a drop on the icon itself.** + - `MenuBarQuickSendView`'s drop zone exists only while the window is open, so the user clicks the icon first and then drops. + - A `MenuBarExtra` has no drop target on its status item. The **Choose Files...** button needs no drag. +- **A Shortcuts send action returns at the code, not at the end of the transfer.** + - `SendFilesIntent` and `SendTextIntent` finish when `awaitCode()` sees `.waiting`. A shortcut that goes on to quit CrocApp, or a user who closes it, ends the transfer before the receiver has the files. The dialog says "Keep CrocApp open until the transfer ends." + - The code has to reach the receiver while the sender is still waiting, so the action cannot wait for the end. Receive does wait for the end. + - Neither the actions nor the Finder Quick Action have been run on a device (`knowledge/device-test-checklist.md`, "Shortcuts actions"). +- **The macOS local-network denial path is hard to exercise during development.** + - This is a development-procedure note, not an app defect. + - Enforcement reportedly engages only for builds resident in `/Applications` (one developer-forum thread, not Apple documentation; strong inference, not verified here). A Debug build launched from DerivedData can sit at `.ready` and read as falsely granted. + - Copy the app to `/Applications` before trying to reproduce a denial. + +### Engine and croc behaviour + +- **When both relay addresses are customised, both are sent.** + - croc's own CLI blanks `RelayAddress6` whenever the IPv4 address is customised, without checking whether the IPv6 one was customised too (`src/cli/cli.go`, send and receive alike), so it silently discards a custom IPv6 relay. + - Keeping both is more correct. The divergence is deliberate and will not be "fixed" toward the CLI's behaviour. +- **A zipped-folder receive that collides with more than 4096 existing members still fails.** + - crocmobile answers croc's per-member `Overwrite ''? (y/N)` prompts from a batch of 4096 lines written up front (`promptPolicyAnswers`). The batch is sized to fit a fresh pipe buffer on Darwin and Linux, so the write never blocks. + - Past that, croc's unzip reads EOF and fails with `received archive failed validation or extraction`, leaving the received `.zip`. + - A larger batch could block the write (`knowledge/crocmobile-bridge.md`). +- **The incoming sheet cannot preview which members of a zipped folder already exist.** + - The file list shows one `.zip` name, so the conflict scan sees no members and offers only Accept (Keep Both). + - Existing members are kept and matching ones in the archive are skipped under every policy, Replace and auto-accept included. The stdin answers croc's unzip prompt reads are always "n", because a queued "y" would also answer the accept prompt croc re-runs after a reconnect or forces when the sender used `--ask` (`knowledge/crocmobile-bridge.md`). + - The archive's contents are known to croc only after it unpacks, and keeping what is there loses nothing. +- **A file whose name ends in a space cannot be sent to a croc v11 peer.** + - CrocApp sends the exact name (crocmobile `splitPaths`). croc 11.5.4 receivers, CLI and crocmobile alike, refuse it: `unsafe receive path in "report.pdf ": Windows-trimmed trailing character`. The sender sees `cancelled`. + - The refusal is croc's receive-path policy. Rename the file to send it. +- **A croc v11 reconnect after a manually accepted receive ends as `refused`.** + - croc re-runs the accept prompt on the sender's file list after each reconnect (`resetForReconnectAttempt`, then `processMessageFileInfo`). The next queued stdin answer is "n", so the receive fails instead of resuming. + - Answering "y" there would accept a list the user never saw, and crocmobile cannot tell that prompt from the unzip prompt on the same pipe. + - Read from croc v11.5.4 source; no harness forces a reconnect. +- **`xfer()` has no watchdog.** + - If croc ever blocks past context cancel and prompt-pipe close, `activeMu` is held for the process's lifetime and every later transfer returns "another transfer is active". + - Forcing the lock open while croc is still running would let two sessions mutate the same process-global state (`os.Chdir`, stdin/stdout swaps). +- **`poll()` reads `croc.Client` scalar and slice fields with no synchronization** (croc's own goroutines write them unguarded). + - The worst case is a briefly wrong progress number. It is bounds-checked (`progressSnapshot` in `session.go`), so it cannot panic. + - A real fix needs upstream locking. +- **A generated-code send with no internet or no reachable pool relay fails before a code is shown.** + - With default settings, no pool relay answers the 1 s probe (`generatePublicCode` in `crocmobile/relay.go`), so the send fails with code `noPublicRelay` (croc's `no public relay available`, the same as the v11 CLI). + - A "draw a code anyway and let a LAN peer join" fallback does not work: after the relay dial fails, croc waits for a LAN peer for only 10 s (`defaultAlternateSenderRouteTimeout`, unexported), and a `croctest send -relay 127.0.0.1:1` with no receiver printed its code and failed about 11 s later. + - Workaround: turn on **Local Network Only** (`web/docs/content/troubleshooting.md`, "Relay unreachable"). The app shows "No public relay answered. Check your internet connection, or turn on Local Network Only in Settings if both devices are on the same network." +- **A receiver that joins before the sender fails with `flate: corrupt input before offset 6`.** + - The receiver's `pake1` local probe times out after 500 ms and it carries on with the main PAKE. The sender's late reply to the old probe is then decoded as a transfer message (`knowledge/crocmobile-bridge.md`, "croc v11.5.4 gotchas"). + - Generated codes are sender-first, but custom codes are not. A receiver who types an agreed code before the sender taps Send, or an app receiver waiting on a CLI sender using `CROC_SECRET`, hits it. The user sees the generic failure copy. + - Workaround: start the sender first, then the receiver. `verify-app-sim.sh`, `verify-app-mac.sh` and `verify-interop.sh` all delay their CLI receiver for this reason. + - Not verified whether `schollz/croc` has an upstream issue for it. The probe timeout and the frame decoding are croc's. +- **A transfer with a croc 10 peer usually shows no error and waits until the user cancels.** + - The app reports `peerVersion` ("peer uses unsupported PAKE protocol version 0; upgrade both croc clients") only when both peers reach the same relay room. + - croc v10 derives the room from the code's first 4 characters (`src/croc/croc.go`, `SharedSecret[:4]`). croc v11 derives it from the first word of a three-word code (`src/codephrase/codephrase.go`, `Parse`). So an app-generated code lands in a different room than a v10 peer computes, unless the first word has exactly 4 letters. + - The v10 CLI's default relay `croc.schollz.com:9009` is the same server as pool relay `1.getcroc.com` but not pools 2 to 4, so only codes that map to pool relay 1 meet. + - Checked by runs: over a shared relay with a legacy-format code (for example `7020-alpha-beta-gamma`) the app gets `peerVersion` in both directions. The v10 CLI prints `could not secure channel` (sender) or `broken pipe` (receiver). + - A v10 peer cannot be changed, and the room and relay derivation are croc's. Workaround: the v10 side upgrades to croc 11. +- **Cancel during a generated-code send's relay probe waits up to 1 s.** + - The probe in `startSession` runs on `context.Background()` (`publicrelay.ProbeTimeout`), so Cancel takes effect when the probe ends. + - Cancelling through `startSession` would need a cancellable context threaded in. The wait is bounded by the probe timeout. +- **croc prints the code phrase, and a non-default relay password, to stderr on every send.** + - croc builds the "On the other computer run" line (`croc `, plus `--pass ` when the password is not the default) with `Quiet=false`. crocmobile needs that (`knowledge/crocmobile-bridge.md`). Its debug logger also prints file names and paths. + - Nothing in the app captures stderr. A build launched from Finder, the Dock or the Home Screen has it connected to `/dev/null` (strong inference, not measured on a device), so it reaches no log store or crash report. + - A harness run or an Xcode debug session does capture it (`verify-app-mac.sh` writes it to its temp logs). + - Revisit if croc gains a way to silence that line without `Quiet`. diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index a5de3b1..c823373 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -11,6 +11,6 @@ Build, release, verification and screenshot scripts. Each script's header is its - A harness undoes in its EXIT trap everything it writes outside its temp dir (the app's Documents, `~/Downloads/CrocApp`, the pasteboard), so a failed run leaves nothing behind. Every croc CLI send takes `--disable-clipboard`: croc otherwise copies its code over the user's pasteboard. - A transfer passes on the app's `verify-result.txt` (or croctest's `EVENT done`) and a byte comparison, never on an exit code alone. Every failure prints a `FAIL:` line naming the scenario. - The sender goes first: a receiver joins only after the sender's code marker (`verify-code-ready.txt`, croctest's `EVENT code`) plus 3 s. A receiver that arrives first fails with `flate: corrupt input`. -- Custom codes differ in their first 4 characters within a run: the relay hashes that prefix into the room name. +- Custom codes (the legacy `NNNN-word` shape) differ in their first 4 characters within a run: the relay derives the room from that prefix (`docs/knowledge/what-is-croc.md`, "Rendezvous"). - `shellcheck -x scripts/*.sh` passes on default checks (CI `shellcheck` job). Disable a rule only on the line that needs it, with the reason beside it. - A script or file CI reads goes into the `changes` regex in `.github/workflows/ci.yml` (`.github/AGENTS.md`). diff --git a/scripts/assemble-site.sh b/scripts/assemble-site.sh index 320fa71..4a8adbe 100755 --- a/scripts/assemble-site.sh +++ b/scripts/assemble-site.sh @@ -30,12 +30,12 @@ test -s "$OUT/404.html" || { echo "error: no landing 404.html in $OUT" >& # A dot-path, so it only survives to production while github-pages.yml keeps # include-hidden-files: true. SECURITY.md hands this address out. test -s "$OUT/.well-known/security.txt" || { echo "error: no security.txt in $OUT" >&2; exit 1; } -# Same dot-path rule. Universal links (R23) need both app IDs served from here. +# Same dot-path rule. Universal links need both app IDs served from here. test -s "$OUT/.well-known/apple-app-site-association" || { echo "error: no apple-app-site-association in $OUT" >&2; exit 1; } for id in HBSU359M33.com.bakirgdev.CrocApp HBSU359M33.com.bakirgdev.CrocApp.direct; do grep -q "\"$id\"" "$OUT/.well-known/apple-app-site-association" || { echo "error: AASA lacks $id" >&2; exit 1; } done -# R23: the code stays in the fragment, so the page must not leak it by referrer +# The code stays in the fragment, so the page must not leak it by referrer # or load anything remote. test -s "$OUT/r/index.html" || { echo "error: no /r/ page in $OUT" >&2; exit 1; } grep -q '&2; exit 1; } diff --git a/scripts/verify-app-mac.sh b/scripts/verify-app-mac.sh index eaf76f5..65cf7c0 100755 --- a/scripts/verify-app-mac.sh +++ b/scripts/verify-app-mac.sh @@ -193,7 +193,7 @@ check_result "ask send" diff -r "$DOCS/askdir" "$DST/askdir" > /dev/null || fail "ask send: received folder differs" echo MAC-ASK-OK -# --- 7: CLI -> app onto an existing file, Keep Both and Replace (R24) -------- +# --- 7: CLI -> app onto an existing file, Keep Both and Replace -------- # --when-exists is the answer AutoVerify gives the incoming sheet; the setting # stays Keep Both, so a Replace proves the late answer reaches croc. Keep Both # leaves the existing file and adds "collide (1).txt"; Replace overwrites it. @@ -221,7 +221,7 @@ collide keepBoth collide replace echo MAC-COLLIDE-OK -# --- 8: 130 MiB in each direction, Connection Automatic and Relay only (R17) -- +# --- 8: 130 MiB in each direction, Connection Automatic and Relay only -- # croc offers Tailcat only for sends of 128 MiB or more, so the file is just # over that. The CLI's --debug log shows the offer ("tailcat-offer") on the # receiving side of an app send, and the sender's size decision @@ -265,7 +265,7 @@ transport_app_receive relay rm -f "$TMP/bigrecv.bin" echo MAC-TRANSPORT-OK -# --- 9: stored transfers through the app (R18-R20) --------------------------- +# --- 9: stored transfers through the app --------------------------- # A local croc store server (croctest store-server), so no relay and no # internet. One app instance uploads and keeps its receipt in memory, so the # revoke has to come from that same process: the script signals it with @@ -331,7 +331,7 @@ esac [ ! -e "$DOCS/storeme.bin" ] || fail "revoked upload wrote a file (app)" echo MAC-STORE-OK -# --- 10: menu-bar quick send, files and clipboard text (R26) ----------------- +# --- 10: menu-bar quick send, files and clipboard text ----------------- # --auto-menubar-send and --auto-menubar-clipboard start the send through # MenuBarQuickSend, the view model behind the menu-bar window. The code is # generated, so the app writes it to verify-code.txt for the CLI receiver. @@ -359,7 +359,7 @@ check_result menubar-clipboard grep -qF "$CLIP" "$TMPROOT/mac-cli-menubar-clip.log" || fail "menu-bar clipboard text did not reach the CLI" echo MAC-MENUBAR-OK -# --- 11: Open at login matches the system status (R26) ----------------------- +# --- 11: Open at login matches the system status ----------------------- # The check registers the app's login item and unregisters it again, so a # stray item survives only if the run is killed between the two (a login item # already present before the run is left unregistered). The status @@ -379,7 +379,7 @@ case "$got" in esac echo MAC-LOGIN-OK -# --- 12: the Shortcuts actions against the CLI (R27) ------------------------- +# --- 12: the Shortcuts actions against the CLI ------------------------- # Shortcuts cannot be driven from a script, so --auto-intent calls each # action's perform() directly (the value rides behind --intent-arg: two bare # arguments in a row make AppKit skip the window). The send actions return diff --git a/scripts/verify-docs.sh b/scripts/verify-docs.sh new file mode 100755 index 0000000..be25171 --- /dev/null +++ b/scripts/verify-docs.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 # awk programs in single quotes: awk expands them, not the shell +# Keeps the repository's markdown pointing at things that exist (docs/AGENTS.md, +# "Keeping docs true"). Checks every .md file outside web/docs/content/, +# which the Docusaurus build checks itself, plus new ones not yet committed: +# +# 1. Relative links. `[text](path)` and `[text](path#anchor)` resolve to a +# tracked file or directory, and an anchor matches a heading in its target. +# 2. Repo paths. An inline-code span that names a repo path (`scripts/x.sh`, +# `docs/knowledge/`, a sibling `tooling.md`) exists. A gitignored build +# output (`CrocKit/Croc.xcframework/`) counts as existing. +# 3. Stale markers, in docs/ and the AGENTS.md files: dates and the +# planning IDs (R12, F31, O9, B23, W4), which go stale (docs/AGENTS.md, +# "Writing rules"), and section-number citations, which rot on every +# re-order. +# +# scripts/verify-docs.sh # findings as path:line: message, exit 1 on any +# +# Fenced code blocks are skipped. Known limits: reference-style links and link +# titles are not checked, and anchors follow GitHub's slug rule for ASCII +# headings only, without the `-1` suffix a repeated heading gets. Pure bash and POSIX awk, so CI runs it on a +# bare runner. Exit 0 clean, 1 findings. +set -euo pipefail +cd "$(dirname "$0")/.." +export LC_ALL=C + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +# Tracked and new docs still on disk (a deletion not yet staged is not a doc). +git ls-files --cached --others --exclude-standard '*.md' ':!:web/docs/content/**' | sort -u | while IFS= read -r f; do + [ -f "$f" ] && echo "$f" +done > "$TMP/files" +# Every tracked file plus every directory above one: the set a path may name. +# Untracked files count too, so a new doc can be linked before its first commit. +git ls-files --cached --others --exclude-standard | + awk '{ print; while (sub("/[^/]*$", "")) print $0 "/" }' | sort -u > "$TMP/tracked" + +# One line per candidate: filelinekindtarget. Kinds: link, code. +while IFS= read -r f; do + awk -v file="$f" ' + /^[ \t]*(```|~~~)/ { + m = $0; sub(/^[ \t]*/, "", m); m = substr(m, 1, 3) + if (fence == "") fence = m; else if (m == fence) fence = "" + next + } + fence != "" { next } + { + line = $0 + # inline code spans + s = line + while (match(s, /`[^`]+`/)) { + print file "\t" FNR "\tcode\t" substr(s, RSTART + 1, RLENGTH - 2) + s = substr(s, RSTART + RLENGTH) + } + # markdown links, outside code spans + gsub(/`[^`]*`/, "", line) + while (match(line, /\]\([^) ]+\)/)) { + print file "\t" FNR "\tlink\t" substr(line, RSTART + 2, RLENGTH - 3) + line = substr(line, RSTART + RLENGTH) + } + }' "$f" +done < "$TMP/files" > "$TMP/candidates" + +# Resolves every candidate in one awk pass over the tracked set. Prints +# filelinemessage for a failure, or fileline?path for a +# path to retry against .gitignore (build outputs exist only after a build). +awk -F'\t' -v top='^(\\.github|app|assets|CrocKit|crocmobile|design|docs|scripts|web)/' ' + function normalize(p, n, part, i, k, out, s) { + n = split(p, part, "/"); k = 0 + for (i = 1; i <= n; i++) { + if (part[i] == "" || part[i] == ".") continue + if (part[i] == "..") { if (k > 0) k--; continue } + out[++k] = part[i] + } + s = ""; for (i = 1; i <= k; i++) s = s (i > 1 ? "/" : "") out[i] + return s + } + function have(p) { sub(/\/$/, "", p); return (p in known) } + function check(p) { if (!have(p)) print file "\t" n "\t?" p } + FILENAME == ARGV[1] { + sub(/\/$/, "") + known[$0] = 1 + b = $0; sub(/.*\//, "", b); base[b] = 1 + next + } + { + file = $1; n = $2; kind = $3; t = $4 + dir = file; if (!sub(/\/[^\/]*$/, "", dir)) dir = "." + if (kind == "link") { + if (t ~ /^(https?|mailto):/) next + path = t; anchor = "" + if (index(t, "#")) { path = substr(t, 1, index(t, "#") - 1); anchor = substr(t, index(t, "#") + 1) } + target = path == "" ? file : normalize(dir "/" path) + if (path != "" && !have(target)) { print file "\t" n "\tbroken link: " t; next } + if (anchor != "" && target ~ /\.md$/) print file "\t" n "\t#" target "#" anchor "#" t + next + } + # Only spans that are a bare path: no spaces, globs or placeholders. + if (t !~ /^[A-Za-z0-9._\/:-]+$/ || t ~ /\/\/|^-|\/\./) next + path = t; sub(/:.*/, "", path) + if (path ~ top) check(path) + else if (path ~ /^\.\.?\//) { if (!have(normalize(dir "/" path))) check(normalize(path)) } + else if (path !~ /\// && path ~ /.\.md$/) { if (!(path in base)) print file "\t" n "\tno such file: `" t "`" } + }' "$TMP/tracked" "$TMP/candidates" > "$TMP/resolved" + +findings=0 +finding() { echo "$1:$2: $3"; findings=$((findings + 1)); } + +# GitHub's heading anchors for a markdown file, one per line. +anchors() { + awk ' + /^[ \t]*(```|~~~)/ { + m = $0; sub(/^[ \t]*/, "", m); m = substr(m, 1, 3) + if (fence == "") fence = m; else if (m == fence) fence = "" + next + } + fence != "" || !/^#+ / { next } + { + h = $0 + sub(/^#+ +/, "", h) + h = tolower(h) + gsub(/[^a-z0-9 _-]/, "", h) + gsub(/ /, "-", h) + print h + }' "$1" +} + +while IFS=$'\t' read -r f n msg; do + case $msg in + \?*) + # A missing path cannot match a directory-only pattern (`dir/`) unless it + # ends in a slash, so try both: a fresh clone has no build outputs. + p=${msg#\?} + p=${p%/} + git check-ignore -q -- "$p" 2>/dev/null || git check-ignore -q -- "$p/" 2>/dev/null || + finding "$f" "$n" "no such path: \`$p\`" + ;; + \#*) + rest=${msg#\#} + target=${rest%%#*} + rest=${rest#*#} + anchor=${rest%%#*} + anchors "$target" > "$TMP/anchors" + grep -qxF -- "$anchor" "$TMP/anchors" || finding "$f" "$n" "no heading for anchor: ${rest#*#}" + ;; + *) finding "$f" "$n" "$msg" ;; + esac +done < "$TMP/resolved" + +# Stale markers: only where docs/AGENTS.md applies. CHANGELOG.md keeps its +# dated release headings, which release.yml parses. +{ grep -E '^docs/|(^|/)AGENTS\.md$' "$TMP/files" || true; } > "$TMP/strict" +while IFS= read -r f; do + awk -v file="$f" ' + /^[ \t]*(```|~~~)/ { + m = $0; sub(/^[ \t]*/, "", m); m = substr(m, 1, 3) + if (fence == "") fence = m; else if (m == fence) fence = "" + next + } + fence != "" { next } + { + s = " " $0 " " + if (match(s, /[^0-9]20[0-9][0-9]-[01][0-9]-[0-3][0-9][^0-9]/)) + print file ":" FNR ": date: " substr(s, RSTART + 1, 10) + if (match(s, /[^A-Za-z0-9_][BFORW][0-9][0-9]?[a-z]?[^A-Za-z0-9_]/)) + print file ":" FNR ": planning ID: " substr(s, RSTART + 1, RLENGTH - 2) + if (match(tolower(s), /(section|§) ?[0-9]/)) + print file ":" FNR ": section-number citation, cite the heading text" + }' "$f" +done < "$TMP/strict" > "$TMP/markers" +while IFS= read -r m; do + echo "$m" + findings=$((findings + 1)) +done < "$TMP/markers" + +if [ "$findings" -gt 0 ]; then + echo "FAIL: $findings finding(s). Fix the doc, or the path it names; never the check." + exit 1 +fi +echo "PASS: links, repo paths and stale markers in $(wc -l < "$TMP/files" | tr -d ' ') markdown files" diff --git a/scripts/verify-interop.sh b/scripts/verify-interop.sh index f059c4d..f1a989d 100755 --- a/scripts/verify-interop.sh +++ b/scripts/verify-interop.sh @@ -2,9 +2,9 @@ # Interop verification: crocmobile (via croctest) <-> croc CLI v11. # Covers: file/folder/text both directions, decline, cancel both directions, # forced relay (LAN disabled), LAN-only, a croc v10 peer both directions, a -# receive onto existing files with Keep Both and Replace (R24), a 130 MiB send -# in each transport mode (R17), stored transfers against a local store server -# (R18-R20), the Advanced options that change the wire (R25: curve, hash, ports, +# receive onto existing files with Keep Both and Replace, a 130 MiB send +# in each transport mode, stored transfers against a local store server, +# the Advanced options that change the wire (curve, hash, ports, # multicast group, proxies, internal DNS), and every public pool relay (custom, # wrapper-generated and CLI-generated codes). # Exits non-zero on first failure. @@ -214,7 +214,7 @@ mkdir -p "$TMP/dst9" diff "$TMP/src/a.txt" "$TMP/dst9/a.txt" > /dev/null || fail "LAN only" wait; pass "LAN only" -# 10. croc v10 peer, both directions (R8): crocmobile reports peerVersion. +# 10. croc v10 peer, both directions: crocmobile reports peerVersion. # Both sides must reach the same relay room. A v10 CLI defaults to # croc.schollz.com, which is only one of the pool relays, so outside RELAY mode # it is pointed at the pool relay the code maps to. LAN is off on both sides: @@ -241,7 +241,7 @@ grep -q 'EVENT error {"code":"peerVersion"' "$TMP/10r.log" || fail "v10 receiver [ ! -e "$TMP/dst10/a.txt" ] || fail "v10 receiver: file arrived" pass "v10 receiver: peerVersion" -# 12. receive onto existing files (R24): Keep Both and Replace, answered at the +# 12. receive onto existing files: Keep Both and Replace, answered at the # prompt and preset with -yes. Keep Both leaves the old file and adds # "a (1).txt"; Replace leaves one file with the new content. A zipped folder # received twice into one folder hits croc's per-member unzip prompt, which @@ -307,7 +307,7 @@ grep -q 'EVENT error {"code":"refused"' "$TMP/12ask.log" || fail "sender --ask u [ ! -e "$TMP/dst12-ask/a.txt" ] || fail "sender --ask under -yes with replace: file arrived" pass "sender --ask under -yes with replace: refused" -# 14. stored transfers (R18-R20) against a local croc store server, no relay and +# 14. stored transfers against a local croc store server, no relay and # no internet: crocmobile uploads and the CLI downloads, the CLI uploads and # crocmobile downloads, and a revoked transfer no longer downloads. Every upload # lasts an hour, allows the downloads the case needs, and is revoked at the end. @@ -387,7 +387,7 @@ pass "store: existing file with Keep Both and Replace, decline keeps the transfe kill "$STORE_PID" wait "$STORE_PID" 2>/dev/null || true -# 15. Advanced options that change the wire (R25), against the croc v11 CLI with +# 15. Advanced options that change the wire, against the croc v11 CLI with # the matching flag. What each case pins down was observed with croc v11.5.4: # - curve: the receiver chooses it and the sender adopts it ("using curve" in # the CLI sender's --debug log); the sender's own curve is never used, so a @@ -556,7 +556,7 @@ if [ -n "${RELAY:-}" ]; then exit 0 fi -# 13. transport (R17): a send over croc's 128 MiB Tailcat threshold in each +# 13. transport: a send over croc's 128 MiB Tailcat threshold in each # mode, both directions. Under auto the sender offers Tailcat and races it # against the relay; either may carry the data. Under relay no Tailcat offer is # made. The --debug CLI logs show the offer ("tailcat-offer") on either side. @@ -597,7 +597,7 @@ transport_receive relay pass "transport relay: wrapper receives 130 MiB, no Tailcat offer" rm -f "$TMP/huge.bin" "$TMP"/13[sr]-*/huge.bin -# 15 proxies and internal DNS (R25). croc's dialer bypasses proxies for local +# 15 proxies and internal DNS. croc's dialer bypasses proxies for local # addresses, so these need the public pool: the transfer goes through a croctest # proxy that counts the connections it tunnels. -no-local keeps the data off the # LAN. Skipped under RELAY, whose address is local. diff --git a/web/docs/content/contribute.md b/web/docs/content/contribute.md index 69e41a1..8ee37ce 100644 --- a/web/docs/content/contribute.md +++ b/web/docs/content/contribute.md @@ -11,6 +11,7 @@ Contributor and internals documentation lives in the repository, not on this sit - [`CONTRIBUTING.md`](https://github.com/bakirgdev/CrocApp/blob/main/CONTRIBUTING.md): setup, branches, commits, style, verification, PR flow - [`CODE_OF_CONDUCT.md`](https://github.com/bakirgdev/CrocApp/blob/main/CODE_OF_CONDUCT.md): Contributor Covenant 3.0 - [`SECURITY.md`](https://github.com/bakirgdev/CrocApp/blob/main/SECURITY.md): scope and private reporting for vulnerabilities +- [`docs/README.md`](https://github.com/bakirgdev/CrocApp/blob/main/docs/README.md): index of every contributor and internals doc - [`docs/ARCHITECTURE.md`](https://github.com/bakirgdev/CrocApp/blob/main/docs/ARCHITECTURE.md): system map, layers, event flow - [`docs/BUILDING.md`](https://github.com/bakirgdev/CrocApp/blob/main/docs/BUILDING.md): exact toolchain, fresh-clone steps, verification harnesses - [`docs/GLOSSARY.md`](https://github.com/bakirgdev/CrocApp/blob/main/docs/GLOSSARY.md): full contributor and internals vocabulary diff --git a/web/docs/content/faq.md b/web/docs/content/faq.md index 5e5cbd8..c6c5b33 100644 --- a/web/docs/content/faq.md +++ b/web/docs/content/faq.md @@ -8,7 +8,7 @@ description: Frequently asked questions about CrocApp, cost, security, accounts, ## Where do I get it, and how does it update? -CrocApp 1.0 comes through the App Store, the Mac App Store and as a notarized disk image for Mac, all arriving at launch. The listings are linked from [crocapp.dev](https://crocapp.dev) once they are live. App Store builds update through the App Store. The disk image updates itself: choose **Check for Updates…** in the CrocApp menu. See [Install](getting-started/install.md). +CrocApp is available as a notarized disk image for Mac, and is coming to the App Store and the Mac App Store. The store listings will be linked from [crocapp.dev](https://crocapp.dev) once they are live. App Store builds update through the App Store. The disk image updates itself: choose **Check for Updates…** in the CrocApp menu. See [Install](getting-started/install.md). ## What does it cost? diff --git a/web/docs/content/getting-started/install.md b/web/docs/content/getting-started/install.md index b6c9b04..fb1ccb4 100644 --- a/web/docs/content/getting-started/install.md +++ b/web/docs/content/getting-started/install.md @@ -1,12 +1,12 @@ --- sidebar_position: 1 title: Install -description: "The three ways to get CrocApp 1.0: the App Store, the Mac App Store and a notarized disk image for Mac, plus how updates work and how to build from source." +description: "How to get CrocApp: a notarized disk image for Mac today, the App Store and the Mac App Store soon, plus how updates work and how to build from source." --- # Install -CrocApp 1.0 comes through three channels. Pick the one that matches your device. All of them need **iOS 26**, **iPadOS 26** or **macOS 26** (`26.0` or any later version). +CrocApp has three channels. The disk image is available now; the two App Store channels are coming. Pick the one that matches your device. All of them need **iOS 26**, **iPadOS 26** or **macOS 26** (`26.0` or any later version). | Channel | For | Where | |---|---|---| diff --git a/web/docs/content/index.md b/web/docs/content/index.md index 9c2db35..4bbe721 100644 --- a/web/docs/content/index.md +++ b/web/docs/content/index.md @@ -29,7 +29,7 @@ CrocApp requires **iOS 26**, **iPadOS 26**, or **macOS 26**, meaning `26.0` or a The other device needs **croc 11 or newer**, whether it runs CrocApp or the croc CLI. croc 10 cannot complete a transfer with CrocApp. ::: -CrocApp 1.0 comes as an app for iPhone and iPad, an app on the Mac App Store and a notarized disk image for Mac. The store listings and the 1.0 disk image arrive at launch. See [Install](getting-started/install.md) for where to get each one. +CrocApp is available as a notarized disk image for Mac. Apps for iPhone and iPad and a Mac App Store version are coming to the App Store. See [Install](getting-started/install.md) for where to get each one. ## Where to go next diff --git a/web/landing/index.html b/web/landing/index.html index ce4bc49..2825965 100644 --- a/web/landing/index.html +++ b/web/landing/index.html @@ -398,7 +398,7 @@

Download

iPhone & iPad

-

Available on the App Store at launch.

+

Coming to the App Store.

iPhone & iPad

Mac

-

Available on the Mac App Store at launch.

+

Coming to the Mac App Store.