diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9110a6b..1116d92 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -33,5 +33,5 @@ Before and after screenshots, light and dark, on every affected platform. - [ ] Tokens, SF Symbols and the system font only - [ ] Both themes checked -- [ ] `scripts/verify-contrast.sh` and `scripts/verify-design.sh` pass +- [ ] `scripts/verify-design.sh` passes - [ ] Screenshots recaptured if the UI changed (`scripts/capture-screenshots.sh`) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9081c5b..cbf0bee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: build: ${{ steps.filter.outputs.build }} design: ${{ steps.filter.outputs.design }} interop: ${{ steps.filter.outputs.interop }} + scripts: ${{ steps.filter.outputs.scripts }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -61,11 +62,12 @@ jobs: } { - out swift '^(app|CrocKit)/|^\.swift-format$|^\.xcode-version$|^scripts/format-swift\.sh$|^\.github/workflows/(ci|reusable-swift-format)\.yml$' + out swift '^(app|CrocKit)/|^scripts/screenshots/|^\.swift-format$|^\.xcode-version$|^scripts/format-swift\.sh$|^\.github/workflows/(ci|reusable-swift-format)\.yml$' out go '^crocmobile/|^\.github/workflows/(ci|reusable-go)\.yml$' out build '^(app|CrocKit|crocmobile)/|^scripts/build-xcframework\.sh$|^\.xcode-version$|^\.github/workflows/ci\.yml$' - out design '^design/|^app/.*\.swift$|^app/CrocApp/Assets\.xcassets/|^web/landing/.*\.(css|html)$|^web/docs/src/css/|^scripts/(lib|verify-contrast|verify-design)\.sh$|^\.github/workflows/ci\.yml$' + out design '^design/|^app/.*\.swift$|^app/CrocApp/Assets\.xcassets/|^web/landing/.*\.(css|html)$|^web/docs/src/css/|^scripts/(lib|verify-design)\.sh$|^\.github/workflows/ci\.yml$' out interop '^(crocmobile|CrocKit)/|^scripts/(lib|verify-interop)\.sh$|^scripts/croc-v10\.version$|^\.github/workflows/ci\.yml$' + out scripts '^scripts/.*\.sh$|^\.github/workflows/ci\.yml$' } | tee -a "$GITHUB_OUTPUT" swift-format: @@ -98,10 +100,25 @@ jobs: with: persist-credentials: false - - run: scripts/verify-contrast.sh - - run: scripts/verify-design.sh + # The runner image ships shellcheck, so there is nothing to install or pin. + shellcheck: + needs: changes + + if: needs.changes.outputs.scripts == 'true' + + runs-on: ubuntu-26.04 + + timeout-minutes: 5 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - run: shellcheck --version && shellcheck -x scripts/*.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). @@ -264,7 +281,7 @@ jobs: pass: name: ci pass - needs: [changes, swift-format, go, contrast, interop, build] + needs: [changes, swift-format, go, contrast, shellcheck, interop, build] if: ${{ always() }} diff --git a/AGENTS.md b/AGENTS.md index d996067..ac8e90c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Native SwiftUI GUI for the croc file-transfer CLI. ## 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-contrast.sh` and `scripts/verify-design.sh` after touching any of it; CI runs both. +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. ## Skills diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29fe2c8..3d7005b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,11 +92,10 @@ govulncheck ./... Anything visual, in the app, the web or elsewhere, follows [`design/AGENTS.md`](design/AGENTS.md). In short, fixed design principles (brandbook), like: design tokens instead of raw hex, px, pt, or durations; SF Symbols only; system font only; a light and a dark value for every surface. -Two scripts enforce it, and CI runs both on any change to `design/`, app Swift or color sets, or web CSS. Run them yourself after touching a value: +One script enforces it, and CI runs it on any change to `design/`, app Swift or color sets, or web CSS. Run it yourself after touching a value: ```bash -scripts/verify-contrast.sh # every contrast ratio in design/colors.md, recomputed from tokens.css -scripts/verify-design.sh # tokens.css = DesignTokens.swift = color sets, and no hex, px or raw system colors in app or web code +scripts/verify-design.sh # tokens.css = DesignTokens.swift = color sets, every contrast ratio in design/colors.md, and no hex, px or raw system colors in app or web code ``` A failure means your change or `design/` is wrong. Fix one of them, never the check. diff --git a/design/AGENTS.md b/design/AGENTS.md index 57f83da..522fabc 100644 --- a/design/AGENTS.md +++ b/design/AGENTS.md @@ -38,14 +38,13 @@ The markdown is canonical. Two hand-written mirrors carry it into code: Change a value in a markdown file and in both mirrors in the same commit. `design/tokens.css` is the only committed CSS copy: `scripts/assemble-site.sh` copies it for the landing page and `web/docs`'s `tokens` npm script for the docs site. Never edit a copy. -Two scripts enforce this directory, and CI runs both on any change to `design/`, `app/` Swift or color sets, or web CSS: +One script enforces this directory, and CI runs it on any change to `design/`, `app/` Swift or color sets, or web CSS: ```bash -scripts/verify-contrast.sh # every ratio colors.md claims, recomputed from tokens.css, plus the seven rules -scripts/verify-design.sh # mirror parity (tokens.css = DesignTokens.swift = color sets) and the no-literals lint +scripts/verify-design.sh # mirror parity (tokens.css = DesignTokens.swift = color sets), contrast (every ratio colors.md claims plus the seven rules) and the no-literals lint ``` -Run them after touching any value. A failure means the change or this directory is wrong; fix one of them, never the check. +Run it after touching any value. A failure means the change or this directory is wrong; fix one of them, never the check. ## Rules @@ -76,7 +75,7 @@ Never encode a value in a name (`--green-600`, not `--green-dark`). 2. Add it to the topic file that owns it, with the role it plays. 3. Add it to `tokens.css` in the matching group. 4. If it is shared, add it to `DesignTokens.swift` or a color set and to the parity table in `scripts/verify-design.sh`. If the app deliberately does not mirror it, add it to `swiftui.md` → Not mirrored instead. -5. If it is a color, run `scripts/verify-contrast.sh` and add a row to `colors.md` → Contrast for anything that reads on top of it. +5. If it is a color, run `scripts/verify-design.sh` and add a row to `colors.md` → Contrast for anything that reads on top of it. 6. A component measurement used by one component is a component constant in `components.md`, not a token. A token is live when the app or a site consumes it, or when it is a step in a scale. **Scales stay whole** (spacing, radius, type, weight, tracking, control height, icon size, icon stroke, glass, Apple's label/fill/background ladders) so nobody reaches for the wrong rung. Any other token with no consumer is dead: delete it from every file in the same commit. diff --git a/design/accessibility.md b/design/accessibility.md index fd1b744..aa3939e 100644 --- a/design/accessibility.md +++ b/design/accessibility.md @@ -4,7 +4,7 @@ The binding floor for the app, the landing page and the docs site. Nothing in `c ## Contrast -WCAG 2.2 AA: body text 4.5:1, large text and UI boundaries 3:1. **Light is the constrained theme:** check it first. Ratios and the seven rules are in `colors.md` → Contrast and are enforced by `scripts/verify-contrast.sh`. Two decide most work: +WCAG 2.2 AA: body text 4.5:1, large text and UI boundaries 3:1. **Light is the constrained theme:** check it first. Ratios and the seven rules are in `colors.md` → Contrast and are enforced by `scripts/verify-design.sh`. Two decide most work: - **Accent is a fill, not a label.** Accent text is `--color-accent-text`, or `--color-accent-text-on-tint` on grouped and tinted surfaces. In the app that includes every bordered and plain button label. - **A status color is a fill, never a foreground.** Status text is `--color-text-primary`. diff --git a/design/colors.md b/design/colors.md index 52532d8..d679c0a 100644 --- a/design/colors.md +++ b/design/colors.md @@ -106,7 +106,7 @@ Status → meaning in CrocApp: **success** = transfer complete, E2E trust badge. ## Contrast (verified, WCAG 2.2) -Every row is recomputed from `tokens.css` by `scripts/verify-contrast.sh`, which also asserts the rules below as invariants, the alias rules (4 and 6, the dark accent collapse) and the two bands of `--shadow-focus-ring` (7). Run it after touching any color. **Light is the constrained theme: check it first.** +Every row is recomputed from `tokens.css` by `scripts/verify-design.sh` (contrast phase), which also asserts the rules below as invariants, the alias rules (4 and 6, the dark accent collapse) and the two bands of `--shadow-focus-ring` (7). Run it after touching any color. **Light is the constrained theme: check it first.** | Pair | Ratio | Verdict | |---|---|---| diff --git a/docs/BUILDING.md b/docs/BUILDING.md index c983342..4a0e4df 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -44,7 +44,7 @@ xcodebuild -scheme CrocApp -destination 'platform=macOS' -derivedDataPath /tmp/d xcodebuild -scheme CrocApp -destination "platform=iOS Simulator,name=$SIM" -derivedDataPath /tmp/dd-sim build ``` -`SIM` defaults to `iPhone 17 Pro` (used the same way in `scripts/verify-app-sim.sh` and `scripts/verify-share-sim.sh`). List installed runtimes with `xcrun simctl list devices`. +`SIM` defaults to `iPhone 17 Pro` (used the same way in `scripts/verify-app-sim.sh`). List installed runtimes with `xcrun simctl list devices`. ## 3. What build-xcframework.sh actually does @@ -111,11 +111,10 @@ A green build is **not** evidence a transfer works. CI runs only `verify-interop |---|---|---|---| | `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, CLI→app via the `--auto-receive` launch argument, gated on a byte-identical diff | outbound network, croc CLI, an iOS Simulator | `CROC`, `SIM` | -| `scripts/verify-share-sim.sh` | 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 (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` | +| `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 `$HOME/go/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): +`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): ```bash croc relay --host 127.0.0.1 --ports 9309,9310,9311,9312,9313 --source-join-limit 1000 --room-join-limit 1000 diff --git a/docs/knowledge/app-ui-architecture.md b/docs/knowledge/app-ui-architecture.md index 2c2f7c5..634c8ec 100644 --- a/docs/knowledge/app-ui-architecture.md +++ b/docs/knowledge/app-ui-architecture.md @@ -202,7 +202,7 @@ Settings overrides apply in any harness run (`isHarnessRun` is true for `--auto- The screenshot flags (`--screen`, `--stage`, `--seed-history`, `--hold`) are documented in `docs/knowledge/screenshots.md`. -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-share-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`, `verify-app-mac.sh`, and `verify-share-sim.sh`. AutoVerify's Documents path is hardcoded, decoupled from `OutputFolderStore.defaultFolder`. +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`. Launch gotchas, both macOS: diff --git a/docs/knowledge/croc-upgrade-playbook.md b/docs/knowledge/croc-upgrade-playbook.md index 69ecffb..fed9b5c 100644 --- a/docs/knowledge/croc-upgrade-playbook.md +++ b/docs/knowledge/croc-upgrade-playbook.md @@ -17,10 +17,9 @@ How to bump the pinned croc version (go.mod). Run this as one session whenever u 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 10 crocmobile↔CLI scenarios - - `./scripts/verify-app-mac.sh` — both directions plus local-only, custom relay, no-compress, and both-sides-confirm; the widest coverage of the option surface - - `./scripts/verify-app-sim.sh` — iOS simulator receive - - `./scripts/verify-share-sim.sh` — share-extension handoff + - `./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. diff --git a/docs/knowledge/crocmobile-bridge.md b/docs/knowledge/crocmobile-bridge.md index ecaa089..f5bb12b 100644 --- a/docs/knowledge/crocmobile-bridge.md +++ b/docs/knowledge/crocmobile-bridge.md @@ -50,7 +50,7 @@ CrocKit Swift package (CrocEngine actor, AsyncStream) → app - 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 (`verify-share-sim.sh` and the app-as-sender scenarios of `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` still uses fixed sleeps). +- 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. @@ -74,9 +74,9 @@ Not an option mismatch but easy to confuse: `NoMultiplexing` and `ThrottleUpload ## Verification harnesses -- `scripts/lib.sh` — sourced by every `verify-*.sh`: the `timeout(1)` shim, `fail`/`pass`, `require_croc`, `kill_jobs` (EXIT trap, so a failed assertion cannot leak a croc/relay/app process), `local_ip`. +- `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, 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-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/`. diff --git a/docs/knowledge/tooling.md b/docs/knowledge/tooling.md index d719118..e3556be 100644 --- a/docs/knowledge/tooling.md +++ b/docs/knowledge/tooling.md @@ -20,7 +20,8 @@ What is wired up, what was deliberately skipped, and the traps in each. | 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 | -| `scripts/verify-contrast.sh`, `scripts/verify-design.sh` (pure bash and awk, no toolchain) | none; the first holds the ratios `design/colors.md` claims, the second holds the token parity and no-literals tables | local + CI (`contrast` job, shown as `design checks`, runs both) | +| `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`) | +| 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/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 | diff --git a/docs/known-issues.md b/docs/known-issues.md index 8fa334c..6799197 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -12,8 +12,6 @@ None. ## Harnesses -- **`verify-interop.sh` can still lose the receiver-first race.** Its scenarios start the receiver after a fixed `sleep 3`, and croctest has no ready marker; on 2026-10-13 a public-pool run failed `relay 1: wrapper custom-code send` with `flate: corrupt input before offset 6` on the CLI receiver, and the retry passed. Port a ready signal from croctest (for example its `EVENT code` line) if it recurs. -- **`verify-app-mac.sh` exits with no `FAIL:` line when a CLI receive fails** (`recv_from_app`, under `set -e`). Its log is still in `$TMPDIR`. `verify-share-sim.sh` and `verify-interop.sh` already report this case. - **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. @@ -77,9 +75,9 @@ None. - **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-share-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. +- **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-share-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. +- **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`). diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md new file mode 100644 index 0000000..a5de3b1 --- /dev/null +++ b/scripts/AGENTS.md @@ -0,0 +1,16 @@ +# scripts/ + +Build, release, verification and screenshot scripts. Each script's header is its documentation (usage, what it proves, what it needs); `docs/BUILDING.md` summarizes them. + +## Conventions + +- Bash with `set -euo pipefail`. Every script `cd`s to the repo root first, so path arguments are relative to the root, not the caller's directory. +- Source `lib.sh` instead of re-implementing what it has: `fail`, `logged`, `kill_jobs`, `await_file`, `local_ip`, `TMPROOT`, and the `timeout` shim (macOS has no `timeout`). +- Run every build or archive step as `logged LOG cmd...`. A bare `> log 2>&1` under `set -e` exits without saying what failed or where the log is. +- Scratch goes in `mktemp -d`, removed by the EXIT trap. Logs worth reading after a failure go under `$TMPROOT` (per-user on macOS), never at a fixed path in `/tmp`. +- 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. +- `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/build-appstore.sh b/scripts/build-appstore.sh index 4489462..2b2006d 100755 --- a/scripts/build-appstore.sh +++ b/scripts/build-appstore.sh @@ -18,6 +18,7 @@ # APPSTORE_WORK sets the scratch directory (archives, DerivedData, logs). set -euo pipefail cd "$(dirname "$0")/.." +. scripts/lib.sh UPLOAD=0 PLATFORMS=() @@ -30,8 +31,7 @@ for arg in "$@"; do done [ ${#PLATFORMS[@]} -gt 0 ] || PLATFORMS=(ios macos) -TMPROOT="${TMPDIR:-/tmp}" -WORK="${APPSTORE_WORK:-${TMPROOT%/}/crocapp-appstore}" +WORK="${APPSTORE_WORK:-$TMPROOT/crocapp-appstore}" rm -rf "$WORK" mkdir -p "$WORK" @@ -43,25 +43,17 @@ for platform in "${PLATFORMS[@]}"; do ARCHIVE="$WORK/CrocApp-$platform.xcarchive" EXPORT="$WORK/export-$platform" - if ! ( cd app && xcodebuild -scheme CrocApp -configuration Release \ - -destination "$DEST" -derivedDataPath "$WORK/dd" \ - -archivePath "$ARCHIVE" -allowProvisioningUpdates archive ) > "$WORK/archive-$platform.log" 2>&1; then - tail -n 40 "$WORK/archive-$platform.log" >&2 - echo "error: $platform archive failed, full log: $WORK/archive-$platform.log" >&2 - exit 1 - fi + logged "$WORK/archive-$platform.log" xcodebuild -project app/CrocApp.xcodeproj -scheme CrocApp \ + -configuration Release -destination "$DEST" -derivedDataPath "$WORK/dd" \ + -archivePath "$ARCHIVE" -allowProvisioningUpdates archive echo "$platform archive: OK" cp "$OPTIONS" "$WORK/options-$platform.plist" - [ "$UPLOAD" = 1 ] && plutil -replace destination -string upload "$WORK/options-$platform.plist" + if [ "$UPLOAD" = 1 ]; then plutil -replace destination -string upload "$WORK/options-$platform.plist"; fi - if ! xcodebuild -exportArchive -archivePath "$ARCHIVE" \ - -exportOptionsPlist "$WORK/options-$platform.plist" \ - -exportPath "$EXPORT" -allowProvisioningUpdates > "$WORK/export-$platform.log" 2>&1; then - tail -n 40 "$WORK/export-$platform.log" >&2 - echo "error: $platform export failed, full log: $WORK/export-$platform.log" >&2 - exit 1 - fi + logged "$WORK/export-$platform.log" xcodebuild -exportArchive -archivePath "$ARCHIVE" \ + -exportOptionsPlist "$WORK/options-$platform.plist" \ + -exportPath "$EXPORT" -allowProvisioningUpdates if [ "$UPLOAD" = 1 ]; then echo "APPSTORE-UPLOAD-OK $platform" else diff --git a/scripts/build-devid.sh b/scripts/build-devid.sh index a72c28b..94a0b67 100755 --- a/scripts/build-devid.sh +++ b/scripts/build-devid.sh @@ -24,10 +24,10 @@ # generate_appcast ends up under $DEVID_WORK/dd/SourcePackages/artifacts. set -euo pipefail cd "$(dirname "$0")/.." +. scripts/lib.sh OUT=${1:-dist} -TMPROOT="${TMPDIR:-/tmp}" -WORK="${DEVID_WORK:-${TMPROOT%/}/crocapp-devid}" +WORK="${DEVID_WORK:-$TMPROOT/crocapp-devid}" ARCHIVE="$WORK/CrocApp.xcarchive" EXPORT="$WORK/export" IDENTITY="Developer ID Application" @@ -44,40 +44,27 @@ fi # picked instead. SIGNER=$(security find-identity -v -p codesigning \ | awk -v id="\"$IDENTITY: " -v team="($TEAM)\"" 'index($0, id) && index($0, team) { print $2; exit }') -if [ -z "$SIGNER" ]; then - echo "error: no \"$IDENTITY\" identity for team $TEAM in the keychain search list." >&2 - echo "Create one at developer.apple.com (Certificates > Developer ID Application) and install it." >&2 - exit 1 -fi +[ -n "$SIGNER" ] || fail "no \"$IDENTITY\" identity for team $TEAM in the keychain search list. Create one at developer.apple.com (Certificates > Developer ID Application) and install it." VERSION=$(sed -nE 's/^MARKETING_VERSION = ([^ ]+)$/\1/p' app/Config/Version.xcconfig) -[ -n "$VERSION" ] || { echo "error: no MARKETING_VERSION in app/Config/Version.xcconfig" >&2; exit 1; } +[ -n "$VERSION" ] || fail "no MARKETING_VERSION in app/Config/Version.xcconfig" rm -rf "$WORK" mkdir -p "$WORK" "$OUT" # Only the app target signs here: Sparkle is a binary package, so the manual # settings below reach no other target. The export re-signs everything nested. -if ! ( cd app && xcodebuild -scheme 'CrocApp Direct' -configuration Release \ - -destination 'generic/platform=macOS' -derivedDataPath "$WORK/dd" \ - -archivePath "$ARCHIVE" archive ARCHS=arm64 \ - CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY="$SIGNER" DEVELOPMENT_TEAM="$TEAM" \ - PROVISIONING_PROFILE_SPECIFIER="$PROFILE" ) > "$WORK/archive.log" 2>&1; then - tail -n 40 "$WORK/archive.log" >&2 - echo "error: archive failed, full log: $WORK/archive.log" >&2 - exit 1 -fi +logged "$WORK/archive.log" xcodebuild -project app/CrocApp.xcodeproj -scheme 'CrocApp Direct' \ + -configuration Release -destination 'generic/platform=macOS' -derivedDataPath "$WORK/dd" \ + -archivePath "$ARCHIVE" archive ARCHS=arm64 \ + CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY="$SIGNER" DEVELOPMENT_TEAM="$TEAM" \ + PROVISIONING_PROFILE_SPECIFIER="$PROFILE" echo "archive: OK" -if ! ( cd app && xcodebuild -exportArchive -archivePath "$ARCHIVE" \ - -exportOptionsPlist Config/ExportOptions-DevID.plist \ - -exportPath "$EXPORT" ) > "$WORK/export.log" 2>&1; then - tail -n 40 "$WORK/export.log" >&2 - echo "error: export failed, full log: $WORK/export.log" >&2 - exit 1 -fi +logged "$WORK/export.log" xcodebuild -exportArchive -archivePath "$ARCHIVE" \ + -exportOptionsPlist app/Config/ExportOptions-DevID.plist -exportPath "$EXPORT" APP="$EXPORT/CrocApp.app" -[ -d "$APP" ] || { echo "error: no CrocApp.app in $EXPORT (see $WORK/export.log)" >&2; exit 1; } +[ -d "$APP" ] || fail "no CrocApp.app in $EXPORT (see $WORK/export.log)" codesign --verify --strict --deep "$APP" echo DEVID-EXPORT-OK @@ -85,7 +72,7 @@ echo DEVID-EXPORT-OK # not-yet-notarized app ("Gatekeeper rejected this file", spctl source # "Unnotarized Developer ID"). The notary log names any real problem. notarize() { - local file=$1 json status + local file=$1 json status id json="$WORK/notary-$(basename "$file").json" # Status is checked from the JSON, not the exit code alone, and a failed # submit still reaches the log fetch below. @@ -97,8 +84,7 @@ notarize() { if id=$(plutil -extract id raw "$json" 2>/dev/null); then xcrun notarytool log "$id" "${NOTARY_AUTH[@]}" >&2 || echo "warning: could not fetch the notary log for $id" >&2 fi - echo "error: notarization of $file: status '$status'" >&2 - exit 1 + fail "notarization of $file: status '$status'" fi } diff --git a/scripts/capture-screenshots.sh b/scripts/capture-screenshots.sh index 1b50842..b60ac33 100755 --- a/scripts/capture-screenshots.sh +++ b/scripts/capture-screenshots.sh @@ -772,18 +772,16 @@ make_payloads echo "building..." BIN="" if [ "$uses_mac" = 1 ]; then - ( cd app && xcodebuild -scheme CrocApp -destination 'platform=macOS' \ - -derivedDataPath "$TMPROOT/dd-mac" -allowProvisioningUpdates build ) > "$TMPROOT/shots-mac-build.log" 2>&1 \ - || fail "macOS build failed (see $TMPROOT/shots-mac-build.log)" + logged "$TMPROOT/shots-mac-build.log" xcodebuild -project app/CrocApp.xcodeproj -scheme CrocApp \ + -destination 'platform=macOS' -derivedDataPath "$TMPROOT/dd-mac" -allowProvisioningUpdates build BIN=$(find "$TMPROOT/dd-mac/Build/Products" -maxdepth 3 -name CrocApp.app | head -1)/Contents/MacOS/CrocApp [ -x "$BIN" ] || fail "no CrocApp.app in $TMPROOT/dd-mac (see $TMPROOT/shots-mac-build.log)" stage_payloads "$MAC_DOCS" fi if [ "$uses_sim" = 1 ]; then - ( cd app && xcodebuild -scheme CrocApp -destination 'generic/platform=iOS Simulator' \ - -derivedDataPath "$TMPROOT/dd-sim" build ) > "$TMPROOT/shots-sim-build.log" 2>&1 \ - || fail "iOS build failed (see $TMPROOT/shots-sim-build.log)" + logged "$TMPROOT/shots-sim-build.log" xcodebuild -project app/CrocApp.xcodeproj -scheme CrocApp \ + -destination 'generic/platform=iOS Simulator' -derivedDataPath "$TMPROOT/dd-sim" build SIM_APP=$(find "$TMPROOT/dd-sim/Build/Products" -maxdepth 3 -name CrocApp.app | head -1) [ -n "$SIM_APP" ] || fail "no CrocApp.app in $TMPROOT/dd-sim (see $TMPROOT/shots-sim-build.log)" fi diff --git a/scripts/format-swift.sh b/scripts/format-swift.sh index 26f015e..70f02d4 100755 --- a/scripts/format-swift.sh +++ b/scripts/format-swift.sh @@ -11,7 +11,8 @@ set -euo pipefail cd "$(dirname "$0")/.." -PATHS=(app/CrocApp app/CrocAppDirect app/CrocShare app/CrocAppTests CrocKit/Sources CrocKit/Tests CrocKit/Package.swift) +PATHS=(app/CrocApp app/CrocAppDirect app/CrocShare app/CrocAppTests CrocKit/Sources CrocKit/Tests CrocKit/Package.swift + scripts/screenshots) LINT_ONLY=0 case "${1-}" in diff --git a/scripts/lib.sh b/scripts/lib.sh index 0b03191..152bb72 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Shared helpers for the verify and capture harnesses. Source it; do not run it. +# Shared helpers for the scripts in this directory. Source it; do not run it. # Per-user private dir on macOS, /tmp elsewhere. Build products and logs stay # out of the world-writable /tmp, where another local user could pre-create or @@ -10,6 +10,14 @@ TMPROOT="${TMPROOT%/}" fail() { echo "FAIL: $*" >&2; exit 1; } pass() { echo "PASS: $*"; } +# Runs CMD with its output in LOG. A failure shows the log's tail and names the +# log, where a bare redirect under `set -e` would exit without a word. +logged() { # logged LOG CMD... + local log=$1 + shift + "$@" > "$log" 2>&1 || { tail -n 40 "$log" >&2; fail "$1 failed, full log: $log"; } +} + require_croc() { command -v "$CROC" >/dev/null 2>&1 || fail "croc not found at '$CROC' (set CROC=/path/to/croc)" } diff --git a/scripts/verify-app-mac.sh b/scripts/verify-app-mac.sh index 9aa393c..eaf76f5 100755 --- a/scripts/verify-app-mac.sh +++ b/scripts/verify-app-mac.sh @@ -1,11 +1,13 @@ #!/usr/bin/env bash -# Verifies the macOS app's UI state machine end-to-end via launch arguments. -# Six directions: CLI -> app receive (+ history), app -> CLI send, local-only -# send, custom-relay send, no-compress send, both-sides-confirm send, then a -# receive onto an existing file with Keep Both and with Replace, then a 130 MiB -# transfer in each direction under Connection Automatic and Relay only (needs -# the public internet: Tailcat bootstraps from tailcat.dev). Every one gates on -# a byte-identical diff AND the app's verify-result.txt. +# Verifies the macOS app's UI state machine end-to-end via launch arguments, +# in 12 scenarios: CLI -> app receive (+ history), app -> CLI send, local-only +# send, custom-relay send, no-compress send, both-sides-confirm send, a receive +# onto an existing file with Keep Both and with Replace, a 130 MiB transfer in +# each direction under Connection Automatic and Relay only (needs the public +# internet: Tailcat bootstraps from tailcat.dev), stored transfers against a +# local store server, menu-bar quick send, Open at login and the Shortcuts +# actions. Every transfer gates on a byte-identical diff AND the app's +# verify-result.txt. set -euo pipefail cd "$(dirname "$0")/.." . scripts/lib.sh @@ -20,11 +22,27 @@ HISTORY="$DOCS/verify-history.txt" READY="$DOCS/verify-code-ready.txt" DD="$TMPROOT/dd-mac" +RECV_DIR="$HOME/Downloads/CrocApp" # the Receive action's real folder +RECV_NAME="intent-recv-$$.txt" + TMP=$(mktemp -d) -trap 'kill_jobs; rm -rf "$TMP"; rm -rf "$DOCS"/bigsend.bin "$DOCS"/bigrecv.bin "$DOCS"/store-src "$DOCS"/storeme.bin "$DOCS"/verify-store-*' EXIT +SAVED_PASTE="" +# Everything a run writes outside $TMP, so a failed run leaves none of it: the +# app's Documents (also its receive folder), the Receive action's file, and the +# user's pasteboard, which step 10 overwrites. +cleanup() { + kill_jobs + if [ -n "$SAVED_PASTE" ]; then pbcopy < "$SAVED_PASTE"; fi + rm -rf "$DOCS"/{macfile,sendme,localme,relayme,nocompme,collide,menubar,intentsend}.txt \ + "$DOCS/collide (1).txt" "$DOCS"/{bigsend,bigrecv,storeme}.bin "$DOCS"/{askdir,store-src} \ + "$DOCS"/verify-* + rm -f "$RECV_DIR/$RECV_NAME" + rm -rf "$TMP" +} +trap cleanup EXIT -( cd app && xcodebuild -scheme CrocApp -destination 'platform=macOS' \ - -derivedDataPath "$DD" -allowProvisioningUpdates build ) > "$TMPROOT/mac-build.log" 2>&1 +logged "$TMPROOT/mac-build.log" xcodebuild -project app/CrocApp.xcodeproj -scheme CrocApp \ + -destination 'platform=macOS' -derivedDataPath "$DD" -allowProvisioningUpdates build APP=$(find "$DD/Build/Products" -maxdepth 3 -name CrocApp.app | head -1) [ -n "$APP" ] || fail "no CrocApp.app in $DD (see $TMPROOT/mac-build.log)" BIN="$APP/Contents/MacOS/CrocApp" @@ -68,10 +86,10 @@ recv_from_app() { # recv_from_app LABEL SRC DS shift 3 mkdir -p "$dst" ( cd "$dst" && CROC_SECRET="$C" timeout "${RECV_TIMEOUT:-120}" "$CROC" --ignore-stdin --yes "$@" ) \ - > "$TMPROOT/mac-cli-$label.log" 2>&1 + > "$TMPROOT/mac-cli-$label.log" 2>&1 || fail "$label: croc receive exited $? (log: $TMPROOT/mac-cli-$label.log)" await_app 30 check_result "$label" - diff "$src" "$dst/${src##*/}" + cmp -s "$src" "$dst/${src##*/}" || fail "$label: received bytes differ" } # Every code's first 4 characters must be unique: the relay hashes that prefix @@ -81,13 +99,13 @@ code() { echo "$1$$-mac"; } # --- 1: CLI -> app, plus SwiftData history isolation ------------------------- C=$(code recv) echo "mac transfer $$" > "$TMP/macfile.txt" -( cd "$TMP" && CROC_SECRET="$C" timeout 120 "$CROC" --ignore-stdin send macfile.txt ) \ +( cd "$TMP" && CROC_SECRET="$C" timeout 120 "$CROC" --disable-clipboard --ignore-stdin send macfile.txt ) \ > "$TMPROOT/mac-cli-send.log" 2>&1 & sleep 3 run_app --auto-receive "$C" > "$TMPROOT/mac-app-recv.log" 2>&1 await_app 120 check_result receive -diff "$TMP/macfile.txt" "$DOCS/macfile.txt" +cmp -s "$TMP/macfile.txt" "$DOCS/macfile.txt" || fail "receive: received bytes differ" # --auto-* launches get an in-memory ModelContainer, so records=1 proves the # transfer was recorded and that the harness isolation still holds. got=$(cat "$HISTORY" 2>/dev/null || echo missing) @@ -168,10 +186,11 @@ run_app --auto-send "$DOCS/askdir" --code "$C" --ask > "$TMPROOT/mac-app-ask.log await_ready DST="$TMP/d6"; mkdir -p "$DST" printf 'y\ny\n' > "$TMP/ask-answers" -( cd "$DST" && CROC_SECRET="$C" timeout 120 "$CROC" --yes < "$TMP/ask-answers" ) > "$TMPROOT/mac-cli-ask.log" 2>&1 +( cd "$DST" && CROC_SECRET="$C" timeout 120 "$CROC" --yes < "$TMP/ask-answers" ) > "$TMPROOT/mac-cli-ask.log" 2>&1 \ + || fail "ask send: croc receive exited $? (log: $TMPROOT/mac-cli-ask.log)" await_app 30 check_result "ask send" -diff -r "$DOCS/askdir" "$DST/askdir" +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) -------- @@ -184,7 +203,7 @@ collide() { # collide POLICY rm -f "$RESULT" "$DOCS/collide.txt" "$DOCS/collide (1).txt" echo "existing file" > "$DOCS/collide.txt" echo "mac collide $policy $$" > "$TMP/collide.txt" - ( cd "$TMP" && CROC_SECRET="$C" timeout 120 "$CROC" --ignore-stdin send collide.txt ) \ + ( cd "$TMP" && CROC_SECRET="$C" timeout 120 "$CROC" --disable-clipboard --ignore-stdin send collide.txt ) \ > "$TMPROOT/mac-cli-collide-$policy.log" 2>&1 & sleep 3 run_app --auto-receive "$C" --when-exists "$policy" > "$TMPROOT/mac-app-collide-$policy.log" 2>&1 @@ -192,9 +211,9 @@ collide() { # collide POLICY check_result "collide $policy" if [ "$policy" = keepBoth ]; then [ "$(cat "$DOCS/collide.txt")" = "existing file" ] || fail "keepBoth: existing file changed" - diff "$TMP/collide.txt" "$DOCS/collide (1).txt" + cmp -s "$TMP/collide.txt" "$DOCS/collide (1).txt" || fail "keepBoth: no collide (1).txt with the new content" else - diff "$TMP/collide.txt" "$DOCS/collide.txt" + cmp -s "$TMP/collide.txt" "$DOCS/collide.txt" || fail "replace: file not replaced" [ ! -e "$DOCS/collide (1).txt" ] || fail "replace: renamed copy written" fi } @@ -224,7 +243,7 @@ transport_app_receive() { # transport_app_receive MODE local mode=$1 log="$TMPROOT/mac-cli-xr$1.log" cli_pid C=$(code "xr$mode") rm -f "$RESULT" "$DOCS/bigrecv.bin" - ( cd "$TMP" && CROC_SECRET="$C" timeout 300 "$CROC" --debug --ignore-stdin send --transport "$mode" bigrecv.bin ) \ + ( cd "$TMP" && CROC_SECRET="$C" timeout 300 "$CROC" --debug --disable-clipboard --ignore-stdin send --transport "$mode" bigrecv.bin ) \ > "$log" 2>&1 & cli_pid=$! sleep 3 @@ -323,9 +342,9 @@ await_ready C=$(cat "$DOCS/verify-code.txt") recv_from_app menubar "$DOCS/menubar.txt" "$TMP/d10" -# The pasteboard is the user's: put it back afterwards. +# The pasteboard is the user's: cleanup puts it back. +pbpaste > "$TMP/pasteboard.txt" SAVED_PASTE="$TMP/pasteboard.txt" -pbpaste > "$SAVED_PASTE" rm -f "$RESULT" "$HISTORY" "$DOCS/verify-code.txt" CLIP="mac menubar clipboard $$" printf '%s' "$CLIP" | pbcopy @@ -334,12 +353,10 @@ await_ready C=$(cat "$DOCS/verify-code.txt") mkdir -p "$TMP/d10t" ( cd "$TMP/d10t" && CROC_SECRET="$C" timeout 120 "$CROC" --ignore-stdin --yes ) \ - > "$TMPROOT/mac-cli-menubar-clip.log" 2>&1 + > "$TMPROOT/mac-cli-menubar-clip.log" 2>&1 || fail "menubar-clipboard: croc receive exited $? (log: $TMPROOT/mac-cli-menubar-clip.log)" await_app 30 -pbcopy < "$SAVED_PASTE" check_result menubar-clipboard grep -qF "$CLIP" "$TMPROOT/mac-cli-menubar-clip.log" || fail "menu-bar clipboard text did not reach the CLI" -rm -f "$DOCS/menubar.txt" echo MAC-MENUBAR-OK # --- 11: Open at login matches the system status (R26) ----------------------- @@ -365,8 +382,8 @@ echo MAC-LOGIN-OK # --- 12: the Shortcuts actions against the CLI (R27) ------------------------- # 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 their code, which the -# app writes to verify-code.txt; Receive saves into the app's receive folder +# arguments in a row make AppKit skip the window). The send actions return +# their code, which the app writes to verify-code.txt; Receive saves into the app's receive folder # (the real ~/Downloads/CrocApp), so the file gets a unique name and is # removed afterwards. rm -f "$RESULT" "$HISTORY" "$DOCS/verify-code.txt" @@ -383,24 +400,21 @@ await_ready C=$(cat "$DOCS/verify-code.txt") mkdir -p "$TMP/d12t" ( cd "$TMP/d12t" && CROC_SECRET="$C" timeout 120 "$CROC" --ignore-stdin --yes ) \ - > "$TMPROOT/mac-cli-intent-text.log" 2>&1 + > "$TMPROOT/mac-cli-intent-text.log" 2>&1 || fail "intent-send-text: croc receive exited $? (log: $TMPROOT/mac-cli-intent-text.log)" await_app 30 check_result intent-send-text grep -qF "$INTENT_TEXT" "$TMPROOT/mac-cli-intent-text.log" || fail "Send Text action: text did not reach the CLI" C=$(code intr) -RECV_NAME="intent-recv-$$.txt" -RECV_DIR="$HOME/Downloads/CrocApp" rm -f "$RESULT" "$HISTORY" echo "mac intent receive $$" > "$TMP/$RECV_NAME" -( cd "$TMP" && CROC_SECRET="$C" timeout 120 "$CROC" --ignore-stdin send "$RECV_NAME" ) \ +( cd "$TMP" && CROC_SECRET="$C" timeout 120 "$CROC" --disable-clipboard --ignore-stdin send "$RECV_NAME" ) \ > "$TMPROOT/mac-cli-intent-send.log" 2>&1 & sleep 3 run_app --auto-intent receive --intent-arg "$C" > "$TMPROOT/mac-app-intent-recv.log" 2>&1 await_app 120 got=$(cat "$RESULT" 2>/dev/null || echo missing) echo "intent receive result: $got" -diff "$TMP/$RECV_NAME" "$RECV_DIR/$RECV_NAME" || fail "Receive action: file differs" -rm -f "$RECV_DIR/$RECV_NAME" "$DOCS/intentsend.txt" +cmp -s "$TMP/$RECV_NAME" "$RECV_DIR/$RECV_NAME" || fail "Receive action: file differs" [ "$got" = "ok success=true files=$RECV_NAME" ] || fail "Receive action: expected the file name back, got '$got'" echo MAC-INTENT-OK diff --git a/scripts/verify-app-sim.sh b/scripts/verify-app-sim.sh index 2c639d1..cc36d6e 100755 --- a/scripts/verify-app-sim.sh +++ b/scripts/verify-app-sim.sh @@ -1,11 +1,16 @@ #!/usr/bin/env bash -# Boots an iOS simulator, installs CrocApp, drives a croc CLI -> app transfer -# via the --auto-receive launch argument, gates on the result file and a -# byte-identical diff. +# Verifies the iOS app on a simulator via launch arguments, in two scenarios: +# 1. CLI -> app receive (--auto-receive). +# 2. The share-extension handoff (--auto-share-send): files staged into the +# App Group container the way CrocShare does, sent by the app, received +# by the CLI. The extension UI itself (share sheet invocation) is +# device/manual territory. +# Each gates on the app's verify-result.txt AND a byte-identical diff. # -# The CLI send starts first and gets a moment to register its code before the -# app's receive launches. Starting the receiver first races the sender's setup -# and corrupts the PAKE handshake ("problem with decoding: invalid character"). +# Whichever side sends must register its code before the receiver joins: a +# receiver that arrives first corrupts the PAKE handshake ("problem with +# decoding: invalid character", or "flate: corrupt input" when it times out +# croc's 500 ms local-endpoint probe and decodes the late reply as data). set -euo pipefail cd "$(dirname "$0")/.." . scripts/lib.sh @@ -13,38 +18,83 @@ cd "$(dirname "$0")/.." CROC="${CROC:-${HOMEBREW_PREFIX:-/opt/homebrew}/bin/croc}" require_croc -CODE="sim-$$-recv" SIM="${SIM:-iPhone 17 Pro}" # list runtimes with: xcrun simctl list devices BUNDLE=com.bakirgdev.CrocApp +GROUP=group.com.bakirgdev.CrocApp DD="$TMPROOT/dd-sim" TMP=$(mktemp -d) trap 'kill_jobs; rm -rf "$TMP"' EXIT -xcrun simctl boot "$SIM" 2>/dev/null || true -( cd app && xcodebuild -scheme CrocApp \ - -destination "platform=iOS Simulator,name=$SIM" \ - -derivedDataPath "$DD" build ) > "$TMPROOT/sim-build.log" 2>&1 +xcrun simctl bootstatus "$SIM" -b > /dev/null +logged "$TMPROOT/sim-build.log" xcodebuild -project app/CrocApp.xcodeproj -scheme CrocApp \ + -destination "platform=iOS Simulator,name=$SIM" -derivedDataPath "$DD" build APP=$(find "$DD/Build/Products" -maxdepth 3 -name CrocApp.app | head -1) [ -n "$APP" ] || fail "no CrocApp.app in $DD (see $TMPROOT/sim-build.log)" xcrun simctl install "$SIM" "$APP" -# Clear artifacts from a previous run so a crashed or no-op app cannot leave -# behind a result file that makes this run look like it passed. -CONTAINER=$(xcrun simctl get_app_container "$SIM" "$BUNDLE" data) -RESULT="$CONTAINER/Documents/verify-result.txt" -rm -f "$RESULT" "$CONTAINER/Documents/simfile.txt" +DOCS="$(xcrun simctl get_app_container "$SIM" "$BUNDLE" data)/Documents" +RESULT="$DOCS/verify-result.txt" +READY="$DOCS/verify-code-ready.txt" +launch() { # launch ARGS... + xcrun simctl terminate "$SIM" "$BUNDLE" 2>/dev/null || true + xcrun simctl launch "$SIM" "$BUNDLE" "$@" > /dev/null +} + +check_result() { # check_result LABEL + local got + await_file "$RESULT" 120 || true + got=$(cat "$RESULT" 2>/dev/null || echo missing) + echo "$1 result: $got" + [ "$got" = "ok success=true" ] || fail "$1: expected 'ok success=true', got '$got'" +} + +# --- 1: CLI -> app ------------------------------------------------------------ +# Results from a previous run are cleared first, so a crashed or no-op app +# cannot leave behind a file that makes this run look like it passed. +C="sim-$$-recv" +rm -f "$RESULT" "$READY" "$DOCS/simfile.txt" echo "sim transfer $$" > "$TMP/simfile.txt" -( cd "$TMP" && CROC_SECRET="$CODE" timeout 120 "$CROC" --ignore-stdin send simfile.txt ) \ +( cd "$TMP" && CROC_SECRET="$C" timeout 120 "$CROC" --disable-clipboard --ignore-stdin send simfile.txt ) \ > "$TMPROOT/sim-send.log" 2>&1 & sleep 3 -xcrun simctl terminate "$SIM" "$BUNDLE" 2>/dev/null || true -xcrun simctl launch "$SIM" "$BUNDLE" --auto-receive "$CODE" - -await_file "$RESULT" 120 || true -GOT=$(cat "$RESULT" 2>/dev/null || echo missing) -echo "result: $GOT" -[ "$GOT" = "ok success=true" ] || fail "expected 'ok success=true', got '$GOT'" -diff "$TMP/simfile.txt" "$CONTAINER/Documents/simfile.txt" || fail "received bytes differ" +launch --auto-receive "$C" +check_result receive +cmp -s "$TMP/simfile.txt" "$DOCS/simfile.txt" || fail "receive: received bytes differ" echo SIM-INTEROP-OK + +# --- 2: share-extension handoff, app -> CLI ------------------------------------ +# `get_app_container ` mis-parses the group +# identifier as a flag on this simctl version, so list the groups and pick ours. +C="shr-$$-send" +rm -f "$RESULT" "$READY" +GROUP_DIR=$(xcrun simctl get_app_container "$SIM" "$BUNDLE" groups | awk -v g="$GROUP" '$1 == g { print $2 }') +[ -n "$GROUP_DIR" ] || fail "could not resolve app group container $GROUP" +rm -rf "$GROUP_DIR/ShareInbox" + +# Stage a batch the way CrocShare's ShareStager does: batch dir, payload, then +# manifest.json written last. +BATCH="batch-$(uuidgen)" +mkdir -p "$GROUP_DIR/ShareInbox/$BATCH" +head -c 1048576 /dev/urandom > "$GROUP_DIR/ShareInbox/$BATCH/payload.bin" +printf '{"batch":"%s","files":["payload.bin"]}\n' "$BATCH" > "$GROUP_DIR/ShareInbox/manifest.json" + +launch --auto-share-send "$C" +# A fixed sleep lost the sender-first race whenever the app launched slowly. +# The marker fires before croc dials the relay, so wait 3 s more (1 s still +# lost the race once). +await_file "$READY" 60 || fail "app never reported its code (no $READY)" +sleep 3 + +# CROC_SECRET rather than a positional code: the croc CLI refuses a positional +# receive code and points at CROC_SECRET. +( cd "$TMP" && CROC_SECRET="$C" timeout 120 "$CROC" --ignore-stdin --yes ) \ + > "$TMPROOT/share-sim-recv.log" 2>&1 || { + rc=$? + tail -n 30 "$TMPROOT/share-sim-recv.log" >&2 + fail "share: croc receive exited $rc (log: $TMPROOT/share-sim-recv.log)" +} +check_result share +cmp -s "$GROUP_DIR/ShareInbox/$BATCH/payload.bin" "$TMP/payload.bin" || fail "share: received bytes differ" +echo SHARE-SIM-OK diff --git a/scripts/verify-contrast.sh b/scripts/verify-contrast.sh deleted file mode 100755 index 686d334..0000000 --- a/scripts/verify-contrast.sh +++ /dev/null @@ -1,322 +0,0 @@ -#!/usr/bin/env bash -# Recomputes every contrast ratio design/colors.md claims, from design/tokens.css. -# -# The Contrast table and the seven rules under it are the binding part of the -# design system. They were hand-computed once; this makes them checkable. Edit a -# green step, run this, find out immediately which rule you just broke. -# -# scripts/verify-contrast.sh # table + pass/fail, exit 1 on drift -# scripts/verify-contrast.sh --quiet # exit code only -# -# Values come from design/tokens.css, never from a web copy. Ratios are WCAG 2.x -# relative luminance, alpha composited onto the surface underneath. Pure bash and -# awk on purpose: no toolchain, so CI can run it on a bare runner. -set -euo pipefail -cd "$(dirname "$0")/.." - -QUIET=0 -case "${1-}" in - --quiet) QUIET=1 ;; - "") ;; - *) echo "usage: $0 [--quiet]" >&2; exit 2 ;; -esac - -TOKENS=design/tokens.css -[ -r "$TOKENS" ] || { echo "FAIL: $TOKENS not readable" >&2; exit 2; } - -# Two tables, piped to awk on stdin because a literal newline cannot travel -# through `awk -v`. Above the marker: every row design/colors.md asserts, as -# theme|foreground|background stack|expected. The stack composites front to -# back, so a translucent tint names the opaque surface under it — keep it and -# the markdown table in step. Below the marker: rules that must hold whatever -# the values are, as label|theme|foreground|stack|min or max|threshold. Below -# the second marker: alias rules, label|theme|token|target, true when the token -# resolves to the same color as the target in that theme. The focus ring bands -# (rule 7) are parsed from --shadow-focus-ring in the END block. -TABLES=' -light|--color-accent|--color-surface-base|3.41 -light|--color-accent|--color-surface-grouped|3.06 -light|--color-accent|--color-accent-tint>--color-surface-base|2.99 -light|--color-accent-text|--color-surface-base|4.94 -light|--color-accent-text|--color-surface-grouped|4.42 -light|--color-accent-text|--color-accent-tint>--color-surface-base|4.32 -light|--color-accent-text|--color-surface-card|4.94 -light|--color-accent-text-on-tint|--color-surface-base|7.36 -light|--color-accent-text-on-tint|--color-surface-grouped|6.60 -light|--color-accent-text-on-tint|--color-accent-tint>--color-surface-base|6.45 -light|--label-2|--color-surface-base|3.44 -light|--label-2-web|--color-surface-base|5.16 -light|--label-2-web|--color-surface-grouped|4.85 -light|--color-text-secondary|--color-accent-tint>--color-surface-base|4.78 -light|--label-3|--color-surface-base|1.73 -light|--color-on-accent|--color-accent|3.41 -light|--color-on-status|--color-status-error|3.55 -light|--color-status-success|--color-status-success-tint>--color-surface-base|1.98 -light|--color-status-warning|--color-status-warning-tint>--color-surface-base|1.96 -light|--color-status-error|--color-status-error-tint>--color-surface-base|2.94 -light|--color-status-success|--color-surface-base|2.22 -light|--color-status-warning|--color-surface-base|2.20 -light|--color-status-error|--color-surface-base|3.55 -light|--color-status-info|--color-surface-base|4.02 -light|--glass-tint-accent|--color-surface-base|1.14 -dark|--color-accent|--color-surface-base|9.44 -dark|--color-accent|--color-surface-card|7.65 -dark|--color-accent|--color-accent-tint>--color-surface-base|7.72 -dark|--color-on-accent|--color-accent|7.19 -dark|--color-on-status|--color-status-error|3.41 -dark|--label-2|--color-surface-base|6.36 -dark|--color-status-success|--color-status-success-tint>--color-surface-base|8.05 -dark|--color-status-warning|--color-status-warning-tint>--color-surface-base|7.93 -dark|--color-status-error|--color-status-error-tint>--color-surface-base|5.25 -dark|--color-status-info|--color-status-info-tint>--color-surface-base|4.92 -== invariants == -accent fill clears 3:1 on base|light|--color-accent|--color-surface-base|min|3.0 -accent-text clears AA on base|light|--color-accent-text|--color-surface-base|min|4.5 -accent-text-on-tint clears AA on grouped|light|--color-accent-text-on-tint|--color-surface-grouped|min|4.5 -accent-text-on-tint clears AA on tint|light|--color-accent-text-on-tint|--color-accent-tint>--color-surface-base|min|4.5 -web secondary clears AA on grouped|light|--label-2-web|--color-surface-grouped|min|4.5 -filled prominent clears 3:1|light|--color-on-accent|--color-accent|min|3.0 -filled destructive clears 3:1, light|light|--color-on-status|--color-status-error|min|3.0 -filled destructive clears 3:1, dark|dark|--color-on-status|--color-status-error|min|3.0 -focus ring outer band clears 3:1 on base|light|--color-accent|--color-surface-base|min|3.0 -focus ring inner band clears 3:1 on the accent fill|light|--color-surface-base|--color-accent|min|3.0 -tertiary is NOT a usable text color|light|--label-3|--color-surface-base|max|3.0 -secondary text clears AA on the accent tint (rule 4)|light|--color-text-secondary|--color-accent-tint>--color-surface-base|min|4.5 -accent-text clears AA on a card|light|--color-accent-text|--color-surface-card|min|4.5 -the accent tint is NOT a visible focus indicator (rule 7)|light|--glass-tint-accent|--color-surface-base|max|1.5 -dark status success clears AA on its own tint|dark|--color-status-success|--color-status-success-tint>--color-surface-base|min|4.5 -dark status warning clears AA on its own tint|dark|--color-status-warning|--color-status-warning-tint>--color-surface-base|min|4.5 -dark status error clears AA on its own tint|dark|--color-status-error|--color-status-error-tint>--color-surface-base|min|4.5 -dark status info clears AA on its own tint|dark|--color-status-info|--color-status-info-tint>--color-surface-base|min|4.5 -== aliases == -rule 4: secondary text is label-2-web, light|light|--color-text-secondary|--label-2-web -rule 4: secondary text is label-2-web, dark|dark|--color-text-secondary|--label-2-web -rule 6: link is accent-text, light|light|--color-text-link|--color-accent-text -rule 6: link is accent-text, dark|dark|--color-text-link|--color-accent-text -dark accent collapses to green-400|dark|--color-accent|--green-400 -dark accent-text collapses to green-400|dark|--color-accent-text|--green-400 -dark accent-text-on-tint collapses to green-400|dark|--color-accent-text-on-tint|--green-400 -' - -printf '%s\n' "$TABLES" | awk -v quiet="$QUIET" -v tokens="$TOKENS" ' -function die(msg) { print "FAIL: " msg > "/dev/stderr"; exit_code = 2; exit 2 } - -function trim(s) { sub(/^[ \t\r\n]+/, "", s); sub(/[ \t\r\n]+$/, "", s); return s } - -# Split on top-level commas only, so a nested rgba(...) survives intact. -function split_top(s, out, i, ch, depth, cur, n) { - depth = 0; cur = ""; n = 0 - for (i = 1; i <= length(s); i++) { - ch = substr(s, i, 1) - if (ch == "(") depth++ - else if (ch == ")") depth-- - if (ch == "," && depth == 0) { out[++n] = trim(cur); cur = "" } - else cur = cur ch - } - out[++n] = trim(cur) - return n -} - -function hex2dec(h, i, c, d, n) { - n = 0 - for (i = 1; i <= length(h); i++) { - c = tolower(substr(h, i, 1)) - d = index("0123456789abcdef", c) - 1 - if (d < 0) die("bad hex digit in " h) - n = n * 16 + d - } - return n -} - -# Returns "r g b a". -function parse_color(v, theme, depth, args, n, inner, hex, parts, cnt, a) { - v = trim(v) - if (depth > 16) die("token recursion too deep at " v) - - if (v ~ /^light-dark\(/) { - inner = substr(v, 12, length(v) - 12) - n = split_top(inner, args) - if (n != 2) die("light-dark() needs 2 arguments: " v) - return parse_color(args[theme == "light" ? 1 : 2], theme, depth + 1) - } - - if (v ~ /^var\(/) { - inner = trim(substr(v, 5, length(v) - 5)) - return resolve(inner, theme, depth + 1) - } - - if (v ~ /^#[0-9a-fA-F]+$/) { - hex = substr(v, 2) - if (length(hex) != 6) die("only 6-digit hex is supported: " v) - return hex2dec(substr(hex, 1, 2)) " " hex2dec(substr(hex, 3, 2)) " " hex2dec(substr(hex, 5, 2)) " 1" - } - - if (v ~ /^rgba?\(/) { - inner = v - sub(/^rgba?\(/, "", inner); sub(/\)$/, "", inner) - cnt = split_top(inner, parts) - if (cnt < 3 || cnt > 4) die("rgb()/rgba() needs 3 or 4 arguments: " v) - a = (cnt == 4) ? parts[4] + 0 : 1 - return (parts[1] + 0) " " (parts[2] + 0) " " (parts[3] + 0) " " a - } - - die("cannot parse color " v) -} - -function resolve(name, theme, depth) { - if (!(name in VAL)) die(name " is not defined in design/tokens.css") - return parse_color(VAL[name], theme, depth) -} - -# Source-over. bg is opaque by the time this runs. -function composite(fg, bg, f, b, a, i, out) { - split(fg, f, " "); split(bg, b, " ") - a = f[4] - out = "" - for (i = 1; i <= 3; i++) out = out (f[i] * a + b[i] * (1 - a)) " " - return out "1" -} - -# Resolve a background stack (front to back, ">"-separated) to one opaque color. -function flatten(stack, theme, names, n, i, result, layer, c) { - n = split(stack, names, ">") - result = resolve(names[n], theme, 0) - split(result, c, " ") - if (c[4] + 0 < 1) die("bottom of stack " names[n] " is not opaque") - for (i = n - 1; i >= 1; i--) { - layer = resolve(names[i], theme, 0) - result = composite(layer, result) - } - return result -} - -function channel(v) { - v /= 255 - return (v <= 0.03928) ? v / 12.92 : ((v + 0.055) / 1.055) ^ 2.4 -} - -function luminance(col, c) { - split(col, c, " ") - return 0.2126 * channel(c[1]) + 0.7152 * channel(c[2]) + 0.0722 * channel(c[3]) -} - -function measure(theme, fg_name, stack, bg, fg, f, la, lb, hi, lo) { - bg = flatten(stack, theme) - fg = resolve(fg_name, theme, 0) - split(fg, f, " ") - if (f[4] + 0 < 1) fg = composite(fg, bg) - la = luminance(fg); lb = luminance(bg) - hi = (la > lb) ? la : lb - lo = (la > lb) ? lb : la - return (hi + 0.05) / (lo + 0.05) -} - -# Rule 7: --shadow-focus-ring is two bands, the base surface inside (2px) and -# the accent outside (4px). The contrast rows prove each band reads against the -# other and the accent; this proves the token still is that ring. -function check_ring( ring, n, bands, k, parts, np, spread, color, want_spread, want_color) { - if (!("--shadow-focus-ring" in VAL)) { failures[++nfail] = "--shadow-focus-ring is not defined in design/tokens.css"; return } - ring = VAL["--shadow-focus-ring"] - n = split_top(ring, bands) - if (n != 2) { failures[++nfail] = "--shadow-focus-ring must have exactly two bands, has " n; return } - for (k = 1; k <= 2; k++) { - want_spread = (k == 1) ? "2px" : "4px" - want_color = (k == 1) ? "var(--color-surface-base)" : "var(--color-accent)" - np = split(bands[k], parts, " ") - spread = parts[4]; color = parts[np] - if (np != 5 || parts[1] != "0" || parts[2] != "0" || parts[3] != "0" || spread != want_spread || color != want_color) - failures[++nfail] = "--shadow-focus-ring band " k " is \"" bands[k] "\", rule 7 needs \"0 0 0 " want_spread " " want_color "\"" - } -} - -BEGIN { - TOLERANCE = 0.01 - section = "cases" - width = 10 - - # design/tokens.css -> VAL[--name] = raw value, comments stripped. - body = "" - while ((getline line < tokens) > 0) body = body line "\n" - close(tokens) - while (match(body, /\/\*/)) { - head = substr(body, 1, RSTART - 1) - rest = substr(body, RSTART + 2) - if (!match(rest, /\*\//)) { body = head; break } - body = head substr(rest, RSTART + 2) - } - # Hand-rolled split: BSD awk also breaks split(s, a, ";") on newlines, which - # truncates multi-line values such as --shadow-focus-ring. - n = 0 - while ((p = index(body, ";")) > 0) { decls[++n] = substr(body, 1, p - 1); body = substr(body, p + 1) } - decls[++n] = body - for (i = 1; i <= n; i++) { - d = trim(decls[i]) - if (d !~ /^--[a-z0-9-]+[ \t]*:/) continue - p = index(d, ":") - name = trim(substr(d, 1, p - 1)) - if (!(name in VAL)) VAL[name] = trim(substr(d, p + 1)) - } -} - -trim($0) == "" { next } -trim($0) == "== invariants ==" { section = "invariants"; next } -trim($0) == "== aliases ==" { section = "aliases"; next } - -section == "cases" { - split($0, f, "|") - ncase++ - ctheme[ncase] = f[1]; cfg[ncase] = f[2]; cstack[ncase] = f[3]; cwant[ncase] = f[4] + 0 - if (length(f[2]) > width) width = length(f[2]) - next -} - -section == "aliases" { - split($0, f, "|") - nalias++ - alabel[nalias] = f[1]; atheme[nalias] = f[2]; atoken[nalias] = f[3]; atarget[nalias] = f[4] - next -} - -{ - split($0, f, "|") - ninvariant++ - ilabel[ninvariant] = f[1]; itheme[ninvariant] = f[2]; ifg[ninvariant] = f[3] - istack[ninvariant] = f[4]; ikind[ninvariant] = f[5]; ithreshold[ninvariant] = f[6] + 0 -} - -END { - if (!quiet) printf "%-6s %-*s %-46s %6s %6s\n", "theme", width, "foreground", "on", "got", "doc" - - for (i = 1; i <= ncase; i++) { - got = measure(ctheme[i], cfg[i], cstack[i]) - on = cstack[i]; gsub(/>/, " over ", on) - drift = (got - cwant[i] < -TOLERANCE || got - cwant[i] > TOLERANCE) - if (drift) failures[++nfail] = ctheme[i] ": " cfg[i] " on " on " is " sprintf("%.2f", got) ", colors.md says " sprintf("%.2f", cwant[i]) - if (!quiet) printf "%-6s %-*s %-46s %6.2f %6.2f%s\n", ctheme[i], width, cfg[i], on, got, cwant[i], drift ? " <-- drift" : "" - } - - for (i = 1; i <= ninvariant; i++) { - got = measure(itheme[i], ifg[i], istack[i]) - ok = (ikind[i] == "min") ? (got >= ithreshold[i]) : (got <= ithreshold[i]) - if (!ok) failures[++nfail] = "invariant broken: " ilabel[i] " (" sprintf("%.2f", got) " is " (ikind[i] == "min" ? "below " : "above ") sprintf("%.2f", ithreshold[i]) ")" - } - - for (i = 1; i <= nalias; i++) { - if (resolve(atoken[i], atheme[i], 0) != resolve(atarget[i], atheme[i], 0)) - failures[++nfail] = "alias rule broken: " alabel[i] " (" atoken[i] " no longer resolves to " atarget[i] ")" - } - - check_ring() - - if (!quiet) printf "\n%d documented ratios, %d invariants, %d alias rules, %d focus-ring bands\n", ncase, ninvariant, nalias, 2 - - if (nfail) { - print "\nFAIL" - for (i = 1; i <= nfail; i++) print " " failures[i] - print "\nUpdate design/colors.md, or revert the token change." - exit 1 - } - - if (!quiet) print "OK: the " ncase " ratios equal the values recorded here (the colors.md Contrast table), and the " ninvariant " invariants, " nalias " alias rules and focus-ring bands hold for design/tokens.css" -} -' diff --git a/scripts/verify-design.sh b/scripts/verify-design.sh index 6d839c7..951c0d6 100755 --- a/scripts/verify-design.sh +++ b/scripts/verify-design.sh @@ -1,20 +1,25 @@ #!/usr/bin/env bash -# Enforces the two rules design/AGENTS.md leaves to a machine. +# shellcheck disable=SC2016 # awk programs and tables in single quotes: awk expands them, not the shell +# Enforces the three rules design/AGENTS.md leaves to a machine. # # 1. Mirror parity. design/tokens.css is canonical; DesignTokens.swift and the # asset catalog color sets are hand-written mirrors. Every shared value # must be equal in all three, every token must either be mirrored or # listed as deliberately not mirrored. -# 2. No literals. Web CSS and the app write tokens, not hex, px, ms, raw +# 2. Contrast. Every ratio design/colors.md claims is recomputed from +# tokens.css (WCAG 2.x relative luminance, alpha composited onto the +# surface underneath), and the seven rules under the table hold as +# invariants. Edit a green step and this names the rule you just broke. +# 3. No literals. Web CSS and the app write tokens, not hex, px, ms, raw # system colors or spring presets (design/web.md -> The only literals web # may write, design/swiftui.md -> Allowed literals). # # scripts/verify-design.sh # findings as path:line: message, exit 1 on any # scripts/verify-design.sh --quiet # findings and FAIL lines only, no OK lines # -# Pure bash and awk on purpose, like verify-contrast.sh: no toolchain, so CI -# runs it on a bare runner. Only POSIX awk features are used (BSD awk on macOS, -# mawk on the Ubuntu runner): no interval expressions, no gawk extensions. +# Pure bash and awk on purpose: no toolchain, so CI runs it on a bare runner. +# Only POSIX awk features are used (BSD awk on macOS, mawk on the Ubuntu +# runner): no interval expressions, no gawk extensions. # Exit 0 clean, 1 findings, 2 usage or unreadable input. set -euo pipefail cd "$(dirname "$0")/.." @@ -30,8 +35,10 @@ export LC_ALL=C TOKENS=design/tokens.css SWIFT_TOKENS=app/CrocApp/Support/DesignTokens.swift COLORSETS=app/CrocApp/Assets.xcassets +COLORS_MD=design/colors.md [ -r "$TOKENS" ] || { echo "FAIL: $TOKENS not readable" >&2; exit 2; } [ -r "$SWIFT_TOKENS" ] || { echo "FAIL: $SWIFT_TOKENS not readable" >&2; exit 2; } +[ -r "$COLORS_MD" ] || { echo "FAIL: $COLORS_MD not readable" >&2; exit 2; } [ -d "$COLORSETS" ] || { echo "FAIL: $COLORSETS not a directory" >&2; exit 2; } # --------------------------------------------------------------------------- @@ -134,6 +141,82 @@ NOT_MIRRORED=' ^--color-status-(success|warning|error|info)$ ' +# --------------------------------------------------------------------------- +# Contrast table. Above the first marker: every row design/colors.md asserts, +# as theme|foreground|background stack|expected. The stack composites front to +# back, so a translucent tint names the opaque surface under it. Each expected +# ratio must also appear in the Contrast section of design/colors.md: keep the +# two in step. Below the first marker: rules that must hold whatever the values +# are, as label|theme|foreground|stack|min or max|threshold. Below the second +# marker: alias rules, label|theme|token|target, true when the token resolves +# to the same color as the target in that theme. The focus ring bands (rule 7) +# are parsed from --shadow-focus-ring, not listed here. +# --------------------------------------------------------------------------- +CONTRAST_TABLE=' +light|--color-accent|--color-surface-base|3.41 +light|--color-accent|--color-surface-grouped|3.06 +light|--color-accent|--color-accent-tint>--color-surface-base|2.99 +light|--color-accent-text|--color-surface-base|4.94 +light|--color-accent-text|--color-surface-grouped|4.42 +light|--color-accent-text|--color-accent-tint>--color-surface-base|4.32 +light|--color-accent-text|--color-surface-card|4.94 +light|--color-accent-text-on-tint|--color-surface-base|7.36 +light|--color-accent-text-on-tint|--color-surface-grouped|6.60 +light|--color-accent-text-on-tint|--color-accent-tint>--color-surface-base|6.45 +light|--label-2|--color-surface-base|3.44 +light|--label-2-web|--color-surface-base|5.16 +light|--label-2-web|--color-surface-grouped|4.85 +light|--color-text-secondary|--color-accent-tint>--color-surface-base|4.78 +light|--label-3|--color-surface-base|1.73 +light|--color-on-accent|--color-accent|3.41 +light|--color-on-status|--color-status-error|3.55 +light|--color-status-success|--color-status-success-tint>--color-surface-base|1.98 +light|--color-status-warning|--color-status-warning-tint>--color-surface-base|1.96 +light|--color-status-error|--color-status-error-tint>--color-surface-base|2.94 +light|--color-status-success|--color-surface-base|2.22 +light|--color-status-warning|--color-surface-base|2.20 +light|--color-status-error|--color-surface-base|3.55 +light|--color-status-info|--color-surface-base|4.02 +light|--glass-tint-accent|--color-surface-base|1.14 +dark|--color-accent|--color-surface-base|9.44 +dark|--color-accent|--color-surface-card|7.65 +dark|--color-accent|--color-accent-tint>--color-surface-base|7.72 +dark|--color-on-accent|--color-accent|7.19 +dark|--color-on-status|--color-status-error|3.41 +dark|--label-2|--color-surface-base|6.36 +dark|--color-status-success|--color-status-success-tint>--color-surface-base|8.05 +dark|--color-status-warning|--color-status-warning-tint>--color-surface-base|7.93 +dark|--color-status-error|--color-status-error-tint>--color-surface-base|5.25 +dark|--color-status-info|--color-status-info-tint>--color-surface-base|4.92 +== invariants == +accent fill clears 3:1 on base|light|--color-accent|--color-surface-base|min|3.0 +accent-text clears AA on base|light|--color-accent-text|--color-surface-base|min|4.5 +accent-text-on-tint clears AA on grouped|light|--color-accent-text-on-tint|--color-surface-grouped|min|4.5 +accent-text-on-tint clears AA on tint|light|--color-accent-text-on-tint|--color-accent-tint>--color-surface-base|min|4.5 +web secondary clears AA on grouped|light|--label-2-web|--color-surface-grouped|min|4.5 +filled prominent clears 3:1|light|--color-on-accent|--color-accent|min|3.0 +filled destructive clears 3:1, light|light|--color-on-status|--color-status-error|min|3.0 +filled destructive clears 3:1, dark|dark|--color-on-status|--color-status-error|min|3.0 +focus ring outer band clears 3:1 on base|light|--color-accent|--color-surface-base|min|3.0 +focus ring inner band clears 3:1 on the accent fill|light|--color-surface-base|--color-accent|min|3.0 +tertiary is NOT a usable text color|light|--label-3|--color-surface-base|max|3.0 +secondary text clears AA on the accent tint (rule 4)|light|--color-text-secondary|--color-accent-tint>--color-surface-base|min|4.5 +accent-text clears AA on a card|light|--color-accent-text|--color-surface-card|min|4.5 +the accent tint is NOT a visible focus indicator (rule 7)|light|--glass-tint-accent|--color-surface-base|max|1.5 +dark status success clears AA on its own tint|dark|--color-status-success|--color-status-success-tint>--color-surface-base|min|4.5 +dark status warning clears AA on its own tint|dark|--color-status-warning|--color-status-warning-tint>--color-surface-base|min|4.5 +dark status error clears AA on its own tint|dark|--color-status-error|--color-status-error-tint>--color-surface-base|min|4.5 +dark status info clears AA on its own tint|dark|--color-status-info|--color-status-info-tint>--color-surface-base|min|4.5 +== aliases == +rule 4: secondary text is label-2-web, light|light|--color-text-secondary|--label-2-web +rule 4: secondary text is label-2-web, dark|dark|--color-text-secondary|--label-2-web +rule 6: link is accent-text, light|light|--color-text-link|--color-accent-text +rule 6: link is accent-text, dark|dark|--color-text-link|--color-accent-text +dark accent collapses to green-400|dark|--color-accent|--green-400 +dark accent-text collapses to green-400|dark|--color-accent-text|--green-400 +dark accent-text-on-tint collapses to green-400|dark|--color-accent-text-on-tint|--green-400 +' + # --------------------------------------------------------------------------- # No-literals allowlists. path ERE @@ source-line ERE @@ reason. A finding is # suppressed only when both match, so an allowance cannot silently widen to a @@ -229,7 +312,7 @@ function swift_clean(path, n, raw, out, i, j, len, c, c2, c3, d, in_block, in_ function count_of(line, ch, t) { t = line; return gsub(ch, "", t) } -# rgba of a token or literal: "r g b a", channels 0-255, alpha 0-1. +# Split on top-level commas only, so a nested rgba(...) survives intact. function split_top(s, out, i, ch, depth, cur, n) { depth = 0; cur = ""; n = 0 for (i = 1; i <= length(s); i++) { @@ -254,15 +337,9 @@ function hex2dec(h, i, c, d, n) { return n } -function die(msg) { print "FAIL: " msg > "/dev/stderr"; internal = 1; exit 2 } -' - -# --------------------------------------------------------------------------- -# Phase 1: mirror parity -# --------------------------------------------------------------------------- -AWK_PARITY=' -function finding(path, line, msg) { print path ":" line ": " msg; nfind++ } +function die(msg) { print "FAIL: " msg > "/dev/stderr"; exit 2 } +# Returns "r g b a": channels 0-255, alpha 0-1. function parse_color(v, theme, depth, args, n, inner, hex, parts, cnt, a) { v = trim(v) if (depth > 16) die("token recursion too deep at " v) @@ -325,9 +402,16 @@ function load_tokens(path, body, n, pieces, i, d, p, name, cum, lead, k) { cum += nl_count(pieces[i]) } } +' + +# --------------------------------------------------------------------------- +# Phase 1: mirror parity +# --------------------------------------------------------------------------- +AWK_PARITY=' +function finding(path, line, msg) { print path ":" line ": " msg; nfind++ } # DesignTokens.swift -> SW[Enum.name] (whole statement), SWLINE[...] -function load_swift(path, n, i, line, depth, sp, STK, c, k, stmt, j, pd, bd, name, pend, key) { +function load_swift(path, n, i, line, depth, sp, c, k, stmt, j, pd, bd, name, pend, key) { n = swift_clean(path) if (n < 0) die(path " not readable") depth = 0; sp = 0; pend = "" @@ -421,7 +505,7 @@ function comp(v, is_alpha) { return v + 0 } -function load_colorset(set, path, line, rc, mode, skip, r, g, b, a, rline, val, have) { +function load_colorset(set, path, line, rc, mode, skip, r, g, b, a, rline, have) { path = colorsets "/" set ".colorset/Contents.json" mode = "any"; skip = 0; have = 0 while ((rc = (getline line < path)) > 0) { @@ -501,7 +585,168 @@ BEGIN { ' # --------------------------------------------------------------------------- -# Phase 2a: web no-literals lint +# Phase 2: contrast +# --------------------------------------------------------------------------- +AWK_CONTRAST=' +function fail(msg) { failures[++nfail] = msg } + +# Source-over. bg is opaque by the time this runs. +function composite(fg, bg, f, b, a, i, out) { + split(fg, f, " "); split(bg, b, " ") + a = f[4] + out = "" + for (i = 1; i <= 3; i++) out = out (f[i] * a + b[i] * (1 - a)) " " + return out "1" +} + +# Resolve a background stack (front to back, ">"-separated) to one opaque color. +function flatten(stack, theme, names, n, i, result, layer, c) { + n = split(stack, names, ">") + result = resolve(names[n], theme, 0) + split(result, c, " ") + if (c[4] + 0 < 1) die("bottom of stack " names[n] " is not opaque") + for (i = n - 1; i >= 1; i--) { + layer = resolve(names[i], theme, 0) + result = composite(layer, result) + } + return result +} + +function channel(v) { + v /= 255 + return (v <= 0.03928) ? v / 12.92 : ((v + 0.055) / 1.055) ^ 2.4 +} + +function luminance(col, c) { + split(col, c, " ") + return 0.2126 * channel(c[1]) + 0.7152 * channel(c[2]) + 0.0722 * channel(c[3]) +} + +function measure(theme, fg_name, stack, bg, fg, f, la, lb, hi, lo) { + bg = flatten(stack, theme) + fg = resolve(fg_name, theme, 0) + split(fg, f, " ") + if (f[4] + 0 < 1) fg = composite(fg, bg) + la = luminance(fg); lb = luminance(bg) + hi = (la > lb) ? la : lb + lo = (la > lb) ? lb : la + return (hi + 0.05) / (lo + 0.05) +} + +# Rule 7: --shadow-focus-ring is two bands, the base surface inside (2px) and +# the accent outside (4px). The contrast rows prove each band reads against the +# other and the accent; this proves the token still is that ring. +function check_ring( ring, n, bands, k, parts, np, spread, color, want_spread, want_color) { + if (!("--shadow-focus-ring" in VAL)) { fail("--shadow-focus-ring is not defined in " tokens); return } + ring = VAL["--shadow-focus-ring"] + n = split_top(ring, bands) + if (n != 2) { fail("--shadow-focus-ring must have exactly two bands, has " n); return } + for (k = 1; k <= 2; k++) { + want_spread = (k == 1) ? "2px" : "4px" + want_color = (k == 1) ? "var(--color-surface-base)" : "var(--color-accent)" + np = split(bands[k], parts, " ") + spread = parts[4]; color = parts[np] + if (np != 5 || parts[1] != "0" || parts[2] != "0" || parts[3] != "0" || spread != want_spread || color != want_color) + fail("--shadow-focus-ring band " k " is \"" bands[k] "\", rule 7 needs \"0 0 0 " want_spread " " want_color "\"") + } +} + +# The Contrast section of colors.md: from its heading to the next `## ` line. +function contrast_section(path, lines, n, i, in_sec, out) { + n = split(read_file(path), lines, "\n") + if (lines[1] == "\001unreadable") die(path " not readable") + in_sec = 0; out = "" + for (i = 1; i <= n; i++) { + if (lines[i] ~ /^## Contrast/) in_sec = 1 + else if (lines[i] ~ /^## /) in_sec = 0 + if (in_sec) out = out lines[i] "\n" + } + return out +} + +# True when num appears in text as a whole number: no digit or "." before it +# (so 3.41 is not found in 13.41), no digit after it. +function has_number(text, num, rest, p, off, before, after) { + rest = text; off = 0 + while ((p = index(rest, num)) > 0) { + before = (off + p > 1) ? substr(text, off + p - 1, 1) : "" + after = substr(text, off + p + length(num), 1) + if (before !~ /[0-9.]/ && after !~ /[0-9]/) return 1 + off += p + rest = substr(rest, p + 1) + } + return 0 +} + +BEGIN { + TOLERANCE = 0.01 + tokens = ENVIRON["TOKENS"]; quiet = (ENVIRON["QUIET"] == "1") + width = 10 + + load_tokens(tokens) + + section = "cases" + n = split(ENVIRON["CONTRAST_TABLE"], rows, "\n") + for (i = 1; i <= n; i++) { + if (trim(rows[i]) == "") continue + if (trim(rows[i]) == "== invariants ==") { section = "invariants"; continue } + if (trim(rows[i]) == "== aliases ==") { section = "aliases"; continue } + split(rows[i], f, "|") + if (section == "cases") { + ncase++ + ctheme[ncase] = f[1]; cfg[ncase] = f[2]; cstack[ncase] = f[3]; cwant[ncase] = f[4] + 0 + if (length(f[2]) > width) width = length(f[2]) + } else if (section == "aliases") { + nalias++ + alabel[nalias] = f[1]; atheme[nalias] = f[2]; atoken[nalias] = f[3]; atarget[nalias] = f[4] + } else { + ninvariant++ + ilabel[ninvariant] = f[1]; itheme[ninvariant] = f[2]; ifg[ninvariant] = f[3] + istack[ninvariant] = f[4]; ikind[ninvariant] = f[5]; ithreshold[ninvariant] = f[6] + 0 + } + } + + doc = contrast_section(ENVIRON["COLORS_MD"]) + if (doc == "") fail(ENVIRON["COLORS_MD"] ": has no Contrast section") + + if (!quiet) printf "%-6s %-*s %-46s %6s %6s\n", "theme", width, "foreground", "on", "got", "doc" + + for (i = 1; i <= ncase; i++) { + got = measure(ctheme[i], cfg[i], cstack[i]) + on = cstack[i]; gsub(/>/, " over ", on) + drift = (got - cwant[i] < -TOLERANCE || got - cwant[i] > TOLERANCE) + if (drift) fail(ctheme[i] ": " cfg[i] " on " on " is " sprintf("%.2f", got) ", colors.md says " sprintf("%.2f", cwant[i])) + if (!quiet) printf "%-6s %-*s %-46s %6.2f %6.2f%s\n", ctheme[i], width, cfg[i], on, got, cwant[i], drift ? " <-- drift" : "" + if (doc != "" && !has_number(doc, sprintf("%.2f", cwant[i]))) + fail(ENVIRON["COLORS_MD"] ": " sprintf("%.2f", cwant[i]) " (" ctheme[i] " " cfg[i] " on " cstack[i] ") is in the verify-design.sh contrast table but not in the Contrast section: keep the two in step") + } + + for (i = 1; i <= ninvariant; i++) { + got = measure(itheme[i], ifg[i], istack[i]) + ok = (ikind[i] == "min") ? (got >= ithreshold[i]) : (got <= ithreshold[i]) + if (!ok) fail("invariant broken: " ilabel[i] " (" sprintf("%.2f", got) " is " (ikind[i] == "min" ? "below " : "above ") sprintf("%.2f", ithreshold[i]) ")") + } + + for (i = 1; i <= nalias; i++) { + if (resolve(atoken[i], atheme[i], 0) != resolve(atarget[i], atheme[i], 0)) + fail("alias rule broken: " alabel[i] " (" atoken[i] " no longer resolves to " atarget[i] ")") + } + + check_ring() + + if (nfail) { + for (i = 1; i <= nfail; i++) print failures[i] + printf "contrast: FAIL (%d finding%s)\n", nfail, (nfail == 1 ? "" : "s") + print "Update design/colors.md, or revert the token change." + exit 1 + } + if (!quiet) + printf "contrast: OK (%d documented ratios, %d invariants, %d alias rules, 2 focus-ring bands)\n", ncase, ninvariant, nalias +} +' + +# --------------------------------------------------------------------------- +# Phase 3a: web no-literals lint # --------------------------------------------------------------------------- AWK_WEB=' function allowed(path, text, n, i, parts, rows) { @@ -553,7 +798,7 @@ function blank_preludes(s, i, c, len, seg, out) { # CM[line, col] = 1 for every character inside a color-mix( ... ) call, across # lines: the only place web.md lets a percentage and `black` appear (components.md # -> Button specs). -function mark_color_mix(text, i, c, len, line, col, depth, want_open, k) { +function mark_color_mix(text, i, c, len, line, col, depth) { delete CM line = 1; col = 0; depth = 0; len = length(text) for (i = 1; i <= len; i++) { @@ -574,7 +819,7 @@ function is_named_color(w) { return index(" " NAMED " ", " " tolower(w) " ") > 0 } -function lint_text(path, text, line0, use_ex, n, T, ln, t, rest, tok, prev, nextc, v, hexlen, pos, col, W, nw, k, off, before) { +function lint_text(path, text, line0, use_ex, n, T, ln, t, rest, tok, prev, nextc, v, hexlen, col, off, before) { if (NAMED == "") NAMED = "aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen" mark_color_mix(text) n = split(text, T, "\n") @@ -666,7 +911,7 @@ function lint_style_attrs(path, text, re, q, rest, base, pre, m, qi, body, pre } # Every