Compare commits

...
405 Commits
Author SHA1 Message Date
Greg ShuflinandClaude Opus 5.5 9851aed695 android: library covers load through Coil, keyed by cover_key
Build Debug APK / build (push) Failing after 2m35s
Comics, music and PDF cards name their cover as a DocumentCover; one Fetcher
reads it through the FFI (MuPDF renders a PDF's first page at the requested
size) and a Keyer caches it under the lib's cover_key, registered app-wide by
a new SynchronicityApplication. The per-screen thumbnail maps and decode loops
are gone, and a cover that changes through sync is no longer held stale for as
long as the screen is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsxkM2ja42H2h5BPRexf2r
2026-10-04 04:44:34 -07:00
Greg ShuflinandClaude Opus 5.5 2e394cadc0 lib: library lists say what each cover is cached under
PdfListItem, ComicTreeItem (with ComicShelf::cover for a series' card) and
music's LibraryEntry carry a cover_key: the hashes of the blobs the cover is
drawn from, set only while they are stored here. A frontend that caches covers
by it gets a new key when a cover changes or arrives through sync, rather than
the old picture. LibraryEntry::has_cover becomes cover_key, and an artist's key
is its portrait's or its mosaic's covers'.

Comics read every first page's key in bulk alongside the availability roll-up,
choosing the first page the way list_comic_pages does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsxkM2ja42H2h5BPRexf2r
2026-10-04 04:44:34 -07:00
Greg ShuflinandClaude Opus 5.5 f8fad97f81 AGENTS.md: a background result knows what it was for
The rule F1's sweep applied across Notes, the player, document detail and the
readers: a buffer carries its record's id, a load into a selection is held
rather than detached, and work that can't be canceled checks a generation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:44:34 -07:00
Greg ShuflinandClaude Opus 5.5 d47c21cf86 cli: 'syn init --no-ensemble' says the same sentence as every other no-ensemble path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:44:34 -07:00
Greg ShuflinandClaude Opus 5.5 27892ffe7a TODO: four daemon findings from the manual checks
A long store path can't bind its socket, the local API's 2 MB body limit, the
daemon never closing its iroh endpoint, and a canceled 'syn docs import' that
doesn't say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:44:34 -07:00
Greg ShuflinandClaude Opus 5.5 2ec882a90a daemon: 'syn daemon start' says why a daemon didn't start, and fails
Restart forked with daemonize's start(), which exits the parent with status 0
the moment the first child does — so a daemon that then failed (a socket path
too long to bind, a node in no ensemble) said so only in daemon.log.

Both daemonizing modes now fork with execute() and wait on a pipe the child
reports through once its node runtime is up: its error if it failed, nothing if
it died. A child whose socket answers but that hasn't reported within five
seconds is still connecting to the network, and counts as started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:44:34 -07:00
Greg ShuflinandClaude Opus 5.5 c2c7fc453f daemon: a SIGTERM while starting stops it cleanly instead of killing it
The signal handlers were installed only once the server was running, so a
signal during Store::open or NodeRuntime::start took the default action and
left the socket and PID file behind. They are now installed first thing, and
startup is raced against them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:44:34 -07:00
Greg ShuflinandClaude Opus 5.5 3278634710 daemon: waiting for a stop means waiting for the process, not its socket
The daemon removes its socket on the way out, before the tasks holding the
store have finished, so 'syn ensemble retire' could find the socket gone and
then fail to open a store still locked. A daemon killed outright never removes
it at all, which a restart reported as 'did not exit within 5 seconds'.

wait_for_exit now takes the pid read before the stop was asked for and waits
for that process to be gone, then clears any socket and PID file it left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:44:34 -07:00
Greg ShuflinandClaude Opus 5.5 77ad1fea65 cli: a node in no ensemble is told how to join one, in one sentence everywhere
Rendering syn ensemble members from EnsembleStatus lost the old
snapshot's recourse: on a node in no ensemble it printed a bare "Error:
not part of any ensemble", and so did the retire and renew
confirmations. The daemon's ensemble_status now says
copy::ensemble_roster::NOT_IN_ENSEMBLE, which names what to run — the
sentence syn ensemble status, gossip-status and dht-peers also use,
where before they said it two different ways. The status and members
help no longer claim a certificate check and a gossip-only list that
they stopped doing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:44:34 -07:00
Greg ShuflinandClaude Opus 5.5 3a08adea65 android: build the Rust library for the device's ABI, x86_64 included
Build Debug APK / build (push) Failing after 3m48s
The x86_64 emulator was running the arm64 .so through the native bridge,
which returns JNI jbooleans with junk in the register's upper bits. Since
jni-sys 0.4 reads jboolean as a Rust bool, every ExceptionCheck reported a
pending exception, so rustls-platform-verifier could neither initialize
nor verify a certificate on the emulator.

Still one Rust cross-compile per build: -PrustAbi wins, else the ABI
Android Studio injects for the device it deploys to, else arm64-v8a. The
.so is now synced into build/rustJniLibs rather than src/main/jniLibs, so
clean removes it and a previously built ABI can't ride along in the APK.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 02:29:29 -07:00
Greg ShuflinandClaude Opus 5.5 7e7f331fd8 lib: initialize rustls-platform-verifier on Android
The cargo update in d370691e took reqwest 0.13.2 -> 0.13.5 and with it
rustls-platform-verifier 0.6 -> 0.7. On Android 0.7 has to be handed the
JavaVM and app Context explicitly, and nothing did, so the first TLS
connection (node init's network setup) failed with "Expect
rustls-platform-verifier to be initialized". initAndroidContext now does
it alongside ndk-context.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 02:29:29 -07:00
Greg Shuflin ffa43e9e5b Commit new test key 2026-10-04 02:29:29 -07:00
Greg ShuflinandClaude Opus 5.5 8e3a49bea9 TODO: a peer's jobs from the remote Sync view, and a Coil fetcher for document covers
Build Debug APK / build (push) Failing after 2m46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:39:01 -07:00
Greg ShuflinandClaude Opus 5.5 4a4789e440 p2p: doc-sync stages each record as it arrived instead of re-encoding it
read_chunk decoded every record off the wire, stage_chunk encoded it
again to stage it, and promote_session decoded it a third time. A record
that decodes is already exactly what promotion will read, so it is now
kept as the bytes it arrived in (ReceivedRecord: its id and those
bytes) and staged as is — one full encode pass less per record on every
catch-up. The decode that checks each record, and drops one this build
can't read, is unchanged. (audit-lib-p2p F3, the type-3 item)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:29:14 -07:00
Greg ShuflinandClaude Opus 5.5 49211050f9 android: one podcast cover composable, loaded through Coil
PodcastCoverArt and PodcastListThumbnail each decoded stored cover bytes
with BitmapFactory on the main thread, fell back to a Coil painter for
the remote URL, and swapped in a placeholder on error; the search
screen inlined the same shape a third time with no error state at all.
PodcastCover hands Coil the stored bytes or the URL as one model — Coil
2 takes a ByteArray — so the decode is off the main thread, downsampled
and cached, with a placeholder for a missing cover and another for a
failed one. A search result whose cover fails to load now shows the
podcast glyph rather than a blank. (F7, the podcast half)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:29:14 -07:00
Greg ShuflinandClaude Opus 5.5 5db5589cba lib: the comics library rolls page availability up in one pass
build_comic_tree_nodes rolled each comic's availability up from its
pages one comic at a time: a page listing, a page-count read and a bulk
availability check per comic, so a library of N comics cost 3N queries.
comic_availabilities does the whole library at once — one read of the
page documents, grouped by comic, and one bulk availability check — and
takes each comic's expected count from the OrderTotal the library tree
already read. The roll-up rule is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:29:14 -07:00
Greg ShuflinandClaude Opus 5.5 1d25080846 lib: one ensemble_peer_ids, freshest first and without revoked peers
The desktop, the daemon and the FFI each had their own ensemble_peer_ids
for a user's pull or push, walking peers in store order with revoked
nodes included — dialling a node whose credential is revoked can only end
in a refusal at admission. scheduler::ensemble_peer_ids is now the one
list, the nodes of ensemble_peers_freshest_first, which already filters
revoked certs and tries the freshest peer first; the three callers keep
only their own error for an empty list, in copy::asset_sync::NO_PEERS's
words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:03:25 -07:00
Greg ShuflinandClaude Opus 5.5 e2628ba6dc p2p: artifact values travel in pages, and the frame cap drops to 64 MiB
An artifact-sync response carried every value asked for in one frame, and
a node catching up asks for every value it is missing at once — the one
message with no bound, which is why MAX_FRAME was 256 MiB. A response
past the cap failed, and failed again on every later pass since each
asked for the same set, so that peer's values never arrived; and an
admitted peer could make this node allocate 256 MiB in one go.

Values now travel in pages of about 4 MiB in both directions: the
responder answers a fetch page by page (reading its store one page at a
time, so its memory is bounded too), and a push is sent the same way
with one ack at the end. A single value too large for a frame is left
out rather than answered None, which would record the peer as lacking
it. The client applies each page as it lands, so a fetch cut off by the
per-peer timeout keeps what arrived and the next pass asks only for the
rest. The session's message count changed, so ARTIFACT_SYNC_ALPN is
bumped.

With nothing unbounded left, MAX_FRAME is 64 MiB. Its doc comment lists
each protocol's bound, and blob-sync, blob-stream and artifact-sync
each have a test building their largest legal message and checking it
fits. (F3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:14:01 -07:00
Greg ShuflinandClaude Opus 5.5 2cf2322a92 gui: Button is an element, and a button is enabled exactly when it has a handler
button(theme, id, label, primary, enabled) decided only how a button
looked; the caller attached the handler separately, so a plain
.on_click on a disabled button still fired. on_click_if gated it, but
the same bool was still written twice and nothing stopped a new call
site from forgetting.

Button is now an opaque RenderOnce element built by button(id, label,
ButtonKind), bar_button, icon_button, transport_button and
transport_button_primary. on_click/on_mouse_down and their _if forms are
the only ways to give it a handler, and having one is what enables it:
no handler draws flat and inert, with no tooltip. There is no
Stateful<Div> left to hang a second handler on; the few adjustments
callers made (w, h, self_start, tooltip) are builder methods.
ButtonKind {Primary, Secondary, Danger, Ghost} replaces the primary
bool and the danger_button/ghost_button functions, and the theme comes
from the context rather than an argument. The one control styled as a
button that isn't clicked, import_trigger's menu, builds on
label_button_shell.

Two buttons changed behavior as a result: Podcasts search's Subscribe
took a plain .on_click, so "Subscribed ✓" subscribed again —
subscribe_button now takes the handler and gates it itself; and the
subscriptions screen's placeholder Sort, drawn clickable with nothing
behind it, now draws disabled. Android needed nothing: its hand-rolled
.clickable uses are rows and cards, none drawn disabled. (F2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:49:54 -07:00
Greg ShuflinandClaude Opus 5.5 13854685d9 TODO: the document detail drops a property edit when it reloads
Build Debug APK / build (push) Failing after 2m38s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:36:35 -07:00
Greg ShuflinandClaude Opus 5.5 ef8c77fb65 podcasts: subscribe takes a NewFeed record across the FFI
Adding the RSS URL gave PodcastFfi::subscribe eight positional arguments
(clippy's too_many_arguments, which lint-rust reports but does not fail
on). NewFeed is now a uniffi::Record and the one argument, so Android
builds the same struct the desktop does; its last-fetched time crosses
as a Timestamp rather than a string parsed on the Rust side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:27:36 -07:00
Greg ShuflinandClaude Opus 5.5 76f110f4d8 gui: Pictures' fetch progress and Ensemble's revoke outcome stay on their own item
An image fetch that finished after the user moved on cleared the newer
image's progress block; a revoke that finished after its dialog was
canceled and another node's opened wrote its outcome into that dialog.
Both now check the item they were about. (F1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:16:00 -07:00
Greg ShuflinandClaude Opus 5.5 1a0d57f876 gui: PDF page turns and bookmark reads land only for the page and PDF asked
go_to_page renders could land out of order — the toolbar, scrub bar and
outline aren't gated on loading — showing, and saving as the reading
position, a page the user had already left; the page turn is now a held
Task that the next one replaces. refresh_bookmarks applies its list only
if that PDF is still the selected one. (F1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:16:00 -07:00
Greg ShuflinandClaude Opus 5.5 c81a9ebdd2 gui: a comic that finishes opening late doesn't replace the one picked after it
open_comic was detached and unguarded, and the library stays clickable
while a remote first page arrives — so opening A then B could end with
A in the reader, or with the reader popping open after Back. The open is
now a held Task that opening another comic or closing the reader drops.
(F1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:15:59 -07:00
Greg ShuflinandClaude Opus 5.5 ab0a7747ce gui: Music's selection and library reads can't land after a newer one
select_track resolved a track into the shared selected-track mutex in
the background, before checking whether the user had since picked
another — so a slow load of A could leave A's decoder there while the
bar named B, and Play sounded A under B's title. A selection counter is
now checked under that mutex immediately before the write, as the player
does, and the selection's Task is held so a newer one cancels the
stream fallback's network wait too (which also stops a stale load
clearing the newer one's progress block). refresh_library_view's Task is
held for the same reason: rows from a level the user had left put
library_rows out of step with library_path. (F1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:15:59 -07:00
Greg ShuflinandClaude Opus 5.5 7366805c9a gui: a document detail's async results land only on that document
Every async step on the detail screen — the detail load, the delete-
locally presence check, pulls, pushes, peer overrides, purge, eviction —
wrote its result into whichever detail was open when it finished. Start
the delete-locally check on A, go back and open B, and B's dialog
offered A's eviction plan, which Confirm then executed under B's id; a
slow load of A could seed B's properties table, and B's Edit/Save would
write them to B.

Results are now written through detail_for(id), which returns the open
detail only if it is the document the step was about; per-peer results
find their Placement row by node rather than by an index taken when the
action started, since a placement reload re-sorts the rows. The detail
load is held in Detail rather than detached, so opening another
document cancels it. A purge that finishes closes the detail only if
it is still the purged document's. (F1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:11:20 -07:00
Greg ShuflinandClaude Opus 5.5 8bc9dacd09 gui: a stream that opens after a newer play can't replace it in the player
start_audio opened the stream (the slow part) and loaded it into the
shared player in the background, and checked play_seq only afterwards,
in the apply step. Play episode A then B, and if A's stream opened last
it replaced B's in the sink while the bar still said B: the next Play
sounded A, and the ticker reported A's position as B's, which Podcasts
persists into B's listen state. play_seq is now shared with the load and
checked under the audio lock, immediately before the load, so a load
that has been superseded never touches the player. (F1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:09:18 -07:00
Greg ShuflinandClaude Opus 5.5 40a054e0eb gui: Notes' editor knows which note it holds, and a stale load can't land
The A/B autosave race was fixed by one check in the load's apply step,
with about ten more of that shape guarding the rest of the async steps.
Two structural changes make the class impossible instead:

- The editor, its note's id and the sync guard are one OpenNote, held
  by ContentState::Loaded rather than beside it. schedule_save and
  flush_save take their destination and their text from the same
  OpenNote, so a save can only write a note's own buffer into it; the
  open-note re-read, the incoming-version actions and Export read it
  the same way. close_note is the one place it is dropped, and updates
  the rail with it.
- The note load is held in _load_task instead of detached. Selecting
  another note, switching notebook or losing the note to a tree reload
  replaces or drops it, and gpui cancels it before its result can land,
  so the apply step no longer checks the selection.

Also: "Start this note over" reopens the note only if the user is still
on it, rather than pulling back one who moved on while it ran. (F1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:07:03 -07:00
Greg ShuflinandClaude Opus 5.5 414c06eacd TODO: interactions with more than one attendee, which the lib supports and no frontend reaches
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:53:45 -07:00
Greg ShuflinandClaude Opus 5.5 1a3eb9f65f AGENTS.md: a preferences screen is not a record, so editing-as-a-mode doesn't reach it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:44:19 -07:00
Greg ShuflinandClaude Opus 5.5 da4336a791 podcasts: one feed episode list, podcast::feed_episodes, for both frontends
The desktop's with_store_state and Android's withStoredEpisodes each
took the Podcast Index page, folded in the stored episodes it was
missing, and re-sorted newest first, and each one's doc comment said it
mirrored the other. That rule is now podcast::feed_episodes over a new
FeedEpisode record (api id, title, description, published, duration,
stream URL, image), which fetch_episodes returns across the FFI and
PodcastFfi::feed_episodes exposes.

Only the list is shared. What the store knows about each episode stays
each frontend's to layer on: the desktop folds it into EpisodeRow (rows
already on screen are kept, so a save in progress stays marked), and
Android keeps its separate storeState map, applied as it arrives while
list changes wait behind the held-episodes strip. Android drops its
PodcastIndexEpisode and PodcastEpisodeItem mappers for one FeedEpisode
mapper; list_episodes and PodcastEpisodeItem leave the FFI, and
PodcastIndexEpisode stops crossing it. (P4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:29:59 -07:00
Greg ShuflinandClaude Opus 5.5 3820d4e81b podcasts: the Podcast Index id is PodcastIndexFeedId, and the RSS URL is kept
A subscription stored the feed's Podcast Index id in a property named
PodcastFeedRssUrl, and the search result's real RSS URL was dropped. The
id now lives in PodcastIndexFeedId, named for what it holds, and
PodcastFeedRssUrl holds the URL — written at subscribe on desktop and
Android from the search result, and read back as PodcastFeed::rss_url /
PodcastItem.rss_url. Nothing fetches from it yet; it is the publisher's
own address for the feed, which reading feeds without the directory
(docs/rss-reader.md phase 6) would need.

Not converted in place: a store holding feeds from before this change
reads their old id as an RSS URL, and is recreated rather than migrated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:07:12 -07:00
Greg ShuflinandClaude Opus 5.5 4ba394af1d TODO: starting the daemon by exec, which would retire proc_title.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:14:16 -07:00
Greg ShuflinandClaude Opus 5.5 932a901bd8 cli: syn ensemble status and members render the lib's EnsembleStatus
The CLI had its own EnsembleMembers daemon command, whose snapshot
re-derived by hand what ensemble::screen::build_ensemble_status computes
— the revoked set, per-peer health, this node revoked or left behind by
an epoch — and syn ensemble status re-derived the pkdns link and gossip
topic the same status already carries. Both now read EnsembleStatus, the
snapshot the TUI already gets, and EnsembleMembers, its two snapshot
structs and the daemon's builder go. So do the retire and renew
confirmations' counts and syn ensemble gossip-status's peer list.

What changes in the output: "last seen" is the shared relative wording
rather than a UTC timestamp; with the daemon running the member list
also shows nodes known only from the pkarr registry, marked "not seen
via gossip yet"; and syn ensemble status drops the pkarr z32 line
(it is in the DHT bootstrap link) and the cert-signature line. The
gossip integration test now waits for every member to be seen via gossip
rather than for the count, which registry-only members reach first. (F3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:14:01 -07:00
Greg ShuflinandClaude Opus 5.5 c418d8e218 daemon: one NotesItem Delete/Rename for notebooks, collections and notes
NotebookCmd, NoteCmd and CollectionCmd each carried their own Delete and
Rename, all six answered by the same delete_item/rename_item on the same
tree. They are now DaemonCommand::NotesItem(NotesItemCmd::{Delete,
Rename}); the CLI keeps its three verbs per kind and sends that, and the
TUI drops the ItemKind mapping that existed only to pick a variant.
A daemon-protocol change; no ALPN is involved. (F21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:00:24 -07:00
Greg ShuflinandClaude Opus 5.5 bc207e6bc4 cli, tui: syn jobs cancel, and c on the TUI's Jobs screen
Three daemon comments and a protocol doc told the reader to run syn jobs
cancel, which did not exist; the TUI's Jobs screen said it had no cancel
because the protocol had no command for one. The daemon's JobRunner
could always cancel. JobsCmd::Cancel { id } now asks it to (Write, the
reach that starts an import), syn jobs cancel <ID> sends it and says
what happened in copy::job_cancel's words, and c cancels the selected
job on the TUI's Active tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:58:30 -07:00
Greg ShuflinandClaude Opus 5.5 1409666a5e AGENTS.md: no database migrations; a store is recreated, not converted
Build Debug APK / build (push) Failing after 2m58s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:42:28 -07:00
Greg ShuflinandClaude Opus 5.5 b97bf4fb49 TODO: ship one rustls crypto provider; pkarr's relays are what keeps two
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:31:13 -07:00
Greg ShuflinandClaude Opus 5.5 94b52ac31a lib: a blob on another node is None, and Notes says so in Pictures' words
load_image_bytes, load_pdf_bytes and load_comic_page_bytes returned
BlobStoreError::NotFound when the document was here and its bytes were
elsewhere, and five callers matched that arm out to mean "ask a peer".
They now return Option, with None for exactly that (load_blob_if_local),
and the callers lose the match. ComicsFfi::get_cover_bytes is Option too,
so Android stops logging an absent cover as a failure.

The one caller that showed the error was Notes' embedded images, whose
placeholder read "blob not found: blake3:…". It now gets
ImageState::Absent with copy::picture_absent's headline for the
image's availability, drawn as a plain placeholder rather than an
error — absence is not an error. (M3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:31:13 -07:00
Greg ShuflinandClaude Opus 5.5 3767af658b android: a picked file streams to disk instead of into the heap
Both Android import paths — a single picked file and a folder import —
read each file whole into a ByteArray and handed it across the FFI, where
Rust wrote it back out to a staging file because the importers take a
path. A large video or PDF was the whole file in the heap, twice.
Kotlin now streams the content into the app's cache, and
ImportBatch::import_file takes that path: StagedFile::adopt moves it
under the name dispatch needs (a rename on one filesystem, a copy and
delete otherwise) and removes it once the import is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:31:13 -07:00
Greg ShuflinandClaude Opus 5.5 d42c4cd974 lib: a registry entry drops the cert signature nothing could check
Each pkarr registry entry carried a copy of its node's delegation-cert
signature, which was never verified and could not be: the cert's signed
payload also covers its version and epoch, which the entry doesn't hold,
and the packet is already signed by the ensemble key. It was 64 of every
entry's 105 bytes against pkarr's 1000-byte packet, so the registry held
6 members before evicting the oldest; it now holds 13.

Entries are written at version 2 (version, pubkey, created_at). The
decoder still reads a version 1 entry's identical prefix, so a registry
an older build published keeps bootstrapping until a node republishes
it. The packet-size tests become one parameterized case at the new
ceiling; the eviction test, which re-implemented the loop rather than
calling it, goes with them. (F21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:13:44 -07:00
Greg ShuflinandClaude Opus 5.5 ec46db167e lib: EnsembleMembership reads its ensemble key from the cert
It stored the ensemble pubkey twice — once beside the delegation cert and
once inside it — with nothing keeping the two equal. The field goes and
ensemble_pubkey() reads the cert's, which is the one the signature
covers. A membership saved by an older build still loads: serde ignores
the field it no longer has. (F21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:06:37 -07:00
Greg ShuflinandClaude Opus 5.5 27ac27e7e6 lib: an agent's type follows from its group
AgentType::Control is exactly the System group — Ensemble, Jobs and
Settings — so each registry row stopped saying so a second time.
AgentGroup::agent_type derives it, and AgentMetadata::agent_type reads
it; the FFI record still carries the field. (A-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:01:28 -07:00
Greg ShuflinandClaude Opus 5.5 37db71fa81 lib: drop the retired-human-kinds conversion at artifact-store open
The project takes no explicit database migrations — a store from an
older schema is deleted and recreated, not converted — so the one-shot
rewrite of human_job/human_religion/human_pet/human_web_presence rows
into facts goes, with its four tests and the Json/Uuid error variants
only it constructed. Also drops a doc comment left behind when the ADR
0038 migration's test went, which had attached itself to the next test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:00:01 -07:00
Greg ShuflinandClaude Opus 5.5 99dd0eaf0c TODO: one rule for Unicode case-insensitive matching, in every modality
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:57:25 -07:00
Greg ShuflinandClaude Opus 5.5 ebb276b77e ffi: JobSubject and ContentKey cross as themselves
Kotlin named a job's subject through JobSubjectRef and an artifact through
DocumentAssetRef — FFI-only copies of JobSubject and ContentKey, each with
a TryFrom back. They existed because the real enums had tuple variants,
which uniffi hands Kotlin as v1/v2. Both now have named fields (Peer
{ node }, DocumentToPeer { document_id, node }, Blob { hash }, Artifact
{ id }, …) and derive uniffi::Enum, so the copies and their conversions
go. BlobHash crosses as its "blake3:<hex>" string and a bare Uuid as its
hyphenated one; a malformed one from Kotlin lifts as InvalidInput.

job_match_key now takes the subject a job row is built from, so the
drift-guard test that compared the two spellings has nothing left to
compare. (F4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:31:56 -07:00
Greg ShuflinandClaude Opus 5.5 dcd8ed77a6 ffi: JobState crosses as itself; Failed names its message
JobItem.state was a snake_case tag beside an error field only Failed
ever set, and the Jobs screen compared the tag against "running",
"queued", "done" and "failed". JobState is now a uniffi::Enum and
JobItem carries it; Failed(String) becomes Failed { message } so Kotlin
reads .message rather than .v1. JobItem.error goes — Kotlin never read
it, since the failure already reaches it through status_line. (F4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:11:17 -07:00
Greg ShuflinandClaude Opus 5.5 651085adca ffi: JobKind crosses as itself, and Kotlin's JobKinds mirror goes
JobItem.kind was a snake_case string, so Android compared it against
string constants it kept itself — a JobKinds object, a private
CONTENT_STREAM_KIND, and two bare "push"es on the Jobs screen. JobKind
is now a uniffi::Enum: JobItem carries it, job_match_key and
job_kind_label take it, and a misspelled kind is a compile error rather
than the InvalidInput that job_match_key used to return for one. (F4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:54:35 -07:00
Greg ShuflinandClaude Opus 5.5 bb88bdacce lib: a node-bearing record hands back its NodePubkey, not hex
Build Debug APK / build (push) Failing after 2m37s
DelegationCert, RegistryMember, PeerNode and RevocationRecord each kept
their own node_id_hex, and two of them a node_short_id that cut the key
with no ellipsis. Each now has one typed accessor — node(), or
revoked_node() on a revocation — and callers say .to_hex() or
.short_id() on what it returns, so a shortened node id goes through
copy::short_id like every other. The NodePubkey(x.node_pubkey) wrappers
at call sites fold into the same accessor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:54:11 -07:00
Greg ShuflinandClaude Opus 5.5 7b25aadf91 lib: NodePubkey::short_id is copy::short_id, ellipsis and all
short_hex cut the key to its first 8 hex characters with no ellipsis,
and its TODO asked for exactly the helper copy::short_id now is. Peer
labels in the sync logs now read like every other shortened id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:40:55 -07:00
Greg ShuflinandClaude Opus 5.5 4f05c05ca4 gui: Documents, the PDF side panel and QR Code empty states use components::note
Build Debug APK / build (push) Failing after 3m1s
Each drew its own centered line: Documents a bold large "No documents"
beside fill_note's "Loading…" and "Nothing matches.", the PDF side panel
a MICRO-sized row, QR Code a default-sized one. They now share note's
look and keep only their own placement. Humans' empty state stays: it
carries an "Add a person" button, which a note does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 05:07:33 -07:00
Greg ShuflinandClaude Opus 5.5 80cb4aca40 gui: the PDF toolbar says "Zoom:" like Comics and Pictures
The PDF reader drew its zoom controls bare while the other two readers
labeled them. It now builds the group with components::reader::zoom_group
and keeps its info button inside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 05:06:07 -07:00
Greg ShuflinandClaude Opus 5.5 d3d9f08782 gui: Notes guards its open note with SyncGuarded
The open note's stored_body/incoming_body pair and its hand-written
broadcast loop (watch_open_note) were the last hand-rolled copy of the
alert-don't-apply shape. Notes now holds the body as
OpenNote { id, body: SyncGuarded<String> }, and check_open_note does
Clippings' check: read the note on the background executor, treat text
equal to the editor as this node's own save, and only then
observe_fetched. It runs from store_watch::follow, so it follows the
store while Notes is on display and catches up on arrival.

Two behaviors change, both accepted in the audit decision:
- Keep mine dismisses the incoming version, so a re-read of that same
  version no longer re-alerts. That memory replaces stopping the watch
  until the overwrite finished.
- A version that arrives while Notes is off display is announced on
  return rather than as it lands, and a newer version replaces a pending
  one instead of being missed until the first is resolved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 04:19:51 -07:00
Greg ShuflinandClaude Opus 5.5 ea16c4aab8 gui: a rail click never re-reads a popped-out screen
Clicking a rail row whose screen is popped out only raises its window.
App::set_current_view already declined to reload Documents and Jobs in
that case, since a re-read would move what the user is pointing at, but
every screen using store_watch::on_arrival/follow re-read anyway.
on_arrival now holds the one rule: skip when popped out. A popped-out
screen is on display, so watch_on_screen has kept it current.

Documents and Jobs move their arrival reload out of app.rs into
on_arrival, and Clippings drops its own visibility plumbing
(clippings_visible/sync_clippings_watch/hide) for on_arrival plus
watch_on_screen like every other screen. Clippings therefore no longer
re-reads when it is popped out or back in, only on arrival.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 04:17:49 -07:00
Greg ShuflinandClaude Opus 5.5 4d705e170a lib: one filtered document tree for desktop and Android
The filtered Documents listing (matches plus their ancestor chain) was
assembled twice, in gui-app's visible_filtered_rows and Kotlin's
filteredDocumentTree, and the copies disagreed on one edge: a row that
arrived as both a match and context was drawn once on the desktop and
twice on Android. documents::filtered_tree is now the one assembly, over
(id, parent_id) pairs; the desktop calls it directly and Android through
the filtered_document_tree export. It also no longer repeats an expanded
match's child that the listing already placed. The mirrored test suites
become one rstest table in the lib.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 04:15:17 -07:00
Greg ShuflinandClaude Opus 5.5 1b5b35420b cli: the binary is syn
It was built as syn-cli while every message it prints, and the daemon's,
tells the user to run `syn …`. The [[bin]] is now syn (the package stays
syn-cli, so -p syn-cli still selects it), and the justfile recipes, the
integration tests' CARGO_BIN_EXE lookup and the flake's package follow —
nix's default package is now .#syn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 04:27:41 -07:00
Greg ShuflinandClaude Opus 5.5 8c4ff271a0 lib: pictures and music share one LibraryTreeItem
PictureTreeItem and MusicTreeItem were the same record but for music's
subtitle. Both libraries now hand out agents::LibraryTreeItem, built by
TreeNode::into_item; pictures leave the subtitle empty. Video and comics
keep their own item types, which carry more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 04:18:12 -07:00
Greg ShuflinandClaude Opus 5.5 86ee31c116 lib: a library leaf with no availability answer is Unavailable in every library
library_tree took the fallback as a parameter because the libraries
disagreed: pictures said Local ("nothing known to be missing"), video,
music and comics said Unavailable. It only arises when a leaf vanishes
between two reads in one transaction, and there claiming the bytes are here
is the one answer that can be wrong, so it is Unavailable everywhere and the
parameter goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 04:03:37 -07:00
Greg ShuflinandClaude Opus 5.5 6e7e77de31 android: stop threading nativeLib through every screen
With NativeLib an object, the ~60 nativeLib parameters and the ~100
arguments passing it down — from MainActivity through each screen, its
sections, its repositories and its helpers — carried nothing a call to
NativeLib doesn't. They go, along with the repositories' now-empty
constructors. (F2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 03:36:46 -07:00
Greg ShuflinandClaude Opus 5.5 88146069a4 android: NativeLib is an object
Every instance was a stateless view of the process-wide node handle held
in its companion, and 15 sites constructed one (most as
remember { NativeLib() }). It is now the singleton it always behaved as.
The JNI binding is unchanged: initAndroidContext is still an instance
method, now on the object's one instance. (F2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 03:34:01 -07:00
Greg ShuflinandClaude Opus 5.5 07766080f5 android: the scheduler worker leaves Wi-Fi and charging to WorkManager
Build Debug APK / build (push) Failing after 2m47s
SchedulerConstraints re-checked the unmetered-network and charging
constraints the WorkRequest already declares — schedule() rewrites them on
every settings change, and WorkManager stops a worker whose constraints
stop holding — and its doc still said no Android driver ran the scheduler.
The worker now checks only that the scheduler is enabled. (F4i)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 03:12:54 -07:00
Greg ShuflinandClaude Opus 5.5 973c427c89 android: delete the Documents row menu and its settings dialog
Each row's ⋮ opened a menu with one item, "Settings", which opened a
read-only dialog of the title, type and created date — what the row itself
and the detail screen already show. (F4h)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 03:12:54 -07:00
Greg ShuflinandClaude Opus 5.5 e3bbfefe42 android: delete the Node Key screen
Settings → System opened a screen showing one HashChip of the node's key,
which Settings → Ensemble already shows in its membership grid. The screen,
its route, the button, the System tab's now single-route nav graph, and the
FFI field that carried its explainer go. (F4g)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 03:12:54 -07:00
Greg ShuflinandClaude Opus 5.5 fc5ed4720f android: the player drops its unused artwork and visualization regions
No Android player turned artwork on, and Visualization had only NONE, so
the capability flags, the Artwork and Visualization composables and
NowPlaying.artworkUri go. ADR 0046 and the design doc note that Android no
longer carries them; the desktop keeps both. (F4f)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 03:03:26 -07:00
Greg ShuflinandClaude Opus 5.5 fecc759717 android: delete the Notes screen's empty right-hand tab panel
NoteTabSidebar offered Info/Tags/Links/History/Attachments tabs that only
highlighted when tapped and had no content behind them, and the desktop has
no such panel. The panel, its header toggle, and the edge swipe that opened
it go; the notebook sidebar is unchanged. (F4e)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 03:02:04 -07:00
Greg ShuflinandClaude Opus 5.5 80fa9488e8 android: Pictures and the comic reader zoom through ZoomState
Build Debug APK / build (push) Failing after 2m41s
Both hand-rolled their own scale/offset and transformable, without the
shared viewer's pan clamp or double-tap. ZoomState now takes a per-viewer
zoom range (Pictures keeps 0.5–8x) and a rotation (Pictures' quarter
turns), ZoomableBitmap takes an ImageBitmap, and it only pans once zoomed
in — the rule the comic reader's pager needed, and harmless to the rest,
since an unzoomed image has nowhere to pan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:51:08 -07:00
Greg ShuflinandClaude Opus 5.5 d7b3947d3a android: one stacked label/value row, ui/LabeledValue
Four private variants — InfoRow, DocumentSettingsRow, MetadataRow and
FactRow — and Humans' ReadOnlyField each laid out the same line, two of
them as a two-column grid that CLAUDE.md says Android does not use. They
are all LabeledValue now, which carries the stacked-on-a-phone rationale
that lived on ReadOnlyField.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:48:27 -07:00
Greg ShuflinandClaude Opus 5.5 9ff527e39a android: the podcast detail screen is a SubAppScreen
It hand-rolled the Surface, back handler and a back-button row that
SubAppScreen provides. The podcast's title moves into the header (so the
body no longer repeats it), and the info button becomes a header action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:46:56 -07:00
Greg ShuflinandClaude Opus 5.5 a18bd4b8f0 gui: the PDF info popup is a Modal
It hand-rolled the backdrop, card and click-swallowing that Modal already
does, with a bespoke ✕. It now uses the dialog pieces every other modal
does and closes with a Close button — copy::action::CLOSE, which Android's
two info dialogs now read too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:45:08 -07:00
Greg ShuflinandClaude Opus 5.5 09d8f6d00b gui: Video, QR Code and the podcast detail screens use screen_header
They each drew their own title row: Video a bold text_xl clone of the
header, QR Code a title inside its padded body, and podcast detail and
episode a "← Back" button beside a heading in the scrolling column. They
now open with the same header row as every other screen, and their back
buttons read "‹ …" like the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:44:05 -07:00
Greg ShuflinandClaude Opus 5.5 ed7e3171bb gui: one held-list strip for Documents, Clippings and podcast episodes
Three screens each drew "N new … — Show" for a list a sync is holding
back, in two different looks. components::incoming_banner::held_strip is
the list-level counterpart of incoming_banner, and all three use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:44:05 -07:00
Greg ShuflinandClaude Opus 5.5 b66874262e gui: Clippings' incoming-change banner is the shared one
It drew its own column of text over two buttons; incoming_banner is the
strip Notes and the podcast queue already show for the same choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:41:18 -07:00
Greg ShuflinandClaude Opus 5.5 c28ffc5a88 gui: the PDF reader's Outline/Bookmarks switch is the shared tab bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:40:55 -07:00
Greg ShuflinandClaude Opus 5.5 8468ef5eef gui: the media library's tabs and layout toggle are the shared components
Its tab bar hand-rolled what tabs::{tab_bar, tab_pill} draw, and its
cards/list toggle was two separately rounded squares beside an existing
icon_segmented_bar that does the same job (and gives each option a
tooltip, now from copy::library_layout).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:40:29 -07:00
Greg ShuflinandClaude Opus 5.5 bdffc838e3 copy: a fetch no peer answered names what it was fetching
Build Debug APK / build (push) Failing after 2m42s
Folding PdfError/ComicError into ContentFetchError (X-6) turned "couldn't
reach a node that has this PDF" into "…this content", right after the
reader had said "Finding a node with this PDF…". The sentence is now
copy::content_fetch::unreachable(subject); the PDF, comic and picture errors
word their wrapped fetch error with their own noun, and the FFI keeps that
wording rather than the inner error's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:17:14 -07:00
Greg ShuflinandClaude Opus 5.5 778fbe8209 android: the podcast held-episodes strip counts, like the desktop's
It always said "Episodes changed elsewhere." while the desktop said "3 new
episodes." Both now read copy::podcast_episodes::held; Android through
podcast_episodes_held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:08:08 -07:00
Greg ShuflinandClaude Opus 5.5 c7a72a990c copy: the Sync settings "Run now" summary is one sentence, shared
The desktop and Android each built it, capitalized differently, and both
counted thumbnails, artifact values and whole files together as
"artifact(s)". copy::settings::run_summary counts the three apart, and
says so plainly when nothing arrived; Android reaches it through
scheduler_run_summary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:07:01 -07:00
Greg ShuflinandClaude Opus 5.5 9386ca7576 copy: a document with no title reads "Untitled" in every modality
The desktop Documents screens and Android said "Untitled Document", the
TUI "(untitled)", the CLI "(untitled)" or "(no title)", and the lib's
own import and title fallbacks spelled out "Untitled" rather than naming
copy::document_display::UNTITLED. All of them now use the constant
(Android through placeholders().untitledDocument).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:31:16 -07:00
Greg ShuflinandClaude Opus 5.5 a88bca4982 copy: an unnamed "Other" contact protocol has its own validation sentence
The desktop's "Name the protocol first" was not a paraphrase of
CONTACT_NEEDS_PROTOCOL (no protocol picked) but a different condition:
Other picked with its name left empty. It gets its own constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:28:26 -07:00
Greg ShuflinandClaude Opus 5.5 8ee258ad54 copy: a retired node's closing sentence says what happened and what to do next
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:28:09 -07:00
Greg ShuflinandClaude Opus 5.5 c42454854d copy: a shortened id keeps its prefix, the way git shortens a hash
Build Debug APK / build (push) Failing after 3m9s
truncate_id/short_id kept the first `keep` characters and the last four
(3fa9c1d2…a1b2), cutting ids from the middle like readable text. Every id
here is random (UUIDv4/v5, node keys, BLAKE3 hashes), so the end is no more
distinguishing than the start, and the prefix is the conventional handle:
3fa9c1d2…. A scheme label such as `blake3:` is kept whole and not counted, so
a blob hash still shows eight characters of the hash itself. Every hash chip
on desktop and Android (via the short_id/truncate_id exports) and the browser
extension's truncateId change with it; ADR 0026 records the amendment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3HRVnoLened7RocqUkvFs
2026-09-28 20:25:08 -07:00
Greg ShuflinandClaude Opus 5.5 8c43229925 AGENTS.md: ids keep their prefix when shortened; the middle cut is for readable text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3HRVnoLened7RocqUkvFs
2026-09-28 20:13:17 -07:00
Greg ShuflinandClaude Opus 5.5 220bc4e15e TODO: move the remaining user-visible strings into copy.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3HRVnoLened7RocqUkvFs
2026-09-28 20:13:17 -07:00
Greg ShuflinandClaude Opus 5.5 1f58066562 TODO: pluralize copy with a library, in every modality
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3HRVnoLened7RocqUkvFs
2026-09-28 20:09:01 -07:00
Greg ShuflinandClaude Opus 5.5 93a0042601 rust: a job's status line is Job::status_line
The desktop's render_status_line and Kotlin's JobStatusLine derived the same
text (progress message while running, the failure once failed, otherwise the
state's word). Job::status_line now does, JobItem carries it as statusLine,
and each screen keeps only its color choice. The job_state_label export lost
its one caller and is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3HRVnoLened7RocqUkvFs
2026-09-28 19:38:58 -07:00
Greg ShuflinandClaude Opus 5.5 615b2d1bcf rust: a music folder's "N tracks" line comes with its tree item
MusicTreeItem carries its subtitle, computed in build_music_nodes (every
track counts toward each folder above it, walking parent links once rather
than recursing per card) with the words in copy::music::track_count. The
desktop's descendant_track_count and Kotlin's descendantTrackCount — the same
recursion with the same sentence — are gone. So are the desktop's and
Kotlin's per-level re-sorts of the music and video trees: the lib already
lists them collections-first by lowercase title (sort_for_display), and a
filter keeps that order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3HRVnoLened7RocqUkvFs
2026-09-28 19:28:59 -07:00
Greg ShuflinandClaude Opus 5.5 713a3f4504 copy: a node's presence word comes from copy::content_presence
The desktop's PresenceLook::label and Android's PresenceLook.label derived
the same word ("Not here" for Absent/Removed, the model's own word
otherwise); both now call copy::content_presence::presence_label, exported
for Android. The row-level words ("Partly here", "Arriving") are
desktop-only and stay where they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:19:03 -07:00
Greg ShuflinandClaude Opus 5.5 2d1122a075 copy: the music build summary line lives in copy::music
The desktop's summary_line and Kotlin's buildSummaryLine/plural were the
same sentence built twice ("N tracks in N albums by N artists; N not yet
available", "Library build canceled"); both now call
copy::music::library_build_summary, exported for Android.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:19:03 -07:00
Greg ShuflinandClaude Opus 5.5 5a2e8419c6 rust: one case-insensitive wikilink title lookup for every notes screen
markup::resolve_wikilinks_by_title builds the lowercased title index that
the desktop Notes pane and the FFI's resolve_wikilinks (Android) each built
by hand, so the matching rule is written once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:19:03 -07:00
Greg ShuflinandClaude Opus 5.5 37dd66b072 rust: the comics library's derivations live in lib's ComicShelf
The desktop's ComicsView and Android's ComicRepository each carried the same
tree logic ("Mirrors the desktop …"): sibling order, the recent filter,
latest-read leaf resolution, series root and effective direction, first
descendant / cover comic, the "N chapters · Continue: X" subtitle and the
In Progress list. They agreed rule for rule, so both now ask
agents::comics::ComicShelf (a uniffi Object on Android), with the words in
copy::comics. ComicTreeItem carries its latest_read_child, which ends
Android's one FFI call per collection on every load (and one per
standalone comic on entering In Progress, now one call); ComicsFfi.list and
get_latest_read_child give way to shelf/in_progress. RECENT_WINDOW_DAYS moves
from agents::pdf to agents and stops being redeclared by the desktop comics
screen and Kotlin. Sibling order keeps both frontends' plain case-insensitive
title fallback (the lib doc had claimed natural-name order; corrected).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:07:53 -07:00
Greg ShuflinandClaude Opus 5.5 dd3e0265ac rust: one async typed Podcast Index client, records across the FFI
agents::podcast's search and episode fetches are one async index_get<T>:
the credential read, one SHA-1 auth header, reqwest's query encoder,
error_for_status (a 401/429 no longer surfaces as a serde "missing field"
error) and .json() in place of text + serde_json. Gone: the two catch_unwind
wrappers, the reqwest::blocking call, the per-call runtime + block_on, the two
description-cleaning loops (one clean_description), and the parse ->
re-serialize -> re-parse round trip. PodcastSearchResult and
PodcastIndexEpisode (trimmed to the fields a frontend reads, which also drops
the non-optional dateCrawled that could fail a page) derive uniffi::Record.

Desktop: search and the episode fetch are awaited on the tokio runtime via
network_task instead of blocking a gpui worker (CLAUDE.md: network waits never
occupy a UI worker thread); the store overlay stays on the background
executor. PodcastsView holds AppServices to hand the runtime down.
Android: PodcastFfi.search/fetchEpisodes return the records directly (run on
a current-thread runtime of their own, since they need no P2P node);
PodcastApi.kt and its four @Serializable mirrors are deleted. Search now uses
GET, as the Podcast Index documents for every endpoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:50:32 -07:00
Greg ShuflinandClaude Opus 5.5 ff6190f502 rust: MusicError carries AudioError and the decoder's error typed
MusicError::Audio(String) took both the audio player's AudioError and
symphonia's probe/decoder errors through e.to_string(). It is now
Audio(#[from] AudioError), transparent, and Decode(#[from] symphonia's
Error), so the four map_errs go (queued by the lib-agents batch).

Fixes a doubled prefix: an AudioError already reads "audio error: ...",
and wrapping its string in Audio's "audio error: {0}" showed
"audio error: audio error: ...".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:26:02 -07:00
Greg ShuflinandClaude Opus 5.5 fb3a156423 rust: AgentGroup derives VariantArray in place of a hand-kept all()
The rail, the FFI's get_agent_groups and the registry test iterate
AgentGroup::VARIANTS, so a new group cannot be left out of a hand-written
list. gui-app takes strum (already in the workspace) for the trait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:26:02 -07:00
Greg ShuflinandClaude Opus 5.5 eca7bfc717 rust: pomodoro phase words come straight from copy::pomodoro
PomodoroPhase::label and completion_message only forwarded to
copy::pomodoro::{phase, completed}; the desktop timer and the FFI call copy
directly and the two methods go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:26:02 -07:00
Greg ShuflinandClaude Opus 5.5 b48e4eff90 rust: podcast listen state is saved through save_listen_state alone
save_listen_progress and save_listen_finished were one-line wrappers over
save_listen_state (queued by the lib-agents batch). The desktop's player
subscription now maps its two events to a ListenState and makes one save,
the FFI's two methods (unchanged for Kotlin) build the state themselves, and
the wrappers go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:26:02 -07:00
Greg ShuflinandClaude Opus 5.5 a000dbebd3 copy: the desktop's "P2P not running" lives in copy::p2p
It is the failure text a network action's row shows, so it belongs in
copy.rs with the rest of the user-visible words (queued by G1, which
gathered its twelve literals into network_task.rs). Wording unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:26:02 -07:00
Greg ShuflinandClaude Opus 5.5 78dc8d5b8f rust: the last blob-load triplets use blob_document's helpers
ffi/documents, ffi/podcasts, ffi/music and agents/podcast each spelled out
blob_store.load(hash, plaintext_hash, vault_id) or the PrimaryContent
get_blob_ref batch; they now call agents::blob_document::{load_blob,
primary_blob_ref} (queued from the lib-agents batch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:26:02 -07:00
Greg ShuflinandClaude Opus 5.5 aea0143f05 rust: materialize takes the Store
materialize() took four store references that every caller pulled out of
one Store (queued once the daemon batch landed); it now takes &Store. Its
primary-content read uses blob_document's primary_blob_ref and load_blob
instead of spelling out the triplet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:26:02 -07:00
Greg ShuflinandClaude Opus 5.5 98582f3942 ffi: a malformed DocumentID or Timestamp from Kotlin lifts as InvalidInput
Their uniffi custom types failed with a bare anyhow error, which Kotlin sees
as SyncException.Internal ("report a bug"). They now fail with
SyncError::InvalidInput, as NodePubkey's already did; one test covers all
three types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:26:01 -07:00
Greg ShuflinandClaude Opus 5.5 28aa2bb686 rust: list_notebooks returns a named NotebookItem
NotebookManager::list_notebooks returned (DocumentID, String) tuples that the
FFI re-wrapped as its own NotebookItem and the daemon as NotebookRow. The lib
now defines NotebookItem (uniffi Record, serde) once; the FFI returns it
as is, the daemon's protocol carries it, and the desktop Notes screen reads
its fields by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:26:01 -07:00
Greg ShuflinandClaude Opus 5.5 51e2966af4 rust: one library tree builder for pictures, video, music and comics
agents::library_tree reads a media library's folder tree — every leaf of
one type plus the containers above it — with DocumentStore::ancestors_of
(one recursive query), one bulk property read for every node's title and
extras, and one bulk availability read. The four builders each had their
own collect_ancestor_collections walk (a query per ancestor) and then two
queries per collection and one or two per leaf; they are now a map over the
shared nodes, and pictures/video/music share one collections-first sort.

Each library keeps its own default for a leaf the availability read has no
row for (pictures Local, video/music Unavailable) and comics keep rolling
availability up from their pages; see handoff notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:26:01 -07:00
Greg ShuflinandClaude Opus 5.5 89a655175d rust: importers take one ImportTarget, and the lib marks imports present
Every importer took (blob_store, doc_store, artifact_store, path, node,
parent, progress) — five clippy::too_many_arguments allows in comics alone,
and a table macro and a comic adapter that only forwarded the seven. They now
take ImportTarget { store, node, parent, progress } and the path; ImportFn is
fn(&ImportTarget, &Path), the directory walk's ImportOne closure receives the
target, and create_collection_node, import_blob_document and import_from_url
take it too. The cbz/cbr dispatch is comics::import_comic_file, which the
comic tree calls directly instead of looking itself up in the table.

SupportedFileType's functions are private behind import/import_directory,
which (like import_file and import_from_url) mark the new document present on
the importing node. Every frontend did that by hand after these calls
(gui import_job::mark_present and Documents, the daemon's mark_present,
Android's ImportBatch), so those copies go. Fixes: the desktop Comics folder
import (import_comic_tree) marked nothing present, and the desktop's and
Android's URL imports didn't either; both now do, as the daemon's did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:26:01 -07:00
Greg ShuflinandClaude Opus 5.5 689ad5b276 rust: one reading module for PDF and comics reading page and bookmarks
agents::reading holds the reading page, PageBookmark (a uniffi Record with
label: Option<String>), list/add/delete bookmark and DeleteBookmarkError.
agents/pdf.rs and agents/comics.rs each had a byte-for-byte copy, and
ffi/pdf.rs re-implemented all of it inline (looking the node key up by hand
three times); ffi/comics.rs copied PDF's get_reading_progress. Both FFI
objects now delegate in one line, BookmarkInfo (which turned a missing label
into "") is gone, and Kotlin reads the label as nullable.

Behavior: list_bookmarks and reading_page now return their store errors
(the desktop keeps its old default of empty / page 0); the listing is sorted
by (page, id) everywhere; ComicsFfi::add_bookmark takes an optional label and
returns the new id like PdfFfi's. ComicMetadata::info_rows and the
comic_info_rows export had no caller anywhere and are deleted, so the PDF's
info rows stay where they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:36:06 -07:00
Greg ShuflinandClaude Opus 5.5 adab0c1ffd gui: TextInput builds its text runs with the multiline element's builder
TextInput split its line into three runs by hand to underline an IME
composition — what multiline_text_element::build_content_runs already does
(audit C3.2). It now calls that with no selection, since a single-line field
paints its selection as a quad; the runs, and so the field, are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 9b3c698a0f gui: import gpui types in components, settings and the top level
About forty gpui::Type paths in code (not doc comments) across twenty files in
this batch's scope, and crate::sizing::scaled in three, are now imported
names, per the Code Style rule against fully-qualified type paths. Mechanical;
text_editing's macro keeps its $crate/gpui paths on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 3f5d33f113 gui: resizable dividers drag through gpui's drag; screens stop wiring it
Every screen with a resizable sidebar (Notes twice, PDF, Pictures) had to wrap
its root in resizable::on_drag, which hung mouse-move/up/up-out handlers and
the resize cursor on the whole screen while a drag was in flight, so the drag
kept tracking once the pointer left the thin strip (audit M12). The divider
now does that itself: gpui's on_drag/on_drag_move follows the pointer
window-wide and keeps the strip's cursor, and its own mouse-up and
mouse-up-out end the drag. resizable::on_drag and its four call sites go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 7b3c0e6475 gui: the scrubber and speed slider are one slider, dragged through gpui's drag
render_scrubber and render_speed_slider drew the same groove, fill and ringed
thumb and did the same ratio-from-cursor math (seek_to_cursor,
drag_speed_slider) (audit M1). They are one slider() with its proportions in
a SliderLook, ratio_at, and an on_slide that sets the value on a press and on
every move of a drag begun there. The drag now goes through gpui's
on_drag/on_drag_move, which follows the pointer window-wide, so a scrub or a
speed drag no longer stops dead when the pointer leaves the 24px strip (B6).
The speed thumb sits half a pixel higher, now centered on its groove.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 92dac4bb67 gui: the player thumbnail builds one img for either kind of art
TrackArt::Url and ::Decoded built the same cover element twice; they map to
an ImageSource first (audit M9).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 511a9648dd gui: remote_fetch's tone is one function, and callers use the copy constants
The wedged/failed/ordinary color match was written in both remote_fetch_note
and remote_fetch_line; it is tone(). cancel_label()/try_again_label() only
wrapped content_fetch::CANCEL/TRY_AGAIN, which Comics, Pictures and PDF now
name directly, and remote_fetch_note is private — only the overlay calls it
(audit M8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 3b5da91131 gui: ZoomableImage's small redundancies
set_content is replace_bitmap plus a scroll reset; pan_by only forwarded to
scroll_by (pan_for_key now calls scroll_by, which takes the doc); the two
scrollbar blocks are one loop over the axes; and render_content takes the
image rather than an ImageSource the Render impl built for it (audit M7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 8568fac23e gui: TextInput and TextArea share their field chrome
Both text widgets set the same field background, border, focus/invalid
border color and rounding (audit C3.3); text_editing::field_chrome is that
box once. TextArea passes invalid=false, so it draws exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 d7aef8f9d5 gui: the last work-then-apply spawns in settings and teardown use spawn_then
Revocation import, the System tab's public-key load and the teardown wipe
each spelled out cx.spawn + background_spawn + this.update + notify, the
shape G1's background::spawn_then already names (left for this batch in the
G1 handoff).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 374876908a gui: one eviction dialog and one checked execute for Delete Locally and Reclaim
The document detail screen's "Delete Locally" and Settings' "Reclaim…"
drew the same evidence dialog (verified / unverified / shared lines, Cancel,
the override-gated "… anyway", the confirm) and ran the same execute step
that re-reads the unverified-deletion override before honoring "anyway"
(audit S2). Both are now components::eviction_dialog: eviction_dialog() takes
the words that differ and the view's cancel/confirm, and execute() is the
checked run. Reclaim's dialog reads the override from the Settings view's own
field instead of a SQLite read on every frame. Words and layout unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 17346b270d gui: one ActionOutcome for every control's last-action line
Documents' AsyncActionState, Ensemble's ActionState and Settings'
RunNowState and RemoteTriggerState were four spellings of Idle / Pending /
succeeded-with-a-sentence / failed-with-a-sentence (audit S3). They are
job_binding::ActionOutcome — the half of a control's state the module doc
already said a job can't carry — with from_result for the "Ok → Done, Err →
Failed" match each result handler wrote, and is_pending. Settings' two
status-line matches are one widgets::outcome_line. Each screen keeps its own
colors and rendering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 67e3f30e8a gui: the Sync tab's local and remote policy tables share their rows
render_local_sync_policy and render_remote_sync_policy built the same
SyncPolicyControl per content-bearing type, differing only in where the
policy and ledger came from and whether Reclaim is offered (audit S7); both
now go through policy_rows. set_scheduler_enabled and set_scheduler_interval
were one persist-log-notify body (S12) and are one set_scheduler; the content
ledger reload is spawn_then, and sync.rs no longer spells out
std::sync::Arc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 77f8fcbf92 gui: Ensemble reads the display timezone from the app global
The roster load and a member's Sync both re-read the settings row from SQLite
for the timezone the app already holds as a gpui global (audit S10); they now
take cx.timezone() when the work is started. Same zone either way (Settings
writes both), minus a store read per load and per sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 c38b91ba31 gui: Ensemble's retire and reset confirmations are one dialog
render_retire_dialog and render_reset_dialog were one dialog written twice
behind two bools (audit S6): same modal, title, two notes, Cancel and a danger
button. They are one ending_confirm: Option<Ending> and one
render_ending_dialog that picks its four copy constants by Ending; the words
are unchanged. The self-revoked banner and the load-error box share the red
error_card they both drew by hand (S13).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 48b7920ffe gui: agent screens are built from one AppServices bundle
SynchronicityView::new passed the same store, node, node runtime, tokio
handle and job runner (plus the media session or nav status for some) to
eleven agent constructors, five to seven loose arguments each in whichever
order that screen listed them, and cloned store/node_runtime/rt aside first
so it could keep its own after the last move (audit T2). Each of those
screens now takes (&AppServices, &sidebar, cx) and destructures what it uses;
app.rs builds the bundle once. Constructors only — no screen's behavior
changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 8f64e58f4c gui: NodeRef and DocumentRef are one IdentityRef, its id a HashChip
node_ref.rs and document_ref.rs were one component written twice (audit C6):
identical render bodies around node_display::resolve and
document_display::resolve, each with a TODO for the copy-to-clipboard HashChip
already does. IdentityRef::node/document resolve the name and render it
beside a HashChip, so the Jobs screen's peer and document ids are now the
app's standard bordered chip (full id on hover, click copies) instead of dim
mono text with a whole-row tooltip — a visible change, on that screen only.

hash_chip()'s wrapper (an unused theme argument around HashChip::new and an
optional prefix) is gone for HashChip::new(..).prefix(..) at its twelve call
sites, and availability.rs's label() forwarder for DocumentAvailability::label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 85c735d541 gui: window-geometry saves debounce by replacing a Task; one popout::open
Root and PopoutWindow each kept a bounds_save_generation counter, bumped it on
every bounds change, and had the delayed save compare it afterwards — hand-
emulated cancellation (audit T3). They now hold the pending save as a Task and
replace it; debounce_geometry_save returns that task, and the SQLite write it
runs has moved off the UI thread onto the background executor.

popout::open and open_at differed only in where the window went (T4): there
is one open(.., WindowBounds, ..) plus cascade_bounds, and app.rs's pop_out
and restore_popouts share one open_popout for "open, record, watch for close".
popout.rs no longer spells out crate::root:: paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 1968d09ac0 gui: no unwrap or expect left in gui-app's top level
main's tokio runtime build is a ? (main returns io::Result), the quit
shutdown handle's mutex tolerates poisoning, and a reqwest client that can't
build (only if TLS won't initialize) is logged and leaves gpui's default HTTP
client in place — remote cover art won't load — instead of panicking at
startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 8cacdbe922 gui: the player's ticker runs only while something plays; transport resets are helpers
Every PlayerControls — one per media screen — woke every 200ms for the life
of the app, playing or not (audit B7). The ticker now starts with playback
(play, restart, resume) and ends at the first tick that finds the bar paused
or stopped.

The transport reset was written out by hand five times (M4): play repeated
replace's load line for line, the Preempted handler repeated stop, and the
ticker's two end-of-track branches were identical. They are load_track, halt
and finish_track; emit_position still flushes before any reset. The custom
speed dialog's three fields (open flag, value before, live value) are one
Option<SpeedDialog> (M6), and the fifteen audio.lock().unwrap() calls go
through a lock helper that tolerates a poisoned mutex (M10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 b0975bcde0 copy: one hours-aware playback clock, copy::clock
The M:SS / H:MM:SS formatter was written in the player (format_time), in
lib's music track list, and reached by video through the player; the listen
progress line in copy::artifact_value::summary had its own M:SS with no hours
field, so a 90-minute position read "In progress: 90:00" (audit M3). All now
use copy::clock(total_secs); each caller keeps its own rounding (a track
length rounds, a playback position truncates).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 0d45f062c8 gui: syntax highlighting is one language table, built per language and cached
syntax_highlight registered its 21 languages with 21 near-identical calls
(audit M5); they are one LANGUAGES table of aliases, grammar and queries (the
TypeScript-over-JavaScript and C++-over-C query deltas are just two queries in
a list).

Bug (B5): the first code fence in any language compiled every grammar's
queries on the UI thread, and every code block was re-parsed on every render —
every keystroke in Split mode. Each language's configuration is now built the
first time a fence in it is highlighted, and highlight() remembers the spans
for recent (language, code) pairs, so re-rendering an unchanged block is a hash
lookup. Highlight output is unchanged; it hands back shared spans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 fc0e4767fe gui: one RGBA-to-RenderImage conversion, and formulas keep their image
render_image::rgba_to_render_image, thumbnails::to_render_image and the PDF
viewer's to_render_image were three BGRA swaps (audit M2). render_image.rs now
has the one to_render_image(RgbaImage) and a from_rgba(buf, w, h) that returns
None instead of panicking when a buffer doesn't match its size; the PDF page
and thumbnail paths and the typst preview go through them (a mismatched page
bitmap is logged and the previous page stays up), and Notes' image embed
converts its decoded RgbaImage directly.

Every formula in a note preview built a new RenderImage from a clone of its
RGBA bitmap on every frame — a new image id each time, so every formula on
screen was re-uploaded to the GPU atlas per repaint (B4). markup_view keeps
one image per cached MathRender (keyed by the Arc, pruned when the math cache
drops it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 aa8560bb6e gui: the zoom control's scale group fills with accent_fill under its text
In explicit-zoom mode the −/percentage/+ group sat on theme.accent with
text_on_accent over it; theme.rs reserves accent for marks and requires
accent_fill under any text (white doesn't reach AA on the brighter one), which
is what the Fit segment beside it already uses (audit B3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 cb13c2568e gui: the Import… pickers don't block the UI thread
import_trigger opened rfd's synchronous FileDialog inside a menu item's click
handler, so the window stopped repainting and taking sync arrivals for as long
as the native dialog was open (audit C10, an instant-feedback violation). It
now awaits rfd::AsyncFileDialog in a window task and hands the picked paths
to the same callbacks, as Notes' Export to PDF already does. Filters and titles
are unchanged; this was the last synchronous file dialog in gui-app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 c7e8739eca gui: tree indents, the indeterminate bar and the player thumbs follow UI zoom
Three raw px() in render code measured against rem-sized neighbors, so each
was right only at 100% zoom: the notes/pictures tree's row indent
(pl(px(8 + 16n)), written twice; audit C8), the indeterminate progress bar's
slide (left(px) against a scaled() width; B1), and the scrubber and speed
thumbs' centering offsets (B2 — the speed thumb's -7px was also half a pixel
short of its 15px icon::SM). The indent is now INDENT_BASE/INDENT_STEP in
scaled() via one row_frame shared by the editing and normal rows; the others
are scaled() or derived from the thumb's own size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 e37a4b9063 gui: one hairline, and every rule is flex_none
The one-pixel divider was spelled out by hand at a dozen sites, half without
flex_none — and in a squeezed flex_col a 1px child that may shrink collapses to
0 and the rule vanishes (audit C7). reader::hairline moves to
section::hairline and is used for the rail's two rules, markup headings and
thematic breaks, the Pictures sidebar, the document list header, the document
detail's section divider and the popup menu separator. The collapsed rail's
inset rule keeps its margins and gains flex_none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 3dfea0f9e9 gui: a focused text field redraws per caret blink, not every frame
TextInput's and MultilineTextElement's paint called request_animation_frame on
every paint while focused, so a focused field kept its window redrawing at the
display's refresh rate for as long as it held focus, to animate a 500ms blink
(audit C3.4). text_editing::paint_caret now paints the caret and schedules one
timer for the next toggle; EditableText::claim_next_blink hands each toggle out
once, so a paint for another reason doesn't start a second timer and a
keystroke (which restarts the phase) starts a fresh one. The blink phases
themselves are unchanged. The 2px caret width is one CARET_WIDTH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 1734bd9cb2 gui: MultilineTextElement reads its widget through MultilineEditing
MultilineTextHost's only implementor was a blanket impl over MultilineEditing,
so the element now takes H: MultilineEditing directly and reads buffer(),
placeholder() and height(); it also stops taking a focus handle its widget
already owns. The measured-layout .expect in prepaint is a let-else that
paints nothing, and the module doc no longer claims gpui's
index_for_position is private — it is public now, but panics before layout,
which is why ours stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:31 -07:00
Greg ShuflinandClaude Opus 5.5 cd22eb1315 gui: one suggestion list for Combobox, TagInput and the palette; lib fuzzy::rank
Combobox, TagInput and CommandPalette each declared their own SelectNext/
SelectPrev actions and up/down bindings, their own wrapping select_next/
select_prev, the clamp-the-highlight block and (the two dropdowns) the same
panel and row styling. components/suggestions.rs now holds the actions,
bind_keys(context), a Highlight, a Suggesting trait with one step() and
navigate() wiring, and panel()/row(). The frizbee config with its typo cap
(written in Combobox, TagInput and lib's find_human_by_name) is lib
synchronicity::fuzzy::rank; gui-app drops its direct frizbee dependency.

Bugs (audit C2): TagInput's dropdown was an in-flow absolute child at a magic
40px offset, so it was clipped by and painted under later siblings; it now
floats like Combobox's does. TagInput's focus handle was never tracked, so
focusing it did nothing; it now hands out its field's handle, as Combobox does.

The "capture the control's bounds, float the popup below it" code the
dropdown menu and Combobox each wrote (audit C9) is components/anchor.rs:
anchored_to (below, or above for TagInput's open_upward) and
track_first_child.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:12:30 -07:00
Greg ShuflinandClaude Opus 5.5 9202aae376 gui: right-click menus take focus; one AnchoredMenu, and the dead menu API goes
The rail's and the notes tree's right-click menus were never focused, so
Up/Down/Enter/Escape did nothing in them (audit C1). A PopupMenu now focuses
itself on its first paint — in whichever window paints it, which also covers a
popped-out Notes — and gives focus back on dismiss unless the chosen item moved
it on purpose (Rename's inline editor, a dialog field).

app.rs and notes each hand-rolled the same "record the right-click, build the
menu on the next paint, subscribe to DismissEvent, float it" state machine
(three fields, four methods each); both are now an AnchoredMenu in menu/mod.rs.
Since building no longer needs a Window, PopupMenu::build and the dropdown
builder take just the menu.

Deleted as unused: context_menu.rs (ContextMenuExt was never called), the
action-dispatching half of PopupMenu (menu, menu_with_*, action_context,
PopupMenuItem::action/icon/label, the Label variant, max_w, is_empty) and the
module-wide allow(dead_code) that hid it. The goal of mirroring upstream
gpui-component's API is dropped: it is built on Zed's gpui, not gpui-ce.
DropdownMenuState's close reset is one method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:11:25 -07:00
Greg ShuflinandClaude Opus 5.5 b8fed7d593 gui: Ensemble's "Sync both ways" re-enables after a sync it didn't start
load.rs seeded a row's sync state as Pending when a CatalogSync for that peer
was running at load time, and carry_over_actions carried that Pending across
every later refresh; only this screen's own click ever resolves it, so after a
scheduler-started sync the button stayed disabled for good (audit S1a). Rows
now load Idle — the button already asks ActiveJobs about the job itself.
reload and refresh share one load helper over spawn_then, and the job watch is
ActiveJobs::watch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:08:04 -07:00
Greg ShuflinandClaude Opus 5.5 86b4e0314e gui: Settings' Credentials buttons enable when they should, and disabled Settings buttons are inert
Credentials' Save passed `!has_input` as its enabled flag, so it greyed out
exactly when there was something to save; Clear, its confirm and Cancel
passed `false`, so they always drew disabled (audit S1b). The clicks still
fired because a disabled button only restyled. Every Settings button whose
enabled flag is computed now attaches its handler with on_click_if, so the
node-name Save, Sync now, Run now, the revoke dialog and a member row's
Ping/Sync both ways commit nothing while drawn disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:07:14 -07:00
Greg ShuflinandClaude Opus 5.5 0c79c28e9f gui: Humans opens and closes its create dialog through one method
The header's and the empty state's "Add" buttons each cleared the name field
and opened the dialog inline, and the backdrop and Cancel each closed it
inline; set_create_open does both. Same behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:00:00 -07:00
Greg ShuflinandClaude Opus 5.5 7983eb85be gui: SyncGuarded::current_mut, and one queue edit path
The podcast list's four optimistic row edits each cloned the whole episode
list to change one row and set it back; SyncGuarded::current_mut edits in
place (set_current was already a plain assignment). The queue's move up, move
down and remove were three copies of guard / optimistic local change /
background write / log; PodcastQueueView::edit is that sequence once. Same
behavior and log lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:59:30 -07:00
Greg ShuflinandClaude Opus 5.5 3fcac3eaa2 gui: one "is the bar holding this episode?" rule, and one Play control
The podcast list, episode screen and queue each tested id match plus
has_audio_session against the transport bar (with the same comment about
set_pending), and the episode screen and queue each mapped the answer to
Play/Pause/Resume. PlayerControls::holding and
podcasts::episode_actions::play_control hold those once. Same labels, same
behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:58:17 -07:00
Greg ShuflinandClaude Opus 5.5 65bb9fc032 gui: the podcast screen's Subscribe control is search's subscribe_button
detail.rs wrote out the same Subscribe / Subscribing… / Subscribed ✓
tri-state and sizing as search.rs's subscribe_button; it now uses it.
Render-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:57:30 -07:00
Greg ShuflinandClaude Opus 5.5 dcff113661 gui: held-change Show buttons and the properties Edit use copy::action
Documents' and the podcast episode list's held-change strips spelled "Show"
and the document detail's properties control spelled "Edit" where
copy::action::SHOW and ::EDIT exist for exactly these (the former's doc names
every changed-elsewhere marker). Same words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:56:15 -07:00
Greg ShuflinandClaude Opus 5.5 9854ba295f gui: one header_search_field for Notes and Documents
Both screens framed their bare search input in header_search and showed
header_search_clear while it held a query, identically apart from the id and
what clearing does. components::screen_header::header_search_field is that
field once. Render-identical. The PDF library's header search has no clear
button, so it keeps its own frame (a decision, see handoff).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:55:09 -07:00
Greg ShuflinandClaude Opus 5.5 82d129f9a6 gui: Notes forgets the open body through one helper
Three places (the open note vanishing from the tree, selecting another note,
switching notebook) each stopped the body watch, dropped the stored baseline
and any held incoming body, and republished the rail slot, four lines apiece.
forget_open_body does it once. Same behavior; Notes' hand-rolled guard is
otherwise left as is (see handoff on SyncGuarded adoption).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:54:24 -07:00
Greg ShuflinandClaude Opus 5.5 e9596366ec gui: MediaLibrary::new takes its tabs
Each of the four library screens constructed a MediaLibrary with an empty
message for a placeholder "library" tab, then immediately replaced the tabs
(repeating that message). new() now takes the tab list; the placeholder tab
and its DEFAULT_TAB_ID are gone. The first tab is active, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:53:31 -07:00
Greg ShuflinandClaude Opus 5.5 4ccee0c0b6 gui: MediaItem carries its DocumentID; drop the always-true activatable
Every MediaLibrary consumer built items with `activatable: true` and an id
stringified from a DocumentID, then parsed ItemActivated's string back into a
DocumentID. MediaItem::id and ItemActivated are DocumentIDs now, and the
activatable knob (true at all six construction sites) is gone, its rationale
folded into availability's doc. Render-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:52:19 -07:00
Greg ShuflinandClaude Opus 5.5 190af3cc91 gui: disabled buttons are inert — OnClickIf::on_click_if
button()'s doc asks every caller to gate its click handler on the same
`enabled` it drew with, since the disabled look alone does not stop a click.
Some callers did (`let mut btn … if enabled { btn = btn.on_click(…) }`, and
the podcast queue's own gated_button); most did not. on_click_if(enabled, …)
is that gate as one call, used at every agent button that draws a disabled
state.

Bug fix: disabled buttons that still fired now don't — most visibly the
podcast episode list's "Loading…" (Refresh) button, which restarted the
fetch it said was running; also PDF/Pictures/Comics prev/next at the ends,
Pictures' rotate with no image, Humans' Save with nothing changed, and the
document detail's busy Sync/Pull/Push/Purge/Delete-locally controls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:50:46 -07:00
Greg ShuflinandClaude Opus 5.5 38bdb65bef gui: TextInput/TextArea::with_content for fields seeded as they are made
Humans, Notes' dialog and the document detail's property editor each built a
field with new, then set_content on a mutable binding. A with_content
builder does that in the chain; the property editor's five typed branches
become one local constructor. Humans' seeded fields drop their empty-string
guard, since setting empty content is a no-op on a new field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:48:34 -07:00
Greg ShuflinandClaude Opus 5.5 6dc622bcec gui: Pictures and QR convert bitmaps through thumbnails::to_render_image
Both re-implemented the RGBA-to-BGRA swap and RenderImage build that
thumbnails::to_render_image already does. Render-identical. (QR decoding via
gpui::Image::from_bytes was not adopted: gpui loads that source
asynchronously and caches every image, so the preview would blank for a
frame per keystroke — see handoff.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:47:33 -07:00
Greg ShuflinandClaude Opus 5.5 003b1a9245 gui: one error_line for errors said under their control; one set_field_error on Humans
The small error-colored sentence under a field or control was spelled out in
Humans (5x), Podcasts (2x), Clippings (2x) and the document detail; it is
components::note::error_line now. Humans' four identical set_*_error methods
(mark the input invalid, set the sentence under it) become one
set_field_error over a FormField enum, and labeled_field_with_error builds its
label with field_label like its siblings. Render-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:46:30 -07:00
Greg ShuflinandClaude Opus 5.5 85e82b592f gui: one dismissible error box for Documents and Notes
Documents' error_banner and Notes' render_error were the same bordered error
sentence with a ✕ (Notes floats it at the bottom over a window-colored
ground). components::note::dismissible_error is that box once; each screen
keeps its own placement. Render-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:43:59 -07:00
Greg ShuflinandClaude Opus 5.5 857e20dbee gui: podcast screens share one card and one description block
The bordered card (p_4, card background and border, CARD radius) was spelled
out seven times across the podcast screens, and the "Description" heading
over a card twice (detail and episode). podcasts::card and
podcasts::description_block hold them once. Render-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:42:50 -07:00
Greg ShuflinandClaude Opus 5.5 aa0ef6035e gui: Humans' circle tabs are tab_bar and tab_pill
render_circle_tabs/render_circle_tab re-implemented components::tabs line for
line apart from the bar's height (control::ROW_XL) and padding (px_4); it now
uses tab_pill and tab_bar with those two overridden. Render-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:40:58 -07:00
Greg ShuflinandClaude Opus 5.5 6064020390 gui: shared reader chrome for Comics, PDF and Pictures
The three readers each built the same toolbar strip, fixed and flexible
toolbar groups, page-counter caption, "Zoom:" group, hairline, media canvas
and absolutely-positioned zoom layer, and Comics and PDF each declared
PAN_STEP and matched up/k/down/j by hand. These now come from
components::reader and ZoomableImage::pan_for_key. Render-identical; the PDF
toolbar keeps its zoom controls without the "Zoom:" label the other two have
(left as a decision, see handoff). The library's two hairlines use the same
helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:40:11 -07:00
Greg ShuflinandClaude Opus 5.5 06841fd720 copy: the podcast episode list's held-change sentence lives in copy.rs
detail.rs wrote "N new episodes." / "Episodes changed elsewhere." on the
screen; it is now copy::podcast_episodes::held, worded exactly as before.
Android's strip still says only "Episodes changed elsewhere." (the zero
case) — whether it should count too is left as a decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:29:02 -07:00
Greg ShuflinandClaude Opus 5.5 e8a7b566f1 gui: one incoming-change banner component for Notes and the podcast queue
notes' render_incoming_body_banner and the queue's render_incoming_banner were
the same strip (icon, copy::incoming_change sentence, take/keep buttons) with
different ids and handlers. components::incoming_banner is that strip once —
the desktop counterpart of Android's ui/IncomingBodyStrip. Render-identical.
Clippings keeps its in-card column variant (a layout decision, see handoff).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:28:09 -07:00
Greg ShuflinandClaude Opus 5.5 11eeca1334 gui: one import pipeline for the five library screens
Comics, Music, PDF, Pictures and Video each wrote an import-files and an
import-folder method (ten ~50-line copies of start job, walk, filter, import,
mark present, finish, report first error, re-read). They now implement
import_job::ImportHost (their file type, their fields, what re-reading means)
and call import_job::import_files/import_folder, which import through the
lib's SUPPORTED_FILE_TYPES table — so comics stops re-implementing the lib's
cbr/cbz dispatch. Comics' folder import keeps import_comic_tree, through the
shared import_job::run.

Small behavior fixes: Comics and Video now tick progress past a skipped file
the way the others did (the job's total counts every picked path, so its bar
could stop short of the end); Music clears its artist-mosaic cache after a
folder import too, not only after a files import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:15:52 -07:00
Greg ShuflinandClaude Opus 5.5 0b3a54d7e7 gui: podcast episode actions shared by the list's cards and the episode screen
Save (as a tracked job), remove, queue, and the Save/Saving/Remove and
Queued/Add control states were written out twice, in podcasts/detail.rs and
podcasts/episode.rs; they now live once in podcasts/episode_actions.rs, each
screen keeping only its own optimistic bookkeeping. EpisodeMeta carries the
list's EpisodeRow instead of restating eleven of its fields, so
to_new_episode exists once, and the episode screen's own saving flag is the
row's.

Bug fix: a refused second save on the episode screen said the raw lowercase
clause ("a save of that episode is already running"); it now shows the same
sentence the list does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:13:17 -07:00
Greg ShuflinandClaude Opus 5.5 c3ff6e861c gui: Notes' persist and manager helpers
Five best-effort preference writes (active notebook/note, view mode, math
dialect, split fraction) were each a ten-line clone-spawn-build-log block;
NotesView::persist(what, write) is that block once, with the same log
lines. manager(store, node) replaces the twelve spelled-out
NotebookManager::new(&store.doc_store, &store.artifact_store, node) (audit
N1, the part spawn_then didn't already cover).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 ba06b32802 gui: give each Documents spinner its own element id
import_spinner handed every spinner on the Documents screens the id
"docs-import-spinner" — the load-more row, each Placement row's override
and job line, each Artifacts row's job line and the Danger zone's — so any two
on screen together shared one animation state (audit bug 15/D22). The
misnamed wrapper is gone; each call names its own id (keyed by row where
there are rows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 d7599670df gui: Documents sizes use copy::format_bytes
The Documents screens had their own format_bytes in SI units (1000) with no
GB tier, so the same file showed a different size here than in the TUI and
on Android, which use the lib's copy::format_bytes (1024, with GB) (audit
bug 10). The local copy is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 21fe15c97a gui: no expect/unwrap left in agents' non-test code
render_detail and Notes' render_dialog take the Detail/Dialog their caller
already matched instead of expect()ing it back out; Humans' save returns if
the detail is gone rather than expecting it; the PDF thumbnail skips a
bitmap whose buffer doesn't match its size instead of panicking; the math
cache capacity is a NonZeroUsize const checked at compile time; Music's
player and the PDF viewer's loaded-document cell recover a poisoned lock
(audit bug 17).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 b141e6da61 gui: put misplaced doc comments back on their items
Comics, PDF, Music, Notes and Humans each had doc comments that had slid onto
the wrong item (a glyph's doc above a tab id, an import doc above the library
build, three orphaned helper docs stacked on Humans' today()) or described
constants that no longer exist; each is moved to its item or deleted (audit
bug 19). The PDF viewer's own THUMB_MAX_EDGE was a copy of
thumbnails::THUMB_MAX_EDGE (same 480) and now uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 3ed9c4093c gui: drop an orphaned doc comment spliced onto row_sizes
Two doc comments had run together above documents::row_sizes: its own, and
one for a per-row "all blob-backed content here" function that no longer
exists (the column reads lib content_ledger::subtree_content now, which
documents itself). Queued from the lib-stores batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 5765a63d05 gui: SettingsStore::membership() at the five one-read sites
Documents detail (purge broadcast, ensemble_peer_ids, placement rows) and
Settings (remote peer list, ensemble load) spelled out
settings_store.batch(|tx| settings_store.load_ensemble_membership(tx)); the
lib now has that as membership() (queued from the lib-p2p batch). The
presence survey's read stays as it was: it shares its transaction with the
node-name read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 8332005101 gui: Follow policy and the PDF no-match sentence come from copy.rs
The Placement picker's "Follow policy" is copy::sync_override::FOLLOW_POLICY
and the PDF library's "No PDFs match “q”" is copy::library_empty::no_match,
which Android already uses (both queued from the FFI batch). Same words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 758f437585 gui: Documents date filter comes from copy::date_filter
DATE_FILTERS/DateFilter restated the lib's copy::date_filter::OPTIONS (the
keys the search query takes and their labels), and "Any Date" was typed
twice in list_view; all three now read the lib's (queued from the FFI
batch). Same keys, same words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 94008126f9 gui: copy::busy::sentence in place of byte-slice capitalizing
import_job's already_running and podcasts' busy_saving capitalized a busy
clause with clause[..1].to_uppercase(), which panics on a multibyte first
letter; the lib's busy::sentence does it by char (queued from the FFI batch).
Both one-use wrappers are inlined into their only call site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 88300275cd gui: network_task::on_endpoint(_as_job) for the endpoint → run_msg → apply shape
Five documents-detail actions (pull, per-artifact pull, push, remote
override, delete-locally evaluation) and six settings ones (ping, peer sync,
reclaim evaluation, remote sync config read/write, Run now) each read the
endpoint, cloned the runtime, spawned, mapped a missing node to "P2P not
running", awaited run_msg, and applied the result; the tracked ones also
wrapped the job handle in an Arc to report progress and finish it. The two
helpers do that once (the job is finished whether or not the view is still
there), with the sentence as network_task::P2P_NOT_RUNNING until copy.rs
takes it (audit D2/X3). Also moves push_document's doc comment back onto it
from row_job_control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 8ab6b78d08 gui: scheduler driver runs synchronicity::scheduler::tick
The loop body (read settings, run every pass when enabled, log the outcome,
clamp the interval) is the lib's scheduler::tick now, which the daemon
already uses; MIN_INTERVAL goes for scheduler::MIN_TICK_INTERVAL (queued F14
from the frontends batch). Log sentences are identical. The per-tick
set_job_runner and the startup wait stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 e610f1f06c gui: NodeSlot for the shared P2P node, in place of Arc<Mutex<Option<Arc<NodeRuntime>>>>
The type was spelled out 29 times and read through 29 .lock().unwrap()s (a
poison-panic each), 17 of them the same .as_ref().map(|rt| rt.p2p.endpoint()).
network_task::NodeSlot wraps the same cell with get/endpoint/set and recovers
a poisoned lock; the embedded daemon socket still gets the raw cell
(shared()), since syn-daemon reads it per request. A watch channel ticks on
every set, so the scheduler's startup wait is NodeSlot::ready under a timeout
instead of a sleep-poll loop (audit gui-core T1). Also drops the stale
"inferred iroh Endpoint, so this crate avoids a direct iroh dep" comments —
the type is the lib's EnsembleEndpoint — and moves comics' fetch_page_remote
doc back onto it from fetch_page_for_reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 71948f5db2 gui: hold Tasks instead of generation counters in podcasts and pomodoro
Podcast search (debounce + in-flight search), the podcast detail's episode
fetch, and Pomodoro's tick loop each bumped a u64 and had the stale task
check it before applying. Replacing a held Option<Task<()>> drops, and so
cancels, the task it supersedes, which is the same thing with less
bookkeeping (audit P8). Documents' page fetches keep their counter: load_more
shares it without superseding, which one Task slot can't express.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 8736a5c9ee gui: background::spawn_then for the work-then-apply shape in agents
About 50 agent call sites spelled out cx.spawn → background_spawn(work).await
→ this.update(apply; cx.notify()).ok(). background::spawn_then(cx, work,
apply) is that shape once; it returns the Task so the sites that hold one in
a field (search, episode fetch, typst compile) still can. Fire-and-forget
writes that only awaited a background task and never touched the view become
a bare cx.background_spawn(..).detach(). Converted mechanically (script),
only where the body was exactly that shape; apply now always repaints, which
at worst adds a harmless notify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 0537c7525e gui: cx.background_spawn in place of cx.background_executor().spawn
gpui's AppContext::background_spawn (on Context and AsyncApp alike) is the
same call; podcasts already used it, the other 87 sites didn't. Mechanical,
across the crate (rustfmt had been splitting the long form over three
lines).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 ba825b2796 gui: document detail uses copy::artifact_value::summary
summarize_artifact_value was a near-verbatim copy of the lib's
copy::artifact_value::summary (queued F2 from the frontends batch), down to
the Gender words, plus an unreachable! for blob-backed kinds. The row now
calls the lib with the same 120-char middle cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 a09685a7b1 gui: PDF zoom re-render checks the document as well as the page
rerender_current_page applied its bitmap if the page index still matched,
so switching to another PDF at the same page while a re-render was in flight
painted the old document's page over the new one (audit bug 14).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 ba404c6111 gui: decode a comic's cover whenever its first page is here
load_thumbnails only decoded a leaf comic's cover when the comic was Local,
but a comic is Remote as soon as any one page is missing, so covers sitting
right here were withheld (audit bug 13). Android fixed the same thing
deliberately (ComicRepository.kt coverComicId); a first page that really is
absent just fails the load and leaves the placeholder, as collections already
did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 fb29f766eb gui: Jobs uses JobSubject::document()/peer() and copy::job_history_times
The screen spelled out JobSubject's variants twice (job_peer,
referenced_document_ids) and built "Started … · Finished …" by hand; the lib
now has those as JobSubject::document()/peer() and copy::job_history_times
(queued from the FFI batch). Also moves reload's doc comment back onto
reload — it had slid onto publish_nav_slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 3b304c6b98 gui: Jobs history follows a timezone change immediately
JobsView kept its own copy of the timezone, read from the settings store on
each reload, so after a Settings change its history timestamps stayed in the
old zone until the next job event (audit bug 12). It now reads the display-tz
global (cx.timezone()) at render, like every other screen, which
timezone::set already repaints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 f05e3952c4 gui: show video durations past an hour as H:MM:SS
The Video library's format_duration wrote M:SS only, so a 83-minute clip read
"83:20" — the rendering player_controls' format_time already calls wrong
(audit bug 11). It now delegates to format_time (made pub), keeping its
round-to-nearest-second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 25e8873757 gui: don't block the UI thread on Notes' Export to PDF file dialog
export_to_pdf opened a synchronous rfd::FileDialog inside the click handler,
so the window could neither repaint nor take sync arrivals while it was open
(audit bug 8, instant-feedback invariant). It now awaits rfd's
AsyncFileDialog (which runs the portal call on its own thread) from the spawned
task. components/import_trigger.rs has the same problem; it's the components
batch's (C10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 5dbcd53167 gui: mark media-screen imports present on this node
The Documents screen and Android's import record each imported document as
present on the importing node; the Comics, Music, PDF, Pictures and Video
screens never did, so their imports didn't count toward this node's holdings
(e.g. the Ensemble screen's per-node document counts) (audit bug 6). The mark
moves to import_job::mark_present, used by Documents and by each media
screen's file and folder imports. A comic *folder* import (lib
import_comic_tree) is not covered — it returns no ids; queued for the lib batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 fa6fe10d8b gui: re-read Humans and Podcasts on arrival
Both screens follow the store only while on display (watch_on_screen), which
relies on arriving at a screen re-reading it, but neither did: a person or
subscription that synced while the screen was hidden stayed missing until the
next store event (audit bug 5). Humans now uses store_watch::follow; Podcasts
re-reads its showing tab on arrival, as switching to that tab does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 4a23c2142a gui: one store_watch::follow for re-read-on-arrival plus watch-on-screen
Comics, PDF, Pictures, Video, Music and Notes each paired a nav-sidebar
subscription (re-read on arrival) with a watch_on_screen call and the same
six-line "applied outright" comment. store_watch now has on_arrival and
follow (the two together), carrying that rationale once. Behavior is
unchanged, including that a rail click on a popped-out screen still re-reads
it (App::set_current_view deliberately doesn't for Documents/Jobs; see
handoff notes). Music keeps its focus hand-back as a second on_arrival.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:49 -07:00
Greg ShuflinandClaude Opus 5.5 f65b4d4a0f gui: don't seed an undated interaction's edit form with "date not synced"
InteractionRow stored its date as the display string, so editing an
interaction whose date hadn't synced copied "date not synced" into the When
field and Save always failed to parse it (audit bug 4). The row keeps the
Option<PartialDate>; the timeline renders the same sentence, and the edit form
starts empty instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:48 -07:00
Greg ShuflinandClaude Opus 5.5 158079c7a9 gui: fill the document detail's Created row
Detail.created_at was initialized empty and never set, so the Created row was
always blank (audit bug 3). The timestamp now comes from build_detail's
document read, next to Updated, formatted the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:48 -07:00
Greg ShuflinandClaude Opus 5.5 24ef3febf8 gui: clear the incoming-change banner when the open note disappears
When reload_tree found the open note gone it closed the editor but kept
incoming_body, stored_body, the watch task and the rail slot, so a dead banner
stayed up with buttons that did nothing and the rail kept saying "1 incoming"
(audit bug 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:48 -07:00
Greg ShuflinandClaude Opus 5.5 f1028905d0 gui: keep a stale note load out of the newly selected note
select_note applied its background load without checking that the note was
still the selected one. Click A then B, and if A's load finished last its body
showed under B's title and the next autosave wrote it into B. The apply now
returns early unless the note it loaded is still selected (audit bug 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:36:48 -07:00
Greg ShuflinandClaude Opus 5.5 30fb1d6a4a rust: MusicTreeItem's doc no longer says the list is unsorted
build_music_nodes sorts it, collections first then by title (audit A-7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:29 -07:00
Greg ShuflinandClaude Opus 5.5 4eed02cf37 rust: podcast downloads carry their errors typed, and record the copy in one transaction
PodcastError::Http(String) and Blob(String), and the seven
map_err(to_string)s that fed them, are #[from] reqwest::Error and
BlobStoreError; the release path's EvictionError gets its own Release
variant instead of being reported as 'failed to store'. The status check is
reqwest's error_for_status. save_episode_audio writes the PrimaryContent
artifact and the mark_present in one transaction instead of two (audit
P-9). A failed download's message now reads as reqwest words it
('HTTP status client error (404 Not Found) for url (...)') rather than
'404 Not Found for ...'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:29 -07:00
Greg ShuflinandClaude Opus 5.5 3ade17ce73 rust: podcast listen state is saved through one save_listen_state
save_listen_progress and save_listen_finished were the same upsert with a
different ListenState; both delegate to save_listen_state now (audit P-10).
The two wrappers stay because gui-app calls them (queued for the GUI
batch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:29 -07:00
Greg ShuflinandClaude Opus 5.5 63e9711aa7 rust: audio_player's tempo is an atomic, and its tests drive the real stretch stage
- The shared tempo was an Arc<Mutex<f64>> whose lock the cpal audio thread
  took (and .unwrap()ed) on every step; it is an AtomicU64 holding the
  f64's bits.
- StretchSource is generic over any rodio Source rather than a Decoder, so
  the tests run the real type over a SamplesBuffer instead of a test-only
  copy of its SoundTouch loop. The decoder pull is extend(take(want)).
- AudioError::Audio(String) and its map_err(to_string)s are #[from]
  variants for rodio's two errors (same message). reset_sink hands back the
  Output it reset, which removes the two .expect("sink opened above")s
  (audit P-7, B6).

Needs a manual check: music and podcast playback, including a speed change
mid-track.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:29 -07:00
Greg ShuflinandClaude Opus 5.5 77dc8c443b rust: streaming's state lock tolerates poison, and its waits are wait_timeout_while
- Eleven .expect("streaming state poisoned")s — one on the audio thread's
  read path — are one Shared::state() that reads through a poisoned lock.
- The blocking read polled with a 20 ms sleep and the reconnect wait
  looped on wait_timeout; both are Condvar::wait_timeout_while with the
  stall deadline, woken by the notify_all the downloader already sends.
- download's five mark_failed exits are one: download_into_cache returns
  the failure's message and download records it. write_at's seek was
  redundant (the file is written sequentially from the window's start).
- State derives Default; resolve_seek_target uses saturating_add_signed
  (audit P-6).

Needs a manual check: remote music playback, including a seek past the
buffered window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:29 -07:00
Greg ShuflinandClaude Opus 5.5 a7b72e2e7e rust: podcast_queue reads its entries through one helper
The list-filter-sort of the queue's entries was written four times
(list_queue, queued_episode_ids, add_to_queue, move_entry); it is
entries(). key_after(p) was key_between(p, None) and is gone (audit P-5).

Bug fix (audit B9): add_to_queue read the clock twice, so an entry's
added_at and its row's modified_at could disagree; it reads it once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:29 -07:00
Greg ShuflinandClaude Opus 5.5 5d5f6035b0 copy: tests are rstest tables, and the indentation test can see what it hunts
- The display-resolve, job_activity, placement, clippings source-line and
  pending, and content_fetch display tests were repeated asserts; they are
  rstest cases (human_display::resolve, untested before, is a case now).
  Peer-health's skew()/ahead() share an issue() builder, and two caseless
  #[rstest]s are #[test] (audit C-2).
- no_copy_string_carries_a_run_of_spaces reset its in-string state at every
  line, so it could not see the slip it exists for: a string that spans
  source lines without a \ continuation, whose indentation lands at the
  start of a line the string did not start on. It is a small lexer now
  (strings, escapes and line continuations, comments, char literals), and
  was checked to fail on such a string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:29 -07:00
Greg ShuflinandClaude Opus 5.5 a63af4c1a8 copy: one resolve_or for the three display resolvers, and tidier peer-health copy
- node_display/document_display/human_display::resolve differed only in the
  placeholder; they share a private resolve_or (audit C-6).
- peer_health::severity's two same-shape arms compute 'losing' and map it
  once; copy() no longer recomputes headline and detail to build one_line
  (C-8).
- content_fetch's module doc had landed on credentials.
- American spelling in doc comments (forever, capitalizations, parenthesize,
  spelled; music's 'anymore'). No user-visible string changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:29 -07:00
Greg ShuflinandClaude Opus 5.5 2939004852 rust: CachedMathEngine renders through its MathRenderer impl only
The inherent render and the trait impl were two copies of one method, the
trait one delegating to the other; the body now lives in the trait impl
(audit Math-1). gui-app already imports MathRenderer where it calls it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:29 -07:00
Greg ShuflinandClaude Opus 5.5 e80707e6bb rust: one typst compile path, and the dead custom-font path goes
- TypstWorld::new -> typst::compile -> comemo::evict -> map diagnostics was
  written three times (PDF export, preview, math); it is typst::compile
  now. compile_to_pdf's two identical map_err blocks are one closure, and
  compile_document no longer returns a world both callers discarded. The
  math engine's parse errors now carry typst's hints, which it used to drop
  (audit T-1).
- FONT_CACHE was an Arc<FontCache> around a FontStore; it is a plain
  static FontStore. The sources map was never written. The main Source is
  parsed once per world (Source::detached) instead of on every source()
  call, which also removes main_id()'s non-test .unwrap() (T-3).
- The custom-font path (compile_to_pdf_with_fonts, new_with_fonts,
  extra_fonts, merged_book, FontCache::infos, load_font_data) lost its only
  caller when the FFI batch removed the export; deleted with its two tests
  (T-2, lib side).
- Pixmap bytes are moved out (take) rather than copied (to_vec).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:29 -07:00
Greg ShuflinandClaude Opus 5.5 17d89bf76c rust: comics names its (entry name, bytes) page pair
extract_cbz and extract_cbr_pages returned (Vec<(String, Vec<u8>)>,
ComicInfo), which clippy's type_complexity rejects; a Page alias names the
pair everywhere it is spelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:29 -07:00
Greg ShuflinandClaude Opus 5.5 1e0e873a40 rust: markup's parser pushes each block once and drains with next_if
- parse's nine Block literals are one: each arm yields its kind and end
  offset, and the loop pushes (audit M-1).
- Peek-then-next-then-unreachable!() drains are Peekable::next_if (and a
  concat_while helper), which also removes flatten_inline's non-test
  events.next().unwrap() (M-2).
- ParagraphContent was a subset of BlockKind; parse_paragraph returns
  BlockKind. Builder gains push_link (the enter/push/exit triple, three
  copies) and push_span (the merge, two copies) (M-3).
- parse's doc comment had landed on wikilink_targets.

Bug fix: heading anchors could collide. 'Foo', 'Foo', 'Foo 1' got foo,
foo-1, foo-1; the allocator now skips any anchor already taken (foo-1-1
for the third), as GitHub does. Test case added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:29 -07:00
Greg ShuflinandClaude Opus 5.5 6693407360 rust: qrcode encodes through one helper
generate_qr_png and generate_qr_terminal each spelled out the encode and
its error mapping (audit H-12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:29 -07:00
Greg ShuflinandClaude Opus 5.5 0e8108777f rust: the Pomodoro phase words live in copy.rs
PomodoroPhase::label and completion_message were user-visible copy written
in the agent; they are copy::pomodoro::{phase, completed} now, and the two
methods (which gui-app and the FFI call) delegate. reset_phase was enter()
of the current phase (audit H-11). No wording changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:29 -07:00
Greg ShuflinandClaude Opus 5.5 061c75417e rust: clippings reads a page of clippings in one transaction
clipping_from opened two connections per clipping and found the page
title by listing every fact; it now takes the caller's transaction and
reads the one fact by id (facts::get_fact). list_page and get read in one
transaction, and update writes body and title in one (audit H-10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:29 -07:00
Greg ShuflinandClaude Opus 5.5 24ff913ee8 rust: fixtures for the repeated store-test setups
S4. facts.rs and contact.rs tests spelled out
store.artifact_store.batch(|tx| f(&store.artifact_store, tx, ..)).unwrap()
22 times; a local in_tx helper does it once, and contact's try_add takes a
label so the relabel and unnamed-protocol tests stop re-spelling the full
eight-argument add_contact_method. The document-store tests' 31 copies of
"Document::new; let id = doc.id.clone(); add_document(tx, doc)" become one
add(store, tx, type, parent) (make_note now goes through it), and
availability's two record-holder helpers take a ContentKey.

Deletes documents/document.rs's has_blob_content_matches_the_documented_set,
which restated the implementation's matches! list verbatim and so could not
fail independently of it; comic_and_podcast_feed_are_excluded stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:54 -07:00
Greg ShuflinandClaude Opus 5.5 4e8e2b153e rust: one set of search filters, one keyset cursor and one pager in document.rs
search_documents and search_document_items each built the same only_roots
/ document_types / date filter / hidden-provenance predicates; the keyset
predicate (created_at < ? OR (created_at = ? AND id < ?)) was written in
both search_document_items and list_children_page; and the "fetch one past
limit, truncate, derive the next cursor" step in both list_children_page
and search_document_items_page. They are now push_search_filters,
push_cursor and paginate (S17).

Also B2: ancestors_of's doc comment had been left sitting on
descendants_of, which then carried both.

Left alone on purpose: search_documents still filters titles in Rust (one
get_title per row) — the SQL LIKE the paged path uses folds case for ASCII
only, so moving it would change which non-ASCII titles match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:54 -07:00
Greg ShuflinandClaude Opus 5.5 5d9ff98cc6 rust: one tombstone_if_present for removals keyed by a derived id
remove_fact, remove_list_fact, remove_contact_method and the podcast
queue's remove_from_queue each spelled out "if get_artifact_by_id is None
return false, else tombstone_artifact(..., now, ...) and return true".
ArtifactStore::tombstone_if_present is that, once (S18). The derived-id
upserts already build through Artifact::with_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:54 -07:00
Greg ShuflinandClaude Opus 5.5 d2bb2eeb79 rust: derive TextFormat's name and ChangeChannel's variant list with strum
TextFormat::as_str re-spelled the serde names by hand; it is now strum's
IntoStaticStr under the same snake_case rule, with a test pinning both
spellings. ChangeChannel::ALL was a hand-kept list of all five variants,
which strum's VariantArray gives as VARIANTS without the chance of a new
channel being left off it.

Skipped from S14 on purpose: ContactProtocol's slug (frozen into derived
ids) and ArtifactKind-from-ArtifactValue (renames stored strings). The
store-local enums were done with their stores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:54 -07:00
Greg ShuflinandClaude Opus 5.5 65ed79d55f rust: the store-lock registry recovers a poisoned mutex instead of panicking
B6: three HELD_LOCKS.lock().expect("store lock registry poisoned") calls
in non-test code, one of them in Drop. They go through held_locks(), which
recovers the guard (each access is a single get/insert/remove, so there is
nothing half-updated to protect), the way change_bus already does. The
lock-file holder reader duplicated in acquire and foreign_holder becomes
read_holder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:54 -07:00
Greg ShuflinandClaude Opus 5.5 52f16c5d28 rust: graph_copy reads each table's columns from the schema at copy time
GRAPH_TABLE_COLUMNS re-listed every column of nine tables, and a test
existed only to catch it drifting (provenance was once silently dropped
from reseeded stores that way). The copy now asks pragma_table_info for the
destination's columns and names them in both the INSERT and the SELECT, so
only the table list is kept and the drift test goes with the class of bug.

Store::db_path returns the PathBuf instead of a String built through
expect("store path is valid UTF-8"), and the ATTACH, which does need text,
reports a non-UTF-8 path as StoreError::NonUtf8Path rather than panicking
(B6, twice).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:54 -07:00
Greg ShuflinandClaude Opus 5.5 9d1bfe848f rust: put ImportCounts' message doc back on message, and fix ImportProgress's
B2: message()'s doc comment had been glued onto bytes_so_far, which then
carried both. B9: ImportProgress's doc said the blob store has "no chunk
hook, so a file contributes its whole size the moment it lands", which
StoreProgress/add_partial a few lines below contradict; it now says what
happens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:54 -07:00
Greg ShuflinandClaude Opus 5.5 1dc54b0467 rust: import_from_url writes in one transaction, with its docs where they belong
Fixes B8: the document and its properties committed in one transaction and
the PrimaryContent artifact in a second, so a failure between them left a
PDF/image/track document with no content pointer. Both stores share
store.db, so it is now one doc_store.batch (UrlImportError gains the
Busy/Db pair every store error has, for the transaction's own errors).

Fixes B2 here: import_from_url's doc comment had been spliced into
download_reporting's mid-sentence, leaving "to matter." as the whole doc
of import_from_url. Also drops the (Title, FileType) match whose
unreachable! arm was a non-test panic (B6), and the three copies of the
Http error closure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:54 -07:00
Greg ShuflinandClaude Opus 5.5 bf5c0e157c rust: the peer roster fails on a database error instead of coming back short
Fixes B7 for get_peer_nodes_for_ensemble: .flatten() over the mapped rows
skipped every Err, including real SQLite errors while stepping the query
(BUSY mid-iteration), and returned a silently short roster. The mapper now
turns only a row it cannot decode into a skip, as the comment intended, and
a stepping error propagates. (peer_health::all got the same fix earlier.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:54 -07:00
Greg ShuflinandClaude Opus 5.5 c6252478d3 rust: add_edge announces the document it bumps
Fixes B5: add_edge updated the from document's modified_at through
touch_document_in_tx without marking it touched, so an edge-only
transaction (a derived-graph rebuild) never published a DocumentEvent,
while remove_edge did. add_edge and write_property now go through
touch_document, and ArtifactStore's CRDT restamp reuses the same
touch_document_in_tx instead of its own copy of the UPDATE. A test pins
the announcement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:54 -07:00
Greg ShuflinandClaude Opus 5.5 a3ff3fc312 rust: write_property and sync_receive_property share their property SQL
Both carried the identical INSERT OR IGNORE (Immutable) and the identical
last-write-wins upsert; they are now insert_property_immutable and
upsert_property_lww, the latter returning whether the row changed.

Fixes B4: write_property appended the old value to property_history before
an upsert whose WHERE excluded.modified_at > properties.modified_at could
reject the write (an older stamp, a clock stepped back), leaving a history
row duplicating the unchanged current value. History is now recorded only
when the upsert applied, on both paths; a test pins it. Also corrects
write_property's doc, which claimed local writes replace regardless of
timestamp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:53 -07:00
Greg ShuflinandClaude Opus 5.5 4c3f14e43c rust: fold materialize's tests onto run/remote_child helpers and rstest cases
Every test re-spelled the four-store destructure and the seven-argument
materialize(...) call. A run() helper, a notebooks() manager and a
remote_child() stub builder replace them, and the music-track/PDF pairs
(primary content written, unavailable content skipped) become one rstest
each with a case per type (S4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:53 -07:00
Greg ShuflinandClaude Opus 5.5 450d05108a rust: materialize through one Materializer, with no unreachable! arms
The four store references were threaded through materialize,
materialize_children and a nine-argument
materialize_primary_content_child; they now live in a private
Materializer, so the recursion takes (parent, dir, skipped).
write_artifact_file and artifact_extension dispatched on an ArtifactKind
that only ever took two values, with unreachable! (non-test panics, B6) for
the rest; they are split into write_note / write_primary_content and
note_format / file_type_extension, and the note extension comes from
TextFormat::extension instead of a copy of it. The public materialize()
signature is unchanged (the daemon calls it).

Also fixes B2 here: materialize_primary_content_child's doc comment had
been spliced onto skip_reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:53 -07:00
Greg ShuflinandClaude Opus 5.5 10af4224f4 rust: two byte-count queries instead of four
artifact_bytes_for_documents / blob_bytes_for_documents and the matching
_for_subtrees pair differed only in the SUM expression, and every caller
added the two halves back together. bytes_for_documents and
bytes_for_subtrees now sum length(value) + blob_size_bytes per row in one
statement each (blob_hash and blob_size_bytes are written together, so the
blob_hash IS NOT NULL filter was implied), and total_bytes_for_rows runs two
queries rather than four. The subtree test now checks the recursion against
per-document totals rather than blob sizes alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:53 -07:00
Greg ShuflinandClaude Opus 5.5 568c416cb3 rust: one seal loop and one commit step in the blob store
store_reporting (slice) and store_file (file) each carried their own copy
of the chunked seal/hash/write/progress loop and of the "rename into place
unless already present" step. A &[u8] is a Read, so both now go through
one seal(reader, len, ...) that ends in commit(); store_ciphertext commits
through the same TempBlob path instead of hand-rolling a shard-dir temp
file (which also stops two concurrent receivers of one blob sharing a temp
name). hash_file becomes blake3's update_reader, and the three copies of
the not-found check become present_blob_path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:53 -07:00
Greg ShuflinandClaude Opus 5.5 ab8a5b37d8 rust: BlobStore resolves vault keys through the Store's own handles
Fixes B10: on every vault-key cache miss, BlobStore::vault_key built a
fresh VaultStore and SettingsStore — running their CREATE TABLEs, and
SettingsStore::new spawned a thread to derive the node keypair while
load_keypair() derived it again synchronously on the new, empty cache.
BlobStore now holds clones of the Store's VaultStore and SettingsStore, so
a miss is one query plus the keypair the settings handle already cached.

The key cache's lock().unwrap() calls (B6) recover a poisoned lock instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:53 -07:00
Greg ShuflinandClaude Opus 5.5 f78ff8addd rust: announcing stores share one AnnouncingDb, and Store::batch stops listing them
peer_nodes, peer_health, revocation, epoch, linkage and settings each
repeated the same ChangeSignal plumbing: a throwaway ledger in new(), a
batch that publishes, subscribe, adopt_ledger (re-spelling the channel) and
publish_pending. They now hold an AnnouncingDb (a Db plus its signal, by
composition, no Deref) and take the ledger in new(); JobStore takes it too.

Every ChangeBus/ChangeSignal registers itself (weakly) with the ledger it
is built on, so Store::batch publishes through ChangeLedger::publish_pending
instead of naming seven stores, and Store::assemble no longer has a second
list of adopt_ledger calls to keep in step. Adding an announcing store can
no longer silently skip Store::batch's announcement.

SettingsStore keeps its own IMMEDIATE-transaction batch and publishes
through the handle; DocumentStore/ArtifactStore keep their ChangeBus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:53 -07:00
Greg ShuflinandClaude Opus 5.5 dcb673d65a rust: sub-stores hold a Db handle and create their tables from one SCHEMA
Every store carried db_path plus a new() that opened a connection and ran
one conn.execute per CREATE TABLE/INDEX, and a ten-line batch() that
forwarded to batch_on. Now stores/mod.rs has a Db { path } whose open()
runs a store's whole schema through execute_batch (S8) and whose batch()
is the shared transaction entry; each store holds one, declares its tables
in a const SCHEMA (explanatory comments kept as SQL comments), and its
public batch() delegates in one line (S3a). Public signatures are
unchanged, so frontends need no edits.

ArtifactStore's retired-kind fixup now runs inside one transaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:53 -07:00
Greg ShuflinandClaude Opus 5.5 6ee8deaf19 rust: settings' three single-row tables share load/save/has helpers
settings, secrets and ensemble are the same table shape; one load_row,
save_row and has_row (through stores::sql::Json and Timestamp) replace the
six hand-written SELECT/INSERT OR REPLACE/COUNT blocks and three identical
CREATE TABLEs. The now-unconstructed Json and Io error variants go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:53 -07:00
Greg ShuflinandClaude Opus 5.5 a87648d840 rust: read peer_health rows typed, and stop swallowing database errors
A strum-derived PeerHealthKind (explicit column strings, pinned by a test)
replaces kind() and the bare "clock_skew"/"schema_ahead" literals, so
clear() and existing() take the enum. existing() and for_node() query their
one node (and kind) instead of reading every row through all().

Fixes B7: all() matched `let Ok(..) = row else continue`, which also
swallowed real SQLite errors while stepping the query and returned a short
roster. Now only a row this build cannot decode is skipped, as documented;
a database error fails the read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:53 -07:00
Greg ShuflinandClaude Opus 5.5 d7ed7c1b6e rust: read the small stores' rows through the stores::sql codecs
content_release, epoch, revocation, document_sync_override and vault bind
Timestamp, [u8; N], DocumentID, VaultId and u32 directly and read them back
typed, dropping their private parse_timestamp copies, the try_into round
trips, and the error variants only those parsers constructed
(InvalidTimestamp, UnknownOverride, Uuid, InvalidNonceLength,
InvalidCiphertextLength).

RevocationAuthority derives its stored text with strum (pinned by a test);
DocumentSyncOverride is stored through sql_strum_text!. epoch history and
the revocation roster keep skipping a row they cannot parse, now by reading
the fallible columns without `?`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:40:53 -07:00
Greg ShuflinandClaude Opus 5.5 539ef000ab rust: Humans reads and writes a human in one transaction, not one per field
- get_human opened ~9 connections (one per field); it now reads the
  human's artifacts once and matches them. list_humans read three fields
  per human (3N connections); it now bulk-reads names, nicknames, and
  circles for every human at once. list_all_circles likewise (audit H-6).
- update_human's eight writes, create_human's three, and each fact /
  contact-method add or remove plus its touch are one transaction each,
  through one set_or_clear and one touching helper; require_human is
  called instead of inlined three times (audit H-7).
- read_interaction_summary takes the Document its callers already hold,
  instead of re-reading it and inventing Utc::now() when it is missing
  (audit H-8).

Bug fix: update_human could half-apply (e.g. the name written, the
nickname not) if a later write failed; it is atomic now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:38:30 -07:00
Greg ShuflinandClaude Opus 5.5 da3a7cf423 rust: a notebook's tree reads note formats in its own transaction
get_tree opened one transaction for the whole subtree but read each note's
format through get_note_format, which opened another per note — the
comment saying the artifact store couldn't take a Transaction was stale.
It reads note_format(tx, ..) now, and get_note_format is gone (audit H-5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:30:35 -07:00
Greg ShuflinandClaude Opus 5.5 4e48482b1f rust: agents share add_titled and ensure_document
add_titled (a new document plus its Title) replaces the hand-written pair in
Notes' create_notebook/create_collection/create_note, clippings' capture,
and pictures' create_album (audit H-4). ensure_document (add unless the
derived id already exists, answering whether it did) replaces the
get-then-add in music's derived artist/album documents, anchors,
agent-settings documents, and podcast episodes (audit X-8). Both live in
agents/ rather than on DocumentStore, since stores/ is another batch's.

Bug fix, same shape as the importers': create_note and capture wrote the
document and its body in two transactions, so a failure between them left
a note or clipping with no body. Each is one transaction now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:27:54 -07:00
Greg ShuflinandClaude Opus 5.5 aac70554ea rust: Notes' synced settings go through one typed get/set pair
The five get/set pairs on NotebookManager each spelled out the
agent_settings call, the AgentId, and the parse. agent_settings gains
get_parsed<T: FromStr>, set_setting takes impl ToString, and each accessor
is one line over private setting/set_setting helpers (audit H-3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:22:50 -07:00
Greg ShuflinandClaude Opus 5.5 591230d730 rust: AgentId's slug and Display come from strum, and the registry is a table
AgentId::as_str was a 20-line hand match and Display a hand impl; both are
now strum's kebab-case (IntoStaticStr/Display), the same spelling serde
already used. The serde round-trip test iterates AgentId::VARIANTS, so it
can no longer miss a new variant. The registry's sixteen 7-line struct
literals are one agent(..) call each (audit A-1). agent_type stays a field:
deriving it from the group is a decision (queued in handoff notes).

AgentGroup::all() is kept: gui-app iterates it by value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:18:58 -07:00
Greg ShuflinandClaude Opus 5.5 ce5668fd65 rust: music's open_track_stream asks content_fetch for its peer candidates
It re-derived content_fetch::peer_candidates line for line (audit A-5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:13:13 -07:00
Greg ShuflinandClaude Opus 5.5 c132008c6a rust: music places a track in the library in one transaction
incorporate_track_cached used to open about ten transactions per track
(blob ref, stamp check, tag write, artist, album, year, two edges, unlink,
two thumbnail checks), each its own connection and writer-lock grab — the
module had already measured per-track connection opens as half the build
time. Now: one read transaction to decide whether to scan, the blob read
and tag parse outside any transaction, then one transaction for
everything the placement writes, then artwork. ensure_artist, ensure_album,
ensure_library_edge and unlink_from_other_albums take the caller's
transaction instead of opening their own (audit A-6).

stored_grouping_tags was cached_grouping_tags with an empty cache; the
freshly-scanned path now passes one (grouping_tags), and it is gone (A-4).

A failure part-way through a track now rolls back that track's tag write
too, so the next build rescans it rather than leaving it half-placed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:12:40 -07:00
Greg ShuflinandClaude Opus 5.5 9cb2d6fc4b rust: comics reads a .cbz in one pass and imports it through one path
- import_cbz_file reads the file and calls import_cbz_bytes instead of
  repeating it.
- ComicInfo::from_xml replaces four copies of the parse_comic_info +
  parse_page_info pair, and import_comic_from_pages takes it whole.
- extract_cbz reads pages and the ComicInfo.xml sidecar in one pass over
  the archive, replacing page_names + extract_pages + find_comic_info_xml
  (which opened the ZIP three times and read it twice).
- chunk_page_name is slice::chunk_by; format_chapter_number was f64's own
  Display.
- import_archive_as_chapter records a missing comic importer as that
  chapter's error instead of .expect()ing it (audit A-2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:03:21 -07:00
Greg ShuflinandClaude Opus 5.5 13fb3e5c1d rust: PdfError and ComicError delegate ContentFetchError instead of flattening it
Both copied ContentFetchError's Stream/Io/NotAvailableRemotely/Canceled
variants and hand-mapped them, on the claim that a #[from] wrapper would
lose ADR 0023's Offline-vs-Canceled distinction. PictureError already
wraps it and ffi/error.rs delegates to ContentFetchError's own SyncError
mapping, which keeps exactly that distinction (audit X-6). gui-app matches
only the BlobStore(NotFound) variant, which stays.

The unreachable-peer message is now content_fetch's ("couldn't reach a
node that has this content") rather than the per-type "...this PDF" /
"...this page".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:58:10 -07:00
Greg ShuflinandClaude Opus 5.5 468dfd4415 rust: one blob-backed import skeleton for pictures, PDF, video, music and comic pages
agents::blob_document::import_blob_document stores the blob, creates the
document, lets the agent write its properties, and points PrimaryContent at
the blob. ImportImageError and ImportPdfError were variant-for-variant the
same and are now one ImportError. primary_blob_ref/load_blob replace the
get_blob_ref+load triplets each agent spelled out (audit X-2, X-7).

Bug fix: the document+properties and its PrimaryContent artifact used to be
two transactions, so a failure between them left a document with no
content. They are now one (both stores share store.db, and a Store's
artifact store announces on the document store's bus, so subscribers still
see one event).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:56:37 -07:00
Greg ShuflinandClaude Opus 5.5 2e5c9aeb49 Android: ktfmt over this batch's Kotlin edits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:29:37 -07:00
Greg ShuflinandClaude Opus 5.5 c87941b254 ffi: clippy fixes for this batch
Document/DocumentType are only used by node.rs's tests since the
single-file import went, and Job's timestamps are already Timestamps, so
JobItem takes them as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:24:46 -07:00
Greg ShuflinandClaude Opus 5.5 a0ca4d3858 ffi: drop the pre-shortened id fields Kotlin's HashChip recomputes (F20)
JobItem.peer_id_short/document_id_short and
DocumentNodeStatusItem.node_id_short carried copy::short_id of an id sent
alongside in full, and HashChip already computes shortId(full) when given
no short form — the same function. The fields, and IdRef's idShort
parameter that only forwarded them, are gone; the chips render the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:17:25 -07:00
Greg ShuflinandClaude Opus 5.5 eb5ed7b901 ffi: SyncError::recourse is a method on the exception, not a free function (F19)
The free recourse(error) existed because uniffi was thought to generate
error classes without methods; uniffi 0.32 renders exported methods on
errors (checked against the generated Kotlin), so the impl block itself
is exported and Kotlin's userRecourse reads recourse().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:15:57 -07:00
Greg ShuflinandClaude Opus 5.5 a783c93a5d Android: say a document is gone instead of spinning forever on its detail screen (BUG5)
DocumentDetailScreen treated "documents().get returned null" and "still
loading" alike, so a document deleted or purged (here, or by a purge
from another node) left the screen on a spinner, and a failed read did the
same after only logging. It is now tri-state: loading, gone — which says
copy::documents::GONE, exported on DocumentCopy — or loaded; a failed read
shows the error like the detail rows' failure already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:11:10 -07:00
Greg ShuflinandClaude Opus 5.5 bb8fa8ca5f copy: the library no-match sentence and the Follow-policy option come from Rust
Two sentences Android retyped:
- "No X match “q”" was built in six Kotlin screens (Comics, Music, PDF,
  Podcasts, Pictures, Video); it is copy::library_empty::no_match,
  exported directly as library_no_match. gui-app's pdf_viewer copy is
  queued for the GUI batch.
- The override picker's "Follow policy" choice and its description were
  Kotlin literals beside Rust's Pin/Exclude; get_document_sync_override_options
  now leads with a value: None option carrying copy::sync_override's
  words, and OverrideControl renders the list as given. gui-app's
  detail_view literal is queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:09:57 -07:00
Greg ShuflinandClaude Opus 5.5 20e801adb7 ffi: a job widget's activity line and a history row's times come from copy
- JobBinding.jobActivityText was a line-for-line copy of
  copy::job_activity; it now calls the exported job_activity_text.
- JobsScreen's "Started … · Finished …" line (a copy of gui-app's
  render_history_timestamps) is copy::job_history_times, exported as
  job_history_times. gui-app switching to it is queued for the GUI batch.

Same words either way; the history line now formats through
Timestamp::format_display directly rather than a string round trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:08:22 -07:00
Greg ShuflinandClaude Opus 5.5 dfdd9af8ff ffi: export the humans field-length cap, and document cancel_job
- HUMAN_FIELD_MAX_LEN in Kotlin was a retyped 255; it now reads
  human_field_max_len(), which is agents::humans::MAX_FIELD_LEN.
- JobsFfi::cancel_job regains the doc comment its deleted Kotlin wrapper
  carried, rewritten for the current runner (every runner-backed job has a
  token; what varies is how promptly the work notices).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:06:12 -07:00
Greg ShuflinandClaude Opus 5.5 2b7f9ae76a ffi: document-type filters as DocumentType with Rust's display names, and date filters from copy (F17)
Bug fix: get_all_document_types returned snake_case names and the
Documents screen title-cased them itself, so its type filter and chips
read "Podcast_feed" and "Music_track". It now returns
DocumentTypeOption { doc_type, display_name } (DocumentType::display_name,
what the desktop shows), and search/search_page take Vec<DocumentType>
instead of strings they silently dropped when unknown.

The date-range options (key + label, and "Any Date") move from a
Kotlin literal list into copy::date_filter, exported as
date_filter_options / date_filter_any_label. gui-app's identical
DATE_FILTERS table is queued for the GUI batch.

Gender and ContactProtocol (the rest of F17) are a decision and are left
as strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:04:51 -07:00
Greg ShuflinandClaude Opus 5.5 636877b630 ffi: hand Kotlin the note tree as a tree (F12)
get_tree flattened agents::notebook::NoteTreeNode into NoteTreeEntry rows
with a stringly "note"/"collection" kind, and NotebookRepository
regrouped them by parent id. NoteTreeNode now derives uniffi::Enum (uniffi
supports recursive enums) and crosses as itself; Kotlin maps it with one
recursive toItems(). Deletes flatten_note_tree_nodes, NoteTreeEntry and
buildTree/buildChildren.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:59:26 -07:00
Greg ShuflinandClaude Opus 5.5 851276e3bc ffi: cross the FFI as the lib's own types instead of four mirrors (F11)
- LogConfig: crate::log::LogConfig derives uniffi::Record; the identical
  ffi::types::LogConfig is gone.
- MusicLibraryLevel: music::LibraryLevel derives uniffi::Enum under that
  name, so list_library takes it directly instead of converting from a
  string-id copy.
- SchedulerRunSummary: run_scheduler_passes returns the scheduler's own
  SchedulerPassResult (catalog / catalog_fetch / content) rather than
  flattening it; SchedulerWorker and the Sync settings "Run now" status
  read summary.catalog.peersSynced etc.
- Kotlin's ContactRow was ContactMethodItem field for field; the human
  detail screen uses the generated record, and the note about staged rows
  having no uri moves onto ContactMethodItem::uri.

NotebookItem stays: list_notebooks' (id, title) tuple is also what
gui-app and the daemon consume, so naming it is queued for the GUI batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:57:22 -07:00
Greg ShuflinandClaude Opus 5.5 6bb3647a05 ffi: generate the constant-copy records, and export copy functions directly (F6)
- copy_record! emits each of the twelve constant-copy records
  (PlaceholderCopy, ActionLabels, … RevocationCopy's 27 fields) and the
  #[uniffi::export] function that fills it, so each field is written once
  instead of twice.
- The 22 one-line pass-throughs whose copy function already has a
  uniffi-compatible signature (&str/u32/bool/ServiceId in, String out) are
  gone: the copy.rs function itself carries
  #[uniffi::export(name = "<the old name>")], so Kotlin's names do not
  change. The rest stay wrappers because they take Option<&str> or usize
  or return &'static str, which uniffi cannot lift or lower.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:54:00 -07:00
Greg ShuflinandClaude Opus 5.5 1153e8d37a ffi: artifact summaries from copy::artifact_value, and untangle the partial-date helpers
- format_artifact_value was a verbatim copy of
  copy::artifact_value::summary (Gender words included); it now calls it
  (frontends batch F2, queued for FFI).
- validate_partial_date re-derived what parse_partial_date and
  parse_optional_partial_date already decide.
- Two doc blocks were glued onto parse_partial_date; the first belongs to
  parse_optional_partial_date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:51:01 -07:00
Greg ShuflinandClaude Opus 5.5 902ec899d1 rust: JobSubject::document()/peer() instead of two copies of the match (F20)
The FFI's job_to_item and list-jobs title lookup each spelled out which
subjects name a document and which a peer; the answer now lives on
JobSubject.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:51:01 -07:00
Greg ShuflinandClaude Opus 5.5 42035116ff copy: capitalize a busy clause into a sentence in one char-safe place (F20)
ffi/music.rs and ffi/import.rs each wrote
format!("{}{}.", clause[..1].to_uppercase(), &clause[1..]), which
byte-slices and so would panic on a multibyte first letter. They now call
copy::busy::sentence. The two gui-app copies are queued for the GUI batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:51:01 -07:00
Greg ShuflinandClaude Opus 5.5 b3e173a83a ffi: one local-then-peer body for a comic page's bytes (F20)
get_page_bytes and prefetch_page_bytes differed only in whether the
remote fetch gets a job row; both now call a private page_bytes with the
runner or None.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:50:59 -07:00
Greg ShuflinandClaude Opus 5.5 5db920e4d9 ffi: one P2P preamble and one DocumentSyncResult conversion (F15)
EnsembleFfi::p2p_parts was SyncNodeCore::require_p2p under another name,
and push_document inlined it a third time; all three now call
require_p2p. The DocumentSyncResult built identically by
sync_document_assets and sync_document_asset is a From<DocAssetSyncResult>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:43:57 -07:00
Greg ShuflinandClaude Opus 5.5 18eb63da78 ffi: read SettingsFfi's getters through SyncNodeCore::settings (F16)
Six getters each loaded the settings row and defaulted the one field they
wanted; Settings::default() carries the same per-field defaults, so each
is now one line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:42:24 -07:00
Greg ShuflinandClaude Opus 5.5 8e9859fe91 ffi: share documents.rs's repeated lookups, and show its timestamps in the user's timezone (F14)
- The primary-content blob-ref lookup (4 copies) is primary_blob /
  required_primary_blob.
- SearchParams is built by one search_params for search and search_page.
- The two DocumentNodeStatusItem builders (node statuses, blob-ref rows)
  share NodeLabels, which also carries the local pubkey and peer names
  they each looked up.
- list_children builds DocumentListItems like search_page does, so
  size_annotated_items takes those instead of a 6-tuple.
- SyncNodeCore::settings() reads settings-or-defaults once.

Bug fix: document-detail property, artifact and node-status times used a
bare "%b %d, %Y %H:%M" in UTC while the Placement table beside them
honored the user's timezone; all now go through
Timestamp::format_display(tz), so they read in the user's zone with its
suffix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:41:49 -07:00
Greg ShuflinandClaude Opus 5.5 2a9d21ae99 ffi: generate SyncNode's per-agent handle accessors with one macro (F13)
The 17 accessors were identical apart from their name and handle type;
ffi_handles! emits them as one #[uniffi::export] impl block, with the
shared explanation on the macro rather than repeated on each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:39:33 -07:00
Greg ShuflinandClaude Opus 5.5 5fa1965e50 ffi: take and return DocumentID/NodePubkey/Timestamp instead of strings (F2)
The custom types already cross as plain strings (Kotlin typealiases), so
about 60 methods that took `*_id: String` and parsed it on the first line
now take a DocumentID, peer-taking methods take a NodePubkey, and methods
and records that returned `id.to_string()` return the id. Deletes
parse_document_ids, the JobSubjectRef parse closures, and ~100 parse and
to_string lines. No Kotlin change: the generated types are still String.

NodePubkey's try_lift now fails with SyncError::InvalidInput, so a
malformed peer id still reaches Kotlin as a typed SyncException (test
added). DocumentID and Timestamp declare their custom_type in documents/,
outside this batch; until theirs do the same, a malformed document id
surfaces as SyncException.Internal rather than InvalidInput (only reachable
through a Kotlin bug, since every id Kotlin holds came from Rust).

DocumentItem keeps String fields: it is built from the store's
DocumentListItem, which still carries strings.

Also restores HumansFfi::delete_interaction's doc comment that the Kotlin
wrapper used to carry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:38:28 -07:00
Greg ShuflinandClaude Opus 5.5 41d5bace05 ffi: delete exports nothing calls any more
After Android's A1/A2 batches these had no Kotlin caller and no Rust caller
elsewhere: content_fetch_stall_hint_ms, compile_typst and
compile_typst_with_fonts (with FontData — so F18's parallel-array wrapper
goes with them), notebook_default_item_name, ComicsFfi::get_metadata and
get_reading_direction, SettingsFfi::get_document_sync_override, and the
four single-file imports (ComicsFfi::import_archive, PdfFfi::import,
ImportFfi::image/music_track) that ImportBatch::import_file replaced —
taking import_document_with_blob and run_one_file_import with them.
EnsembleFfi::get_gossip_neighbor_count is inlined into its one Rust caller.
Doc comments that named the deleted paths now name ImportBatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:21:41 -07:00
Greg ShuflinandClaude Opus 5.5 b98cc54651 ffi: drop SyncNodeCore::with_store for a plain store() binding (F9)
with_store(|store| body) only called f(&self.store()), so every one of its
~115 call sites paid a closure, an Ok(..) wrap and an extra indent for
nothing. Bind `let store = self.core.store();` once per method instead and
let `?` work directly. Mechanical; no behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:17:55 -07:00
Greg ShuflinandClaude Opus 5.5 b55b682c8e ffi: classify retire, retirement, ping and asset-sync errors instead of flattening them to Internal (F3)
ensemble.rs mapped every RetireError, RetirementError, PingError,
AssetSyncError and InitError to SyncError::Internal, so a wrong keyphrase,
"already in an ensemble", a ping timeout or an unreachable peer all
reached the user as ReportBug. Add From impls in error.rs beside their
neighbors (plus RevocationError and the revocation/epoch stores' Busy) and
let `?` convert; sync_document_asset's hand-written UnknownAsset special
case is now the AssetSyncError impl.

Bug fix: those failures now carry the right Recourse (AmendInput for a bad
keyphrase, RetryLater for an offline peer, ResolvePrecondition for setup).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:13:38 -07:00
Greg ShuflinandClaude Opus 5.5 3f99681089 tui: bring the help overlay and the Jobs screen's doc up to date (B10)
The `?` overlay left out the Documents screen's `d` (show/hide hidden
documents) and the whole Jobs screen; both are listed now. The Jobs
screen's module doc said the daemon had no JobRunner to cancel through; it
has had one since the scheduler driver — what is missing is a protocol
command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 8c150782ee cli: name commands in messages the way they are actually typed (B4)
`linkage pair` told the user to run `syn node daemon start`; the node
commands are top-level (`node` is only a help group), so it is `syn daemon
start`. And a handful of CLI messages, plus the extension popup, said
`syn-cli …` where everything else — the other hundred-odd messages across
lib, daemon, CLI and TUI, and the docs — says `syn …`; they now agree. (The
built binary is still named `syn-cli`; that naming question is in the
handoff notes.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 521b907d8e tui: the $EDITOR path alerts on an incoming body instead of writing over it (B3)
`E` wrote the whole buffer back unconditionally once $EDITOR exited — the
design principle's "stop writing over it" violation: a whole-buffer write
is applied as a diff against what the store holds now, so a body that
arrived from another node while the editor was open was deleted from the
merged text everywhere. It also wrote when nothing changed or the editor
failed (vim's `:cq`), and ran an EDITOR with arguments (`code -w`) as one
program name.

Now: the editor runs through `sh -c '$EDITOR "$1"'`, as git does; a failed
exit or an unchanged file writes nothing and says so; and before writing,
the note is re-read. If its body moved meanwhile, the edit is not written —
it opens in the TUI's own editor as an unsaved buffer over the original
base, with the arrived version held as `incoming`, which is exactly the
state `on_documents_changed` produces: "⟳ changed elsewhere", `:e` loads
theirs, `:w!` keeps yours, and nothing moves until the user picks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 bdb16e1dce daemon: put jobs_snapshot's doc comment back on jobs_snapshot (B9)
It sat on `MAX_AWAIT_CHANGE_MS`, run together with that constant's own doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 e37fd770ca daemon: count documents with COUNT(*) for Status and NodeInfo (B2)
Both loaded every document — a `Document` built per row — only to take the
length, and the browser extension sends `status` to each node every time
its popup opens. `DocumentStore::count_documents` is the same count as one
`SELECT COUNT(*)`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 8c33a83767 daemon: stop a directory import walk when its job is canceled (B1)
The daemon's own copy of the tree walk never checked `is_canceled`, though
the comment above the per-path loop said it did, so `syn jobs cancel` on a
large directory import ran on to the end of the directory. It now checks
per entry, as lib's walk does. (F10 — sharing lib's walk instead — needs a
collection hook and a caller-owned progress in lib's API; left for the lib
batch.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 fa6efcc9db daemon: take the artifact-value summary from a new copy::artifact_value
The one-line artifact summary ("In progress: m:ss", "Bookmark: page N",
"(sealed)", …) was written three times — lib's FFI helpers, the desktop's
document detail, and the daemon's `docs show` — differing only in how much
body text each keeps; `Gender`'s display words likewise (FFI, daemon,
CLI). `copy::artifact_value::{summary(value, max_chars), gender}` is new
code in lib (nothing existing there is changed), and the daemon and the
CLI's `humans show` now use it; the daemon's blob-backed arm stops being an
`unreachable!`. Switching the FFI helper and the desktop's copy over is
queued for those batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 19c9400195 rust: drop unused and duplicated frontend dependencies
cli-app named `iroh` and `log` but uses neither (`log` only appeared in a
doc comment; logging goes through `synchronicity::log`), and tui-app named
`serde_json` without using it. syn-daemon's dev-dependencies re-listed
`chrono`, `serde_json` and `uuid` from its normal dependencies under a
comment claiming integration tests link only against dev-dependencies —
they link against the package's normal dependencies too (B12). No version
changes; Cargo.lock only loses the three edges.

Left alone: the workspace's two argument parsers (clap in gui-app, bpaf
elsewhere) — a design choice with little LOC in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 7abfba3ec9 extension: one api shim module, and create the context menu root once
`globalThis.browser ?? globalThis.chrome` was defined in linkage.js,
permissions.js and background.js; it is `src/api.js` now. And
`rebuildMenu` created the identical root item in two branches; it creates it
once and adds per-node children only when more than one node is linked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 2de21300b0 daemon: odds and ends in server.rs, and stop a restart from orphaning a live daemon
- `commanded_shutdown`'s changed()/borrow() loop is `watch::Receiver::wait_for`.
- The unused `/status` route and `handle_status` go: no Rust, JS or test
  caller; the extension sends the `status` command.
- `shutdown_signal` no longer `expect`s on installing its signal handlers;
  one that can't be installed is logged and never fires (and the
  not-unix branch of a Unix-socket daemon goes).
- `serve` takes the PID path instead of `socket_path.parent().unwrap()`.

B14: `kill_existing_daemon` shelled out to `kill` and ignored the result,
then after five seconds deleted the socket out from under what could still
be a running daemon — which kept the store lock and became unreachable. It
now sends SIGTERM with `libc::kill` (already a dependency), reports a
failure to signal, and refuses to proceed if the daemon has not exited
rather than removing its socket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 044edc6019 daemon: run the scheduler tick through a shared lib scheduler::tick
The daemon's and the desktop's timer loops each carried the same body:
load the scheduler settings, run_all_passes when enabled, log the identical
"tick complete" / "tick skipped" sentence, and sleep for the interval
floored at a MIN_INTERVAL both defined. That body is now
`scheduler::tick(store, job_runner, endpoint, local_node) -> Duration` (plus
`MIN_TICK_INTERVAL`), added to lib without touching its existing code, and
the daemon's driver is a loop around it. The desktop driver's switch is
queued for the GUI batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 361357eed3 cli: parse document ids and the timezone at the command line
About twenty positionals were `id: String` / `parent_id: String`, each
turned into a DocumentID by a `parse_id` call in main.rs; `settings timezone`
likewise took a string it validated itself. bpaf parses any `FromStr` type,
so these are `DocumentID` and `Option<chrono_tz::Tz>` now, and a bad value
fails at parse time with bpaf's usage error ("couldn't parse `x`: …")
instead of "invalid document id: x". `linkage revoke` keeps its string and
hand parse, whose error points at `syn linkage list`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 8ddb4eca8f daemon: typed ids and enums in protocol rows (wire unchanged)
Document ids in the protocol's rows and snapshots were `String`, so the
daemon stringified DocumentIDs it held and the TUI parsed them straight back
(`doc_item`, `parse_id`, `DocumentID::from_str`), keying its expanded-row
sets by `to_string()`. They are `DocumentID` now — a serde newtype over
Uuid, so the JSON is the same string — and the TUI's Documents screen keeps
the daemon's `DocumentItemRow`s rather than copying them into `DocItem`s.
`DeletedSnapshot` stays a string: credential-clear answers with a service
name in it.

`RetirementSnapshot.outcome` is `RetirementOutcome` (snake_case, as before)
rather than a string the CLI matched by spelling, and `ListDocuments.sort` is
`DocumentSort` (kebab-case, as before), parsed by bpaf for `--sort`.

B7: an unknown `--sort` value was silently treated as `name`; it is now a
usage error naming the three it accepts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 bf1122a653 tui: one background-worker type, one busy guard, one reload per view
Documents' pull and Ensemble's actions each hand-built the thread + mpsc +
try_recv + Disconnected dance; both now use `background::Pending<M, T>`
(spawn with the metadata the screen shows, `poll` for the outcome). A worker
that dies without reporting is now an error outcome like any other: on the
Ensemble screen that lands on the member's row instead of a popup.

Also: the Ensemble screen's "{participle} {label} already — wait for it"
guard (3 copies) is `busy()`, `reload` is `on_nodes_changed` plus the
default status rather than a copy of its body, and `start_action` takes the
command to send instead of an `unreachable!` arm for the prompted kinds.
Notes' "reload whichever view is showing" (4 copies) is `reload_view`, and
the ItemKind → delete/rename command mapping (2) is `ItemKind::{delete,
rename}`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 1629ebf766 cli: share the integration tests' helpers, and give each daemon test its own dir and seeds
The store-setup helper existed three times (setup_test_node, setup_node,
setup_source), the keyphrase parser twice and parse_ensemble_pubkey twice;
they are now `common::{node, parse_keyphrase, parse_ensemble_pubkey}`, and
common.rs loses the nine per-item `#[allow(dead_code)]`s its file-level one
already covered. The seeds and `setup_store` live in tests/support/seeds.rs,
which the dev example includes by path instead of carrying its own copy.

p2p_ping wrote its stores into the source tree (tests/stores/), which is why
`DaemonGuard` had to delete the socket a killed daemon leaves (Nix refuses a
tree containing one) — and, in this checkout, why the daemons failed to start
at all: the socket path exceeded SUN_LEN. It uses TempDirs now, and the guard
only kills. B8: its outsider test reused NODE_A/NODE_B's seeds while the
other test's daemons ran, so two live daemons could share an iroh node id;
it has seeds of its own. B10: the module doc named `syn p2p node-addr`,
which does not exist.

Also `AppState::new` replaces the four hand-built AppState literals in the
daemon and its tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 df6df8d9fd daemon: snapshot summaries the CLI and TUI both render
`EnsembleSyncSnapshot` → `PeerSyncCopy` was written identically in the CLI's
format.rs and the TUI's ensemble screen; the job-state words were hardcoded
in the CLI and taken from `copy::job_state` in the TUI; and the TUI's
Documents screen wrote its own asset-sync sentences, which disagreed with
the shared `copy::asset_sync` ones the CLI used ("was already on this node"
against "is already on this node", its own peer-count phrasings).

Now `EnsembleSyncSnapshot::summary`, `AssetSyncSnapshot::summary(label)` and
`JobSnapshot::state_text` live beside the snapshots; the frontends only pick
colors. The TUI's pull status line therefore changes to the shared wording —
the copy-rule fix, not a new sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 f56422a088 daemon: one start path for all three StartModes, and shared liveness helpers
`server::run`'s Foreground/Restart/EnsureRunning arms each repeated the
stale-socket removal, the store-lock check, the log-file setup and
Daemonize builder, proc_title plus logger re-init, and runtime → serve →
cleanup. They now share one sequence; only what differs (bail, kill, or
return when a daemon is already live; write a PID file, `start`, or
`execute` and wait) stays per mode.

Socket liveness (probed six ways), the PID-file read, and the
poll-until-deadline loop become `syn_daemon::{socket_is_live, read_pid,
poll_until}`, and the CLI's `DaemonStatus` and `wait_for_daemon_exit` move to
`syn_daemon::client` beside `ensure_daemon_running`, which uses the same
wait.

Needs a manual check: `syn daemon start`, `start --foreground`, a TUI
auto-start, and a restart over a running daemon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 4b98898cb6 cli: helpers for the confirm prompt, keyphrase prompt and repeated output pieces
Written once each instead of per call site: the `[y/N]` confirmation (4
copies), the keyphrase prompt (two near-identical functions, now one taking
its flag hint and empty-input message), reading stdin to a string (2),
SecretFormat from `--format-a` (2), the WRITE-DOWN-YOUR-KEYPHRASE block shared
by `ensemble init` and `reseed` (2), a DHT registry row (2), a hand-rolled
hex loop (4), the `%Y-%m-%d %H:%M UTC` format (5) and the 14-column dim label
(2). Output is unchanged: each phrase-row layout and every sentence stays
as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 5cba9fde34 daemon: local helpers for the node pubkey, presence marks and row mapping
commands.rs spelled out `local_node_pubkey()?.ok_or_else("node not
initialized …")` twelve times, the best-effort mark_present-with-warning
block four times, NotebookManager-plus-local-node for each of Notebook, Note
and Collection, the same SearchParams twice in ListDocuments, and the
DocumentListItem-to-row mapping three times. Each is now written once:
`local_node`, `mark_present`, `notes`, and `From<DocumentListItem>` for
DocumentListRow/DocumentItemRow. The ensemble-members pubkey uses
`NodePubkey::to_hex` rather than a hand-rolled hex loop.

`syn docs search` now answers from `search_document_items` — one query
carrying each title — rather than `search_documents` plus a title read per
match (N+1). Same filters and ordering; the title match is SQLite's LIKE, as
the desktop and Android document search already use, so case-folding is
ASCII-only where it used to be Unicode lowercase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 c743ec3884 tui: shared widgets module and a Screen trait
Every screen carried its own copy of `centered_rect` (×5), the error popup
(×4), the focused-border style (×6), the wrap-around move_up/move_down (×4),
the list highlight, the tab span, the padded label/value line and the
by-id selection restore; `app.rs` then matched on AgentId in five places to
reach the same four screens.

`ui.rs` holds one of each — `pane`, `error_popup`, `highlighted`, `step`,
`restore_selection`, `tab_span`, `field` — with popups centered by ratatui
0.30's `Rect::centered` rather than a hand-built three-way Layout (a popup
can move by a cell on odd remainders). Each screen implements `ui::Screen`
(render, handle_key, status_line, is_typing, error), and `App::screen()` is
the one AgentId match left.

Terminal setup and teardown use `ratatui::try_init`/`try_restore`, which also
restore the terminal on a panic. `handle_key` no longer threads the terminal
through every screen for the one that needs it: Notes suspends itself around
`$EDITOR` and raises `needs_full_redraw`, which the loop answers with a
`terminal.clear()` before the next draw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 0c00712a56 cli: one typed call per command instead of build_command + format_response
A leaf command's handling was split three ways: a pre-dispatch or
`build_command` (five `unreachable!`s for arms the pre-dispatch had already
taken) picked the DaemonCommand, `send_command` sent it, and
`format_response` matched on the command again to pick a body type and
renderer — about 25 of its arms raw or dead, because no CLI command sends
those commands. The dead EnsembleBumpEpoch arm had already drifted from the
live `renew_membership` output (B13).

Each leaf now names its command and its renderer in one
`client::run(store, start, cmd, render)` call; `format::*` are plain
`fn(Snapshot) -> String`. The exhaustive CLI-enum matches keep every command
deciding its rendering, and `handle_ensemble` loses its `unreachable!()` arm.
Commands that auto-started the daemon still do; ensemble addr/ping/sync still
require one the user started.

B5: a failed command now returns its error to `main` rather than printing
"error: …" and calling `process::exit(1)` itself, so it gets main's
"Error:" rendering like every other failure. A daemon body this build cannot
decode is now an error rather than a line printed as if it were an answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 b8c59c16dc daemon: answer every command through one async commands::run
`handle_command` carried eleven hand-written special cases, each repeating
the require_p2p / success / error ladder, and `dispatch` was synchronous, so
every async or P2P command was an `unreachable!` arm in it — reachable in
practice: the CLI's no-daemon fallback could hand it `ImportUrl` and panic.

`commands::run(cmd, &Ctx { store, job_runner, p2p })` is now the one
exhaustive match. A command that needs P2P asks `cx.p2p()?`, which answers
the same "P2P node is not running on this node" the server tests assert;
EnsembleStatus still degrades without P2P; AwaitChange still parks on
spawn_blocking; SetNodeName's best-effort gossip re-announce stays in the
server, the only process with a runtime to re-announce through. Shutdown and
pairing, which live in the serving process's memory, are answered by the
server and are an error (not a panic) anywhere else.

The CLI's daemon-or-direct branches (import, import-url, rename, timezone,
node_info/ensemble_members/document_detail wrappers) collapse into one
`client::query_store_as`, which runs `commands::run` in-process when no
daemon responds. The friendly IANA timezone error moves into the SetTimezone
arm so both paths give it (B15). Ping and retire timeouts use
`Duration::try_from_secs_f64` rather than the panicking constructor, and the
node-runtime mutex no longer unwraps on poison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:03:38 -07:00
Greg ShuflinandClaude Opus 5.5 be64dbf54c rust: read property and artifact values through get_value and try_as_*
The ~40 hand-written "match the value, Some on the one variant I want,
None otherwise" blocks around get_property and artifact reads become
get_value(..)?.and_then(PropertyValue::try_as_text) / .try_as_u32() and
a.value.try_as_reading_position() and friends. Deletes music's
u32_property and clippings' text_property (the one-off readers these
replaced) and shortens comics' order/reading-direction/page-count readers
to one line each. The five derived-id artifact writes in facts, contact
and podcast_queue use Artifact::with_id instead of spelling out the struct
with Timestamp::now().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:47:41 -07:00
Greg ShuflinandClaude Opus 5.5 585b24d091 rust: write properties through DocumentStore::set_property
Mechanical conversion of every write_property(tx, &Property::new(id.clone(),
kind, PropertyValue::X(v), node)) in the agents, documents, credentials,
ensemble and the desktop's detail view to set_property(tx, &id, kind, v,
node); rustfmt laid each old call out over nine lines. Music's local
write/write_or_clear closures now wrap set_property/set_or_clear. Tests get
a matching test_support::set_property. Literal .to_string() values drop
the conversion now that &str converts into PropertyValue. Also picks up a
clippy redundant-closure (Timestamp::now) the previous commit introduced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:39:13 -07:00
Greg ShuflinandClaude Opus 5.5 4afaf7dd32 rust: chunk node_content_status's bulk key lookups and read rows typed
keys_with_known_holder bound two parameters per key with no chunking, so
document_availability_bulk over a library of more than ~16k missing items
failed with "too many SQL variables" (audit B1); it and holders_of (which
chunked by hand, citing a stale 999 limit) now go through query_in_chunks
with a row-value list. ContentPresence derives strum instead of its
hand-written as_str/parse, and the readers take NodePubkey and Timestamp
straight from the row. NodeContentStatusError's InvalidTimestamp,
InvalidNodePubkey and UnknownPresence variants had no remaining
constructor and are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:10:39 -07:00
Greg ShuflinandClaude Opus 5.5 7fa2a5fd01 rust: read document-store rows with one mapper per record type
Document, Property, Edge, DocumentListItem, SetElement and the three
tombstone rows each had their own read-strings-then-parse copies; now
DOCUMENT_COLS + document_from_row, PROPERTY_COLS + property_from_row
(shared by properties and property_history), list_item_from_row (shared by
search_document_items and ancestors_of) and small per-row mappers,
through the stores::sql codecs. Writes bind typed values; property rows
bind through one property_params.

- load_documents_by_ids was an N+1 (one SELECT id, then one get_document
  per id) behind list_documents(_by_type), list_children(_page),
  list_root_by_type, find_documents_by_title and search_documents; each
  now selects the document columns directly in one query.
- get_property was a byte-for-byte copy of get_property_in_tx; it now
  calls it.
- list_properties_for_documents, descendants_of and ancestors_of go
  through query_in_chunks, so a list larger than SQLite's bound-parameter
  limit no longer fails (audit B1). ancestors_of now drops matches and
  cross-chunk duplicates in Rust rather than with a second bound id list.
- list_tombstones_since is one statement (?1 IS NULL OR deleted_at >= ?1).
- DocumentStoreError's Json, Uuid, InvalidTimestamp, InvalidNodePubkey,
  Unknown{DocumentType,PropertyKind,EdgeKind,Provenance} variants had no
  remaining constructor and are gone, with their ffi/error.rs arms that
  reported corrupt rows as SyncError::InvalidInput (audit B12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:05:54 -07:00
Greg ShuflinandClaude Opus 5.5 a41f6604f2 rust: read artifact rows with one mapper per record type
The Artifact row was decoded five times (get_artifact, get_artifact_by_id,
list_artifacts_of_kind, get_artifacts_for_documents, list_artifacts) and
the ArtifactRef row three times, each reading strings into a tuple and
parsing them in a second pass; now ARTIFACT_COLS + artifact_from_row and
artifact_ref_from_row, through the stores::sql codecs. The three copies of
"deserialize the value, take its blob_ref or fail" are blob_ref_of, the
four byte-total queries share bytes_by_document, singleton_flag() == 1
becomes is_singleton(), and writes bind typed values (BlobHash gains a
text codec). Enables rusqlite's fallible_uint so u64 sizes bind and read
without as-casts.

Every bulk IN (...) query here now goes through query_in_chunks, so a list
larger than SQLite's bound-parameter limit (32766 in the bundled build)
no longer fails with "too many SQL variables" (audit B1).
unfulfilled_stub_ids_for_documents and list_blob_refs_for_documents are
generic over the id type, so availability passes its DocumentIDs straight
through instead of stringifying them to look results up by string.

ArtifactStoreError's InvalidTimestamp, InvalidNodePubkey, UnknownKind,
UnknownDocType and BlobHash variants had no remaining constructor and are
gone, and ffi/error.rs no longer maps artifact row-parse errors to
SyncError::InvalidInput: they come from the store's own rows, so they are
Internal (audit B12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:56:10 -07:00
Greg ShuflinandClaude Opus 5.5 0209458d62 rust: read linkage rows with one mapper and one lookup
The SELECT column list was written three times and each read went through
a Columns tuple, row_to_columns and linkage_from_columns; now LINKAGE_COLS
and linkage_from_row, and authenticate reuses one() now that one() takes
its value as &dyn ToSql. LinkageStoreError's InvalidTimestamp and
InvalidUuid variants had no remaining constructor and are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:49:04 -07:00
Greg ShuflinandClaude Opus 5.5 9bf666e603 rust: read peer_nodes rows with one mapper
get_peer_nodes_for_ensemble read blobs and strings into an 8-tuple and
converted each by hand; peer_from_row now reads [u8; 32] and Timestamp
directly through rusqlite and the stores::sql codecs, and writes bind the
arrays and timestamps as themselves. A row that fails to read is still
skipped, as before (B7, errors swallowed by that skip, is left for a
separate change). PeerNodeStoreError::InvalidTimestamp had no remaining
constructor and is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:49:04 -07:00
Greg ShuflinandClaude Opus 5.5 f0f0f48bd8 rust: read job rows straight into Job
row_to_job read eleven columns as strings and handed them to an
11-argument build_job under #[allow(clippy::too_many_arguments)]; it now
reads each field through the stores::sql codecs (TextUuid, JobKind,
Timestamp, Json<JobProgress>) and only parses subject/origin/state by
hand. Writes bind the typed values too. JobStoreError's Json, Uuid, Parse,
UnknownKind and InvalidTimestamp variants had no remaining constructor and
are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:48:58 -07:00
Greg ShuflinandClaude Opus 5.5 3a2bceaa8e rust: read node_document_status rows with one mapper
The four readers each read columns as strings into a tuple and rebuilt a
NodeDocumentRecord by hand; they now select the same RECORD_COLS and map
with record_from_row through the stores::sql codecs. NodeDocumentPresence
derives strum instead of its hand-written as_str/FromStr, the three mark_*
functions share one private mark, and list_nodes_for_documents goes
through query_in_chunks, so a page larger than SQLite's bound-parameter
limit no longer fails. The error enum's parse variants (InvalidTimestamp,
InvalidNodePubkey, UnknownPresence, InvalidDocumentId) had no remaining
constructor and are gone; a corrupt row now reports as a database error
whose source names what failed to parse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:44:44 -07:00
Greg ShuflinandClaude Opus 5.5 7b407db1ac rust: rusqlite codecs for the store domain types (stores/sql.rs)
ToSql/FromSql for Timestamp (written exactly as to_rfc3339(), since the
SQL compares those strings; rusqlite's chrono feature is deliberately not
used), DocumentID, VaultId, NodePubkey (hex), and the strum enums stored by
name (DocumentType, PropertyKind, ArtifactKind, EdgeKind, Provenance,
JobKind) via sql_strum_text!; a Json<T> wrapper for JSON BLOB columns; and
TextUuid for plain Uuid TEXT columns (rusqlite's uuid feature would bind a
BLOB). A column that fails to parse reports a ColumnParseError naming the
type and the text found, inside rusqlite's FromSqlConversionFailure.

placeholders() moves here from document.rs, alongside query_in_chunks,
which splits an IN (...) list across statements under SQLite's
bound-parameter limit. Stores switch over in the following commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:31:22 -07:00
Greg ShuflinandClaude Opus 5.5 f1199cdb07 rust: stamp with Timestamp::now() instead of wrapping Utc::now()
Mechanical: every Timestamp(chrono::Utc::now()) and Utc::now().into()
that produced a Timestamp in the lib now reads Timestamp::now(); three
files lose their now-unused chrono::Utc import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:18:23 -07:00
Greg ShuflinandClaude Opus 5.5 bd3e7aea7e rust: property and artifact helpers for the common write and read shapes
Adds DocumentStore::set_property (write a fresh local value, taking
anything Into<PropertyValue>), set_or_clear (write when present, else
remove_property_if_present, generalizing music's local write_or_clear) and
get_value; From impls into PropertyValue for String/&str/Vec<String>/
Timestamp/PartialDate/f64/u32/bool; strum::EnumTryAs on PropertyValue and
ArtifactValue (the two numeric PropertyValue projections are hand-written
as try_as_u32/try_as_f64 because the derive would call them try_as_u_32);
Timestamp::now(); and Artifact::with_id for derived-id artifacts, which
Artifact::new now calls. Call sites convert in the following commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:16:35 -07:00
Greg ShuflinandClaude Opus 5.5 5395a6eaba rust: build the import table's adapters with one macro
The five hand-written adapter fns that forwarded seven arguments to each
agent's importer and flattened its error to a String are now one
import_fn! macro producing a non-capturing closure, which coerces to
ImportFn in the const table. The comic directory importer already had the
right signature and goes in directly; import_comic keeps its body for the
cbr feature branch. Also folds import_tree's child_parent/next_parent
two-step and matches_path's map(..).unwrap_or(false).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:14:13 -07:00
Greg ShuflinandClaude Opus 5.5 e748381ebf rust: delete dead store and import helpers
Nine pub fns with no caller anywhere in the workspace or behind uniffi:
ArtifactStore::{artifact_bytes_per_document, blob_bytes_per_document,
count_artifacts, documents_with_unfulfilled_stubs},
DocumentStore::count_properties, JobStore::set_running,
BlobStore::wipe_all_blobs, PeerNode::first_seen_date_str,
import::all_document_extensions, plus TextFormat's unused lenient FromStr.
The doc comments that linked the deleted functions (two of them intra-doc
links that would now be broken) are reworded to stand on their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:10:51 -07:00
Greg ShuflinandClaude Opus 5.5 18508feff0 Android: one SettingsHeading for the settings screen's group headings
Five SectionHeadings on the System settings screen repeated the same
top/bottom padding; SettingsHeading carries it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 ef89ec92d5 Android: guard the ensemble roster's three sections once
The node heading, the member list and the empty state were each wrapped
in the same screenData?.let { if (!loading && error == null) … }; one
takeIf over the three says it once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 b4d43930d1 Android: share NodeControls' compact button and outcome line
PingControl and SyncControl each spelled out the same 32dp outlined
button and the same ok-in-success-color / error-in-error-color status
pair; CompactActionButton and OutcomeText say them once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 a0ddc41077 Android: stop threading the note editor's parameters through layers
NotesContentArea took 30 parameters and passed 22 straight on to
NoteEditorPane; it now takes an editor slot and hands it the modifier
carrying the swipe/offset handling, so NotesScreen calls NoteEditorPane
directly. SplitBody likewise takes the preview as a slot instead of
PreviewBody's ten inputs, and the left and right sidebars share one
SlidingSidebar for their enter/exit animation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 2c9560328a Android: one IncomingBodyBuffer under the note and clipping editors
The note editor ran the incoming-body rule inline in NotesScreen (load
theirs, keep mine, observe, the post-save baseline) over three session
fields, while the clipping session had the same logic as methods over
three fields of its own. IncomingBodyBuffer, beside IncomingBody.alertFor,
now holds text/stored/incoming and the transitions; each session owns
one and keeps its existing names (body, text, incoming) by delegation.

Ordering is unchanged: keepLocal still moves the baseline to their
version and clears the hold before the caller asks for the write, and
the note's save gate (and so autosave/flush) still refuses the body
while a version is held. The two identical activity-scoped
rememberXSession helpers become one activityViewModel<T>().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 0f5e2c9d0f Android: derive the type ramp from Material's defaults
Type.kt restated all fifteen Material 3 styles to change four weights
and one size. It now takes Material's Typography() re-pointed at
Typeface.UI (the existing withFontFamily) and overrides only
headline*/titleLarge (SemiBold) and labelSmall (12sp). Its comment no
longer claims only used styles are overridden.

Needs a visual check: Material's default styles carry its default
lineHeightStyle/platformStyle, which the hand-built TextStyles did not,
so line metrics may shift slightly; displayLarge (unused) picks up its
-0.25sp tracking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 e7d7fcf083 Android: import types instead of writing them fully qualified
CLAUDE.md's style rule: use an import rather than a fully qualified
type in code. Covers the remaining TextOverflow, ExperimentalLayoutApi,
java.time, java.io/java.net, android.util.Log and android.graphics.Color
references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 73aa9d30d5 Android: fold document detail's repeated cards, rows and load chain
- The five Card/Column(16dp, 8dp)/SectionHeading openings become one
  DetailCard(title, trailing).
- The ID row, which re-implemented MetadataRow around a HashChip, uses
  a slot overload of it; its unused valueStyle parameter goes.
- PropertyRow and ArtifactRow share KindAndSummary (kind, modified-at,
  three-line value).
- The load chain's four sequential getOrElse { fail; return } reads are
  one ffi block on Dispatchers.IO, as are the document and descendant
  count reads that were on the main thread.
- Fully qualified TextOverflow references become an import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 b16c807e15 Android: one FilterDropdown and RemovableFilterChip on the documents list
The type and date filters were two ~45-line ExposedDropdownMenuBox +
read-only anchor blocks, each with its own expanded flag hoisted to the
screen; FilterDropdown owns its expanded state and hands its menu a
close(). The two removable chips become RemovableFilterChip, and the
set toggle written out twice becomes Set.toggle. The type and MIME
tables are read once with remember instead of a LaunchedEffect wrapped
in a try/catch around two infallible FFI calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 0074e66760 Android: route every length-capped human field through HumanTextField
Five OutlinedTextFields on the human detail screen hand-copied
HumanTextField's HUMAN_FIELD_MAX_LEN guard. HumanTextField now takes an
optional label and a modifier, so the gender freeform, fact key and
value, and contact value and label fields use it; the length cap is
written once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 b783638908 Android: drop a redundant podcast back handler and refresh flag
PodcastSearchScreen registered its own BackHandler inside a
SubAppScreen, which already has one. PodcastInfoScreen set a
shouldRefresh flag after subscribing to trigger reloadPodcasts(), which
subscribeToPodcast (and unsubscribeFromPodcast) already call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 e7d55e33f2 Android: tick playback position inside AudioPlayerManager
The music and podcast screens each ran the same while(isPlaying)
{ startPositionUpdates(); delay(1000) } loop, and startPositionUpdates
was really a one-shot refresh. The transport now starts a ticker from
onIsPlayingChanged and stops it on pause (refreshing once, so a paused
bar shows where playback stopped rather than up to a second before).
The literal log tag repeated through the file becomes a TAG constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 c381b349e4 Android: typed nav routes for Documents and Ensemble
The last two string-route graphs move to @Serializable routes like the
rest of the app: Documents' sealed DocumentsDestination (with its fully
qualified navArgument/NavType, against the style rule) and Ensemble's
EnsembleRoute. The String overload of AnimatedNavHost then has no
caller and goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 655d9442dd Android: share the initialization steps' title and ground
The four initialization screens each spelled out the same headlineLarge
centered title and the same full-screen background-plus-32dp ground;
InitTitle and Modifier.initStep say it once. The two-column mnemonic
grid walks displayLines.chunked(2) instead of indexing by twos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 89d264718d Android: one startNodeServices, one Settings branch in MainActivity
Booting into Main and finishing initialization both started the P2P
foreground service and then scheduled background sync; startNodeServices
does both. The ENSEMBLE and SETTINGS agent branches differed only in the
initial section, so they share one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 a2661a9ec6 Android: table-drive IncomingBodyTest
Seven near-identical alertFor tests become one table of named cases,
each keeping its reasoning as a comment; the case name is the assertion
message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 b25ce77652 Android: back the PDF page cache with android.util.LruCache
The cache was a hand-rolled access-ordered LinkedHashMap with its own
eviction loop and clear(). LruCache does both; entryRemoved recycles an
evicted page's bitmap as the loop did, and evictAll() replaces clear's
recycle-then-clear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 586f42cf44 Android: drop JobUtils aliases and PushJobRefs from the Jobs screen
JobUtils.kt was two one-line aliases of the uniffi jobKindLabel and
formatTimestamp; call them directly. PushJobRefs/asPushRefs paired each
id with its short form before building the push label, but IdRef already
takes a nullable short id. JobStatusLine's three identical
onSurfaceVariant branches collapse into the else, and the running-row
spinner is an InlineSpinner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 2fe3571081 Android: fold FlatButtonWithIcon into FlatButton, rename ui.IconButton
FlatButtonWithIcon was FlatButton with a composable icon slot and no
enabled state; FlatButton takes an optional ImageVector instead. The
start-aligned nav-row button is renamed IconRowButton so it no longer
shadows Material's IconButton (a bare glyph) in files that import both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 c560d7ce42 Android: one import path for a picked file, one import button
PdfFilePicker and ComicFilePicker were the same button with a different
MIME type, and VideoScreen inlined a third; ImportFileButton replaces
all three. importPdf, importComic, importVideo and importDocument each
resolved a name, read the bytes on IO and called a per-type import;
NativeLib.importPickedFile does it once, through the generic batch
import (Rust's SUPPORTED_FILE_TYPES dispatch) as a one-file tracked job.

Behavior differences:
- Documents' single-file import now handles every type folder import
  does (video and comics too, not just PDF/image/mp3) and names a PDF by
  its full filename like the PDF library and folder import, not its stem.
- A failed video import is now reported through the snackbar; it was
  only logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 88d54ab241 Android: one rememberReloadKey for store-change reload counters
Seven screens each held a mutableIntStateOf(0), bumped it from
OnStoreChange(DOCUMENTS), keyed their load effect on it, and pasted the
same three-line comment above it. rememberReloadKey returns that counter
(still bumpable by the screen after its own writes) with the comment
written once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 7fb3d7b59b Android: one SectionTabs for screens' own sub-view tab rows
The same TabRow + forEach(Tab) loop was written out on Jobs, document
detail, human detail, the podcast library, the media library and
Settings. SectionTabs<T> takes the entries, a label and optional icon,
and a scrollEdgePadding for the two scrollable rows.

Also fixes human detail losing its tab on rotation: it kept the section
in remember, where every other detail screen uses rememberSaveable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 1bc8665ee2 Android: one segmented picker, labeled switch and explainer text
ui/Pickers.kt adds SegmentedChoice<T> (the SingleChoiceSegmentedButtonRow
+ forEachIndexed + itemShape block written out eleven times), LabeledSwitch
(label-left/switch-right rows) and ExplainerText (the receded explainer
tone). Built on them:
- SyncOverrideControl and RemoteOverrideControl become one OverrideControl
  taking an isSelected predicate, a pending flag and an optional
  description.
- SyncPolicyControl and RemoteSyncPolicyControl share a PolicyPicker
  (name, picker, description, held/missing lines).
- The note toolbar's FormatDropdown and ReadingSurfaceDropdown share one
  CompactDropdown<T>.

The remote override's "Keep here:" caption is now bodySmall like the
local one (was labelSmall).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 79db22b02d Android: share centered empty/loading states, spinner and error card
ui/States.kt gathers the shapes screens kept re-typing: CenteredMessage
(MediaLibrary's private CenteredNote, with an optional glyph) replaces
the empty/failed panes on Jobs, the podcast queue, the document list,
new-episodes, podcast search and document detail; CenteredLoading the
four centered first-load spinners; InlineSpinner the small in-row and
in-button spinners; ErrorCard the errorContainer cards on Ensemble,
Node key and Retirement. RemoteFetchOrSpinner folds the identical
remote-fetch-or-spinner branch of the picture viewer and comic reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 4345ab7aa1 Android: share one ConfirmDialog and one InputDialog
Five hand-built confirm AlertDialogs (notes delete, human delete, job
history clear, purge, retire/reset) become ConfirmDialog, which closes
itself before running the act. Notes' InputDialog moves to ui/ with
accept/isValid/keyboardOptions, and replaces the new-person and PDF
go-to-page dialogs.

Human delete's confirm word is now error-colored like every other
destructive confirmation (it was the one exception).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 d040f6d1c4 Android: fetch shared copy once, from one SharedCopy object
The eight ui/*Copy.kt wrappers were each a remember { x() } or a plain
alias, and actions() was called ~40 times directly inside composables,
so every recomposition paid an FFI hop and an allocation for a constant.
One object with lazy vals holds each record for the process lifetime,
with one consolidated KDoc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 9c8dd927e4 Android: call stateless uniffi functions directly, not through NativeLib
NativeLib carried a wrapper for each top-level uniffi function that
needs no node (listTimezones, buildInfo, parseMarkup, renderMath, ...).
Callers now import the uniffi function; the fallible ones
(generateEnsembleKey, validatePartialDate, generateQrCodePng,
renderMath) are wrapped in ffiCatching at the call site, so their
Result handling is unchanged. The UInt/ULong conversions the wrappers
did move to the one caller each.

compileTypst, compileTypstWithFonts and notebookDefaultItemName had no
callers and go. With the wrappers gone, NoteEditorPane, TypstPreview
and MathContext no longer need a NativeLib at all, and QrCodeScreen and
GenerateEnsembleKeyScreen lose their nativeLib parameter.

Audit F18 (ffi report).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 236d717a56 Android: call uniffi handles through NativeLib.ffi, drop the *Agent layer
The 18 *Agent.kt classes (about 1,500 lines) were ~175 methods of the
form ffiCatching { nativeLib.node().podcast().x(a) }, each with a KDoc
restating the Rust doc comment that uniffi already copies into the
generated bindings. They are replaced by one member,

    internal inline fun <T> ffi(block: SyncNode.() -> T): Result<T> =
        ffiCatching { node().block() }

so a call site reads nativeLib.ffi { podcast().queueAdd(id) }. The
Result totality guarantee is unchanged: every call still goes through
ffiCatching, including the NotInitialized case.

The few methods that did more than forward stay, as small pieces:
- NativeLib.timezone()/nodeName() (log and collapse to null, 9 callers)
  and NativeLib.setLogConfig (also updates the Android log filter)
- NativeLib.beginImportBatch + BatchImport, moved to BatchImport.kt
- the one-caller "absent -> null + log" readers (comic pages/covers,
  pictures, blobs, podcast cover art, documents.get) became a generic
  Result.getOrLog(tag, what) at the call site, keeping each message;
  isBlobLocal/fetchBlobToPath/pagesNeedingPrefetch are inlined likewise
- UInt/ULong conversions and Array->List moved into the call
Call sites that used named arguments keep them, under the uniffi names.
nodeInitialized/resetNode lose their NativeLib wrappers the same way.

Not carried over, having no callers: ComicsAgent.getMetadata and
getReadingDirection, EnsembleAgent.getGossipNeighborCount,
InteractionsAgent.setAttendees, SettingsAgent.getDocumentSyncOverride.

Two small behavior differences: a call's arguments are now evaluated
inside ffiCatching (an exception while computing one becomes a failed
Result instead of propagating), and the best-effort readers log under
their caller's tag rather than "ComicsAgent" etc.

Audit F1 (android and ffi reports); the shape is recorded in the audit
handoff notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 3b02980b1e Android: drop unused Gradle dependencies
Nothing imports retrofit, okhttp's logging-interceptor, the retrofit
kotlinx-serialization converter, fragment-ktx or media3-ui (no
PlayerView, no layouts). Removed after grepping main, test and res, and
assembleDebug still builds. Version pins are left alone.

Audit F5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 49e6fa0aa4 Android: delete dead ui, init, pomodoro and document helpers
Zero callers or zero effect:
- rememberActionLabels / rememberPlaceholders (the non-composable
  actions()/placeholders() are what every site calls)
- SyncException.isVanished
- PresenceLook.ARRIVING (of() never produces it) and rowLabel
- RemoteFetch's stallHintMs()
- SynShapes.Pill
- PendingMnemonic.continueToMain, always false, and its branch
- the Pomodoro sessionsSummary alias
- PomodoroViewModel.onCleared: the ticker runs in viewModelScope, which
  cancels it anyway
- iconForDocumentType's note/notebook/collection branch, identical to
  its else
- DocumentFolderPicker's default label, equal to its one caller's
- the template ExampleUnitTest (2 + 2 = 4)

Audit F4d. Not taken: DocumentDisplayModel.size / formatDocumentSize,
which the audit called unread — VideoScreen draws it as the subtitle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 3c1d4f45a9 Android: delete dead podcast components and player calls
Zero callers: the PodcastItem and EpisodeItem composables,
Podcast.episodes (only ever emptyList(), and the reason equality had to
skip it), PodcastEpisode.isDownloaded (never set true or read),
PodcastRepository's public episodes flow, getEpisodesForPodcast and
getPodcast, and AudioPlayerManager/AgentAudioPlayer seekBackward,
seekForward and getDuration with their MILLIS_PER_SECOND.

Audit F4c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 5588ca9763 Android: delete the notes model's dead tree helpers
NotesModels.kt carried helpers from before the tree came from the store:
newId, addToCollection, removeItem, the tree renameItem, updateNoteBody,
placeholderNotebooks, and NoteBodyState.isEditable, none called anywhere
(isEditable's rule moves into the class doc). NoteItem.body was always
"" and never read. itemId() and NoteSearch's title() restated what the
sealed base can hold, so id and title are abstract there.

Audit F4b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 78147b15fd Android: open PDFs by path only, carried in a typed nav route
PdfScreen's only caller selects a PDF by path; the open-by-Uri and
open-by-bytes paths were dead: PdfRepository (PdfSource, SelectedPdf,
the StateFlow, selectPdf*/clearSelection), two of the three
PdfViewerScreen overloads, the PdfViewer(Uri) overload, and
MuPdfDocument.openFromUri/openFromBytes/getPageSize with their temp-file
cleanup. The documentId is always present too, so the viewer's
null-document branches (and the drawer's bookmarksEnabled switch) go.

The selection now travels as a @Serializable PdfViewerRoute(path, name,
documentId) instead of in a remember'd repository, which also fixes
rotating while a PDF is open: the recreated NavHost restored the viewer
route with no selection and drew nothing.

Audit F4a; fixes BUG4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 8751ffafa9 Android: keep ImportAgent.supports inside ffiCatching
supports called nativeLib.node() outside ffiCatching, so a
NotInitialized SyncException escaped as a thrown exception instead of
landing in a Result — the one import-surface call that broke the
totality guarantee. It returns Result<Boolean> now, and the folder walk
treats a failure as unsupported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 80602c5859 Android: middle-truncate a revoked node's id
Naming a node that never announced a name used nodeIdHex.take(8), an
end cut, against the rule that truncation cuts from the middle. Use the
shared shortId (the one HashChip uses) and drop the local SHORT_ID_CHARS.

Fixes audit BUG7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 b63267b56f Android: take audio focus and pause when headphones unplug
The shared ExoPlayer was built with neither audio attributes nor
becoming-noisy handling, so podcasts and music kept playing over a phone
call and out of the speaker after the headphones came out. Two builder
calls: setAudioAttributes(DEFAULT, handleAudioFocus = true) and
setHandleAudioBecomingNoisy(true).

Fixes audit BUG6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 38206bae23 Android: keep notes collections closed across store changes
The notes tree is re-read outright on every OnStoreChange (which fires on
this node's own autosave too), on every save/rename/create/delete, and
NotebookRepository builds each CollectionItem with the default
isExpanded = true. So any sync or save reopened every collection the user
had closed — rows moving under the user's finger, against the first
design principle — even though NotesScreen's comment said the sidebar
kept its expansion.

Expansion now lives in NotesScreen as a rememberSaveable set of collapsed
collection ids, passed to visibleNoteRows and the sidebar, instead of on
the model. That deletes CollectionItem.isExpanded, toggleCollection and
the notebooks.map rewrite the toggle did.

Fixes audit BUG3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 b819bd5963 Android: stop saving podcast search results across recreation
rememberSaveable held a List<PodcastSearchResult>, a kotlinx
@Serializable class that is neither Parcelable nor java.io.Serializable,
so rotating or backgrounding with results on screen crashed. The loading
flag was saveable too and could come back stuck at true after process
death. Both are plain remember now; the query and error text stay saved.

Fixes audit BUG2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 27bac60fe3 Android: show failures without uniffi's msg= prefix
uniffi renders SyncException.message as "msg=<text>", and about fifty
sites showed it.message/e.message to the user, most with a per-site
fallback string that could never fire (a SyncException always has a
message). Add Throwable.userMessage beside SyncFailures.kt's detail
(detail for a SyncException, else message, else toString) and use it
everywhere a failure reaches the screen or a log line.

The folder import in DocumentsScreen now goes through
launchReportingFailure instead of a bare launch + catch-all + showError;
the snackbar text ("Couldn't import folder: ...") is unchanged, and it
gains the shared retry policy.

Fixes audit BUG1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 cb21cadfa3 Android: ktfmt build.gradle.kts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:07:13 -07:00
Greg ShuflinandClaude Opus 5.5 188a53e6e7 p2p: drop needless borrows left in doc-sync tests
Clippy fixup for 'one ingest path for doc-sync pulls': the tests that moved
onto TestStore bind the stores by reference already.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:00:52 -07:00
Greg ShuflinandClaude Opus 5.5 f1fdc160e9 p2p: drop the last panics on the sync path, and fix stale protocol docs
Panics (audit B12): SyncCursor::encode swallowed an encode failure into an
empty cursor with unwrap_or_default — it now returns the error through
wire::encode; Android's DNS-over-HTTPS setup expect()ed the TLS config and
unwrap()ed two parsed socket addresses — it now reports a P2PStartError and
builds the addresses with SocketAddr::from. (That block is
target_os = "android" only and was not compiled here.)

Docs (audit B11): GATED_ALPNS carried two doc comments run together;
MembershipBatch had DocumentTombstoneBatch's doc glued on top and the latter
had none; build_membership_batch was documented as building tombstones;
node_admin claimed no membership check exists, which admission now
contradicts; the doc-sync session diagram omitted the tombstone and release
batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:54:44 -07:00
Greg ShuflinandClaude Opus 5.5 f5e23b7505 ensemble: build roster rows with one closure keyed by NodePubkey
build_ensemble_status wrote EnsembleMemberInfo out three times (this node,
gossip peers, registry-only members) with the same revoked/held/health
lookups, and keyed the revocation map by hex String — built with a
hand-rolled fold — so health_of/held_by parsed hex back into NodePubkey per
row. Everything is keyed by NodePubkey now and one row() closure builds all
three kinds. Adds a test pinning the row order, dedup, and revoked flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:50:26 -07:00
Greg ShuflinandClaude Opus 5.5 f057182219 ensemble: one encoding for signed records, and one registration path
EpochBump and RevocationRecord each carried to_bytes/to_blob/from_blob/
from_bytes, a many-argument signing_payload, and copy_from_slice parsing.
The SignedRecord trait provides the encoding once from payload(),
signature() and from_parts(); fields are read with a small Fields reader.
Records (and DelegationCert) are now built with a placeholder signature and
signed over their own payload(), so the free signing_payload fns and their
three identical call sites per type go away. Byte layouts are unchanged.

Also: the pkarr TXT-record decode loop is written once (registry_entries);
DelegationCert::create and revocation stamps use chrono instead of
SystemTime + expect; the cert payload doc now includes the epoch (audit
B11); and create_new_ensemble/join_local share register(), which also stops
setup_default_vault_key loading the node keypair a third time (so the
never-reachable InitError::KeypairMissing is gone).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:45:33 -07:00
Greg ShuflinandClaude Opus 5.5 4fa5baa2b5 p2p: one per-peer fetch walk for document pulls and the scheduler
sync_document_assets, sync_document_asset, and the scheduler's catalog-fetch
and content passes each walked peers fetching blobs under PEER_BLOB_TIMEOUT
and artifacts under PEER_ARTIFACT_TIMEOUT, recomputing what was missing
after each peer, with the two timeouts defined twice. asset_sync's
fetch_from_peers is the one walk: it takes what is missing now plus a
closure that recomputes it, and returns received counts, peers tried, and
whether the user canceled.

A canceled blob transfer counts what completed before the stop by
recomputing against the store, exactly as sync_document_assets did (and as
sync_document_asset's one-blob special case did); the content pass, which
used to count nothing on a cancel, now gets the same accounting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:38:21 -07:00
Greg ShuflinandClaude Opus 5.5 a7bc621919 content_fetch: one peer loop, one job finish, and the local path on load_to_file
fetch_blob_bytes and write_remote_blob_to_file were the same peer loop with a
Vec or a file for a sink; stream_from_peers is the one loop, taking a sink
factory it calls afresh for each peer that has the blob (so a failed peer's
partial copy is never appended to). The two *_as_job wrappers share
finish_stream_job.

write_local_blob_to_file re-implemented BlobStore::load_to_file chunk by
chunk, re-opening and re-seeking the blob for every 64 KiB read and
reporting a read failure as 'no vault key locally' (audit B8). load_to_file
now takes an on_chunk callback that can stop the copy, and the local path is
that plus the byte tracker; its errors surface as ContentFetchError::Local,
mapped to the readers' existing BlobStore variants and at the FFI to
BlobStoreError's own classification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:33:03 -07:00
Greg ShuflinandClaude Opus 5.5 9b4a4594e1 p2p: name peers by NodePubkey, not hex strings, in every client call
fetch_blobs_from_peer, query_blob_presence, fetch_artifacts_from_peer,
open_blob_stream, the node-admin calls, and deliver_revocations took a hex
&str (or an EndpointAddr) and re-parsed it, while the push fns already took a
NodePubkey. All take NodePubkey now, and wire::open converts it to an
EndpointId once; the per-protocol InvalidTarget variants fold into
WireError::InvalidPeer (still InvalidInput at the FFI).

The peer lists follow: scheduler::peer_order_by_presence,
content_fetch::peer_candidates, asset_sync's peers, the three
ensemble_peer_ids helpers (gui, daemon, ffi) and retirement::PeerLabel hold
NodePubkeys instead of converting to hex and back, which also retires the
two parse_peer copies whose failure silently dropped possession evidence.
The uniffi-exported remote_admin fns keep their String parameter and parse
it once at the boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:26:38 -07:00
Greg ShuflinandClaude Opus 5.5 bd28fdb64b ffi: route fetch_blob_to_path through content_fetch; drop the legacy whole-blob stream
Android's fetch_blob_to_path (music playback of a track held on another node)
walked get_peer_nodes_for_ensemble unordered and unfiltered — revoked peers
included — buffered the whole ciphertext in memory via
blob_sync::stream_blob_ciphertext_from_peer, and had no job and no cancel
(audit B6). It now calls content_fetch::write_blob_to_file_as_job, the path
the PDF reader already uses: presence-ordered, revoked peers filtered,
chunked to file, as a cancelable ContentStream job. The uniffi signature is
unchanged.

stream_blob_ciphertext_from_peer, its timeout, and
BlobStore::decrypt_ciphertext had no other callers and are deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:16:28 -07:00
Greg ShuflinandClaude Opus 5.5 2a1414987a p2p: one gossip join sequence for desktop and Android
NodeRuntime::start and Android's start_p2p_node each loaded the node name,
fetched the DHT registry into the UI cache, derived bootstrap ids, joined,
and spawned the bootstrap retry loop. P2PNode::join_ensemble does all of it
from the node's own store; both callers make one call, and
join_ensemble_gossip/seed_peer_addresses become its internals.

Bug fix (audit B5): Android's copy skipped seeding persisted peer addresses
and using persisted peers as bootstrap peers, so a phone only found peers
through the DHT registry. It now gets the same persisted-peer bootstrap as
the desktop. The per-peer bootstrap info dumps collapse to one count line,
dedup is by EndpointId rather than its string, and the registry cache lock
recovers from poisoning instead of panicking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:13:40 -07:00
Greg ShuflinandClaude Opus 5.5 aa216e5a23 p2p: one routine applies signed revocations and epoch bumps
doc-sync's membership batch, revocation delivery, and gossip's two handlers
each verified records against the ensemble key, bounded the stamp with
wire_timestamp-or-now, recorded revocations, then applied bumps oldest first.
revocation_delivery::apply_signed_membership is now the one copy; it returns
per-caller counts (verified for delivery's reply, new revocations and epochs
moved for doc-sync's log), and gossip calls it with a one-element slice. The
unbelievable-stamp warning gossip had now fires on every channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:11:04 -07:00
Greg ShuflinandClaude Opus 5.5 db1e4a9012 p2p: gossip takes the Store instead of five optional stores and setters
GossipDiscovery held Option<..> handles to the peer-node, peer-health,
document, revocation and epoch stores, filled in by five setters (one
through join_ensemble_gossip's peer_node_store parameter, which both callers
filled the same way), and every handler carried a 'no store attached'
branch. It now takes Arc<Store> at construction; the handlers take the store
they need directly, and join_ensemble_gossip loses the parameter.

The announcement watch channel holds a NodeAnnouncement rather than its
serialized bytes, so update_node_name no longer deserializes it back and
drops the wrong-variant NotJoined hack; messages are serialized at broadcast
through one broadcast() helper that the three broadcast_* fns share too.
PeerMap shrinks to the replay high-water mark it actually is, and its lock
and the neighbor set recover a poisoned lock instead of panicking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:06:17 -07:00
Greg ShuflinandClaude Opus 5.5 a09f587851 p2p: one ingest path for doc-sync pulls, and pass &Store instead of its fields
The client pull and the server's reverse pull ran the same sequence — header,
membership batch, tombstones, releases, staged chunks, schema-gap record,
promote — written out twice. Both now call ingest_pull (the reverse pull
passes no resume cursor, as before, so always opens a fresh session).

promote_session, apply_records, apply_document_tombstone_batch,
build_document_record, build_header and fetch_chunk took five to nine loose
fields of one Store under three #[allow(clippy::too_many_arguments)]; they
take &Store now, and serve_pull_response builds one SyncScope and varies only
its cursor. The peer-health store was optional only so tests could pass None,
so SkewedStamps::report takes it directly. SyncRequest::full()/scoped()
replace five literal constructions. The doc-sync tests use the shared
TestStore fixture instead of hand-building four stores each.

Also drops the next_cursor.unwrap() in the serving loop (audit B12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:00:11 -07:00
Greg ShuflinandClaude Opus 5.5 a97423a6b8 p2p: make doc-sync's client a plain future with a progress callback
sync_from_peer ran the exchange in a detached tokio::spawn and handed back a
channel stream whose sends ignored a closed receiver, so dropping the stream
did not cancel anything despite the doc saying it did (audit B3). The
scheduler's PEER_CATALOG_TIMEOUT, its cancel break, and asset_sync's timeouts
all returned while the sync kept running, and the next tick could start a
second concurrent sync with the same peer.

It is now an ordinary async fn returning a SyncOutcome, reporting through an
FnMut(SyncProgress) -> ControlFlow callback. Dropping or timing out the future
abandons the exchange; the callback answering Break stops it at the next
chunk with DocSyncError::Canceled, which gives the scheduler's catalog pass
and refresh_document_catalog a per-chunk cancel instead of between-peers.
Without the spawn, sync_from_peer and its callers (scheduler passes,
asset_sync's refresh/recursive/push) take &Store instead of Arc<Store>.

Behavior change on cancel: a canceled catalog sync is now an error (recorded
as Canceled on the job) rather than Ok with zero counts, so it no longer
advances the peer's last_synced_at watermark over a partial pull, and
scheduler::sync_with_peer no longer goes on to trigger the remote and run
content passes after the user canceled.

Adds a test that an abandoned staging session leaves the next sync free to
complete without its records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:47:43 -07:00
Greg ShuflinandClaude Opus 5.5 96f3e36f70 rust: move duplicated test fixtures into test_support
content_ledger, scheduler and eviction each carried verbatim copies of
missing_blob_ref/attach/add_doc, reseed its own attach_blob, and retire,
retirement and scheduler wrote out the eight-field PeerNode literal five
times with its upsert transaction around it. test_support now has
add_doc, missing_blob_ref, attach_blob_ref, attach_stored_blob, peer_node
and add_peer; tests override only the fields they care about.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:31:19 -07:00
Greg ShuflinandClaude Opus 5.5 1b5b128850 jobs: fold the run_as_job variants and drop the watch-channel flag
- run_as_job and run_as_job_async are now the _with_progress forms with
  an op that ignores the reporter, so open_job/close_job inline away.
- JobSubject::from_key builds its parse error in one closure instead of
  seven copies.
- The cancel flag was a tokio watch channel used only as a bool: it is
  now an Arc<AtomicBool> shared by the runner's entry and the handle, and
  JobHandle.finished needs no Arc (the handle is never cloned).
- node-admin's JobRunnerCell becomes Arc<OnceLock<..>> — every modality
  wires the same runner, so first-wins is the existing behavior — which
  drops two poisoned-lock expects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:11:26 -07:00
Greg ShuflinandClaude Opus 5.5 e4e779550c vault: one blob cipher for sealing and opening, one HKDF helper
decrypt_blob_chunk re-derived the HKDF content key and rebuilt the cipher
for every 64 KiB chunk — 16k expansions per GB — though BlobSealer's own
doc explained why the encrypt side avoids exactly that (audit B10).
BlobSealer becomes BlobCipher with seal and open; decrypt_blob,
BlobStore::load_to_file, BlobStreamSession (which now holds a cipher
instead of vault key + hash) and content_fetch build it once per blob.

The four HKDF-SHA256-to-32-bytes copies (vault x3, Secret::to_keypair)
share hkdf32, and ChaCha20Poly1305 keys are built with the infallible
KeyInit::new from a [u8; 32], removing five expects; hkdf32 keeps the one
genuinely-infallible expansion expect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:06:39 -07:00
Greg ShuflinandClaude Opus 5.5 b6f71dd96d rust: shared membership, keyphrase and hex helpers
- SettingsStore::membership() replaces the
  settings_store.batch(|tx| settings_store.load_ensemble_membership(tx))
  dance at 17 call sites across lib (p2p, ensemble, scheduler, node,
  node_status, ffi).
- retire.rs's three copies of from_keyphrase -> compare pubkey ->
  WrongKeyphrase become ensemble_keypair_for().
- Hand-rolled hex loops become hex::encode (already a direct dependency),
  including BlobHash::from_blake3_hex's decode, which also stops it
  panicking on a 64-byte string containing a multi-byte character.

Short-id truncation (NodePubkey::short_hex etc.) is untouched: that is
user-visible and left for a decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 06:00:42 -07:00
Greg ShuflinandClaude Opus 5.5 4847ff8740 p2p: skip an undecodable artifact value on fetch instead of failing
fetch_artifacts_from_peer ?'d on one ArtifactValue it could not decode,
abandoning the rest of the response — so after a new ArtifactValue
variant, a single artifact from a newer peer blocked every artifact fetch
from it. serve_push already skips such an item with a warning; the fetch
side now does the same (audit B7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 05:55:39 -07:00
Greg ShuflinandClaude Opus 5.5 66b4072a8c p2p: delete dead gossip and blob-sync code
None of these had a caller anywhere in the workspace (gui-app, daemon,
cli, tui, ffi, Kotlin):
- blob_sync::fetch_missing_blobs_from_peer (and the module doc pointing
  at it);
- GossipDiscovery::with_peer_node_store, shutdown and the event_task
  handle only shutdown read (P2PNode::shutdown goes through the router),
  plus GossipError::Shutdown;
- P2PNode::discovered_peers and GossipDiscovery::peer_map, and with them
  PeerMap::peers and DiscoveredPeer's relay_url/created_at_secs, which
  nothing but tests read. len/is_empty stay as test-only helpers;
- the empty rust/lib/src/bin directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 05:55:03 -07:00
Greg ShuflinandClaude Opus 5.5 0f608567b0 p2p: one ping exchange instead of two
P2PNode::ping and ping_via_endpoint each carried the whole PING/PONG
exchange, differing only in how the target was parsed and in the CLI's
connect hint. A free p2p::ping(ep, addr, timeout) now does the exchange;
ping_node_id parses JSON-or-id and adds its hint, ping_via_endpoint parses
a hex id. Callers are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 05:49:41 -07:00
Greg ShuflinandClaude Opus 5.5 c49d2e2d51 p2p: share blob-sync's chunk send and receive loops
Three receive loops and two send loops in blob_sync were the same code
with and without a progress tracker. send_blob/recv_blob take a
FetchTracker (untracked where nobody watches), keeping the cancel-between-
chunks behavior of the tracked paths exactly.

Sending no longer collects the chunks into a Vec to find the last one or
copies each 4 MiB chunk: BlobChunk borrows its data as a Cow. The receive
buffer's preallocation is capped at one chunk rather than trusting the
peer's claimed size. blob-stream's send loop drops the same collect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 05:48:14 -07:00
Greg ShuflinandClaude Opus 5.5 ae10b2b6d9 p2p: trim protocol-handler boilerplate
EnsembleProtocol gains an associated Error type, so each handler's serve
is its former run body and Gated converts with AcceptError::from_err —
instead of six serve shims that flattened the error to a string and lost
its source chain. The Debug supertrait is dropped (Gated prints the
handler's type name), taking eight hand-written Debug impls and six new()
constructors with it; Retiring derives Debug and uses Default.

A small Gate struct replaces the local generic gate() fn, which shrinks
P2PNode::start's 60-line router block to one line per ALPN. ClientIntent,
generic but used by artifact-sync alone, becomes a plain enum there. The
admission lint's accepted spelling follows the rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 05:40:07 -07:00
Greg ShuflinandClaude Opus 5.5 5463d8a6dc p2p: bound dial and admission handshake together, once
EnsembleEndpoint::connect's DIAL_TIMEOUT covered only the QUIC dial, so a
peer that completed QUIC and never answered admission hung the caller
until its own outer budget (180 s for doc-sync) — audit B4. It now bounds
dial + handshake together, which is what artifact-sync, blob-sync,
blob-stream and node-admin each re-implemented with a second 15 s (10 s)
timeout around connect. Those wrappers, their constants and the two
timeout-only _inner fns in blob_sync are gone.

Effective bounds: unchanged (15 s) for artifact/blob/stream; node-admin's
connect bound goes from 10 s to 15 s; doc-sync, revocation delivery and
ping gain a handshake bound they never had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 05:37:14 -07:00
Greg ShuflinandClaude Opus 5.5 14be1ae21d p2p: share one wire framing module across every protocol
Seven protocols each carried an identical write_msg/read_msg pair and the
same seven transport/codec error variants. They now share p2p/wire.rs:
framing, a WireError each protocol wraps as Wire(#[from] WireError), and
open/accept helpers for the single bidirectional stream.

Fixes two bugs from the p2p audit:
- B1: the length prefix was trusted, so any peer could make us allocate
  up to 4 GiB — on admission, before its cert was checked. Frames are now
  capped (MAX_FRAME for members, 64 KiB for the handshake), and the writer
  refuses an oversized frame instead of truncating its length to u32.
- B2: rmp-serde wrote every Vec<u8> as an integer array (~1.5x for
  ciphertext). wire::encode writes bin; a unit test proves the decoder
  reads both encodings for every wire shape, so no ALPN bump is needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 05:33:24 -07:00
Greg ShuflinandClaude Opus 5.5 82a4c5a9a4 Android: fetch rustls-platform-verifier's AAR from upstream's Maven archive
Build Debug APK / build (push) Failing after 4m21s
The cargo update moved rustls-platform-verifier-android to 0.2, which no
longer bundles a maven/ directory for Gradle to point at, so the build could
not resolve the verifier. Upstream now publishes the AAR under org.rustls in
a Maven archive on GitHub; that repository is scoped to that one group, and
the version is read from Cargo.lock so the JVM half always matches the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 01:31:06 -07:00
Greg ShuflinandClaude Opus 5.5 67e1d206e0 rust: as_chunks over chunks_exact with a constant size (clippy, Rust 1.98)
Same iteration, remainder dropped as before; the chunk is now a [u8; N], so
the PDF UTF-16 decoders destructure it rather than index it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 01:09:45 -07:00
Greg ShuflinandClaude Opus 5.5 249cf90d96 gui: move gpui-ce to 17d9c8e8 (HEAD as of 2026-09-27)
HEAD renames the packages to gpui-ce and gpui_ce_platform; their lib names
are unchanged, so the dependency keys stay gpui/gpui_platform and no import
moves. It needs Rust 1.95, which the flake now provides. The one API break is
Deferred::with_priority becoming priority, with the same meaning for an
explicit value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:55:42 -07:00
Greg ShuflinandClaude Opus 5.5 b5eb1ea68d TODO: pkarr 8 drops the compare-and-swap the membership registry relies on
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:40:28 -07:00
Greg ShuflinandClaude Opus 5.5 fb23a0c532 gui: bump tree-sitter and tree-sitter-highlight to 0.27
Build Debug APK / build (push) Failing after 23m51s
Highlighter::highlight gained an input-encoding argument; None keeps the
UTF-8 parse it always did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:25:15 -07:00
Greg ShuflinandClaude Opus 5.5 8c58985e08 rust: bump uniffi to 0.32
Its Kotlin-facing breaks are UDL [ByRef] bytes arguments and async primary
constructors; this crate has no UDL and no async constructors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:10:40 -07:00
Greg ShuflinandClaude Opus 5.5 6e85b6b0cd rust: bump iroh-mainline-address-lookup to 0.5
pkarr stays on 5: pkarr 8 drops the compare-and-swap argument from publish,
which publish_membership relies on so two nodes registering at once don't
evict each other's registry entries. Moving to it is a design change, not a
version bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:07:03 -07:00
Greg ShuflinandClaude Opus 5.5 ec927b97cd rust: bump rusqlite to 0.40
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:59:44 -07:00
Greg ShuflinandClaude Opus 5.5 fb904219ce rust: bump the RustCrypto set (sha2, hkdf, chacha20poly1305, argon2)
aead 0.6 renames AeadInPlace to AeadInOut and deprecates Nonce::from_slice,
so nonces are built from their [u8; 12] by value. SealedName::open splits the
wire with split_first_chunk rather than a slice it then re-checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:49:10 -07:00
Greg ShuflinandClaude Opus 5.5 036f798b2a rust: bump rstest, dirs, lofty, base64, zip, quick-xml; drop unused fontdb
quick-xml 0.42 hands names, attribute keys and text back as str rather than
bytes, so ComicInfo.xml parsing compares against string literals now. fontdb
was declared by lib but never used, and typst still pins 0.23.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:42:28 -07:00
Greg ShuflinandClaude Opus 5.5 548e86eabe TODO: introduce Quint, starting with a known incremental-sync bug left in as its proof
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:32:32 -07:00
Greg ShuflinandClaude Opus 5.5 d370691ec7 rust: pin gpui-ce to its locked rev, and cargo update to latest compatible
The gpui-ce git dependency had no rev, so any cargo update pulled its HEAD —
which has since renamed the package to gpui-ce, and fails to resolve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:30:15 -07:00
Greg ShuflinandClaude Opus 5.5 f0a5968efc flake: move the Nix toolchain to Rust 1.98.1, matching rustup stable
1.92 was holding back crate updates for no reason: the native toolchain is
already on the latest stable, and gpui-ce's HEAD needs 1.95.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:30:15 -07:00
Greg Shuflin fcb840fdb8 Add note about tabs
Build Debug APK / build (push) Successful in 18m18s
2026-09-22 01:24:20 -07:00
Greg ShuflinandClaude Fable 5.1 45a625f480 Android: the PDF library reads the agent's own list
PdfLibraryScreen takes PdfFfi::list instead of the generic document
search, so each row's subtitle (page count, or when it was added) and its
availability marker come from the same Rust list the desktop draws. The
byte-size subtitle it had before was a line the desktop never showed.

Closes the TODO item; ADR 0035's consequences updated to match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BgxTcsGx5z4m8ftm4U489
2026-09-21 20:43:41 -07:00
Greg ShuflinandClaude Fable 5.1 d5623d2604 Android: ktfmt catch-up on two files that landed unformatted
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BgxTcsGx5z4m8ftm4U489
2026-09-21 20:43:41 -07:00
Greg ShuflinandClaude Fable 5.1 b4f08e5a6c Desktop: the PDF library row's subtitle comes from the lib
The "Added …" derivation and the recently-added window it keyed off move
to synchronicity-lib (copy::pdf_library, agents::pdf::RECENT_WINDOW_DAYS);
the row now shows the page count when one was read at import.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BgxTcsGx5z4m8ftm4U489
2026-09-21 20:31:33 -07:00
Greg ShuflinandClaude Fable 5.1 6299bb4af3 lib: persist a PDF's page count at import and put it on the library row
A new PdfPageCount property (U32, LastWriteWins, absent rather than 0 when
the bytes didn't parse) is written by write_pdf_properties from the same
hayro parse that already reads the title, so every import path gets it.
PdfListItem carries the count and a ready-made subtitle — the count when
there is one, else the existing "Added …" line — decided in
copy::pdf_library so the desktop and Android cannot word the row
differently. The item is now a uniffi record and PdfFfi::list hands the
whole library across, availability included.

A new PropertyKind is a sync wire change, so SYNC_PROTOCOL_VERSION bumps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BgxTcsGx5z4m8ftm4U489
2026-09-21 20:31:33 -07:00
Greg ShuflinandClaude Fable 5.1 ffc7269a3d Desktop: a hard clip on user text cuts from the middle too
The middle-ellipsis pass covered every .truncate() call but left the
elements that clipped with overflow_hidden + whitespace_nowrap and no
ellipsis at all. Where the text is the user's — a document title in the
screen header, a note's title in its meta row, a node name, a sidebar
row, a media-library row or breadcrumb, the track fetch line — it now
gets truncate_middle() instead of a raw cut at the right edge.

screen_title and header_crumb were flex_none, so a long document title
pushed the search field and actions off the header rather than giving
way; both now shrink (never grow) with a middle ellipsis.

Fixed-vocabulary labels — the agent rail's names, the "Video" heading,
the reading-surface picker — keep their hard clip: nothing at their end
tells two of them apart, and the rail's clip-as-it-narrows is on purpose.
The table's Auto column is sized to its content and never overflows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 20:00:37 -07:00
Greg ShuflinandClaude Fable 5.1 50e1db1510 Android: a one-line truncation cuts from the middle
Single-line Text uses TextOverflow.MiddleEllipsis. Multi-line clamps
keep the end ellipsis: Compose only supports the middle one on a single
line. HashChip's private copy of truncate_id is replaced by the FFI
export of the shared one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 23:35:22 -07:00
Greg ShuflinandClaude Fable 5.1 5889b6d985 AGENTS.md: a truncation cuts from the middle
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 23:35:22 -07:00
Greg ShuflinandClaude Fable 5.1 2c836ab4eb Extension: ids, clipping previews and node names cut from the middle
src/elide.js restates copy::elide_middle and copy::truncate_id, which
the extension cannot call. CSS has no middle ellipsis, so a node name
is measured and fitted instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 23:25:12 -07:00
Greg ShuflinandClaude Fable 5.1 ee2acd1742 Desktop: a one-line truncation cuts from the middle
gpui's .truncate() ellipsizes the end. components::truncate adds
truncate_middle() — the same composition over text_ellipsis_middle —
and every truncating element uses it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 23:19:25 -07:00
Greg ShuflinandClaude Fable 5.1 5cc95b86c5 Truncation by character count cuts from the middle
An end cut drops whatever distinguishes two long strings that share a
beginning — a draft/final suffix, an episode number, the last digits of
an id. copy::elide_middle keeps both ends, and truncate_id, the FFI's
value previews, the daemon's docs-show summaries, the desktop property
table and the TUI's note tree all go through it (the TUI by columns
rather than characters). The FFI previews were byte-sliced and would
have panicked on non-ASCII text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 23:18:03 -07:00
Greg ShuflinandClaude Opus 5 8bd0d74a45 A pull or push stops when the user says stop
Every surface that starts one of these had a live cancellation token
already; nothing read it. asset_sync's functions took no signal, so the
work ran to the end and the row was only recorded as Canceled.

The signal is a ProgressReporter, threaded through every asset_sync entry
point and down into blob_sync's chunk loops. It is read in four places,
which between them are what a cancel feels like: between documents in a
recursive walk (a folder of two hundred episodes is two hundred short
transfers, and that is where it pays off most), before dialing each peer,
and once per chunk in both the fetch and the push. A Canceled from a
transfer ends the whole pass rather than falling through to the next peer,
which would have made it mean "skip this node".

A stopped pull reports what it got, as a `canceled` flag beside real
counts rather than an error in place of them. Blobs that completed inside
the interrupted session are read back off the store, since the error
carries no count and a verified blob is on disk either way. The sentences
live in copy::asset_sync so all four surfaces share them — the case that
matters is a canceled pull reporting itself "up to date" over partial
counts, which is a lie about the ensemble.

Two things scoped deliberately, both written down where the code decides:

- A canceled push names no artifact count. That number is the peer's tally
  and canceling abandons the session before it arrives, so the peer keeps
  what it got but this node cannot say how much.
- The recursive walk drops the byte counters it would otherwise emit. Its
  unit is a document, and per-file bytes restart the row's counters at
  each one, drawing a bar that fills and resets — which reads as stuck.
  Counting documents properly is follow-up work; ImportCounts has the
  shape.

The blob protocol's header already carried total_bytes and a per-item
size, so the counters cost no wire change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPmrChbfTsjXLr162v18hQ
2026-09-11 04:06:08 -07:00
Greg Shuflin dd5e063271 Spelling: American, in the podcast save/drop copy
Build Debug APK / build (push) Successful in 5m17s
2026-09-07 00:42:38 -07:00
Greg Shuflin e52652c3a2 Android can save an episode, drop it, and play the copy
The player screen grows the same three-state control the desktop's cards
have — Save for offline / Saving… / Remove download — and episode rows
and queue rows carry the `⬇ Saved` mark beside the listen label.

The control is on the player screen rather than on every row, which is
where the desktop puts it: a phone row is ~350dp and already ends in the
queue control, so a second button would leave the title two words. This is
the same width decision as the two-column/stacked split on detail screens,
not a difference of taste — the rows still say whether an episode is
saved.

Playback opens the saved copy through `local_audio_path`, which decrypts
to a file under the app cache named after the blob's own address: a second
play finds it already there, and an episode re-saved from a publisher who
changed the file lands under a different name rather than silently serving
the old audio. Decrypting streams a chunk at a time, so a 200 MB episode
does not have to fit in memory twice on its way to ExoPlayer — which
`getBlobBytes` would have required.

A failed decrypt falls back to streaming: the enclosure is still there,
and refusing to play something because a cache write went wrong would be
the worse answer.
2026-09-07 00:42:38 -07:00
Greg Shuflin c40d80606f The desktop can save an episode, drop it, and play the copy
Episode cards and the episode screen grow one control in three states —
Save for offline / Saving… / Remove download — and a `⬇ Saved` mark that
also appears on queue rows. The mark is per node and is only ever an
addition: its absence says "not here", not "nowhere".

Saving is a tracked `Import` job keyed on the episode document, so the
click returns at once and two presses are one download. A save refused by
that dedup says so where the control is.

`episode_audio_source` is what makes any of it worth doing: every place
that hands an episode to the player now prefers the saved copy and falls
back to the enclosure URL, rather than three screens each deciding. An
episode saved and then streamed anyway is a download that cost bandwidth
and bought nothing.

`EpisodeRow` loses its derived `PartialEq`. `saved_locally` and
`saving` are facts about this disk, not about the document, and no sync
can change either — leaving them in would put a "these episodes changed
elsewhere" marker over the user's own download finishing. They are kept
fresh through `current` instead, which is allowed because a mark
changing in place moves nothing.

The episode screen also stops retyping "+ Queue"/"✓ Queued", which have
lived in `copy::podcast_queue` since the queue landed.
2026-09-07 00:42:38 -07:00
Greg Shuflin ee2a29c49f An episode can be downloaded and kept, and dropped again
The TODO's "no offline copy of an episode": playback streamed the
enclosure and nothing ever put the audio in the blob store, so an episode
could not be taken onto a plane.

`podcast::save_episode_audio` downloads the enclosure to a staging file
and seals from there. Two new `BlobStore` methods make that possible
without holding the episode in memory — `store_file` seals from a path
and `load_to_file` decrypts to one, both a 64 KiB chunk at a time. A
200 MB episode was 200 MB resident through `store`'s `&[u8]`, and the
phone is the node most likely to be asked to hold one. `store_file` reads
twice, because convergent encryption derives the content key from
`BLAKE3(plaintext)` of the whole blob — that price is paid in disk reads
rather than in memory, and a test pins the two paths to the same address.

Downloading gives the episode a document, whether or not anyone has
started listening: saving a back catalogue for a flight is something the
user has said about those episodes.

`release_episode_audio` drops the local copy, deliberately not through
`eviction`. That machinery exists to stop a user deleting the ensemble's
last copy and pays live presence queries for it; a podcast enclosure has a
copy the ensemble never controlled, at the URL on the document itself.
The two records eviction *would* have written are still written, through
its `record_local_release` — extracted here, because owing peers a
release log is not eviction's alone.

The episode document survives, and must: `Provenance::Syndicated` is
never tombstoned, since a deterministic id that has been tombstoned can
never be used again.

`saved_locally` joins the two batched projections both frontends already
read, so a list gets the mark without a query per row.
2026-09-07 00:42:38 -07:00
Greg Shuflin 86007fdd31 The Queue tab survives a trip to the player and back
`rememberSaveable` for the podcast library's tab, and the words on the
two-answer strip stop claiming only two screens draw it — the queue is the
third, over a hand-ordered list rather than a buffer, and what each of its
answers costs is different enough to say.
2026-09-07 00:42:38 -07:00
Greg Shuflin 30f3c78d9c Progress writing follows the transport, not the screen showing it
Playback outlives the screen on both modalities — Android's transport is a
`MediaSessionService` that keeps going from the notification, and the
desktop's bar sits under every tab of the Podcasts agent — but the write
loop was owned by the player screen. Leaving it flushed once and then
recorded nothing, so an hour listened from the lock screen came back as
the two minutes before the user navigated away. On the desktop it was
worse than that: the subscription hung off the podcast *detail* screen, so
an episode played from the Queue tab was never recorded at all.

Android gets a `ListenProgressRecorder` that follows the Podcasts handle
on the transport for as long as the app is alive; the player screen keeps
only the resume *read*, which is still its own (a position arriving
mid-episode must never seek under the listener). `flushListenProgress`
and the scope it needed go with it — there is nothing left to outlive.

On the desktop the same subscription moves up to `PodcastsView`, which
owns the player and lives as long as the app. The feed's remembered rate
stays with the detail screen, which is the only thing that knows which
feed it belongs to.

What is written and when is unchanged; `docs/audio-player-ui-design-\
principles.md` now says which lifetime it belongs to, since "flushed when
leaving the screen" was the line that made this look correct.
2026-09-07 00:42:38 -07:00
Greg Shuflin ccd942aad0 Android episode rows say where the listener got to
The phone already wrote the position and resumed from it; the list said
nothing, so an episode started and abandoned looked exactly like one never
opened.

A row's second line grows a third fact after its type and date — the
`DocumentDisplayModel.status` slot, null for every other kind of document,
which is most of them. The episode's words are `copy::listen_state`'s, the
same ones the desktop's cards draw, and they arrive already written on the
batched `episode_states` read the queue control was already making.
2026-09-07 00:42:38 -07:00
Greg Shuflin 06ace12d09 Android gets the play queue, and the control that fills it
`podcast_queue` — the cross-series queue the desktop has a whole tab for —
was desktop-only, which also left the phone with no way to put an episode
anywhere but straight into the player.

A Queue tab joins Subscriptions and New episodes in the podcast library,
and each episode row grows the `+ Queue` / `✓ Queued` control the
desktop's cards have. Both read their words from `copy::podcast_queue`.

The queue is ordered by hand, which makes it the sharpest case of
"a sync must never disrupt what the user is doing" on a phone: an
arriving reorder moves the row under a thumb already reaching for it. So a
re-read that differs from what is on screen is held and announced — the
same two-answer strip and the same words the desktop shows — and while it
is held the reorder controls are inert. The user's own moves apply at
once, and a write that fails re-reads the store and says so, rather than
leaving the list claiming a move the ensemble never heard.

Rows come through `episode_states`, one batched read per page rather than
one per row.
2026-09-07 00:42:38 -07:00
Greg Shuflin 4c6ad8043c One episode-list join and one set of queue words, for every frontend
The phone is about to grow a queue screen and listen state on its episode
rows, and both were about to be assembled a second time in Kotlin.

- `podcast::episode_store_state` is the batched join the desktop's
  `with_store_state` was doing by hand: derived document id, listen state
  and queue membership for a whole page of a feed, in one pass. Asking per
  row is two store round trips per episode on a list of thirty, and a sync
  re-reads the list.
- `QueuedEpisodeRow` carries the episode whole (`StoredEpisode`) rather
  than the four fields the desktop happened to draw. The queue is the one
  place an episode is reached without its feed's list in hand, so opening
  one from there has nowhere else to get its description or publish date.
  The property-reading loop it had grown is now `podcast::stored_episode`,
  shared with `list_feed_episodes`.
- `copy::listen_state::label` and `copy::podcast_queue` move the words
  out of the desktop screen: "▶ 12m left", "✓ Finished", "+ Queue",
  "✓ Queued", "Remove", and the empty-queue sentence. Two modalities
  drawing the same rows from the same synced artifact must not each spell
  them.
- The FFI grows `episode_states`, the five `queue_*` calls, the queue's
  incoming-change banner, and `podcast_queue_labels`.
2026-09-07 00:42:38 -07:00
Greg Shuflin 18341719d0 Android saves where the listener got to, and resumes from it
Android played episodes and remembered nothing. The artifact
(`ArtifactKind::ListenProgress`), the store functions, and the desktop's use of
them all existed; the phone simply had no path to them, which is also why it
created no episode documents at all once browsing a feed stopped writing them.

`ListenState` now crosses the FFI as itself — it is a three-variant enum, and a
mirror record would be a second place for those variants to disagree — under
three new `PodcastFfi` methods: `listen_state`, `save_listen_progress`,
`save_listen_finished`. All three sit on the `agents::podcast` functions the
desktop already calls, so both modalities write the same row through the same
code.

The player screen then does, in order:

- derive the episode's document id from its feed and publisher id, which writes
  nothing and works before any document exists;
- read the stored position and start playback there — read *once*, before
  playing, because a position arriving from a peer mid-episode must never seek
  under the listener;
- create the document on the first transition to playing, which is what
  "starts listening" means and the moment an episode earns a row;
- write the position every 5 seconds, and once more the instant playing stops.

Two ways this goes quietly wrong, both handled. A pause that only cancels the
write loop loses up to the interval, and pausing is how people ordinarily stop
— so stopping flushes rather than just returning. And the end of an episode
stops playback too, so the stop-flush would race the `Finished` write and could
land a position two seconds short of the end on top of it; the flush stands
down when the track ended. Leaving the screen flushes as well, on a
repository-owned scope, because playback outlives the composition and the
composition's own scope is already cancelling by then.

The write policy — every 5s, floor at 3s in, `Finished` rather than a position
at the end — is now written down in `docs/audio-player-ui-design-principles.md`
rather than decided twice. Both players' constants cite it. Android's live with
the writer in `PodcastRepository`, so the floor holds for every path that
writes a position instead of each call site remembering it.

What this does not do: the episode *list* still shows no listen state on
Android, where the desktop's rows read "12m left". And on both modalities the
write loop belongs to the player screen, not the transport, so a phone playing
from the notification after the screen closed records nothing past its parting
flush. Both are in TODO.md.
2026-09-07 00:42:38 -07:00
Greg Shuflin a4775169d9 Podcast episodes get derived ids, and a document only when there's something to store
Two changes that turn out to be one.

**Derived ids.** Episodes took fresh v4 ids and `save_feed_episodes`
deduplicated by scanning the feed's existing children for a matching
`PodcastEpisodeApiId`. That only compares against what *this* node holds,
so two nodes refreshing one feed independently created two documents per
episode and sync delivered both — the failure `docs/rss-reader.md` §3.3
exists to reject. `episode_document_id(feed, api_id)` derives the id
through `documents::derived_id`, and the dedup scan is deleted along with
`existing_episode_api_ids`.

**A document is what an episode with state looks like.** Searching a
podcast and subscribing wrote the first 50 episodes as documents, every one
of which then synced to every node. Nothing was stored *about* them:
listen position, queue membership and (eventually) downloaded audio are the
only reasons an episode needs a row. So `save_feed_episodes` becomes
`ensure_episode_document`, singular and idempotent, called at the moment
the user plays or queues an episode — not when they browse one.

The two changes are the same change: a derived id is what makes browsing
without writing possible. A row the API just returned can be asked what its
document id *would* be, so a listing carries listen state and queue
membership for episodes no node has ever written a document for, and the
document that later appears lands at exactly that id.

The list on screen is now the publisher's, from the API, with the store
layered over it — plus any episode this node holds a document for that has
since dropped off the feed's first page, which would otherwise silently
vanish. A failed fetch falls back to what is held rather than erroring, so
an offline node still reaches what it has state on.

Android loses its offline episode list and gains no writer: it saves no
listen position and has no queue, so nothing there creates an episode
document yet. `PodcastAgent.ensureEpisode` is the seam; TODO.md carries it.
2026-09-07 00:42:38 -07:00
Greg Shuflin 013c885d60 One helper computes every derived id, instead of seven near-copies
Several kinds of row here have an id that is a function of what they are
rather than a fresh v4 — a music artist from its tag, an agent's settings
row, a fact under a key — which is what lets two nodes create "the same"
thing without coordinating and converge on one row after sync.

Every one of them derived its id for itself, and while they all agreed on
UUIDv5, the particulars drifted: a namespace constant per site, three
conventions for joining key parts (`\u{1f}` in facts, `:` in anchors,
`\u{0}` in credentials), and no shared answer to whether the parts arrive
already normalized. Agreement on those particulars *is* the point — two
derivations that differ in any of them produce two ids just as surely as
two different algorithms would, and each side is internally consistent, so
nothing notices.

`documents::derived_id` is now the only place `Uuid::new_v5` is called for
a row id. A caller names a `Domain` and the parts of its key; the namespace
and separator are the helper's. Canonicalization moves in with it, as
`folded` (trim + case-fold) and `collapsed` (adds NFKC and a whitespace
squeeze), both idempotent so a caller that normalizes for its own reasons
can pass the result straight through. The `Domain` enum is the registry of
every derived id in the codebase; adding one has nowhere else to go.

Ids move, once. The project has no backwards-compatibility constraint and
key-derivation salts are explicitly in scope for breaking changes; the two
"have not moved" tests that pinned the old per-site formulas are replaced
by one pinning this one.

Also adds `Provenance::Syndicated` — deterministic id, listed, never
tombstoned — which the podcast agent needs next and no existing variant
said. `Anchor` has the same three predicate answers but means "an agent's
well-known singleton"; there is one syndicated document per item a
publisher released.
2026-09-07 00:42:38 -07:00
Greg Shuflin a78af76391 Vault: bind vault_id as AAD when wrapping a vault key
Build Debug APK / build (push) Successful in 11m47s
Every wrapped key in vault_keys is sealed under the same node-local wrapping
key, and the wrap used empty associated data — so a row's ciphertext was
interchangeable between vaults, and the table's primary key was the only thing
saying which vault a key belonged to. Asking for one vault's key and being
handed another's would have succeeded quietly.

Binding the id makes that fail authentication instead. It matches what every
other AEAD call site here already does: a blob chunk binds its plaintext hash
and index so it cannot be spliced elsewhere, and a sealed node name binds its
pubkey so it cannot be lifted onto another member's announcement.

This changes the wrap format, so existing vault_keys rows no longer unwrap and
an existing store fails to start with a decryption error. Acceptable per the
project's status; a store is re-initialized rather than migrated.

Found while writing docs/vault-pddb-research.md (§4F).
2026-09-01 03:40:14 -07:00
Greg Shuflin d938010159 Docs: correct the vault sections of data-model and document-sync
Four claims found while writing docs/vault-pddb-research.md, none of which the
code has supported for a while:

- Blob content encryption is built (ADR 0028), so the data model's "remaining
  step" is only Property and Artifact values. The note now says which boundary
  that leaves standing: blob bytes, and nothing else.
- document-sync said a receiver without the vault key "cannot decrypt the
  content". It cannot open blob bytes; PropertyRecord.value is MessagePack
  plaintext on the wire and in SQLite at both ends.
- It also had the default vault's UUID being HKDF-derived from the ensemble
  public key. The id is the nil UUID constant; the *key* is what is derived,
  from the ensemble secret. Id and key had been conflated.
- Vault key versioning describes machinery that does not exist — vault_keys has
  no version column and default_vault() hard-codes version 1 — so it now says so
  and points at the revocation 3c item that wants rotation first.
2026-09-01 03:29:52 -07:00
Greg Shuflin 9c083f61cc Docs: mine Xous' PDDB for the Vault design
Vaults are specified but almost entirely unbuilt, so this is the cheap moment
to read the closest prior art. docs/vault-pddb-research.md is the comparison:
what transfers (the union view and its write-target rule, enumerate-vs-unlock,
key commitment, an offline store reader), what does not (a noise-filled fixed
container, make-before-break journaling, device-bound AAD, a per-device
pepper), and the pitfalls — most of which bite harder here than they did on a
single 100MiB device, because an ensemble manufactures the disk snapshots that
deniable storage cannot survive.

Its conclusion is that a locked vault and a hidden one are different products
and only the first is reachable, so TODO.md now carries the work that implies
and docs/long-term-todo.md carries the hidden vault, its trigger, and the three
storage rules it would have to be built under.
2026-09-01 03:23:03 -07:00
Greg ShuflinandClaude Sonnet 5 15b0db7a9d Spelling: British -> American across in-app copy, docs, and code
Build Debug APK / build (push) Successful in 5m51s
Rewrites every British spelling found in the codebase to American: neighbour,
colour, catalogue, behaviour, honour, flavour, grey, judgement,
acknowledgement, licence, defence, organise/optimise/initialise/recognise/
analyse, artefact, cancelled/cancelling, labelled/labelling, modelled/
modelling, and centre/centred/centring all become their American forms —
consistently in prose (docs, ADRs, comments) and in identifiers, including
the FFI-shared CatalogueSync/Cancelled enum variants and catalogue_* fields
that cross the Rust/Kotlin boundary.

Left untouched: external API names that only look like a spelling variant
(kotlinx.coroutines.CancellationException/NonCancellable, Compose Material3's
labelLarge) and the EFF diceware wordlist used for key derivation.

Adds a Code Style rule to AGENTS.md/CLAUDE.md requiring American spelling in
user-facing copy and documentation going forward, with that same carve-out.

Verified: cargo build --workspace, all 1410 Rust tests, Kotlin compile,
fmt-rust/fmt-kotlin, lint-kotlin-fast (detekt), and the lint-gui-layout/
lint-sql-params/lint-p2p-admission custom checks all pass clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fHWzCF6oNiCV4ue1bBf1t
2026-09-01 03:01:16 -07:00
Greg ShuflinandClaude Opus 5 2104fc9e62 Docs: the alert-don't-apply flow now has three callers, not one
Build Debug APK / build (push) Successful in 5m11s
TODO.md's clippings item is closed. ADR 0039's `NoteEditor` prediction
gets its second correction: the guard was the hard part, not the editor,
and TextArea is what a card-sized buffer with no TextFormat wants.

ADR 0031 gains an amendment for what the third caller surfaced — that the
'identical to what the editor already holds' comparison cannot live in
SyncGuarded, because the type would then have to hold a text buffer and
stop being gpui-free. Both editable-buffer callers make it before feeding
the guard, and that is a decision worth recording rather than
rediscovering. It also stops claiming Android has no equivalent: it has
the rule (ui/IncomingBody) and the shared words, just not the type.

AGENTS.md's worked examples pointed at notes/NotesScreen.kt for a symbol
that now lives in ui/IncomingBody.kt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsACwkLMA9MUh5bvgAnZnK
2026-08-31 20:28:51 -07:00
Greg ShuflinandClaude Opus 5 ef83ff3fe5 Clippings become editable on Android, on the same guard
ClippingEditSession is Android's half of the desktop's SyncGuarded: it
holds the open buffer, the store baseline and a version arriving from
another node, and IncomingBody.alertFor decides which reads are news and
which are this node's own save coming back. A ViewModel rather than
remember, for the reason NotesEditorSession is one — a decision that
vanishes on a rotation is not one the user was given.

The alert-don't-apply rule and its strip were the note editor's alone and
are now shared, because two screens holding whole-buffer last-write-wins
text is exactly when a rule stops belonging to one of them: IncomingBody
moves to the ui package with its test, and NoteEditorPane's private
banner becomes ui/IncomingBodyStrip taking a subject's IncomingChangeCopy.

ClippingsFfi gains update(), which the module doc used to say it would
not export; the reason it would not was the missing guard, and the guard
now has a name to point at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsACwkLMA9MUh5bvgAnZnK
2026-08-31 20:24:48 -07:00
Greg ShuflinandClaude Opus 5 c887ba971b Clippings become editable on the desktop, behind the sync guard
A clipping's body is LastWriteWins, so an editable buffer over one is
exactly the case CLAUDE.md's first principle is about. The screen now
opens a TextArea over one clipping at a time and holds a SyncGuarded
<String> around it: while an editor is open it re-reads that clipping on
every store change, and a body this node did not write is announced in
the card and held rather than applied. Save is inert while a version is
unresolved — the banner's Load theirs / Keep mine are the only writes
that have an answer for what they overwrite, and copy::incoming_change
gains a ClippingBody subject so they say the same thing the Notes pane
does.

An editor is on screen whether or not its row is: adopting a held list
page can push the edited clipping off the loaded rows, and a decision
with nowhere to make it is not one the user was given.

NoteEditor was the component ADR 0039 expected to reuse; TextArea is
what shipped. NoteEditor owns a whole pane (monospace, Tab-indent,
markdown list continuation) and a clipping is edited in the card it is
read in, beside a composer using the same box for the same text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsACwkLMA9MUh5bvgAnZnK
2026-08-31 20:09:06 -07:00
Greg Shuflin 03a1e7471f Ensemble: stop claiming per-peer reachability the mesh cannot report
Build Debug APK / build (push) Successful in 5m9s
The reachability matrix was recorded as blocked on data — each node
gossiping its neighbour set, every node assembling the N×N picture. The
data was never the problem. The matrix arrived as the answer to "what goes
in the rectangle where the rejected node editor was", and DIRECTION.md
§8.5 argues for it as a picture: every pair appears twice and reads the
same, "which is what co-equality looks like drawn". True, and not a
question anyone was asking the screen. It is withdrawn, not deferred.

The same defect was already shipping at 1×N. The roster read the live
gossip neighbour set per peer and drew everything outside it as Degraded —
but iroh-gossip keeps a bounded partial mesh, holding a small neighbour
set on purpose and dropping edges it does not need, so a node we could
dial in one hop showed as degraded whenever the swarm had no edge to it.
That badge reported swarm topology in the voice of connectivity.

Remove it rather than repoint it at iroh Endpoint::remote_info. The
screen already probes on demand — Ping reports a real round-trip, Sync now
reports what happened — and an on-demand probe beats a dot that is stale
the moment it is drawn. Gone with it: MemberConnectionState, the
connection_state field, P2PNode::active_neighbor_ids_hex,
GossipDiscovery::active_neighbor_ids, and the dot/glyph on all four
modalities. active_neighbors stays for neighbor_count, with a doc comment
saying why it must not be exposed per peer.

The aggregate line had the same defect and is reworded, not deleted:
"{n}/{m} other nodes reachable now" becomes "N gossip neighbour(s)", a
bring-up diagnostic that claims nothing about reachability. On the desktop
it moves from the roster header into the Membership fact grid, where a
diagnostic belongs. All three phrasings now come from
copy::ensemble_roster — the TUI already disagreed with the other two —
reaching Android through an ensemble_roster_copy FFI record.

What the badge was groping toward is a real question, and it is now in
long-term-todo.md: when this node is continuously updating a document
meant to reach the ensemble live and that stops happening, the user should
be told. That is a claim about what they are doing, not about the network,
and it cannot be designed before some agent actually pushes a live
continuous update — podcast playback position being the first candidate.
2026-08-31 17:51:04 -07:00
Greg Shuflin d9efd613d2 TODO: three items move to the long-term list, which now says why things land there
The preamble claimed the distinction was not size or difficulty. It is,
sometimes: theming, self-hostable relays and parallel asset fetch are all
deferred because the cost is out of proportion to what they buy today, or
because acting now means guessing at a design decision. Name the three
reasons an item lands here, and admit that not every entry has a crisp
trigger.

Also drops the Security section from TODO.md, which had nothing open left
in it.
2026-08-31 16:59:11 -07:00
Greg Shuflin 8d9b6a65d0 linkages: a token carries capabilities, not the whole command surface
Build Debug APK / build (push) Successful in 4m4s
ADR 0017 decided against capability scoping in v1 — a paired client got what a
CLI session gets — and wrote down the price as "the same blast radius as a
compromised CLI session", worth revisiting. What made it worth revisiting was
not a use case; it was reading the list next to the fact that the holder is a
browser sandbox with no filesystem access of its own. ImportFiles reads any
path the daemon's user can read, into the store where the client reads it back.
MaterializeDocument writes any path it can write. PurgeDocument destroys data
ensemble-wide. Shutdown stops the node, and was reachable because it was
handled *above* the two hand-written guards rather than below them.
CredentialCmd::{Set,Clear} rewrites the node's API keys, and simply did not
exist when those guards were written — which is the whole argument for not
doing this with guards.

So: five capabilities in stores::linkage, one per command through
DaemonCommand::required_capability. The match is exhaustive, and that is the
part worth having — a command added without deciding what it costs stops the
build instead of inheriting the last variant's reach.

Transport becomes Caller, because the question is who is asking rather than
which socket they reached: Owner is answered unconditionally (reaching a socket
inside a 0700 store directory already means being the user who owns it), Linked
carries the linkage id. One gate, before anything is done, replacing both
guards. A holder may revoke its own linkage and no other — that is the
extension's Unlink, and it needs the caller's identity rather than the
command's cost, so it lives in Caller::permits.

Credentials are NodeAdmin in full, including the read. Status carries no secret
value, so Read would be defensible, but no linkage has a use for any of it and
relaxing this later is easier than clawing it back. Linkage listing likewise:
a client knows its own, so what a listing offers is everything else the owner
has paired.

Reused from the parked linkage-security branch: the Capability enum, the
required_capability shape, the Caller/permits split, and the revoke-your-own
carve-out. Dropped from it: LinkageScope::{Companion, Full}, `pair --full`, the
stored scope column and the narrowest-reading rule for an unknown scope. The
qualm recorded in TODO.md was right — nothing wants Full. The extension's whole
vocabulary is four commands, so a two-member scope model has one real member
and one guess about linkages that do not exist yet. Policy over capabilities is
cheap to add against a second client and expensive to un-guess once it is on
every row.

The loopback tests move with the model: a paired client can no longer enumerate
linkages, and the escalation test reads the count from the store rather than
over a wire that now refuses. New tests draw one command per refused capability
rather than re-asserting the two cases that used to be special-cased.

ADR 0017 gets the amendment; TODO.md's Security section is now empty and says
so.
2026-08-31 03:32:29 -07:00
Greg Shuflin ddcc8ee021 docs: ADR 0052 — what each of the two local listeners may trust
A node answers local callers on a Unix socket and a loopback TCP port, and
"local" has been doing damage: it suggests one trust boundary where there are
three, with different populations on each side, and more than one bug here has
come from reasoning about the wrong one.

Mostly a consolidating record — permissions, Origin refusal, CORS, the pairing
challenge, hashed tokens, pairing refused over loopback all already exist, and
their reasoning was scattered across module docs and one commit message. Three
things it settles rather than restates:

- **Code running as the node's own user is not a boundary and will not be
  made one.** It can read store.db directly, and it can drive the socket's
  full surface including pairing. The loopback mitigations look like they
  should help here and do not: they defend against a caller who cannot read
  your files.
- **Offline copies are out of scope**, stated as the model rather than as an
  omission. The whole store directory is the secret; its mode is the whole of
  the at-rest story.
- **A token is not a CLI session**, which reverses ADR 0017's v1 grant
  decision. That one is a real decision and the implementation follows.

The deferred half — optional start-time password protection — moves to a new
docs/long-term-todo.md, for work where the decision to not build it has
already been taken and an entry in TODO.md would keep reading as unfinished
business. Each entry there carries what would make it worth doing; this one's
is the threat model gaining offline copies, and its note says to reach for a
keychain or TPM before a prompt, since only the headless node has no
alternative.

TODO.md loses the encrypt-at-rest item and gains a line saying where that kind
of thing goes now. The Argon2id entry stays but records that its one concrete
caller has moved.
2026-08-31 03:13:30 -07:00
Greg Shuflin ff95b9b6a3 The presence grid stops reading authorship as possession
`node_status::artifact_presence_for_node` was rewritten during revocation
phase 3a so that a remote node reads `Present` only on that node's own word
from `NodeContentStore` — an artifact's `origin_node` is the sender's
unsigned claim about a third node, and a relayed record carries a claim its
relayer never checked either. The helper grew four paragraphs saying so.

The desktop did not call it for every row. A node named as an artifact's
origin that has no `NodeDocumentRecord` gets an extra row on the grid, and
that row was assembled by hand twenty lines below the import: `Present` if
the local store held the artifact value, `Ref` otherwise. So the inference
the phase removed was still live on the one modality with a presence grid, and
still on the screen a user reads before deciding what is safe to delete
locally. Any ensemble member could put a `Present` on any node's row by
naming it as an author.

The fix is a third function beside the other two rather than a second
correction, because the failure mode here is a caller that assembles a row
itself: `artifact_presence_without_record` takes the same evidence its
siblings do and floors at `Unknown`, not `Ref` — a node with no record has
not claimed to hold the document either, so even the weak rung is unearned.
Two tests pin it, next to the ones that pin the same rule for the helper it
was supposed to mirror.

Nothing else derives a decision from `origin_node`: the daemon and the
Android FFI both go through the helper, and the TUI and CLI only print it.

With that closed, per-record signatures for provenance are settled as not
worth building, and TODO.md's item goes. The arithmetic is in
NODE_REVOCATION.md's Open Questions — briefly, 64 bytes on eight record types,
a signature column on seven tables because a signature has to survive relay,
a verification per row, and a cert to carry for an origin never met, in
exchange for three read-only surfaces and against an adversary holding one of
the user's own delegation certs. §3a records what would reopen it: any point
at which attribution stops being decoration and starts being load-bearing.
2026-08-31 00:54:26 -07:00
516 changed files with 36228 additions and 42033 deletions
+76 -8
View File
@@ -19,7 +19,10 @@ between arbitrary sets of nodes.
# Project Status
Experimental — no backwards-compatibility constraints. Breaking changes to
serialization formats, database schemas, wire protocols, key derivation
salts, and CLI interfaces are all acceptable.
salts, and CLI interfaces are all acceptable. That includes not writing
migrations: when a schema changes, an existing store is manually deleted and
recreated, not converted in place, so no code rewrites old rows on open or
keeps a reader for a retired column, table, or artifact kind.
# Design Principles
@@ -46,11 +49,16 @@ finished, whatever else it does.
chosen; a save that silently wins the conflict is the same bug wearing a
different hat.
- Worked examples, one per modality: `rust/gui-app/src/agents/notes/mod.rs`
(`watch_open_note`/`render_incoming_body_banner`),
(`check_open_note`/`render_incoming_body_banner`),
`rust/tui-app/src/notes_screen.rs` (`on_documents_changed`, and the editor's
`:e`), and Android's `notes/NotesScreen.kt` (`IncomingBody.alertFor`, whose
doc comment is where the rule for telling *their* write from *ours* is
written down).
`:e`), and on Android `ui/IncomingBody.kt`, whose doc comment is where the
rule for telling *their* write from *ours* is written down.
- The two shared pieces to reach for rather than rebuild: the desktop's
`components::sync_guarded::SyncGuarded<T>` (ADR 0031) and Android's
`ui/IncomingBody` plus `ui/IncomingBodyStrip`, whose words come from
`copy::incoming_change::Subject` — a new subject is a variant there, not a
sentence on a screen. `agents/clippings` is the shortest end-to-end
reading of the whole shape on both.
- **Absence is not an error.** A document whose
content lives on another node is a state to render, not a failure and not a
blank. See `docs/data-model.md` and ADR 0030 §4: offering an empty buffer for
@@ -65,7 +73,7 @@ finished, whatever else it does.
- This holds for lists and collections on the form as much as for its plain
fields, however the store happens to keep them. "Each row is its own
artifact, so there's nothing to batch behind Save" is a fact about storage,
not a licence to write one — it makes Save mean two different things on one
not a license to write one — it makes Save mean two different things on one
form, and it leaves the user unable to back out of a removal. Stage the
edit and diff it against what is persisted when Save runs; the Humans
screen's `diff_staged`/`diffStaged` is the shape.
@@ -73,11 +81,19 @@ finished, whatever else it does.
they should be one code path. Watch for state that lives outside the
screen's own struct — the desktop's text fields are `TextInput` entities,
so a Cancel that only dropped the staged collections would leave a
cancelled rename on screen.
canceled rename on screen.
- Not everything on a screen is part of its form. A thing that is its own
document — Humans' interactions, shared with their other attendees — is
written when the user says to write it, and lives outside the mode. The
test is whether it is a *field of this record* or a record of its own.
- A preferences screen is not a record. Settings is a set of independent
choices — a theme, a zoom level, a timezone, a sync policy — each
meaningful on its own, and some only judgeable once seen applied. So a
control there applies the moment it is touched, the way every platform's
settings do, and a free-text field (a node name, a credential) commits on
its own Save or Enter rather than per keystroke. The test is whether the
values describe one thing the user would want to back out of together, or
are separate choices that only share a screen.
- Worked examples: `rust/gui-app/src/agents/human.rs` (`DetailMode`,
`reload_detail`, `save_current`) and Android's `HumanDetailScreen.kt`.
- **The instant-feedback invariant**: anything that might not complete promptly
@@ -92,6 +108,28 @@ finished, whatever else it does.
executor. Any lib function that reaches the network should be `async` rather
than taking a `tokio::runtime::Handle` to block on. This is not per-agent:
it holds for every agent that opens something living on another node.
- **A result knows what it was for.** Anything a screen loads, saves or plays
in the background can finish after the user has moved on — another note
selected, the document closed, a different track started — and it must not
land on whatever is selected *now*.
- **A buffer carries the id of the record it belongs to.** A save takes its
destination and its text from the same value, never the destination from a
separate "selected" field. A stale apply can then at worst show the wrong
text for a moment; it can never write one record's text into another, which
then syncs everywhere. That is the bug Notes' `OpenNote` closed: a slow load
of note A installed A's body under B's title, and the next keystroke
autosaved it into B.
- **A load applied into a selection is held, not detached.** Keep its `Task`
in a field (`_load_task`): assigning the next one drops the old, and gpui
cancels a dropped `Task`, so the latest selection wins by construction
rather than by a "still selected?" check each async step has to remember.
Compose gets the same from `LaunchedEffect(key)`, which cancels the previous
coroutine when the key changes.
- Where the work can't be canceled because it has already handed something
off, check a generation at the point it applies — `player_controls`'
`play_seq`, compared under the audio lock. A result applied by id looks its
target up by that id (`detail_view`'s `detail_for`) rather than writing
into whatever is open.
# Build Commands
@@ -137,11 +175,40 @@ developers build.
carries its full reasoning and its escape hatch.
# Code Style
- Use American spelling in user-facing copy and documentation (color, not
colour; behavior, not behaviour; canceled, not cancelled; catalog, not
catalogue) — consistently, throughout the codebase. Leave alone any spelling
that comes from outside the project: a fixed external API/library name (e.g.
Kotlin's `kotlinx.coroutines.CancellationException`/`NonCancellable`,
Compose Material3's `labelLarge`), a URL, or a third-party wordlist
(`rust/lib/src/ensemble/eff_large_wordlist.txt`) — those aren't ours to
respell.
- Errors: Use typed errors with meaningful messages. In rust, prefer `thiserror`. Avoid using .unwrap() or .expect() in non-test code, and fix uses of these or other panicking functions when you encounter them.
- the justfile codifies recipes that run standard Rust and Kotlin formatters (just fmt-kotlin, just fmt-rust). Run these tools to keep code formatted on every commit.
- don't generate code that has fully-qualified type names (e.g. `documents::Artifacts::new()`); instead prefer to `use documents::Artifacts`, `Artifacts::new()`
- GUI frameworks (GPUI or Jetpack Compose): Prefer to create components built out of functional subcomponents with meaningful names, instead of long components with no internal structure. Prefer to define named constants instead of using the same color, spacing, etc. values in-situ. Don't define default values for properties unless necessary. Prefer to create text elements that have copy-able text;
- When displaying hex-encoded hashes or UUIDs, use standard UI components (depending on modality) which display a truncated hash but make the full hash available if the user requests it.
- **A truncation of human-readable text cuts from the middle, never the end.**
What tells two long strings apart is as often at the end as at the start —
`Quarterly report - draft` against `- final`, an episode number, a file
extension — and an end ellipsis drops exactly that.
- A cut made by character count is `copy::elide_middle`. The TUI's
`notes_screen::elide` is the same rule in terminal columns, and the browser
extension's `src/elide.js` is it restated in JS, since the extension cannot
call the lib.
- **Ids and hashes are the exception: a shortened id keeps its prefix**
(`3fa9c1d2…`), the way git shortens a commit hash. An id is random, so its
end is no more distinguishing than its start, and the prefix is the
conventional handle — the part people read aloud, compare and type. The
full value stays one action away through the hash-chip component (see the
bullet above). The helpers are `copy::short_id`/`copy::truncate_id`.
- A cut made by layout uses the toolkit's middle ellipsis:
`components::truncate::TruncateMiddle::truncate_middle()` on gpui rather
than `.truncate()`, `TextOverflow.MiddleEllipsis` in Compose, and the
extension's `fitMiddle` where CSS would have said `text-overflow: ellipsis`.
- The one exception is a multi-line clamp (`maxLines = 2` and up): Compose
only supports a middle ellipsis on a single line and falls back to a hard
clip, so those keep `TextOverflow.Ellipsis`.
- **gpui sizing: no raw `px()` in render code.** Font sizes, icon/control
sizes, panel widths, and gaps come from `gui-app`'s `sizing` module
(`font::`, `icon::`, `control::`, `layout::`), or `sizing::scaled(..)` for a
@@ -310,7 +377,8 @@ developers build.
Android window, and it is worth doing for its own sake before it is worth
doing for this.
- Worked examples: `human.rs`'s `read_only_row`/`read_only_values` against
`HumanDetailScreen.kt`'s `ReadOnlyField`.
`HumanDetailScreen.kt`'s `ReadOnlyField`. Android's one stacked row is
`ui/LabeledValue`; reach for it rather than laying out another.
# VCS Workflow
- This project uses Jujutsu (jj) with a git backend. Prefer `jj` commands over
+1 -1
View File
@@ -51,7 +51,7 @@ most widespread example of an overlay OS today.
<!-- note: I would eventually like to be able to say Synchronicity is a computing stack, but that is not the case yet -->
<!-- the overlay OS terminology comes from https://urbit.org/overview/urbit-explained among others -->
<!-- note: "Ra is a single entity distributed across the whole solar system. No part of Ra is slaved to any other part of it. No shell-Earth's core node has a noticeably differing personality from the others, or from the megastructure inside the Sun. Opinions and behaviour and available information are continuously synchronising. Eventually, there is only one Ra." - qntm's Ra, Abstract War -->
<!-- note: "Ra is a single entity distributed across the whole solar system. No part of Ra is slaved to any other part of it. No shell-Earth's core node has a noticeably differing personality from the others, or from the megastructure inside the Sun. Opinions and behavior and available information are continuously synchronising. Eventually, there is only one Ra." - qntm's Ra, Abstract War -->
What can someone do with Synchronicity? Some examples:
+309 -195
View File
@@ -1,5 +1,9 @@
# TODO
Unfinished work this project intends to get to. Work deliberately *not* being
done now — sound idea, decision already taken to defer it — lives in
`docs/long-term-todo.md` instead, with the trigger that would bring it back.
## Concepts
### Key Management & Identity
@@ -13,12 +17,41 @@
- **Details**: Argon2id is the right primitive for that job. Design it as a separate module
rather than re-introducing it to `Secret::to_keypair`. Pick parameters appropriate for
mobile, and store a per-secret random salt with the ciphertext.
- **No caller yet, on purpose.** The one that was written down — encrypting the node
secret at rest — is deferred to `docs/long-term-todo.md`, which carries the reasoning
and the triggers that would reopen it. Build the derivation when something needs it,
not before.
#### Consider ideas from Plausibly Deniable DataBase
- https://betrusted.io/xous-book/ch09-00-pddb-overview.html
- this experimental OS project introduces a PDDB concept that has some similarities to Vaults; I want
to make sure that I'm borrowing as many good ideas, potential security pitfalls to avoid, etc. from
this project
#### Act on the PDDB comparison for Vaults
- **Description**: Xous' Plausibly Deniable DataBase has been mined for ideas and
pitfalls; the findings and the reasoning are in `docs/vault-pddb-research.md`.
Its headline conclusion is that a locked vault and a *hidden* one are different
products, and that sync makes the hidden one close to unreachable — so vaults
should be locked-but-visible, said so on purpose. What remains is the work that
conclusion implies, all of it cheapest now, before a second vault exists.
- **Decide and write down** (`docs/vault-pddb-research.md` §7): that vaults conceal
content and not existence, in `docs/data-model.md`; which vault a new document
lands in, since `Document::new` currently assigns `DEFAULT_VAULT_ID` itself and
has no parameter; and whether a `Passphrase` vault stores its own salt, which is
the choice that forecloses deniability for that variant.
- **Build**:
- A locked vault as a *state*, alongside `DocumentAvailability`'s three, reached
before render rather than as a `VaultKeyNotFound` at decrypt time — otherwise a
sealed document renders as fully present and then blanks, which is the bug
"Absence is not an error" exists to prevent. Words in a `copy::vault` module.
- Vault-awareness in `eviction.rs`, `content_ledger.rs` and the last-copy guard.
This one is data loss, not disclosure: both are vault-blind today, so eviction
would happily delete the only copy of a blob nobody present can read.
- Pin system-written documents (agent settings, ADR 0018, and anything else the
system writes rather than the user) to the default vault at the type level.
The PDDB shipped the bug where a setting written while a secret vault was open
silently reverts when it is locked.
- Property and Artifact `value` encryption under the vault key, which is the only
part of the boundary `docs/document-sync.md` claims and the code does not have.
- **Also worth having**: an offline store verifier that walks documents → artifacts
→ blob files and opens every sealed chunk, on the model of the PDDB's
`backalyzer.py`. Useful before vaults, and the only way to answer "what in this
store can I still open?" once blobs live under more than one key.
#### Design a robust manifold key rotation scheme
- **Description**: No mechanism exists for rotating the manifold (master) keypair if it is
@@ -61,60 +94,6 @@ this project
until it has announced itself once. The error says so.
- Consider ideas from FOKS, the Federated Open Key Service: https://foks.pub/#roadmap
## Security
Cross-cutting security work. Protocol-level authentication landed with
`rust/lib/src/p2p/admission.rs`; `docs/NODE_REVOCATION.md` carries the audit
that produced most of this list, and the phased plan for revocation itself.
#### Scope what a linkage token may do
- **Description**: A linkage token grants the whole command surface — ADR 0017 records that
as a deliberate v1 decision ("what a CLI session gets"), but the concrete capabilities
are worth naming now that the audit has: `ImportFiles { paths }` reads any file the
daemon user can read *into the store, where the client can read it back*;
`MaterializeDocument { path, write }` writes any file the daemon user can write;
`Shutdown` stops the node; `PurgeDocument` destroys data ensemble-wide.
- **Why it deserves a second look**: the holder is a browser extension — a sandboxed
context with no filesystem access of its own. Pairing hands it more than the browser
ever gave it.
- **Details**: the machinery now exists to do this cheaply — `Transport` is threaded into
`handle_command`, so a per-transport (or per-linkage) command allowlist has somewhere to
live. Pairing control is already refused over loopback for exactly this reason.
- **An attempt at this is parked on the `linkage-security` branch** — read it before
starting, and read it sceptically: it introduces a capability model to make scopes work,
which may be more machinery than one minimal browser extension warrants and may still be
the wrong shape for the linkages that aren't designed yet.
#### Encrypt the node secret at rest
- **Description**: `secrets.value` is plain JSON (`{"version":1,"seed":[…]}`). File
permissions are now enforced (`stores::permissions`), so this is defence in depth rather
than an open hole — but anything that reads the file (a backup, a synced folder, a
misconfigured container mount, a stolen disk) still gets the node identity and, through
it, every blob.
- **Relation to other items**: this is the concrete first use for the Argon2id derivation
path above — the wrapping key would come from a user passphrase, which means deciding
what unlocks a headless node at boot.
#### Make synced provenance verifiable, not just displayed
- **Description**: `origin_node` on synced rows is read out of the incoming record
(`p2p/doc_sync.rs`, in `apply_records` and the tombstone/edge paths) and never checked
against anything, so any member can attribute a write to any node key. This reads like
the amplifier it sounds like, but `docs/NODE_REVOCATION.md` §3a examined it and found
it isn't one: nothing in the merge rules (`modified_at`, `deleted_at`, set-element
add/remove stamps) reads `origin_node`, so a false claim can't win a comparison,
suppress a write, or resurrect anything. The one place that *did* treat it as evidence
— `node_status::artifact_presence_for_node` inferring content possession from it — has
been rewritten to derive possession only from the authenticated sender
(`NodeContentStore`). Taking it from the authenticated sender instead of the wire claim,
this item's original fix, is wrong, not just naive: doc-sync is transitive, so it would
attribute every relayed edit to whoever forwarded it rather than who wrote it.
- **What's left**: `origin_node` stays a claim — safe to show, unsafe to decide with —
documented as such at each record's definition. Making it verifiable needs a signature
per record under the claimed origin's own key, which also needs that node's delegation
cert reachable at the receiver (carried alongside, for a node never met before). Real
work, not currently blocking anything. See `docs/NODE_REVOCATION.md` §3a and its Open
Questions entry "Per-record signatures for provenance."
## Media library
The shared browser component is `docs/adr/0025-media-library-shared-browser.md`.
@@ -131,9 +110,6 @@ The shared browser component is `docs/adr/0025-media-library-shared-browser.md`.
- **Mixed folders**: a directory holding loose page images *and* subdirectories
imports the loose images as a chapter named after the parent folder, colliding
cosmetically with the series `Collection`'s own name. Rare; disclosed.
- **PDF secondary line shows "Added Xh/d ago", not a page count.** The count is
now read at import time (`agents::pdf::read_metadata`, ADR 0035); what is left
is persisting it as a property and rendering it on both platforms.
#### Music metadata-driven library organization
Remaining steps are in `docs/music-library.md`'s status section, which supersedes what
@@ -165,44 +141,57 @@ since it isn't worth taking before it shows up in a profile.
- Notes has its own narrower version of this gap — see "Take cues from Obsidian's search"
under Agents ▸ Notes.
### Make Unicode text and case-insensitive matching uniform across modalities
- **Description**: Case-insensitive matching is decided per call site, and the sites
disagree. The paged title search (`search_document_items`, which the desktop, Android
and daemon search use) is SQLite `LIKE`, which folds ASCII case only, so `É` doesn't
match `é`. `DocumentStore::search_documents` folds in Rust with `to_lowercase`, which
handles Unicode — at the price of one `get_title` per row (an N+1). Pick one rule for
case folding and normalization (NFC/NFD, and whether to fold accents at all), state it
once, and apply it everywhere a user's text is matched: title search, filters,
`fuzzy::rank`, the TUI and the browser extension.
- **Includes**: `search_documents`' title filter. Moving it into the SQL the paged path
uses removes the N+1, but today that would make it ASCII-only. Settle the rule first,
then make both paths follow it — e.g. a Unicode-aware SQLite function or a
pre-folded title column, rather than `LIKE`.
### CLI document search UX
- The CLI document search experience, especially by type, needs better UX — ideas: a fuzzy
finder for `docs list`, a better search language.
## Build & Development
### Build a user-editable color theming system for gui-app
- **Description**: `rust/gui-app/src/theme.rs` now defines the role tokens (surfaces,
text, accent, semantic status colors) and resolves them for light/dark from a
`ThemeMode` setting. What's missing is the *user-facing* half: editing tokens, and a
small set of starter presets to pick between.
- **Details**: Surfaced during `/plan-design-review` on the PDF-library MVP (2026-07-16)
— hand-picking one "final" palette is the wrong call for a personal tool, since tastes
differ and any single hardcoded scheme dates. The token module is the foundation this
builds on. Android now has the equivalent in `ui/theme/SynPalette.kt` — a `SynPalette`
role table with two instances — so a preset system should drive both from one set of
values rather than growing separately on each side.
### Gossip each node's neighbour set, so the Ensemble screen can show reachability
- **Description**: ADR 0032 settles the Ensemble screen as a roster of co-equal peers plus
an N×N reachability matrix — rows and columns the same nodes in the same order, so every
pair appears twice and reads the same, which is what co-equality looks like drawn
(`design/visual-design-review/DIRECTION.md` §8.5). The roster is built; the matrix is
not, because the data does not exist. A node knows only its own gossip neighbours
(`P2PNode::active_neighbor_ids_hex`), so a matrix drawn today would have one real row,
one real column, and (N-1)² unknown cells.
- **Details**: The prerequisite is for each node to gossip its own neighbour set, which
every node then assembles into the full picture — small, useful independently of the UI
(it is also what would let the app say *why* a peer is unreachable rather than only
*that* it is), and cheap to carry on the existing gossip announcement. Worth deciding
whether a relayed connection is distinguishable from a direct one at the iroh layer
before designing the cell states; the specimen assumes direct / relayed / unreachable.
### Ship one rustls crypto provider, not two
- **Description**: Two rustls crypto providers are compiled in. iroh uses `ring` (its
default `tls-ring`, plus `p2p/mod.rs`'s Android DNS-over-HTTPS config), and reqwest
uses `aws-lc-rs` (its `rustls` feature, the default features of our own `rustls`
dependency, and `http.rs`'s explicit provider). `aws-lc-sys` is a large native build:
it lengthens every cold build, the Android one included, and adds to the `.so`.
- **The blocker is pkarr.** Its `relays` feature, which `full-client` turns on, depends
on reqwest with `rustls` and therefore on aws-lc-rs, with no way to choose a provider.
The registry's `PkarrClient::builder()` uses both the DHT and those HTTPS relays, so
as long as relays are in, aws-lc-rs stays. Switching only our own code to ring would
save nothing and give up post-quantum key exchange on our HTTPS.
- **Options**:
- **ring everywhere.** Build pkarr with `dht` only, set reqwest to
`rustls-no-provider`, and install ring as the process default; the desktop's
`http_client.rs` and stream-download build their own reqwest clients. The cost is
the relay fallback, on networks that block UDP to the DHT. ring has no ML-KEM, and
its "Add Kyber" issue was closed as not planned, so this means no post-quantum key
exchange for the foreseeable future.
- **aws-lc-rs everywhere.** Set iroh and iroh-relay to `default-features = false`
with `tls-aws-lc-rs`. This keeps post-quantum key exchange everywhere, including
iroh's QUIC, but keeps the heavy native library.
- Ask pkarr upstream for a `rustls-no-provider`-style option, which would make the
first option possible without losing the relays.
- Verify any change with `cargo tree -i ring` and `cargo tree -i aws-lc-rs`, for
`--target aarch64-linux-android` as well as the host.
### Finish harmonizing the Android visual style with ADR 0032
- **Description**: The Android pass (ADR 0032 §10) landed the foundations, the whole
structural half, and all but one of the five gaps against the desktop
(`bgMediaCanvas`, the reading-surface story, the live-state slot's two wired
publishers, and selection icon/text colours). One remains, deliberately deferred:
publishers, and selection icon/text colors). One remains, deliberately deferred:
- **A `Presence` tab** (§7). Still the one to hold longest. A nodes × documents grid is a
desktop-shaped affordance that `design/visual-design-review/PRESENCE-AFFORDANCE.md`
treats as unsettled even on the desktop, and a phone may want a different answer rather
@@ -264,23 +253,6 @@ since it isn't worth taking before it shows up in a profile.
the error half; the data half is undocumented.
- Consider lessons from https://docs.divio.com/documentation-system/
### Make relay/DNS infrastructure configurable and self-hostable
- The P2P layer relies entirely on n0's public infrastructure: relay servers
(`*.relay.n0.iroh.link`) for NAT traversal, and pkarr DNS (`dns.iroh.link`)
for publishing/resolving node addresses by public key. Both are open source
(`iroh-relay`, `iroh-dns-server`) and designed to be self-hosted.
- Options to investigate:
- Configurable custom relay URLs and DNS server in `Settings`
- A bundled `iroh-relay` mode so a node can optionally relay for others
- The Mainline DHT path (`DhtAddressLookup`) as a fully decentralized fallback needing no
trusted server. It's the BitTorrent DHT — ~20M nodes since ~2005, maintained "for free"
by BitTorrent's existence. BEP 44 (~2014) added storage of arbitrary *signed* data.
pkarr builds on that: sign a packet of DNS-like records (IPs, relay URLs) with your
Ed25519 key, published indexed by your public key, self-authenticating. iroh's
`PkarrPublisher` uses `dns.iroh.link` as an HTTP relay to the DHT (convenient but
centralized); `DhtAddressLookup` participates directly with no intermediary.
- mDNS (`address_lookup::mdns`) for local-network discovery with no infrastructure
## Testing
### Android repository unit tests
@@ -311,8 +283,56 @@ since it isn't worth taking before it shows up in a profile.
surfacing, and the general sync-status signal it's part of" for the design side.
- There should be tests for the above
### Model-check the sync protocols with Quint
- **Description**: introduce [Quint](https://quint-lang.org) (TLA+'s logic, code-like
syntax, a simulator plus the Apalache model checker) for the protocols whose bugs only
show up in some interleaving of several nodes. Specs go in a new top-level `specs/`
(not `design/`, which is for visual explorations), checked by a `just check-specs`
recipe that gets the tooling from the flake. Model at protocol level — 3 nodes, 2
documents, bounded clocks — not the code.
- **The first spec should find a known bug. Don't fix it before then.** It is left in on
purpose, as the proof of concept that a model checker finds this class systematically:
- The scheduler's unattended catalog pass (`scheduler::run_catalog_pass`) only ever
pulls with `since = peer.last_synced_at`, which is the *requester's* clock, read after
the sync completes (`record_sync`).
- The responder filters on `documents.modified_at >= since` (`SyncScope::predicate`), and
`modified_at` is the *original author's* stamp, carried unchanged through every relay
(`doc_sync::apply_records`).
- So: C edits a document at t=10; A pulls from B at t=20; B pulls from C at t=25. A's
next pull from B asks for "since 20", and the edit never reaches A through B. A write
landing on B *during* A's pull is missed the same way, and clock skew widens both.
Only a manual full sync (`scheduler::sync_with_peer`) recovers.
- The property to state is eventual convergence over a connected but not complete peer
graph. The likely fix — a responder-local change sequence as the watermark instead of
the author's timestamp — should be checked in the spec before it is written in Rust.
- **Candidates after that**, in rough order of payoff:
- Node retirement and revocation delivery (`ensemble::retirement`,
`p2p::revocation_delivery`, plus the gossip and `MembershipBatch` channels and epoch
monotonicity). `docs/NODE_REVOCATION.md` ▸ "Retiring a node, and what the wipe
waits for" states the invariants: commit before ack, idempotent retries, wipe only
after an ack, a revoked cert is never re-admitted. Crash-at-any-step is what model
checking is good at.
- The last-copy eviction guard (ADRs 0013, 0029, 0048): "the last known copy is never
deletable" under two nodes evicting concurrently, each counting on the other's copy
from possession evidence that has gone stale.
- Not `SyncGuarded` (ADR 0031): three local states, a better fit for proptest.
- **Keeping specs honest**: Quint can export ITF traces; replaying them against the real
stores in a Rust test (the `itf` crate) is what stops a spec from drifting into prose.
## Code Design
### Consider starting the daemon by exec rather than fork
- **Description**: `syn-daemon/src/proc_title.rs` is 270 lines, much of it unsafe
`/proc/self/stat` parsing and argv overwriting. It exists only because `daemonize`
forks without exec, so the daemon inherits the argv of the CLI, TUI or desktop app
that started it. Spawning it with
`Command::new(current_exe()).arg0("syn-daemon")` plus a hidden entry argument would
remove the file and the `daemonize` dependency.
- **The cost**: each of `syn`, `syn-tui` and `syn-gui` needs that hidden entry point,
daemon startup takes a different path, and log-file handling may change with it. It
is a risky startup change with no user-visible gain, so it waits until something
else needs that startup path reworked.
### Redesign keyboard navigation across the TUI and GUI
- **Description**: `gui-app` and `tui-app` grew keyboard navigation ad hoc, screen by
screen, with no shared model for the two concerns that recur everywhere: (1) drilling
@@ -334,6 +354,39 @@ since it isn't worth taking before it shows up in a profile.
with sub-tabs", so new agents get consistent navigation instead of copy-pasting a
`Mode` enum.
### Pluralize copy with a library, in every modality
- **Description**: Counts in user-visible copy are pluralized three ways today: the
`"artifact(s)"` form (49 in `copy.rs`, plus cli-app and gui-app literals), hand-rolled
`if n == 1` branches, and Kotlin literals. Replace all of them with one mechanism built
on a library designed for plural rules. That way every count reads "1 artifact" or
"3 artifacts", and each rule is written once.
- **Scope**: `copy.rs` first, since Android and the TUI/CLI get most of their words from it,
then any count still composed in gui-app, cli-app, tui-app or Kotlin. The browser
extension cannot call the lib, so it needs its own equivalent: JS `Intl.PluralRules`.
- **Candidates**: ICU4X's `icu_plurals`, which only selects the plural category and so fits
`copy.rs`'s plain functions; or Project Fluent (`fluent-bundle`), which would also
move the strings into message files. Choose with localization in mind, even though
there's no second language yet.
### Move the remaining user-visible strings into `copy.rs`
- **Description**: About 170 strings that users read are still literals in gui-app screens,
alongside CLI-only sentences in cli-app and a table of strings Kotlin shares with the
desktop. CLAUDE.md says copy lives in `copy.rs`, grouped by subject, and is imported by
every modality.
- gui-app: Settings, linkages, menu labels such as "Pop Back In", import_trigger's
"Files…"/"Folder…", the command palette, the player, and PDF/comic info-row labels.
- cli-app: the keyphrase write-down block and the job/linkage/retire flows in `main.rs`.
- Kotlin: presence and eviction sentences, the scheduler run summary, the sync, About
and unverified explainers, theme names, Humans and Notes labels, and podcast and
comics strings.
- **Watch for**: paraphrases, which are the same defect in a harder-to-spot form. Where two
modalities word one idea differently, pick one wording before moving it rather than
moving both. Known pairs:
- "Untitled Document" vs `document_display::UNTITLED`;
- desktop's "Name the protocol first" vs `humans::CONTACT_NEEDS_PROTOCOL`;
- the "Run now" summary's capitalization;
- desktop's "N new episodes." vs Android's "Episodes changed elsewhere.".
## Android/Kotlin Improvements
### Investigate whether mainline DHT lookups are silently blocked on the Android emulator
@@ -379,15 +432,15 @@ since it isn't worth taking before it shows up in a profile.
bootstraps via DHT, the bug is emulator-specific and low priority.
### Android error surfacing, and the general sync-status signal it's part of
- **The general gap**: per-peer gossip reachability is visible (Connected/Degraded badges
on the Ensemble screen), but there is still no user-facing "a sync is happening / just
failed / just succeeded" signal anywhere else. The whole premise is that sync won't
silently lose or desync data; a silent failure undermines that regardless of how
- **The general gap**: there is no user-facing "a sync is happening / just failed / just
succeeded" signal anywhere. (There used to be per-peer Connected/Degraded badges on the
Ensemble screen; they claimed a reachability the gossip mesh cannot report, and were
removed rather than corrected — ADR 0032's amendment.) The whole premise is that sync
won't silently lose or desync data; a silent failure undermines that regardless of how
reliable the protocol is. Needs to hook every sync-triggering codepath — manual "sync
now", background periodic sync, document import/receive — not one narrow case. Builds on
the existing reachability visibility rather than replacing it: this is per-operation
presence/success/failure, not per-peer connectivity. This is tier 2 of the taxonomy
below.
now", background periodic sync, document import/receive — not one narrow case. This is
per-operation presence/success/failure, not per-peer connectivity. This is tier 2 of the
taxonomy below.
- **Android-specific taxonomy**: `docs/adr/0021-android-error-surfacing-tiers.md`.
Follow-ups it left open:
- gpui's Notes agent (which has its own shipped banner) and the TUI (no error-surfacing
@@ -408,13 +461,11 @@ since it isn't worth taking before it shows up in a profile.
### Keeping track of long-running jobs
Model and dedup design: `docs/adr/0011-jobs-first-class-model.md`; product design:
`docs/sync-management.md`'s "Jobs" section.
- **Cancellation is still coarse for a single blocking P2P call.** Every surface is
runner-backed now, so a manual pull/push/eviction has a live token — but nothing reads it
mid-transfer: `asset_sync`'s functions take no cancellation signal, so the work runs to
the end and the row is only *recorded* as `Cancelled`. The imports and scheduler passes,
which check `is_cancelled` between units, do stop. Threading a signal into `asset_sync`
(probably a `&dyn ProgressReporter`, since that is what the byte counters would want
anyway) is the fix, and it would improve desktop as much as the FFI/daemon.
- **An eviction does not stop on cancel.** It is local disk work with no cancel check at all.
- **`syn docs import` doesn't say when it was canceled.** A `syn jobs cancel` stops the
batch, and the CLI prints the files that made it in and exits as if that were all of
them: `ImportOutcome` carries no stopped-early flag to print a note from. The sentence
belongs in `copy.rs`, next to `asset_sync::pushed`'s canceled reading.
- **The TUI has no import affordance at all.** When it gains one it should start a job from
the outset rather than being retrofitted the way the GUI's library screens were.
- **A blob's plaintext is read whole before any of it is sealed.** `BlobStore::store` takes
@@ -437,18 +488,33 @@ Model and dedup design: `docs/adr/0011-jobs-first-class-model.md`; product desig
Rust derives to be a label Kotlin can name before it calls, which it currently can't — or an
`ImportsInProgress` that filters by kind alone, the way the desktop's does, which sidesteps
the naming problem entirely and is probably the right first move.
- **No widget offers to cancel the job it shows.** Every surface is runner-backed, so the token
exists — but the transfer paths don't read it mid-flight (see the cancellation bullet above),
so a Cancel on a bound widget would promise more than it delivers for exactly the widgets
that have one. Worth doing once `asset_sync` takes a cancellation signal: a bound widget is
the natural place to stop the work it started, rather than sending the user to Jobs to find
the row.
- **No widget offers to cancel the job it shows.** The blocker is gone — a pull or push now
honors the token within a chunk — so a Cancel on a bound widget would deliver what it
promises. A bound widget is the natural place to stop the work it started, rather than
sending the user to Jobs to find the row.
- **A recursive pull reports no progress, because its unit has no counter yet.** One transfer
now counts its bytes onto the job row, which is right for the single-artifact pull and the
push. The whole-subtree walk deliberately drops them (`asset_sync::CancelOnly`): its unit is
a *document*, and passing per-file bytes through restarts the row's counters at each one.
What it wants is documents-done over a subtree total, with the current file's bytes folded
in — `documents::import::ImportCounts` already does exactly that shape for imports and is
the thing to reach for. The subtree total is `list_subtree_ids`, already computed by the
catalog phase.
- **Nothing outside the Jobs screen draws the byte counters a pull now reports.** The
document-detail controls that start these transfers still show a spinner.
`components::remote_fetch` and `ui/RemoteFetch.kt` are the shape to reuse.
- **A peer's jobs can't be seen or stopped from here.** node-admin serves `ListJobs` and
`CancelJob` (`p2p::node_admin::{list_jobs, cancel_job}` are the client half), but nothing
calls them on either modality. Settings ▸ Sync's remote target can only say a triggered
pass *started* — `docs/sync-management.md` notes the gap — and watching it run, or
stopping it, means walking over to the other device. Polling `ListJobs` while a remote
pass runs, with a Cancel per row, would close it.
### The peer walk is serial, and the peer list is never pruned
- **Description**: `scheduler::ensemble_peers_freshest_first` returns every node gossip has
ever heard from for this ensemble — rows are never pruned, so a machine retired months
ago is still in the list. Every pass walks it *in series* with a per-peer budget
(`PEER_CATALOGUE_TIMEOUT` is 180s), so an ensemble carrying a couple of dead nodes
(`PEER_CATALOG_TIMEOUT` is 180s), so an ensemble carrying a couple of dead nodes
spends most of a pass dialling them. Symptom: a trivially small sync — one podcast and
twenty episode documents — takes many minutes.
- **What's already done**: the list is ordered most-recently-seen first, so the live peer
@@ -465,17 +531,24 @@ Model and dedup design: `docs/adr/0011-jobs-first-class-model.md`; product desig
- Prune or archive peer rows that have not been seen in a long time, with a way to see
and undo it. This is really "decommission a node", which is the revocation work.
### Sequential vs. parallel peer fetch for asset sync
- **Description**: `asset_sync::sync_document_assets` walks ensemble peers *sequentially*,
asking each for what's still missing. Each attempt is bounded by a connect timeout and a
total transfer timeout, so one dead peer can't block a healthy one — but the worst case
still pays each timeout in series, and multiple healthy peers go unused.
- **Details**: Trade-offs to weigh —
- Racing the *same* large blob across peers wastes bandwidth; cancelling in-flight
transfers once one wins adds complexity.
- Better middle ground: shard *distinct* missing items across peers, and/or race only the
connect/handshake phase and commit the transfer to the first responder.
- Keep bounded-sequential as the simple fallback.
### pkarr 8 drops compare-and-swap, which the membership registry leans on
- **Description**: `pkarr` is held at 5 while everything else is current. The ensemble
registry is one signed packet that every node read-modify-writes
(`ensemble::publish_membership`): read the latest, add this node, publish with the
read packet's timestamp as CAS, so two nodes registering at once fail rather than one
silently evicting the other's entry. pkarr 8's `Client::publish` takes no CAS, and its
DHT client passes `None` to mainline's `put_mutable`.
- **Porting the calls is trivial** (`resolve_most_recent` becomes `resolve(key,
ResolvePolicy::NetworkOnly)` returning a `Result`, whose `NotFound` is the old `None`);
what needs deciding is what replaces CAS. Options:
- Publish through `mainline` directly for this one packet, which still supports CAS.
- Drop CAS and make the race harmless: re-resolve after publishing and republish if our
entry, or a sibling's we saw, is missing. Converges, but only while someone keeps
retrying.
- Stop sharing one packet across writers, if the bootstrap design
(`docs/BOOTSTRAPPING.md`) can live without a single registry.
- Also a candidate for the Quint work above: "no registered node is lost from the
registry" under concurrent registration is exactly its kind of property.
### Track per-node artifact content presence across the network
Design in `docs/adr/0013-blob-possession-evidence-last-copy-guard.md` and
@@ -492,6 +565,24 @@ How it works: `rust/lib/src/stores/lock.rs` and
whole batch completes** — events are collected daemon-side and replayed. Consider
streaming progress over the socket if large imports feel unresponsive;
`DaemonCommand::AwaitChange` is the precedent for pushing over that socket.
- **A store whose path is long enough cannot run a daemon.** The socket is
`<store>/.daemon.sock`, and a Unix socket path has to fit `sun_path` — 108 bytes on
Linux, 104 on macOS — so a canonical store path much past 90 bytes fails to bind.
`syn daemon start` now says so rather than exiting 0, but the store is still unusable
through the daemon. Binding by a path relative to the store (the daemon already runs
with the store as its working directory) only moves the problem to every client, which
would have to `chdir` too; a socket under `$XDG_RUNTIME_DIR` named by a hash of the
store path doesn't have it.
- **The daemon's local API refuses a request body over 2 MB** — axum's default
`DefaultBodyLimit`, never raised. A note body past that can arrive from a peer (an
artifact frame is far larger, see `wire::MAX_FRAME`) and then cannot be saved back
through the CLI or the TUI. The limit wants to be a named constant sized against what a
document can actually hold, on the Unix socket and the loopback listener alike.
- **The daemon never closes its iroh endpoint on the way out.** `P2PNode::shutdown`
exists and the desktop's quit path calls it; `server::serve` drops the runtime
instead, so every stop logs iroh's "Endpoint dropped without calling
`Endpoint::close`" ERROR, and peers learn the connection is gone by timeout rather than
by a CONNECTION_CLOSE.
## Agents
@@ -513,6 +604,22 @@ How it works: `rust/lib/src/stores/lock.rs` and
drops the other's, until someone makes a newer move. Converging it wants a
move log with undo/redo by timestamp (Kleppmann et al., *A highly-available
move operation for replicated trees*). See ADR 0049.
- **The desktop document detail drops a property edit when it reloads.**
`detail_view.rs`'s `load_detail` sets `editing_properties = None` on every
load, and it runs not only on open and after Save but after a pull of the
whole document, a per-artifact pull, or a delete-locally finishes. So an
action the user started earlier, completing in the background, discards the
property edit they are now partway through — a sync disrupting what the user
is doing (AGENTS.md's first Design Principle). Two related gaps on the same
form:
- Save writes *every* editable field, not the ones the user changed. A
property another node changed after the form opened is overwritten with the
value the form was seeded with. Diff against what was loaded, as the Humans
screen's `diff_staged` does.
- The detail doesn't follow the store at all, so that other node's change is
never shown while the detail is open. Following it means alert-don't-apply
while editing (`components::sync_guarded::SyncGuarded`, ADR 0031), not a
reload.
- Look at the Papra open-source project for document UI design ideas; also PdfDing
- Some documents get misleading titles when imported naively (e.g. comics) — importers
should produce sensible titles, since those are what the Documents view shows
@@ -530,12 +637,12 @@ How it works: `rust/lib/src/stores/lock.rs` and
button is durable. This is `docs/sync-management.md`'s "push-assisted delivery", moved
up; see `docs/content-placement-redesign.md` §5.
- **Questions the queue exists to let us answer** (experiment, not argument — the right
behaviour against a real sleeping phone isn't knowable from here):
behavior against a real sleeping phone isn't knowable from here):
- How long does a queued directive stay valid? A `Mirror` pressed a week ago may not be
what the user still wants.
- What happens if the target's own policy changes before it wakes — does the directive
still apply, or has it been superseded by a more recent local decision?
- Should a pending directive be visible, and cancellable, or is that clutter for
- Should a pending directive be visible, and cancelable, or is that clutter for
something that usually lands within seconds?
- Delivery: gossip carries small frames (4 KiB, see the presence-announcement item) and
reaches nodes as they appear; store-and-forward through a reachable third node is the
@@ -561,48 +668,38 @@ How it works: `rust/lib/src/stores/lock.rs` and
- **Per-artifact sync controls on the document detail page.** Pulling one artifact works
on every surface (`asset_sync::sync_document_asset`).
- ~~**Nothing asks peers whether a newer version exists.**~~ Closed: the whole-document
pull (`asset_sync::sync_document_assets_recursive`) now runs a scoped catalogue refresh
pull (`asset_sync::sync_document_assets_recursive`) now runs a scoped catalog refresh
against every ensemble peer before the content walk, so "up to date" is an answer about
the ensemble rather than about this node's last scheduler pass. See
`docs/sync-management.md`'s "On-demand catalogue refresh for one document".
`docs/sync-management.md`'s "On-demand catalog refresh for one document".
- **The per-row "Sync" control still doesn't.** `sync_document_asset` — the narrow
per-artifact affordance in the Artifacts section — deliberately skips the catalogue
per-artifact affordance in the Artifacts section — deliberately skips the catalog
phase: it fetches the one `ContentKey` the caller named, against whatever the local
catalogue says. That's right for "fetch this blob I can see," and wrong if what the user
catalog says. That's right for "fetch this blob I can see," and wrong if what the user
means is "get me the current version of this note." Decide whether the row control should
refresh that one artifact's row first, or whether the whole-document button is the only
place that question belongs.
- Feeds into automatic sync policy eventually.
- Document search (title, then body) lives in its own top-level section — see ## Search.
### One derived-identity helper, and bring the podcast agent onto it
### Canonical URIs for ADR 0050's captured posts
- **Description**: ADR 0050 needs a post's id derived from `(platform, canonical_uri)` so
two nodes saving the same post converge, and so a future Feeds agent ingesting the same
post reaches the same document rather than a second one. That helper should not be
written for posts alone: six UUIDv5-from-a-normalized-key derivations already exist
independently — `music::{artist_document_id, album_document_id, library_edge_id}`,
`agent_settings_document_id`, `anchor_document_id`, `documents::facts`' derived row ids,
`derived_edge_id`, and `podcast_queue`'s — each with its own namespace constant and its
own convention for joining key parts.
- **The podcast agent is the counter-example, not a partial solution.** It derives
nothing: episodes get fresh v4 ids and `save_feed_episodes` dedups by scanning the
feed's existing children for a matching `PodcastEpisodeApiId`. That only dedups against
what *this* node already holds, so two nodes refreshing the same feed independently
create two documents per episode and sync delivers both — the exact failure
`docs/rss-reader.md` §3.3 rejects. Deriving an episode id from
`(feed, PodcastEpisodeApiId)` fixes it and deletes `existing_episode_api_ids` along with
the dedup scan.
- **What to reuse from podcast**: the `api_id` plumbing is the canonical key it already
has; §3.3's `feed_item_document_id` sketch is the shape; and the feed→item document
layout is what Feeds mirrors. The dedup pass is what goes away.
- **Canonicalization is part of the helper, not a caller's preparation** — two
derivations that agree on UUIDv5 and disagree on whether
`bsky.app/profile/<handle>/post/<rkey>` normalizes to its `at://` uri produce two ids
just as surely as two different algorithms would.
post reaches the same document rather than a second one. The derivation itself is done —
`documents::derived_id` is the one place a UUIDv5 identity is computed, and a new post id
is a `Domain` variant. What is missing is the canonicalization, which is part of the
helper and not a caller's preparation: two derivations that agree on UUIDv5 and disagree
on whether `bsky.app/profile/<handle>/post/<rkey>` normalizes to its `at://` uri produce
two ids just as surely as two different algorithms would.
- Needs a per-platform rule (at minimum Bluesky, Mastodon, Twitter/X) and a decision about
what a *failed* canonicalization does — the raw uri is a defensible key as long as it is
never silently mixed with canonicalized ones under the same `Domain`.
- **`podcast::episode_document_id` is the worked example** of the derived-id half:
`(feed document, publisher's episode id)`, `Provenance::Syndicated`, no dedup scan.
### Service credentials: what's left after the store
The store is built (ADR 0051): a `ServiceCredential` document per service,
sealed with the vault key, synced on the catalogue plane. Podcast Index reads
sealed with the vault key, synced on the catalog plane. Podcast Index reads
from it; a Settings ▸ Credentials tab on the desktop, the same section on
Android, and `syn settings credentials{,-set,-clear}` enter and remove keys;
and the tests provision the published test key themselves. Each service's
@@ -625,19 +722,14 @@ Open:
### Clippings: what's left after the first capture path
Clipping works end to end (ADR 0039): a context menu in the extension, `syn
clippings capture`, a `Clipping` document under an anchored root, and a
read-only Clippings screen on the desktop and on Android, both reading their
words from `copy::clippings`. Both page as they are scrolled, over
Clippings screen on the desktop and on Android, both reading their words from
`copy::clippings`. Both page as they are scrolled, over
`DocumentStore::list_children_page`'s keyset cursor — the two open items ADR
0039 left ("Load more, and what it forces" and an unpaginated `list_children`)
were one problem and are closed together.
were one problem and are closed together. Both also edit a clipping in place,
behind the alert-don't-apply flow (ADR 0031) the last open item was waiting on.
Open:
- **Editing a clipping.** Both screens are read-only because an editable
buffer a sync can land on needs the `SyncGuarded` alert-don't-apply flow
around it (ADR 0031) — the body is `LastWriteWins`, so an unguarded editor
would silently lose one side. `NoteEditor` takes a `String` and a
`TextFormat` and is reusable as-is; what has to be built is the watch loop
and the banner, not the editor.
- **No linkages on Android.** The Clippings screen landed; a phone still
cannot show or revoke what it has granted to a program on its machine. See
the browser-extension section below, which carries the same gap.
@@ -667,7 +759,27 @@ Open:
loadable zip and the store icons are in place; what is left is Firefox
signing, without which there is no permanent install there.
### Music
### Humans: interactions with more than one attendee
- **Description**: The data model has group interactions — one `Interaction` document
with an `EdgeKind::Attendee` edge to everyone who was there, so one dinner covers four
people (`docs/humans-agent-research.md` §A) — and the lib has the whole API
(`HumansManager::log_interaction` takes attendees, `set_attendees` replaces them,
both over the FFI). No frontend uses it. Logging an interaction from a person's
History tab passes only that person (`human.rs`'s `std::slice::from_ref(&id)`,
`HumanDetailScreen.kt`'s `listOf(humanId)`), editing one changes title, date and
write-up only, and nothing calls `set_attendees`. So every interaction has exactly one
attendee, and the "other attendees" line both timelines already draw never shows
anything.
- **The work**: an attendee picker in the interaction form on desktop and Android,
over the user's humans and prefilled with the person whose screen it is, written
with the form's Save — an interaction is its own document, so it lives outside the
person's edit mode (AGENTS.md, "Not everything on a screen is part of its form").
Whether the TUI and CLI get it too is part of the decision.
- **Open questions**:
- Can the person whose screen this is be removed? Doing so moves the interaction
off their timeline, which the form should say rather than just do.
- What removing the last attendee means: an interaction with nobody at it, a
refusal, or a delete.
- See ideas in https://unformeddelta.wiki/ihe0bEPXK5ip/metadata-patterns-not-accounted-for-by-music-apps
- Also see UI ideas from Empeg, via the Cathode Ray Dude video (e.g. waveform seek tool)
- Consider https://www.symfonium.app/ as an example
@@ -699,7 +811,7 @@ Open:
entirely and drive Media3/ExoPlayer's `PlayerView` directly.
- No poster-frame thumbnails — needs a decoded frame, so it's blocked on the
above; `MediaLibrary` shows the placeholder glyph for every clip meanwhile.
- Only MP4/MOV/M4V are recognised (`re_mp4`); MKV/WebM would need a second
- Only MP4/MOV/M4V are recognized (`re_mp4`); MKV/WebM would need a second
container parser.
- Android has no folder import for video (desktop does, via
`import_directory_tree`) — single-file picker only.
@@ -766,26 +878,24 @@ Open:
https://unattributed.cc/2026/07/21/how-i-write-blog-posts/
- consider something like this query language: https://docs.kiwifs.com/concepts/dql (Obsidian
has a similar concept)
- note tabs should have good ideas from opera web browser, such as slowly-fading lines indicating how recently it was
used; also groups
### Podcast
- **Rich episode experience**: a Rust data structure representing transcript, sections,
chapters, and UI to display them. Linux Unplugged is a good model.
- **No offline copy of an episode.** Playback streams the enclosure URL
(`AudioSource::Stream`), and listen position is a synced artifact — but nothing ever
puts the audio in the blob store, so an episode cannot be taken onto a plane, and the
content policy that governs every other kind of blob has nothing to govern here.
There was a `fetch_episode_audio` that pulled a whole enclosure into memory and had no
callers; it is deleted, and whatever does this should stream into a blob rather than
start from that shape.
- **Nothing ever refreshes a subscribed feed's episodes.** Persisted episodes are served
from disk indefinitely, so new ones never appear — and note the trap in the affordance
that looks like it does: the desktop's "Refresh" re-runs `episodes_for_feed`, which
returns the *stored* episodes whenever there are any and only falls back to the API for
a feed with none. Needs a real refetch and/or a periodic job; `save_feed_episodes`
already dedups by `api_id`, so re-running it over a full fetch inserts only what's new.
Episodes another node fetched do arrive by sync now, which is the closest thing to a
refresh the ensemble has. The GUI also has no "load more" paging, where Android raises
its request cap as the user scrolls.
- **A saved episode is not governed by content policy.** Saving one writes a
`PrimaryContent` blob like any other, but nothing has taught the scheduler's content
pass or the Sync policy tab that a `Mirror` policy on `PodcastEpisode` should fetch
saved episodes onto other nodes, or that a `Stream` policy should reclaim them. Today
saving and removing are per-node acts the user makes by hand on each device. Deciding
this needs an answer to a question the other types don't raise: the enclosure has a
public source, so "the ensemble should hold this" and "each node that wants it can
fetch it from the publisher" are both defensible.
- **The desktop has no "load more" paging**, where Android raises its request cap as the
user scrolls. Both now fetch the episode list from the API on every open — a subscribed
feed no longer serves a stale copy from disk — so what is missing is only the second
page.
- **Lightning boost support**: figure out how hard this would be.
### Comics
@@ -817,3 +927,7 @@ has a similar concept)
- Something like https://github.com/RackulaLives/Rackula/
- Ebook reader
- Some kind of low-security bitcoin / other cryptocurrency functionality
- password/secrets manager
- rich dictionary functionality, like rikaichain/yomitan - maybe this should be part of an even more general app-wide
text analysis tool that makes it possible to perform arbitrary analysis (incl. sending to a LLM) for any text in any document
in synchronicity - it should also be capable of doing OCR for text in images
+5
View File
@@ -23,6 +23,11 @@ under `android/`. The root `CLAUDE.md` holds the project-wide rules.
`aarch64-linux-android` the first time is ~15 min. Once `rust/target` has
it, that step is a no-op and `unit-tests-kotlin`/`just lint-kotlin` take
seconds — see "Two Kotlin lint recipes" below.
- **Which ABI**: the Rust library is built for one ABI per build —
arm64-v8a by default, whatever Android Studio's Run targets, or
`-PrustAbi=x86_64` (e.g. `just gradlew installDebug -PrustAbi=x86_64`).
Installing on an x86_64 emulator needs the x86_64 build; the arm64 one
runs there through the native bridge and every HTTPS connection fails.
- **Neither**: Android/Kotlin changes can't be compiled or verified at all;
say so explicitly rather than guessing whether a change compiles.
- **Two Kotlin lint recipes, and the difference matters** (see ADR 0022):
+60 -26
View File
@@ -10,6 +10,33 @@ plugins {
alias(libs.plugins.ktfmt)
}
// The one ABI the Rust library is built for, since cross-compiling it is the
// expensive part of a build. An explicit `-PrustAbi=` wins; otherwise the ABI
// Android Studio injects when it deploys to a device (its first entry is the
// device's preferred ABI), so Run builds x86_64 for the emulator and arm64 for
// a phone. A command-line build with neither gets arm64, which is what ships.
//
// Never run the arm64 library on an x86_64 emulator: the native bridge breaks
// JNI `jboolean` returns — see `build-synchronicity-lib-android` in the
// justfile.
val supportedRustAbis = listOf("arm64-v8a", "x86_64")
val rustAbi: String =
providers.gradleProperty("rustAbi").orNull
?: providers.gradleProperty("android.injected.build.abi").orNull?.split(",")?.firstOrNull {
it in supportedRustAbis
}
?: "arm64-v8a"
require(rustAbi in supportedRustAbis) { "rustAbi=$rustAbi is not one of $supportedRustAbis" }
val rustTriple = mapOf("arm64-v8a" to "aarch64-linux-android", "x86_64" to "x86_64-linux-android")
val cargoTargetDir = System.getenv("CARGO_TARGET_DIR") ?: "${rootDir.parentFile}/rust/target"
val rustLibraryPath = "$cargoTargetDir/${rustTriple.getValue(rustAbi)}/release/libsynchronicity.so"
// buildRustLib syncs the .so here, under build/ so `clean` removes it and so
// an ABI built earlier can't linger in the APK beside the current one.
val rustJniLibsDir = layout.buildDirectory.dir("rustJniLibs")
android {
namespace = "com.gregshuflin.synchronicity"
compileSdk = 37
@@ -23,7 +50,7 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk { abiFilters += listOf("arm64-v8a") }
ndk { abiFilters += listOf(rustAbi) }
}
buildTypes {
@@ -41,7 +68,7 @@ android {
}
buildFeatures { compose = true }
sourceSets { getByName("main") { jniLibs.directories += "src/main/jniLibs" } }
sourceSets { getByName("main") { jniLibs.directories += rustJniLibsDir.get().asFile.path } }
// Explicitly set the NDK version to one that's installed
ndkVersion = "28.0.13004108"
@@ -60,9 +87,8 @@ tasks.register<Exec>("buildRustLib") {
"Builds the Rust library via `just build-synchronicity-lib-android` and copies it to the jniLibs directory"
val repoRoot = project.rootDir.parentFile
val cargoTargetDir = System.getenv("CARGO_TARGET_DIR") ?: "$repoRoot/rust/target"
val builtLibrary = file("$cargoTargetDir/aarch64-linux-android/release/libsynchronicity.so")
val jniLibsDir = file("src/main/jniLibs/arm64-v8a")
val builtLibrary = file(rustLibraryPath)
val jniLibsDir = rustJniLibsDir.get().asFile
// Everything that decides what ends up in the .so, so Gradle can skip this
// task outright rather than shelling out to cargo on every single build —
@@ -81,15 +107,15 @@ tasks.register<Exec>("buildRustLib") {
// The recipe this shells out to: it sets RUSTFLAGS and the NDK linker, and
// pins ANDROID_NDK_VERSION, so a toolchain change lands here as an edit.
inputs.file("$repoRoot/justfile")
outputs.file(File(jniLibsDir, "libsynchronicity.so"))
inputs.property("abi", rustAbi)
outputs.dir(jniLibsDir)
workingDir = repoRoot
commandLine("just", "build-synchronicity-lib-android")
commandLine("just", "build-synchronicity-lib-android", rustAbi)
doLast {
jniLibsDir.mkdirs()
copy {
from(builtLibrary)
sync {
from(builtLibrary) { into(rustAbi) }
into(jniLibsDir)
}
}
@@ -162,6 +188,9 @@ abstract class GenerateUniffiBindings : DefaultTask() {
@get:PathSensitive(PathSensitivity.NONE)
abstract val rustLibrary: RegularFileProperty
// Which ABI's .so the recipe reads — and builds first, if it is stale.
@get:Input abstract val abi: Property<String>
// Repo root: `just` is invoked from there, and the recipe's paths are
// relative to it.
@get:Internal abstract val repoRoot: DirectoryProperty
@@ -178,7 +207,7 @@ abstract class GenerateUniffiBindings : DefaultTask() {
outDir.mkdirs()
execOperations.exec {
workingDir = repoRoot.get().asFile
commandLine("just", "uniffi-bindgen-generate", outDir.absolutePath)
commandLine("just", "uniffi-bindgen-generate", outDir.absolutePath, abi.get())
}
}
}
@@ -195,11 +224,8 @@ androidComponents {
description = "Regenerates Kotlin uniffi bindings from the compiled Rust library"
dependsOn("buildRustLib")
val cargoTargetDir =
System.getenv("CARGO_TARGET_DIR") ?: "${project.rootDir.parentFile}/rust/target"
rustLibrary.set(
file("$cargoTargetDir/aarch64-linux-android/release/libsynchronicity.so")
)
rustLibrary.set(file(rustLibraryPath))
abi.set(rustAbi)
repoRoot.set(file("${project.rootDir.parentFile}"))
}
// Wiring the task rather than a path carries the dependency with it, so
@@ -211,6 +237,22 @@ androidComponents {
}
}
// The AAR must be exactly the version of rustls-platform-verifier-android that
// Cargo resolved: it is the JVM half of the same component, called over JNI,
// and a mismatch fails at runtime rather than at build time.
val rustlsPlatformVerifierVersion: String by lazy {
val crate = "rustls-platform-verifier-android"
val lines = rootDir.parentFile.resolve("rust/Cargo.lock").readLines()
val nameIdx = lines.indexOfFirst { it.trim() == "name = \"$crate\"" }
lines
.drop(nameIdx + 1)
.firstOrNull { it.trimStart().startsWith("version = ") }
?.substringAfter('"')
?.substringBefore('"')
.takeIf { nameIdx >= 0 && !it.isNullOrEmpty() }
?: error("$crate not found in rust/Cargo.lock")
}
// Make the Rust build task run before Java compilation
tasks.named("preBuild") { dependsOn("buildRustLib", "copySharedAssets", "copySharedFonts") }
@@ -219,7 +261,7 @@ dependencies {
implementation("net.java.dev.jna:jna:5.18.1@aar")
// JVM component for rustls-platform-verifier (TLS cert verification on Android)
implementation("rustls:rustls-platform-verifier:0.1.1")
implementation("org.rustls:rustls-platform-verifier:$rustlsPlatformVerifierVersion")
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -231,7 +273,6 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation("androidx.compose.material:material-icons-extended:1.6.3")
implementation("androidx.fragment:fragment-ktx:1.6.2")
implementation("io.coil-kt:coil-compose:2.5.0")
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.work.runtime.ktx)
@@ -243,14 +284,8 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
// Retrofit for API calls
implementation("com.squareup.retrofit2:retrofit:2.9.0")
// Using kotlinx.serialization instead of Gson for better type safety
implementation("com.squareup.okhttp3:logging-interceptor:4.9.0")
// Kotlinx Serialization for JSON handling
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
// Coroutines for async operations
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
@@ -258,7 +293,6 @@ dependencies {
// Media3 ExoPlayer for audio playback
implementation("androidx.media3:media3-exoplayer:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1")
implementation("androidx.media3:media3-common:1.2.1")
implementation("androidx.media3:media3-session:1.2.1")
@@ -279,7 +313,7 @@ detekt {
config.setFrom("$projectDir/detekt.yml")
buildUponDefaultConfig = true
allRules = false
// Analyse files across threads. Off by default in detekt; the type-resolution
// Analyze files across threads. Off by default in detekt; the type-resolution
// pass (`detektDebug`, the one that has to pass — ADR 0022) is the slowest
// thing in the Kotlin lint path once the Rust build is out of the way.
parallel = true
+1
View File
@@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".SynchronicityApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
@@ -11,17 +11,16 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uniffi.synchronicity.AgentInfo
import uniffi.synchronicity.getAgents
/**
* App-level state for the root composable: opening the shared native store, loading the agent
* registry, and the initial node-initialized routing decision.
*
* This owns the process-wide [NativeLib] handle and runs the blocking store init off the main
* thread in [viewModelScope], so the work survives configuration changes instead of restarting on
* every recomposition.
* It runs the blocking [NativeLib] store init off the main thread in [viewModelScope], so the work
* survives configuration changes instead of restarting on every recomposition.
*/
class AppViewModel(application: Application) : AndroidViewModel(application) {
val nativeLib = NativeLib()
private val _uiState = MutableStateFlow<AppUiState>(AppUiState.Loading)
val uiState: StateFlow<AppUiState> = _uiState.asStateFlow()
@@ -33,10 +32,10 @@ class AppViewModel(application: Application) : AndroidViewModel(application) {
// must route to the Error screen rather than crash the process.
@Suppress("TooGenericExceptionCaught")
try {
withContext(Dispatchers.IO) { nativeLib.initialize(getApplication()) }
withContext(Dispatchers.IO) { NativeLib.initialize(getApplication()) }
} catch (e: Throwable) {
Log.e(TAG, "Failed to initialize store", e)
_uiState.value = AppUiState.Error(e.message ?: e.toString())
_uiState.value = AppUiState.Error(e.userMessage)
return@launch
}
@@ -46,7 +45,7 @@ class AppViewModel(application: Application) : AndroidViewModel(application) {
@Suppress("TooGenericExceptionCaught")
val agents =
try {
nativeLib.getAgents()
getAgents()
} catch (e: Throwable) {
Log.e(TAG, "Failed to load agents", e)
agentLoadError = e.toString()
@@ -54,10 +53,11 @@ class AppViewModel(application: Application) : AndroidViewModel(application) {
}
val nodeInitialized =
nativeLib.nodeInitialized().getOrElse { e ->
Log.e(TAG, "Failed to check node initialization status", e)
false
}
NativeLib.ffi { nodeInitialized() }
.getOrElse { e ->
Log.e(TAG, "Failed to check node initialization status", e)
false
}
_uiState.value =
AppUiState.Ready(
@@ -0,0 +1,130 @@
package com.gregshuflin.synchronicity
import android.content.Context
import android.net.Uri
import android.util.Log
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import uniffi.synchronicity.ImportBatch
import uniffi.synchronicity.ImportBatchSummary
import uniffi.synchronicity.SyncException
private const val TAG = "BatchImport"
/**
* Open a batch import: one tracked, cancelable job covering [totalFiles] files holding roughly
* [totalBytes] bytes, fed one file at a time through the returned [BatchImport]. Both totals are
* counted by the caller before it starts — see `DocumentDirectoryImport.kt`'s tree pass — and
* become the Jobs screen's denominators.
*
* [source] identifies the folder (its tree URI); [label] is the readable name the Jobs screen
* shows. Fails with `SyncException.Busy` when an import of the same [source] is already running:
* this is the one import path on this side that goes through the live `JobRunner`, so it dedups.
*/
fun NativeLib.beginImportBatch(
source: String,
label: String,
totalFiles: Int,
totalBytes: Long,
): Result<BatchImport> = ffi {
BatchImport(import().beginBatch(source, label, totalFiles.toUInt(), totalBytes.toULong()))
}
/**
* Import one file the user picked, through the same dispatch a folder import uses (Rust's
* `SUPPORTED_FILE_TYPES`), as a one-file tracked job on the Jobs screen — the instant-feedback
* invariant. Succeeds with the picked file's display name. A file no supported type recognizes is a
* failure here rather than a skip, since the user asked for this one in particular.
*
* The file's MIME type rides along as the dispatch fallback for a name with no extension. A failure
* comes back as a failed [Result] for the caller to report through the snackbar (docs/adr/0021).
*/
suspend fun NativeLib.importPickedFile(context: Context, uri: Uri): Result<String> =
withContext(Dispatchers.IO) {
val name = getFileNameFromUri(context, uri) ?: "import"
val copy =
copyToImportCache(context, uri)
?: return@withContext Result.failure(
SyncException.InvalidInput("Could not read $name")
)
try {
result {
val batch = beginImportBatch(uri.toString(), name, 1, copy.length()).bind()
val imported =
batch.importFile(name, context.contentResolver.getType(uri), copy, null).also {
batch.finish()
}
imported.bind()
?: return@withContext Result.failure(
SyncException.InvalidInput("Unsupported file type: $name")
)
name
}
} finally {
copy.delete()
}
}
/**
* Stream [uri]'s content into a new file in the app's cache, for [BatchImport.importFile] to take
* over — rather than reading it into a `ByteArray`, which for a large video or PDF is the whole
* file in the heap. The caller deletes it afterwards; if the import got as far as moving it into
* Rust's staging, that delete finds nothing.
*
* Null, logged, when the content can't be opened or copied: a picker hands out URIs that fail to
* open for a dozen unrelated reasons, none of which should stop the rest of a folder import.
*/
@Suppress("TooGenericExceptionCaught")
fun copyToImportCache(context: Context, uri: Uri): File? {
var copy: File? = null
return try {
context.contentResolver.openInputStream(uri)?.use { source ->
File.createTempFile("import-", null, context.cacheDir).also { file ->
copy = file
file.outputStream().use { source.copyTo(it) }
}
}
} catch (e: Exception) {
Log.i(TAG, "Could not read $uri", e)
copy?.delete()
null
}
}
/**
* One batch import in progress — the Kotlin half of `ffi.rs`'s `ImportBatch`, wrapping each call in
* [ffiCatching].
*
* The loop stays here because only Kotlin can read a Storage Access Framework tree; everything else
* about the import — dispatching each file to the right importer, counting files and bytes,
* reporting progress, and finalizing the job — is on the Rust side. [finish] must be reached on
* every path, including failures, or the job is only finalized when the object is eventually
* collected.
*/
class BatchImport internal constructor(private val batch: ImportBatch) {
/** Whether the Jobs screen's Cancel button has been pressed for this import. */
fun isCanceled(): Boolean = batch.isCanceled()
/**
* Import [file], a copy from [copyToImportCache], under the name the user knows it by. Succeeds
* with the new document id, or with null when no supported type recognized the file (a skip,
* not a failure). [mimeType] is only a fallback for dispatch, used when [fileName] has no
* extension a supported type claims.
*/
fun importFile(
fileName: String,
mimeType: String?,
file: File,
parentId: String?,
): Result<String?> = ffiCatching { batch.importFile(fileName, mimeType, file.path, parentId) }
/** Count a file the caller couldn't read, so the job's denominator still advances. */
fun skipFile(fileName: String, byteSize: Long, reason: String) {
batch.skipFile(fileName, byteSize.toULong(), reason)
}
/** Finalize the job and report what the batch did. Safe to call more than once. */
fun finish(): ImportBatchSummary = batch.finish()
}
@@ -1,33 +0,0 @@
package com.gregshuflin.synchronicity
import uniffi.synchronicity.ChangeCursor
/**
* Kotlin wrapper for the store-change signal's FFI surface — mirrors [JobsAgent]'s pattern: one
* method per [uniffi.synchronicity.ChangesFfi] method, each wrapped in [ffiCatching].
*
* Exposed as [NativeLib.changes]. Screens do not call this directly; they collect
* [StoreChanges.cursor], which runs one wait for the whole process. See ADR 0041.
*/
class ChangesAgent internal constructor(private val nativeLib: NativeLib) {
/**
* Where every channel's revision counter stands right now — the cursor to start a watch from,
* so the first [wait] waits for the *next* change rather than reporting what is already on
* screen.
*/
fun cursor(): Result<ChangeCursor> = ffiCatching { nativeLib.node().changes().cursor() }
/**
* Blocks until something in the store changes past [since], or [timeoutMs] elapses, returning
* the cursor as of waking — pass it back as [since] next time so a change that lands while the
* caller is reloading isn't slept through.
*
* **Blocks the calling thread**, so call it off the main one. A cancelled coroutine can't
* interrupt the call, so [timeoutMs] is also what bounds how long a thread stays parked after
* whoever was waiting goes away.
*/
fun wait(since: ChangeCursor, timeoutMs: Long): Result<ChangeCursor> = ffiCatching {
nativeLib.node().changes().wait(since, timeoutMs.toULong())
}
}
@@ -1,47 +0,0 @@
package com.gregshuflin.synchronicity
import uniffi.synchronicity.ClippingItem
import uniffi.synchronicity.ClippingPage
import uniffi.synchronicity.DocumentCursor
/**
* Kotlin wrapper for the Clippings agent's FFI surface — mirrors [NotebookAgent]'s pattern: one
* method per [uniffi.synchronicity.ClippingsFfi] method, each wrapped in [ffiCatching].
*
* Exposed as [NativeLib.clippings], so call sites read `nativeLib.clippings.list()` instead of
* `nativeLib.clippingsList()` string-prefix pseudo-namespacing.
*
* No `update`: a clipping's body is last-write-wins, so editing one needs the alert-don't-apply
* flow around it, and the Rust side does not export a write without it. See `ffi/clippings.rs`.
*/
class ClippingsAgent internal constructor(private val nativeLib: NativeLib) {
/**
* One page of clippings, newest first, with the cursor for the next.
*
* Pass `null` for the first page and the previous page's `nextCursor` for each one after it; a
* null cursor coming back is the end of the collection. The page size is the Rust side's, not
* this screen's — it is a property of the read underneath.
*/
fun listPage(cursor: DocumentCursor?): Result<ClippingPage> = ffiCatching {
nativeLib.node().clippings().listPage(cursor)
}
fun get(id: String): Result<ClippingItem?> = ffiCatching {
nativeLib.node().clippings().get(id)
}
/**
* Keep [text] as a new clipping, and return its id.
*
* The caller names no destination — this node resolves where clippings are filed, the same way
* it does for the browser extension's captures (ADR 0039).
*/
fun capture(text: String, sourceUrl: String?, pageTitle: String?): Result<String> =
ffiCatching {
nativeLib.node().clippings().capture(text, sourceUrl, pageTitle)
}
/** Delete a clipping on this node. */
fun delete(id: String): Result<Unit> = ffiCatching { nativeLib.node().clippings().delete(id) }
}
@@ -1,152 +0,0 @@
package com.gregshuflin.synchronicity
import android.util.Log
import uniffi.synchronicity.BookmarkInfo
import uniffi.synchronicity.ComicMetadata
import uniffi.synchronicity.ComicTreeItem
import uniffi.synchronicity.ReadingDirection
private const val TAG = "ComicsAgent"
/**
* Kotlin wrapper for the Comics agent's FFI surface — mirrors [NotebookAgent]'s pattern: one method
* per [uniffi.synchronicity.ComicsFfi] method, each wrapped in [ffiCatching].
*
* Exposed as [NativeLib.comics], so call sites read `nativeLib.comics.list()` instead of
* `nativeLib.listComics()` string-prefix pseudo-namespacing.
*
* The comic library is a hierarchy of Collection groupings and Comic leaves ([ComicTreeItem]); a
* comic's pages are Image child documents whose bytes are fetched with [getPageBytes] over the ids
* from [listPageIds].
*/
@Suppress("TooManyFunctions") // one method per ComicsFfi method — see NativeLib's own suppression
class ComicsAgent internal constructor(private val nativeLib: NativeLib) {
/** The full comics library hierarchy (unsorted, flat — group by [ComicTreeItem.parentId]). */
fun list(): Result<List<ComicTreeItem>> = ffiCatching { nativeLib.node().comics().list() }
/** A comic's expected total page count; can exceed [listPageIds]'s size while syncing. */
fun pageCount(comicId: String): Result<UInt> = ffiCatching {
nativeLib.node().comics().pageCount(comicId)
}
/**
* Document ids of the comic's pages, in reading order; load each via [getPageBytes]. A listed
* page's *document* has synced here — its image blob may still be on another node.
*/
fun listPageIds(comicId: String): Result<List<String>> = ffiCatching {
nativeLib.node().comics().listPageIds(comicId)
}
/**
* One page's raw (still-encoded) image bytes, from the local blob store or streamed from a peer
* that holds it (ADR 0016) — so a comic that hasn't synced here still reads. Blocking for as
* long as the peer takes; call it off the main thread.
*
* Null means the page is genuinely not obtainable right now (no reachable peer serves it, or
* P2P isn't running) — a state for the reader to render, not a crash. Compare
* [DocumentsAgent.getBlobBytes], which is the local half alone.
*/
fun getPageBytes(pageId: String): ByteArray? =
ffiCatching { nativeLib.node().comics().getPageBytes(pageId) }
.onFailure { Log.w(TAG, "Page $pageId not available locally or from any peer", it) }
.getOrNull()
/**
* [getPageBytes] for the read-ahead — the pages [pagesNeedingPrefetch] named, which the user
* did not ask for and is not waiting on.
*
* Identical but for one thing: no job row. Three speculative rows per page turn would bury the
* one page the reader is actually blocked on, which is the row that matters.
*/
fun prefetchPageBytes(pageId: String): ByteArray? =
ffiCatching { nativeLib.node().comics().prefetchPageBytes(pageId) }
.onFailure { Log.w(TAG, "Read-ahead of page $pageId did not land", it) }
.getOrNull()
/**
* Which of [pageIds] the reader should fetch ahead of itself while sitting on [currentIndex] —
* the pages in the read-ahead window whose blobs aren't stored here. Cheap and local (no
* network), so it's fine to call on every page turn; the caller walks the result through
* [prefetchPageBytes] and caches what lands.
*/
fun pagesNeedingPrefetch(pageIds: List<String>, currentIndex: Int): List<String> =
ffiCatching {
nativeLib.node().comics().pagesNeedingPrefetch(pageIds, currentIndex.toUInt())
}
.onFailure { Log.w(TAG, "Failed to compute the prefetch window", it) }
.getOrDefault(emptyList())
// The cover (first page) may not be synced yet (NotFound), which is normal, so
// absence collapses to null just as [NativeLib.getDocumentBlobBytes] does.
fun getCoverBytes(comicId: String): ByteArray? =
ffiCatching { nativeLib.node().comics().getCoverBytes(comicId) }
.onFailure { Log.w(TAG, "Cover not available for comic $comicId", it) }
.getOrNull()
fun getMetadata(comicId: String): Result<ComicMetadata> = ffiCatching {
nativeLib.node().comics().getMetadata(comicId)
}
/**
* The reader's last-viewed page (0-based), or null when unread — mirrors
* [PdfAgent.getReadingProgress].
*/
fun getReadingProgress(comicId: String): Result<UInt?> = ffiCatching {
nativeLib.node().comics().getReadingProgress(comicId)
}
fun setReadingProgress(comicId: String, page: UInt): Result<Unit> = ffiCatching {
nativeLib.node().comics().setReadingProgress(comicId, page)
}
/**
* [nodeId]'s own explicit reading-direction setting, if any — `null` means unset, not
* left-to-right. Callers resolve the effective direction for a chapter by walking up to its
* series root (see [ComicTreeItem.readingDirection]'s doc comment).
*/
fun getReadingDirection(nodeId: String): Result<ReadingDirection?> = ffiCatching {
nativeLib.node().comics().getReadingDirection(nodeId)
}
/**
* Set [nodeId]'s explicit reading-direction setting — conventionally called on a series' root
* so the whole series shares one setting.
*/
fun setReadingDirection(nodeId: String, direction: ReadingDirection): Result<Unit> =
ffiCatching {
nativeLib.node().comics().setReadingDirection(nodeId, direction)
}
/** The child issue last opened under a series collection, for "continue reading". */
fun getLatestReadChild(parentId: String): Result<String?> = ffiCatching {
nativeLib.node().comics().getLatestReadChild(parentId)
}
fun setLatestReadChild(parentId: String, childId: String): Result<Unit> = ffiCatching {
nativeLib.node().comics().setLatestReadChild(parentId, childId)
}
fun listBookmarks(comicId: String): Result<List<BookmarkInfo>> = ffiCatching {
nativeLib.node().comics().listBookmarks(comicId)
}
fun addBookmark(comicId: String, page: UInt): Result<Unit> = ffiCatching {
nativeLib.node().comics().addBookmark(comicId, page)
}
fun deleteBookmark(comicId: String, bookmarkId: String): Result<Unit> = ffiCatching {
nativeLib.node().comics().deleteBookmark(comicId, bookmarkId)
}
/**
* Import a `.cbz` archive's bytes as a new Comic under [parentId] (or root); returns its id.
*/
fun importArchive(
fileName: String,
archiveBytes: ByteArray,
parentId: String? = null,
): Result<String> = ffiCatching {
nativeLib.node().comics().importArchive(fileName, archiveBytes, parentId)
}
}
@@ -1,35 +0,0 @@
package com.gregshuflin.synchronicity
import uniffi.synchronicity.ServiceCredential
import uniffi.synchronicity.ServiceId
/**
* Kotlin wrapper for the service-credential FFI surface — mirrors [NotebookAgent]'s pattern: one
* method per [uniffi.synchronicity.CredentialsFfi] method, each wrapped in [ffiCatching].
*
* Exposed as [NativeLib.credentials], so call sites read `nativeLib.credentials.services()`.
*
* There is no read-back of a stored secret, and there is not meant to be: see the Rust module's doc
* comment. [services] answers whether a key is set, which is the only question a settings screen
* has.
*/
class CredentialsAgent internal constructor(private val nativeLib: NativeLib) {
/** Every service this app can hold a key for, and what is stored for each. */
fun services(): Result<List<ServiceCredential>> = ffiCatching {
nativeLib.node().credentials().services()
}
/**
* Seal one field of [service]'s credential. Callers skip blank inputs rather than sending them,
* so re-entering half a credential does not wipe the other half.
*/
fun setField(service: ServiceId, field: String, value: String): Result<Unit> = ffiCatching {
nativeLib.node().credentials().setField(service, field, value)
}
/** Forget [service]'s key on every node in the ensemble. */
fun clear(service: ServiceId): Result<Unit> = ffiCatching {
nativeLib.node().credentials().clear(service)
}
}
@@ -1,125 +0,0 @@
package com.gregshuflin.synchronicity
import android.util.Log
import uniffi.synchronicity.DocumentArtifactItem
import uniffi.synchronicity.DocumentBlobRefItem
import uniffi.synchronicity.DocumentCursor
import uniffi.synchronicity.DocumentItem
import uniffi.synchronicity.DocumentNodeStatusItem
import uniffi.synchronicity.DocumentPage
import uniffi.synchronicity.DocumentPlacementView
import uniffi.synchronicity.DocumentPropertyItem
private const val TAG = "DocumentsAgent"
/**
* Kotlin wrapper for the generic (agent-independent) document FFI surface — mirrors
* [NotebookAgent]'s pattern: one method per [uniffi.synchronicity.DocumentsFfi] method, each
* wrapped in [ffiCatching].
*
* Exposed as [NativeLib.documents], so call sites read `nativeLib.documents.search(...)` instead of
* `nativeLib.searchDocuments(...)` string-prefix pseudo-namespacing.
*/
@Suppress(
"TooManyFunctions"
) // one method per DocumentsFfi method — see NativeLib's own suppression
class DocumentsAgent internal constructor(private val nativeLib: NativeLib) {
// A lookup whose null result means "no such document" — a normal outcome.
fun get(documentId: String): DocumentItem? =
ffiCatching { nativeLib.node().documents().get(documentId) }
.onFailure { Log.w(TAG, "Failed to load document $documentId", it) }
.getOrNull()
fun listProperties(documentId: String): Result<List<DocumentPropertyItem>> = ffiCatching {
nativeLib.node().documents().listProperties(documentId)
}
fun listNodeStatuses(documentId: String): Result<List<DocumentNodeStatusItem>> = ffiCatching {
nativeLib.node().documents().listNodeStatuses(documentId)
}
/**
* Where this document's content sits across the ensemble — the Placement table. This node
* first, then every peer.
*
* One call rather than assembling ensemble members, this node's override, and per-node document
* status in Kotlin: doing that here is how the two modalities drift, and the desktop reads the
* same rows from the same place.
*
* Walks the subtree and stats a file per content artifact, so call it off the main thread.
*/
fun documentPlacement(documentId: String): Result<DocumentPlacementView> = ffiCatching {
nativeLib.node().documents().documentPlacement(documentId)
}
fun listArtifacts(documentId: String): Result<List<DocumentArtifactItem>> = ffiCatching {
nativeLib.node().documents().listArtifacts(documentId)
}
fun listBlobRefs(documentId: String): Result<List<DocumentBlobRefItem>> = ffiCatching {
nativeLib.node().documents().listBlobRefs(documentId)
}
fun search(
searchQuery: String?,
documentTypes: Array<String>,
dateFilter: String?,
): Result<List<DocumentItem>> = ffiCatching {
nativeLib.node().documents().search(searchQuery, documentTypes.toList(), dateFilter)
}
/**
* Keyset-paginated document search. Pass [cursor] = null for the first page. [onlyRoots]
* restricts the page to top-level documents (no parent); pass `false` to get every matching
* document flat (e.g. while searching). [includeHidden] overrides the default exclusion of
* hidden documents — agent-derived music artists/albums, and agents' own settings rows.
*/
@Suppress("LongParameterList") // mirrors the Rust DocumentsFfi::search_page signature
fun searchPage(
searchQuery: String?,
documentTypes: Array<String>,
dateFilter: String?,
onlyRoots: Boolean,
includeHidden: Boolean,
cursor: DocumentCursor?,
limit: Int,
): Result<DocumentPage> = ffiCatching {
nativeLib
.node()
.documents()
.searchPage(
searchQuery,
documentTypes.toList(),
dateFilter,
onlyRoots,
includeHidden,
cursor,
limit.toUInt(),
)
}
/** Direct children of [parentId], for expanding a turnstile in the documents list. */
fun listChildren(parentId: String): Result<List<DocumentItem>> = ffiCatching {
nativeLib.node().documents().listChildren(parentId)
}
// A blob that isn't present locally yet (NotFound) is normal — e.g. catalogue
// metadata synced ahead of the blob — so absence collapses to null.
fun getBlobBytes(documentId: String): ByteArray? =
ffiCatching { nativeLib.node().documents().getBlobBytes(documentId) }
.onFailure { Log.w(TAG, "Blob not available for document $documentId", it) }
.getOrNull()
fun isBlobLocal(documentId: String): Boolean =
ffiCatching { nativeLib.node().documents().isBlobLocal(documentId) }.getOrDefault(false)
// Streams the blob from a peer, decrypts it, and writes plaintext to outputPath.
// Does not store anything in the local blob store — the file is for temporary playback.
// Returns false (rather than a Result) since "couldn't fetch from any peer" is an
// expected outcome the caller handles by falling back.
fun fetchBlobToPath(documentId: String, outputPath: String): Boolean =
ffiCatching { nativeLib.node().documents().fetchBlobToPath(documentId, outputPath) }
.onFailure { Log.w(TAG, "Failed to stream blob from peers for $documentId", it) }
.isSuccess
}
@@ -1,113 +0,0 @@
package com.gregshuflin.synchronicity
import uniffi.synchronicity.DocumentAssetRef
import uniffi.synchronicity.DocumentPushResult
import uniffi.synchronicity.DocumentSyncResult
import uniffi.synchronicity.EnsembleStatus
import uniffi.synchronicity.PurgeResult
import uniffi.synchronicity.RetirementRound
import uniffi.synchronicity.RetirementStart
import uniffi.synchronicity.RevokedNodeInfo
import uniffi.synchronicity.SyncDocumentsResult
/**
* Kotlin wrapper for the Ensemble/P2P FFI surface — mirrors [NotebookAgent]'s pattern: one method
* per [uniffi.synchronicity.EnsembleFfi] method, each wrapped in [ffiCatching].
*
* Exposed as [NativeLib.ensemble], so call sites read `nativeLib.ensemble.getEnsembleStatus()`
* instead of `nativeLib.getEnsembleStatus()` string-prefix pseudo-namespacing.
*/
@Suppress("TooManyFunctions") // one method per EnsembleFfi method — see NativeLib's own suppression
class EnsembleAgent internal constructor(private val nativeLib: NativeLib) {
fun joinExistingEnsemble(keyphrase: String, nodeName: String?): Result<Unit> = ffiCatching {
nativeLib.node().ensemble().joinExistingEnsemble(keyphrase, nodeName)
}
fun getEnsembleStatus(): Result<EnsembleStatus> = ffiCatching {
nativeLib.node().ensemble().getEnsembleStatus()
}
// Infallible: returns 0 when the P2P node isn't running rather than erroring.
fun getGossipNeighborCount(): Int = nativeLib.node().ensemble().getGossipNeighborCount().toInt()
fun triggerGossipBootstrap(): Result<Unit> = ffiCatching {
nativeLib.node().ensemble().triggerGossipBootstrap()
}
fun startP2PNode(): Result<Unit> = ffiCatching { nativeLib.node().ensemble().startP2pNode() }
fun stopP2PNode(): Result<Unit> = ffiCatching { nativeLib.node().ensemble().stopP2pNode() }
// Revoke another node's credential with the ensemble key. The keyphrase is the whole
// authorization story, and is also what supplies the confirmation a destructive act needs —
// which is why the screen offering this has no separate "are you sure".
fun revokePeer(nodeIdHex: String, keyphrase: String): Result<RevokedNodeInfo> = ffiCatching {
nativeLib.node().ensemble().revokePeer(nodeIdHex, keyphrase)
}
// Take a revocation record carried here by hand — the repair path for a node retired with
// nobody listening. Verified exactly as one off the wire is.
fun importRevocation(blob: String): Result<RevokedNodeInfo> = ffiCatching {
nativeLib.node().ensemble().importRevocation(blob)
}
// Start retiring *this* node. One-way: from here it serves and dials nothing but revocation
// delivery. Nothing is deleted — the caller drives [pushRetirementRound] and then
// [NodeReset.perform].
fun beginRetirement(): Result<RetirementStart> = ffiCatching {
nativeLib.node().ensemble().beginRetirement()
}
// Offer the record to every known peer once, stopping at the first that commits it. One round
// per call, so the screen can stay in front of the user and say what it is trying.
fun pushRetirementRound(recordBlob: String): Result<RetirementRound> = ffiCatching {
nativeLib.node().ensemble().pushRetirementRound(recordBlob)
}
fun pingNode(nodeIdHex: String, timeoutSecs: Int): Result<Double> = ffiCatching {
nativeLib.node().ensemble().pingNode(nodeIdHex, timeoutSecs.toUInt())
}
fun syncDocuments(nodeIdHex: String): Result<SyncDocumentsResult> = ffiCatching {
nativeLib.node().ensemble().syncDocuments(nodeIdHex)
}
// Pull this document's missing artifacts, and those of every descendant
// document, from ensemble peers into the local node, persisting everything
// fetched. Backs the document-detail "Sync to this node" control, which
// recurses into the whole subtree.
fun syncDocumentAssets(documentId: String): Result<DocumentSyncResult> = ffiCatching {
nativeLib.node().ensemble().syncDocumentAssets(documentId)
}
// Pull one artifact of this document from ensemble peers — the per-row "Sync"
// button in the document-detail Artifacts section. The narrow counterpart to
// syncDocumentAssets, which stays the bulk action: this fetches exactly the
// named artifact and doesn't recurse into child documents.
fun syncDocumentAsset(documentId: String, asset: DocumentAssetRef): Result<DocumentSyncResult> =
ffiCatching {
nativeLib.node().ensemble().syncDocumentAsset(documentId, asset)
}
// Push this document (and every descendant) to one specific ensemble member:
// the write-side counterpart to syncDocumentAssets. Backs the document-detail
// "Push now" button on each "Sync to other node" row.
fun pushDocument(documentId: String, nodeIdHex: String): Result<DocumentPushResult> =
ffiCatching {
nativeLib.node().ensemble().pushDocument(documentId, nodeIdHex)
}
// Number of descendants a document has. Backs the "Purge" confirmation dialog's
// warning about how many child documents will also be purged.
fun countDocumentDescendants(documentId: String): Result<UInt> = ffiCatching {
nativeLib.node().ensemble().countDocumentDescendants(documentId)
}
// Permanently purges a document (and its descendants): deletes it locally and
// broadcasts a delete intent to the rest of the ensemble. Backs the document-detail
// "Purge" control.
fun purgeDocument(documentId: String): Result<PurgeResult> = ffiCatching {
nativeLib.node().ensemble().purgeDocument(documentId)
}
}
@@ -1,54 +0,0 @@
package com.gregshuflin.synchronicity
import uniffi.synchronicity.DocumentType
import uniffi.synchronicity.EvictionOutcomeItem
import uniffi.synchronicity.EvictionPlanItem
/**
* Kotlin wrapper for the last-copy guard's FFI surface — mirrors [NotebookAgent]'s pattern: one
* method per [uniffi.synchronicity.EvictionFfi] method, each wrapped in [ffiCatching].
*
* Exposed as [NativeLib.eviction], so call sites read
* `nativeLib.eviction.evaluateDeleteLocally(...)` instead of `nativeLib.evaluateDeleteLocally(...)`
* string-prefix pseudo-namespacing.
*/
class EvictionAgent internal constructor(private val nativeLib: NativeLib) {
// Gather evidence for deleting a document's subtree's locally-held blob bytes —
// read-only. Backs the document-detail "Delete Locally" confirmation dialog.
fun evaluateDeleteLocally(documentId: String): Result<EvictionPlanItem> = ffiCatching {
nativeLib.node().eviction().evaluateDeleteLocally(documentId)
}
// Deletes what evaluateDeleteLocally found safe to delete. allowUnverified is
// re-checked against the buried Settings override at execute time, not trusted
// from the caller.
fun executeDeleteLocally(
documentId: String,
allowUnverified: Boolean,
): Result<EvictionOutcomeItem> = ffiCatching {
nativeLib.node().eviction().executeDeleteLocally(documentId, allowUnverified)
}
// Type-scoped counterpart to evaluateDeleteLocally, backing the Sync policy
// screen's per-type "N no longer wanted by policy — Reclaim…" affordance.
fun evaluateTypeReclaim(docType: DocumentType): Result<EvictionPlanItem> = ffiCatching {
nativeLib.node().eviction().evaluateTypeReclaim(docType)
}
fun executeTypeReclaim(
docType: DocumentType,
allowUnverified: Boolean,
): Result<EvictionOutcomeItem> = ffiCatching {
nativeLib.node().eviction().executeTypeReclaim(docType, allowUnverified)
}
/** The buried "allow deleting unverified content" override — off by default. */
fun getAllowUnverifiedDeletion(): Result<Boolean> = ffiCatching {
nativeLib.node().eviction().getAllowUnverifiedDeletion()
}
fun setAllowUnverifiedDeletion(allow: Boolean): Result<Unit> = ffiCatching {
nativeLib.node().eviction().setAllowUnverifiedDeletion(allow)
}
}
@@ -1,5 +1,6 @@
package com.gregshuflin.synchronicity
import android.util.Log
import kotlinx.coroutines.CancellationException
import uniffi.synchronicity.SyncException
@@ -19,9 +20,8 @@ import uniffi.synchronicity.SyncException
* `launchReportingFailure`). [CancellationException] is rethrown, never captured: coroutine
* cancellation depends on it propagating.
*
* Shared by [NativeLib] and its per-agent handles (e.g. [NotebookAgent]) so every uniffi call
* anywhere in the app goes through the same totality guarantee, rather than each handle
* reimplementing it.
* [NativeLib.ffi] wraps every call on the node in this, so every uniffi call anywhere in the app
* goes through the same totality guarantee.
*/
@Suppress("TooGenericExceptionCaught")
internal inline fun <T> ffiCatching(block: () -> T): Result<T> =
@@ -34,3 +34,11 @@ internal inline fun <T> ffiCatching(block: () -> T): Result<T> =
} catch (e: Exception) {
Result.failure(SyncException.Internal("internal error: $e"))
}
/**
* A best-effort read: the value, or null once the failure is logged. For optional resources — a
* cover, a page that hasn't synced here — where absence is normal and the caller draws a
* placeholder rather than an error.
*/
fun <T> Result<T>.getOrLog(tag: String, what: String): T? =
onFailure { Log.w(tag, what, it) }.getOrNull()
@@ -1,87 +0,0 @@
package com.gregshuflin.synchronicity
import uniffi.synchronicity.BacklinkItem
import uniffi.synchronicity.ContactProtocolItem
import uniffi.synchronicity.HumanListItem
import uniffi.synchronicity.HumanProfile
/**
* Kotlin wrapper for the Humans agent's FFI surface — mirrors [NotebookAgent]'s pattern: one method
* per [uniffi.synchronicity.HumansFfi] method, each wrapped in [ffiCatching].
*
* Exposed as [NativeLib.humans], so call sites read `nativeLib.humans.list()` instead of
* `nativeLib.humansList()` string-prefix pseudo-namespacing.
*/
@Suppress("TooManyFunctions") // one method per HumansFfi method — see NativeLib's own suppression
class HumansAgent internal constructor(private val nativeLib: NativeLib) {
/** Interactions — the same Rust object, split off so each vocabulary reads on its own. */
val interactions: InteractionsAgent = InteractionsAgent(nativeLib)
fun list(): Result<List<HumanListItem>> = ffiCatching { nativeLib.node().humans().list() }
fun create(fullName: String): Result<String> = ffiCatching {
nativeLib.node().humans().create(fullName)
}
fun get(id: String): Result<HumanProfile> = ffiCatching { nativeLib.node().humans().get(id) }
@Suppress("LongParameterList") // mirrors the Rust HumansFfi::update signature
fun update(
id: String,
fullName: String,
nickname: String,
birthDate: String,
gender: String,
genderOther: String,
notes: String,
): Result<Unit> = ffiCatching {
nativeLib
.node()
.humans()
.update(id, fullName, nickname, birthDate, gender, genderOther, notes)
}
/**
* Record a value under [key]. A second, different value under a key that already has one makes
* that key multi-valued; re-adding the same value is a no-op.
*/
fun addFact(id: String, key: String, value: String): Result<Unit> = ffiCatching {
nativeLib.node().humans().addFact(id, key, value)
}
/** Remove one value under [key]; returns whether it was there. */
fun removeFact(id: String, key: String, value: String): Result<Boolean> = ffiCatching {
nativeLib.node().humans().removeFact(id, key, value)
}
/** Every protocol the contact-method picker offers, in display order. */
fun contactProtocols(): Result<List<ContactProtocolItem>> = ffiCatching {
nativeLib.node().humans().contactProtocols()
}
/**
* Record a way of reaching this person. [protocol] is a slug from [contactProtocols], or
* `other:<name>`; [value] is what the user typed, canonicalized on the Rust side.
*/
fun addContactMethod(id: String, protocol: String, value: String, label: String): Result<Unit> =
ffiCatching {
nativeLib.node().humans().addContactMethod(id, protocol, value, label)
}
/** Remove a contact method, keyed on the canonical [value] the profile carries. */
fun removeContactMethod(id: String, protocol: String, value: String): Result<Boolean> =
ffiCatching {
nativeLib.node().humans().removeContactMethod(id, protocol, value)
}
fun delete(id: String): Result<Unit> = ffiCatching { nativeLib.node().humans().delete(id) }
/**
* Every document that `[[wikilinks]]` to this one — where their name has been written down, as
* opposed to [InteractionsAgent.forHuman]'s record of having actually seen them.
*/
fun backlinks(id: String): Result<List<BacklinkItem>> = ffiCatching {
nativeLib.node().humans().backlinks(id)
}
}
@@ -1,111 +0,0 @@
package com.gregshuflin.synchronicity
import uniffi.synchronicity.ImportBatchSummary
/**
* Kotlin wrapper for the generic (agent-independent) import FFI surface — mirrors [NotebookAgent]'s
* pattern: one method per [uniffi.synchronicity.ImportFfi] method, each wrapped in [ffiCatching].
*
* Exposed as [NativeLib.import], so call sites read `nativeLib.import.image(...)` instead of
* `nativeLib.importImageDocument(...)` string-prefix pseudo-namespacing. PDF- and comics-specific
* import stays on [PdfAgent]/[ComicsAgent] instead — see those classes' doc comments.
*/
class ImportAgent internal constructor(private val nativeLib: NativeLib) {
fun image(
fileName: String,
fileExt: String,
imageBytes: ByteArray,
parentId: String? = null,
): Result<String> = ffiCatching {
nativeLib.node().import().image(fileName, fileExt, imageBytes, parentId)
}
fun musicTrack(
fileName: String,
musicBytes: ByteArray,
parentId: String? = null,
): Result<String> = ffiCatching {
nativeLib.node().import().musicTrack(fileName, musicBytes, parentId)
}
/**
* Whether a file of this name (and MIME type, when the picker reports one) is a type any
* importer would take. Asked before reading a file's bytes, so a folder import doesn't pull a
* video into memory only for Rust to skip it. Infallible: it's a question about the
* supported-type table, not about this node.
*/
fun supports(fileName: String, mimeType: String?): Boolean =
nativeLib.node().import().supports(fileName, mimeType)
// Create a Collection document for organising imported files. A null parentId
// creates a root-level collection; otherwise a sub-collection under parentId.
fun createCollection(parentId: String?, title: String): Result<String> = ffiCatching {
nativeLib.node().import().createCollection(parentId, title)
}
fun fromUrl(url: String): Result<String> = ffiCatching {
nativeLib.node().import().fromUrl(url)
}
/**
* Open a batch import: one tracked, cancellable job covering [totalFiles] files holding roughly
* [totalBytes] bytes, fed one file at a time through the returned [BatchImport]. Both totals
* are counted by the caller before it starts — see `DocumentDirectoryImport.kt`'s tree pass —
* and become the Jobs screen's denominators.
*
* [source] identifies the folder (its tree URI); [label] is the readable name the Jobs screen
* shows. Fails with `SyncException.Busy` when an import of the same [source] is already
* running: this is the one import path on this side that goes through the live `JobRunner`, so
* it dedups.
*/
fun beginBatch(
source: String,
label: String,
totalFiles: Int,
totalBytes: Long,
): Result<BatchImport> = ffiCatching {
BatchImport(
nativeLib
.node()
.import()
.beginBatch(source, label, totalFiles.toUInt(), totalBytes.toULong())
)
}
}
/**
* One batch import in progress — the Kotlin half of `ffi.rs`'s `ImportBatch`, wrapping each call in
* [ffiCatching] the way [ImportAgent] does.
*
* The loop stays here because only Kotlin can read a Storage Access Framework tree; everything else
* about the import — dispatching each file to the right importer, counting files and bytes,
* reporting progress, and finalizing the job — is on the Rust side. [finish] must be reached on
* every path, including failures, or the job is only finalized when the object is eventually
* collected.
*/
class BatchImport internal constructor(private val batch: uniffi.synchronicity.ImportBatch) {
/** Whether the Jobs screen's Cancel button has been pressed for this import. */
fun isCancelled(): Boolean = batch.isCancelled()
/**
* Import one file's bytes. Succeeds with the new document id, or with null when no supported
* type recognised the file (a skip, not a failure). [mimeType] is only a fallback for dispatch,
* used when [fileName] has no extension a supported type claims.
*/
fun importFile(
fileName: String,
mimeType: String?,
bytes: ByteArray,
parentId: String?,
): Result<String?> = ffiCatching { batch.importFile(fileName, mimeType, bytes, parentId) }
/** Count a file the caller couldn't read, so the job's denominator still advances. */
fun skipFile(fileName: String, byteSize: Long, reason: String) {
batch.skipFile(fileName, byteSize.toULong(), reason)
}
/** Finalize the job and report what the batch did. Safe to call more than once. */
fun finish(): ImportBatchSummary = batch.finish()
}
@@ -1,65 +0,0 @@
package com.gregshuflin.synchronicity
import uniffi.synchronicity.InteractionDetail
import uniffi.synchronicity.InteractionItem
/**
* Kotlin wrapper for the interaction half of the Humans agent's FFI surface, reached as
* `nativeLib.humans.interactions`.
*
* A separate class from [HumansAgent] rather than more methods on it: an interaction is its own
* document shared by everyone who was there, not a field of one person, and keeping the two
* vocabularies apart also keeps each file under detekt's function-count threshold. Both still land
* on the same Rust `HumansFfi` object.
*/
class InteractionsAgent internal constructor(private val nativeLib: NativeLib) {
/**
* Record an interaction, returning its id.
*
* [occurredOn] is required and must be a canonical partial date — `2026-03-14`, `--03-14`, or
* `2026`. Being dated is what makes this an interaction rather than a note about someone; a
* bare year is how you say you don't remember the day.
*/
fun log(
title: String,
occurredOn: String,
body: String,
attendees: List<String>,
): Result<String> = ffiCatching {
nativeLib.node().humans().logInteraction(title, occurredOn, body, attendees)
}
fun get(id: String): Result<InteractionDetail> = ffiCatching {
nativeLib.node().humans().getInteraction(id)
}
/** Replace an interaction's title, date, and write-up. The guest list is [setAttendees]. */
fun update(id: String, title: String, occurredOn: String, body: String): Result<Unit> =
ffiCatching {
nativeLib.node().humans().updateInteraction(id, title, occurredOn, body)
}
/** Replace the set of people who were there. */
fun setAttendees(id: String, attendees: List<String>): Result<Unit> = ffiCatching {
nativeLib.node().humans().setAttendees(id, attendees)
}
/**
* Delete an interaction. It is one shared document, so this removes it from every attendee's
* timeline, not only the one you are looking at.
*/
fun delete(id: String): Result<Unit> = ffiCatching {
nativeLib.node().humans().deleteInteraction(id)
}
/** Every interaction a person attended, most recent first. */
fun forHuman(id: String): Result<List<InteractionItem>> = ffiCatching {
nativeLib.node().humans().interactionsWith(id)
}
/** Every interaction on this node, most recent first. */
fun list(): Result<List<InteractionItem>> = ffiCatching {
nativeLib.node().humans().listInteractions()
}
}
@@ -1,35 +0,0 @@
package com.gregshuflin.synchronicity
import uniffi.synchronicity.JobItem
/**
* Kotlin wrapper for the Jobs screen's FFI surface — mirrors [NotebookAgent]'s pattern: one method
* per [uniffi.synchronicity.JobsFfi] method, each wrapped in [ffiCatching].
*
* Exposed as [NativeLib.jobs], so call sites read `nativeLib.jobs.listActiveJobs()` instead of
* `nativeLib.listActiveJobs()` string-prefix pseudo-namespacing.
*/
class JobsAgent internal constructor(private val nativeLib: NativeLib) {
fun listActiveJobs(): Result<List<JobItem>> = ffiCatching {
nativeLib.node().jobs().listActiveJobs()
}
fun listJobHistory(limit: Int): Result<List<JobItem>> = ffiCatching {
nativeLib.node().jobs().listJobHistory(limit.toUInt())
}
fun clearJobHistory(): Result<Unit> = ffiCatching { nativeLib.node().jobs().clearJobHistory() }
/**
* Best-effort cancel: `true` only if `jobId` had a live cancellation token to flip. See
* `ffi.rs`'s `JobsFfi::cancel_job` doc comment -- only `BackgroundSync`, scheduler-originated
* `CatalogueSync`/`ContentFetch`, `ContentStream` (a reader opening something that lives on
* another node -- see `lib::content_fetch`), and a batch (folder) `Import` have one; a
* manually-triggered sync/fetch, a single-file `Import`, or any `Push`/`Eviction`/`Purge`,
* returns `false` with no error.
*/
fun cancelJob(jobId: String): Result<Boolean> = ffiCatching {
nativeLib.node().jobs().cancelJob(jobId)
}
}
@@ -53,7 +53,7 @@ import com.gregshuflin.synchronicity.scheduler.SchedulerWorker
import com.gregshuflin.synchronicity.settings.SettingsScreen
import com.gregshuflin.synchronicity.settings.SettingsSection
import com.gregshuflin.synchronicity.ui.AnimatedNavHost
import com.gregshuflin.synchronicity.ui.IconButton
import com.gregshuflin.synchronicity.ui.IconRowButton
import com.gregshuflin.synchronicity.ui.NavSlot
import com.gregshuflin.synchronicity.ui.NavSlotView
import com.gregshuflin.synchronicity.ui.NavStatus
@@ -106,8 +106,7 @@ class MainActivity : ComponentActivity() {
// normal init flow (AppScreen) will start the service once it is, so skip
// quietly here rather than letting the FFI call throw.
if (!NativeLib.isInitialized()) return
NativeLib()
.nodeInitialized()
NativeLib.ffi { nodeInitialized() }
.onSuccess { initialized ->
if (initialized) startForegroundService(Intent(this, P2PService::class.java))
}
@@ -128,7 +127,7 @@ fun AppScreen(modifier: Modifier = Modifier, viewModel: AppViewModel = viewModel
) {}
}
is AppUiState.Error -> StoreInitErrorScreen(state.message, modifier = modifier)
is AppUiState.Ready -> AppContent(viewModel.nativeLib, state, modifier = modifier)
is AppUiState.Ready -> AppContent(state, modifier = modifier)
}
}
@@ -165,22 +164,17 @@ private fun StoreInitErrorScreen(message: String, modifier: Modifier = Modifier)
}
@Composable
private fun AppContent(
nativeLib: NativeLib,
state: AppUiState.Ready,
modifier: Modifier = Modifier,
) {
private fun AppContent(state: AppUiState.Ready, modifier: Modifier = Modifier) {
val context = LocalContext.current
val navController = rememberNavController()
val themeState = rememberThemeState(nativeLib)
val themeState = rememberThemeState()
// When the node already exists we boot straight into Main, so make sure the P2P
// foreground service is running for that path. The init-complete and settings-reset
// paths start/stop it themselves.
LaunchedEffect(Unit) {
if (state.nodeInitialized) {
context.startForegroundService(Intent(context, P2PService::class.java))
scheduleBackgroundSync(context, nativeLib)
startNodeServices(context)
}
}
@@ -220,7 +214,6 @@ private fun AppContent(
AppNavHost(
navController = navController,
startDestination = startDestination,
nativeLib = nativeLib,
state = state,
themeState = themeState,
)
@@ -242,7 +235,6 @@ private fun AppContent(
private fun AppNavHost(
navController: NavHostController,
startDestination: Any,
nativeLib: NativeLib,
state: AppUiState.Ready,
themeState: ThemeState,
) {
@@ -251,13 +243,11 @@ private fun AppNavHost(
composable<InitializationRoute> {
InitializationNavigation(
onInitializationComplete = {
context.startForegroundService(Intent(context, P2PService::class.java))
scheduleBackgroundSync(context, nativeLib)
startNodeServices(context)
navController.navigate(MainRoute) {
popUpTo(InitializationRoute) { inclusive = true }
}
},
nativeLib = nativeLib,
}
)
}
composable<MainRoute> {
@@ -271,7 +261,6 @@ private fun AppNavHost(
val agentId = AgentId.valueOf(backStackEntry.toRoute<AgentRoute>().agentId)
AgentDestination(
agentId = agentId,
nativeLib = nativeLib,
themeState = themeState,
onBack = { navController.popBackStack() },
onOpenHuman = { humanId -> navController.navigate(HumanDetailRoute(humanId)) },
@@ -293,9 +282,7 @@ private fun AppNavHost(
}
composable<RetirementRoute> {
RetirementScreen(
nativeLib = nativeLib,
nodeName =
remember { nativeLib.settings.getNodeName().orEmpty() }.ifEmpty { "this node" },
nodeName = remember { NativeLib.nodeName().orEmpty() }.ifEmpty { "this node" },
onFinish = {
navController.navigate(InitializationRoute) {
popUpTo(RetirementRoute) { inclusive = true }
@@ -306,7 +293,6 @@ private fun AppNavHost(
composable<HumanDetailRoute> { backStackEntry ->
HumanDetailScreen(
humanId = backStackEntry.toRoute<HumanDetailRoute>().humanId,
nativeLib = nativeLib,
onBack = { navController.popBackStack() },
)
}
@@ -320,7 +306,6 @@ private fun AppNavHost(
@Composable
private fun AgentDestination(
agentId: AgentId,
nativeLib: NativeLib,
themeState: ThemeState,
onBack: () -> Unit,
onOpenHuman: (String) -> Unit,
@@ -328,37 +313,30 @@ private fun AgentDestination(
onRetire: () -> Unit,
) {
when (agentId) {
AgentId.NOTES -> NotesScreen(nativeLib = nativeLib, onBack = onBack)
AgentId.NOTES -> NotesScreen(onBack = onBack)
AgentId.PDF_VIEWER -> PdfScreen(onBackToMain = onBack)
AgentId.PODCASTS -> PodcastScreen(nativeLib = nativeLib, onBackToMain = onBack)
AgentId.PODCASTS -> PodcastScreen(onBackToMain = onBack)
AgentId.DOCUMENTS -> DocumentsScreen(onBackToMain = onBack)
AgentId.MUSIC -> MusicScreen(onBack = onBack)
AgentId.COMICS -> ComicsScreen(nativeLib = nativeLib, onBack = onBack)
AgentId.COMICS -> ComicsScreen(onBack = onBack)
AgentId.CODE -> CodeScreen(onBack = onBack)
AgentId.PICTURES -> PicturesScreen(nativeLib = nativeLib, onBack = onBack)
AgentId.PICTURES -> PicturesScreen(onBack = onBack)
AgentId.VIDEO -> VideoScreen(onBack = onBack)
AgentId.POMODORO_TIMER -> PomodoroTimerScreen(onBack = onBack)
AgentId.HUMANS ->
HumansScreen(nativeLib = nativeLib, onOpenHuman = onOpenHuman, onBack = onBack)
AgentId.QR_CODE -> QrCodeScreen(nativeLib = nativeLib, onBack = onBack)
AgentId.JOBS -> JobsScreen(nativeLib = nativeLib, onBack = onBack)
AgentId.CLIPPINGS -> ClippingsScreen(nativeLib = nativeLib, onBack = onBack)
AgentId.HUMANS -> HumansScreen(onOpenHuman = onOpenHuman, onBack = onBack)
AgentId.QR_CODE -> QrCodeScreen(onBack = onBack)
AgentId.JOBS -> JobsScreen(onBack = onBack)
AgentId.CLIPPINGS -> ClippingsScreen(onBack = onBack)
// The Ensemble and Settings buttons both open the settings screen; the Ensemble button
// just lands on the Ensemble section of its bottom navigation bar.
AgentId.ENSEMBLE ->
SettingsScreen(
themeState = themeState,
nativeLib = nativeLib,
initialSection = SettingsSection.ENSEMBLE,
onBackToMain = { backToMainAfterSettings(nativeLib, onBack, onNodeReset) },
onRetire = onRetire,
)
// just lands on its Ensemble section.
AgentId.ENSEMBLE,
AgentId.SETTINGS ->
SettingsScreen(
themeState = themeState,
nativeLib = nativeLib,
initialSection = SettingsSection.SYSTEM,
onBackToMain = { backToMainAfterSettings(nativeLib, onBack, onNodeReset) },
initialSection =
if (agentId == AgentId.ENSEMBLE) SettingsSection.ENSEMBLE
else SettingsSection.SYSTEM,
onBackToMain = { backToMainAfterSettings(onBack, onNodeReset) },
onRetire = onRetire,
)
}
@@ -368,27 +346,24 @@ private fun AgentDestination(
* Routes back from the settings screen. The node may have been reset from the System section, so
* re-check initialization status and fall back to the initialization flow when it's gone.
*/
private fun backToMainAfterSettings(
nativeLib: NativeLib,
onBack: () -> Unit,
onNodeReset: () -> Unit,
) {
private fun backToMainAfterSettings(onBack: () -> Unit, onNodeReset: () -> Unit) {
val initialized =
nativeLib.nodeInitialized().getOrElse { e ->
Log.e("AppContent", "Failed to check node initialization status after settings", e)
false
}
NativeLib.ffi { nodeInitialized() }
.getOrElse { e ->
Log.e("AppContent", "Failed to check node initialization status after settings", e)
false
}
if (initialized) onBack() else onNodeReset()
}
/**
* (Re-)enqueue the background sync scheduler's periodic `WorkManager` tick with this node's current
* scheduler settings — called wherever the P2P foreground service is started, since the scheduler
* tick depends on P2P eventually coming up the same way.
* Start what an initialized node runs in the background: the P2P foreground service, and the
* background sync scheduler's periodic `WorkManager` tick (re-enqueued with this node's current
* scheduler settings, since that tick depends on P2P coming up the same way).
*/
private fun scheduleBackgroundSync(context: Context, nativeLib: NativeLib) {
nativeLib.settings
.getSchedulerSettings()
private fun startNodeServices(context: Context) {
context.startForegroundService(Intent(context, P2PService::class.java))
NativeLib.ffi { settings().getSchedulerSettings() }
.onSuccess { settings -> SchedulerWorker.schedule(context, settings) }
.onFailure { Log.w("AppContent", "Failed to load scheduler settings", it) }
}
@@ -464,7 +439,7 @@ fun MainScreen(
)
for (agent in inGroup) {
val slot = playbackSlot(agent.id, playback) ?: NavStatus.get(agent.id)
IconButton(
IconRowButton(
onClick = { onAgentSelect(agent) },
icon = agent.icon,
text = agent.displayName,
@@ -1,51 +0,0 @@
package com.gregshuflin.synchronicity
import uniffi.synchronicity.LibraryBuildSummary
import uniffi.synchronicity.LibraryEntry
import uniffi.synchronicity.LibraryEntryKind
import uniffi.synchronicity.MusicLibraryLevel
import uniffi.synchronicity.MusicTreeItem
/**
* Kotlin wrapper for the Music agent's FFI surface — mirrors [ComicsAgent]'s pattern: one method
* per [uniffi.synchronicity.MusicFfi] method, each wrapped in [ffiCatching].
*
* Exposed as [NativeLib.music], so call sites read `nativeLib.music.listTree()` instead of
* `nativeLib.listMusicTree()` string-prefix pseudo-namespacing.
*
* A track's audio bytes are not fetched through here: playback loads them with
* [DocumentsAgent.getBlobBytes] (local) or [DocumentsAgent.fetchBlobToPath] (streamed from a peer),
* the same way it did before the folder hierarchy existed.
*/
class MusicAgent internal constructor(private val nativeLib: NativeLib) {
/** The full music folder hierarchy (unsorted, flat — group by [MusicTreeItem.parentId]). */
fun listTree(): Result<List<MusicTreeItem>> = ffiCatching {
nativeLib.node().music().listTree()
}
/**
* One level of the tag-derived Artist → Album → Track library, already in display order. An
* empty artist level means no library has been built on this node yet ([buildLibrary]).
*/
fun listLibrary(level: MusicLibraryLevel): Result<List<LibraryEntry>> = ffiCatching {
nativeLib.node().music().listLibrary(level)
}
/**
* Cover bytes for a library row that reported [LibraryEntry.hasCover] — an album's own art, or
* an artist's derived portrait/mosaic. Null when there turned out to be none.
*/
fun entryArt(entryId: String, kind: LibraryEntryKind): Result<ByteArray?> = ffiCatching {
nativeLib.node().music().entryArt(entryId, kind)
}
/**
* Rebuild the tag-derived library from the tracks held here. Long-running (it parses every
* not-yet-scanned audio blob) and tracked as a Job, so call it off the main thread; it fails
* with [uniffi.synchronicity.SyncException.Busy] if a build is already running.
*/
fun buildLibrary(): Result<LibraryBuildSummary> = ffiCatching {
nativeLib.node().music().buildLibrary()
}
}
@@ -2,60 +2,50 @@ package com.gregshuflin.synchronicity
import android.content.Context
import android.util.Log
import uniffi.synchronicity.AgentInfo
import uniffi.synchronicity.DocumentSyncOverrideOption
import uniffi.synchronicity.FontData
import java.io.File
import uniffi.synchronicity.LogVerbosity
import uniffi.synchronicity.MarkupDoc
import uniffi.synchronicity.MathDialect
import uniffi.synchronicity.MathRender
import uniffi.synchronicity.SyncException
import uniffi.synchronicity.SyncNode
import uniffi.synchronicity.SyncPolicyOption
import uniffi.synchronicity.TypstPreviewOutcome
import uniffi.synchronicity.WikilinkTitle
// NativeLib is a deliberately flat 1:1 mirror of the uniffi FFI surface: one thin
// wrapper per exported Rust function, with parameter lists that follow the Rust
// signatures. Detekt's size/parameter-count heuristics aren't meaningful here —
// splitting the class or bundling parameters would just diverge from the FFI.
@Suppress("TooManyFunctions", "LongParameterList")
class NativeLib {
companion object {
private const val TAG = "NativeLib"
/**
* The app's handle on the shared Rust node. A per-agent call goes through [ffi] straight to the
* uniffi object — `nativeLib.ffi { podcast().queueAdd(id) }` — whose methods and docs uniffi
* generates from the Rust side, so there is no Kotlin layer restating them. Top-level uniffi
* functions that need no node (`parseMarkup`, `buildInfo`, …) are called directly, wrapped in
* [ffiCatching] where they can fail.
*
* One per process, because there is one node: every screen, the P2P service and the background
* workers share a single open store and P2P runtime.
*/
object NativeLib {
private const val TAG = "NativeLib"
// Store directory under the app's internal files dir. Holds `store.db`
// and a `blobs/` subdirectory — the same layout the CLI and desktop app
// use, so the FFI can share Store::open / Store::create with them.
private const val STORE_DIRNAME = "store"
// Store directory under the app's internal files dir. Holds `store.db`
// and a `blobs/` subdirectory — the same layout the CLI and desktop app
// use, so the FFI can share Store::open / Store::create with them.
private const val STORE_DIRNAME = "store"
// Process-wide handle to the Rust node. Every NativeLib instance (UI
// screens, the P2P service, repositories) constructs its own NativeLib but
// must share a single open store + P2P runtime, so the handle lives in the
// companion object rather than per-instance.
@Volatile private var syncNode: SyncNode? = null
private val initLock = Any()
@Volatile private var syncNode: SyncNode? = null
private val initLock = Any()
/**
* True once the shared node handle has been opened (the store is ready). Non-throwing, so
* callers can probe readiness without constructing a NativeLib or guarding against
* IllegalStateException.
*/
fun isInitialized(): Boolean = syncNode != null
init {
try {
System.loadLibrary("synchronicity")
Log.d(TAG, "Successfully loaded native library")
uniffi.synchronicity.initAndroidLogging(LogVerbosity.NORMAL, false)
Log.d(TAG, "Successfully initialized Android logging")
} catch (e: UnsatisfiedLinkError) {
Log.e(TAG, "Failed to load native library", e)
throw e
}
init {
try {
System.loadLibrary("synchronicity")
Log.d(TAG, "Successfully loaded native library")
uniffi.synchronicity.initAndroidLogging(LogVerbosity.NORMAL, false)
Log.d(TAG, "Successfully initialized Android logging")
} catch (e: UnsatisfiedLinkError) {
Log.e(TAG, "Failed to load native library", e)
throw e
}
}
/**
* True once the shared node handle has been opened (the store is ready). Non-throwing, so
* callers can probe readiness without guarding against IllegalStateException.
*/
fun isInitialized(): Boolean = syncNode != null
/**
* Open (or create on first launch) the shared native store. Throws if the store can't be
* opened; callers are expected to surface that to the user rather than continuing with a null
@@ -66,7 +56,7 @@ class NativeLib {
Log.d(TAG, "NativeLib already initialized")
return
}
// Guard against two instances/threads opening the store concurrently
// Guard against two threads opening the store concurrently
// (e.g. the UI's LaunchedEffect and P2PService.onCreate both run on IO).
synchronized(initLock) {
if (syncNode != null) {
@@ -75,12 +65,12 @@ class NativeLib {
}
initAndroidContext(context)
Log.d(TAG, "Successfully initialized Android context")
val storeDir = java.io.File(context.filesDir, STORE_DIRNAME).absolutePath
val storeDir = File(context.filesDir, STORE_DIRNAME).absolutePath
val opened = SyncNode(storeDir)
syncNode = opened
Log.d(TAG, "Successfully opened store at $storeDir")
// Apply stored log settings now that the store is open.
ffi { opened.settings().getLogConfig() }
ffi { settings().getLogConfig() }
.getOrNull()
?.let { cfg ->
uniffi.synchronicity.updateAndroidLogFilter(cfg.verbosity, cfg.showIrohLogs)
@@ -94,12 +84,7 @@ class NativeLib {
// Throws a typed SyncException rather than IllegalStateException so that
// "the store isn't open yet" arrives at callers through the same channel as
// every other native failure — see the totality note on [ffi].
//
// internal rather than private: per-agent handles like [NotebookAgent] live in
// their own top-level class (real object structure instead of notebookXxx-style
// string prefixing — see that class's doc comment) and need this to reach their
// uniffi sub-object, e.g. `nativeLib.node().notebook()`.
// every other native failure — see the totality note on [ffiCatching].
internal fun node(): SyncNode =
syncNode ?: throw SyncException.NotInitialized("call initialize() first")
@@ -107,234 +92,25 @@ class NativeLib {
// can access Android system APIs (certificate store, DNS, etc.).
external fun initAndroidContext(context: Context)
// See [ffiCatching]'s doc comment for the totality guarantee this provides. Kept as a
// thin alias here so the ~130 call sites below don't need touching; per-agent handles
// (e.g. [NotebookAgent]) call [ffiCatching] directly.
private inline fun <T> ffi(block: () -> T): Result<T> = ffiCatching(block)
// ── Stateless helpers (top-level Rust functions) ────────────────────────────
fun generateEnsembleKey(): Result<List<String>> = ffi {
uniffi.synchronicity.generateEnsembleKey()
}
// Infallible: pure computation in Rust with no `Result` return.
fun listTimezones(): List<String> = uniffi.synchronicity.listTimezones()
/** Build/version metadata for the About section. Static, computed in Rust. */
fun buildInfo(): uniffi.synchronicity.BuildInfo = uniffi.synchronicity.buildInfo()
/** Ping timeout (seconds), sourced from shared Rust so it matches the GUI. */
fun pingTimeoutSecs(): Int = uniffi.synchronicity.pingTimeoutSecs().toInt()
/** Default Documents-list page size, sourced from shared Rust so it matches the GUI/CLI. */
fun defaultDocumentPageSize(): Int = uniffi.synchronicity.defaultDocumentPageSize().toInt()
/**
* Check one partial-date field ("1990-06-15", "--06-15", "1990"), returning the reason it was
* rejected.
*
* Lets a form mark the offending field rather than showing a general "save failed": the save
* call itself rejects the whole record and its error names no field. Pass [required] = false
* where blank is allowed, as it is for a birth date.
* Run [block] against the open node, with every failure — including "the store isn't open yet"
* — in the [Result]. See [ffiCatching] for the totality guarantee. The block's receiver is the
* uniffi [SyncNode], so a call reads `ffi { notebook().createNote(parentId, title) }`.
*/
fun validatePartialDate(text: String, required: Boolean): Result<Unit> = ffi {
uniffi.synchronicity.validatePartialDate(text, required)
}
internal inline fun <T> ffi(block: SyncNode.() -> T): Result<T> = ffiCatching { node().block() }
/**
* Format an epoch-seconds timestamp using the shared "last seen" rule (within 24h → HH:mm:ss,
* otherwise Mon DD, HH:mm) in the given IANA [tz].
*/
fun formatSeenTimestamp(epochSecs: Long, tz: String?): String =
uniffi.synchronicity.formatSeenTimestamp(epochSecs.toULong(), tz)
// ── Settings with behavior of their own ─────────────────────────────────
fun getAllDocumentTypes(): List<String> = uniffi.synchronicity.getAllDocumentTypes()
// Unset is a normal state for these, not an error; a native failure is logged and collapses to
// the same null, so the many synchronous call sites stay simple.
fun timezone(): String? =
ffi { settings().getTimezone() }.getOrLog(TAG, "Failed to read timezone")
fun getAllDocumentMimeTypes(): List<String> = uniffi.synchronicity.getAllDocumentMimeTypes()
fun nodeName(): String? =
ffi { settings().getNodeName() }.getOrLog(TAG, "Failed to read node name")
fun getAgents(): List<AgentInfo> = uniffi.synchronicity.getAgents()
/** Renders [text] as a QR code, PNG-encoded, decodable via [android.graphics.BitmapFactory]. */
fun generateQrCodePng(text: String): Result<ByteArray> = ffi {
uniffi.synchronicity.generateQrCodePng(text)
}
fun compileTypst(source: String): Result<ByteArray> = ffi {
uniffi.synchronicity.compileTypst(source)
}
fun compileTypstWithFonts(
source: String,
fontNames: Array<String>,
fontDataArrays: Array<ByteArray>,
): Result<ByteArray> = ffi {
val fonts = fontNames.zip(fontDataArrays).map { (name, data) -> FontData(name, data) }
uniffi.synchronicity.compileTypstWithFonts(source, fonts)
}
// ── Podcasts ────────────────────────────────────────────────────────────────
//
// Grouped as its own handle (see [PodcastAgent]'s doc comment) instead of
// podcastXxx-prefixed methods here — mirrors the Rust-side PodcastFfi split.
val podcast: PodcastAgent = PodcastAgent(this)
// ── Node lifecycle ───────────────────────────────────────────────────────────
fun nodeInitialized(): Result<Boolean> = ffi { node().nodeInitialized() }
fun resetNode(): Result<Unit> = ffi { node().resetNode() }
// ── Settings ───────────────────────────────────────────────────────────────
//
// Grouped as its own handle (see [SettingsAgent]'s doc comment) instead of
// settings-flavored methods here — mirrors the Rust-side SettingsFfi split.
val settings: SettingsAgent = SettingsAgent(this)
/** The policies a document type can be set to, with their labels and descriptions. */
fun getSyncPolicyOptions(): List<SyncPolicyOption> = uniffi.synchronicity.getSyncPolicyOptions()
/** The overrides a document can be set to, with their labels and descriptions. */
fun getDocumentSyncOverrideOptions(): List<DocumentSyncOverrideOption> =
uniffi.synchronicity.getDocumentSyncOverrideOptions()
// ── Ensemble / P2P ──────────────────────────────────────────────────────────
//
// Grouped as its own handle (see [EnsembleAgent]'s doc comment) instead of
// living directly here — mirrors the Rust-side EnsembleFfi split.
val ensemble: EnsembleAgent = EnsembleAgent(this)
// ── Deleting content safely (the last-copy guard) ───────────────────────────
//
// Grouped as its own handle (see [EvictionAgent]'s doc comment) instead of
// living directly here — mirrors the Rust-side EvictionFfi split.
val eviction: EvictionAgent = EvictionAgent(this)
// ── Remote administration ────────────────────────────────────────────────────
//
// Grouped as its own handle (see [RemoteAdminAgent]'s doc comment) instead of
// remoteXxx-prefixed methods here — mirrors the Rust-side RemoteAdminFfi split.
val remoteAdmin: RemoteAdminAgent = RemoteAdminAgent(this)
// ── Documents ─────────────────────────────────────────────────────────────
//
// Grouped as its own handle (see [DocumentsAgent]'s doc comment) instead of
// documentXxx-prefixed methods here — mirrors the Rust-side DocumentsFfi split.
val documents: DocumentsAgent = DocumentsAgent(this)
// ── PDF agent ─────────────────────────────────────────────────────────────
//
// Grouped as its own handle (see [PdfAgent]'s doc comment) instead of
// pdfXxx-prefixed methods here — mirrors the Rust-side PdfFfi split.
val pdf: PdfAgent = PdfAgent(this)
// ── Comics ──────────────────────────────────────────────────────────────────
//
// Grouped as its own handle (see [ComicsAgent]'s doc comment) instead of
// comicXxx-prefixed methods here — mirrors the Rust-side ComicsFfi split.
val comics: ComicsAgent = ComicsAgent(this)
// ── Pictures ────────────────────────────────────────────────────────────────
//
// Grouped as its own handle (see [PicturesAgent]'s doc comment) instead of
// pictureXxx-prefixed methods here — mirrors the Rust-side PicturesFfi split.
val pictures: PicturesAgent = PicturesAgent(this)
// ── Music ───────────────────────────────────────────────────────────────────
//
// Grouped as its own handle (see [MusicAgent]'s doc comment) instead of
// musicXxx-prefixed methods here — mirrors the Rust-side MusicFfi split.
val music: MusicAgent = MusicAgent(this)
// ── Import ────────────────────────────────────────────────────────────────
//
// Grouped as its own handle (see [ImportAgent]'s doc comment) instead of
// importXxx-prefixed methods here — mirrors the Rust-side ImportFfi split.
// PDF/comics-specific import lives on [PdfAgent]/[ComicsAgent] instead.
val import: ImportAgent = ImportAgent(this)
// ── Notebook agent ────────────────────────────────────────────────────────
//
// Grouped as its own handle (see [NotebookAgent]'s doc comment) instead of
// notebookXxx-prefixed methods here — mirrors the Rust-side split of
// notebook_* SyncNode methods into their own NotebookFfi uniffi object.
val notebook: NotebookAgent = NotebookAgent(this)
/**
* Default title for a freshly created note/collection, sourced from shared Rust so the
* create-then-rename flow matches the GUI. Static, not tied to a running node — stays here
* rather than on [NotebookAgent] since it isn't a [uniffi.synchronicity.NotebookFfi] method.
*/
fun notebookDefaultItemName(): String = uniffi.synchronicity.notebookDefaultItemName()
// ── Markup rendering (shared IR, docs/notes-markup-rendering.md) ────────────
//
// Stateless top-level Rust functions, like compileTypst/searchPodcasts above —
// no SyncNode needed since parsing/rendering doesn't touch the store.
fun parseMarkup(source: String): MarkupDoc = uniffi.synchronicity.parseMarkup(source)
fun resolveWikilinks(doc: MarkupDoc, titles: List<WikilinkTitle>): MarkupDoc =
uniffi.synchronicity.resolveWikilinks(doc, titles)
fun renderTypstPreview(source: String, pixelsPerPt: Float, gapPt: Float): TypstPreviewOutcome =
uniffi.synchronicity.renderTypstPreview(source, pixelsPerPt, gapPt)
fun renderMath(
dialect: MathDialect,
source: String,
display: Boolean,
fontSizePx: Float,
dpr: Float,
): Result<MathRender> = ffi {
uniffi.synchronicity.renderMath(dialect, source, display, fontSizePx, dpr)
}
// ── Humans agent ──────────────────────────────────────────────────────────
//
// Grouped as its own handle (see [HumansAgent]'s doc comment) instead of
// humansXxx-prefixed methods here — mirrors the Rust-side HumansFfi split.
val humans: HumansAgent = HumansAgent(this)
// ── Clippings ─────────────────────────────────────────────────────────
//
// Grouped as its own handle (see [ClippingsAgent]'s doc comment) instead of
// clippingsXxx-prefixed methods here — mirrors the Rust-side ClippingsFfi split.
val clippings: ClippingsAgent = ClippingsAgent(this)
// ── Service credentials ───────────────────────────────────────────────
//
// Grouped as its own handle (see [CredentialsAgent]'s doc comment) instead of
// credentialXxx-prefixed methods here — mirrors the Rust-side CredentialsFfi split.
val credentials: CredentialsAgent = CredentialsAgent(this)
// ── Jobs ──────────────────────────────────────────────────────────────
//
// Grouped as its own handle (see [JobsAgent]'s doc comment) instead of
// living directly here — mirrors the Rust-side JobsFfi split.
val jobs: JobsAgent = JobsAgent(this)
// ── Store changes ─────────────────────────────────────────────────────
//
// Grouped as its own handle (see [ChangesAgent]) instead of living directly
// here — mirrors the Rust-side ChangesFfi split. Screens collect
// [StoreChanges.cursor] rather than calling this.
val changes: ChangesAgent = ChangesAgent(this)
/** Persist the log config and immediately apply it to the running logger. */
fun setLogConfig(verbosity: LogVerbosity, showIrohLogs: Boolean): Result<Unit> =
ffi { settings().setLogConfig(verbosity, showIrohLogs) }
.onSuccess { uniffi.synchronicity.updateAndroidLogFilter(verbosity, showIrohLogs) }
}
@@ -55,19 +55,19 @@ object NodeReset {
* Android settings is the way out.
*
* Runs under [NonCancellable], and picks its own dispatcher per step. The caller is a click
* handler on a screen this navigates away from, so a cancellable reset is one that could stop
* handler on a screen this navigates away from, so a cancelable reset is one that could stop
* between destroying the store and clearing what was decrypted out of it. The wipe blocks its
* thread for as long as it takes, so it goes to [Dispatchers.IO]; the audio transport is a
* Media3 `MediaController`, which may only be touched from the main thread.
*/
suspend fun perform(context: Context, nativeLib: NativeLib): Result<Unit> =
suspend fun perform(context: Context): Result<Unit> =
withContext(NonCancellable) {
val app = context.applicationContext
withContext(Dispatchers.Main) { AudioPlayerProvider.release(app) }
SchedulerWorker.cancel(app)
val wiped = withContext(Dispatchers.IO) { nativeLib.resetNode() }
val wiped = withContext(Dispatchers.IO) { NativeLib.ffi { resetNode() } }
app.stopService(Intent(app, P2PService::class.java))
withContext(Dispatchers.IO) { clearCaches(app) }
@@ -1,127 +0,0 @@
package com.gregshuflin.synchronicity
import uniffi.synchronicity.DocumentAvailability
import uniffi.synchronicity.MathDialect
import uniffi.synchronicity.NoteContent
import uniffi.synchronicity.NoteTreeEntry
import uniffi.synchronicity.NotebookItem
import uniffi.synchronicity.NotesViewMode
import uniffi.synchronicity.TextFormat
/**
* Kotlin wrapper for the Notes/Notebook agent's FFI surface.
*
* The Rust side groups these as their own uniffi object ([uniffi.synchronicity.NotebookFfi],
* obtained fresh per call via [NativeLib.node]'s `notebook()` — cheap, just clones an `Arc`) rather
* than `notebook_*`-prefixed methods on `SyncNode`. This class mirrors that: one small method per
* [uniffi.synchronicity.NotebookFfi] method, each wrapped in [ffiCatching] the same way every other
* [NativeLib] method is (see that function's doc comment for the `Result` totality guarantee).
*
* Exposed as [NativeLib.notebook], so call sites read `nativeLib.notebook.createNotebook(...)` —
* real object structure for a per-agent FFI surface, instead of
* `nativeLib.notebookCreateNotebook(...)` string-prefix pseudo-namespacing.
*/
@Suppress("TooManyFunctions") // one method per NotebookFfi method — see NativeLib's own suppression
class NotebookAgent internal constructor(private val nativeLib: NativeLib) {
fun listNotebooks(): Result<List<NotebookItem>> = ffiCatching {
nativeLib.node().notebook().listNotebooks()
}
fun createNotebook(title: String): Result<String> = ffiCatching {
nativeLib.node().notebook().createNotebook(title)
}
fun getTree(notebookId: String): Result<List<NoteTreeEntry>> = ffiCatching {
nativeLib.node().notebook().getTree(notebookId)
}
fun createNote(parentId: String, title: String): Result<String> = ffiCatching {
nativeLib.node().notebook().createNote(parentId, title)
}
fun createCollection(parentId: String, title: String): Result<String> = ffiCatching {
nativeLib.node().notebook().createCollection(parentId, title)
}
/** Null means this node doesn't hold the note's body yet (not-yet-synced note). */
fun getNote(noteId: String): Result<NoteContent?> = ffiCatching {
nativeLib.node().notebook().getNote(noteId)
}
/**
* Whether this node holds the note's body — [DocumentAvailability.LOCAL] is the only editable
* state. See [uniffi.synchronicity.NotebookFfi.noteBodyAvailability]: an editor opened on
* anything else must be read-only, because saving would fork the note into two CRDT documents
* that merge to the whole note twice (`docs/adr/0030-note-body-crdt-loro.md`).
*/
fun noteBodyAvailability(noteId: String): Result<DocumentAvailability> = ffiCatching {
nativeLib.node().notebook().noteBodyAvailability(noteId)
}
/**
* Discard a body no node has claimed and start a fresh, empty one — the way out of
* [DocumentAvailability.UNAVAILABLE], where waiting may never end. Destructive: the absent text
* is tombstoned across the ensemble, not just here.
*/
fun abandonAbsentBody(noteId: String): Result<Unit> = ffiCatching {
nativeLib.node().notebook().abandonAbsentBody(noteId)
}
/** Applies whether or not this node holds the body: the format is its own artifact. */
fun setNoteFormat(noteId: String, format: TextFormat): Result<Unit> = ffiCatching {
nativeLib.node().notebook().setNoteFormat(noteId, format)
}
fun updateNote(noteId: String, newTitle: String, newBody: String): Result<Unit> = ffiCatching {
nativeLib.node().notebook().updateNote(noteId, newTitle, newBody)
}
fun renameItem(id: String, newTitle: String): Result<Unit> = ffiCatching {
nativeLib.node().notebook().renameItem(id, newTitle)
}
fun deleteItem(id: String): Result<Unit> = ffiCatching {
nativeLib.node().notebook().deleteItem(id)
}
/**
* Which notebook/note the Notes agent should reopen to — synced across nodes (see
* `docs/adr/0018-agent-settings-documents.md`), not just remembered on this device.
*/
fun getActiveNotebook(): Result<String?> = ffiCatching {
nativeLib.node().notebook().getActiveNotebook()
}
fun setActiveNotebook(notebookId: String): Result<Unit> = ffiCatching {
nativeLib.node().notebook().setActiveNotebook(notebookId)
}
fun getActiveNote(): Result<String?> = ffiCatching {
nativeLib.node().notebook().getActiveNote()
}
fun setActiveNote(noteId: String): Result<Unit> = ffiCatching {
nativeLib.node().notebook().setActiveNote(noteId)
}
/** Edit/Preview/Split — persisted per docs/adr/0018-agent-settings-documents.md. */
fun getViewMode(): Result<NotesViewMode> = ffiCatching {
nativeLib.node().notebook().getViewMode()
}
fun setViewMode(mode: NotesViewMode): Result<Unit> = ffiCatching {
nativeLib.node().notebook().setViewMode(mode)
}
/**
* Which notation `$...$`/`$$...$$` spans use in markdown notes — see NativeLib's math section.
*/
fun getMathDialect(): Result<MathDialect> = ffiCatching {
nativeLib.node().notebook().getMathDialect()
}
fun setMathDialect(dialect: MathDialect): Result<Unit> = ffiCatching {
nativeLib.node().notebook().setMathDialect(dialect)
}
}
@@ -1,69 +0,0 @@
package com.gregshuflin.synchronicity
import uniffi.synchronicity.BookmarkInfo
/**
* Kotlin wrapper for the PDF agent's FFI surface — mirrors [NotebookAgent]'s pattern: one method
* per [uniffi.synchronicity.PdfFfi] method, each wrapped in [ffiCatching].
*
* Exposed as [NativeLib.pdf], so call sites read `nativeLib.pdf.getReadingProgress(...)` instead of
* `nativeLib.getPdfReadingProgress(...)` string-prefix pseudo-namespacing.
*/
class PdfAgent internal constructor(private val nativeLib: NativeLib) {
fun import(fileName: String, pdfBytes: ByteArray, parentId: String? = null): Result<String> =
ffiCatching {
nativeLib.node().pdf().import(fileName, pdfBytes, parentId)
}
/**
* Write a PDF's content to [outputPath], from the local blob store or streamed from a peer,
* **without ever holding the whole document in memory**. `true` on success.
*
* The only way to open a PDF from a document. mupdf's Java binding opens a *file*, and
* [com.gregshuflin.synchronicity.pdfs.MuPdfDocument.openFromBytes] gets there by writing the
* array to a temp file anyway — so carrying a PDF through a `ByteArray` buys nothing and costs
* a full copy of it on a heap typically capped near 200 MB. A 271 MB PDF cannot be opened that
* way at all; through here it opens with a 64 KiB buffer.
*
* A remote PDF reports progress as a `content_stream` job the caller should watch (see
* [JobsAgent.listActiveJobs], matching on `documentId`) and can cancel; a local one is a plain
* decrypt with no job row.
*
* [outputPath] is the caller's temporary file to place and delete. Blocking for as long as the
* peer takes; call it off the main thread.
*
* The failure is handed back rather than logged away: `Offline` (no node could be reached) and
* `NotFound` (there is no PDF content at all) call for different words and different
* affordances, and only the caller knows which of its surfaces should say them. Use
* [com.gregshuflin.synchronicity.detail] for the message and
* [com.gregshuflin.synchronicity.userRecourse] for whether to offer a retry (docs/adr/0023).
*/
fun writeToPath(documentId: String, outputPath: String): Result<Unit> = ffiCatching {
nativeLib.node().pdf().writeToPath(documentId, outputPath)
}
fun getReadingProgress(documentId: String): Result<UInt?> = ffiCatching {
nativeLib.node().pdf().getReadingProgress(documentId)
}
fun setReadingProgress(documentId: String, currentPage: UInt): Result<Unit> = ffiCatching {
nativeLib.node().pdf().setReadingProgress(documentId, currentPage)
}
fun listBookmarks(documentId: String): Result<List<BookmarkInfo>> = ffiCatching {
nativeLib.node().pdf().listBookmarks(documentId)
}
fun addBookmark(documentId: String, page: UInt, label: String?): Result<String> = ffiCatching {
nativeLib.node().pdf().addBookmark(documentId, page, label)
}
fun deleteBookmark(documentId: String, bookmarkId: String): Result<Unit> = ffiCatching {
nativeLib.node().pdf().deleteBookmark(documentId, bookmarkId)
}
private companion object {
const val TAG = "PdfAgent"
}
}
@@ -1,43 +0,0 @@
package com.gregshuflin.synchronicity
import android.util.Log
import uniffi.synchronicity.PictureTreeItem
private const val TAG = "PicturesAgent"
/**
* Kotlin wrapper for the Pictures agent's FFI surface — mirrors [ComicsAgent]'s pattern: one method
* per [uniffi.synchronicity.PicturesFfi] method, each wrapped in [ffiCatching].
*
* Exposed as [NativeLib.pictures], so call sites read `nativeLib.pictures.listTree()`.
*
* Before this existed, the Pictures screen listed image documents through [DocumentsAgent.search],
* which answers a narrower question: it returns Image documents and nothing else, so the Collection
* albums the desktop browses were invisible here, and a generic document row carries no
* availability, so a picture on another node looked exactly like one on this one.
*/
class PicturesAgent internal constructor(private val nativeLib: NativeLib) {
/**
* The full picture library: every Image and the Collection albums holding at least one, flat
* with parent links and already in display order. A consumer wanting the hierarchy groups by
* [PictureTreeItem.parentId]; one wanting a plain list of pictures keeps the leaves.
*/
fun listTree(): Result<List<PictureTreeItem>> = ffiCatching {
nativeLib.node().pictures().listTree()
}
/**
* One picture's raw (still-encoded) image bytes, from the local blob store or streamed from a
* peer that holds it (ADR 0016) — so a picture that hasn't synced here still opens. Blocking
* for as long as the peer takes; call it off the main thread.
*
* Null means the image is genuinely not obtainable right now (no reachable peer serves it, or
* P2P isn't running) — a state for the viewer to render, not a crash. Compare
* [DocumentsAgent.getBlobBytes], which is the local half alone.
*/
fun getImageBytes(imageId: String): ByteArray? =
ffiCatching { nativeLib.node().pictures().getImageBytes(imageId) }
.onFailure { Log.w(TAG, "Image $imageId not available locally or from any peer", it) }
.getOrNull()
}
@@ -1,80 +0,0 @@
package com.gregshuflin.synchronicity
import android.util.Log
import uniffi.synchronicity.PodcastEpisodeInput
import uniffi.synchronicity.PodcastEpisodeItem
import uniffi.synchronicity.PodcastItem
private const val TAG = "PodcastAgent"
/**
* Kotlin wrapper for the Podcasts agent's FFI surface — mirrors [NotebookAgent]'s pattern: one
* method per [uniffi.synchronicity.PodcastFfi] method, each wrapped in [ffiCatching].
*
* Exposed as [NativeLib.podcast], so call sites read `nativeLib.podcast.subscribe(...)` instead of
* `nativeLib.subscribeToPodcast(...)` string-prefix pseudo-namespacing.
*/
class PodcastAgent internal constructor(private val nativeLib: NativeLib) {
@Suppress("LongParameterList") // mirrors the Rust PodcastFfi::subscribe signature
fun subscribe(
id: String,
title: String,
description: String,
author: String,
coverArtUri: String?,
lastUpdated: String,
): Result<String> = ffiCatching {
nativeLib
.node()
.podcast()
.subscribe(id, title, description, author, coverArtUri, lastUpdated)
}
/**
* Search the Podcast Index.
*
* A method on the agent rather than a top-level function since ADR 0051: the API credentials
* live in the store now, so a call needs a node to unseal them from.
*/
fun search(query: String): Result<String> = ffiCatching {
nativeLib.node().podcast().search(query)
}
/** Episodes for one feed, from the Podcast Index. See [search]. */
fun fetchEpisodes(podcastId: Long, max: Long): Result<String> = ffiCatching {
nativeLib.node().podcast().fetchEpisodes(podcastId, max)
}
fun unsubscribe(documentId: String): Result<Unit> = ffiCatching {
nativeLib.node().podcast().unsubscribe(documentId)
}
fun storeCoverArt(documentId: String, imageBytes: ByteArray): Result<Unit> = ffiCatching {
nativeLib.node().podcast().storeCoverArt(documentId, imageBytes)
}
// Cover art is an optional, best-effort resource: a missing/unsynced thumbnail
// is normal, so failures collapse to null rather than a Result the UI must handle.
fun getCoverArtBytes(documentId: String): ByteArray? =
ffiCatching { nativeLib.node().podcast().getCoverArtBytes(documentId) }
.onFailure { Log.w(TAG, "Failed to load cover art for $documentId", it) }
.getOrNull()
fun saveCustomSpeed(documentId: String, speed: Float): Result<Unit> = ffiCatching {
nativeLib.node().podcast().saveCustomSpeed(documentId, speed)
}
fun listDocuments(): Result<List<PodcastItem>> = ffiCatching {
nativeLib.node().podcast().listDocuments()
}
fun listEpisodes(feedDocumentId: String): Result<List<PodcastEpisodeItem>> = ffiCatching {
nativeLib.node().podcast().listEpisodes(feedDocumentId)
}
fun saveEpisodes(feedDocumentId: String, episodes: List<PodcastEpisodeInput>): Result<UInt> =
ffiCatching {
nativeLib.node().podcast().saveEpisodes(feedDocumentId, episodes)
}
}
@@ -1,40 +0,0 @@
package com.gregshuflin.synchronicity
import uniffi.synchronicity.DocumentSyncOverride
import uniffi.synchronicity.DocumentType
import uniffi.synchronicity.RemoteDocumentSyncPolicy
import uniffi.synchronicity.SyncPolicy
/**
* Kotlin wrapper for the remote-administration FFI surface — mirrors [NotebookAgent]'s pattern: one
* method per [uniffi.synchronicity.RemoteAdminFfi] method, each wrapped in [ffiCatching].
*
* Exposed as [NativeLib.remoteAdmin], so call sites read
* `nativeLib.remoteAdmin.getSyncConfig(nodeIdHex)` instead of
* `nativeLib.getRemoteSyncConfig(nodeIdHex)` string-prefix pseudo-namespacing.
*/
class RemoteAdminAgent internal constructor(private val nativeLib: NativeLib) {
/** `nodeIdHex`'s sync policy for every content-bearing type, fetched over the ensemble. */
fun getSyncConfig(nodeIdHex: String): Result<List<RemoteDocumentSyncPolicy>> = ffiCatching {
nativeLib.node().remoteAdmin().getSyncConfig(nodeIdHex)
}
fun setSyncPolicy(
nodeIdHex: String,
docType: DocumentType,
policy: SyncPolicy,
): Result<List<RemoteDocumentSyncPolicy>> = ffiCatching {
nativeLib.node().remoteAdmin().setSyncPolicy(nodeIdHex, docType, policy)
}
// Sets (or, if value is null, clears) nodeIdHex's override for documentId. Returns
// the confirmed value straight back rather than the whole remote config.
fun setDocumentOverride(
nodeIdHex: String,
documentId: String,
value: DocumentSyncOverride?,
): Result<DocumentSyncOverride?> = ffiCatching {
nativeLib.node().remoteAdmin().setDocumentOverride(nodeIdHex, documentId, value)
}
}
@@ -1,138 +0,0 @@
package com.gregshuflin.synchronicity
import android.util.Log
import uniffi.synchronicity.DocumentSyncOverride
import uniffi.synchronicity.DocumentType
import uniffi.synchronicity.LogConfig
import uniffi.synchronicity.LogVerbosity
import uniffi.synchronicity.ProseFont
import uniffi.synchronicity.ReadingSurface
import uniffi.synchronicity.SchedulerRunSummary
import uniffi.synchronicity.SchedulerSettings
import uniffi.synchronicity.SyncPolicy
import uniffi.synchronicity.ThemeMode
private const val TAG = "SettingsAgent"
/**
* Kotlin wrapper for this node's settings FFI surface — mirrors [NotebookAgent]'s pattern: one
* method per [uniffi.synchronicity.SettingsFfi] method, each wrapped in [ffiCatching].
*
* Exposed as [NativeLib.settings], so call sites read `nativeLib.settings.getThemeMode()` instead
* of `nativeLib.getThemeMode()` string-prefix pseudo-namespacing.
*/
@Suppress("TooManyFunctions") // one method per SettingsFfi method — see NativeLib's own suppression
class SettingsAgent internal constructor(private val nativeLib: NativeLib) {
fun getPublicKeyHex(): Result<String> = ffiCatching {
nativeLib.node().settings().getPublicKeyHex()
}
// Settings reads where a null result means "unset" — a normal state, not an
// error. A native failure is logged and also collapses to null/default so the
// many synchronous call sites stay simple.
// getNodeName() itself returns String?; Result<String?>.getOrNull() flattens a
// native failure and a genuine "unset" value to the same null.
fun getNodeName(): String? =
ffiCatching { nativeLib.node().settings().getNodeName() }
.onFailure { Log.w(TAG, "Failed to read node name", it) }
.getOrNull()
fun setNodeName(name: String?): Result<Unit> = ffiCatching {
nativeLib.node().settings().setNodeName(name)
}
fun getTimezone(): String? =
ffiCatching { nativeLib.node().settings().getTimezone() }
.onFailure { Log.w(TAG, "Failed to read timezone", it) }
.getOrNull()
fun setTimezone(tz: String?): Result<Unit> = ffiCatching {
nativeLib.node().settings().setTimezone(tz)
}
fun getThemeMode(): Result<ThemeMode> = ffiCatching {
nativeLib.node().settings().getThemeMode()
}
fun setThemeMode(themeMode: ThemeMode): Result<Unit> = ffiCatching {
nativeLib.node().settings().setThemeMode(themeMode)
}
// Typeface for note body copy in the notes preview (ADR 0024). Only the
// prose face is selectable; UI and monospace are fixed.
fun getProseFont(): Result<ProseFont> = ffiCatching {
nativeLib.node().settings().getProseFont()
}
fun setProseFont(proseFont: ProseFont): Result<Unit> = ffiCatching {
nativeLib.node().settings().setProseFont(proseFont)
}
// The ground documents are read on — paper, white, or dark — independent of
// the app's chrome theme (ADR 0032 §5).
fun getReadingSurface(): Result<ReadingSurface> = ffiCatching {
nativeLib.node().settings().getReadingSurface()
}
fun setReadingSurface(readingSurface: ReadingSurface): Result<Unit> = ffiCatching {
nativeLib.node().settings().setReadingSurface(readingSurface)
}
// This node's background sync scheduler settings (master on/off, interval, Wi-Fi-only/
// charging constraints). Read/written by scheduler/SchedulerWorker.kt.
fun getSchedulerSettings(): Result<SchedulerSettings> = ffiCatching {
nativeLib.node().settings().getSchedulerSettings()
}
fun setSchedulerSettings(settings: SchedulerSettings): Result<Unit> = ffiCatching {
nativeLib.node().settings().setSchedulerSettings(settings)
}
// Run one full scheduler tick (catalogue, catalogue-fetch, content passes) against
// every reachable ensemble peer. Backs scheduler/SchedulerWorker.kt; requires P2P to
// be running.
fun runSchedulerPasses(): Result<SchedulerRunSummary> = ffiCatching {
nativeLib.node().settings().runSchedulerPasses()
}
/** This node's sync policy for every document type, in `DocumentType` order. */
fun getSyncPolicies(): Result<List<uniffi.synchronicity.DocumentSyncPolicy>> = ffiCatching {
nativeLib.node().settings().getSyncPolicies()
}
fun setSyncPolicy(docType: DocumentType, policy: SyncPolicy): Result<Unit> = ffiCatching {
nativeLib.node().settings().setSyncPolicy(docType, policy)
}
/**
* This node's Pin/Exclude override for a document, or null if it follows this node's sync
* policy for the document's type. Backs the document-detail "Keep here" picker.
*/
fun getDocumentSyncOverride(documentId: String): Result<DocumentSyncOverride?> = ffiCatching {
nativeLib.node().settings().getDocumentSyncOverride(documentId)
}
fun setDocumentSyncOverride(documentId: String, value: DocumentSyncOverride): Result<Unit> =
ffiCatching {
nativeLib.node().settings().setDocumentSyncOverride(documentId, value)
}
/** Clears a document's override, reverting it to following its type's sync policy. */
fun clearDocumentSyncOverride(documentId: String): Result<Unit> = ffiCatching {
nativeLib.node().settings().clearDocumentSyncOverride(documentId)
}
fun getLogConfig(): Result<LogConfig> = ffiCatching {
nativeLib.node().settings().getLogConfig()
}
/** Persist the log config and immediately apply it to the running logger. */
fun setLogConfig(verbosity: LogVerbosity, showIrohLogs: Boolean): Result<Unit> =
ffiCatching { nativeLib.node().settings().setLogConfig(verbosity, showIrohLogs) }
.also { result ->
if (result.isSuccess) {
uniffi.synchronicity.updateAndroidLogFilter(verbosity, showIrohLogs)
}
}
}
@@ -2,8 +2,10 @@ package com.gregshuflin.synchronicity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
@@ -31,7 +33,7 @@ private const val RETRY_DELAY_MS = 3_000L
/**
* How long a wake lets the rest of a burst land before it is reported.
*
* A catalogue sync commits a chunk of documents per transaction and does it repeatedly, so a screen
* A catalog sync commits a chunk of documents per transaction and does it repeatedly, so a screen
* told about each one would spend the sync rebuilding itself. One report per window bounds that at
* a handful a second while a sync runs, and costs nothing while one isn't: this is a settle
* interval, not a poll — nothing wakes unless something already happened.
@@ -71,11 +73,11 @@ object StoreChanges {
* The latest cursor, or `null` until the first one has been read. Screens collect this through
* [OnStoreChange] rather than directly.
*/
fun cursor(nativeLib: NativeLib): StateFlow<ChangeCursor?> =
fun cursor(): StateFlow<ChangeCursor?> =
shared
?: synchronized(lock) {
shared
?: watch(nativeLib)
?: watch()
.stateIn(scope, SharingStarted.WhileSubscribed(LINGER_MS), null)
.also { shared = it }
}
@@ -86,18 +88,20 @@ object StoreChanges {
* The first emission is the cursor as of starting, which is what the screens are already at —
* so [OnStoreChange] never treats it as a change and nothing reloads what it just read.
*/
private fun watch(nativeLib: NativeLib): Flow<ChangeCursor> = flow {
var since = readCursor(nativeLib)
private fun watch(): Flow<ChangeCursor> = flow {
var since = readCursor()
emit(since)
while (currentCoroutineContext().isActive) {
val woken =
withContext(Dispatchers.IO) { nativeLib.changes.wait(since, WAIT_TIMEOUT_MS) }
withContext(Dispatchers.IO) {
NativeLib.ffi { changes().wait(since, WAIT_TIMEOUT_MS.toULong()) }
}
val next = woken.getOrNull()
if (next == null) {
delay(RETRY_DELAY_MS)
continue
}
since = settle(nativeLib, next)
since = settle(next)
emit(since)
}
}
@@ -109,9 +113,10 @@ object StoreChanges {
* quiet, and a screen that stopped updating until it did would be worse than one updating a few
* times a second. A read that fails here just reports what woke us.
*/
private suspend fun settle(nativeLib: NativeLib, woken: ChangeCursor): ChangeCursor {
private suspend fun settle(woken: ChangeCursor): ChangeCursor {
delay(COALESCE_MS)
return withContext(Dispatchers.IO) { nativeLib.changes.cursor() }.getOrDefault(woken)
return withContext(Dispatchers.IO) { NativeLib.ffi { changes().cursor() } }
.getOrDefault(woken)
}
/**
@@ -120,9 +125,10 @@ object StoreChanges {
* A screen can be composed before onboarding has opened a store, and the answer to that is to
* wait for one rather than to leave this collector with no signal for as long as it lives.
*/
private suspend fun readCursor(nativeLib: NativeLib): ChangeCursor {
private suspend fun readCursor(): ChangeCursor {
while (true) {
val cursor = withContext(Dispatchers.IO) { nativeLib.changes.cursor() }.getOrNull()
val cursor =
withContext(Dispatchers.IO) { NativeLib.ffi { changes().cursor() } }.getOrNull()
if (cursor != null) return cursor
delay(RETRY_DELAY_MS)
}
@@ -142,12 +148,8 @@ object StoreChanges {
* the user is pointing at is alerted, not rewritten underneath them.
*/
@Composable
fun OnStoreChange(
nativeLib: NativeLib,
vararg channels: ChangeChannel,
onChange: suspend () -> Unit,
) {
val cursor by StoreChanges.cursor(nativeLib).collectAsState()
fun OnStoreChange(vararg channels: ChangeChannel, onChange: suspend () -> Unit) {
val cursor by StoreChanges.cursor().collectAsState()
val applied = remember { mutableStateOf<ChangeCursor?>(null) }
// The effect restarts on a new cursor, not on a new lambda, so a screen whose reload closes
// over state that has since moved — "is this list settled?" — would otherwise act on the
@@ -163,6 +165,21 @@ fun OnStoreChange(
}
}
/**
* A counter that ticks whenever one of [channels] changes while this screen is composed — the key a
* screen's load effect restarts on. The screen may bump it too, after a write of its own.
*
* A document arriving from another node, or an import finishing here, belongs in the list on screen
* — not in the one the user will see next time they open this. Being [OnStoreChange] underneath,
* the watch is scoped to composition, so a screen off the back stack re-reads nothing.
*/
@Composable
fun rememberReloadKey(vararg channels: ChangeChannel): MutableIntState {
val key = remember { mutableIntStateOf(0) }
OnStoreChange(*channels) { key.intValue++ }
return key
}
/**
* Whether [channel]'s counter moved between [previous] and this cursor.
*
@@ -2,7 +2,6 @@ package com.gregshuflin.synchronicity
import uniffi.synchronicity.Recourse
import uniffi.synchronicity.SyncException
import uniffi.synchronicity.recourse
/**
* The failure text to show a user or write to a log, without uniffi's generated `message` prefix.
@@ -14,7 +13,7 @@ import uniffi.synchronicity.recourse
* The `when` is exhaustive with no `else` because [SyncException] is a sealed hierarchy — this is
* the part of Rust's `Result<T, SyncError>` that survives the trip through `kotlin.Result`, whose
* error slot is statically only `Throwable`. When the Rust `SyncError` enum grows a variant, uniffi
* regenerates the sealed class and this stops compiling, which is the intended behaviour: see
* regenerates the sealed class and this stops compiling, which is the intended behavior: see
* docs/adr/0022 and docs/adr/0023.
*/
val SyncException.detail: String
@@ -26,20 +25,23 @@ val SyncException.detail: String
is SyncException.Offline -> msg
is SyncException.InvalidInput -> msg
is SyncException.PreconditionUnmet -> msg
is SyncException.Cancelled -> msg
is SyncException.Canceled -> msg
is SyncException.Internal -> msg
}
/**
* The text to show a user for any failure: [detail] for a [SyncException], else the throwable's own
* message. Use this rather than `message`, which carries uniffi's `msg=` prefix (see [detail]).
*/
val Throwable.userMessage: String
get() = (this as? SyncException)?.detail ?: message ?: toString()
/**
* What the user can do about this failure — the shared Rust-side policy (docs/adr/0023), not a
* per-frontend guess. `gui-app` and the TUI resolve the same failure the same way.
*
* Delegates to the exported free function rather than a method: uniffi generates error enums as
* plain classes with no methods, so the classification round-trips through the FFI. That costs one
* small buffer, at error frequency.
*/
val SyncException.userRecourse: Recourse
get() = recourse(this)
get() = recourse()
/**
* Whether a frontend should offer an immediate "Try again" affordance.
@@ -50,12 +52,3 @@ val SyncException.userRecourse: Recourse
*/
val SyncException.isRetryableNow: Boolean
get() = userRecourse == Recourse.RETRY_NOW
/**
* Whether the thing being acted on is simply gone (deleted here or on another node).
*
* Callers should refresh the offending view and drop the stale item rather than reporting an error
* at all — the operation failed, but nothing is wrong.
*/
val SyncException.isVanished: Boolean
get() = userRecourse == Recourse.VANISHED
@@ -0,0 +1,22 @@
package com.gregshuflin.synchronicity
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import com.gregshuflin.synchronicity.covers.DocumentCover
import com.gregshuflin.synchronicity.covers.DocumentCoverFetcher
import com.gregshuflin.synchronicity.covers.DocumentCoverKeyer
/**
* Exists to give Coil one app-wide [ImageLoader] that knows how to load a [DocumentCover], so the
* library screens' covers share one memory cache with each other and with the podcast art.
*/
class SynchronicityApplication : Application(), ImageLoaderFactory {
override fun newImageLoader(): ImageLoader =
ImageLoader.Builder(this)
.components {
add(DocumentCoverKeyer())
add(DocumentCoverFetcher.Factory())
}
.build()
}
@@ -0,0 +1,85 @@
package com.gregshuflin.synchronicity.clippings
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.gregshuflin.synchronicity.ui.IncomingBody
import com.gregshuflin.synchronicity.ui.IncomingBodyBuffer
/**
* The clipping the user has open in an editor, and everything unsaved about it.
*
* A clipping's text is a whole-buffer, last-write-wins artifact, so an editable buffer over one is
* exactly the case AGENTS.md's first design principle is about: a version arriving from another
* node is announced and held — the buffer is left exactly as the user left it — and nothing is
* written until they say which version they want. [IncomingBody.alertFor] is the rule that decides
* which reads are news and which are this node's own save coming back; this type is the state that
* rule runs against.
*
* A ViewModel rather than `remember`, for the reason `NotesEditorSession` is one: a decision that
* vanishes when the user glances at another screen — or turns the phone — is not a decision they
* were really given. Everything else on the Clippings screen (the loaded page, the composer, what
* is expanded) stays in `remember`, because it is cheap to rebuild and means nothing once you have
* left.
*
* One clipping at a time. Two open buffers would be two unresolved decisions sharing one strip.
*/
class ClippingEditSession : ViewModel() {
/** The clipping open in the editor, or `null` when none is. */
var clippingId by mutableStateOf<String?>(null)
private set
/** The editor's text, its store baseline, and any version held from another node. */
val buffer = IncomingBodyBuffer()
/** What is in the editor — this node's unsaved work, and the thing being protected. */
var text by buffer::text
/** Text for this clipping that this node did not write. Held, never applied. */
val incoming: String?
get() = buffer.incoming
/**
* What went wrong with the last attempt to save, shown on the editor. A save that failed must
* not also lose what it was given, so the buffer is left alone and this says why.
*/
var error by mutableStateOf<String?>(null)
/** Whether the editor is open on `id`. */
fun isEditing(id: String): Boolean = clippingId == id
/** Open an editor over `stored`, which is what the store holds for `id` right now. */
fun open(id: String, stored: String) {
clippingId = id
buffer.reset(stored)
error = null
}
/** Close the editor, discarding whatever is in it. */
fun close() {
clippingId = null
buffer.reset("")
error = null
}
/**
* A fresh read of the store, for whichever clipping is open; see [IncomingBodyBuffer.observe].
*/
fun observe(stored: String) = buffer.observe(stored)
/** Show the version that arrived; see [IncomingBodyBuffer.loadIncoming]. */
fun loadIncoming() {
if (buffer.loadIncoming()) error = null
}
/** Keep this node's text over theirs; see [IncomingBodyBuffer.keepLocal] for the ordering. */
fun keepLocal(): String? = buffer.keepLocal()
/** The store now holds `written`, so a re-read finding it is the write completing, not news. */
fun saved(written: String) {
buffer.saved(written)
error = null
}
}
@@ -1,6 +1,7 @@
package com.gregshuflin.synchronicity.clippings
import android.content.ClipData
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -40,8 +41,11 @@ import com.gregshuflin.synchronicity.NativeLib
import com.gregshuflin.synchronicity.OnStoreChange
import com.gregshuflin.synchronicity.documents.ui.formatDate
import com.gregshuflin.synchronicity.ui.ChangedElsewhereStrip
import com.gregshuflin.synchronicity.ui.IncomingBodyStrip
import com.gregshuflin.synchronicity.ui.SharedCopy
import com.gregshuflin.synchronicity.ui.SubAppScreen
import com.gregshuflin.synchronicity.ui.actions
import com.gregshuflin.synchronicity.ui.activityViewModel
import com.gregshuflin.synchronicity.userMessage
import kotlinx.coroutines.launch
import uniffi.synchronicity.ChangeChannel
import uniffi.synchronicity.ClippingItem
@@ -52,7 +56,11 @@ import uniffi.synchronicity.clippingBodyElsewhere
import uniffi.synchronicity.clippingDeleteFailed
import uniffi.synchronicity.clippingPending
import uniffi.synchronicity.clippingReadFailed
import uniffi.synchronicity.clippingUpdateFailed
import uniffi.synchronicity.clippingsCopy
import uniffi.synchronicity.incomingClippingChangeCopy
private const val TAG = "ClippingsScreen"
private val SCREEN_PADDING = 16.dp
@@ -74,10 +82,15 @@ private const val PREFETCH_ROWS = 10
* title of it — the title *is* its first line), same read-only stance, and its words come from
* `copy::clippings` so the two cannot drift apart.
*
* **Existing clippings are read-only here too.** Editing one needs the alert-don't-apply flow
* around an editable buffer (CLAUDE.md; ADR 0031), because the body is last-write-wins and an
* unguarded editor would silently lose one side. The composer has no such problem: nothing can
* arrive for a clipping that does not exist yet.
* **A clipping can be edited in place**, one at a time, and the buffer carries the whole
* alert-don't-apply flow around it (AGENTS.md; ADR 0031): the body is last-write-wins, so a version
* arriving from another node is announced on the card and held, and nothing is written until the
* user says which one they want. [ClippingEditSession] is that state, and it outlives a navigation
* and a rotation on purpose. The composer has no such problem: nothing can arrive for a clipping
* that does not exist yet.
*
* Only the text is editable — `source_url` is written `Immutable` (ADR 0039), and the title is the
* first line of the text — so there is no second field and nothing for Save to batch.
*
* Clippings arrive while the screen is open — from a browser on a desktop node, or from any node's
* sync — so it watches the store rather than only reading on navigation. What it does with an
@@ -88,10 +101,11 @@ private const val PREFETCH_ROWS = 10
* is appended, never spliced in at the top, so nothing under the user's finger moves.
*/
@Composable
fun ClippingsScreen(nativeLib: NativeLib, onBack: () -> Unit, modifier: Modifier = Modifier) {
fun ClippingsScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
val copy = remember { clippingsCopy() }
val timezone = remember { nativeLib.settings.getTimezone() }
val timezone = remember { NativeLib.timezone() }
val listState = rememberLazyListState()
val session = activityViewModel<ClippingEditSession>()
var clippings by remember { mutableStateOf<List<ClippingItem>>(emptyList()) }
// What a watch has read but not shown, when showing it would have disrupted something. The
@@ -108,11 +122,13 @@ fun ClippingsScreen(nativeLib: NativeLib, onBack: () -> Unit, modifier: Modifier
var loadingMore by remember { mutableStateOf(false) }
// Whether the user is plainly not in the middle of reading something. Prepending to a list is
// disruptive while they are: every row below shifts by one. The desktop asks the same three
// questions (`ClippingsView::settled`).
// disruptive while they are: every row below shifts by one. The desktop asks the same
// questions (`ClippingsView::settled`), and an open editor is the strongest of them — a card
// being typed into is the one thing on this screen that must not move.
val settled =
expandedId == null &&
!composing &&
session.clippingId == null &&
listState.firstVisibleItemIndex == 0 &&
listState.firstVisibleItemScrollOffset == 0
@@ -120,8 +136,7 @@ fun ClippingsScreen(nativeLib: NativeLib, onBack: () -> Unit, modifier: Modifier
// settled, so the rows they can see are the rows they get) or is held aside; either way the
// list re-anchors at the top and pages down again from there.
fun load(intoView: Boolean) {
nativeLib.clippings
.listPage(null)
NativeLib.ffi { clippings().listPage(null) }
.onSuccess { page ->
error = null
if (intoView) {
@@ -132,27 +147,66 @@ fun ClippingsScreen(nativeLib: NativeLib, onBack: () -> Unit, modifier: Modifier
held = page
}
}
.onFailure { error = clippingReadFailed(it.message ?: "unknown error") }
.onFailure { error = clippingReadFailed(it.userMessage) }
}
fun loadMore() {
val cursor = nextCursor ?: return
if (loadingMore) return
loadingMore = true
nativeLib.clippings
.listPage(cursor)
NativeLib.ffi { clippings().listPage(cursor) }
.onSuccess { page ->
clippings = clippings + page.items
nextCursor = page.nextCursor
error = null
}
.onFailure { error = clippingReadFailed(it.message ?: "unknown error") }
.onFailure { error = clippingReadFailed(it.userMessage) }
loadingMore = false
}
// Put one row back from the store without disturbing the rows around it — the answer to a
// save, where reloading the whole list would re-anchor it under an editor that is still open.
// Re-read rather than assembled here: `update` derives the title from the text, so a row built
// locally would disagree with the store about it.
//
// A failure here is logged and no more: the write it follows has already landed, and the
// change signal that write set off re-reads the list anyway, so the worst case is one stale
// card for a coalescing interval.
fun refreshRow(id: String) {
NativeLib.ffi { clippings().get(id) }
.onSuccess { updated ->
if (updated != null) {
clippings = clippings.map { if (it.id == id) updated else it }
}
}
.onFailure { Log.w(TAG, "Failed to re-read clipping $id after saving it", it) }
}
// Write the buffer, and report a failure on the editor rather than losing what it holds.
fun write(id: String, text: String, thenClose: Boolean) {
NativeLib.ffi { clippings().update(id, text) }
.onSuccess {
if (thenClose) session.close() else session.saved(text)
refreshRow(id)
}
.onFailure { session.error = clippingUpdateFailed(it.userMessage) }
}
// Navigating to the screen is an explicit act, so the first read applies outright.
LaunchedEffect(reloadToken) { load(intoView = true) }
OnStoreChange(nativeLib, ChangeChannel.DOCUMENTS) { load(intoView = settled) }
OnStoreChange(ChangeChannel.DOCUMENTS) {
// The open editor first: it is what the user is actually in, and the list's own answer to
// the same signal may hold rows back on account of it.
val openId = session.clippingId
if (openId != null) {
val stored = NativeLib.ffi { clippings().get(openId) }.getOrNull()?.text
// Gone, unreadable, or its body is no longer held here: none of those is a version to
// offer the user a choice between. Nor is an answer about a clipping the editor has
// since moved off.
if (stored != null && session.clippingId == openId) session.observe(stored)
}
load(intoView = settled)
}
// Fetch ahead of the edge rather than at it: asking when the last row appears means the user
// watches a gap while the read runs. Keyed on the last visible index so it fires once per
@@ -172,7 +226,8 @@ fun ClippingsScreen(nativeLib: NativeLib, onBack: () -> Unit, modifier: Modifier
IconButton(onClick = { composing = !composing }) {
Icon(
imageVector = if (composing) Icons.Default.Close else Icons.Default.Add,
contentDescription = if (composing) actions().cancel else copy.newClipping,
contentDescription =
if (composing) SharedCopy.actions.cancel else copy.newClipping,
)
}
},
@@ -207,20 +262,36 @@ fun ClippingsScreen(nativeLib: NativeLib, onBack: () -> Unit, modifier: Modifier
Composer(
copy = copy,
onKeep = { text, sourceUrl ->
nativeLib.clippings
.capture(text, sourceUrl, null)
NativeLib.ffi { clippings().capture(text, sourceUrl, null) }
.onSuccess {
composing = false
reloadToken++
}
// Left open, with the text still in it: a capture that failed must not
// also lose what it was given.
.onFailure { error = it.message }
.onFailure { error = it.userMessage }
},
onDiscard = { composing = false },
)
}
// The open editor, when the clipping it belongs to is not among the loaded rows — deleted
// on another node, or pushed off the first page by arrivals the user asked to see. An
// editor with unsaved text in it, or an unanswered version held against it, must not
// become invisible because the list moved: the decision would still be outstanding and
// there would be nowhere to make it. The desktop keeps the same invariant.
val detached = session.clippingId?.takeIf { open -> clippings.none { it.id == open } }
if (detached != null) {
ClippingEditor(
session = session,
copy = copy,
onSave = { write(detached, session.text, thenClose = true) },
onKeepMine = {
session.keepLocal()?.let { mine -> write(detached, mine, thenClose = false) }
},
)
}
if (clippings.isEmpty()) {
Text(
text = copy.empty,
@@ -235,23 +306,40 @@ fun ClippingsScreen(nativeLib: NativeLib, onBack: () -> Unit, modifier: Modifier
verticalArrangement = Arrangement.spacedBy(GAP),
) {
items(clippings, key = { it.id }) { clipping ->
ClippingCard(
clipping = clipping,
copy = copy,
timezone = timezone,
expanded = expandedId == clipping.id,
onToggle = {
expandedId = if (expandedId == clipping.id) null else clipping.id
},
onDelete = {
nativeLib.clippings
.delete(clipping.id)
.onSuccess { reloadToken++ }
.onFailure {
error = clippingDeleteFailed(it.message ?: "unknown error")
if (session.isEditing(clipping.id)) {
ClippingEditor(
session = session,
copy = copy,
onSave = { write(clipping.id, session.text, thenClose = true) },
onKeepMine = {
session.keepLocal()?.let { mine ->
write(clipping.id, mine, thenClose = false)
}
},
)
},
)
} else {
ClippingCard(
clipping = clipping,
copy = copy,
timezone = timezone,
expanded = expandedId == clipping.id,
// Offered only on a clipping whose text this node holds: editing a
// body that lives elsewhere would write a second version of text this
// node has never seen. One at a time, so the rest go inert.
onEdit =
clipping.text
?.takeIf { session.clippingId == null }
?.let { body -> { session.open(clipping.id, body) } },
onToggle = {
expandedId = if (expandedId == clipping.id) null else clipping.id
},
onDelete = {
NativeLib.ffi { clippings().delete(clipping.id) }
.onSuccess { reloadToken++ }
.onFailure { error = clippingDeleteFailed(it.userMessage) }
},
)
}
}
}
}
@@ -273,7 +361,7 @@ private fun Composer(
) {
var text by remember { mutableStateOf("") }
var sourceUrl by remember { mutableStateOf("") }
val labels = actions()
val labels = SharedCopy.actions
Card(modifier = Modifier.fillMaxWidth().padding(SCREEN_PADDING)) {
Column(
@@ -307,6 +395,68 @@ private fun Composer(
}
}
/**
* A clipping's text, open for editing: the box, whatever a sync is holding against it, and the way
* out.
*
* Only the text, because only the text is a field of this record that a later edit revises — the
* source URL is `Immutable` and the title is the first line of the text. So AGENTS.md's "the mode
* is the whole screen" rule has nothing here to batch behind Save, and Cancel has nothing to back
* out of but the buffer.
*
* Cancel is also the answer to a version that arrived and was never resolved: the store holds
* theirs, this node wrote nothing, so backing out takes theirs — which is what the strip said would
* happen.
*/
@Composable
private fun ClippingEditor(
session: ClippingEditSession,
copy: ClippingsCopy,
onSave: () -> Unit,
onKeepMine: () -> Unit,
) {
val labels = SharedCopy.actions
val pending = session.incoming != null
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(CARD_PADDING),
verticalArrangement = Arrangement.spacedBy(GAP),
) {
OutlinedTextField(
value = session.text,
onValueChange = { session.text = it },
placeholder = { Text(copy.editPlaceholder) },
minLines = COLLAPSED_LINES,
modifier = Modifier.fillMaxWidth(),
)
if (pending) {
IncomingBodyStrip(
copy = remember { incomingClippingChangeCopy() },
onLoadTheirs = { session.loadIncoming() },
onKeepMine = onKeepMine,
)
}
session.error?.let { message ->
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(GAP)) {
// Inert while a version from another node is unresolved: saving through it would
// overwrite their edit without ever saying so. The strip directly above is where
// that write is offered, with what it costs written on it.
TextButton(onClick = onSave, enabled = session.text.isNotBlank() && !pending) {
Text(labels.save)
}
TextButton(onClick = { session.close() }) { Text(labels.cancel) }
}
}
}
}
/** One clipping: the text itself, where it came from, when it was kept, and what can be done. */
@Composable
private fun ClippingCard(
@@ -314,12 +464,14 @@ private fun ClippingCard(
copy: ClippingsCopy,
timezone: String?,
expanded: Boolean,
/** `null` when this clipping cannot be edited here, or another one already is. */
onEdit: (() -> Unit)?,
onToggle: () -> Unit,
onDelete: () -> Unit,
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val labels = actions()
val labels = SharedCopy.actions
val body = clipping.text
Card(modifier = Modifier.fillMaxWidth()) {
@@ -373,6 +525,9 @@ private fun ClippingCard(
Text(copy.copyAction)
}
}
if (onEdit != null) {
TextButton(onClick = onEdit) { Text(labels.edit) }
}
TextButton(onClick = onDelete) {
Text(labels.delete, color = MaterialTheme.colorScheme.error)
}
@@ -1,35 +0,0 @@
package com.gregshuflin.synchronicity.comics
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.gregshuflin.synchronicity.ui.IMPORT_ICON
import com.gregshuflin.synchronicity.ui.IconButton
/**
* Import button for a `.cbz` comic archive. A `.cbz` is a ZIP under the hood and Android's picker
* has no reliable MIME type for it, so this opens the generic any-file document picker and leaves
* validation to the importer, which surfaces a clear error if the pick isn't a real comic archive.
*/
@Composable
fun ComicFilePicker(
onFileSelect: (Uri) -> Unit,
modifier: Modifier = Modifier,
text: String = "Import comic (.cbz)",
) {
val filePickerLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) {
uri: Uri? ->
uri?.let { onFileSelect(it) }
}
IconButton(
modifier = modifier,
onClick = { filePickerLauncher.launch("*/*") },
icon = IMPORT_ICON,
text = text,
iconContentDescription = text,
)
}