Commit Graph
100 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 ef839ba0cd The store's layout names live in the core: local::DB_FILE, BLOBS_DIR
"inkwell.db" and "blobs" were spelled out in the ffi and the desktop (whose copy
of DB_FILE sat in the crossover shim). The layout is the core's, the same on
every client, so the names are now core constants and both clients read them.

DRY pass #2, batch 1, F4 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:17:26 -04:00
bvandeusenandClaude Opus 5.5 1f2ddd70d3 Unlinking a device is one flow in the core: link::unlink
The desktop's sync_unlink and the ffi's unlink were both written out in full: try
the revoke, clear the link either way, and log the outcome. link::unlink(db, held)
now does that. Each client reads its link with state::credentials (with its seal)
before the await and passes it in.

DRY pass #2, batch 1, F3 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:16:59 -04:00
bvandeusenandClaude Opus 5.5 91c47245ab Linking a device is one flow in the core: sync::link
The desktop's sync_link and the ffi's link_with_password/link_with_token were
the same steps written out twice: probe, refuse an incompatible server before
any credential is sent, log in or verify a pasted token, keep the link, and adopt
the server's trash retention. link::authenticate(url, Credential) does the
network half and link::store(conn, ..., seal) keeps it, sealed when the client
has a seal. Each client now only reads its input and picks its seal.

The desktop checks for a missing email/password before probing rather than after.
Same error, sooner.

