Skip to content

feat: swift client - #1

Merged
tmm merged 105 commits into
mainfrom
swift-client
Oct 21, 2026
Merged

tmm merged 105 commits into
mainfrom
swift-client

Conversation

@tmm

@tmm tmm commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

A Swift client for Zero on iOS and macOS, with parity to the JS client, checked against conformance vectors generated from the JS implementation.

Draft: the last milestone (V5, zbugs-swift on the virtual list) is finished on a Mac but not pushed yet. It will land on this branch before this is ready.

What's here

  • swift/: the Zero Swift package. It has the client (connection, mutations, queries and IVM), ZeroUI (@ZeroQuery for SwiftUI), ZeroVirtual (virtual lists: VirtualList for SwiftUI and VirtualListView for UIKit and AppKit, a counterpart of @rocicorp/zero-virtual), ZeroTesting, and TestHost, a test app for the virtual lists.
  • packages/zero-conformance: vectors generated from the JS client and ZQL, which the Swift tests replay.
  • packages/zero-swift-cli: generates Swift schema, query and mutator types from a Zero schema.
  • apps/zbugs-swift: zbugs as a SwiftUI app for iPhone, iPad and macOS, with its own backend for CI.

Changes outside those directories

  • New workflows: swift.yml (Linux build and tests, the conformance vectors, end-to-end tests), swift-macos.yml (macOS, the iOS simulator and the zbugs build), zbugs-screenshots.yml (zbugs and virtual-list UI tests in simulators and on macOS), and swift-release.yml (Changesets version the Swift client; a release tags swift/ on the wevm/zero-swift mirror and publishes the zero-swift CLI to npm, after approval in the swift-release environment). See .changeset/README.md.
  • .github/actions/playwright-install: the browser cache is now keyed by the OS release too. ubuntu-latest moved to Ubuntu 26.04, and a WebKit cached on 24.04 failed Public Test on main at b39e85c.
  • apps/zbugs/api/index.ts: the GitHub login callback now redirects to zbugs-swift://auth?jwt=… when that is the requested redirect, because the app's web authentication session can't read the cookie. This is safe because ASWebAuthenticationSession hands the callback only to the app that started it, even if another app registers the scheme, and the app doesn't register the scheme with the system. A comment in the code says so.
  • apps/zbugs/scripts/dev-token.ts (pnpm dev-token <userID> [crew|user]): prints a development JWT for clients that can't log in through GitHub. The package.json and tsconfig.scripts.json changes register it.
  • Formatter ignores for generated Swift, .agents/setup for orbs, and pnpm-lock.yaml.

Testing

All of this runs in CI. The virtual list's per-frame probe reports 0 wrong frames on a physical iPhone 15 Pro, the iOS simulator and macOS.

ampagent and others added 30 commits October 19, 2026 04:45
Record the scoping decisions for a native Swift Zero client on iOS 26
and macOS 26: architecture, API, storage, lifecycle, conformance suite,
drift harness, distribution, and milestones M0-M8.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Add the swift/ package with Zero, ZeroUI, and ZeroTesting targets, the
private zero-conformance package that emits vectors from the JS
implementation, and workflows for Linux, macOS, and the SwiftPM mirror.
The orb setup installs the Swift 6.2.4 toolchain.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Add the Swift JSONValue model with a JSON.parse-compatible parser and a
JSON.stringify-compatible serializer, JS number formatting and parsing,
UTF-8 and UTF-16 string orders, xxHash32 (h32/h64/h128), the AST with
normalization and query hashing, value comparison, primary-key storage
keys, LIKE/ILIKE, and TTL. Each is checked against vectors that the
zero-conformance package generates from the JS implementation; seeded
fuzz corpora are generated in CI and shared with the Swift job.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
…uilder

Port the client IVM engine 1:1 from packages/zql: MemorySource with its
B+tree indexes and push overlays, Filter/Exists/FanOut/FanIn, Join,
FlippedJoin, UnionFanOut/In, Take, Skip, Cap, TakeGate, the pipeline
builder with correlated predicate pushdown, ArrayView change application,
and the untyped AnyQuery builder.

The zero-conformance package gains IVM vectors generated by running the JS
builder and engine: 34 curated programs and a seeded fuzz corpus, each
replaying a push sequence and recording the view and the changes it
receives. Swift replays the same programs through its builder and engine
and must match the AST, format, view, and changes at every step.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Add Swift types for every upstream and downstream message of
packages/zero-protocol, with their bodies, the error kind, origin and
reason enums, binary poke chunk decoding, the Sec-WebSocket-Protocol
encoding, and normalizeClientSchema. Decoding accepts and rejects what
valita does in passthrough mode. The types drop unknown keys, as strip
mode does.

Valita behaviors that the types reproduce:
- Upstream is an open tagged tuple union. A message with an unknown
  string tag decodes to .unrecognized, kept as sent. Downstream is
  closed.
- ASTs sent by clients use strictASTSchema: no unknown keys, and at most
  50 levels deep. ASTs from zero-cache are lenient.
- Mutation results follow the JS client: it parses in passthrough mode
  and then checks 'error' in result. Valita returns just {data} once
  data is present, so a result with data is OK even when it also has an
  error key.
- pushResponse tries the OK variant first. jsonObjectSchema (rows patch
  merge) also accepts arrays.
- Inspect bodies are checked by a small valita interpreter and kept as
  stripped JSON.

The new zero-conformance generator emits vectors for downstream and
upstream messages (hand-written, seeded random, and randomly edited),
poke chunks split at seeded byte boundaries, sec protocols, and client
schema normalization. Larger fuzz corpora go to generated/.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Node 24.22's V8 misreads the escaped key in `{"\n":2}` as a backslash once it
has parsed an object keyed by a backslash, so the poke-chunk fuzz corpus CI
generated on Node 24 held wrong expectations. Generate vectors with the Node
in .nvmrc, and refuse to emit vectors on a runtime whose JSON.parse fails the
probe.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Add the transport protocol the engine talks through, the sync connect URL,
client-to-server name mapping, and the client schema the handshake sends.

A Linux end-to-end suite connects to zero-cache over a test-only SwiftNIO
WebSocket transport, asks for a named query, and checks the rows it receives
through binary poke chunks. packages/zero-conformance/src/e2e runs the server
side: a Postgres database loaded from a fixture, an API server for the app's
named queries and mutators, and zero-cache from source. CI runs it on the
Ubuntu runner's Postgres.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Add the typed layer over AnyQuery: generated row structs conform to ZeroRow
and name their columns and relationships by key path, Query<Row, Result>
builds with key paths and a `$0.column >= value` expression DSL, and views
decode into the row structs. Column values go through ZeroValue, with string
enums, integer columns, and typed JSON objects.

packages/zero-swift-cli is the `zero-swift codegen` command. It loads the TS
schema, reads enumerations and json<T>() types with the TypeScript checker,
and emits the row structs, their enum and JSON types, the ZeroSchema, and the
`zql` builder. The Swift tests compile the Swift it generates for the zql
test schema and a fixture that covers keywords, reserved names, shared enums,
nested JSON, integer overrides, compound keys, and two-hop relationships; CI
checks the checked-in output is current.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Runs 127 scenarios (connection, errors, pokes, queries, mutations)
against TestZero over a MockSocket with fake timers and memory storage,
and records what the app and the server observe after every step. The
Swift client replays them in M4.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Port the zero-client engine: the connection run loop and state machine,
the replica with its pending mutations and rebase, the pusher, the query
manager with TTLs and got queries, the poke handler, the mutation tracker,
auth and recovery. It runs synchronously on the main actor: an event loop
reproduces JS microtask order, timers come from an injected Scheduler, and
sockets are callback-based. ZeroTesting gains ManualScheduler (fake timers)
and MockSocket.

The public API is Zero<Context> with defineQuery/defineMutator; mutator
bodies are plain throws. A view is destroyed when its last reference goes.