DRY pass #2, batch 1, F2 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:16:53 -04:00
bvandeusenandClaude Opus 5.5 70274347af One read of a device's link: state::credentials, with the client's seal
Five places read the server address and token straight from sync_state:
sharing, autosync, sync_unlink, update's download token, and the ffi. Reading it
raw is how Android came to send its sealed token to the share routes (#5381).
state::credentials(conn, seal) now holds that read. With a seal it opens the token
(open_token), and without one (the desktop keeps it plain) it returns it as stored.
Every site calls it.

DRY pass #2, batch 1, F1 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:14:18 -04:00
bvandeusenandClaude Opus 5.5 17ae8c0863 A failed update install says the install failed, not the check
installUpdate, and a failed channel or source switch, all fell back to "The
update check failed." Each now names what failed. A check that runs after a
successful switch keeps its own message.

Fixes #5387.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:12:13 -04:00
bvandeusenandClaude Opus 5.5 1173c1cf12 A device's snooze is clamped to the server's range, 1 minute to 30 days
The server clamped; the core took any i64, so 0 or less set a reminder in the past
and a huge value overflowed Duration::minutes. Every caller passes 60 or 1440
today, so this was latent. Both sides now name the range, SNOOZE_MAX_MINUTES,
and point at each other.

Fixes #5386.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:11:53 -04:00
bvandeusenandClaude Opus 5.5 7259708f28 Session length, trash retention and attachment size have bounds
Each accepted any integer. A session length of 0 expired every session at once,
the admin's own included; an attachment limit above the 64 MB body ceiling
allowed files no request could carry, and a negative one refused every upload.

- session_ttl_days 1..3650, trash_retention_days 0..3650 (0 = keep), and
  max_attachment_mb 1..MAX_BODY_MB-1, leaving room for the multipart envelope.
- MAX_BODY_MB is the one number app.py's MAX_CONTENT_LENGTH and that maximum
  both read.
- A value stored before its bounds existed reads as the nearest bound.

Fixes #5384.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:10:28 -04:00
bvandeusenandClaude Opus 5.5 bfab8746ad Opening a purged note, or one of its files, is a 404
get_note and get_attachment selected with the ACL inline and skipped the purged
filter, so a tombstone came back 200 (#2128 says a purged note reads as absent).
Both now go through _get_visible, which cannot skip it. Reorder's batch lookup
gains the same filter, so a stale id cannot write a place onto a tombstone.

Fixes #5383.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:09:40 -04:00
bvandeusenandClaude Opus 5.5 8eff5f60a6 A deleted tag is a tombstone on every path, and the web skips tombstones
The web deleted a tag's row outright (delete, and merge's source), so the change
feed never carried it and linked devices kept the tag. A device's delete left a
tombstone that the web still listed, matched by name on create and rename, minted
#tags onto, and accepted in a picker.

- labeling.tombstone_label is the one way a tag is deleted: drop its links, set
  purged_at. REST delete, merge and sync's op=delete all use it.
- labeling.live(owner) is the one definition of a tag that exists; every catalog
  read uses it (list, lookup, create/rename matching, #tag minting, picker ids,
  export, and sync's name-clash check).
- 0040: (owner_id, name) is unique among live tags only, so a tombstone gives its
  name back and #grocery can be made again, on the web or from a device.

Fixes #5382.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:08:54 -04:00
bvandeusenandClaude Opus 5.5 be4897276c Android sharing sends the opened device token, not the sealed one
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Web typecheck and unit tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 34s
CI & Build / integration (push) Successful in 1m39s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m58s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m11s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m17s
Android / Build the server image (push) Successful in 1s
Android has stored its device token sealed ("sealed:…") since 8592b83, and
core's sharing calls read the token from the store themselves. The ffi opened
it in credentials() and then threw the result away, so every Share-sheet
request went out as `Bearer sealed:…` and the server refused it.

The sharing functions now take the server address and token from the caller.
The ffi passes what credentials() opened; the desktop, which stores its token
plain, reads it through sharing::stored_link. A new ffi test serves one request
on a loopback port and checks the bearer token that arrives (#5381).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:05:10 -04:00
bvandeusenandClaude Opus 5.5 39b1ebae96 Settings → Activity: an audit log of what happened to accounts
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m27s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m47s
CI & Build / Build & push image (push) Successful in 45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m4s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Sign-ins and failed sign-ins, accounts created and sign-ups refused,
password changes, resets and reset links, devices linked and unlinked,
invites made and revoked. Each is kept in `audit_events` with the address
it came from, for `audit_retention_days` (Settings → Security, 90 by
default, 0 keeps them forever), and listed newest first for admins under
Settings → Activity. The retention loop deletes older events.

`audit.record` writes in its own session, so a refusal is kept even when
the request's transaction rolls back. A failure to record is logged and
swallowed, never the reason a sign-in fails. A throttled attempt (429) is
not recorded: a row per refused request would make each request in a
flood cost a database write. Throttle trips stay in the app log.

Also: the storage-limit test puts `storage_quota_gb` back afterwards,
since settings outlive the per-test truncate.

#2939 §5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:19:41 -04:00
bvandeusenandClaude Opus 5.5 043c87a8dc Each account may store 5 GB of attachments; admins have no limit
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m33s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m12s
CI & Build / Build & push image (push) Successful in 55s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m43s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m32s
Desktop (Tauri) / Update manifest (push) Successful in 5s
#2939 §4. max_attachment_mb capped one file, so any account could fill the
volume. The new Settings → Attachments → storage_quota_gb (default 5, 0 for
no limit) caps an account's total. That total is every attachment on every
note the account owns, trash included, since trashed files stay on disk until
emptied. Admins are exempt.

storage.upload_refusal is now the one check every upload makes: per file, then
per account. It is used by:
- the web upload route;
- the sync PUT, which judges the declared Content-Length before reading the
  bytes;
- the importer, which learns the room left up front and refuses the whole
  archive if its attachments don't fit (nothing is committed).

Over the limit is answered 507 Insufficient Storage, not 413. The core treats
a 4xx as a permanent refusal it never retries, and a 5xx as worth another try.
So a file refused for want of room syncs by itself once space is freed. The
cost is that an over-limit device re-sends that file each cycle until then.

GET /api/auth/storage returns used and limit, and the Account page shows it as
a Storage row ("1.2 GB of 5 GB used").

docs/public-hosting.md drops the quota gap and gains a section on the limit.
Its Android paragraph still said a public http:// address was only warned
about; since 1dd6fc1 it is refused, and the paragraph now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:12:26 -04:00
bvandeusenandClaude Opus 5.5 4b4659157e Retire the bridge to the old dev channel release
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 18s
CI & Build / Web typecheck and unit tests (push) Successful in 14s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / integration (push) Successful in 1m33s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m55s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m51s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Scribe #3884. The dev channel's release tag moved from `dev` to `dev-rolling`
on 2026-09-10. Since then, the manifest job has also written latest.json to the
old `dev` release, so that desktop apps installed before the move could update
across. The operator has had two desktop installs and is fine reinstalling,
so the bridge goes. The old release and tag are deleted next, through the forge.

- desktop.yml: the BRIDGE_TAG=dev export is removed.
- write-manifest.sh: the TEMPORARY bridge block is removed.
- ci-requirements.md: the "Transitional" paragraph becomes a note that the tag
  is gone, and that an app installed before 2026-09-10 reinstalls with
  install.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:46:37 -04:00
bvandeusenandClaude Opus 5.5 8592b83538 android: the device token is stored sealed under a Keystore key
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 20s
CI & Build / Python tests (push) Successful in 20s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 1m12s
CI & Build / integration (push) Successful in 1m44s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m17s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m27s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m15s
Android / Build the server image (push) Successful in 1s
Family idea #5105, practice 12, as the operator chose on 2026-10-08: the token
is encrypted, and Android backup stays on.

The core:
- Adds a TokenSeal trait in sync/state.rs, with set_sealed_link and
  open_token.
- A sealed token is stored as "sealed:<value>".
- A plain token, stored before this change or while sealing failed, is sealed
  in place on its next read.
- A sealed token that won't open is dropped, and the server address and cursor
  are kept, so the app reads as unlinked and asks to sign in again. That is
  what happens after Android restores the app onto another phone.
- The desktop passes no seal and keeps storing the token as before.

The FFI:
- Exports TokenSeal as a uniffi foreign trait (seal_token / open_token, null
  rather than an exception).
- Requires it in Inkwell's constructor, so there is no moment a token could be
  stored unsealed.
- Routes credentials(), unlink() and store_link() through it.

Kotlin:
- KeystoreTokenSeal is AES-GCM under an Android Keystore key, using the
  SealedBox framing from Minstrel's KeystoreSessionVault (Scribe snippet #5025),
  with no new dependency.
- SealedBoxTest checks the framing on the JVM.

allowBackup stays true, and the manifest says why. An unlinked phone's notes
exist only on the phone, and the backup is their one other copy. The backup
carries a token nothing can open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:23:22 -04:00
bvandeusenandClaude Opus 5.5 bb591871a4 Account page: change your password, or sign out everywhere else
Family idea #5105, practice 4. Either action signs the account out of every
other browser and unlinks every device. The browser that made the change stays
signed in.

- POST /api/auth/password needs the current password. A wrong one returns 403,
  not 401, so this browser doesn't read as signed out, and it counts against the
  sign-in throttle. A short new password returns 400.
- POST /api/auth/sign-out-elsewhere does the same sign-out without a password
  change. Called from a device, it keeps that device linked.
- _sign_out_elsewhere moves session_epoch on and deletes device tokens. The
  reset route now uses it too, keeping no device.
- The page is renamed from "Linked devices" to "Account", in the router title
  and both nav entries. Its sections are Linked devices, Password (one short
  line, then the form) and Sessions (a single "Sign out everywhere else" row in
  the device rows' style), per preference 188: one line each, no paragraphs.
- docs/public-hosting.md says how sessions end, and why a browser session isn't
  listed the way a device is: it is a signed cookie, ended by moving the epoch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:22:16 -04:00
bvandeusenandClaude Opus 5.5 1dd6fc1e20 Apps refuse to send their token over plain http:// to a public address
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 56s
CI & Build / integration (push) Successful in 1m20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m23s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m22s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m13s
Android / Build the server image (push) Successful in 1s
Family idea #5105, practice 13, as the operator chose on 2026-10-08.
The check lives in the shared core, so the desktop and Android both get it.

compat::cleartext_allowed decides from the address text alone, with no DNS
lookup. It allows https:// always. It allows http:// to private, loopback,
link-local and CGNAT IPs (CGNAT covers Tailscale), to fc00::/7, fe80::/10 and
::1, to single-label names, and to LAN suffixes (.local, .lan, .home.arpa,
.ts.net and others).

The refusal runs in two places:
- probe, so linking stops before a password or token is sent;
- the top of run_cycle, so a device linked before this change stops syncing
  with a message telling it to re-link, instead of sending its token on
  every cycle.

The server is unchanged and never forces HTTPS (rule 94). Plain http:// on
a LAN links and syncs as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:14:14 -04:00
bvandeusenandClaude Opus 5.5 5d08d8a7a6 The first account can only be made in a 30-minute setup window
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 38s
Family idea #5105, practice 8 (Scribe #5113), the operator's choice of
setup window over a setup code.

Before this, whoever reached /register first on an empty server became
its admin. On a fresh server at a public address, that could be a
stranger, and a new DNS name is found within minutes.

Now the first registration is refused once 30 minutes have passed since
the server started (create_app records STARTED_AT). A restart opens the
window again. It is a constant rather than a Setting, because there is no
admin yet to change one. Once an account exists it no longer matters, so
existing servers are unaffected. public-hosting.md says so, and two
integration tests cover both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:08:29 -04:00
bvandeusenandClaude Opus 5.5 4c350838b1 Server idle timeout 120s, and say why there is no read timeout
Family idea #5105, practice 9 (Scribe #5113). The body cap was already
there (MAX_CONTENT_LENGTH, 64 MiB). For timeouts, read from hypercorn
0.18's source:

- --keep-alive goes from 600 to 120. It is also the header timeout:
  hypercorn marks a connection busy only once a whole request has
  arrived, so a client dribbling headers was allowed ten minutes per
  connection. 120 stays above Traefik's 90s backend idle timeout, so the
  proxy never reuses a connection this server just closed.
- No --read-timeout, deliberately. It bounds every socket read, including
  the whole of a streaming download while the client sends nothing, so it
  would cut off an APK fetched slowly over mobile data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:08:29 -04:00
bvandeusenandClaude Opus 5.5 65f004029b Signed-in app downloads are cached privately, not publicly
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 49s
Quart's send_file marks every file it sends Cache-Control: public. The app
download is behind a login, so a shared cache or proxy could have kept one
account's copy and handed it to anyone. send_artifact now marks it
private, as the attachment route already does. Family idea #5105,
practice 11 (Scribe #5113).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:33:48 -04:00
bvandeusenandClaude Opus 5.5 97b04f9f92 Close the gaps family idea #5103 found in how Inkwell distributes its app
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
Android / Build, or is the channel already serving this? (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 18s
CI & Build / integration (push) Successful in 1m13s
CI & Build / Build & push image (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Android / Core and FFI clippy and tests (push) Successful in 45s
Android / Kotlin + Rust (APK) (push) Successful in 9m50s
Android / Build the server image (push) Successful in 2s
Three of the idea's practices this project still owed (Scribe #5118):

Practice 3, CI fails on the wrong signer. The signing step printed the
certificate and went on. It now fails unless the APK has exactly one
signer and that signer is the release certificate (SHA-256 408a5835…,
pinned from run 8753). The steps that publish come after it in the same
job, so a wrongly signed build is never staged or published.

Practice 6, app downloads are throttled and carry a sha256 ETag.
- The download route counts per account and answers 429 with
  Retry-After past the limit. The limit is a new Settings → Security
  value, "App downloads per account per hour" (default 30), live like the
  sign-in limits.
- The ETag is the sidecar's sha256, not Quart's mtime-and-path, so a
  phone resuming a download across a redeploy is not told its partial
  copy is stale. Quart's own ETag and conditional handling are off, and
  the route runs the conditional pass after setting the ETag, so Range
  and If-Range are judged against the content.

Practice 9, the update offer and debug builds.
- The install-permission notice re-reads the grant each time the app
  comes back, as ReminderNotice does. Read once, it stayed up after
  someone granted the permission in Settings and came back.
- A debuggable build says it can't update itself and checks for nothing.
  Android would refuse the release-signed APK over a debug signature
  anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:21:40 -04:00
bvandeusenandClaude Opus 5.5 f0ce5687cc android: build the signed APK's Rust with the release profile
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
Android / Core and FFI clippy and tests (push) Successful in 25s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m11s
Android / Build the server image (push) Successful in 1s
Every APK so far carried a debug-profile libinkwell_ffi.so (opt-level 0),
because the workspace's release profile sets strip = true, which removes
the symbols uniffi's --library mode reads the interface from (run 4077).

The cargoNdk task now builds --release with two environment overrides, for
this build only:
- CARGO_PROFILE_RELEASE_STRIP=debuginfo keeps the symbol table. A release
  build has no debug info, so this keeps symbols and nothing else.
- CARGO_PROFILE_RELEASE_PANIC=unwind keeps a core panic reaching Kotlin as
  an exception, which the board shows as an error, not an app exit. Phones
  have always had unwind, because debug unwinds, so this keeps what they
  do.

Overrides rather than a profile of our own because cargo-ndk copies its -o
output from the release directory, and nothing says it handles another.
The desktop's binaries are unchanged. android.yml passes release on the
signed path; the unsigned debug path keeps debug.

Scribe #2810.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:39:36 -04:00
bvandeusenandClaude Opus 5.5 8cdb36dc2a web: two board columns on a phone, as the Android app has
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 5s
CI & Build / Web typecheck and unit tests (push) Successful in 11s
CI & Build / Python tests (push) Successful in 18s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Build & push image (push) Successful in 47s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m8s
Desktop (Tauri) / Update manifest (push) Successful in 3s
The board was one column below 640px, so on a phone it read as a list
while the app showed two. It is now two columns from the smallest width,
with an 8px gap there (16px from sm up). Two columns at 16px on a 390px
screen would leave ~170px cards. NoteCard's bottom margin, the vertical
half of the gap, tightens with it.

NoteGrid is the only place the board's columns are defined, so board,
search, timeline and reminders all change together.

Fill order is untouched. CSS columns fill top to bottom, so note #2 sits
under #1 rather than beside it, unlike Android. That is left until it has
been looked at on a phone, as #2950 recommends.

Scribe #2950.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:32:16 -04:00
bvandeusenandClaude Opus 5.5 87725ecab7 android: search narrows the board in view, and combines with its filters
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 9s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
CI & Build / Web typecheck and unit tests (push) Successful in 11s
CI & Build / Python tests (push) Successful in 17s
Android / Core and FFI clippy and tests (push) Successful in 1m5s
CI & Build / integration (push) Successful in 1m42s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m24s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m59s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m14s
Android / Build the server image (push) Successful in 1s
As on the web, the search box is now one more facet on the board you are
looking at, rather than a separate unfiltered search. "These words, in
notes tagged grocery" works: on the main board the text is sent to the
core together with the Filters sheet's tags, attachment and shared
switches, and the core ANDs them in list_notes. Archive, Trash and a
tag's view take the text alone. A search typed on Reminders, which is not
a board view, moves to the main board, as the web does.

The Filters chip stays while you search; it was hidden before, on the
mistaken claim that the web hides its filters too. Drag-to-reorder stays
off during a search, since a filtered subset can't be renumbered against
notes it can't see.

store::search and the FFI's search_notes had no other callers, and are
removed. They also searched archived notes and ignored pinning, which the
board's query does not.

Scribe #2942.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:19:50 -04:00
bvandeusenandClaude Opus 5.5 5fa261cea7 android: hold Coil at 3.5.0, the last release that builds on compileSdk 36
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 11s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m43s
CI & Build / Build & push image (push) Skipped
Android / Core and FFI clippy and tests (push) Successful in 1m7s
Android / Kotlin + Rust (APK) (push) Successful in 8m47s
Android / Build the server image (push) Successful in 2s
Run 8731 failed checkAarMetadata: Coil 3.6.x requires compileSdk 37, and
it pulls in Compose 1.12, which requires AGP 9.1. This project is on
compileSdk 36 and AGP 9.0.1. Coil 3.5.0's AARs ask for 36, and its Compose
(JetBrains 1.11.1) is within this project's BOM (Compose 1.11.2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:59:09 -04:00
bvandeusenandClaude Opus 5.5 a213e2e186 android: link preview cards show the page's image, as on the web
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
Android / Core and FFI clippy and tests (push) Successful in 28s
CI & Build / integration (push) Successful in 1m21s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 6m10s
Android / Build the server image (push) Successful in 1s
The card draws the linked page's og:image in a strip down its left edge,
cropped to the card's height: 96dp full, 48dp compact, matching the web's
w-24 and w-12. The image is remote, so the phone fetches it from whatever
host the link points at, exactly as a browser does for the web card. The
operator chose that parity (Scribe #3307).

The image is Coil 3's AsyncImage, with OkHttp as its fetcher. Coil's disk
cache means a card scrolled past twice costs one download. When there is
no image, or it fails to load, nothing is drawn rather than an empty box,
and the card is the text card it was before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:47:26 -04:00
bvandeusenandClaude Opus 5.5 10412a2fb7 desktop: rustfmt's shape for the server feed arm
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / integration (push) Successful in 1m20s
CI & Build / Build & push image (push) Successful in 21s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m6s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Run 8721's format check; clippy and the Rust tests were already green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:00:07 -04:00
bvandeusenandClaude Opus 5.5 b03c9cf81a desktop: in-app updates follow the server you installed from
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m17s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m45s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 40s
Milestone 325 step 6 (Scribe #3254).

The server publishes its AppImage in the updater's own format at
/api/client/linux-appimage/update.json: the ordering key as `version`, the
signature, and an absolute download URL built on the host that was asked,
so the token the updater attaches goes nowhere else. Unsigned platforms and
a server with no AppImage 404.

The desktop's update source is now Fabled-Git (and its channel) or one
server:
- `read_source` is the one reader. The installer's `install-server` marker
  feeds the `update_server` pref once per new value, exactly as the channel
  marker feeds its pref; tauri.conf.json's endpoint is never consulted.
- From a server, the check and the download carry the sync link's token
  when the app is linked to that same server. Without one the update shows
  and says to link rather than offering a button that 401s.
- A server with no build says so. A 404 is "up to date" only on the forge,
  where it means an unpublished channel.
- Sync → App updates offers the source once there is a server to offer (the
  chosen one, or the linked one), and only shows the channel for the forge.

The trust anchor does not move: whatever the source, the updater verifies
the AppImage against the public key built into the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:56:36 -04:00
bvandeusenandClaude Opus 5.5 871878de41 install.sh installs from your own server, not just from the forge
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 1m15s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m5s
CI & Build / Build & push image (push) Successful in 55s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m15s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 2m57s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Milestone 325 step 5 (Scribe #3253).

    curl -fsSL https://notes.example.com/install.sh | sh

The server serves the installer at /install.sh with its own address written
into it (installer.py). Settings → Public address when set, the request's own
address otherwise. The substitution is one variable, given a value that has
passed a strict shape check, and the script checks it again; an address that
cannot pass makes the route refuse rather than serve a script pointed
elsewhere. `public_url` joins the live settings cache so the route needs no
database.

From a server, the script:
- resolves each Linux bundle from the public /api/client/<platform>, and
  builds the download URL from the platform id rather than reading it from
  the reply;
- asks for a device token (from the terminal, since stdin is the script),
  or takes TS_TOKEN, and sends it from a file rather than the command line;
- checks the sha256 the server published before anything installs;
- revokes a prompted token once the download is done;
- records `install-server` for the updater (step 6) instead of the channel.

The forge path is unchanged, and stays the default for the copy the forge
serves. The Account page's downloads card shows the one-line command
whenever the server holds a Linux client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:44:39 -04:00
bvandeusenandClaude Opus 5.5 802eab4ef9 android: bring BoardScreen and NoteCard back under detekt's complexity limit
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 12s
Android / Core and FFI clippy and tests (push) Successful in 26s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m31s
Android / Build the server image (push) Successful in 1s
Run 8693's detekt failed both on CyclomaticComplexMethod (17 and 15 against
15) after the filters and drag-to-reorder landed.

- BoardState now carries `filterable` and `reorderable`, so the board's
  rules for when the filter row shows and when a card can be carried live
  with the state they read instead of as boolean chains in the screen.
- NoteCard's contents (tags, body, links, attachments, reminder, sharing)
  move to their own CardContents composable, leaving NoteCard the gestures,
  the frame and the menu.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:38:37 -04:00
bvandeusenandClaude Opus 5.5 3b4fe310b8 android: the board's Filters and drag-to-reorder, as on the web
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
Android / Core and FFI clippy and tests (push) Successful in 58s
CI & Build / integration (push) Successful in 1m38s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m26s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m11s
Android / Kotlin + Rust (APK) (push) Failing after 5m6s
Android / Build the server image (push) Successful in 1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m1s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Filters: a chip under the search bar opens a sheet with Has attachment, Shared
with me and tags (a note must carry all of them), applied as they are tapped,
with a count on the chip and a Clear beside it. Only the main board filters and
search spans everything, as on the web; opening another view starts it
unfiltered, a deleted tag drops out of the filters as it does from the lens, and
an empty filtered board says so.

Reorder: hold a card, then move it. The hold is the long press that opens the
card's menu, which closes as the card starts to move; lifting without moving
leaves the menu as before, and moving before the hold is a scroll. Cards trade
places live and the drop writes the order through the core's reorder, newly
exposed over the ffi as reorder_notes. Only on the plain main board, and only on
the same side of the pinned line, since the store sorts pinned first.

#5313.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:28:22 -04:00
bvandeusenandClaude Opus 5.5 4149229988 web: component tests for the Share dialog
@vue/test-utils and jsdom as dev dependencies, jsdom chosen per file with the
vitest environment comment so the existing unit tests stay on node. The dialog's
repo.shares is faked; the tests cover offering everyone, sharing with a person
and a group, changing and removing a share (and the board's shared flag that
follows), a refused share keeping the choice, the load retry, and the
single-person instance. #5313.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:28:22 -04:00
bvandeusenandClaude Opus 5.5 3829d52e4d ci: find our own container from mountinfo, the way Steward does
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 15s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 1m20s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build & push image (push) Successful in 25s
The integration lookup read the job's id from /etc/hostname, which holds only
while the runner leaves the hostname as the container id. Steward's fix (#5104)
reads it from the /etc/hostname bind mount's path in /proc/self/mountinfo and
falls back to the hostname, so one recipe now serves every repo (#5313).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:12:52 -04:00
bvandeusenandClaude Opus 5.5 c614e6859a Revert the deliberately failing core test
CI & Build / Python lint (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 57s
CI & Build / integration (push) Successful in 1m17s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m56s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 10m1s
Android / Build the server image (push) Successful in 1s
It proved the APK gate (#5237): run 8667 failed at the core's tests, skipped
the APK job and still dispatched the server image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:46:39 -04:00
bvandeusenandClaude Opus 5.5 d682ae026d ci: the core checks run in their own job on ci-tauri, and the APK needs it
CI & Build / Python lint (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Failing after 48s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / integration (push) Successful in 1m18s
CI & Build / Build & push image (push) Skipped
Android / Build the server image (push) Successful in 44s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
The step added in 42db4cd ran cargo on the Android image, which has OpenSSL
only for the Android targets; the host build died at openssl-sys (run 8663)
before reaching a test. The core's clippy and tests are now a 'rust' job on
ci-tauri, the image desktop's verify runs them on, and the APK job needs it.

The server-image dispatch moves to its own job: it was a step inside the APK
job, and a skipped job runs no steps, so failing core checks would have
silently stopped the server image too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:42:17 -04:00
bvandeusenandClaude Opus 5.5 a682d6d225 core: a deliberately failing test, to prove the APK gate (reverted next)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 12s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m31s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 3m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:35:06 -04:00
bvandeusenandClaude Opus 5.5 42db4cde6b ci: the APK waits for the core's clippy and tests
android.yml never ran cargo, so a core test that failed in desktop.yml's
verify job stopped the desktop installers and not the APK, which links the
same core through android/ffi (#5237). The Kotlin + Rust job now runs clippy
and the tests for inkwell-core and inkwell-ffi before anything is assembled
or published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:35:06 -04:00
bvandeusenandClaude Opus 5.5 82aa5ba7b6 android: wrap the board's column choice the way ktlint wants
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 1m10s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:48:15 -04:00
bvandeusenandClaude Opus 5.5 d9f755b128 ci: the integration lane finds its own Postgres, not another job's
CI & Build / Python lint (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 3m27s
Run 8653 failed at 'alembic upgrade head' with a password error: two
integration jobs were on the runner at once, and the name=integration
filter took the other one's database (#5312). The lookup is now scoped to
this job's GITEA-ACTIONS-TASK-<id>- prefix, read from the job container's
own name, and requires exactly one match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:40:36 -04:00
bvandeusenandClaude Opus 5.5 d6757fc0fc android: the board takes three and four columns on a wide window, as the web does
The board was Fixed(2) at every width, so a tablet showed two wide columns
where the web shows three or four (#5311). The column count now follows the
web's NoteGrid breakpoints on the window's width: three from 1024dp, four
from 1280dp. A phone keeps two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:36:41 -04:00
bvandeusenandClaude Opus 5.5 b019172d47 all: remove saved views, the Has-reminder filter and the Created range
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 13s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 1m29s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m49s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 9m29s
Step 18 of the audit follow-through (#5180), on the operator's decisions.
Inkwell is for capture and recall (note 2897), and these three duplicated a
surface that does the job already:

- Saved views. They lived only on the web; the desktop kept its own set that
  never synced, and Android had none. Tags in the drawer already give
  one-click recall. Gone from the server (routes, model, migration 0038 drops
  the table), the core (store functions, schema v13 drops its table), the
  desktop commands, the web adapters, the drawer's Views list and the
  "Save view" link.
- The "Has reminder" facet. The Reminders page lists them, sorted by due.
- The FilterBar's "Created" range. Timeline is the date lens and keeps the
  created_after/created_before query it builds from local days, which also
  retires the UTC/local-day disagreement between the two (B3).

An old link that still carries the removed keys opens the plain board; a
web test pins that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:29:28 -04:00
bvandeusenandClaude Opus 5.5 888c6410f0 all: trim the history essays out of the longest comments
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 2m0s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m51s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m39s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m4s
From the audit (#5179). Ten comment blocks narrated how the code got here:
milestone numbers, earlier values, the operator's verdict on an old design.
Each now says what the code does and why, and the history stays in git,
Scribe and docs/sync.md. The protocol-version comment in sync.py points at
docs/sync.md's policy section, which already lists every bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:03:44 -04:00
bvandeusenandClaude Opus 5.5 e75e3d37d0 web: shared note actions, reminder chips, page header, formatters and settings seam
From the audit (#5179, web half).

- NoteActions: share, pin, archive and trash (restore and delete forever
  when trashed), as both the card and the editor offer them. The editor's
  history toggle goes in its slot, and it closes on `acted`.
- ReminderActions: the Done / 1h / 1d chips on the card and in the editor,
  which are now the same chips.
- PageHeader: the back-to-board header that Settings, Sync and Linked
  devices each wrote out, now with a `back` icon from the shared set.
- notes/datetime: formatShortDateTime (was formatReminder and the editor's
  revLabel) and formatDateTime (the two `fmt` copies).
- notes/colors: labelDotClasses (the sidebar's and the tag manager's
  labelDot).
- Drawer links use exact-active-class instead of route.name ternaries.
- BoardView binds its three grids from one gridBinds object.
- Settings goes through repo.settings (rest, plus a local adapter that
  answers "needs a server") and shows load failures through AsyncState.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:02:18 -04:00
bvandeusenandClaude Opus 5.5 05c82c2362 core, desktop: Db::conn everywhere, Change::default, notes_where, and shared row mappings
Android / Kotlin + Rust (APK) (push) Canceled after 12m20s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 15s
CI & Build / Web typecheck and unit tests (push) Successful in 16s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 4m6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m20s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
From the audit (#5179, core and desktop half).

- Every `db.0.lock().map_err(|e| e.to_string())?` (about 50 sites in core and
  the desktop) is now `db.conn()?`. The few sites that deliberately handle
  a poisoned lock differently, and the tests, keep their own spelling.
- push::Change derives Default, so its four constructors name only the
  fields they set.
- store: list_notes, reminders, titles and search share notes_where (ids
  from a query, each loaded through load_note). Labels share
  LABEL_SELECT/label_row, and saved filters share
  SAVED_FILTER_SELECT/saved_filter_row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:57:24 -04:00
bvandeusenandClaude Opus 5.5 646a115701 server: one credential check, one coerce_bool, one top-position query, and the shared response helpers
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m11s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 49s
From the audit (#5179, server half).

- auth: login and device-login share _check_credentials (dummy hash for a
  missing account, throttle bookkeeping, the failure log line) and
  _bad_credentials.
- settings uses common.coerce_bool. Its private copy differed only in
  treating a non-string as its truthiness, which the shared one now does.
- notes: create and import share helpers.top_position.
- auth, settings_api, sync and client_dist return errors through
  responses.json_error / not_found, and parse ids with parse_uuid.
- sync: push replies are built by _result(id, entity, status, **extra).

Already merged by earlier steps, so nothing to do here: attachment storage
(store_attachment), the preview upsert (only unfurl_queue writes one now),
and _serialize_note (delegates to _serialize_notes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:55:01 -04:00
bvandeusenandClaude Opus 5.5 b8f13cfc4a tests: the share ACL test's final body no longer expects the removed add-item route's eggs
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m5s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:13:44 -04:00
bvandeusenandClaude Opus 5.5 e2e0740b06 tests: stop posting to the removed add-item route in the share ACL test
CI & Build / Python lint (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 13s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Failing after 1m7s
CI & Build / Build & push image (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:06:42 -04:00
bvandeusenandClaude Opus 5.5 7eacd0569c all: delete the code nothing calls
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Failing after 1m21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m47s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m39s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m37s
From the audit (#5178). Each was unreachable from every client:

- Checklist add-item and delete-item: REST POST /items and DELETE /items/<id>,
  the Tauri commands, the store, rest and local adapters, the core's
  add_item/delete_item, set_item_text and remove_item, and the FFI exports.
  Adding, rewording and removing an item are body edits in every editor. The
  checked toggle stays, and its rewriter is simpler without the drop branch.
- Manual unfurl: POST /unfurl and its adapters. Previews arrive in the
  background after a save (unfurl_queue).
- The /api/config `android_client` key, android_release() and the
  APK_NAME/MANIFEST_NAME aliases. Phones poll /api/client/android.
- users.email_verified and users.avatar_path (migration 0037). Nothing set
  the first or read the second; the SMTP reset never checked verification.
- derive::extract_tags (only tests used it; the shared fixture now runs
  through extract_tag_spans), the unused check and link icons, and the
  unused editor_add_item string.
- The blob scheme is renamed tsblob -> inkblob. URLs are built as notes are
  read, so nothing stored carries the old one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:00:44 -04:00
bvandeusenandClaude Opus 5.5 fd1d50662f android: share a note with a group
CI & Build / Python lint (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m39s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m37s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m26s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m16s
The Share sheet lists groups after people, shows a group share as its name and
how many are in it, and shares through the FFI's ShareTarget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:25:16 -04:00
bvandeusenandClaude Opus 5.5 442cca4398 web, core, desktop: share a note with a group, and Settings → Groups
The Share dialog lists people and groups in one picker and shows a group share
as its name and member count. Settings gains a Groups section for the admin:
create, rename, delete, and add or remove people.

The core client reads the directory's groups and group shares (ShareTarget:
a member or a group); the desktop command takes user_id or group_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:25:16 -04:00
bvandeusenandClaude Opus 5.5 5f3cfe8bb7 groups: admin-managed groups, sharing a note with one, and membership in the feed
/api/groups (admin) creates, renames and deletes groups and adds or removes
members. The member directory lists every group, and a share may name a
group_id instead of a user_id; a note's shares answer with `member` or `group`.

A note shared with a group reaches whoever is in it now, so membership is what
the feed follows: joining grants each of the group's notes to the new member's
devices, and leaving (or the group being deleted) revokes them unless a direct
share or another group still reaches that person. recipients() never counts the
note's own owner, who may sit in a group it is shared with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:25:16 -04:00
bvandeusenandClaude Opus 5.5 2e2714a50b core: clippy — a one-element slice from a reference in the store test
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Build & push image (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / integration (push) Successful in 1m16s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m26s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m37s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m25s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (APK) (push) Successful in 10m54s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:18:53 -04:00
bvandeusenandClaude Opus 5.5 4f5459cb94 android: pin and archive a note someone shared with you
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m30s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Successful in 1m24s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s
The card's long-press menu and the editor's overflow now open on shared notes
with Pin and Archive; Labels, Share and Move to trash stay the owner's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:13:58 -04:00
bvandeusenandClaude Opus 5.5 89cd1c76bb core, web: pin, archive and reorder a note someone shared with you
Schema v12 adds notes.state_at: a recipient's own pin, archive and order are
stamped there instead of on updated_at, which stays the text's time. Push sends
them only to a server advertising `shared_state` (push::Accepts), a view share
included; the first pull at that level starts the feed over once so held copies
drop their owner's pins. The client speaks protocol 7 and lists `shares` and
`shared_state` among the features a server may lack.

The web card and editor offer pin, archive and drag on shared notes; share and
trash stay the owner's, and the board's trash key skips notes you don't own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:13:58 -04:00
bvandeusenandClaude Opus 5.5 63955bbe97 sync: recipients keep their own pin, archive and place on shared notes
A note_user_state row per (note, recipient) holds what used to be the owner's
columns as far as anyone else could tell. The board filters and orders through
the viewer's own state; PATCH and reorder write it for a note shared at any
level; the feed's revision for a shared note is the later of the note's and the
caller's row, so a recipient's pin reaches their devices and no one else's.

Push takes the three with their own `state_at` stamp (protocol 7,
`shared_state`), so pinning a copy whose text is behind never makes that text
win over the owner's edit. A body is only stamped as an edit when it changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:13:58 -04:00
bvandeusenandClaude Opus 5.5 5e8c6dc7bf android: detekt — check the link before loading shares, and NoteAccess gets its own file
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:52:21 -04:00
bvandeusenandClaude Opus 5.5 2e7db21b21 android: share a note, and read or edit one shared with you
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 18s
CI & Build / integration (push) Successful in 1m25s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m39s
Android / Kotlin + Rust (APK) (push) Failing after 4m53s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m42s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m40s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The editor's menu has Share…, which opens a sheet of who the note is shared
with and lets you add someone at view or edit, change it, or stop sharing;
unlinked, it says sharing needs a server. A note shared to view opens
read-only; at edit only its text can change. Cards say who shared a note
("From Robin") or that yours is shared, and a view-only note's boxes don't
tick.

Also: two core store tests used unwrap_err on a Result<Note>, which needs
Note: Debug; they use err().expect() now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:42:30 -04:00
bvandeusenandClaude Opus 5.5 aa36b43dc3 core: shared notes on the desktop and phone, and Share from the desktop
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m14s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 9m20s
The core pulls with shares from a server offering them (protocol 6): a note
says how it is held (owner, edit, view) and who shared it, and a revoked note
leaves the device. The first such pull starts the feed over once, so notes
shared before this build arrive. The store refuses what a share doesn't allow
(view: everything; edit: anything but the text), push sends only the text of
someone else's note, and their notes stay out of trash, reminders and
reordering. Unlinking drops them.

The Share dialog's calls go to the linked server over the device token, as
Tauri commands and through the FFI. The desktop now offers Share and "Shared
with me"; unlinked, the dialog says sharing needs a server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:33:16 -04:00
bvandeusenandClaude Opus 5.5 75928c7afd sync: shared notes in the feed, revocations, and text pushes from an edit share
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python tests (push) Successful in 15s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 54s
The change feed answers `?shares=1` with every note the caller can see, each
saying how it is held, plus a `revoked` list of notes that left them. Granting
a share moves the note past the recipient's cursor; ending one, or the owner
deleting the note, leaves a revocation on the same cursor. A recipient at edit
may push the note's text, and nothing else. Protocol 6, feature `shares`;
opt-in, so the floor stays at 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:25:13 -04:00
bvandeusenandClaude Opus 5.5 2e2d8667dd password reset by email: Settings → Email, Forgot password?, and a test-email button
CI & Build / Python tests (push) Successful in 15s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 1m16s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 1m15s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m49s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m26s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The operator asked for self-service reset over SMTP. It reuses #5173's
password_resets table, /reset-password page, one-hour single-use token and
sign-out-everywhere.

- Settings (rule 25, not env): a new Email group (SMTP server, port,
  encryption as a choice, username, password, from), General → Public
  address, and Security → Reset emails per account. The registry gains
  `choices`, `secret` (the value is never sent back, `is_set` says one is
  saved, an empty save keeps it) and `url` (http(s), trailing slash
  stripped).
- mailer.py: stdlib smtplib on a worker thread, 20 s timeout,
  starttls | tls | none. mail_settings() is None until a server, a sender
  and the public address are set. Links are built from the public address
  because the Host header can be forged.
- POST /api/auth/forgot-password: the same answer at the same speed for
  any address. The link is made and mailed off the request (send_later).
  It is throttled like a sign-in per visitor address, and capped per typed
  email by reset_emails_per_account; past the cap it answers the same and
  sends nothing.
- POST /api/settings/test-email: mails the admin with the saved settings
  and shows the server's error if it fails.
- Public config `password_reset_by_email`. Sign-in shows "Forgot
  password?" only then, linking to a new /forgot-password page.
- docs/public-hosting.md: an "Email and forgotten passwords" section.

Tests: the secret stays server-side; emailed link → reset; the same
answer for unknown addresses; the cap; test email success and failure;
validation units. #5266.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:15:43 -04:00
bvandeusenandClaude Opus 5.5 ca242e59a6 tests: adding a checklist item answers 201
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 59s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m48s
CI & Build / Build & push image (push) Successful in 1m2s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m7s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The edit-share test expected 200 from POST …/items, which creates. #5174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:06:39 -04:00
bvandeusenandClaude Opus 5.5 f53d377766 sharing: share a note from the web, at view or edit, with anyone on the instance
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python tests (push) Successful in 14s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / integration (push) Failing after 54s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m6s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 2m33s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 2m25s
The ACL has gated every read since M0, but nothing could write a share.

Server:
- shares_api: GET /api/users/directory (everyone but you, signed-in only),
  GET/POST /api/notes/<id>/shares and DELETE …/shares/<share_id>, owner
  only. Sharing again with the same person changes the permission
  (ON CONFLICT on the new unique index).
- acl.visible_to_user takes permission=; granted_to and shared_ids feed
  the serializer.
- Edit covers body and checklist (_get_editable). Everything else stays
  _get_owned. A view share's write is a 404 like a stranger's (#1984). An
  editor's PATCH naming anything but body is a 403.
- Serialized notes carry permission, shared and shared_by. A recipient
  never gets the owner's labels, and a #tag an editor types files under
  the owner's (it always went to note.owner_id).
- ?shared=with_me, also allowed in saved views. Trash and reminders are the
  owner's. purge_note drops the note's shares.
- Migration 0034: one share per note and person (and per group), permission
  limited to view and edit, an index for "shared with me".

Web:
- ShareDialog (one, mounted by the shell): pick a member, Can view or Can
  edit, change or remove existing shares, with loading, error and empty
  states.
- Card: "Shared by X" or "Shared" chip; owner-only actions and reminder
  buttons hidden for recipients; checkboxes inert at view.
- Editor: read-only at view; text and checklist only at edit; Share button
  for the owner.
- FilterBar: Shared with me. Repo seam gains `shares`; the offline desktop
  shows none of it (#5175 brings sharing there).

Tests: owner, recipient and stranger across reads, every write at view and
edit, tag filing, unshare, trash, delete and validation; web unit tests for
the facet and permission helpers. #5174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:01:53 -04:00
bvandeusenandClaude Opus 5.5 f100e5ef85 tests: the self-revoke routing check reads the URL map; its 400 moves to Postgres
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 50s
test_devices signed a fake account into a session and relied on
login_required answering without the database. Since 3dd0b44 the session
path reads the account's epoch, so the fake account hit an unreachable
database and 500ed. The routing property (the static /devices/self rule beats
/devices/<device_id>) is now asserted on the URL map, and the view's 400 for a
web session is an integration test with a real account. #5173.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:41:44 -04:00
bvandeusenandClaude Opus 5.5 3dd0b44cb9 password reset: an admin makes a one-hour link, and using it signs the account out everywhere
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Failing after 12s
CI & Build / integration (push) Successful in 49s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m5s
Desktop (Tauri) / Update manifest (push) Successful in 5s
There is no mail path, so a forgotten password needed a hand on the database
(#2939 §2). Settings → People lists the accounts; Reset password makes a link
that works once within an hour, shown once for the admin to hand over. Making
another link for the same account closes the earlier one.

Using it (/reset-password) sets the password, deletes the account's device
tokens, and moves users.session_epoch on. Sessions are signed cookies the
server can't delete, so each now carries the epoch it signed in under and
login_required reads the account's epoch by primary key. A cookie from before
this has no epoch and reads as 0, the starting value, so the upgrade signs
nobody out. A deleted account's session now stops working too.

The one-time link reveal moves out of InviteList into OneTimeLink, and the
link-building into router/links.ts, shared by invites and resets.

Migration 0033. #5173.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:35:58 -04:00
bvandeusenandClaude Opus 5.5 28fa8badcb invites: an admin lets one person register while registration stays closed
CI & Build / Python lint (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 54s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m28s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m14s
Until now adding a second person meant re-opening registration to the
whole internet while they signed up (#2939 §1). An admin now makes an
invite in Settings: a link that works once, expires (7 days by default,
1 to 30), and can be pinned to one email address. Only the token's hash
is stored, so the link is shown once.

POST /api/auth/register takes `invite`. Redemption is one conditional
UPDATE inside the transaction that creates the account, so two people
racing one link can't both get in, and a taken email leaves the invite
unused. Every refusal says "invalid or expired invite". The register
page reads ?invite= and opens even while registration is closed.

Refs #5172

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:13:03 -04:00
bvandeusenandClaude Opus 5.5 df85535ea2 desktop: reminders reach you when the window isn't in front
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 36s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m30s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m38s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s
A Rust worker reads due reminders from the local store every 15 s and
announces each occurrence once: always to the main window as a toast, and
as a system notification (tauri-plugin-notification) when that window
isn't focused. The page no longer polls on the desktop; its Notification
went nowhere in WebKitGTK and its timer stopped with the window. The
Reminders page says what each surface actually does.

Core gains store::due_reminders, compared by instant, not by string.

Refs #5171

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:46:02 -04:00
bvandeusenandClaude Opus 5.5 5989ffc1c6 desktop: Import and Export work offline; the menu toggle is reachable; errors say why
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 48s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 4m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m10s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m32s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 12m42s
Export and Import were a link to the server and a reject("needs a server") on the
desktop. Both now run in the core with no server:

- core/src/local/portable.rs builds the same zip the server writes (notes.json,
  a Markdown file per note, each attachment this device holds) and reads either
  export marker or a Google Keep Takeout zip, with the server's decompression
  budget and an all-or-nothing transaction. Export saves to Downloads (no new
  plugin) and the sidebar says where; Import takes the archive as raw IPC bytes.
- core/testdata/portable.json pins the format for both copies: the server runs
  its Keep and native readers against it (test_portable_fixture.py) and checks
  its real export's keys (test_integration.py); the core runs the same cases.
- Found on the way: both importers skipped a Keep note that is only a photo as
  "empty". It now imports, on the server and in the core.
- New dependency, approved: `zip` (deflate only) plus `flate2` on its pure-Rust
  backend, both already in the lockfile.

The AppImage applications-menu toggle moves from Account, which the desktop
never shows, to the Sync page; the first-run prompt now says so.

errorMessage (#5236) replaces the hand-rolled `.error ?? …` / `.message ?? e`
reads at the remaining catch sites, so a desktop failure shows its real reason.

Task #5170.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:36:42 -04:00
bvandeusenandClaude Opus 5.5 ac4427f834 android: shows and attaches files, and the share sheet takes images
CI & Build / Web typecheck and unit tests (push) Successful in 11s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Successful in 45s
CI & Build / Build & push image (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 1s
CI & Build / Python tests (push) Successful in 12s
Android / Kotlin + Rust (APK) (push) Successful in 11m6s
The phone downloaded every attachment and drew none of them, so a photo note
looked empty. Now:

- Cards show a note's first image and name its other files; the editor shows
  every image at full width and every file as a row. Tapping one opens it in
  whatever app handles its type (a cache copy under its real name, through a
  FileProvider that serves only those copies). Each can be removed, and a file
  the server refused says why under it.
- The editor's toolbar has an Attach button (any type, several at once). Files
  are stored on the phone straight away and upload on the next sync that
  reaches a server, through the core's step-6 path. Link previews show in the
  editor too, and can be dismissed.
- Share → Inkwell accepts one or several images, with or without a caption,
  finishing #1899's deferred image/* target.
- The FFI gains add_attachment, delete_attachment, delete_preview and
  blob_path. The sync summary counts uploads and failed uploads.

Images decode at the size they are drawn (BitmapFactory sampling plus EXIF
rotation, small LRU cache), so no image library is added. Files over 50 MB are
refused on the phone before they are read whole into memory.

Task #5169.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:59:36 -04:00
bvandeusenandClaude Opus 5.5 5b11490858 core: the compat forward-compat test builds its feature list from the constants
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 16s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / integration (push) Successful in 41s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m19s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m34s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m20s
Android / Kotlin + Rust (APK) (push) Successful in 8m9s
A literal list went stale the moment attachment_sync was added (run 8513), the
same way pinned version numbers did at v2 — for a reason unrelated to what the
test checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:13:17 -04:00
bvandeusenandClaude Opus 5.5 2b2ceaa82e attachments sync: attach offline, upload when linked, removals stick (#5168)
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 35s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s
Desktop could not create an attachment at all, and a removed attachment or
dismissed preview came back on the next pull. Now:

- core: add_attachment keeps the bytes in the blob store and queues the row
  (schema v10: attachments.uploaded / upload_error). Push uploads it once its
  note has landed. A refusal that retrying won't fix (too large, id clash, hash
  mismatch) is recorded on the file and not re-sent every cycle; the editor
  shows it.
- core: removing a synced attachment or dismissing a preview leaves a tombstone
  in pending_deletes; push sends it as an `attachment`/`preview` delete, and a
  pull while it waits doesn't put the row back. A pull also keeps files still
  waiting to upload instead of replacing them wholesale.
- server: PUT /api/sync/attachments/<id> (raw body, sha256-checked, idempotent,
  size-capped) and child deletes in push, which apply regardless of LWW and
  answer noop for rows the caller can't see. One store_attachment helper for
  the upload route, the importer and sync. Protocol 5, feature attachment_sync;
  the client sends neither to a server without it.
- server: migration 0031 makes a link preview's insert/delete bump its note, so
  background-fetched previews and web dismissals reach linked devices.
- desktop: Attach and paste-image work offline (raw-bytes IPC command).
- SVG is served as a download by the desktop blob scheme too (as #1981 did for
  the web), and drawn as a file chip on both.
- autosync: drop the catch_unwind; release builds abort on panic, so it only
  ever worked in debug builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:07:52 -04:00
bvandeusenandClaude Opus 5.5 efb141e555 desktop: syncs on its own — at launch, soon after an edit, every few minutes and on focus
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m47s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (APK) (push) Successful in 11m34s
Android / Build, or is the channel already serving this? (push) Successful in 5s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 13s
CI & Build / Python tests (push) Successful in 14s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / integration (push) Successful in 47s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m8s
Until now the only caller of the sync engine was the "Sync now" button. A worker
thread now owns every cycle (the button's included, so two never overlap):

- launch: one cycle as the app opens;
- edit: every 10s it reads a fingerprint of the pending set and sends when that
  moved. A fingerprint rather than "anything pending", because a rejected change
  stays pending and would otherwise be resent every tick forever;
- timer: a pull every 5 minutes with nothing to send;
- focus: at most once per 30s.

Failed automatic cycles back off (doubling from 10s to 5 minutes). A panicking
cycle counts as a failed one rather than ending the thread. Every cycle is
emitted as inkwell://synced: the board reloads when the pull changed something,
and the Sync screen shows the last automatic failure. No final push on quit;
the launch cycle sends whatever was left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:40:27 -04:00
bvandeusenandClaude Opus 5.5 09239ee3c7 web: the app's type-check leaves the unit tests out; CI checks them separately
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 19s
CI & Build / integration (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 1m13s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 3m19s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m36s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Run 8478 passed every test lane and then failed `Build & push image`. The
Dockerfile's frontend stage copies only frontend/, and `npm run build`
type-checks with tsconfig.json, which covered grammar.test.ts. Its import of
../core/testdata/grammar.json doesn't exist inside that stage. Nothing was
published: the build is the publish and it stopped.

tsconfig.json now excludes `*.test.ts`. The new tsconfig.test.json extends it
with the tests included, and ci.yml's typecheck lane runs that one, so the
tests are still type-checked before anything ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:34:14 -04:00
bvandeusenandClaude Opus 5.5 38da5160a0 grammar: a #tag starts after whitespace and with a letter, on every surface
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / integration (push) Successful in 38s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m7s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m38s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 10m35s
The shared fixture went red on the server (run 8468: 5 failed), because the three
tag rules disagreed:
- core and web: any non-tag character counts as a boundary, so `(#todo)`
  and `end.#tag` are tags, and so is the `/#section` of a pasted URL;
- server: only whitespace counts, but `#1st` and `#_x` are tags.

A note's labels could therefore change every time it synced.

All three now share the strict rule: start of line or whitespace, then a
letter, then letters, digits, `_` and `-`. Nothing becomes a tag that wasn't
already one everywhere, and URL anchors stop becoming labels on desktop and
Android. The server's existing `http://x/#nope` test already expected this.

derive.rs's boundary, markdown.ts's lookbehind and tags.py's regex change
together; `_is_tag` goes because the regex now requires the letter. The
fixture flips `(#todo)`, `end.#tag` and its lift case, and adds the URL case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:22:53 -04:00
bvandeusenandClaude Opus 5.5 535331c5b2 tests: one fixture for the note grammar, run by the core, the server and the web
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Failing after 27s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 1s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Failing after 12s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 4m25s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m28s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m19s
Desktop (Tauri) / Update manifest (push) Successful in 9s
Android / Kotlin + Rust (APK) (push) Canceled after 11m21s
The checklist grammar and the #tag rule are implemented three times (derive.rs,
checklist.py/tags.py, markdown.ts), and the tag colour twice (colors.ts,
DerivedTint.kt). Only Rust and Kotlin had tests. core/testdata/grammar.json now
holds one set of cases (task lines, rendered items, tags, standalone-tag lifts
and the tint hashes), and every suite reads it.

- web: vitest, a dev dependency approved for #5166, with `npm test`.
  grammar.test.ts runs the fixture, and titles.test.ts pins #5165's palette fix.
- ci.yml runs the web tests in the job the image build needs. desktop.yml's
  verify job runs them too, because the installers embed this frontend and
  can't see ci.yml's verdict (rule 177).
- core: derive.rs reads the fixture. server: tests/test_grammar_fixture.py.
- Android keeps its hand-written tint values; its doc now points at the fixture.

The server is expected red here, on purpose. tags.py only takes a tag after
whitespace and lets it start with a digit or `_`, while the core (the
definition) takes any non-tag boundary and needs a letter. So `(#todo)` is a
label on the phone and plain text on the server. The fix follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:11:26 -04:00
bvandeusenandClaude Opus 5.5 af0389ed13 web: the command palette finds notes written since it was first opened
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python tests (push) Successful in 16s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 58s
Desktop (Tauri) / Clippy, tests and rustfmt (push) Successful in 3m30s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m43s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m23s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The palette's note list loaded once per session behind a `loaded` flag, and
the `reload()` that would have cleared it had no caller. So a note written
after the first open couldn't be found by name until the page reloaded
(#5165, audit B4). The list is now fetched again on every open, and the last
list stays visible meanwhile. The input takes focus before the fetch, and a
failed fetch keeps the old list instead of breaking the palette.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:51:36 -04:00
bvandeusenandClaude Opus 5.5 8db9f3685b server: one save path for a note's text, so a restored link gets its preview
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 11s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Clippy, tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 46s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
A body edit is a sequence: keep a revision, rename the note, lift #tags,
commit, queue link previews. It was written out in PATCH, the item routes,
restore and sync push, and the copies had drifted. Now they all call
`notes/body.py: write_body`, which says how the old text is kept ("session",
"always" for restore, "never" for a new note) and returns whether the text
changed. The routes commit through `_commit_note`, which queues previews after
the commit.

Fixes, both red on 1a2f71e (run 8441):
- restoring a revision queues previews for its links (#5164, audit B5);
- a pushed note keeps the client's edit time when a standalone #tag is lifted.
  The lift's extra flush used to let `onupdate` stamp the server clock over it.

Sync push also queues its previews after the batch commits, not mid-batch,
where a fast fetch could look for a note that wasn't committed yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:31:41 -04:00
bvandeusenandClaude Opus 5.5 86409e02b0 ci: drop the deliberate failure — the gate held on run 8437
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 14s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
CI & Build / integration (push) Failing after 49s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Clippy, tests and rustfmt (push) Successful in 2m26s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m54s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m52s
Desktop (Tauri) / Update manifest (push) Successful in 7s
Run 8437 failed `verify` on purpose, and the Linux build, the Windows
installer and the update manifest all reported skipped. This removes the red
step, so this push is the other direction: a green verify still publishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:26:48 -04:00
bvandeusenandClaude Opus 5.5 1a2f71e381 tests: every note-text write path is pinned, and two of them fail today
Integration tests for the edit sequence at each door: create, PATCH, ticking an
item, restoring a revision and sync push. Two fail on today's code, on purpose:

- restoring a revision never queues link previews, so a restored link stays a
  bare URL (#5164, audit B5);
- a pushed note whose standalone #tag gets lifted stores the server's clock as
  its edit time instead of the client's, because the lift's second flush lets
  the column's onupdate overwrite it.

The fix follows in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:26:15 -04:00
bvandeusenandClaude Opus 5.5 4d61b34b85 ci: the Windows installer waits for the Rust checks, like the Linux bundles do
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 33s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 17s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 33s
Desktop (Tauri) / Clippy, tests and rustfmt (push) Failing after 2m58s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Clippy, the workspace tests and rustfmt move out of the Linux `build` job
into their own `verify` job, and both publishing jobs need it. Before this,
`windows` needed only `decide`, so on run 8411 a red clippy stopped the Linux
lane while the Windows installer built and published to dev-rolling (#5184,
rule 177).

This commit also carries a deliberately failing step at the end of `verify`.
It is the red half of the proof: both publishers must report `skipped`. The
next commit removes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:22:28 -04:00
bvandeusenandClaude Opus 5.5 be200641cd core: notes show their real created and edited times, and desktop search works
CI & Build / Build now, or wait for Android? (push) Canceled after 0s
CI & Build / TypeScript typecheck (push) Canceled after 0s
CI & Build / Python lint (push) Canceled after 0s
CI & Build / Python tests (push) Canceled after 0s
CI & Build / integration (push) Canceled after 0s
CI & Build / Build & push image (push) Canceled after 0s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m41s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 10m41s
Both bugs were caught red by the tests in 732fd7a (run 8418: 5 failed, 147
passed) before this fix.

- load_note reads columns by NAME. Dropping `color` (fa89da1) shifted every
  column after it and the two timestamps were missed, so created_at showed the
  last edit and updated_at showed the trash time — null on any live note. Only
  the read was wrong; nothing stored is, so no data needs repairing.
- The board's text facet binds its pattern once for its one placeholder. It
  pushed it twice after the title column went (95aa10c), and rusqlite refused
  every query with InvalidParameterCount — every desktop search failed.
  Android searches through store::search and was never affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:42:51 -04:00
bvandeusenandClaude Opus 5.5 732fd7a827 core: the store tests pass clippy and rustfmt, so they reach the test step
CI & Build / Build now, or wait for Android? (push) Canceled after 0s
CI & Build / TypeScript typecheck (push) Canceled after 0s
CI & Build / Python lint (push) Canceled after 0s
CI & Build / Python tests (push) Canceled after 0s
CI & Build / integration (push) Canceled after 0s
CI & Build / Build & push image (push) Canceled after 0s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 3m48s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 3m55s
Android / Kotlin + Rust (APK) (push) Canceled after 4m29s
7bc8e04 never got as far as running them: clippy's cloned_ref_to_slice_refs
rejected four `&[x.clone()]` slices, and the file wasn't rustfmt-formatted.
Still tests only — the expected RED is the two timestamp tests and the
text-search tests, ahead of the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:35:10 -04:00
bvandeusenandClaude Opus 5.5 7bc8e04518 core: the local store has tests, and two of them fail on today's code
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 33s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m46s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 5m43s
store.rs had none, which is how two bugs reached desktop and Android unseen.
These exercise every board facet, the timestamps, tags, revisions, items,
trash, reminders and label merges against a real migrated schema.

Two are expected RED on this commit, on purpose, so CI shows they catch what
they were written for:
- each_timestamp_comes_from_its_own_column / a_new_note_carries_both_timestamps:
  load_note reads created_at and updated_at one column too far right since
  fa89da1 dropped `color`.
- text_search_*: the text facet binds its LIKE pattern twice for one `?`,
  left over from title+body (95aa10c).

The fix follows in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:29:33 -04:00
bvandeusenandClaude Opus 5.5 c5f93cf9f1 rename: the sign-in screen shows Inkwell's mark, and every tab says Inkwell
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 12s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / integration (push) Successful in 43s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 1m0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m28s
Desktop (Tauri) / Update manifest (push) Successful in 10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m22s
The login and register screens still drew a hard-coded "TS" tile. They now
use /icon.svg, the same mark the shell's header shows.

Browser tabs took index.html's static <title> and never changed it, so every
tab read the same, and some browsers showed the URL instead. usePageTitle,
mounted once in App.vue, sets "<page> · <site name>". Routes outside the shell
name themselves with meta.title. Board lenses use the lens name the header
already shows, now in useLensName so the tab and the header read from one
place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:52:50 -04:00
bvandeusenandClaude Opus 5.5 6d082b2ad8 rename: the docs say Inkwell, and nothing else still says ThoughtSync by accident
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 16s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m2s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m36s
Step 6 of milestone 481. README, docs/*, ci-requirements.md, the desktop and
Arch READMEs, alembic.ini, .gitignore, the frontend package name, the service
worker's cache name (its activate handler deletes any cache by another name, so
the old one is cleaned up), and the Android names in the release body.

What still says thoughtsync does so on purpose (Scribe note 5071):
- the desktop data crossover (crossover.rs) and its startup log
- the old-export import marker
- the "Upgrading from ThoughtSync" block in .env.example, and compose's pointer
  to it
- the packages being retired: deb conflicts/replaces thought-sync, pacman
  thoughtsync and thoughtsync-desktop
- the Android signing keyAlias, which names a key in the existing keystore
- history: shipped alembic migrations, and the test-binary hashes that
  ci-requirements.md records from 2026-08-18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:35:02 -04:00
bvandeusenandClaude Opus 5.5 81cd719327 rename: Android is Inkwell — package, applicationId, uniffi class, assets
Step 4 of milestone 481 (Scribe note 5071: a full rename).

- namespace and applicationId com.fabledsword.inkwell; the Kotlin package moves
  with them, and ktlint re-sorted the imports the rename reordered (checked
  locally with CI's ktlint 1.4.0 and detekt 1.23.7, both clean)
- uniffi: class Inkwell in com.fabledsword.inkwell.core, InkwellApplication,
  InkwellTheme, Theme.Inkwell, log tags, prefs and work names, client agent
  inkwell-android
- the lane publishes inkwell.apk / inkwell-android.json; fetch-clients,
  guard-forward, publish-release and write-manifest read the same names

A new applicationId is a new app. The old ThoughtSync app keeps its own store
and stays installed beside it. Notes cross over by syncing, and the old app is
then removed by hand.

Kept: the signing keyAlias is still "thoughtsync". It names the key inside the
existing keystore, and the key, and so the certificate, are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:34:02 -04:00
bvandeusenandClaude Opus 5.5 256fba3610 icon: Inkwell's mark is a black inkpot and quill on the brand yellow
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 43s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m13s
Desktop (Tauri) / Update manifest (push) Successful in 8s
Android / Kotlin + Rust (APK) (push) Canceled after 11m42s
Step 5 of milestone 481. Replaces the linked-notes constellation, which had been
stale since note links were dropped (alembic 0024). The colour scheme stays.

packaging/icons.py draws the mark once and renders every variant from it: the
rounded tile (web, desktop), the maskable full-bleed web icon, and the Android
adaptive foreground. The detail (shaft, vane splits, glint) is cut out of the ink
with a mask rather than painted on in yellow, because the Android foreground is
now transparent and its alpha is also the themed-icon silhouette. The old
foreground was the opaque maskable tile, which a themed icon would have drawn as
a solid square.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:30:51 -04:00
bvandeusenandClaude Opus 5.5 fe6f0746b2 rename: the desktop is Inkwell — crates, Tauri identity, data move, packaging
Step 3 of milestone 481 (Scribe note 5071: a full rename).

- crates thoughtsync-{core,desktop,ffi,uniffi-bindgen} → inkwell-*, the
  Cargo.lock entries moved to match (checked with `cargo metadata --locked`)
- Tauri: productName "Inkwell", identifier com.fabledsword.inkwell, binary
  `inkwell`, updater feed on bvandeusen/inkwell, store file inkwell.db
- client agent inkwell-desktop, headers X-Inkwell-Client/-Protocol (the server
  reads neither), capture event inkwell://captured, display-version env
- .deb: conflicts + replaces thought-sync, so the updater's install retires the
  old package instead of colliding on it. kebab-case("Inkwell") is `inkwell`, so
  the package name finally matches the command and verify.sh now asserts it
- pacman: inkwell, conflicting with and replacing thoughtsync and
  thoughtsync-desktop
- AppImage ~/Applications/Inkwell.AppImage, menu entry inkwell.desktop,
  installer, release titles, desktop asset names in fetch-clients.sh

The one shim, chosen by the operator because it is the only copy of a
local-first user's notes: crossover.rs moves the old
com.fabledsword.thoughtsync app-data dir's contents into the new one on startup,
before the store opens, renaming thoughtsync.db and its -wal/-shm with it. It
skips when the new dir already has a store, and anything already in the new dir
wins (the installer writes its channel marker there first). Tested.

Android's Kotlin side (package, applicationId, uniffi class) is step 4. Its
release asset names stay thoughtsync.* until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:27:26 -04:00
bvandeusenandClaude Opus 5.5 a706644455 rename: the server is Inkwell — package, env vars, image, compose, export marker
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 45s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m33s
Desktop (Tauri) / Update manifest (push) Successful in 7s
Android / Kotlin + Rust (APK) (push) Successful in 11m25s
Step 2 of milestone 481. The operator chose a full rename (Scribe note 5071), so
this goes past the display strings into the identities:

- src/thoughtsync → src/inkwell; every import, the Dockerfile and both compose
  commands, alembic env, pyproject
- THOUGHTSYNC_* → INKWELL_* (database URL, secret key, log level, tag/port/bind)
- container data dir /var/thoughtsync → /var/inkwell
- image git.fabledsword.com/bvandeusen/inkwell; Postgres user/db default inkwell;
  CI's integration service follows
- the files the image serves are inkwell.*. fetch-clients.sh still fetches the
  thoughtsync-named release assets, because the lanes that publish them are
  renamed in steps 3 and 4
- exports are written with app "inkwell"

Two deliberate exceptions, both because data rides on them:

- compose volumes are now named explicitly and overridable (INKWELL_DB_VOLUME,
  INKWELL_DATA_VOLUME), so a deployment installed as ThoughtSync points at the
  volumes and DB identity it already has. .env.example says exactly what to set
- import still accepts app "thoughtsync", because exports written before the
  rename are backups. Tested both ways

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:18:49 -04:00
bvandeusenandClaude Opus 5.5 f806e35d41 rename: the apps say Inkwell — web, desktop, Android and server strings
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 11s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 58s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 15s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m50s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 11m37s
ThoughtSync is renamed Inkwell ("Fabled Inkwell" in full; Scribe note 5071).
This is step 1 of milestone 481: every string a person reads in the running
apps. Identities installed clients depend on are deliberately untouched — the
Tauri productName (it derives the .deb Package: field), identifier and binary
name, applicationId, X-ThoughtSync-* headers, the export's app marker, env vars,
module and crate names.

- web: title, PWA manifest (name "Fabled Inkwell", short_name "Inkwell"),
  offline page, icon labels, build labels, prompts, notification title
- server: site_name default, import error, link-preview User-Agent
- 0030: a stored site_name of exactly the old default follows the rename. The
  Settings page saves every key, so most servers hold "ThoughtSync" without an
  admin ever having chosen it; a name they typed is left alone
- desktop: window title, default device name, local-mode site name, log line
- android: app_name and the strings that name the app
- core: probe and compatibility messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:16:14 -04:00
bvandeusenandClaude Opus 5 fb469ed73a update: wrap the dev-rolling feed assertion the way rustfmt wants
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m55s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Update manifest (push) Successful in 4s
7296889 lengthened `/dev/latest.json` to `/dev-rolling/latest.json` in
each_channel_has_its_own_fixed_feed, which pushed the assert past the
line width. `cargo fmt --all --check` failed the Linux desktop job (run
6358) after Clippy and the tests had passed, so that build, its publish
and the manifest job never ran. Layout taken verbatim from the diff
rustfmt printed; no behaviour change.

Scribe #2184.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
2026-09-10 18:44:02 -04:00
bvandeusenandClaude Opus 5 72968897ab channels: the dev channel publishes on dev-rolling, so its tag stops shadowing the branch
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 3m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m20s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m13s
The rolling dev release lived on a tag named `dev`, beside the branch
named `dev`. Once a clone had fetched tags, `git push origin dev` failed
with "src refspec dev matches more than one" (Scribe #2184, note #3042),
and every session had to know to spell out refs/heads/dev.

The channel is still `dev` everywhere a person sees it: the app's
setting, `install.sh --channel dev`, the stored pref. Only the release
tag moves, to `dev-rolling`, matching roundtable-android. `stable` has no
branch to collide with and keeps its name.

- packaging/channel-tag.sh is the one channel -> tag mapping CI reads:
  the publish steps in android.yml and desktop.yml, the manifest job,
  fetch-clients.sh and guard-forward.sh. guard-forward exits 2 on an
  unmapped channel instead of fetching an empty URL and passing.
- update.rs and install.sh carry their own copy because neither can run
  it; update.rs gains a test that no channel feed is named like a branch.
- tests/test_channel_tag.py runs the script: no tag is a branch name,
  dev is exactly dev-rolling, an unknown channel fails with no output.
- publish-release.sh titles the release "ThoughtSync dev (rolling)", so
  the tag name does not leak into what people read.

TEMPORARY bridge: desktop apps installed before this have
.../download/dev/latest.json compiled in. The dev manifest job sets
BRIDGE_TAG=dev, and write-manifest.sh writes the same latest.json to
the old `dev` release. Its URLs name dev-rolling assets, so those apps
update once into a build that reads the new tag. The bridge, and the old
release and tag, are removed once installed apps have crossed over.
Until then the push still needs the explicit refspec, as
ci-requirements.md now says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
2026-09-10 18:34:11 -04:00
bvandeusenandClaude Opus 5 53d51ce01c ci: artifact uploads move to stock upload-artifact@v7
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 45s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m33s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s
The Android APK upload and both desktop bundle uploads (Linux and
Windows) went through the bvandeusen fork mirror, with comments saying
stock upload-artifact throws GHESNotSupportedError on this hostname. That
stopped being true when the runner moved to gitea/runner 3.x, which
edits the refusal out of the action bundle; stock upload v4-v7 and
download v4-v8 were proven on 2026-09-10 (Scribe spike #3843) and the
same swap is verified on four other repos.

Artifact names, paths, if-no-files-found: error and the no
continue-on-error stance are unchanged. ci-requirements.md now says
stock v7 and keeps what is still true: @v3 uploads are invisible.

Scribe snippet #2271, milestone 395.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
2026-09-10 17:32:55 -04:00
bvandeusenandClaude Opus 5 cf2854a029 ktlint: a multiline .border() left the next '.' orphaned, exactly as #3110 records
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 5s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 25s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m2s
`standard:chain-method-continuation` on `LinkPreviewRow.kt:83`. The `.border(…)`
call took three arguments across four lines, and the `.padding(…)` after it then
began a line with a `.` — which the rule only accepts glued to the closing
paren, `).padding(…)`.

Issue #3110 hit this same rule in `NoteCard.kt` and recorded the fix: do not
write the multiline element. Naming `shape`, `padH` and `padV` first collapses
`.border` back to one line and removes the duplicated RoundedCornerShape at the
same time, which is better than what ktlint was willing to accept.

Also did what #3110's verification note says to do rather than fixing only the
line the linter named: scanned every Kotlin file this branch touched for the
same shape — a multiline chain element followed by a `.` on a new line — and
found no others. ktlint reports one violation and stops, so a second would have
cost another full Android lane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 19:01:09 -04:00
bvandeusenandClaude Opus 5 62338bb0a4 android: a link in a note renders as a link card, not a bare URL
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 11s
Android / Kotlin + Rust (APK) (push) Failing after 3m33s
The web and desktop have shown link previews since #2898; the phone showed the
raw address. The data was already on the device — `Note.previews` is populated
by the core and carried through the FFI — and nothing under `app/src/main` read
the field.

The three presentation rules are copied from `NoteCard.vue` rather than
re-decided, so the same note reads the same way on every surface:

  * A note that is NOTHING but a URL renders as its preview and nothing else.
    Printing the address under a card that already says where it goes is saying
    the same thing twice, badly.
  * Links mentioned INSIDE a note get a compact strip at the FOOT of the card.
    Above the body would put a stranger's headline where the note's first line
    should be; the web learned that in M13.
  * Several stack.

`LONE_URL` mirrors the web's `LONE_URL_RE` including the tolerated whitespace —
if the two regexes disagree, one note reads as a card here and a paragraph
there.

Falling back to the URL is deliberate in all three of the cases that produce no
preview: not a lone URL, not unfurled yet, or never unfurlable. A note written
on the phone and not yet synced is permanently in the middle one, because the
unfurl is server-side (`unfurl_queue.py`) and arrives on a later pull — so that
state has to look deliberate, and showing the link does.

No unfurl fetch was added here, and none should be: a phone fetching OG tags
would be a second SSRF-hardened fetcher on the surface least able to afford the
call.

## No image, and that is a question rather than an omission

`LinkPreview.image_url` is a REMOTE third-party address — the web renders it
straight from whatever host the link points at. Matching that here would have
this app fetch images from arbitrary hosts, on a phone, on possibly metered
data, and would make it the first image loading anywhere in this client: there
is no loader, no cache, and not one `Image(` in the whole app today. That is a
decision about privacy and data use, not a rendering detail, so the text card
ships and the image is asked about rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 18:51:51 -04:00
bvandeusenandClaude Opus 5 1a41373347 board: a note trashed from search results now leaves the results
Search for something, long-press a hit, Move to trash: the snackbar said it
happened and the card sat there until the query next ran. Reachable from the
editor's overflow too — both go through `mutate`.

`mutate` kept the existing list whenever a search was running, with the
reasoning recorded in place: search results are the answer to a query, not a
live view, and running the BOARD query underneath them would replace the hits
with the whole board.

That is right about the board query and wrong about the note. A hit that no
longer matches has left the answer, not just moved within it — pinning one and
watching it not re-sort is fine; trashing one and watching it stay is not.

So the search is re-run instead of the destination loaded. The results are
still the answer to the query, just a current one, and it costs one local
SQLite query — the same argument the surrounding comment already makes for
reloading the board.

Creating a note while searching still leaves the list alone: a new note that
does not match the query has no business appearing in its results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 18:51:51 -04:00
bvandeusenandClaude Opus 5 729d0dadf1 editor: collect the refund — the web editor autosaves on an idle pause
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m29s
Desktop (Tauri) / Update manifest (push) Successful in 5s
#2971's engine work was already done and its benefit was never taken up here.

Both engines coalesce revision snapshots to one per editing session —
`src/thoughtsync/revisions.py::should_snapshot` and `store.rs`'s namesake, the
server's applied on the PATCH path AND in `sync.py`, with four integration
tests covering it. So a write has cost a write, not a write plus a revision,
for some time.

But this editor still wrote only on `close()`. That save-on-close existed
BECAUSE writes were expensive; with the reason gone, all that was left was the
cost — a tab closed mid-paragraph lost the paragraph, which is the one thing a
notes app must not do. Android already debounces (`BoardViewModel`); the shared
Vue editor did not, so web and desktop kept paying for a trade that had been
cancelled.

Now: a 1s idle pause writes.

EDIT MODE ONLY, deliberately. In compose, `dismiss` discards a note that was
never persisted so an accidental keystroke or a type-to-compose never litters
the board. An autosave there would create the row and quietly take that
behaviour away. Materialising a compose on first keystroke is a separate
decision (#2967), not a side effect of this one.

Three details that decide whether it is safe rather than merely present:

  * `flush` returns without writing while a save is in flight, so an autosave
    landing there would silently drop everything typed since that save began.
    It RE-ARMS instead of skipping.
  * Errors are swallowed and retried on the next pause. An autosave that
    interrupts typing with a message is worse than one that waits, and `close`
    still surfaces a real failure where the person is looking.
  * The timer is cancelled by `close`, by `dismiss` and on unmount, so nothing
    fires through a component during its leave animation or after it is gone.

Checked and found harmless rather than assumed: `notes.reconcile` replaces the
store's item but never touches `useNoteEditor`'s `editing` ref, so the
`watch(() => props.note)` that calls `setBody` does not fire on a save. Were
that not true, autosaving would have reset the field and the caret every
second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 18:26:05 -04:00
bvandeusenandClaude Opus 5 23a61365da capture: the suggested shortcut is a UI affordance, so it lives in the UI
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Successful in 17s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m31s
Desktop (Tauri) / Update manifest (push) Successful in 5s
`-D warnings` failed the Linux lane on `constant SUGGESTED is never used`, and
it was right — the suggestion is implemented in `bridge.ts` as
SUGGESTED_CAPTURE_SHORTCUT, and nothing in Rust ever read the copy here.

Deleted rather than exposed through a command. This side accepts any
combination the OS will take; picking one to put in front of someone as a
starting point is a UI decision, and a constant here would only be a second
copy of a string one layer reads and the other does not.

Worth noting what this run DID prove, since the previous one proved nothing:
the lockfile gate passed and the Windows job built the NSIS installer end to
end. So `tauri-plugin-global-shortcut`'s handler signature — the thing I could
not verify without a toolchain — is correct, and the feature compiles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 18:13:31 -04:00
bvandeusenandClaude Opus 5 6c0153be1e desktop: a global hotkey opens a small window to write in, now with its lockfile
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 29s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m5s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m22s
Restores 42e06da, which was reverted only because Cargo.lock had not been
updated for the new crate and every cargo invocation in CI passes `--locked`.
Both desktop jobs failed on that line before compiling anything, so nothing
about the code had been judged.

The lockfile was generated in CI's own `ci-tauri:1.97` image — one container,
`cargo fetch`, nothing built. `cargo fetch` and NOT `generate-lockfile`: the
latter re-resolves from scratch and would have churned versions across the
whole workspace to add one dependency. The diff is 67 insertions, zero
deletions, six packages — tauri-plugin-global-shortcut plus global-hotkey,
x11rb, x11rb-protocol, xkeysym and gethostname. Nothing existing moved.

The feature itself, unchanged from 42e06da:

Press the combination anywhere and a small window arrives over whatever you
were doing; type, Ctrl/Cmd+Enter, gone. The board never comes forward.

There is no default shortcut on purpose — any default is a key combination
taken away from something else on somebody's machine, silently, at install
time. CommandOrControl+Shift+N is offered as a one-click suggestion.

Stored and live are separate fields because they disagree: a combination
another app holds is saved and does nothing when pressed, and a Wayland
compositor may refuse global grabs outright. `capture_shortcut_set` registers
before storing, so a refused combination is never written down as if it worked.

The window hides rather than closes and keeps its text, so an interrupted
capture is still there next press — which is what makes Escape safe. A failed
save keeps it open too, rather than discarding the only copy of something just
written in order to report a retryable problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 18:05:20 -04:00
bvandeusenandClaude Opus 5 10ea15bef0 Revert the desktop hotkey: a new crate needs a Cargo.lock this machine cannot write
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m7s
Desktop (Tauri) / Update manifest (push) Successful in 4s
`42e06da` added `tauri-plugin-global-shortcut` to Cargo.toml without updating
Cargo.lock, and every cargo invocation in CI passes `--locked`. Both desktop
jobs failed on the same line before compiling anything:

    error: cannot update the lock file ... because --locked was passed

So this says nothing about whether the code is right — clippy never ran. The
gate did exactly its job.

There is no Rust toolchain on this workstation (rule 10 — CI verifies), and a
lockfile is the one artifact CI is deliberately forbidden to generate. Hand-
writing the entries is not a real option: it needs the exact checksum and the
whole transitive tree, and a wrong checksum fails harder than a missing one.

Reverted rather than left red, because a red `dev` blocks everything behind it
and the Android half of #1899 is green and unaffected at c8318c3. The work is
intact in 42e06da and comes back with `git revert 5e0c...` once the lockfile
exists — nothing here needs rewriting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 09:10:33 -04:00
bvandeusenandClaude Opus 5 42e06da576 desktop: a global hotkey opens a small window to write in, and nothing else
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 30s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 5s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 35s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 35s
The other half of #1899. Press the combination anywhere and a 520x220 window
arrives over whatever you were doing; type, Ctrl/Cmd+Enter, it is gone. The
board never comes forward, which is the whole point — bringing the app up to
write one line is the friction this removes.

## There is no default shortcut, deliberately

A global shortcut is the one setting here that can collide with software this
app knows nothing about. Any default is a key combination taken away from
something on somebody's machine, silently, at install time. So the feature is
OFF until a combination is chosen, and choosing one is how it turns on.
CommandOrControl+Shift+N is offered as a one-click suggestion, never applied
on the user's behalf.

## Stored and live are reported separately

`CaptureShortcut` carries both `shortcut` and `registered`, because they
genuinely disagree: a combination another app grabbed first is saved and does
nothing when pressed, and on Wayland a compositor may refuse global grabs
outright. Saying only "your shortcut is X" would be a lie with a keystroke
attached, so the settings row says "saved but isn't active — something else is
holding it". `capture_shortcut_set` registers BEFORE storing, so a
combination the system refuses is never written down as though it worked.

Registration at startup is best-effort and logged: a shortcut that worked when
it was chosen can be taken by something installed later, and the app must
still open.

## Two windows, one database, no shared store

The capture window runs a second copy of the frontend with its own Pinia
stores, so a note saved there is invisible to the board until it is told. It
is told — `capture_done(saved)` emits to `main`, and BoardView reloads. The
emit failing is cosmetic (the note is already in SQLite) so it is logged, not
raised.

The window is opened at `index.html?capture=1` rather than at `/capture`
because the bundled assets are served as FILES: a path with no file behind it
404s in the production build while routing fine under the dev server. The
router turns the query into the route.

It is hidden rather than closed on the way out, and it keeps its text. A
capture interrupted by something more urgent is still there on the next press,
which is what makes Escape safe to press. A failed save also keeps the window
open holding the text — hiding it would throw away the only copy of something
just written in order to report a problem you could retry your way out of.

## Where the setting lives

Rule 25 says a tunable belongs in the UI, and this one has to be. It sits in
the desktop's Sync screen beside the update channel, not in admin Settings:
that screen is the SERVER's and bounces on desktop anyway, while this is a
property of one installation on one machine. Persisted with the same
`store::set_pref` the update channel uses.

No @tauri-apps/api dependency was added — everything routes through `invoke`
and the `withGlobalTauri` global, as the rest of the bridge does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 09:02:39 -04:00
bvandeusenandClaude Opus 5 c8318c323a android: Share → ThoughtSync, and a "New note" entry in the selection toolbar
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m21s
Capture without opening the app first — the input half of #1899. Two ways in:
the share sheet from anywhere, and the text-selection toolbar in any app's
text field.

## The note is created, not pre-filled

The obvious build is "open the editor on a draft holding the shared text".
That silently loses it. `NoteEditorScreen`'s flush is guarded by
`bodyText != note.body`, so a draft handed the text already has nothing to
save — share a link, press back without typing, and it is gone. Which is
exactly the shape of a share: the common case is walking away.

So `captureShared` makes the row first and opens the editor on the real
note. A share has already said "keep this"; creating it is what honours
that, and back then leaves a saved note rather than a decision.

## launchMode="singleTop"

The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own intent,
which is why `onNewIntent` already worked there. A share intent is built by
the OTHER app and nothing here can add a flag to it, so the activity has to
declare it. Without that, every share while the app was running would stack a
second MainActivity — a second view model, a second board, and a back press
landing on a stale copy of the same app.

## Subject and text, both

A browser sends EXTRA_SUBJECT as the page title and EXTRA_TEXT as the URL.
Keeping both makes the note read as its title, because the core names a note
by its first line — the difference between a board you can scan and a column
of identical links. `distinct` because plenty of senders put the same string
in both.

The extras are removed on read, like the reminder's note id and for the same
reason: the activity keeps its launch intent, so without consuming them a
rotation would replay the share and mint the note again.

## Not included: images

`image/*` is deliberately absent from the filter. Nothing in this app can
create an attachment — the core has `delete_attachment` and no counterpart,
and the FFI exposes neither. Declaring the mime type would put ThoughtSync in
front of people in the share sheet for a job it cannot do, and fail after
they had already chosen it. Adding it needs an attachment-creation path
through the core, the FFI and sync, which is its own piece of work.

The desktop half of #1899 — a global hotkey — is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-09-01 08:53:38 -04:00