All 127 client scenario vectors from the JS client pass.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
The client harness gains `restart`: close the client and start a new one on
the same in-memory storage, optionally later or with other options or
mutators. New scenarios cover what a relaunched client starts from (rows,
cookie, got queries, pending mutations of earlier clients), client group
selection by mutator names, storage names, client GC with deleteClients, and
the server errors that disable a client group or drop the store.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
The replica is stored per JS database name in SQLite (or in process memory
with ZeroStorage.memory): client groups, clients and their heartbeats,
deleted clients, and each group's snapshot and pending mutations. A new
client joins the group with its mutator names and replays the pending
mutations; writes go through on a background queue in order. Heartbeats,
client and client group GC, deleteClients, collecting unused databases,
disabling a group or dropping the store on server errors, and the memory
fallback with onStorageFailure follow Replicache.

All 144 client scenarios pass on memory and on SQLite. Definition overloads
now let a context-free app write `(tx, args)`; NoArgs marks a no-argument
definition that reads the context.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
The harness stubs document with one whose visibility a step sets, and passes
hiddenTabDisconnectDelay through, so the client's background and foreground
behavior is recorded from the JS client like the rest.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
- Logging: LogSink and logLevel (default warn) at the JS client's log points;
  os.Logger on Apple platforms, stderr elsewhere.
- Views: snapshots with row diffs (listener and AsyncStream); LiveQuery, an
  @observable typed query; Connection is @observable with updateNeeded.
- Recovery: without onClientStateNotFound, Zero replaces its client in place
  and moves views, live queries, and preloads to it.
- Visibility: disconnect in the background, reconnect in the foreground
  (hiddenTabDisconnectDelay semantics); reconnect without the backoff when the
  network is back; wait for pending mutations before suspension.
- Apple: URLSessionTransport (the default socket factory), the UIKit/AppKit
  lifecycle and NWPathMonitor hooks, a SwiftUI @ZeroQuery and environment, and
  a diffable data source adapter.
- Transport frames are written by one task, so they keep their order.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
DynamicProperty is not main-actor isolated, so ZeroQuery conforms with
@preconcurrency; AppKit snapshots reload changed rows, having no reconfigure.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Syncs a named query, runs mutations optimistically until the API server
confirms them, sees them from a second client, and relaunches on the same
SQLite store. The test schema is generated for the E2E target too.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
zero-swift drift record runs an app's TS queries and mutators on samples
in offline clients and records their ASTs, outcomes, and row changes.
Drift.failures (ZeroTesting) replays the record against the Swift port.

Query gains a partial-row start, a where that takes nil as JS takes
undefined, and and/or over optional conditions; escapeLike is ported.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
apps/zbugs-swift ports apps/zbugs/shared to the Swift client and checks
the port against the TS definitions on 37 query and 38 mutator samples.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
The drift recorder runs each mutator sample again with the clock a day
later and fails when the runs differ. DESIGN.md records the M6 and M7
decisions.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
…port

zbugs' Postgres allows nulls in columns its schema says can't be null, and
JS shows those rows. Swift failed to decode them, which emptied the whole
view. A null in a non-optional String, Double, Int, or Bool column now
reads as "", 0, or false; enums and JSON types still fail.

NIOTransport moves from the E2E tests into a ZeroNIOTesting product, so
apps can run their own end-to-end tests on Linux, where corelibs'
URLSessionWebSocketTask never completes a connection.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
The GitHub login sets the JWT as a cookie, which the Swift app's web
authentication session can't read. With redirect=zbugs-swift://auth the
API redirects there with the JWT in the query instead.

`pnpm dev-token <userID> [crew|user]` signs a JWT with the dev key, for
clients that can't log in through GitHub in development and tests.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
ZBugsUI has the app's screens: the issue list with zbugs' filters and
sorts, issue detail with comments, and creating, editing, closing, and
commenting, all through the ported queries and mutators. The list also
comes in UIKit and AppKit through DiffableAdapter, picked in the sidebar.

Login takes a zbugs JWT, from the GitHub login through a web
authentication session or from `pnpm dev-token`, and keeps it in the
keychain. App/ is an XcodeGen project with one multiplatform target, which
macOS CI builds for the iOS simulator and macOS.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
server/server.ts runs zbugs' backend from this repository's sources: a
fresh database with zbugs' migrations and seed data, the API server, and
zero-cache. BackendTests lists, creates, and comments through it, so the
API server runs the TS mutators and queries next to the Swift ones, and
checks that a second client sees the result and that both sides report
zbugs' errors alike. The Linux end-to-end job runs it.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
- The mutation tracker found a mutation by scanning the outstanding ones,
  so mutations slowed down as they piled up (229 µs each over a second of
  them, now 21 µs). It keeps them by ID, as the JS Map does.
- A live query read every row again on every change. It now reuses the
  rows of unchanged entries, matched in order (92 µs instead of about
  640 µs per update of a 1000-row list).
- B-tree nodes skip dynamic exclusivity checks (the tree copies shared
  nodes before writing them), and the forward iterator reads a leaf's keys
  once rather than per element: a 1000-element scan went from 23 µs to
  2.7 µs, and views hydrate faster. The tree is inlinable, so other
  modules get it specialized.
- A view's hydration takes its row list out once, not once per row.
- UTF-8 string comparison and code unit equality compare bytes.
- Reading a column is inlinable and specialized into the row structs.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
ZeroBenchmarks ports zero.bench.ts and the shared B-tree and hash
benchmarks under their names, and adds the Swift client's own: memory for
100k rows, hydration of up to 100k rows (as structs too), pokes, and
mutations including the client's side of a round trip.
`swift run -c release ZeroBenchmarks [filter…]`.

packages/zero-conformance has the JS side of those that have one
(`pnpm --filter zero-conformance bench`). BENCHMARKS.md has both sets of
numbers from one machine: Swift is faster on all the client benchmarks and
B-tree scans, and slower on B-tree lookups.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
The IVM operators, wire-protocol bodies, B-tree, hashes, and JSON
machinery were public, so apps (and the docs) saw ~1,700 symbols. They
are package now: the package's tests, ZeroTesting, and the benchmarks
still see them; apps see what they and the generated code use (~720).

ErroredQuery and escapeLike stay public (views report the one; zbugs
uses the other). zbugs reads JWT claims with JSONDecoder instead of the
engine's parser.

A package type with @inlinable members has to be @usableFromInline, or
other modules quietly get unspecialized calls: BTreeSet<Int> scans from
the benchmarks were 5x slower until it was.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
ampagent and others added 24 commits October 21, 2026 00:19
Each row is hosted in its own hosting view, cut off from the list's
environment, so a @ZeroQuery in a row had no client and stayed empty
without an error. Rows now get the list's .zero(_:) client.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
…'s frame timing

On the iOS simulator, real-fling tests failed the momentum check in their
first frames ("speed 4590 between 10413.9 and 4290"), while no row was out
of place: the scroll offset and the rows moved together in every frame.
The simulator's display link fires on time, but its callbacks come late
by varying amounts, so a frame's sample can be one frame ahead of its
timestamp, and the next pair's speed spikes. Under a fast fling,
deceleration alone also moves a pair's speed by more than the fixed 300
pt/s band.

A pair's speed is now compared with the mean of its neighbors', within
300 pt/s or 15 % of it, and frames within two of one whose callback came
more than half a frame later than the last are skipped, as late frames
already were. Replayed on the iPhone traces, it still finds bf41176's
jump under a real fling.
…hey are

Rows' hosting views already inherit the list's environment, Zero client
and custom values alike (checked on macOS and the iOS 26.5 simulator), so
0ff3f12's explicit .environment(\.zero, zero) was redundant. It goes, and
so does ZeroVirtual's dependency on ZeroUI; the doc comment now says rows
inherit. The test stays, guarding the inheritance.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
…lGeometryHost goes

VirtualList lays its rows out with LayoutHost since a018d98, and nothing
used ScrollGeometryHost. Its scenario tests (paging, taller rows, stick
to bottom, a far jump, flings, a fling to the top from a permalink) now
drive LayoutHost through a fake view that runs its layout passes, and
check every pass, not only where the list settles: a row the test holds
is never drawn moved, the core never scrolls under a fling away from the
start, and no gap stands above the first row at the top. A new case
checks LayoutHost takes leftover space at the start out at once. The
tests of ScrollGeometryHost's own reporting go with it.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
93af68b committed the renamed ScrollGeometryHost tests, not their port to
LayoutHost, so nothing built. This is the port it describes.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
VirtualListView is VirtualList for UIKit and AppKit apps, with rows that
are the app's own views. It takes the same ZeroVirtualizer and a row
closure, (item, reusing) -> UIView/NSView, which makes a row's view or
shows the row in one another row left. Views of rows that leave are kept
hidden in one pool. A row whose data changes is shown again in its own
view. Heights come from Auto Layout at the list's width
(systemLayoutSizeFitting; on macOS, fittingSize with a width constraint,
after laying out at that width so wrapping labels wrap there), and
rowHeightDidChange(_:) is for rows that change height on their own.

It's the same native scroll view as VirtualList's, exposed as scrollView
for insets and delegates, so the rows' frames, the content size and the
offset still change in one layout pass. It follows the virtualizer while
it's in a window.

AppleTests open it in a window and scroll to a row far below, and check
that a reused view shows its new row; each fails when its part is undone
(not attaching; reusing a view without showing the new row in it).

Underneath, RowsLayout no longer hosts SwiftUI itself: how a row's view is
made, shown again when its row changes, and measured is a RowViews
(HostedRowViews for VirtualList, unchanged in behavior; AppRowViews here),
and the rest of the layout is the same for both.
… too

With -native 1, TestHost's chat and issues lists are VirtualListViews of
UIKit or AppKit rows (NativeRowView: two labels and the probe's marker,
one accessibility element like the SwiftUI rows). NativeVirtualListUITests
and NativeFrameProbeUITests run the same tests on them, and CI runs each
in its own job.
ubuntu-latest moved to Ubuntu 26.04, and the cache, keyed by Playwright's
version alone, restored a WebKit built for 24.04 that can't load
libicudata.so.74 there: Public Test failed on main at b39e85c.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
Git merged pnpm-lock.yaml without a conflict, but upstream's move to
esbuild 0.28.2 and vite 8.3.4 left the zero-swift-cli and
zero-conformance importers on the old versions, so a frozen install
failed. Those importers now take the new versions; nothing else in the
lockfile changes.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
…buntu 24.04

Public JS runs on pull requests into main, so PR #1 was the first to check
zero-swift's CLI:
- syncpack: its esbuild follows the workspace's 0.28.2. It stays on
  TypeScript 6, as it uses the compiler API at runtime, which TypeScript 7
  doesn't have, and its @rocicorp/zero peer range is the app's, not the
  workspace's version; both are recorded in .syncpackrc.
- oxfmt: zbugs-screenshots.yml formatted.
Public Test moves to ubuntu-24.04: ubuntu-latest is now 26.04, which
Playwright 1.60 has no browsers for.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
VirtualList and VirtualListView lay out a header and a footer in the same
pass as the rows: they're part of the content, before the space before the
rows and after the space after them, so scrollToItem and the anchoring
account for them.

A selection of one row by key (a Binding for VirtualList; selection and
onSelect for VirtualListView): a tap selects a row, even the selected one,
and while the list has focus the arrow keys and j and k move the selection
and scroll it into view. The list highlights the selected row in the
platform's colors, passes isSelected to the row, and marks it selected for
accessibility.
…ection

The UI tests check the header pushing the rows at the top and leaving them
in place above the screen, the footer after the last row, and selecting by
a tap and (on macOS) by the arrow keys and j and k. The frame probe's
Issues script grows the header at the top and above the screen and, on
macOS, moves the selection by the arrow keys past the screen's bottom.
… virtualizer over

Switching between two lists on one virtualizer (zbugs' SwiftUI and native
issue lists) can attach the new list before the old one leaves; the old
one then detached the virtualizer from the new list.
…they scroll

The issue list is a VirtualList of issueListV2 pages, set up as zbugs' list
page sets up useZeroVirtualizer, in place of its first 200 issues; the UIKit
and AppKit list is a VirtualListView in place of the DiffableAdapter table.
Both use the virtual list's selection for the split view's highlight, and
open an issue on a tap, even the selected one.

An issue's page is one VirtualList: the header and the description above
its comments, which page in as zbugs' comments do, and the composer below.

The selected issue and the list's scroll state are kept in SceneStorage: a
relaunch opens the list at the issue, as the web app's list permalink does,
or else where it was.

A UI test scrolls the list of all issues past 200 of them.
…ng title, and a new filter opens at its top

The cell's nested stack views squeezed the icon out beside a long title,
and measured some titles at one line: plain constraints instead. A filter
change cleared the selected issue's permalink, so the new list opened at
its top, not at an issue it may not have.
…ts rows offline

The list held off drawing its rows until it had landed at its target, and
it lands once the rows' queries complete. Offline, those queries never
complete, so the list stayed blank. It now draws once the viewport is
placed at the target, whether or not the queries have completed.

The offline zbugs screenshot runs skip the paging test, since the list
doesn't page past its first page offline.
…o npm

swift-mirror.yml becomes swift-release.yml. After the mirror job, it packs
packages/zero-swift-cli without credentials and publishes the tarball with
npm trusted publishing, in the swift-release environment. The npm jobs run
once the ZERO_SWIFT_NPM_PUBLISH variable is 'true', and the package version
must match swift/VERSION. The package is no longer private, and the mirror
is public.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
…ery row

When no row is on screen in both frames, the probe flagged a jump. A
stalled main thread (a slow CI simulator's) lets a fling run on by a
screenful or more between two frames, and UIScrollView catches up on the
frame after it, so neither frame shares a row with the one before. The
probe now compares the rows laid out in both frames by their content
positions: a scroll leaves them where they were, less what the list
compensated in that pass; a jump moves them. Up to a quarter of the
offset's change, or 50 pt, counts as a scroll. The flag reports dt, the
callback latency and the offset.
On iOS the arrow keys and j and k needed a hardware keyboard, and the
list took first responder on a tap only for them. The iOS list now has
no key commands and doesn't take first responder; a tap still selects a
row, highlights it, and calls onSelect, and the selection binding and
the accessibility traits are as they were. macOS keeps its keys.
…lish to npm by default

A changeset for zero-swift versions the CLI and the Swift package together:
pnpm changeset:version writes the CLI's version to swift/VERSION. On main,
swift-release.yml keeps a version PR open. With no changesets pending, it
releases the version when its mirror tag or npm package is missing, after
approval in swift-release. Each step skips what's already released, so a
failed run can be rerun. The ZERO_SWIFT_NPM_PUBLISH and mirror-token gates
are gone; a missing token or trusted publisher now fails the job.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​changesets/​cli@​3.0.3991007494100

View full report

@tmm
tmm marked this pull request as ready for review October 21, 2026 18:50
@tmm tmm changed the title feat: a Swift client for Zero (iOS and macOS) feat: swift client Oct 21, 2026
…prettierignore as upstream has them

.prettierignore goes back to upstream's: appending to it had joined its last
line to ours. Changesets runs through pnpm dlx, pinned at 3.0.3, from
zero-swift's version-packages script, so it's no longer a root dependency.

Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859
Co-authored-by: tmm <tmm@tmm.dev>
@tmm
tmm merged commit 799aafa into main Oct 21, 2026
20 of 21 checks passed
@tmm
tmm deleted the swift-client branch October 21, 2026 20:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants