25 Commits
Author SHA1 Message Date
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
bvandeusenandClaude Opus 5 cc50812a86 ktlint: the Tags imports landed after SyncScreen, and SyncState sorts after that
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 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
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 12s
CI & Build / integration (push) Successful in 23s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m8s
`standard:import-ordering`. The two new imports were inserted by anchoring on
`com.fabledsword.thoughtsync.ui.SyncScreen`, which looked like the right
neighbour and is not — `SyncState` and `SyncViewModel` both sort after it, so
Tags* wedged into the middle of the Sync block.

Moved below `SyncViewModel`. Every import block in the five files this branch
touched is now confirmed sorted, not just the one ktlint happened to reach
first — it 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-08-31 19:53:08 -04:00
bvandeusenandClaude Opus 5 1e54b80f15 android: a Tags screen, so the phone can do more than attach tags to a note
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 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
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 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 3m28s
Android could list tags and mint new ones. It could not rename, recolour,
delete or merge one — and since the per-note colour picker was removed with
2949, tag colour is the ONLY colour control in the product, which meant an
Android-only session had no way to change any colour anywhere.

A destination reached from the drawer, not a modal. The web's LabelsModal is
a modal because a desktop can float one over the board; on a phone this is a
place you go to tidy up, and a full screen is what that is.

The manage entry is an action ON the drawer's Tags header rather than a row
in it, so it cannot be mistaken for a sixth lens. The header now renders even
when there are no tags: this screen is where you make the first one, and
hiding the way in until one exists is a door that only appears once you are
already inside.

## The two calls this needed

RENAME and MERGE deliberately do not follow the same rule, and the screen
says so rather than hiding it.

  * A rename that lands on an existing name merges, older survives (3324).
    That path is accident-prone — it is a text field, and a typo reaches it —
    so it needs a rule that cannot depend on which way round it was typed.
    The screen catches the collision against the LIST, not from what the core
    returns: the survivor may be the tag being renamed, so an unchanged id
    afterwards proves nothing. Then it asks before merging.

  * An explicit merge keeps its direction. Here the person is choosing, and
    the direction IS the intent — folding #grocery into #groceries is a
    decision, and overriding it with age would refuse the thing they asked
    for. The price is that the direction has to be unmissable, so the body
    names the tag that stops existing and every row offered is the survivor.

Delete quotes the note count, because "it is on 40 notes" is a different
decision from "delete this tag?". The count comes from `list_labels`, the
only call the core populates one on. It also says that a tag written as #tag
in a body comes back on that note's next edit — deleting the row cannot
un-write the word, and that is better said than discovered.

## The board had to learn something

`Destination.WithLabel` holds an id, and deleting or merging a tag the board
is currently LOOKING at would strand it on a lens that queries a row which no
longer exists — permanently empty, escapable only via the drawer. So
`loadLabels` became `refreshLabels`: public, and it drops back to Notes when
the current lens is gone. A failed listing deliberately does NOT trigger that
fallback — "I could not read the tags" is not evidence that this one went.

Reused rather than rewritten: `ErrorBanner` (the board and editor already
share it), `MenuItem` from Panel.kt (it closes the menu before acting so a
dialog cannot open under a hanging menu), `PlainTextField`, and the
`NOTE_TINTS` palette — the screen consumes it and does not fork a copy.

`default` stays in the palette on purpose: a tag with that colour gets a hue
derived from its name, so it means "let it pick", and removing it would leave
no way back to that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 19:44:54 -04:00
bvandeusenandClaude Opus 5 550a34d8e2 fmt: rustfmt budgets macro arguments at 60 chars, not the 100-char line
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 4s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m2s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m45s
Both new assertions fit well inside the 100-column limit and both were still
rejected. The governing setting is `fn_call_width` (60), applied to a macro's
argument list: `survivor.id, older.id, "the older row is the one that
survives"` is 62 characters, so rustfmt breaks it and pairs the two values on
one line with the message beneath.

The neighbouring `assert_eq!(survivor.name, "Grocery", "spelled the way the
caller asked")` was accepted at 59 characters of arguments, which is the
same rule agreeing rather than a different one.

rustfmt's own output, pasted back. Second time this lane has caught the same
class of thing in one session — the other was a method chain, budgeted at 60
by `chain_width`. Recorded so the next person reaches for the 60, not the 100.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 16:52:00 -04:00
bvandeusenandClaude Opus 5 193dfb9e94 tags: renaming onto an existing tag merges them, and the older row survives
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) Failing after 2m35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m46s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 5m37s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
The three surfaces did not agree on what renaming a tag onto a name another
one already holds should do, and none of the three answers was good.

I described this wrongly first time and the correction matters. The local
store does NOT silently create a duplicate: `idx_labels_name` is unique on
`lower(name)`, so the bare UPDATE in `rename_label` failed, and the user got
a raw SQLite "UNIQUE constraint failed" as their error message. The server
meanwhile answered 409 "a tag with that name already exists" — and only on
an EXACT match, because its constraint is on the raw name while every
client's index is on `lower(name)`.

That last part is the sharper bug. The server would happily hold "Groceries"
beside "groceries"; no synced client can store both. Creating that pair on
the web armed a pull that fails later, on a phone, in a path with no UI.

Operator's call: a rename onto an existing name means merge — typing an
existing tag's name onto this one says they are the same thing.

  * `store::rename_label` and the server's PATCH now implement one rule.
    THE OLDER ROW SURVIVES and takes the new spelling. Age rather than "the
    one that already held the name", so that renaming A→B and B→A land on
    the same survivor; otherwise the outcome depends on which way round
    someone typed it, and two devices tidying the same pair disagree about
    which id still exists. Ties go to the incumbent, so it stays
    deterministic.

  * The core reuses `merge_labels` rather than reimplementing the move. That
    is the only place that knows to mark every affected NOTE dirty before
    the delete cascades the membership rows away, which is what makes a
    merge reach the server at all.

  * The server's rename and its `/merge` route now share one `_merge_into`
    helper, for the same reason.

  * Both server lookups became case-INSENSITIVE, matching every client. The
    create path is included: it was the one actually minting the unstorable
    pair, so fixing only the rename would have left the door open.

  * The web asks before merging, naming both note counts. A merge cannot be
    undone by repeating it and is now reachable by a typo in a text field —
    the same reasoning as the delete confirmation in #2116. The confirmation
    lives in the shared store, so the desktop gets it too; the FFI does not
    ask, because that belongs to the surface with a person in front of it.

  * The web store detects the merge from the LIST, not the response: the
    survivor may be the row we asked to rename, so an unchanged id proves
    nothing.

Tests: three integration tests over a real database (both rename directions
land on the older row; a case-varied create returns the existing tag) and
two through the Android FFI, which is the binding the phone will use.

Also fixes a straggler from 8c7553d — the delete confirmation still said
"the label".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 16:46:15 -04:00
bvandeusenandClaude Opus 5 8c7553d619 copy: the product says "tags" now, and the schema keeps saying Label
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 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 23s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m7s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m17s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
Two words for one concept cost real comprehension: over a single exchange
the operator concluded that auto-tagging did not exist (it does, in
`derive.rs`) and that a tag-management view did not exist (it does,
`LabelsModal.vue`). The `#` is how most of these get made, so the `#` wins
the noun.

User-visible strings only, on all three surfaces plus the server's errors.
`Label`, `NoteLabel`, `via_tag`, `label_id`, the tables, `/api/labels` and
the FFI names are all untouched — renaming those touches migrations and the
wire format to buy nothing a reader can see.

Two of these were more than a find-and-replace:

  * Android's `label_from_tag` said "from #tag", sitting beside a chip that
    already renders as `#name`. Once every one of them IS a tag that hint is
    circular. What it actually tells you is that the note's BODY owns this
    one — which is why it alone has no remove cross — so it now says "from
    the text".

  * The web's empty state said "No labels yet — create one above" while
    Android's already mentioned the `#` route. The web now says it too. That
    is the exact fact the operator did not have.

The paired `aria-label`s went with their `title`s; a screen reader saying
"label" while the tooltip says "tag" is the same confusion with a smaller
audience.

Left alone deliberately: `json_error("invalid label")` and
`"label_ids must be a list"` in `notes/__init__.py` name the `?label=` query
parameter and the `label_ids` request field. Those are wire surface, not the
word a person reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 15:53:28 -04:00
bvandeusenandClaude Opus 5 d838b27518 ffi: Kotlin could list and create a tag but never rename, recolour, delete or merge one
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
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
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 10s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m0s
`core/src/local/store.rs` implements all seven label operations. The uniffi
object exposed three of them, so Android could attach tags to a note and
mint new ones, and could do nothing else with them ever.

The four additions are pure passthrough, because reading the store showed
both of the things #2963 said to check rather than assume are already
handled there:

  * The note count exists. `Label` carries `count: Option<i64>` and
    `list_labels` computes it per row, excluding trashed notes — which is
    the number a delete confirmation should show. The single-label returns
    all end in `load_label` and leave it `None` on purpose, so a screen must
    read counts from the LIST and never from an operation's result.

  * Sync is free. `rename_label` and `set_label_color` set `dirty = 1`;
    `remove_label` records a pending delete; `merge_labels` records one for
    the source AND marks every note that carried it dirty before the delete
    cascades the membership rows away, because push sends `label_ids` per
    note.

So no store change, no sync change, no count plumbing — the binding only.

One divergence found and documented rather than fixed: renaming a tag onto
an existing name is a 409 on the server (`labels.py:94`) and a silent
duplicate in the local store. The desktop has always had this, calling the
same `store::rename_label`; Android now inherits it. Deciding which side is
right belongs with the screen (#2964), not with the binding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 15:52:00 -04:00
bvandeusenandClaude Opus 5 a69159e562 fmt: rustfmt breaks the tuple-index chain, and the desktop lane means it
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
CI & Build / Python tests (push) Successful in 10s
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 23s
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 5m27s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m21s
`cargo fmt --all --check` failed the desktop lane on one hunk in the new
`client_headers_identify_app_and_protocol` test. Clippy and every test
passed; only the formatter objected.

rustfmt splits `client_headers()[0].1.starts_with(..)` across lines because
an index followed by a tuple field followed by a call is a three-element
chain, and it will not keep one on a single line inside a macro argument
regardless of width. This is rustfmt's own output, pasted back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 14:57:07 -04:00
bvandeusenandClaude Opus 5 c40916699b sync: the client header said "desktop" from every phone, and named the wrong version
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
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m55s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m37s
`client_headers()` built `thoughtsync-desktop/{CARGO_PKG_VERSION}`, and both
halves were wrong.

This crate is compiled into the Android app as well as the desktop one, so
every phone in the field announced itself as a desktop. And CARGO_PKG_VERSION
here is the CORE crate's version — a number no build stamps and no user has
ever seen — where the thing a reader of that header wants is the app's own
build (note 3127 §5: with no version tags, the artifact's self-report is the
only answer to "which build is this?").

The core cannot know either value, so the host says them. `set_client_agent`
is a OnceLock the desktop fills in `run()` and Android fills in
`ThoughtSyncApplication.onCreate`, before anything can sync. A host that never
introduces itself sends `thoughtsync-unidentified/unknown` rather than a
plausible default: nothing reads this header today, which is exactly why a
wrong value could sit in it for months — the first person to look at a server
log is the first who could catch it, and only if what they see is obviously a
host that never said who it was.

Android's version comes from the INSTALLED package, through a new
`Context.installedVersionName()` that the foot of the Sync screen now shares.
One answer to "which build is on this phone", so the line a person quotes in a
bug report and the line in the server's log cannot disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 08:40:01 -04:00
bvandeusenandClaude Opus 5 ef418a8c92 buttons: one definition of the shape, worn by a <button> and by an <a>
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 4s
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 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 6s
The download links added in fd1e4ae carried their own copy of BaseButton's
class list, because BaseButton is a <button> and cannot hold an href — and a
download must be an anchor, so the browser's own download manager gets the
3-95 MB transfer instead of a blob this app would have to hold in memory.

A copy is not a solution to that; it is two primary buttons that look alike
until someone changes one. So the shape moves to `.btn` + `.btn-primary` /
`.btn-ghost` in the components layer, where both elements can wear it, and
neither owns it.

The `disabled:` variants stay on BaseButton. An anchor has no :disabled, so
they were never shared and pretending otherwise would put a rule in the
shared definition that only one of its two users can ever match.

Verified there is exactly one shape to unify and no third copy: `px-4 py-2.5`
appears in three other files and all three are something else (a toast, a
dashed quick-add affordance, a retention notice). The smaller brand buttons in
AppShell and NoteEditor are a different size, which is a size-variant question
and not this one. And exactly one call site passes a class to BaseButton —
`shrink-0` — which cannot conflict with anything the shape declares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 08:08:01 -04:00
bvandeusenandClaude Opus 5 fd1e4ae487 downloads: five clients, and the page leads with the one that fits you
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 lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The Account page offered the APK and nothing else, because the APK was all
the server held. Step 3 baked in four more, so the single card had to become
a section — and five artifacts is exactly where a downloads page turns into
a table of filenames and stops being a product.

So it LEADS with what fits the machine asking, from the user agent, and keeps
the rest quiet but visible. A wrong guess costs nothing: nothing is behind a
disclosure and every other client is one click away.

Linux gets all three at once, because the UA says "Linux" and nothing about
dpkg or pacman — there is no better answer available. They are named for the
distro rather than the package format, since a person knows which system they
run and not necessarily which packaging it uses. The AppImage carries one
clause of its own: it is 95 MB against 3, and it is also the only bundle that
updates itself in place. Both facts belong to the same decision.

macOS and iOS lead with nothing and say so. There is no build for either, and
"There's no macOS build yet" is the difference between deliberate and broken.

The version renders `unknown` rather than blank, and the download stays
offered — not knowing which build it is, is not a reason to withhold it.

Two things this did NOT do, both deliberate:

The task asked for a Tauri case — do not offer the desktop app to someone
already running it. That case cannot be reached: `/account` redirects to the
board in the desktop app (requiresServer, router/index.ts), because device
tokens are a server-side concept. A branch for it would be dead code.

`.btn-link` mirrors BaseButton's declarations rather than replacing them.
BaseButton is a <button> and cannot carry an href, and unifying the two would
have put every button in the app into an operator pass that CI cannot check —
for a cosmetic gain. The comment names the pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 07:58:17 -04:00
Bryan Van Deusen 8a75e5f340 clients: an unquoted 1.0.3504551 is not JSON, and every sidecar was one
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
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 16s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m27s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Android / Kotlin + Rust (APK) (push) Successful in 8m18s
`fetch-clients.sh` wrote `"version_code": %s` unquoted, which was right when the
only ordering key in sight was Android's integer. The desktop's is Tauri's
`1.0.<minutes>`, and unquoted that is not valid JSON at all — so `json.loads`
raised on all four generated sidecars and the server advertised nothing. A silent
zero, not an error: `_read` treats a malformed sidecar as "no client here", which
is right for a corrupt drop-in and indistinguishable from this.

Caught by running the real fetch against the live dev channel and feeding the
result to the real resolver, rather than by reading the printf.

Also makes `_resolve` wrap BOTH candidate roots in Path(). Only the first was, and
the asymmetry fails the same quiet way: a str `/` str raises TypeError, `_read`
catches it, and a perfectly good directory reads as empty.

The whole pipeline now resolves end to end against the live channel — five of five
platforms, every sidecar valid JSON, one human-readable version across all of them
with each artifact keeping its own comparator type:

  android         2026.08.30.1711  code=3504552        57.6 MB
  linux-appimage  2026.08.30.1711  code='1.0.3504551'  95.3 MB  signed
  linux-deb       2026.08.30.1711  code='1.0.3504551'   3.3 MB
  linux-pacman    2026.08.30.1711  code='1.0.3504551'   2.7 MB
  windows         2026.08.30.1711  code='1.0.3504551'   2.6 MB
2026-08-30 13:21:03 -04:00
Bryan Van Deusen d2f9d316cf tests: 300 comes back as "300" from a platform whose key is not an integer
CI & Build / Build now, or wait for Android? (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 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 15s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
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 41s
The precedence test wrote `version_code=300` for all five platforms and compared
the desktop's against the int it wrote. It comes back as `"300"`, because the
module preserves each platform's own comparator type instead of flattening both
to int — which is the behaviour the change it was testing had just introduced.

A `coded()` helper now says which shape to expect and why, and the assertion runs
over every non-Android platform rather than spot-checking `linux-deb`. The test
caught a real inconsistency in itself precisely because it compared against a
concrete value rather than round-tripping what it wrote.
2026-08-30 13:19:18 -04:00
Bryan Van Deusen ff6e99eb62 image: bake every client in, not just the phone
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
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Failing after 15s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 10s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s
~104 MB on top of ~85 MB, almost all of it the AppImage. That is what the product
being complete costs (rule 23): a self-hoster gets a working app for their machine
from the server holding their notes, with no account on a forge that is private.
The AppImage is not optional within that — it is the only bundle that can replace
itself in place, so a server without one cannot serve in-app updates to anybody.

`packaging/fetch-clients.sh` replaces the inline fetch and writes the fixed names
and sidecars `client_dist.py` reads. It never fails: a platform with nothing
published means the server advertises nothing for it and the UI hides that
download, and eight fetches must not become eight ways to redden a green lane.

THE VERSION IS FETCHED, NOT DERIVED, and this is the part that would have been
wrong the easy way. The obvious shortcut is `version.sh display desktop` in the
image job — it has the checkout. But this commit may not be the commit the channel
is serving: a push touching only `src/` does not rebuild the desktop, so the
channel still holds an older build and a locally-derived version would describe
those bytes with this commit's number. `client_dist.py`'s size check could not
catch it, because size IS measured from the real file — it would sail through and
lie about the version alone. So `write-manifest.sh` now publishes
`thoughtsync-desktop.json` beside `latest.json`, from the same two values in the
same breath, and only size/sha256 are measured at bake time.

Which needed the prune's keep-list, or the sidecar would have been uploaded and
deleted again in the same run — a fixed name is self-limiting, which is exactly
why that list exists.

`version_code` is NOT uniformly an integer, and coercing it was a leftover from
the days when Android was the only platform. Android's must stay a JSON number:
`ClientRelease` in core declares it `i64` and a string fails to deserialize on
every phone in the field. The desktop's is Tauri's semver key `1.0.<minutes>` —
the value its updater actually compares — and `int()` would have rejected every
desktop sidecar CI writes. The table now says which is which, and tests pin both
directions.

Also retires the comment above the fetch step, which claimed the APK came from
"always the rolling dev release" and mentioned `:<version>` images. M314 step 3
made the channel conditional in the code directly below it, and step 6 removed
version-shaped image tags entirely.

Verified against the live dev channel before pushing: the Android half resolves
and exits 0, the desktop half degrades with a warning because the sidecar does not
exist yet, and all five constructed bundle filenames return 200.
2026-08-30 13:11:04 -04:00
Bryan Van Deusen ef8aa9340f clients: the server hands out five platforms, not "the Android client"
Android / Kotlin + Rust (APK) (push) Skipped
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 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 30s
`client_dist.py` was written for one platform and everything structural in it was
already right — drop-in beats baked, the pair must describe one build, absence is
an ordinary answer, metadata public and bytes authenticated. This widens it to a
table rather than building beside it. Its own docstring made the argument years
before there was a second platform: a self-hoster should not need an account on
someone else's forge to get the app for their own notes.

Server side only. CI bakes nothing new until step 3 and the UI reads nothing new
until step 4, so this lands green and inert.

Five rows — android, linux-deb, linux-pacman, linux-appimage, windows — each
naming its artifact, sidecar and mimetype. Fixed filenames, version only in the
sidecar: a version-stamped name would force a glob, and a glob over a directory an
operator drops files into is how you serve the older of two builds, which is the
failure write-manifest.sh already carries a comment about.

THE ANDROID NAMES AND ROUTE DO NOT MOVE. The lane publishes those exact filenames,
clients in the field poll /api/client/android, and `android_client` stays on
/api/config beside the new `clients` map. Renaming them to match the pattern would
buy tidiness and strand every installed phone; retiring the key belongs to a later
change made when nothing polls it, not to the change introducing its replacement.
Fields were added, not moved — `ClientRelease` in core is a plain serde struct and
ignores what it does not know.

PRECEDENCE IS PER PLATFORM, which is the trap the table introduces. "First
directory holding anything wins" would mean dropping in an APK silently retracts
the four desktop downloads. Pinned by a test.

The AppImage needs a third file. It is the only bundle that replaces itself in
place, so the updater verifies a minisign signature before it does — and a bundle
that cannot be verified cannot be offered. A missing or empty `.sig` therefore
makes it absent rather than merely unsigned, and the signature travels WITH the
version so an updater can never pair one build's version with another's signature.

The tests parametrize over the table instead of testing Android and trusting the
rest. The bugs this module can have are not platform-specific, and a suite that
only exercised one platform is how the other four would ship untested.
2026-08-30 12:52:40 -04:00
Bryan Van Deusen f992439588 version: every surface can say which build it is, and two of them were lying
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
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m50s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
Note 3127 §5 removed version tags, so an artifact's self-report is now the only
answer to "which build is this?" — and nothing exists to contradict it when it
is wrong. Three surfaces gain a dim build line: the foot of the web rail, the
login screen, and the foot of Sync on Android.

The login screen because "I can't sign in" is a bug report like any other, and
requiring an account to read a build number withholds it from exactly the people
who can't get past that page. `/api/config` is already public.

Two of the values it was going to show were wrong, which is the part worth
knowing about.

The DESKTOP reported `env!("CARGO_PKG_VERSION")` from `config_get` and from the
startup log. `cargo tauri build --config '{"version": ...}'` overrides
tauri.conf.json, not Cargo's own metadata — so both read the literal `0.2.0` in
Cargo.toml, on every build ever shipped. They now read a display version baked in
by the lane through `option_env!`, hoisted to the crate root because two readers
of one fact is how this repo keeps producing 2181-2183. Not the ordering key
either: `1.0.<minutes>` is the opaque value Tauri's updater compares and must
never be shown to a person, and `update.rs` still reads it because a comparator
is exactly what it is (rule 149).

The SERVER fell back to `__version__` when APP_VERSION was absent, so a server
run from a checkout reported `0.2.0` — a real-looking version naming no build
anybody could obtain. `__init__.py` already asserted the honest answer was
"APP_VERSION being missing, which app.py already handles"; it did not, and a
comment claiming a behaviour two files away is how that stayed true-sounding.
Now an explicit "unknown", with the packaging version left where "unknown" is
not a legal value.

Android reads the INSTALLED package's versionName rather than BuildConfig, so it
reports what is actually on the phone.

Everything renders "unknown" rather than blank when it cannot say. A blank looks
like a layout bug; a plausible default cannot be caught by anything.

build.rs gets `rerun-if-env-changed` for the baked value: cargo does not track an
`option_env!` variable on its own, and the desktop lane having no cache today is
what makes that easy to forget the day one is added.
2026-08-29 23:07:29 -04:00
Bryan Van Deusen 544cf72735 install: the stable fallback is dead now that stable publishes its own bundles
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
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 6s
CI & Build / Build & push image (push) Successful in 29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m25s
Desktop (Tauri) / Update manifest (push) Successful in 4s
It existed for one window: `stable` was a manifest-only pointer at whatever `v*`
tag had last been cut, and `stable` is the DEFAULT channel, so without the
fallback `curl … | sh` was broken for everyone between step 3 landing and the
first merge to `main`. That merge happened (`b6673c6`), and `stable` now holds
its own signed bundles at 1.0.3503145 — AppImage, deb and pacman, all resolving
by the one lookup both channels share.

Kept as a fallback it stops being a safety net and becomes a mask: the branch
only runs when `stable` has no bundles, which from here on means something is
broken, and chasing a `v*` release instead of saying so is the wrong answer.

The header now says the transition is finished and that neither channel should
be special-cased again, because the shape of that code invites re-adding it.
2026-08-29 16:59:44 -04:00
54 changed files with 3653 additions and 379 deletions
+24 -36
View File
@@ -323,53 +323,41 @@ jobs:
docker system prune -af || true
docker builder prune --keep-storage 5g -f || true
# Bake the Android client in, on EVERY image build, so :dev, :latest and
# :<version> all carry one and a `docker compose pull` delivers a new client
# along with the new server.
# Bake EVERY client in, on every image build, so a self-hoster gets a working
# app for their machine from the server holding their notes — without an
# account on this forge, which is private (issue 2091) and is why serving them
# from a release page was never an option for anybody but the operator.
#
# Always the rolling `dev` release — the newest build there is. A versioned
# image therefore carries the newest client rather than one pinned to that
# version; the two negotiate a sync protocol version before linking, so
# "newest" is safe in a way "matching" would not buy anything over.
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
# the price of the product being complete (rule 23), and the AppImage is not
# optional within it: it is the ONLY bundle that can replace itself in place,
# so a server without one cannot serve in-app updates to anyone.
#
# Fetched by the JOB, not by the Dockerfile: the release is private, and a
# Fetched by the JOB, not by the Dockerfile: the releases are private, and a
# token used inside a build lands in the context or a layer.
#
# NEVER fails the build. An image with no Android client advertises none and
# hides the download — a supported state, and the only one available before
# the first Android build has ever published.
- name: Fetch the Android client to bake in
# NEVER fails the build — see the script. A platform with nothing published
# means the server advertises nothing for it and the UI hides that download,
# which is a supported state and the only one available before that platform's
# first build has ever published.
- name: Fetch the clients to bake in
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
mkdir -p client
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves the dev
# client; :latest serves the stable one. This read `download/dev`
# unconditionally until M314 step 3, on every branch — so every stable
# server shipped a dev-channel APK to anyone who downloaded the client
# from it. Not a versioning gap; a plain defect, fixed here because this
# is the step that gave `stable` an APK to point at.
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
# :latest serves stable ones. This read `download/dev` unconditionally
# until M314 step 3, on every branch — so every stable server shipped a
# dev-channel APK to anyone who downloaded the client from it. Not a
# versioning gap; a plain defect, and the reason the channel is chosen here
# rather than inside the script: the caller is what knows which image it is
# building.
case "${{ github.ref_name }}" in
main) channel=stable ;;
*) channel=dev ;;
esac
echo "Baking in the $channel client."
base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel"
ok=1
for f in thoughtsync.apk thoughtsync-android.json; do
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
done
if [ "$ok" = 1 ]; then
echo "Baking in:"
cat client/thoughtsync-android.json
ls -l client/thoughtsync.apk
else
# Both or neither. Half a pair is worse than none: the server would
# read a sidecar describing an APK that isn't there, or an APK it
# cannot state a version for.
echo "::warning::No Android client on the dev release — this image ships without one."
rm -f client/thoughtsync.apk client/thoughtsync-android.json
fi
sh packaging/fetch-clients.sh "$channel" client
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
+24 -1
View File
@@ -162,6 +162,14 @@ jobs:
# separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
# A different value for a different audience: this is the one a person
# quotes in a bug report, the key above is the one only a comparator
# sees. Exported rather than passed as a flag because the macro that
# reads it is in Rust source, not in Tauri's config.
THOUGHTSYNC_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
export THOUGHTSYNC_DISPLAY_VERSION
echo "Baking display version $THOUGHTSYNC_DISPLAY_VERSION"
cargo tauri build \
--config '{"build":{"beforeBuildCommand":""}}' \
--config "{\"version\":\"$version\"}" \
@@ -347,6 +355,14 @@ jobs:
# separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
# A different value for a different audience: this is the one a person
# quotes in a bug report, the key above is the one only a comparator
# sees. Exported rather than passed as a flag because the macro that
# reads it is in Rust source, not in Tauri's config.
THOUGHTSYNC_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
export THOUGHTSYNC_DISPLAY_VERSION
echo "Baking display version $THOUGHTSYNC_DISPLAY_VERSION"
updater='{}'
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
updater='{"bundle":{"createUpdaterArtifacts":true}}'
@@ -447,6 +463,12 @@ jobs:
# match the binary it points at is an updater that never settles. It must
# be `key`: this value is matched against bundle filenames.
version="$(sh packaging/version.sh key desktop)"
# The version a PERSON reads, published beside the manifest as
# `thoughtsync-desktop.json`. The image build reads it to describe the
# bundles it bakes in (packaging/fetch-clients.sh) without re-deriving
# anything from its own checkout — which would be a different commit
# whenever the desktop did not rebuild.
display="$(sh packaging/version.sh display desktop)"
# Both channels are rolling: the manifest lands on the same release that
# holds the bundles, and the previous build's bundles are dropped once it
# points at this one. Nothing can reach them, and they are ~100 MB a push.
@@ -460,4 +482,5 @@ jobs:
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
esac
export PRUNE_OLD_ASSETS=true
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
APP_VERSION="$version" DISPLAY_VERSION="$display" \
bash desktop/packaging/write-manifest.sh
Generated
+67
View File
@@ -1286,6 +1286,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1405,6 +1415,24 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "global-hotkey"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e"
dependencies = [
"crossbeam-channel",
"keyboard-types",
"objc2",
"objc2-app-kit",
"once_cell",
"serde",
"thiserror 2.0.20",
"windows-sys 0.59.0",
"x11rb",
"xkeysym",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -3947,6 +3975,21 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b"
dependencies = [
"global-hotkey",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.20",
]
[[package]]
name = "tauri-plugin-log"
version = "2.9.0"
@@ -4196,6 +4239,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-global-shortcut",
"tauri-plugin-log",
"tauri-plugin-updater",
"thoughtsync-core",
@@ -5577,6 +5621,23 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
@@ -5587,6 +5648,12 @@ dependencies = [
"rustix",
]
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "yoke"
version = "0.8.3"
+13 -7
View File
@@ -24,17 +24,23 @@ COPY --from=build-frontend /build/dist/ src/thoughtsync/static/
COPY alembic.ini .
COPY alembic/ alembic/
# The Android client this server hands out. CI fetches the newest published build
# into ./client immediately before this runs (ci.yml), so every image tag — :dev,
# :latest and :<version> alike — ships a client, and a `docker compose pull`
# delivers a new one with no file copying by hand.
# The clients this server hands out — the APK and all four desktop bundles. CI
# fetches the newest published build of each into ./client immediately before this
# runs (packaging/fetch-clients.sh), so both image tags ship a full set and a
# `docker compose pull` delivers new ones with no file copying by hand.
#
# Fetched by the JOB rather than here on purpose: the release is private, and a
# ~104 MB of this image is that set, almost all of it the AppImage.
#
# Fetched by the JOB rather than here on purpose: the releases are private, and a
# token used inside a build ends up in the build context or a layer.
#
# LAST of the COPYs, deliberately: this directory changes on every build, so
# putting it above the `pip install` layer would invalidate that layer every time.
#
# The directory is tracked (client/.keep) so this COPY cannot fail on a tree where
# that step never ran. An image with no APK is a supported state — the server
# advertises nothing and the web UI hides the download (client_dist.py).
# that step never ran. An image with no clients — or with some and not others — is
# a supported state: the server advertises what it has and the web UI hides the
# rest (client_dist.py).
COPY client/ src/thoughtsync/client/
ENV PYTHONPATH=/app/src
+38
View File
@@ -99,15 +99,53 @@
android:supportsRtl="true"
android:theme="@style/Theme.ThoughtSync"
android:usesCleartextTraffic="true">
<!--
launchMode="singleTop" exists for the SHARE filters below.
The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own
intent, so onNewIntent already worked for that one. A share intent is
built by the OTHER app — Chrome, a reader, the text-selection toolbar —
and nothing here can add a flag to it. Without singleTop declared on the
activity itself, every share while the app is running would stack a
second MainActivity on top of the first: a second view model, a second
board, and a back press that lands on a stale copy of the same app.
-->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.ThoughtSync">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!--
Capture without opening the app first: Share → ThoughtSync from
anywhere, and the selection toolbar in any text field.
text/plain ONLY, and image/* deliberately absent. Nothing in this
app can create an attachment — the core has `delete_attachment` and
no counterpart, and the FFI exposes neither. Claiming images in the
share sheet would put this app in front of people for a job it
cannot do and fail after they had chosen it.
-->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!--
The label is what appears in the text-selection menu beside Copy and
Share, where "ThoughtSync" would say who rather than what.
-->
<intent-filter android:label="@string/capture_process_text">
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<!--
@@ -0,0 +1,21 @@
package com.fabledsword.thoughtsync
import android.content.Context
/**
* The `versionName` of the INSTALLED package, or null when it cannot be read.
*
* From the package manager rather than from `BuildConfig`: this reports what is
* actually on the phone, which is the question both callers are asking — a bug
* report reading the foot of Sync, and a server log reading the client header. It
* also needs no `buildFeatures.buildConfig`, which this module does not enable.
*
* Returns null rather than a fallback string, because the two callers want
* different ones: the UI wants a localized "unknown" from string resources, the
* client header wants the literal the core recognizes. Note 3127 §5 governs both —
* with no version tags, the artifact's self-report is the only answer to "which
* build is this?", so a missing name must read as missing and never as a plausible
* default that nothing can contradict.
*/
fun Context.installedVersionName(): String? =
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull()
@@ -33,6 +33,8 @@ import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
import com.fabledsword.thoughtsync.ui.SyncScreen
import com.fabledsword.thoughtsync.ui.SyncState
import com.fabledsword.thoughtsync.ui.SyncViewModel
import com.fabledsword.thoughtsync.ui.TagsScreen
import com.fabledsword.thoughtsync.ui.TagsViewModel
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
import com.fabledsword.thoughtsync.ui.UpdateViewModel
import com.fabledsword.thoughtsync.ui.olderThan
@@ -51,12 +53,24 @@ class MainActivity : ComponentActivity() {
*/
private val requestedNote = mutableStateOf<String?>(null)
/**
* Text shared into the app from elsewhere, waiting to become a note.
*
* Same shape and same reason as [requestedNote]: a share that arrives while
* the app is already running lands in [onNewIntent], long after the
* composition was built, so a piece of state it is already reading is the only
* way in. The activity is `singleTop` in the manifest precisely so that this
* path exists for an intent another app built.
*/
private val sharedText = mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val app = application as ThoughtSyncApplication
requestedNote.value = takeRequestedNote(intent)
sharedText.value = takeSharedText(intent)
setContent {
ThoughtSyncTheme {
@@ -67,7 +81,7 @@ class MainActivity : ComponentActivity() {
// than render an empty board that looks like data loss.
StoreUnavailableScreen(reason = app.openFailure)
} else {
App(core, requestedNote)
App(core, requestedNote, sharedText)
}
}
}
@@ -77,6 +91,7 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
requestedNote.value = takeRequestedNote(intent)
sharedText.value = takeSharedText(intent)
}
/**
@@ -92,10 +107,58 @@ class MainActivity : ComponentActivity() {
intent.removeExtra(Reminders.EXTRA_NOTE_ID)
return id
}
/**
* Read the text a share or a text selection brought in, and CONSUME it.
*
* Consumed for the same reason [takeRequestedNote] is: the activity keeps the
* intent it was launched with, so without removing the extras a rotation would
* replay the share and mint the same note again, with nothing on screen to
* explain where the duplicates were coming from.
*/
private fun takeSharedText(intent: Intent?): String? {
val shared =
when (intent?.action) {
Intent.ACTION_SEND -> intent.takeSendText()
Intent.ACTION_PROCESS_TEXT -> intent.takeProcessText()
else -> null
}
return shared?.takeIf { it.isNotBlank() }
}
}
/**
* The shared text, with a subject line above it when the sender gave one.
*
* Sharing a page from 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 — so this is not decoration, it is what
* turns a board of identical-looking links into a board you can scan.
*
* `distinct` because plenty of apps put the same string in both, and a note that
* says the URL twice is worse than one that says it once.
*/
private fun Intent.takeSendText(): String? {
val body = getStringExtra(Intent.EXTRA_TEXT)
val subject = getStringExtra(Intent.EXTRA_SUBJECT)
removeExtra(Intent.EXTRA_TEXT)
removeExtra(Intent.EXTRA_SUBJECT)
return listOfNotNull(subject, body)
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
.joinToString("\n")
}
/** The selection from another app's text field, via the selection toolbar. */
private fun Intent.takeProcessText(): String? {
val text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()
removeExtra(Intent.EXTRA_PROCESS_TEXT)
return text
}
/** Which screen is up. Exactly one at a time. */
private enum class Screen { BOARD, EDITOR, SYNC }
private enum class Screen { BOARD, EDITOR, SYNC, TAGS }
/**
* The whole app, once the store is open.
@@ -104,7 +167,7 @@ private enum class Screen { BOARD, EDITOR, SYNC }
* both cover the display completely, so keeping the board's two-column grid
* measuring and recomposing underneath one would be pure waste.
*
* Still no navigation library. Three destinations, each entered from exactly one
* Still no navigation library. Four destinations, each entered from exactly one
* place and left by back — a nav graph would be ceremony around an enum, and the
* state that actually matters (which note is open, whether this device is linked)
* already lives in view models.
@@ -113,6 +176,7 @@ private enum class Screen { BOARD, EDITOR, SYNC }
private fun App(
core: ThoughtSync,
requestedNote: MutableState<String?>,
sharedText: MutableState<String?>,
) {
val context = LocalContext.current
val board: BoardViewModel =
@@ -135,6 +199,15 @@ private fun App(
}
}
// Cleared the same way and for the same reason: without it every later
// recomposition would capture the shared text again as a new note.
LaunchedEffect(sharedText.value) {
sharedText.value?.let {
board.captureShared(it)
sharedText.value = null
}
}
ReminderAlarms(core)
// A pull can rewrite every note the board is holding, so a sync that changed
// anything tells it to reload. Wired here, at the one place that owns both.
@@ -149,6 +222,13 @@ private fun App(
// opens the editor on an unsaved draft, so writing a note and editing one are the
// same surface with the same toolbar.
var showingSync by rememberSaveable { mutableStateOf(false) }
var showingTags by rememberSaveable { mutableStateOf(false) }
// Tag writes reach the board two ways at once: the drawer lists tags, and the
// board may be LOOKING at one that a delete or a merge just removed. Both are
// `refreshLabels`, which also leaves a lens whose tag stopped existing.
val tags: TagsViewModel =
viewModel(factory = TagsViewModel.factory(core, onStoreChanged = board::refreshLabels))
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
val settings = remember(context) { SyncSettings(context) }
@@ -161,6 +241,9 @@ private fun App(
val screen =
when {
showingSync -> Screen.SYNC
// Above the editor: tags are reached only from the board's drawer, so
// there is never an open note underneath one to go back to.
showingTags -> Screen.TAGS
editing != null -> Screen.EDITOR
else -> Screen.BOARD
}
@@ -188,6 +271,18 @@ private fun App(
onInstallOutcome = update::consumeInstallOutcome,
)
Screen.TAGS ->
TagsScreen(
state = tags.state,
onClose = { showingTags = false },
onCreate = tags::create,
onRename = tags::rename,
onColour = tags::setColour,
onDelete = tags::remove,
onMerge = tags::merge,
onDismissError = tags::dismissError,
)
Screen.EDITOR ->
NoteEditorScreen(
// Non-null by construction: `screen` is EDITOR only when it is.
@@ -218,6 +313,7 @@ private fun App(
onDismissError = sync::dismissSyncError,
),
onOpenSync = { showingSync = true },
onManageTags = { showingTags = true },
onSearch = board::search,
onCompose = board::compose,
onToggleItem = board::toggleItem,
@@ -247,6 +343,7 @@ private fun App(
// The sync screen has no back handler of its own, so one lives here. The
// editor keeps its own, because it has to save the open note before leaving.
BackHandler(enabled = showingSync) { showingSync = false }
BackHandler(enabled = showingTags) { showingTags = false }
}
/**
@@ -3,6 +3,7 @@ package com.fabledsword.thoughtsync
import android.app.Application
import android.util.Log
import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.thoughtsync.core.setClientAgent
/**
* Opens the shared Rust core once, for the process lifetime.
@@ -30,6 +31,14 @@ class ThoughtSyncApplication : Application() {
override fun onCreate() {
super.onCreate()
// Introduce this app to any server it links to, before anything can sync.
// The core cannot name us — the same crate is compiled into the desktop app,
// and it used to announce every phone as `thoughtsync-desktop` carrying the
// core crate's own version. "unknown" rather than a guess when the package
// manager will not say (note 3127 §5).
setClientAgent("thoughtsync-android", installedVersionName() ?: "unknown")
try {
val handle = ThoughtSync(filesDir.absolutePath)
core = handle
@@ -26,6 +26,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
@@ -74,6 +75,7 @@ fun BoardScreen(
onOpenNote: (Note) -> Unit,
sync: BoardSync,
onOpenSync: () -> Unit,
onManageTags: () -> Unit,
onSearch: (String) -> Unit,
onCompose: () -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit,
@@ -138,6 +140,10 @@ fun BoardScreen(
onOpenSync()
scope.launch { drawerState.close() }
},
onManageTags = {
onManageTags()
scope.launch { drawerState.close() }
},
)
},
) {
@@ -367,6 +373,7 @@ private fun NavigationDrawer(
syncSummary: String?,
onOpen: (Destination) -> Unit,
onOpenSync: () -> Unit,
onManageTags: () -> Unit,
) {
ModalDrawerSheet {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
@@ -380,18 +387,36 @@ private fun NavigationDrawer(
DrawerRow(destination, current, onOpen)
}
if (labels.isNotEmpty()) {
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
// The header renders even with no tags, unlike the rows below it: the
// manage screen is where you go to MAKE the first one, and hiding the
// way in until one exists would be a door that appears only once you
// are already inside. It is an action ON the section rather than a row
// in it, so it cannot be mistaken for one more lens.
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 16.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.nav_labels),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, bottom = 4.dp),
modifier = Modifier.weight(1f),
)
labels.forEach { label ->
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
IconButton(onClick = onManageTags) {
Icon(
Icons.Filled.Edit,
contentDescription = stringResource(R.string.tags_manage),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
labels.forEach { label ->
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
@@ -123,7 +123,7 @@ class BoardViewModel(
init {
refresh()
loadLabels()
refreshLabels()
}
fun open(destination: Destination) {
@@ -162,13 +162,34 @@ class BoardViewModel(
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
}
private fun loadLabels() {
/**
* Reload the drawer's tags, and leave a lens whose tag no longer exists.
*
* Public because the Tags screen owns operations this board cannot see: a
* delete or a merge removes a tag, and the board may be LOOKING at that tag —
* `Destination.WithLabel` holds an id, and a query for a deleted one returns
* nothing forever. Without the fallback, tidying up tags could strand the board
* on a permanently empty lens whose only escape is the drawer.
*
* A rename needs no fallback: the id survives, and re-listing gives the drawer
* the new name. A rename that MERGED is a delete of one of the two, which this
* catches by id like any other.
*/
fun refreshLabels() {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.onSuccess { state = state.copy(labels = it) }
.onSuccess { labels ->
state = state.copy(labels = labels)
val lens = state.destination
if (lens is Destination.WithLabel && labels.none { it.id == lens.id }) {
open(Destination.Notes)
}
}
// A drawer that cannot list labels is a degraded drawer, not a
// broken board — the notes are still there. Failing quietly here
// beats an error banner over working content.
// beats an error banner over working content. The lens is left
// alone in this case on purpose: "I could not read the tags" is not
// evidence that this one is gone.
.onFailure { state = state.copy(labels = emptyList()) }
}
}
@@ -235,6 +256,32 @@ class BoardViewModel(
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1)
}
/**
* Capture text shared into the app from somewhere else, and open it.
*
* The note is CREATED here rather than opened as a pre-filled draft, and that
* is the whole design of this path. The editor only flushes when its text
* differs from the note it was handed (`NoteEditorScreen`'s `flush`), so a
* draft arriving already full of the shared text is a draft with nothing to
* save — share a link, press back without typing, and it would be gone. A
* share has already said "keep this"; making the row first is what honours it.
*
* Opening the editor afterwards is then free of that risk: the note exists,
* back leaves it alone, and adding a line of context is optional rather than
* load-bearing.
*/
fun captureShared(text: String) {
val content = text.trim()
if (content.isEmpty()) return
// A share is a new sitting even if the editor was already open on
// something, so the field must be re-keyed onto what arrives. `createFrom
// Draft` deliberately does not bump this — it is written for the autosave
// case, where re-keying mid-typing would be the bug.
draftDismissed = false
state = state.copy(editingSession = state.editingSession + 1)
createFromDraft(content)
}
/**
* Set when a draft's editor closes, so a create still in flight does not reopen
* it. The editor flushes its text and then closes, and the flush is a coroutine —
@@ -387,7 +434,7 @@ class BoardViewModel(
}
// The drawer lists labels with their note counts, and both
// just changed.
loadLabels()
refreshLabels()
}
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
@@ -425,9 +472,13 @@ class BoardViewModel(
* correct-until-a-moment-ago content, and flashing it empty would be a worse
* lie than showing it one frame stale.
*
* Search results are left alone — they are the answer to a query, not a live
* view, and re-running the board query underneath them would replace the hits
* with the whole board.
* While a search is running the QUERY is re-run rather than the board's
* destination — running `load` here would replace the hits with the whole
* board, which is why this branch exists at all. It used to keep the existing
* list instead, and that was right for a note whose place in the pile changed
* and wrong for one that left it: trashing a hit left the card sitting there,
* with a snackbar saying it was gone, until the query happened to re-run
* (#3111). Re-asking is still the answer to the query, just a current one.
*/
private fun mutate(
closeEditor: Boolean = false,
@@ -439,10 +490,8 @@ class BoardViewModel(
try {
val updated = withContext(Dispatchers.IO) { block(core) }
val notes =
if (state.searching) {
state.notes
} else {
withContext(Dispatchers.IO) { load(state.destination) }
withContext(Dispatchers.IO) {
if (state.searching) core.searchNotes(state.query) else load(state.destination)
}
// On IO, not here: re-deriving the alarm reads every note
// that carries a reminder, and this line runs on the main
@@ -0,0 +1,121 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.core.LinkPreview
import com.fabledsword.thoughtsync.core.Note
private val PREVIEW_RADIUS = 8.dp
/**
* A URL that is the WHOLE body, whitespace either side allowed.
*
* Mirrors `LONE_URL_RE` in `NoteCard.vue` deliberately — the two surfaces have to
* agree on what counts as "this note is a link", or the same note reads as a card
* on one and a paragraph on the other. Someone pasting a link rarely trims it,
* which is why the surrounding whitespace is tolerated rather than rejected.
*/
private val LONE_URL = Regex("""^\s*(https?://[^\s<>"'\]\)]+)\s*$""")
/**
* The preview for a note that is nothing but a URL, or null.
*
* Null covers three different situations that all render the same way — the body
* is not a lone URL, the server has not unfurled it yet, or it never could. The
* card falls back to showing the URL as text in every one of them, so it is never
* blank and the link is never unreachable.
*
* A note written on the phone and not yet synced is permanently in the middle
* case: the unfurl happens server-side (`unfurl_queue.py`) and arrives on a later
* pull. That is the honest behaviour and it has to look deliberate, which showing
* the URL does.
*/
fun loneUrlPreview(note: Note): LinkPreview? {
if (!LONE_URL.matches(note.body)) return null
val url = note.body.trim()
return note.previews.firstOrNull { it.url == url }
}
/** True when the body is a lone URL, whether or not a preview has arrived for it. */
fun isLoneUrl(note: Note): Boolean = LONE_URL.matches(note.body)
/**
* A fetched link preview, in one of two sizes.
*
* [compact] is a single row — one line of title and the site — for a URL mentioned
* *inside* a note that has its own words. The note is the thing; the link is a
* footnote to it. Full size is for a note that IS a URL, where the link is the
* note and a compact strip would be a card with nothing on it.
*
* No image, unlike the web's `LinkPreview.vue`. `image_url` is a REMOTE
* third-party address, so drawing it would have this app fetch from whatever host
* a link happens to point at — on a phone, on possibly metered data, and as the
* first image loading anywhere in this client. That is a decision about privacy
* and data use rather than a rendering detail, so the text card ships and the
* image is left to be asked for (Scribe #3307).
*/
@Composable
fun LinkPreviewCard(
preview: LinkPreview,
compact: Boolean,
modifier: Modifier = Modifier,
) {
// Every element of the Modifier chain stays on ONE line, which is why the shape
// and the two paddings are named first. `standard:chain-method-continuation`
// wants a `.` that follows a MULTILINE element glued to its closing paren —
// `).padding(…)` — which is unreadable, so the multiline element is avoided
// instead (Scribe #3110).
val shape = RoundedCornerShape(PREVIEW_RADIUS)
val padH = if (compact) 8.dp else 10.dp
val padV = if (compact) 6.dp else 8.dp
Column(
modifier =
modifier
.fillMaxWidth()
.clip(shape)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
.padding(horizontal = padH, vertical = padV),
) {
preview.siteName?.takeIf { it.isNotBlank() }?.let { site ->
Text(
text = site.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
// The URL stands in for a missing title so the row always says SOMETHING
// about where it goes.
text = preview.title?.takeIf { it.isNotBlank() } ?: preview.url,
style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// First thing to go when there is no room — the compact row is a footnote and
// a description would make it the loudest part of the card.
if (!compact) {
preview.description?.takeIf { it.isNotBlank() }?.let { body ->
Text(
text = body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -123,16 +123,37 @@ fun NoteCard(
Spacer(Modifier.height(8.dp))
}
// A note that is NOTHING but a URL renders as its preview and nothing
// else — printing the raw address under a card that already says where it
// goes is saying the same thing twice, badly. Until the unfurl lands, or
// if it never does, `preview` is null and the body falls through to
// NoteBody, which shows the URL. Never a blank card.
val lonePreview = remember(note.body, note.previews) { loneUrlPreview(note) }
// Body then checklist, in order — a note can carry both (M13 step 2). The
// first line of the body IS the note's name, at the same weight as the rest of
// it (M13 steps 3 and 4).
if (note.body.isNotBlank()) {
if (lonePreview != null) {
LinkPreviewCard(preview = lonePreview, compact = false)
} else if (note.body.isNotBlank()) {
NoteBody(note = note, onToggleItem = onToggleItem)
}
// Links mentioned INSIDE a note: a compact strip at the foot of the card,
// under the note's own words rather than stacked on top of them. Putting
// them above would set a stranger's headline where the note's first line
// should be — the web learned that in M13 and moved them down.
if (!isLoneUrl(note) && note.previews.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
note.previews.forEach { preview ->
LinkPreviewCard(preview = preview, compact = true)
Spacer(Modifier.height(4.dp))
}
}
// A note with nothing in it still has to occupy the board legibly — otherwise
// it reads as a rendering bug.
if (note.body.isBlank()) {
if (note.body.isBlank() && note.previews.isEmpty()) {
Text(
text = stringResource(R.string.board_empty_note),
style = MaterialTheme.typography.bodyMedium,
@@ -31,12 +31,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.UpdateOutcome
import com.fabledsword.thoughtsync.installedVersionName
/**
* Opt-in server pairing.
@@ -120,10 +122,38 @@ fun SyncScreen(
onDismissRevokeNotice = onDismissRevokeNotice,
)
}
BuildLine()
}
}
}
/**
* The build, dim, at the foot of Sync — the same thing the web UI puts at the
* bottom of its rail (#3181).
*
* Note 3127 §5 is why it is here at all. With version tags gone, an artifact's own
* self-report is the only answer to "which build is this?" — so it renders
* "unknown" rather than nothing when the name is absent, because a blank line looks
* like a layout bug and a plausible default cannot be caught by anything.
*
* The read itself is `installedVersionName()`, shared with the client header the
* app sends its server: one answer to "which build is on this phone", so the line
* a person quotes in a bug report and the line in the server's log cannot disagree.
*/
@Composable
private fun BuildLine() {
val context = LocalContext.current
val unknown = stringResource(R.string.build_unknown)
val version = remember(context) { context.installedVersionName() ?: unknown }
Text(
text = version,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp),
)
}
// ───────────────────────────────── linked ─────────────────────────────────
@Composable
@@ -0,0 +1,584 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label
/** The swatch shown beside a tag, and tapped to change its colour. */
private val SWATCH = 22.dp
/** What the row's overflow menu is currently asking about. */
private sealed interface TagDialog {
data class Rename(
val tag: Label,
) : TagDialog
/** A rename whose new name another tag already holds — see [RenameDialog]. */
data class ConfirmMerge(
val tag: Label,
val into: Label,
val name: String,
) : TagDialog
data class Merge(
val tag: Label,
) : TagDialog
data class Delete(
val tag: Label,
) : TagDialog
data class Colour(
val tag: Label,
) : TagDialog
}
/**
* Tag management: list, create, rename, recolour, delete, merge.
*
* A destination you go to, not a modal. The web's `LabelsModal.vue` is a modal
* because a desktop has room to float one over the board; on a phone this is a
* place you visit to tidy up, and a full screen is what that is.
*
* It is also, since the per-note colour picker was removed, the ONLY colour
* control in the product. That is why the swatch is a first-class tap target on
* every row rather than something behind the overflow menu.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TagsScreen(
state: TagsState,
onClose: () -> Unit,
onCreate: (String) -> Unit,
onRename: (String, String) -> Unit,
onColour: (String, String) -> Unit,
onDelete: (String) -> Unit,
onMerge: (String, String) -> Unit,
onDismissError: () -> Unit,
) {
var dialog by remember { mutableStateOf<TagDialog?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.tags_title)) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.tags_back),
)
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.imePadding(),
) {
// An indeterminate bar rather than blocking the list: a tag write is a
// local SQLite call and usually finishes before this is seen at all.
if (state.busy) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
// The same banner the board and the editor use. A third way of saying
// "that did not work" would be a third thing to keep consistent.
state.error?.let { message ->
ErrorBanner(message = message, onDismiss = onDismissError)
}
NewTagField(
enabled = !state.busy,
onCreate = onCreate,
)
if (!state.loading && state.tags.isEmpty()) {
EmptyTags()
}
// weight, NOT fillMaxSize: this has siblings above it, and filling the
// whole height would measure the list against space the field and the
// banner have already taken — pushing the end of the list off-screen.
LazyColumn(modifier = Modifier.weight(1f)) {
items(state.tags, key = { it.id }) { tag ->
TagRow(
tag = tag,
enabled = !state.busy,
onColour = { dialog = TagDialog.Colour(tag) },
onRename = { dialog = TagDialog.Rename(tag) },
onMerge = { dialog = TagDialog.Merge(tag) },
onDelete = { dialog = TagDialog.Delete(tag) },
)
}
}
}
}
when (val open = dialog) {
null -> Unit
is TagDialog.Rename ->
RenameDialog(
tag = open.tag,
others = state.tags,
onDismiss = { dialog = null },
onRename = { name ->
dialog = null
onRename(open.tag.id, name)
},
// Renaming onto a name another tag holds MERGES the two, and that
// cannot be undone by repeating it, so the confirmation replaces
// this dialog rather than the rename just happening.
onWouldMerge = { into, name -> dialog = TagDialog.ConfirmMerge(open.tag, into, name) },
)
is TagDialog.ConfirmMerge ->
ConfirmDialog(
title = stringResource(R.string.tags_rename_merges_title, hash(open.into.name)),
body = stringResource(R.string.tags_rename_merges_body, hash(open.into.name)),
confirm = stringResource(R.string.tags_rename_merges_confirm),
onDismiss = { dialog = null },
onConfirm = {
dialog = null
onRename(open.tag.id, open.name)
},
)
is TagDialog.Merge ->
MergeDialog(
tag = open.tag,
others = state.tags.filter { it.id != open.tag.id },
onDismiss = { dialog = null },
onMerge = { target ->
dialog = null
onMerge(open.tag.id, target.id)
},
)
is TagDialog.Delete ->
ConfirmDialog(
title = stringResource(R.string.tags_delete_title, hash(open.tag.name)),
// The count is the part that makes the consequence real — "it is on
// 40 notes" is a different decision from "delete this tag?". It comes
// from the LIST, the only call the core populates a count on.
body =
open.tag.count
?.takeIf { it > 0 }
?.let { stringResource(R.string.tags_delete_body_counted, it) }
?: stringResource(R.string.tags_delete_body),
footnote = stringResource(R.string.tags_delete_from_text),
confirm = stringResource(R.string.tags_delete_confirm),
onDismiss = { dialog = null },
onConfirm = {
dialog = null
onDelete(open.tag.id)
},
)
is TagDialog.Colour ->
ColourDialog(
tag = open.tag,
onDismiss = { dialog = null },
onPick = { key ->
dialog = null
onColour(open.tag.id, key)
},
)
}
}
/**
* `#` on the name, everywhere it is spoken about.
*
* The chips already wear it (`NoteCard.kt`, `EditorChrome.kt`) and it is the
* reason these are called tags at all — a dialog that said "Delete grocery?" would
* be talking about something else.
*/
private fun hash(name: String): String = "#$name"
@Composable
private fun NewTagField(
enabled: Boolean,
onCreate: (String) -> Unit,
) {
var text by remember { mutableStateOf("") }
val submit = {
if (text.isNotBlank()) {
onCreate(text)
text = ""
}
}
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
PlainTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.weight(1f),
hint = R.string.tags_new_hint,
enabled = enabled,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
)
TextButton(onClick = submit, enabled = enabled && text.isNotBlank()) {
Text(stringResource(R.string.tags_create))
}
}
}
/**
* Said out loud rather than left as a blank screen — and it names the `#` route,
* because the operator did not know `#tag` extraction existed at all (Scribe
* #2949) and this is the natural place to say so.
*/
@Composable
private fun EmptyTags() {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(R.string.tags_empty_title),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringResource(R.string.tags_empty_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun TagRow(
tag: Label,
enabled: Boolean,
onColour: () -> Unit,
onRename: () -> Unit,
onMerge: () -> Unit,
onDelete: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = labelTintFor(tag.name, tag.color)
var menuOpen by remember { mutableStateOf(false) }
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier =
Modifier
.size(SWATCH)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), CircleShape)
.clickable(enabled = enabled, onClick = onColour),
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = hash(tag.name),
style = MaterialTheme.typography.bodyLarge,
color = tint.tagInk(dark),
)
Text(
text = countLabel(tag.count),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column {
IconButton(onClick = { menuOpen = true }, enabled = enabled) {
Icon(
Icons.Filled.MoreVert,
contentDescription = stringResource(R.string.tags_actions),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
// Panel.kt's MenuItem, not a bare DropdownMenuItem: every one of
// these raises a dialog, and it closes the menu BEFORE acting so the
// dialog cannot open underneath a menu still hanging over it.
val close = { menuOpen = false }
MenuItem(R.string.tags_rename, close, onRename)
MenuItem(R.string.tags_merge, close, onMerge)
MenuItem(R.string.tags_delete, close, onDelete)
}
}
}
}
/**
* Zero is its own sentence, not "0 notes".
*
* A count of null means the core did not populate one — only `list_labels` does —
* which is a different thing from a tag with no notes, so it reads as unknown
* rather than as empty.
*/
@Composable
private fun countLabel(count: Long?): String =
when {
count == null -> ""
count <= 0L -> stringResource(R.string.tags_count_none)
count == 1L -> stringResource(R.string.tags_count_one)
else -> stringResource(R.string.tags_count, count.toInt())
}
/**
* Rename, with the merge caught before it happens.
*
* The collision is detected HERE, against the list, rather than from what the
* core returns: the merge survivor is whichever tag is older, so it may well be
* the one being renamed, and an unchanged id afterwards would prove nothing.
* Matching is case-insensitive because the core's is.
*/
@Composable
private fun RenameDialog(
tag: Label,
others: List<Label>,
onDismiss: () -> Unit,
onRename: (String) -> Unit,
onWouldMerge: (Label, String) -> Unit,
) {
var text by remember(tag.id) { mutableStateOf(tag.name) }
val trimmed = text.trim()
val clash =
others.firstOrNull { it.id != tag.id && it.name.equals(trimmed, ignoreCase = true) }
val submit = {
when {
trimmed.isEmpty() -> Unit
clash != null -> onWouldMerge(clash, trimmed)
else -> onRename(trimmed)
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.tags_rename_title, hash(tag.name))) },
text = {
PlainTextField(
value = text,
onValueChange = { text = it },
hint = R.string.tags_new_hint,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
)
},
confirmButton = {
TextButton(onClick = submit, enabled = trimmed.isNotEmpty()) {
Text(stringResource(R.string.tags_rename_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
/**
* Merge, with the direction stated and the survivor named.
*
* Unlike a rename — where the OLDER tag survives so that the outcome cannot
* depend on which way round it was typed — this one is deliberate, so the
* direction the person chooses IS the intent and is honoured. The price of that
* is that the direction has to be unmissable, which is why the body names the tag
* that stops existing and every row here is the one that survives.
*/
@Composable
private fun MergeDialog(
tag: Label,
others: List<Label>,
onDismiss: () -> Unit,
onMerge: (Label) -> Unit,
) {
val dark = isSystemInDarkTheme()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.tags_merge_title, hash(tag.name))) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringResource(R.string.tags_merge_body, hash(tag.name)),
style = MaterialTheme.typography.bodyMedium,
)
if (others.isEmpty()) {
Text(
text = stringResource(R.string.tags_merge_none),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
others.forEach { other ->
val tint = labelTintFor(other.name, other.color)
Text(
text = hash(other.name),
style = MaterialTheme.typography.bodyLarge,
color = tint.tagInk(dark),
modifier =
Modifier
.fillMaxWidth()
.clickable { onMerge(other) }
.padding(vertical = 8.dp),
)
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
/**
* The palette, and since the per-note picker was removed this is the only place in
* the product a colour is chosen.
*
* Every key from [NOTE_TINTS], `default` included: a tag whose colour is
* `default` gets a hue derived from its name (`DerivedTint.kt`), so "default" here
* means "let it pick" rather than "grey", and taking it away would leave no way
* back to that.
*/
@Composable
private fun ColourDialog(
tag: Label,
onDismiss: () -> Unit,
onPick: (String) -> Unit,
) {
val dark = isSystemInDarkTheme()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.tags_colour_of, hash(tag.name))) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
NOTE_TINTS.forEach { (key, tint) ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onPick(key) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier =
Modifier
.size(SWATCH)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), CircleShape),
)
Text(
text = tint.label,
style = MaterialTheme.typography.bodyMedium,
color =
if (key == tag.color) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
)
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
/** A destructive confirmation: what it is, what it costs, and one way out. */
@Composable
private fun ConfirmDialog(
title: String,
body: String,
confirm: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
footnote: String? = null,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = body, style = MaterialTheme.typography.bodyMedium)
footnote?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(confirm) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
},
)
}
@@ -0,0 +1,181 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.ThoughtSync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Everything the Tags screen renders from.
*
* [tags] comes from `list_labels`, which is the only call that populates a
* `count` — the single-tag returns leave it null by design. So the counts a
* confirmation dialog quotes are always the LIST's, never an operation's result.
*/
data class TagsState(
val loading: Boolean = true,
val tags: List<Label> = emptyList(),
/** An in-flight write, for disabling the controls that would race it. */
val busy: Boolean = false,
val error: String? = null,
)
/**
* Create, rename, recolour, delete and merge tags.
*
* A peer of the web's `LabelsModal.vue`, not a reduced companion — the same six
* operations over the same core the desktop uses.
*
* ## Why every write re-lists
*
* A tag operation changes more than the row it names. A merge deletes one tag and
* moves its notes; a delete changes nothing else's count but removes a drawer
* lens; a rename can MERGE (see below) and so can make a different row vanish.
* Re-listing after each write costs one cheap local SQLite read and removes a
* whole class of "the screen thinks there are still two" bugs. Patching the list
* in place would mean re-deriving, in Kotlin, rules the core already owns.
*
* ## Renaming can merge
*
* `rename_label` folds two tags together when the new name is one another tag
* already holds, and the OLDER row survives (Scribe #3324). So it can return a
* tag whose id is not the one passed in, and it can make another tag stop
* existing. The screen asks first; this view model does not, because a
* confirmation belongs to the surface with a person in front of it.
*
* ## Threading
*
* All of these are ordinary blocking FFI into SQLite — no async, no network — so
* they take [Dispatchers.IO], exactly like the board's calls. Sync happens later:
* the core marks the rows dirty and the next sync carries them.
*/
class TagsViewModel(
private val core: ThoughtSync,
/**
* Called after any write that landed.
*
* The board holds its own snapshot of the tag list for the drawer, and its
* current destination may BE one of these tags — deleting or merging that one
* leaves it looking at a lens that no longer exists. Wiring the two together
* explicitly is less magic than a shared event bus and makes the dependency
* visible at the construction site, the same way [SyncViewModel] does it.
*/
private val onStoreChanged: () -> Unit,
) : ViewModel() {
var state by mutableStateOf(TagsState())
private set
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
state =
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.fold(
onSuccess = { state.copy(tags = it, loading = false, error = null) },
// Unlike the drawer, this screen cannot fail quietly: it is
// the only thing on the display, and an empty list here
// would read as "you have no tags" rather than "I couldn't
// look".
onFailure = { state.copy(loading = false, error = it.describeTagFailure()) },
)
}
}
fun create(name: String) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
// Find-or-create in the core: typing a name that exists in another case
// attaches the existing tag rather than minting a near-duplicate.
write { it.createLabel(trimmed) }
}
fun rename(
id: String,
name: String,
) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
write { it.renameLabel(id, trimmed) }
}
fun setColour(
id: String,
colour: String,
) = write { it.setLabelColor(id, colour) }
fun remove(id: String) = write { it.removeLabel(id) }
/**
* Fold [sourceId] into [targetId]. The source stops existing.
*
* Directional and not undone by repeating it — the caller has to have said
* which one survives before this runs, because afterwards there is nothing
* left to read the direction from.
*/
fun merge(
sourceId: String,
targetId: String,
) {
if (sourceId == targetId) return
write { it.mergeLabels(sourceId, targetId) }
}
fun dismissError() {
state = state.copy(error = null)
}
/**
* Run one store write, then re-list and tell the board.
*
* `busy` is cleared in the same assignment that stores the result, so no path
* out of here can leave the screen stuck with its controls disabled.
*/
private fun write(block: (ThoughtSync) -> Unit) {
if (state.busy) return
state = state.copy(busy = true, error = null)
viewModelScope.launch {
val failure =
runCatching { withContext(Dispatchers.IO) { block(core) } }
.exceptionOrNull()
val tags =
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.getOrDefault(state.tags)
state =
state.copy(
tags = tags,
busy = false,
error = failure?.describeTagFailure(),
)
// Even a FAILED write can have changed the store — a merge that threw
// partway still moved rows — so the board is told either way.
onStoreChanged()
}
}
companion object {
fun factory(
core: ThoughtSync,
onStoreChanged: () -> Unit,
): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = TagsViewModel(core, onStoreChanged) as T
}
}
}
/**
* The core reports problems as one error type carrying a message meant to be
* shown, so the message is used when there is one.
*/
private fun Throwable.describeTagFailure(): String = message ?: "Something went wrong."
+62 -7
View File
@@ -6,7 +6,7 @@
<string name="search_hint">Search your notes</string>
<string name="search_clear">Clear search</string>
<string name="nav_open">Open navigation</string>
<string name="nav_labels">Labels</string>
<string name="nav_labels">Tags</string>
<!-- Compose sheet -->
<string name="compose_open">New note</string>
@@ -42,7 +42,7 @@
<string name="editor_body_hint">Take a note…</string>
<string name="editor_add_item">Add item</string>
<string name="editor_remove_item">Remove item</string>
<string name="editor_remove_label">Remove label</string>
<string name="editor_remove_label">Remove tag</string>
<string name="editor_reminder">Set a reminder</string>
<string name="editor_more">More actions</string>
<string name="editor_saving">Saving…</string>
@@ -52,7 +52,7 @@
<string name="editor_done">Done</string>
<string name="editor_pin">Pin</string>
<string name="editor_unpin">Unpin</string>
<string name="editor_labels">Labels…</string>
<string name="editor_labels">Tags…</string>
<string name="editor_archive">Archive</string>
<string name="editor_unarchive">Unarchive</string>
<string name="editor_trash">Move to trash</string>
@@ -66,11 +66,61 @@
<string name="editor_delete_forever_body">It will be removed from this device and from every device you sync with. This cannot be undone.</string>
<string name="editor_delete_forever_confirm">Delete</string>
<!-- Quick capture from outside the app: the share sheet and the text-selection
toolbar. "New note" says what happens; the activity's own label would say
who it happens in. -->
<string name="capture_process_text">New note</string>
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
schema still says Label, and no string here needs to know that. -->
<string name="tags_manage">Manage tags</string>
<string name="tags_title">Tags</string>
<string name="tags_back">Back</string>
<string name="tags_new_hint">New tag</string>
<string name="tags_create">Create</string>
<string name="tags_count">%1$d notes</string>
<string name="tags_count_one">1 note</string>
<string name="tags_count_none">No notes yet</string>
<string name="tags_empty_title">No tags yet</string>
<string name="tags_empty_body">Create one above, or write a #tag in a note and it becomes one.</string>
<string name="tags_actions">More actions</string>
<string name="tags_colour">Colour</string>
<string name="tags_colour_of">Colour for %1$s</string>
<string name="tags_rename">Rename</string>
<string name="tags_rename_title">Rename %1$s</string>
<string name="tags_rename_confirm">Rename</string>
<!-- Renaming onto an existing tag merges the two, older survives (Scribe
#3324). A merge cannot be undone by repeating it and is reachable here by
a typo, so it says so before it happens — same reasoning as #2116. -->
<string name="tags_rename_merges_title">Merge with %1$s?</string>
<string name="tags_rename_merges_body">A tag called %1$s already exists. Renaming will merge these two into one, carrying every note from both. The notes are kept; one of the two tags stops existing, and that cannot be undone.</string>
<string name="tags_rename_merges_confirm">Merge</string>
<string name="tags_merge">Merge into…</string>
<string name="tags_merge_title">Merge %1$s into…</string>
<!-- The survivor is named in the button, not just the title: this is the one
operation here that repeating does not undo. -->
<string name="tags_merge_body">Every note tagged %1$s will be tagged with the one you pick instead, and %1$s will stop existing. The notes are kept.</string>
<string name="tags_merge_none">There is no other tag to merge into.</string>
<string name="tags_delete">Delete</string>
<string name="tags_delete_title">Delete %1$s?</string>
<string name="tags_delete_body">It will be removed from every note that has it, on every device you sync with. The notes themselves are kept.</string>
<string name="tags_delete_body_counted">It is on %1$d notes. It will be removed from all of them, on every device you sync with. The notes themselves are kept.</string>
<string name="tags_delete_confirm">Delete</string>
<!-- A tag written as #tag in a note's body is owned by that text. Deleting the
row cannot un-write the word, so it comes back on that note's next edit —
said here rather than left as a surprise. -->
<string name="tags_delete_from_text">Tags written as #tag in a note come back when that note is next edited.</string>
<string name="tags_cancel">Cancel</string>
<!-- Pickers -->
<string name="label_picker_title">Labels</string>
<string name="label_new_hint">Type a label and press enter</string>
<string name="label_from_tag">from #tag</string>
<string name="label_none_body">No labels yet. Type one above, or write a #tag in a note and it becomes one.</string>
<string name="label_picker_title">Tags</string>
<string name="label_new_hint">Type a tag and press enter</string>
<string name="label_from_tag">from the text</string>
<string name="label_none_body">No tags yet. Type one above, or write a #tag in a note and it becomes one.</string>
<string name="picker_next">Next</string>
<string name="picker_set">Set</string>
<string name="picker_time_title">Pick a time</string>
@@ -200,4 +250,9 @@
<!-- Errors -->
<string name="error_dismiss">Dismiss</string>
<!-- The build, at the foot of Sync. Never blank: an APK with no versionName is
a real state (a bare `gradlew assembleDebug` with no override) and saying
so is better than an empty line that reads as a layout bug. -->
<string name="build_unknown">unknown</string>
</resources>
+148
View File
@@ -333,6 +333,65 @@ impl ThoughtSync {
.map_err(CoreError::store)
}
/// Rename a label. Every note carrying it follows, because notes reference it
/// by id and never by name.
///
/// Renaming onto a name another tag already holds MERGES the two, and the OLDER
/// row is the survivor — it keeps its id and colour and takes the new spelling.
/// Matching is case-insensitive, like `find_or_create_label`.
///
/// So this call can return a label whose id is NOT the one passed in, and it can
/// make another label stop existing. A UI over it should say so before calling:
/// the merge cannot be undone by repeating it, and here it is reachable by a
/// typo in a text field. The web asks first (`stores/labels.ts`); this binding
/// deliberately does not, because a confirmation belongs to the surface that has
/// a person in front of it, not to the store.
///
/// `store::rename_label` and the server's PATCH implement the same rule, so the
/// phone, the desktop and the web agree on which row survives.
pub fn rename_label(&self, id: String, name: String) -> Result<Label, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::rename_label(&conn, &id, &name)
.map(Label::from)
.map_err(CoreError::store)
}
/// Recolour a label.
///
/// `color` is a palette KEY from the shared vocabulary (`NoteTint.kt` on this
/// side), not a hex value — the point of the shared palette is that a colour
/// picked on the phone resolves to the same swatch on the web and the desktop,
/// which a literal colour could not promise across themes.
pub fn set_label_color(&self, id: String, color: String) -> Result<Label, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::set_label_color(&conn, &id, &color)
.map(Label::from)
.map_err(CoreError::store)
}
/// Delete a label. The notes that carried it are NOT deleted — they simply stop
/// carrying it, which is the thing a confirmation dialog has to say out loud.
///
/// A `#tag` in a body will re-derive the label on the next edit of that note.
/// That is correct rather than a leak: the text mandates it, and deleting the
/// row cannot un-write the word.
pub fn remove_label(&self, id: String) -> Result<(), CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::remove_label(&conn, &id).map_err(CoreError::store)
}
/// Fold `source` into `target` and return the survivor.
///
/// DIRECTIONAL and NOT reversible by repeating it: source stops existing. Any
/// UI over this has to name the survivor before it runs, because afterwards
/// there is nothing left to read the direction from.
pub fn merge_labels(&self, source_id: String, target_id: String) -> Result<Label, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::merge_labels(&conn, &source_id, &target_id)
.map(Label::from)
.map_err(CoreError::store)
}
// ─────────────────────────────── sync ────────────────────────────────
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
@@ -520,6 +579,20 @@ pub fn checklist_render(text: String, checked: bool) -> String {
local::derive::render_item(&text, checked)
}
/// Tell the core which app it is running inside, and which build of it.
///
/// Android has to say so because the core cannot: the same crate is compiled into
/// the desktop app, and it used to announce every phone in the field as
/// `thoughtsync-desktop` carrying the CORE crate's version — a number no build
/// stamps and nobody has seen. The honest value is the installed package's own
/// `versionName`, which is what Kotlin passes here.
///
/// Called once from `ThoughtSyncApplication.onCreate`, before anything can sync.
#[uniffi::export]
pub fn set_client_agent(name: String, version: String) {
compat::set_client_agent(&name, &version);
}
/// Every checklist item in a body, with the line each one sits on — so a renderer
/// walking the body line by line knows which lines are boxes and what is in them.
#[uniffi::export]
@@ -892,6 +965,81 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// Renaming a tag onto a name another tag already holds MERGES the two, and the
/// OLDER row is the survivor.
///
/// Before this, the bare UPDATE met `idx_labels_name` — unique on `lower(name)` —
/// and the user got a raw "UNIQUE constraint failed" from SQLite. Merging is what
/// a person means by typing an existing tag's name onto this one.
#[test]
fn renaming_onto_an_existing_tag_merges_into_the_older_one() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let older = app.create_label("grocery".to_string()).expect("older");
// `created_at` is RFC3339 to the MILLISECOND. Without a gap the two rows can
// share a timestamp, and then the tie-break is under test instead of the age
// rule this test is about.
std::thread::sleep(std::time::Duration::from_millis(5));
let newer = app.create_label("errands".to_string()).expect("newer");
let one = app.create_note(draft("milk")).expect("note one");
let two = app.create_note(draft("stamps")).expect("note two");
app.set_note_labels(one.id.clone(), vec![older.id.clone()])
.expect("tag one");
app.set_note_labels(two.id.clone(), vec![newer.id.clone()])
.expect("tag two");
// The YOUNGER one is renamed onto the older's name, in a different case —
// matching is case-insensitive, and the survivor takes the spelling asked for.
let survivor = app
.rename_label(newer.id.clone(), "Grocery".to_string())
.expect("a rename onto an existing name merges instead of failing");
assert_eq!(
survivor.id, older.id,
"the older row is the one that survives"
);
assert_eq!(survivor.name, "Grocery", "spelled the way the caller asked");
let all = app.list_labels().expect("list");
assert_eq!(all.len(), 1, "the two became one");
assert_eq!(all[0].id, older.id);
assert_eq!(all[0].count, Some(2), "carrying every note from both sides");
std::fs::remove_dir_all(&dir).ok();
}
/// The mirror of the test above. Renaming the OLDER one onto the younger's name
/// still leaves the older row standing — it just changes its name.
///
/// This is the whole reason age decides rather than "whoever already held the
/// name": otherwise the survivor depends on which way round someone typed it,
/// and two devices tidying the same pair would disagree about which id exists.
#[test]
fn the_rename_merge_survivor_does_not_depend_on_the_direction() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let older = app.create_label("grocery".to_string()).expect("older");
std::thread::sleep(std::time::Duration::from_millis(5));
let newer = app.create_label("errands".to_string()).expect("newer");
let survivor = app
.rename_label(older.id.clone(), "errands".to_string())
.expect("rename");
assert_eq!(survivor.id, older.id, "age wins in this direction too");
assert_eq!(survivor.name, "errands");
assert_ne!(
survivor.id, newer.id,
"the younger row is the one that went"
);
assert_eq!(app.list_labels().expect("list").len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
/// A crude RFC3339 sanity check that doesn't pull a date crate into this
/// crate's dev-dependencies to assert one field is well-formed.
fn chrono_free_parse(raw: &str) -> usize {
+50
View File
@@ -820,7 +820,57 @@ pub fn create_label(conn: &Connection, name: &str) -> rusqlite::Result<Label> {
load_label(conn, &id)
}
/// Rename a label. Renaming ONTO a name another label already holds MERGES the two.
///
/// It cannot simply be an UPDATE: `idx_labels_name` is unique on `lower(name)`, so
/// the bare statement failed with a raw SQLite "UNIQUE constraint failed" that
/// reached the user as database internals. Merging is the operator's call, and it
/// is the reading that matches what a person means — typing an existing tag's name
/// onto this one says "these are the same thing."
///
/// THE OLDER ROW SURVIVES, and takes the new spelling. Older rather than "the one
/// that already held the name" because age is the property neither participant's
/// role can change: rename A→B and rename B→A must land on the same survivor, or
/// the result depends on which way round someone happened to type it. Ties (two
/// labels minted in the same millisecond) go to the incumbent, so the outcome is
/// still deterministic.
///
/// Matching is case-insensitive, agreeing with `find_or_create_label` — "Groceries"
/// finds "groceries", and the survivor ends up spelled the way the caller asked.
pub fn rename_label(conn: &Connection, id: &str, name: &str) -> rusqlite::Result<Label> {
let clash: Option<(String, String)> = conn
.query_row(
"SELECT id, created_at FROM labels WHERE lower(name) = lower(?1) AND id <> ?2",
params![name, id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
if let Some((other_id, other_created)) = clash {
let mine_created: String =
conn.query_row("SELECT created_at FROM labels WHERE id = ?1", [id], |r| {
r.get(0)
})?;
// `created_at` is RFC3339 to the millisecond with a `Z`, so it is fixed-width
// and lexicographic order IS chronological order — no parsing needed.
let (survivor, doomed) = if other_created <= mine_created {
(other_id, id.to_string())
} else {
(id.to_string(), other_id)
};
// Reuse the merge rather than re-implement it: it is the only place that
// knows to mark every affected NOTE dirty before the delete cascades the
// membership rows away, which is what makes the merge reach the server.
merge_labels(conn, &doomed, &survivor)?;
// The survivor may still carry the old spelling — it is the one that keeps
// existing, so it is the one that has to end up named what was asked for.
conn.execute(
"UPDATE labels SET name = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
params![name, now(), survivor],
)?;
return load_label(conn, &survivor);
}
conn.execute(
"UPDATE labels SET name = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
params![name, now(), id],
+50 -2
View File
@@ -17,6 +17,7 @@
//! `docs/sync.md` for the policy that governs when those numbers move.
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
/// The sync wire protocol this client speaks.
///
@@ -172,10 +173,41 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
}
}
/// Who this client says it is, set once by the host application at startup.
///
/// THE CORE CANNOT KNOW THIS, and the value it used to invent was wrong twice. It
/// was `thoughtsync-desktop/{CARGO_PKG_VERSION}`, and this crate is compiled into
/// the desktop app AND the Android app — so every phone in the field announced
/// itself as a desktop. The version was worse: `CARGO_PKG_VERSION` here is the
/// version of the CORE crate, a number no build stamps and no user has ever seen,
/// while the thing a reader of that header wants is the app's own build (note 3127
/// §5 — with no version tags, the artifact's self-report is the only answer to
/// "which build is this?").
///
/// So the host names itself. `OnceLock` because identity is fixed for the life of
/// the process and a second caller should be ignored rather than race the first.
static CLIENT_AGENT: OnceLock<String> = OnceLock::new();
/// Name this client for the servers it talks to — `("thoughtsync-android", "2026.08.31.1204")`.
///
/// Call once at startup, before any sync. Calling twice is not an error and the
/// first name wins; not calling it at all is visible in the header rather than
/// silently plausible.
pub fn set_client_agent(name: &str, version: &str) {
let _ = CLIENT_AGENT.set(format!("{name}/{version}"));
}
/// Headers this client puts on every request to a linked server, so the server can
/// log or gate on client identity without a separate handshake round-trip.
pub fn client_headers() -> [(&'static str, String); 2] {
let agent = format!("thoughtsync-desktop/{}", env!("CARGO_PKG_VERSION"));
// `unidentified/unknown`, never a plausible default. Nothing reads this header
// today, which is exactly why a wrong value could sit in it for months: the
// first person to look at a server log is the first person who could catch it,
// and only if what they see is obviously a host that never introduced itself.
let agent = CLIENT_AGENT
.get()
.cloned()
.unwrap_or_else(|| "thoughtsync-unidentified/unknown".to_string());
[
("X-ThoughtSync-Client", agent),
(
@@ -374,10 +406,26 @@ mod tests {
#[test]
fn client_headers_identify_app_and_protocol() {
// Sets the process-wide agent, which is why this test also owns the
// assertion about it: a second test calling `set_client_agent` would race
// this one for the OnceLock, and whichever lost would see the other's name.
// One test, both branches, in order.
assert!(
client_headers()[0]
.1
.starts_with("thoughtsync-unidentified/"),
"a host that never introduced itself must say so"
);
set_client_agent("thoughtsync-test", "2026.08.31.1204");
let headers = client_headers();
assert_eq!(headers[0].0, "X-ThoughtSync-Client");
assert!(headers[0].1.starts_with("thoughtsync-desktop/"));
assert_eq!(headers[0].1, "thoughtsync-test/2026.08.31.1204");
assert_eq!(headers[1].1, CLIENT_PROTOCOL_VERSION.to_string());
// First name wins — a second host cannot rename a running process.
set_client_agent("thoughtsync-impostor", "0");
assert_eq!(client_headers()[0].1, "thoughtsync-test/2026.08.31.1204");
}
#[test]
+7 -27
View File
@@ -10,7 +10,10 @@
# Both are fixed-tag releases: the tag never moves and the assets are pruned to the
# current build, so the tag alone names the newest one. `stable` only became one in
# M314 step 3, when `main` started publishing — before that it was a manifest-only
# pointer at whatever `v*` tag somebody had last cut.
# pointer at whatever `v*` tag somebody had last cut, and this script carried a
# fallback that chased the `v*` release its manifest named. That came out once
# `main` had published to `stable` for real (`b6673c6`); the two channels are the
# same shape now and nothing here should special-case one of them again.
# Pick one with `--channel dev` or `TS_CHANNEL=dev`. Through a pipe the options go
# after a `--`: curl -fsSL <url> | sh -s -- --channel dev
#
@@ -86,36 +89,13 @@ esac
# --- resolve the release for this channel -----------------------------------
say "Finding the latest ThoughtSync build on the $channel channel…"
# ONE lookup for both channels now. Each is a release whose tag never moves and whose
# assets are pruned to the current build, so the tag alone names the newest build on
# that channel — which is exactly what an installer wants and what the in-app updater
# ONE lookup, both channels. Each is a release whose tag never moves and whose assets
# are pruned to the current build, so the tag alone names the newest build on that
# channel — which is exactly what an installer wants and what the in-app updater
# already reads.
json="$(curl -fsSL "$API/releases/tags/$channel" 2>/dev/null)" ||
die "the $channel channel has nothing published yet."
# TRANSITIONAL — delete with the rest of the old scheme (M314 step 7).
#
# `stable` existed before this as a manifest-ONLY pointer: `latest.json` naming a
# version whose bundles lived on a separate `v<version>` release. Between this commit
# and the first merge to `main` it still looks like that, and `stable` is the DEFAULT
# channel — so without this fallback `curl … | sh` is broken for everyone in that
# window. It costs nothing once main has published: the grep finds the bundles and
# this branch never runs again.
if [ "$channel" = "stable" ] && ! printf '%s' "$json" | grep -q "releases/download/stable/[^\"]*\.\(AppImage\|deb\|pkg\.tar\)"; then
say "stable has no bundles of its own yet — falling back to the version its manifest names."
manifest="$(curl -fsSL "$INSTANCE/$REPO/releases/download/stable/latest.json" 2>/dev/null || true)"
stable_version="$(printf '%s' "$manifest" |
grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 |
sed -E 's/.*"([^"]+)"$/\1/')"
if [ -n "$stable_version" ]; then
json="$(curl -fsSL "$API/releases/tags/v$stable_version" 2>/dev/null)" ||
die "the stable channel names $stable_version, but there is no v$stable_version release to install."
else
json="$(curl -fsSL "$API/releases/latest" 2>/dev/null)" ||
die "no stable build published yet — try --channel dev, or merge to main."
fi
fi
# Pull asset URLs straight out of the release JSON (no jq). Anchored on the closing
# quote so a `…AppImage.sig` URL can't be truncated into a match of its own.
asset_url() {
+42 -17
View File
@@ -24,6 +24,11 @@ set -euo pipefail
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required (owner/repo)}"
: "${RELEASE_TAG:?RELEASE_TAG is required (the release holding the bundles)}"
: "${APP_VERSION:?APP_VERSION is required (the version the bundles carry)}"
# The version a PERSON reads, published beside the manifest so the image build can
# describe the bundles it bakes in without re-deriving anything. Required rather
# than defaulted: a missing value here would silently publish a sidecar naming the
# wrong build, and there is nothing downstream that could catch it.
: "${DISPLAY_VERSION:?DISPLAY_VERSION is required (the human-readable version)}"
# The manifest is published to the release that HOLDS the bundles. There is no
# second place any more.
@@ -116,27 +121,47 @@ pub_date="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
echo "==> Manifest:"
cat "$work/latest.json"
# The manifest goes on the same release the bundles were just read from — which is
# also the one `publish-release.sh` created or refreshed moments earlier, so it is
# Both files go on the same release the bundles were just read from — which is also
# the one `publish-release.sh` created or refreshed moments earlier, so it is
# guaranteed to exist by the time this runs.
target_id="$release_id"
target_assets="$assets"
# Replace rather than duplicate: Forgejo rejects a second asset with the same name,
# and this file is rewritten on every publish by design.
old_id="$(printf '%s' "$target_assets" \
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"latest\.json\"" \
| head -1 | grep -oE '[0-9]+' | head -1 || true)"
if [ -n "${old_id:-}" ]; then
echo "==> Removing the previous latest.json (id $old_id)"
curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$target_id/assets/$old_id" >/dev/null
fi
# and these files are rewritten on every publish by design.
replace_asset() {
local path="$1" name="$2" escaped old_id
escaped="${name//./\\.}"
old_id="$(printf '%s' "$assets" \
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"$escaped\"" \
| head -1 | grep -oE '[0-9]+' | head -1 || true)"
if [ -n "${old_id:-}" ]; then
echo "==> Removing the previous $name (id $old_id)"
curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$release_id/assets/$old_id" >/dev/null
fi
echo "==> Uploading $name to $RELEASE_TAG"
curl -fsS -X POST "${AUTH[@]}" "$API/releases/$release_id/assets?name=$name" \
-F "attachment=@$path" >/dev/null
}
echo "==> Uploading latest.json to $RELEASE_TAG"
curl -fsS -X POST "${AUTH[@]}" "$API/releases/$target_id/assets?name=latest.json" \
-F "attachment=@$work/latest.json" >/dev/null
replace_asset "$work/latest.json" "latest.json"
echo "==> Done. $RELEASE_TAG now advertises $APP_VERSION for ${#entries[@]} platform(s)."
# The version pair, for whoever needs to describe these bundles without rebuilding
# them — today the image build, which bakes the desktop clients in and writes each
# one a sidecar (`packaging/fetch-clients.sh`).
#
# It is published HERE, beside the manifest, because this is the step that speaks
# for what the channel serves: both files are written in the same breath from the
# same two values, so they cannot disagree about which build is current. A consumer
# deriving the version from its own checkout instead would describe these bytes
# with whatever commit it happened to be on.
#
# No `size` or `sha256` — those are per-artifact and there are four. Whoever
# downloads a bundle measures the bytes it actually got, which is the only way to
# tell a truncated download from a whole one.
printf '{\n "version_name": "%s",\n "version_code": "%s"\n}\n' \
"$DISPLAY_VERSION" "$APP_VERSION" > "$work/thoughtsync-desktop.json"
replace_asset "$work/thoughtsync-desktop.json" "thoughtsync-desktop.json"
echo "==> Done. $RELEASE_TAG now advertises $DISPLAY_VERSION ($APP_VERSION) for ${#entries[@]} platform(s)."
# --- prune superseded builds from a rolling channel ---------------------------
#
@@ -169,7 +194,7 @@ if [ "${PRUNE_OLD_ASSETS:-false}" = "true" ]; then
# every desktop push regardless. That is exactly what happened on run
# 4098, which swept the APK run 4092 had just published.
case "$asset_name" in
latest.json|thoughtsync.apk|thoughtsync-android.json) continue ;;
latest.json|thoughtsync-desktop.json|thoughtsync.apk|thoughtsync-android.json) continue ;;
*"$APP_VERSION"*) continue ;;
esac
echo " removing $asset_name"
+5
View File
@@ -64,3 +64,8 @@ tauri-plugin-log = "2"
# the plugin declares android support level "none", which is why the Android client
# gets a server-served update path instead (Scribe note 2725).
tauri-plugin-updater = "2"
# The system-wide quick-capture hotkey. Desktop only by nature — Android has no
# concept of a global shortcut, and its half of this feature is a share-sheet
# intent filter instead.
tauri-plugin-global-shortcut = "2"
+10
View File
@@ -1,3 +1,13 @@
fn main() {
// Cargo does NOT track an `option_env!` variable on its own — the macro is
// expanded at compile time and nothing records that the crate depends on it.
// So without this line, a cached `target/` would keep a binary reporting
// whatever version the previous build baked, and the footer would confidently
// name the wrong build. The desktop lane has no cache today, which is exactly
// why this is easy to forget the day one is added.
//
// See DISPLAY_VERSION in `src/commands/local.rs`.
println!("cargo::rerun-if-env-changed=THOUGHTSYNC_DISPLAY_VERSION");
tauri_build::build()
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Core capability for the main ThoughtSync window.",
"windows": ["main"],
"description": "Core capability for the ThoughtSync windows: the board and the quick-capture window.",
"windows": ["main", "capture"],
"permissions": ["core:default"]
}
+226
View File
@@ -0,0 +1,226 @@
//! Quick capture: a system-wide hotkey that opens a small window to type into.
//!
//! The point is capture WITHOUT the app. Bringing the whole board forward to write
//! one line is the friction this removes, so the shortcut opens a small window of
//! its own rather than focusing `main` — and that window closes itself the moment
//! the note is saved.
//!
//! ## Why the shortcut is configurable, and why it starts unset
//!
//! A global shortcut is the one setting in this app that can collide with software
//! it knows nothing about. Whatever default is picked is a key combination taken
//! away from something on somebody's machine, silently, at install time. So there
//! is no default: the feature is off until someone chooses a combination, and
//! choosing one is how it turns on.
//!
//! The suggestion the settings screen offers (`CommandOrControl+Shift+N`) lives in
//! the frontend, not here. It is a UI affordance — a starting point put in front of
//! someone — and this side accepts any combination the OS will take, so a constant
//! here would be a second copy of a string only the UI ever reads.
//!
//! ## Failure has to be visible
//!
//! Registering can fail — the combination may already be held by the window
//! manager or another app, and on Wayland a compositor may refuse global grabs
//! outright. A hotkey that quietly does nothing is worse than one that was never
//! offered, because there is nothing to look at and nothing to fix. So the stored
//! shortcut and the LIVE registration are reported separately: see
//! [`CaptureShortcut`].
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use thoughtsync_core::local::{store, Db};
const SHORTCUT_PREF: &str = "capture_shortcut";
/// The window the hotkey opens. Also the label the capability file grants to.
pub const CAPTURE_WINDOW: &str = "capture";
/// Emitted to the main window after a capture is saved, so the board reloads.
///
/// The two windows hold separate copies of the frontend and therefore separate
/// Pinia stores; nothing in the capture window's store can reach the board's. The
/// note is already in SQLite by the time this fires — this only says "look again".
pub const CAPTURED_EVENT: &str = "thoughtsync://captured";
/// The stored shortcut and whether it is actually live.
///
/// Two fields rather than one because they genuinely disagree: a combination can
/// be saved and refuse to register, and the person needs to be told which of those
/// they are looking at. `registered: false` with a non-empty `shortcut` is the
/// "something else already has this" case.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureShortcut {
/// The stored combination, or empty when quick capture is off.
pub shortcut: String,
/// Whether the OS accepted it. Always false when `shortcut` is empty.
pub registered: bool,
}
fn stored(db: &Db) -> Result<String, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
Ok(store::pref(&conn, SHORTCUT_PREF)
.map_err(|e| e.to_string())?
.unwrap_or_default())
}
/// Open (or focus) the capture window.
///
/// Reused rather than recreated: holding one window and showing it is what makes
/// the second press feel instant, and it means a half-typed capture survives the
/// window being dismissed and reopened.
///
/// `always_on_top` and `center` because this is summoned over whatever you were
/// doing — a capture window that opens behind the app you called it from has
/// failed at the only thing it does.
fn open_capture_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window(CAPTURE_WINDOW) {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
return;
}
// `index.html?capture=1` rather than a `/capture` path: the bundled assets are
// served as files, so a path with no file behind it is a 404 in the production
// build even though it routes fine under the dev server. A query string is
// carried through untouched and the router reads it on boot.
let built = WebviewWindowBuilder::new(
app,
CAPTURE_WINDOW,
WebviewUrl::App("index.html?capture=1".into()),
)
.title("Quick capture")
.inner_size(520.0, 220.0)
.min_inner_size(360.0, 160.0)
.resizable(true)
.always_on_top(true)
.center()
.skip_taskbar(true)
.build();
match built {
Ok(window) => {
let _ = window.set_focus();
}
// Never a panic and never fatal: failing to open a capture window must not
// take down an app whose board is working fine.
Err(e) => log::error!("could not open the capture window: {e}"),
}
}
/// Register `shortcut`, replacing whatever was live.
///
/// Unregisters everything first rather than tracking the previous binding: this
/// app owns exactly one global shortcut, so "all of ours" and "the old one" are
/// the same set, and keeping a copy of it is one more thing to get out of step.
fn register(app: &AppHandle, shortcut: &str) -> Result<(), String> {
let manager = app.global_shortcut();
let _ = manager.unregister_all();
if shortcut.is_empty() {
return Ok(());
}
let parsed: Shortcut = shortcut
.parse()
.map_err(|_| format!("'{shortcut}' is not a shortcut this system understands."))?;
manager
.on_shortcut(parsed, |app, _shortcut, event| {
// Pressed only. Without this the window is opened on the press AND on
// the release, and the second one lands on the window the first opened.
if event.state == ShortcutState::Pressed {
open_capture_window(app);
}
})
.map_err(|e| format!("Something else on this system is already using it ({e})."))
}
/// Restore the stored shortcut at startup.
///
/// Best-effort by construction: a combination that worked when it was chosen can
/// be taken by something installed later, and the app must still open. The failure
/// is logged and the UI will show it as not registered when the settings screen is
/// next opened.
pub fn restore(app: &AppHandle, db: &Db) {
let shortcut = match stored(db) {
Ok(s) if !s.is_empty() => s,
Ok(_) => return,
Err(e) => {
log::warn!("could not read the capture shortcut: {e}");
return;
}
};
match register(app, &shortcut) {
Ok(()) => log::info!("quick capture is on: {shortcut}"),
Err(e) => log::warn!("quick capture shortcut '{shortcut}' did not register: {e}"),
}
}
#[tauri::command]
pub fn capture_shortcut_get(app: AppHandle, db: State<'_, Db>) -> Result<CaptureShortcut, String> {
let shortcut = stored(&db)?;
// Asked of the manager rather than remembered from startup: the answer can
// have changed since, and a settings screen that reports a stale success is
// the exact thing this pair of fields exists to prevent.
let registered = !shortcut.is_empty()
&& shortcut
.parse::<Shortcut>()
.map(|s| app.global_shortcut().is_registered(s))
.unwrap_or(false);
Ok(CaptureShortcut {
shortcut,
registered,
})
}
/// Store a shortcut and make it live, or clear it with an empty string.
///
/// Registers BEFORE storing, so a combination the system refuses is not written
/// down as though it worked — the person would reopen the settings and find it
/// listed as their shortcut while nothing happened when they pressed it.
#[tauri::command]
pub fn capture_shortcut_set(
shortcut: String,
app: AppHandle,
db: State<'_, Db>,
) -> Result<CaptureShortcut, String> {
let wanted = shortcut.trim().to_string();
register(&app, &wanted)?;
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::set_pref(&conn, SHORTCUT_PREF, &wanted).map_err(|e| e.to_string())?;
log::info!(
"quick capture shortcut {}",
if wanted.is_empty() {
"cleared".to_string()
} else {
format!("set to {wanted}")
}
);
Ok(CaptureShortcut {
shortcut: wanted.clone(),
registered: !wanted.is_empty(),
})
}
/// Hide the capture window and tell the board to reload.
///
/// Hidden rather than closed so the next press has a window to show instead of one
/// to build. Called after a save and on Escape alike; `saved` is what decides
/// whether the board is told to look again.
#[tauri::command]
pub fn capture_done(saved: bool, app: AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window(CAPTURE_WINDOW) {
window.hide().map_err(|e| e.to_string())?;
}
if saved {
if let Some(main) = app.get_webview_window("main") {
// Failure here is cosmetic — the note is saved either way and the board
// will show it on its next load — so it is logged, not raised.
if let Err(e) = main.emit(CAPTURED_EVENT, ()) {
log::warn!("could not tell the board about a capture: {e}");
}
}
}
Ok(())
}
+3 -1
View File
@@ -31,7 +31,9 @@ pub fn config_get(db: State<'_, Db>) -> PublicConfig {
PublicConfig {
site_name: "ThoughtSync".to_string(),
allow_registration: false,
version: env!("CARGO_PKG_VERSION").to_string(),
// The build a person reads, baked at compile time — see crate::display_version
// for why this is neither CARGO_PKG_VERSION nor the updater's ordering key.
version: crate::display_version().to_string(),
enable_url_unfurl: false,
trash_retention_days: retention_days.max(0) as u32,
}
+50 -2
View File
@@ -9,10 +9,42 @@
//! remains here is the Tauri command surface (`commands`), desktop integration
//! (menu-entry install for the Linux AppImage), the in-app updater, and boot.
mod capture;
mod commands;
mod integration;
mod update;
/// The build a PERSON reads, baked in by the desktop lane at compile time.
///
/// Lives at the crate root because it has two readers — `config_get`, which puts it
/// in the UI, and `log_environment`, which puts it in the log — and this repo has
/// spent several issues on one fact held in two places (2181, 2182, 2183).
///
/// `option_env!`, not `env!`: a local `cargo tauri build` sets nothing, and this has
/// to keep compiling. `None` becomes "unknown" at each call site rather than a
/// plausible-looking default — note 3127 §5 makes this string the only answer to
/// "which build is this?" now that there are no version tags, so there is nothing
/// left to contradict it if it lies. An honest "I cannot say" is the only safe wrong
/// answer.
///
/// NOT `CARGO_PKG_VERSION`, which both readers used to use, and which was wrong on
/// every build ever shipped: `cargo tauri build --config '{"version": ...}'`
/// overrides `tauri.conf.json`, not Cargo's own metadata, so the literal `0.2.0` in
/// Cargo.toml is what reached the UI and the log regardless of what was built.
///
/// NOT the ordering key either. That value — `1.0.<minutes>`, which the override
/// above does set — is the opaque value Tauri's updater compares; it lands in bundle
/// filenames and `latest.json` and must never be shown to a person (#3144). Two
/// values, two audiences. `update.rs` deliberately still reads the key, through
/// `app.package_info().version`, because a comparator is exactly what it is.
const DISPLAY_VERSION: Option<&str> = option_env!("THOUGHTSYNC_DISPLAY_VERSION");
/// The baked build, or the honest "I cannot say". The only way in — the const is
/// private so no caller can reach past the fallback.
pub(crate) fn display_version() -> &'static str {
DISPLAY_VERSION.unwrap_or("unknown")
}
// The store and the sync engine live in the shared `thoughtsync-core` crate, which
// the Android client binds through uniffi (Scribe note 2730). Aliased to their old
// names so every call site below reads exactly as it did when they were modules of
@@ -22,6 +54,12 @@ use thoughtsync_core::{local, sync};
pub fn run() {
use tauri_plugin_log::{Target, TargetKind};
// Introduce ourselves to any server this app links to, BEFORE anything can sync.
// The core cannot work this out — it is compiled into the Android app too — so
// the header says "desktop" only because the desktop says so here, and carries
// the build a person can read rather than the core crate's own version.
sync::compat::set_client_agent("thoughtsync-desktop", display_version());
#[cfg(target_os = "linux")]
harden_linux_webkit_rendering();
@@ -43,6 +81,10 @@ pub fn run() {
// build without a signing key still starts normally and simply reports that
// updates aren't configured.
.plugin(tauri_plugin_updater::Builder::new().build())
// The quick-capture hotkey. Registering the combination itself happens in
// `setup`, once the store is open and can be asked which one to use — the
// plugin only has to exist before then.
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
// Attachment bytes are served to the webview from the local blob store
// (M10.7f). Registered on the BUILDER because a scheme has to exist before
// the webview is created; the directory it reads from arrives later, in
@@ -81,6 +123,9 @@ pub fn run() {
// in this directory saying which one the user picked (issue 2183).
update::adopt_installer_channel(&db, &dir);
sweep_local_trash(&db);
// Before the store is handed to the app: `restore` needs to read the
// stored shortcut out of it, and after `manage` the Db has moved.
capture::restore(app.handle(), &db);
app.manage(db);
// Attachment bytes live beside the database, filed by content hash, so a
// synced image is readable with no network (M10.7d).
@@ -139,6 +184,9 @@ pub fn run() {
update::update_channel_set,
update::update_check,
update::update_install,
capture::capture_shortcut_get,
capture::capture_shortcut_set,
capture::capture_done,
])
.run(tauri::generate_context!())
.expect("error while running the ThoughtSync desktop app");
@@ -219,8 +267,8 @@ fn log_event(level: String, message: String) {
fn log_environment(app: &tauri::App) {
use tauri::Manager;
log::info!(
"ThoughtSync desktop v{} starting ({} {})",
env!("CARGO_PKG_VERSION"),
"ThoughtSync desktop {} starting ({} {})",
display_version(),
std::env::consts::OS,
std::env::consts::ARCH,
);
+4
View File
@@ -36,6 +36,10 @@ pinning.
If you want a specific build — testing something, or holding back — drop it in
`/var/thoughtsync/client/` and it wins over the image's copy.
That directory is shared with the desktop clients the server hands out, and
**precedence is decided per platform**: dropping in an APK overrides the baked APK
and leaves every other client alone. It is one directory, not one choice.
Two files, both required:
| File | What it is |
+33 -5
View File
@@ -27,6 +27,23 @@ const ui = useUiStore();
// Sync is a desktop-app concern: the web build already IS the server's UI.
const desktopApp = isDesktop();
// The build, for the dim line at the foot of the rail (#3181).
//
// NEVER BLANK. "unknown" is the honest answer when the value is missing, and an
// empty space is a bug that reads as a design choice. Note 3127 §5: with version
// tags gone this is the only answer to "which build is this?", so it has to be
// either right or visibly absent.
//
// One slot, two artifacts, and that is deliberate rather than sloppy. In the
// browser `repo` is `rest`, so this is the SERVER's version; in the desktop shell
// `repo` is `local` and `config_get` returns the desktop build's own. Each surface
// names the thing the person is actually looking at. A linked server's version is
// a different question and Sync answers it separately.
const buildVersion = computed(() => config.version || "unknown");
const buildLabel = computed(
() => `ThoughtSync ${desktopApp ? "desktop" : "server"} build ${buildVersion.value}`,
);
async function removeView(f: SavedFilter) {
if (!window.confirm(`Delete the "${f.name}" view?`)) return;
try {
@@ -415,7 +432,7 @@ async function signOut() {
@click="drawer = false"
></div>
<aside
class="fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-[calc(0.75rem+env(safe-area-inset-top,0px))] transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 sm:pb-3 sm:pt-3 dark:border-neutral-800 dark:bg-neutral-950"
class="fixed inset-y-0 left-0 z-40 flex w-64 -translate-x-full flex-col overflow-y-auto border-r border-neutral-200 bg-neutral-50 p-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-[calc(0.75rem+env(safe-area-inset-top,0px))] transition-transform duration-200 sm:static sm:z-auto sm:w-56 sm:translate-x-0 sm:pb-3 sm:pt-3 dark:border-neutral-800 dark:bg-neutral-950"
:class="drawer ? 'translate-x-0' : ''"
>
<nav class="flex flex-col gap-0.5 text-sm" @click="drawer = false">
@@ -424,18 +441,18 @@ async function signOut() {
</RouterLink>
<div class="mt-3 flex items-center justify-between px-3 pb-1">
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span>
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Tags</span>
<button
type="button"
class="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200"
title="Edit labels"
aria-label="Edit labels"
title="Manage tags"
aria-label="Manage tags"
@click="managing = true"
>
<Icon name="pencil" />
</button>
</div>
<p v-if="!labels.items.length" class="px-3 py-1 text-xs text-neutral-400">No labels yet</p>
<p v-if="!labels.items.length" class="px-3 py-1 text-xs text-neutral-400">No tags yet</p>
<RouterLink
v-for="lb in labels.items"
:key="lb.id"
@@ -527,6 +544,17 @@ async function signOut() {
</button>
</div>
</nav>
<!-- The build. `mt-auto` puts it at the foot of the rail when the nav is
short and lets it simply follow when the nav has scrolled.
`select-all` because the one thing anybody does with this is copy it
into a bug report. -->
<p
class="mt-auto select-all px-3 pt-6 text-[11px] text-neutral-400 dark:text-neutral-500"
:title="buildLabel"
>
{{ buildVersion }}
</p>
</aside>
<!-- tabindex="-1" so the skip link above actually moves FOCUS here, not just
+7 -9
View File
@@ -11,18 +11,16 @@ withDefaults(
</script>
<template>
<!-- The look comes from `.btn` + a variant in style.css, NOT from here. A
download has to be an <a> (only an anchor can carry an href and hand the
transfer to the browser), so the shape has to live somewhere both elements
can wear it. The disabled: variants stay local an anchor has no
:disabled, so they are not shared and never were. -->
<button
:type="type"
:disabled="disabled || loading"
class="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold transition
focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2
focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950
disabled:cursor-not-allowed disabled:opacity-60"
:class="
variant === 'primary'
? 'bg-brand text-neutral-900 shadow-sm hover:bg-brand-600 active:bg-brand-700'
: 'text-neutral-700 hover:bg-neutral-200/70 dark:text-neutral-200 dark:hover:bg-neutral-800'
"
class="btn disabled:cursor-not-allowed disabled:opacity-60"
:class="variant === 'primary' ? 'btn-primary' : 'btn-ghost'"
>
<span
v-if="loading"
+167
View File
@@ -0,0 +1,167 @@
<script setup lang="ts">
// Every client this server holds, with the one that fits the visitor on top.
//
// Five artifacts is where a downloads page turns into a table of filenames and
// stops being a product. So this LEADS with the download that fits the machine
// asking and keeps the rest quiet but visible — nothing is behind a disclosure,
// because a wrong guess must cost a person nothing.
import { computed } from "vue";
import { useConfigStore, type ClientRelease } from "../stores/config";
const config = useConfigStore();
type Family = "android" | "windows" | "linux" | "mac" | "ios" | "other";
/**
* Which OS is asking, from the user agent.
*
* ORDER IS THE WHOLE ALGORITHM. Android's UA contains "Linux", an iPad's contains
* "Mac OS X", and a Chromebook's contains "X11" — so each narrow test has to run
* before the broad one that would otherwise swallow it.
*
* `navigator.userAgent` rather than `userAgentData`: the reduced UA Chrome now
* sends still carries the platform token, which is the only thing being asked
* for, and one code path beats two for a guess that is allowed to be wrong.
*/
function detectFamily(ua: string): Family {
if (/Android/i.test(ua)) return "android";
if (/Windows/i.test(ua)) return "windows";
if (/iPhone|iPad|iPod/i.test(ua)) return "ios";
if (/Mac OS X|Macintosh/i.test(ua)) return "mac";
// CrOS lands here on purpose: a Chromebook's Linux container is a Debian one,
// which is the first thing the Linux group offers.
if (/Linux|X11|CrOS/i.test(ua)) return "linux";
return "other";
}
// What to lead with per family, in the order someone on it should see them.
//
// Linux gets all three because the UA says "Linux" and nothing about dpkg or
// pacman — there is no more specific answer to be had, so the three are named for
// the DISTRO a person knows rather than the package format they may not.
//
// macOS and iOS lead with nothing. There is no build for either, and an empty
// lead is the honest way to say so — see `missingPlatform` below.
const LEAD: Record<Family, string[]> = {
android: ["android"],
windows: ["windows"],
linux: ["linux-deb", "linux-pacman", "linux-appimage"],
mac: [],
ios: [],
other: [],
};
const FAMILY_TITLE: Record<Family, string> = {
android: "Android",
windows: "Windows",
linux: "Linux",
mac: "macOS",
ios: "iOS",
other: "This machine",
};
// Read once. The UA does not change while the page is open, and making it
// reactive would only invite someone to think it could.
const family = detectFamily(navigator.userAgent);
// The lead offers this server actually holds. A platform in LEAD that the server
// has no build for simply is not here — the guess never conjures a download.
const lead = computed(() =>
LEAD[family]
.map((id) => config.clients[id])
.filter((c): c is ClientRelease => Boolean(c)),
);
const others = computed(() => {
const leading = new Set(lead.value.map((c) => c.platform));
// Object.values keeps the server's own PLATFORMS order, which is a deliberate
// one (phone first, then the desktop bundles) and not worth re-deciding here.
return Object.values(config.clients).filter((c) => !leading.has(c.platform));
});
const groups = computed(() => {
const out: { title: string; releases: ClientRelease[]; prominent: boolean }[] = [];
if (lead.value.length) {
out.push({ title: FAMILY_TITLE[family], releases: lead.value, prominent: true });
}
if (others.value.length) {
out.push({
// Without a lead there is no "other" — the whole list is the choice.
title: lead.value.length ? "Other platforms" : "Choose a platform",
releases: others.value,
prominent: false,
});
}
return out;
});
// Said plainly, so a Mac reads as "not yet" rather than as a page that failed to
// find its own downloads.
const missingPlatform = computed(() =>
!lead.value.length && (family === "mac" || family === "ios") ? FAMILY_TITLE[family] : "",
);
// One decimal below 10 MB, none above: these sit in one list where a 2.7 MB
// package and a 95 MB AppImage are compared, and "3 MB" next to "95 MB" loses the
// only distinction that matters at the small end.
function readableSize(bytes: number): string {
const mb = bytes / 1024 / 1024;
return `${mb < 10 ? mb.toFixed(1) : mb.toFixed(0)} MB`;
}
</script>
<template>
<!-- Nothing at all on a server with no clients a brand-new instance before its
first image carrying them. An empty section would be a promise it can't keep. -->
<section v-if="groups.length" class="mb-6 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800">
<h2 class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Get the apps</h2>
<p class="mt-0.5 text-xs text-neutral-400">
Served by this server, so they always speak the same sync protocol.
</p>
<p v-if="missingPlatform" class="mt-1 text-xs text-neutral-400">
There's no {{ missingPlatform }} build yet.
</p>
<div v-for="group in groups" :key="group.title" class="mt-4">
<p class="text-xs font-medium uppercase tracking-wide text-neutral-400">{{ group.title }}</p>
<ul class="mt-2 flex flex-col gap-2">
<li
v-for="client in group.releases"
:key="client.platform"
class="flex items-center justify-between gap-4"
>
<div class="min-w-0">
<p class="text-sm text-neutral-800 dark:text-neutral-100">{{ client.label }}</p>
<p class="mt-0.5 text-xs text-neutral-400">
<!-- `unknown` rather than a blank or a plausible default: with no
second source to contradict it, a wrong version here is a wrong
answer nothing can catch. Not knowing which build it is, is also
not a reason to withhold the download. -->
Version {{ client.version || "unknown" }} · {{ readableSize(client.size) }}<span
v-if="client.platform === 'linux-appimage'"
>, and the only one that updates itself in place</span
>
</p>
</div>
<!-- An anchor, never BaseButton and never a fetch: these are 395 MB and
the browser's own download manager handles the transfer better than
anything this app would do with a blob. It wears `.btn` — the same
definition BaseButton wears, so the two cannot drift.
`download` carries no filename because the server already names the
file in its Content-Disposition, which browsers prefer over this
attribute anyway — a value here would be inert and read as if it
weren't. -->
<a
:href="client.url"
download
class="btn shrink-0"
:class="group.prominent ? 'btn-primary' : 'btn-ghost'"
>
Download
</a>
</li>
</ul>
</div>
</section>
</template>
+1 -1
View File
@@ -108,7 +108,7 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
class="mt-2 flex flex-col gap-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800"
>
<div v-if="labels.items.length" class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Labels</span>
<span class="w-16 shrink-0 text-xs text-neutral-400">Tags</span>
<button
v-for="lb in labels.items"
:key="lb.id"
+2 -2
View File
@@ -46,7 +46,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<template>
<div ref="root" class="relative">
<button type="button" class="icon-btn" title="Labels" aria-label="Labels" @click="open = !open">
<button type="button" class="icon-btn" title="Tags" aria-label="Tags" @click="open = !open">
<Icon name="tag" />
</button>
<div
@@ -56,7 +56,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<input
v-model="filter"
type="text"
placeholder="Label note…"
placeholder="Tag note…"
class="mb-1 w-full rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-600 dark:bg-neutral-900"
/>
<ul class="max-h-48 overflow-y-auto">
+8 -8
View File
@@ -67,7 +67,7 @@ async function doMerge(sourceId: string, targetId: string) {
<template>
<BaseModal panel-class="w-full max-w-sm shadow-xl" @close="emit('close')">
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
<h2 class="text-sm font-semibold">Manage labels</h2>
<h2 class="text-sm font-semibold">Manage tags</h2>
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
</div>
<div class="flex flex-col gap-2 p-4">
@@ -76,7 +76,7 @@ async function doMerge(sourceId: string, targetId: string) {
<input
v-model="newName"
type="text"
placeholder="Create label"
placeholder="Create tag"
class="flex-1 rounded-md border border-neutral-300 bg-white px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800"
/>
</form>
@@ -87,7 +87,7 @@ async function doMerge(sourceId: string, targetId: string) {
class="h-4 w-4 shrink-0 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/15"
:class="labelDot(lb)"
:title="`Color: ${NOTE_COLOR_LABELS[resolveLabelColor(lb)]}`"
aria-label="Change label color"
aria-label="Change tag color"
@click="openColor(lb.id)"
/>
<input
@@ -104,8 +104,8 @@ async function doMerge(sourceId: string, targetId: string) {
v-if="canMerge"
type="button"
class="icon-btn"
title="Merge into another label"
aria-label="Merge into another label"
title="Merge into another tag"
aria-label="Merge into another tag"
@click="openMerge(lb.id)"
>
<Icon name="merge" />
@@ -113,8 +113,8 @@ async function doMerge(sourceId: string, targetId: string) {
<button
type="button"
class="icon-btn"
title="Delete label"
aria-label="Delete label"
title="Delete tag"
aria-label="Delete tag"
@click="labels.remove(lb.id)"
>
<Icon name="trash" />
@@ -160,7 +160,7 @@ async function doMerge(sourceId: string, targetId: string) {
</li>
</ul>
<p v-if="!labels.items.length" class="py-2 text-center text-xs text-neutral-400">
No labels yet create one above.
No tags yet create one above, or write a #tag in a note and it becomes one.
</p>
</div>
</BaseModal>
+63 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue";
@@ -165,6 +165,63 @@ async function flush(): Promise<void> {
}
}
// ---- idle autosave ----
//
// This editor used to write ONLY on close, and the reason was cost: a body write
// snapshotted a revision, so saving often meant a version history of thirty
// snapshots of one paragraph being typed. The price was durability — a tab closed
// mid-paragraph lost the paragraph, which is the one thing a notes app must not do.
//
// That trade is gone. Both engines now coalesce snapshots to one per editing
// session (`revisions.py` and `store.rs`'s `should_snapshot`, Scribe #2971), so a
// write costs a write. Writing on an idle pause is what collects the refund; the
// Android editor already does the same.
const AUTOSAVE_MS = 1000;
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
function cancelAutosave(): void {
if (autosaveTimer !== null) {
clearTimeout(autosaveTimer);
autosaveTimer = null;
}
}
async function autosave(): Promise<void> {
// `flush` returns without writing while a save is in flight, which would silently
// drop everything typed since that save began. Re-arming rather than skipping is
// what keeps that from being a lost paragraph.
if (saving.value) {
scheduleAutosave();
return;
}
try {
await flush();
} catch {
// Swallowed on purpose. An autosave that interrupts typing with an error is
// worse than one that waits for the next pause, and `close` still surfaces a
// real failure at the moment the person is looking at the editor.
}
}
function scheduleAutosave(): void {
cancelAutosave();
autosaveTimer = setTimeout(() => {
autosaveTimer = null;
void autosave();
}, AUTOSAVE_MS);
}
// EDIT mode only, deliberately. In compose, `dismiss` discards a note that was
// never persisted, so that an accidental keystroke or a type-to-compose never
// litters the board — and an autosave that created the row would take that away
// without anyone asking for it. Materialising a compose on first keystroke is a
// separate decision (Scribe #2967), not a side effect of this one.
watch(body, () => {
if (!isCreate.value) scheduleAutosave();
});
onBeforeUnmount(cancelAutosave);
function resetCompose(): void {
noteId.value = null;
setBody("");
@@ -225,6 +282,10 @@ async function finish(): Promise<void> {
// Persist (create in compose, save in edit) and close the editor.
async function close(): Promise<void> {
// Cancelled first: a timer that fires during the leave animation would write
// through a component on its way out, after `flush` has already saved the same
// text.
cancelAutosave();
await flush();
await finish();
}
@@ -233,6 +294,7 @@ async function close(): Promise<void> {
// commit it explicitly (Done, Ctrl/Cmd+Enter, or Shift+Enter). An existing note, or a
// compose already persisted by a rich action, closes normally (saving its text).
async function dismiss(): Promise<void> {
cancelAutosave();
if (isCreate.value) {
await finish();
return;
+53
View File
@@ -5,6 +5,13 @@
interface TauriGlobal {
core: { invoke: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T> };
// Also from `withGlobalTauri`. Needed because quick capture puts the app in TWO
// windows, each with its own Pinia stores — a note saved in one is invisible to
// the other until something says so, and an event is the only channel between
// them that does not involve polling SQLite.
event?: {
listen: <T>(event: string, handler: (e: { payload: T }) => void) => Promise<() => void>;
};
}
declare global {
@@ -192,3 +199,49 @@ export const updates = {
*/
install: () => invoke<void>("update_install"),
};
// --- Quick capture (#1899) ---------------------------------------------------
/**
* The stored hotkey and whether the OS actually accepted it.
*
* They disagree more often than you would like: a combination can be saved and
* refuse to register because a window manager or another app already holds it,
* and on Wayland a compositor may refuse global grabs entirely. `registered:
* false` alongside a non-empty `shortcut` is precisely that case, and the UI has
* to say so — a hotkey that silently does nothing is worse than none, because
* there is nothing to look at and nothing to fix.
*/
export interface CaptureShortcut {
/** The stored combination, or "" when quick capture is off. */
shortcut: string;
registered: boolean;
}
/** Offered as a starting point, never applied on the user's behalf. */
export const SUGGESTED_CAPTURE_SHORTCUT = "CommandOrControl+Shift+N";
/** Fired at the main window after a capture is saved. */
const CAPTURED_EVENT = "thoughtsync://captured";
export const capture = {
shortcut: () => invoke<CaptureShortcut>("capture_shortcut_get"),
/** Pass "" to turn quick capture off. Rejects if the system refuses it. */
setShortcut: (shortcut: string) => invoke<CaptureShortcut>("capture_shortcut_set", { shortcut }),
/** Hide the capture window; `saved` decides whether the board is told to reload. */
done: (saved: boolean) => invoke<void>("capture_done", { saved }),
};
/**
* Run `handler` whenever a note is captured in the other window.
*
* Returns an unlisten function, or a no-op on the web build and on any desktop
* runtime that does not expose the event API — the board simply keeps showing
* what it has until its next load, which is a stale list rather than a broken one.
*/
export async function onCaptured(handler: () => void): Promise<() => void> {
const events = window.__TAURI__?.event;
if (!events) return () => {};
return events.listen(CAPTURED_EVENT, () => handler());
}
+16
View File
@@ -24,6 +24,15 @@ const router = createRouter({
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
],
},
{
// The quick-capture window (#1899). Its own route because it is its own
// WINDOW — no shell, no nav, one field. Desktop only: there is no global
// hotkey in a browser tab and nothing to summon it.
path: "/capture",
name: "capture",
component: () => import("../views/CaptureView.vue"),
meta: { requiresAuth: true, requiresDesktop: true },
},
{
path: "/settings",
name: "settings",
@@ -89,6 +98,13 @@ router.beforeEach(async (to) => {
if (to.meta.requiresDesktop && !isDesktop()) {
return { name: "board" };
}
// The capture window is opened at `index.html?capture=1` rather than at
// `/capture`, because the bundled assets are served as files and a path with no
// file behind it 404s in the production build — it only routes under the dev
// server. A query string survives that, and this is where it becomes a route.
if (to.query.capture === "1" && to.name !== "capture") {
return { name: "capture" };
}
// Deliberately NOT applied to /login and /register: bouncing those on desktop
// would loop against the requiresAuth guard above the moment a session is
// missing. Nothing on the desktop navigates to them any more (AppShell's sign-out
+40 -10
View File
@@ -2,15 +2,38 @@ import { defineStore } from "pinia";
import { ref } from "vue";
import { repo } from "../adapters";
// The Android build this server can hand out. Absent — not null — when it has
// none, so `v-if` on it is the whole test; see client_dist.py.
export interface AndroidClient {
// One client build this server can hand out. Platforms it holds nothing for are
// ABSENT from the map rather than present-and-null, so a key test is the whole
// question; see client_dist.py.
//
// Named for its twin in `core/src/sync/client.rs`, which deserializes the same
// payload. That one is deliberately NARROWER — it only ever reads
// `/api/client/android`, so its `version_code` is an `i64` and it declares none of
// the fields below that Android does not use. Widening it to match this is not a
// tidy-up: every phone in the field runs the current shape.
export interface ClientRelease {
// The table row's id — "android", "linux-deb", "linux-appimage", "windows".
platform: string;
// What a person calls it, named for the DISTRO rather than the package format
// ("Debian / Ubuntu", not ".deb"). The server owns this wording so the five
// labels cannot drift apart across the surfaces that show them.
label: string;
version: string;
// What decides "is this newer". The name is for people and sorts like a string.
version_code: number;
//
// Not one type across platforms, deliberately: Android's is an integer because
// Android's own install gate compares one, and the desktop's is Tauri's semver
// key `1.0.<minutes>`. Nothing in this app compares them — the union is here so
// the shape is honest rather than to be read.
version_code: number | string;
size: number;
sha256: string;
// A PATH, never an absolute URL — the client joins it to the server it is
// already talking to.
url: string;
// Present only for the AppImage: the minisign signature the desktop updater
// checks before replacing the running binary.
signature?: string;
}
export interface PublicConfig {
@@ -20,7 +43,14 @@ export interface PublicConfig {
enable_url_unfurl: boolean;
// How many days a note survives in Trash before the server purges it. 0 = forever.
trash_retention_days: number;
android_client?: AndroidClient;
// Every client this server holds, keyed by platform id. Absent on a server that
// holds none, and absent on the desktop's own offline config — the Tauri build
// answers `config_get` locally and has no clients to hand out.
//
// `/api/config` also carries `android_client`, which is NOT declared here: it
// exists for phones in the field polling for their own update, not for this app,
// and reading it here would be a second path to the same fact.
clients?: Record<string, ClientRelease>;
}
// Public, unauthenticated app config (site name, whether signups are open).
@@ -33,9 +63,9 @@ export const useConfigStore = defineStore("config", () => {
// unreachable — and 30 is a safer stand-in than 0, since claiming "kept forever"
// when the server is actually purging is the wrong way to be wrong.
const trashRetentionDays = ref(30);
// Null until proven otherwise: a server with no APK, and an older server that
// never had the field, both correctly show no download.
const androidClient = ref<AndroidClient | null>(null);
// Empty until proven otherwise: a server with no clients, and an older server
// that never had the field, both correctly offer no downloads.
const clients = ref<Record<string, ClientRelease>>({});
const loaded = ref(false);
async function load(): Promise<void> {
@@ -47,7 +77,7 @@ export const useConfigStore = defineStore("config", () => {
version.value = cfg.version;
enableUrlUnfurl.value = cfg.enable_url_unfurl ?? true;
trashRetentionDays.value = cfg.trash_retention_days ?? 30;
androidClient.value = cfg.android_client ?? null;
clients.value = cfg.clients ?? {};
} catch {
// Keep defaults if the config endpoint is unreachable.
} finally {
@@ -66,7 +96,7 @@ export const useConfigStore = defineStore("config", () => {
version,
enableUrlUnfurl,
trashRetentionDays,
androidClient,
clients,
loaded,
load,
reload,
+29 -1
View File
@@ -33,7 +33,35 @@ export const useLabelsStore = defineStore("labels", () => {
}
async function rename(id: string, name: string): Promise<void> {
// Renaming onto a name another tag already holds MERGES the two — server-side
// and in the local store, identically. Detected from the LIST rather than from
// the response: the survivor is whichever row is older, so it may well be the
// one we asked to rename, and an id that still matches proves nothing happened.
const absorbing = items.value.find(
(lb) => lb.id !== id && lb.name.toLowerCase() === name.toLowerCase(),
);
if (absorbing) {
// A merge cannot be undone by repeating it, and here it is reachable by a
// typo in a text field — so it asks, the way deleting one does. The counts
// are named because "40 notes" is the part that makes the consequence real.
const mine = items.value.find((lb) => lb.id === id);
const confirmed = window.confirm(
`A tag called "${absorbing.name}" already exists.\n\n` +
`Renaming will MERGE these two into one tag named "${name}", carrying ` +
`every note from both (${mine?.count ?? 0} + ${absorbing.count ?? 0}). ` +
"The notes are kept; one of the two tags stops existing, and that cannot " +
"be undone.",
);
if (!confirmed) return;
}
const updated = await repo.labels.rename(id, name);
if (absorbing) {
// One row is gone and the survivor's count grew, and this response carries no
// count — reload rather than guess which of the two we are now holding.
await load();
return;
}
const idx = items.value.findIndex((lb) => lb.id === id);
// The single-label PATCH doesn't recompute the count — keep the one we have.
if (idx >= 0) items.value[idx] = { ...updated, count: items.value[idx].count };
@@ -53,7 +81,7 @@ export const useLabelsStore = defineStore("labels", () => {
// notes themselves survive; only the membership goes, which is the part people
// most need reassuring about.
const label = items.value.find((lb) => lb.id === id);
const subject = label ? `the label "${label.name}"` : "this label";
const subject = label ? `the tag "${label.name}"` : "this tag";
const confirmed = window.confirm(
`Delete ${subject}?\n\n` +
"It will be removed from every note that uses it, on every device you sync " +
+29
View File
@@ -253,6 +253,35 @@ body {
@apply inline-flex min-h-[2.25rem] items-center justify-center px-3;
}
}
/* THE button shape — the ONE definition of it in the app.
*
* It lives here, in the components layer, rather than inside BaseButton.vue,
* because not every button in this app is a <button>. A DOWNLOAD has to be an
* anchor: these are 3-95 MB installers, only an <a> can carry an href, and the
* browser's own download manager handles that transfer better than anything the
* app would do by fetching to a blob. BaseButton cannot serve that case, and a
* second copy of its class list for anchors is how a page ends up with two
* kinds of primary button that drift apart.
*
* So: BaseButton.vue wears these, and so does any anchor that must read as a
* button. Neither owns the look.
*
* The `disabled:` variants are NOT here on purpose — an anchor has no
* :disabled. BaseButton adds them itself, which is exactly the split: shared
* where it is shared, local where the element differs.
*/
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm
font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50
dark:focus-visible:ring-offset-neutral-950;
}
.btn-primary {
@apply bg-brand text-neutral-900 shadow-sm hover:bg-brand-600 active:bg-brand-700;
}
.btn-ghost {
@apply text-neutral-700 hover:bg-neutral-200/70 dark:text-neutral-200 dark:hover:bg-neutral-800;
}
.nav-link {
@apply flex items-center gap-2 rounded-lg px-3 py-2 font-medium text-neutral-600 transition
hover:bg-neutral-200/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
+6 -35
View File
@@ -5,6 +5,7 @@ import { useDevicesStore } from "../stores/devices";
import { useUiStore } from "../stores/ui";
import BaseButton from "../components/BaseButton.vue";
import BaseInput from "../components/BaseInput.vue";
import ClientDownloads from "../components/ClientDownloads.vue";
import Icon from "../components/Icon.vue";
import { isDesktop, desktop as desktopBridge, type IntegrationStatus } from "../desktop/bridge";
@@ -12,14 +13,10 @@ import { isDesktop, desktop as desktopBridge, type IntegrationStatus } from "../
// Android apps authenticate sync with a device bearer token issued here.
const devices = useDevicesStore();
const ui = useUiStore();
// The Android build this server holds, if it holds one. Null on a server with no
// APK — the card below is hidden rather than offering a download that 404s.
// Loaded here rather than in ClientDownloads: this view already awaits it, and a
// component that fetches its own config would race the one that does.
const config = useConfigStore();
function readableSize(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
}
const error = ref("");
const newName = ref("");
const creating = ref(false);
@@ -160,35 +157,9 @@ onMounted(() => {
</BaseButton>
</section>
<!-- The Android client this server hands out (hidden when it has none) -->
<section
v-if="config.androidClient"
class="mb-6 flex items-center justify-between gap-4 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800"
>
<div class="min-w-0">
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Android app</p>
<p class="mt-0.5 text-xs text-neutral-400">
Version {{ config.androidClient.version }} ·
{{ readableSize(config.androidClient.size) }} · served by this server, so it always
speaks the same sync protocol.
</p>
</div>
<!-- A plain anchor, not BaseButton and not a fetch: this is 55 MB, and the
browser's own download manager handles it better than anything this app
would do with a blob. Styled to match BaseButton's primary variant,
which is a <button> and cannot carry an href. -->
<a
:href="config.androidClient.url"
:download="`thoughtsync-${config.androidClient.version}.apk`"
class="inline-flex shrink-0 items-center justify-center gap-2 rounded-lg bg-brand px-4 py-2.5
text-sm font-semibold text-neutral-900 shadow-sm transition hover:bg-brand-600
active:bg-brand-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand
focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50
dark:focus-visible:ring-offset-neutral-950"
>
Download
</a>
</section>
<!-- Every client this server holds, the one that fits this machine on top.
Hides itself when the server holds none. -->
<ClientDownloads />
<!-- One-time token reveal -->
<div
+15 -2
View File
@@ -11,7 +11,7 @@ import EmptyState from "../components/EmptyState.vue";
import FilterBar from "../components/FilterBar.vue";
import NoteGrid from "../components/NoteGrid.vue";
import NoteEditor from "../components/NoteEditor.vue";
import { isDesktop, sync as syncBridge } from "../desktop/bridge";
import { isDesktop, onCaptured, sync as syncBridge } from "../desktop/bridge";
const notes = useNotesStore();
const config = useConfigStore();
@@ -191,7 +191,7 @@ const emptyState = computed(() => {
if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." };
if (currentView.value === "archived")
return { title: "Nothing archived", subtitle: "Archived notes are tucked away here." };
if (currentLabel.value) return { title: "No notes with this label", subtitle: "Tag a note to see it here." };
if (currentLabel.value) return { title: "No notes with this tag", subtitle: "Tag a note to see it here." };
// Says what "no account" actually means for the notes about to be written here.
// A new user otherwise has no way to tell whether this thing is storing their
// thoughts locally, silently waiting for a login, or quietly sending them off.
@@ -227,8 +227,21 @@ onMounted(() => {
.catch(() => {});
}
});
// A note written in the quick-capture window lands in the same SQLite file but a
// different Pinia store — this window has no way to know unless it is told.
// Registered as a promise because the listener is set up asynchronously, and
// unregistered on the way out so a board that has been navigated away from does
// not keep reloading itself.
let stopCaptureListener: (() => void) | null = null;
onMounted(() => {
void onCaptured(() => void reload()).then((stop) => {
stopCaptureListener = stop;
});
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", onBoardKey);
stopCaptureListener?.();
ui.boardCardFocused = false;
});
watch([currentView, currentLabel, facetKey], reload);
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
// The quick-capture window: one field, and two ways out.
//
// This runs in a SECOND Tauri window, summoned by a global hotkey over whatever
// the person was doing. Everything here is shaped by that: no shell, no nav, no
// board — a window that arrives uninvited has to be finishable in one gesture and
// leave nothing behind if it isn't.
import { nextTick, onMounted, ref } from "vue";
import { repo } from "../adapters";
import { capture } from "../desktop/bridge";
const body = ref("");
const field = ref<HTMLTextAreaElement | null>(null);
const saving = ref(false);
const error = ref("");
onMounted(async () => {
// Focused on arrival, and after a save. The whole feature is "press the keys and
// start typing" — a window that needs a click first has not saved anyone a step.
await nextTick();
field.value?.focus();
});
async function save() {
const content = body.value.trim();
// Nothing typed is not an error, it is a change of mind — the same reading the
// board takes of tapping + and walking away.
if (!content) {
void capture.done(false);
return;
}
saving.value = true;
error.value = "";
try {
await repo.notes.create({ body: content });
body.value = "";
await capture.done(true);
} catch {
// The window STAYS OPEN on failure, holding the text. Hiding it would throw
// away the only copy of something the person just wrote, to report a problem
// they could otherwise retry their way out of.
error.value = "Couldn't save that. Your text is still here — try again.";
} finally {
saving.value = false;
}
}
function dismiss() {
// The text is deliberately KEPT. The window is hidden rather than destroyed, so
// a capture interrupted by something more urgent is still there on the next
// press — which is the behaviour that makes it safe to press Escape.
void capture.done(false);
}
</script>
<template>
<div
class="flex h-screen w-screen flex-col gap-2 bg-neutral-50 p-3 text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100"
>
<textarea
ref="field"
v-model="body"
class="min-h-0 flex-1 resize-none rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
placeholder="Write it down…"
aria-label="New note"
@keydown.esc.prevent="dismiss"
@keydown.enter.ctrl.prevent="save"
@keydown.enter.meta.prevent="save"
/>
<p v-if="error" class="text-xs text-red-600 dark:text-red-400">{{ error }}</p>
<div class="flex items-center justify-between gap-3">
<!-- The shortcuts are written down rather than assumed: this window is seen
rarely and briefly, and it is the only place they are discoverable. -->
<p class="text-xs text-neutral-400">
<kbd>Ctrl</kbd>/<kbd></kbd> + <kbd>Enter</kbd> to save · <kbd>Esc</kbd> to dismiss
</p>
<div class="flex shrink-0 items-center gap-2">
<button type="button" class="btn btn-ghost" @click="dismiss">Cancel</button>
<button type="button" class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? "Saving" : "Save" }}
</button>
</div>
</div>
</div>
</template>
+12
View File
@@ -88,6 +88,18 @@ async function submit() {
>Create one</RouterLink
>
</p>
<!-- The build, on the one screen a person can reach WITHOUT an account.
"I can't sign in" is a bug report like any other and it needs a build
number; requiring a login to read one would withhold it from exactly the
people who cannot get past this page. `/api/config` is public, so this
costs nothing that was not already public (#3181). -->
<p
class="mt-8 select-all text-center text-[11px] text-neutral-400 dark:text-neutral-500"
:title="`ThoughtSync server build ${config.version || 'unknown'}`"
>
{{ config.version || "unknown" }}
</p>
</div>
</main>
</template>
+94
View File
@@ -5,8 +5,11 @@ import BaseButton from "../components/BaseButton.vue";
import BaseInput from "../components/BaseInput.vue";
import Icon from "../components/Icon.vue";
import {
SUGGESTED_CAPTURE_SHORTCUT,
capture as captureBridge,
sync as syncBridge,
updates as updateBridge,
type CaptureShortcut,
type Compatibility,
type ProbeResult,
type RevokeOutcome,
@@ -77,6 +80,31 @@ const checkedOnce = ref(false);
const updateAvailable = computed(() => !!update.value?.available);
// --- Quick capture -----------------------------------------------------------
// A desktop-local preference, so it lives here beside the update channel rather
// than in admin Settings: that screen is the SERVER's, and this is a property of
// this installation on this machine.
const shortcut = ref<CaptureShortcut>({ shortcut: "", registered: false });
const shortcutDraft = ref("");
const savingShortcut = ref(false);
const shortcutError = ref("");
async function saveShortcut(value: string) {
savingShortcut.value = true;
shortcutError.value = "";
try {
shortcut.value = await captureBridge.setShortcut(value);
shortcutDraft.value = shortcut.value.shortcut;
} catch (e) {
// The message comes from the core and names the actual reason — "something
// else is already using it" reads very differently from "that is not a
// shortcut this system understands", and both are things you can act on.
shortcutError.value = String((e as { message?: string }).message ?? e);
} finally {
savingShortcut.value = false;
}
}
async function checkUpdates() {
checking.value = true;
updateError.value = "";
@@ -126,6 +154,13 @@ async function refresh() {
// An older build without the update commands — leave the default showing
// rather than blocking the whole Sync screen on it.
}
try {
shortcut.value = await captureBridge.shortcut();
shortcutDraft.value = shortcut.value.shortcut;
} catch {
// Older build without the capture commands. Same reading as the channel
// above — show the default rather than block the screen.
}
try {
status.value = await syncBridge.status();
pending.value = await syncBridge.hasPending();
@@ -452,6 +487,65 @@ onMounted(refresh);
</form>
</template>
<!-- Quick capture. Outside the linked/unlinked split for the same reason as
updates: a hotkey that writes to the local store needs no server. -->
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
<h2 class="text-sm font-semibold">Quick capture</h2>
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
A system-wide shortcut that opens a small window to write a note in, without
bringing this one forward.
</p>
<div class="mt-4 flex items-end gap-3">
<BaseInput
id="capture-shortcut"
v-model="shortcutDraft"
label="Shortcut"
:placeholder="SUGGESTED_CAPTURE_SHORTCUT"
class="flex-1"
/>
<BaseButton :loading="savingShortcut" @click="saveShortcut(shortcutDraft)">Save</BaseButton>
<BaseButton
v-if="shortcut.shortcut"
variant="ghost"
:loading="savingShortcut"
@click="saveShortcut('')"
>
Turn off
</BaseButton>
</div>
<p v-if="shortcutError" class="mt-2 text-sm text-red-600 dark:text-red-400">
{{ shortcutError }}
</p>
<!-- Stored and LIVE are reported separately because they can disagree: a
combination another app grabbed first is saved here and does nothing when
pressed, and saying only "your shortcut is X" would be a lie with a
keystroke attached. -->
<p
v-else-if="shortcut.shortcut && !shortcut.registered"
class="mt-2 text-sm text-amber-700 dark:text-amber-400"
>
{{ shortcut.shortcut }} is saved but isn't active something else on this
system is holding it. Try a different combination.
</p>
<p v-else-if="shortcut.registered" class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
Press {{ shortcut.shortcut }} anywhere to capture a note.
</p>
<p v-else class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
Off. There's no default on purpose — any combination picked for you is one
taken away from something else on your machine.
<button
type="button"
class="underline hover:text-neutral-700 dark:hover:text-neutral-300"
@click="saveShortcut(SUGGESTED_CAPTURE_SHORTCUT)"
>
Use {{ SUGGESTED_CAPTURE_SHORTCUT }}
</button>
</p>
</section>
<!-- Updates sit outside the linked/unlinked split on purpose: an install that
has never touched a server still updates itself. -->
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env sh
#
# Collect every client this image should hand out, into one directory.
#
# fetch-clients.sh <dev|stable> <destdir>
#
# The server serves clients from `DATA_DIR/client/` or from the copy baked into the
# image (`client_dist.py`). This is what fills the second one. It runs in CI, right
# before `docker build`, and writes the FIXED filenames that module looks for.
#
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A `:dev` image serves dev clients;
# `:latest` serves stable ones. Passed in rather than derived here, because the
# caller is the thing that knows which image it is building.
#
# NEVER FAILS. A platform with nothing published means the server advertises
# nothing for it and the UI hides that download — a supported state, and the only
# one available before a platform's first build has ever published. Turning eight
# fetches into eight ways to redden an otherwise fine lane would be strictly worse
# than shipping an image that offers four clients instead of five.
#
# WHY THE VERSION IS FETCHED AND NOT DERIVED. The obvious shortcut is to run
# `version.sh display desktop` here — this job has the checkout, after all. It is
# wrong: this commit may not be the commit the channel is serving. A push touching
# only `src/` does not rebuild the desktop, so the channel still holds an older
# build, and a locally-derived version would describe those bytes with this
# commit's number. The size check in `client_dist.py` would not catch it, because
# the size IS measured from the real file — it would sail through and lie about the
# version only. So the version comes from the channel, beside the bytes it
# describes, and only `size`/`sha256` are measured here.
set -eu
channel="${1:?usage: fetch-clients.sh <dev|stable> <destdir>}"
dest="${2:?usage: fetch-clients.sh <dev|stable> <destdir>}"
case "$channel" in dev|stable) : ;; *)
echo "fetch-clients.sh: unknown channel '$channel'" >&2; exit 2 ;;
esac
SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}"
REPO="${GITHUB_REPOSITORY:-bvandeusen/thoughtsync}"
BASE="$SERVER/$REPO/releases/download/$channel"
mkdir -p "$dest"
# Authenticated when we have a token — these releases are private (issue 2091), so
# on this instance we always do. Anonymous still works against a public fork.
fetch() {
if [ -n "${GITHUB_TOKEN:-}" ]; then
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "$2" "$1"
else
curl -fsSL -o "$2" "$1"
fi
}
# One field out of a small flat JSON object. `grep`/`sed` rather than a parser
# because this runs in the CI image's busybox sh and adding a jq dependency to buy
# one string is not a trade worth making. The sidecars are written by us and are
# one level deep.
field() {
grep -oE "\"$2\"[[:space:]]*:[[:space:]]*\"?[^,\"}]+\"?" "$1" 2>/dev/null \
| head -1 | sed -E 's/.*:[[:space:]]*"?([^"]*)"?[[:space:]]*$/\1/'
}
bytes() { wc -c < "$1" | tr -d ' '; }
digest() { sha256sum "$1" | cut -d' ' -f1; }
# The sidecar shape `client_dist.py` reads. `size` and `sha256` are measured from
# the file that actually landed, so a truncated download cannot be described as a
# whole one.
sidecar() {
_file="$1"; _out="$2"; _name="$3"; _code="$4"
# `version_code` is QUOTED here, and that is not a slip. This function only ever
# writes DESKTOP sidecars, whose ordering key is Tauri's `1.0.<minutes>` — which
# unquoted is not valid JSON at all, so every sidecar this wrote would fail to
# parse and the server would advertise nothing. Android's sidecar is a different
# file, copied verbatim from its lane, and keeps its integer.
printf '{\n "version_name": "%s",\n "version_code": "%s",\n "size": %s,\n "sha256": "%s"\n}\n' \
"$_name" "$_code" "$(bytes "$_file")" "$(digest "$_file")" > "$_out"
}
echo "==> Collecting the $channel clients"
# --- Android -----------------------------------------------------------------
#
# Its sidecar is published whole by the Android lane — an APK keeps its version in
# a binary AXML manifest, so the values are recorded where they were already known.
# Copied verbatim rather than rebuilt here.
if fetch "$BASE/thoughtsync.apk" "$dest/thoughtsync.apk" &&
fetch "$BASE/thoughtsync-android.json" "$dest/thoughtsync-android.json"; then
echo " android $(field "$dest/thoughtsync-android.json" version_name)"
else
# Both or neither. Half a pair is worse than none: the server would read a
# sidecar describing an APK that is not there, or an APK it cannot state a
# version for.
echo "::warning::No Android client on the $channel channel — this image ships without one."
rm -f "$dest/thoughtsync.apk" "$dest/thoughtsync-android.json"
fi
# --- desktop -----------------------------------------------------------------
#
# One sidecar on the channel carries the version PAIR for all four bundles, because
# they are one build: `version_name` is what a person reads, `version_code` is the
# ordering key, and the key is also what the bundle filenames are stamped with.
# Written by `write-manifest.sh`, which is the step that speaks for what the channel
# serves.
bake_desktop() {
desk="$dest/.desktop-release.json"
if ! fetch "$BASE/thoughtsync-desktop.json" "$desk"; then
echo "::warning::No desktop release on the $channel channel — this image ships without desktop clients."
rm -f "$desk"
return 0
fi
name="$(field "$desk" version_name)"
key="$(field "$desk" version_code)"
rm -f "$desk"
if [ -z "$name" ] || [ -z "$key" ]; then
echo "::warning::The $channel desktop sidecar named no version — skipping desktop clients."
return 0
fi
echo " desktop $name (key $key)"
# Bundle filenames are stamped with the ORDERING KEY — what Tauri puts in them,
# and what `write-manifest.sh` already selects on. Constructed rather than
# discovered from the release's asset list: one shape, no JSON walk, and a name
# that does not resolve is caught by the fetch failing rather than by matching
# the wrong file.
#
# `<platform id>|<published name>|<name on disk>`
for row in \
"linux-deb|ThoughtSync_${key}_amd64.deb|thoughtsync.deb" \
"linux-pacman|thoughtsync-${key}-1-x86_64.pkg.tar.zst|thoughtsync.pkg.tar.zst" \
"linux-appimage|ThoughtSync_${key}_amd64.AppImage|thoughtsync.AppImage" \
"windows|ThoughtSync_${key}_x64-setup.exe|thoughtsync-setup.exe"
do
id="${row%%|*}"; rest="${row#*|}"
remote="${rest%%|*}"; local_name="${rest#*|}"
if ! fetch "$BASE/$remote" "$dest/$local_name"; then
echo "::warning::$channel has no $remote — this image ships without the $id client."
rm -f "$dest/$local_name"
continue
fi
# The AppImage is the only bundle that replaces itself in place, so the updater
# verifies a signature before it does. Without one it is not servable as an
# update, and `client_dist.py` treats it as absent rather than offering it
# unverifiable — so drop the bundle too rather than baking 95 MB nothing can use.
if [ "$id" = "linux-appimage" ]; then
if ! fetch "$BASE/$remote.sig" "$dest/$local_name.sig"; then
echo "::warning::$remote has no signature on $channel — dropping the AppImage."
rm -f "$dest/$local_name" "$dest/$local_name.sig"
continue
fi
fi
sidecar "$dest/$local_name" "$dest/thoughtsync-$id.json" "$name" "$key"
echo " $id $(bytes "$dest/$local_name") bytes"
done
}
bake_desktop
echo "==> Baked in:"
ls -l "$dest"
+13 -7
View File
@@ -1,10 +1,16 @@
"""ThoughtSync — self-hosted personal thought-capture web app (FabledSword family)."""
# The FALLBACK version, used only when APP_VERSION is absent from the environment —
# i.e. running from a checkout rather than from an image. A built image always has
# it, derived from the server's own shipped file set (packaging/version.sh), so this
# string never reaches a deployed instance and bumping it changes nothing a user
# sees. Kept because a package needs a version and "unknown" is not a valid one for
# packaging metadata; the honest "I cannot say" for a running server is APP_VERSION
# being missing, which app.py already handles.
# PACKAGING METADATA, and nothing else. Not the version any running server reports.
#
# A built image carries APP_VERSION in the environment, derived from the server's
# own shipped file set (packaging/version.sh); `app.py` reads that and reports an
# explicit "unknown" when it is absent, so this string never reaches a user and
# bumping it changes nothing anybody sees.
#
# It exists because a Python package needs a version and "unknown" is not a legal
# one here. It used to double as app.py's fallback, which meant a server run from a
# checkout confidently reported `0.2.0` — a real-looking version naming no build
# that exists. Note 3127 §5 is why that matters more than it reads: with version
# tags gone, a build's self-report is the only answer to "which build is this?",
# and there is nothing left to catch it lying.
__version__ = "0.2.0"
+18 -5
View File
@@ -11,7 +11,6 @@ from datetime import timedelta
from quart import Quart, jsonify, send_from_directory
from quart.sessions import SecureCookieSessionInterface
from . import __version__
from .auth import bp as auth_bp
from .client_dist import advertisement as client_advertisement, bp as client_bp
from .config import Config
@@ -64,7 +63,20 @@ def create_app() -> Quart:
# Ephemeral/env secret so the app (and DB-free unit tests) construct without a
# database. before_serving swaps in the real, DB-persisted key before serving.
app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48)
app.config["APP_VERSION"] = os.environ.get("APP_VERSION", __version__)
# The RUNNING build, or an explicit "unknown" — never the packaging fallback.
#
# This read `os.environ.get("APP_VERSION", __version__)`, so a server started
# from a checkout reported `0.2.0`: a real-looking version that names no build
# anybody could get. `__init__.py` already claimed the honest answer was
# "APP_VERSION being missing, which app.py already handles" — it did not, and a
# comment asserting a behaviour two files away from the code is how that stayed
# true-sounding for months.
#
# It matters more than it used to. Note 3127 §5 removed version tags, so this
# string is the only answer to "which build is this?" and nothing exists to
# contradict it when it is wrong. `__version__` stays where it belongs, as
# packaging metadata, which is the one place "unknown" is not a legal value.
app.config["APP_VERSION"] = os.environ.get("APP_VERSION") or "unknown"
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
# Auto-mark the session cookie Secure on HTTPS requests (see the interface above).
@@ -192,9 +204,10 @@ def create_app() -> Quart:
# linking — while it still has no token and possibly no account — to decide
# whether it can talk to this server, and which optional features to offer.
data.update(protocol_advertisement())
# Which Android client this server can hand out, if any. Absent rather than
# null when it has none, so the web UI hides the download instead of
# offering a button that 404s.
# Which CLIENTS this server can hand out, if any — the whole set under
# `clients`, plus the older `android_client` key that phones in the field
# still read. Absent rather than null when it has none, so the web UI hides
# a download instead of offering a button that 404s.
data.update(client_advertisement())
return jsonify(data)
+283 -71
View File
@@ -1,4 +1,4 @@
"""The server hands out the Android client it is in step with.
"""The server hands out the clients it is in step with.
## Why the server, and not a release page
@@ -11,43 +11,69 @@ It also keeps the two in step by construction. Client and server already
negotiate a sync protocol version before linking, so a server that also serves
the client cannot hand out a phone it is unable to talk to.
## Where the file comes from
That argument was never Android-specific, which is why this module now serves a
TABLE of platforms rather than the one it was written for.
Two places, checked in that order:
## Where the files come from
Two places, checked in that order, per platform:
1. `DATA_DIR/client/` — the mounted volume that already holds attachments. An
operator who wants a SPECIFIC build drops it there and it wins.
2. the copy baked into the image at build time — CI fetches the newest published
Android build into every image, so `:dev`, `:latest` and `:<version>` all
carry a client and `docker compose pull` delivers a new one with nothing
copied by hand.
build of each client into every image, so `:dev` and `:latest` both carry a
full set and `docker compose pull` delivers new ones with nothing copied by
hand.
The precedence is the point: the image is the default, and a person who wants to
override it should not have to fight it. The baked copy sits inside the package
rather than under DATA_DIR because DATA_DIR is a volume mount, and anything the
image wrote there would be hidden the moment one is attached.
**Precedence is decided per platform, not for the set.** A drop-in `.deb` does
not shadow the baked APK. The alternative — first directory holding anything
wins — would mean replacing one client silently retracts the other four.
## What a platform needs on disk
Two files, and both must be present:
- `thoughtsync.apk` — the client
- `thoughtsync-android.json` — `{version_name, version_code, size, sha256}`
- the artifact, at a FIXED name (`thoughtsync.deb`, not
`ThoughtSync_2026.08.30.0307_amd64.deb`)
- its sidecar, `{version_name, version_code, size, sha256}`
The sidecar exists because an APK's version lives in a binary AXML manifest that
Python cannot read without the Android build tools. CI writes it beside the APK
at publish time, where the real values are already known.
**Fixed names, and the version only in the sidecar.** A version-stamped filename
would force this module to glob, and a glob over a directory an operator can drop
files into is how you serve the older of two builds — `write-manifest.sh` carries
a comment about exactly that, from the time it advertised a new version while
pointing at an old binary.
The sidecar exists because a version is not reliably readable from the artifact:
an APK keeps it in a binary AXML manifest Python cannot parse without the Android
build tools, and a `.deb` or an AppImage would each need a different unpacker. CI
writes the sidecar where the real value is already known.
The AppImage needs a THIRD file, `thoughtsync.AppImage.sig`. That is the minisign
signature the desktop updater verifies before replacing the running binary, and a
bundle that cannot be verified cannot be offered as an update — so a missing
signature makes the AppImage absent rather than merely unsigned.
## Absence is normal
A server with no APK advertises nothing, and the web UI hides the download
rather than offering a button that 404s. Same for a mismatched pair: if the
sidecar's recorded size does not match the file on disk, the two did not arrive
together and the server says it has nothing rather than serving one build while
describing another.
A server with no client for a platform advertises none, and the web UI hides that
download rather than offering a button that 404s. Same for a mismatched pair: if
the sidecar's recorded size does not match the file on disk, the two did not
arrive together, and the server says it has nothing rather than serving one build
while describing another.
This is the ONLY state available before a platform's first build has ever
published, so it is an ordinary answer and never an error.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from quart import Blueprint, jsonify, send_from_directory
@@ -55,10 +81,106 @@ from quart import Blueprint, jsonify, send_from_directory
from .auth import login_required
from .config import Config
APK_NAME = "thoughtsync.apk"
MANIFEST_NAME = "thoughtsync-android.json"
DOWNLOAD_PATH = "/api/client/android/download"
APK_MIMETYPE = "application/vnd.android.package-archive"
@dataclass(frozen=True)
class Platform:
"""One installable client, and where its files sit."""
id: str
# What a person calls it. Named for the DISTRO rather than the package format
# ("Debian / Ubuntu", not ".deb") — someone knows which system they run and
# does not necessarily know which packaging it uses.
label: str
artifact: str
sidecar: str
mimetype: str
# An updater-verifiable bundle: `<artifact>.sig` must be present too, and its
# contents are published with the metadata. Only the AppImage, because it is
# the only bundle that can replace itself in place — a package-manager install
# cannot, by design (see the desktop's update.rs).
signed: bool = False
# Whether this platform's ordering key is an INTEGER.
#
# `version_code` is "whatever this platform's comparator reads", and that is not
# one type. Android's is an int because Android's own install gate compares one,
# and it must stay a JSON number — `ClientRelease` in core/src/sync/client.rs
# declares it `i64` and a string would fail to deserialize on every phone in the
# field. The desktop's is Tauri's semver key, `1.0.<minutes>`, which is the value
# its updater compares and is not an integer at all.
#
# Coercing everything to int was inherited from the days when Android was the
# only platform, and would have rejected every desktop sidecar written.
code_is_int: bool = True
@property
def signature(self) -> str:
return f"{self.artifact}.sig"
@property
def download_path(self) -> str:
return f"/api/client/{self.id}/download"
# ONE definition of what this server can hand out. Every route, the /api/config
# advertisement and the tests all read this table; adding a platform is adding a
# row.
PLATFORMS: tuple[Platform, ...] = (
Platform(
id="android",
label="Android",
# UNCHANGED, and it must stay unchanged: the Android lane publishes these
# exact names, CI bakes them in under them, and clients in the field poll
# `/api/client/android`. Renaming them to match the pattern below would
# buy tidiness and strand every installed phone.
artifact="thoughtsync.apk",
sidecar="thoughtsync-android.json",
mimetype="application/vnd.android.package-archive",
),
Platform(
id="linux-deb",
label="Debian / Ubuntu",
artifact="thoughtsync.deb",
sidecar="thoughtsync-linux-deb.json",
mimetype="application/vnd.debian.binary-package",
code_is_int=False,
),
Platform(
id="linux-pacman",
label="Arch / CachyOS",
artifact="thoughtsync.pkg.tar.zst",
sidecar="thoughtsync-linux-pacman.json",
mimetype="application/zstd",
code_is_int=False,
),
Platform(
id="linux-appimage",
label="Other Linux (AppImage)",
artifact="thoughtsync.AppImage",
sidecar="thoughtsync-linux-appimage.json",
# No registered type for an AppImage, and guessing one buys nothing: it is
# served as an attachment either way, and octet-stream is the answer that
# cannot be wrong.
mimetype="application/octet-stream",
signed=True,
code_is_int=False,
),
Platform(
id="windows",
label="Windows",
artifact="thoughtsync-setup.exe",
sidecar="thoughtsync-windows.json",
mimetype="application/vnd.microsoft.portable-executable",
code_is_int=False,
),
)
BY_ID: dict[str, Platform] = {p.id: p for p in PLATFORMS}
# The Android names, still importable under their old spellings because docs and
# the CI lane refer to them. Derived from the table rather than restated, so the
# two cannot drift.
APK_NAME = BY_ID["android"].artifact
MANIFEST_NAME = BY_ID["android"].sidecar
# The copy CI bakes into the image. Inside the package, NOT under DATA_DIR: that
# is a volume mount, and a file the image wrote there would vanish behind it.
@@ -67,103 +189,193 @@ BAKED_ROOT = Path(__file__).resolve().parent / "client"
bp = Blueprint("client_dist", __name__)
def _read(root: Path) -> dict | None:
"""The build in one directory, or None.
def _read(root: Path, platform: Platform) -> dict | None:
"""One platform's build in one directory, or None.
Never raises. A missing directory, an unreadable sidecar, malformed JSON and a
sidecar that describes a different file are all the same answer to the only
question being asked — "is there a client here I can honestly offer?" — and
that answer is no.
Never raises. A missing directory, an unreadable sidecar, malformed JSON, a
sidecar that describes a different file and — for a signed bundle — a missing
signature are all the same answer to the only question being asked, "is there
a client here I can honestly offer?", and that answer is no.
"""
try:
size = (root / APK_NAME).stat().st_size
meta = json.loads((root / MANIFEST_NAME).read_text(encoding="utf-8"))
size = (root / platform.artifact).stat().st_size
meta = json.loads((root / platform.sidecar).read_text(encoding="utf-8"))
version = str(meta["version_name"])
code = int(meta["version_code"])
# See `code_is_int`. Android's must parse as an integer; the desktop's is
# Tauri's semver key and is carried through as written.
code = int(meta["version_code"]) if platform.code_is_int else str(meta["version_code"])
recorded = int(meta["size"])
digest = str(meta["sha256"])
except (OSError, ValueError, TypeError, KeyError):
return None
if not str(code).strip() or not version.strip():
# A sidecar can be well-formed and still say nothing. An empty version is
# not a version, and it would render as a blank on the download card.
return None
# The pair has to describe one build. A sidecar left behind by a previous
# release would otherwise advertise a version this server cannot serve, and the
# phone would download something other than what it was promised.
# client would download something other than what it was promised.
if recorded != size:
return None
return {
release = {
"platform": platform.id,
"label": platform.label,
"version": version,
# What Android actually compares. `version` is for people; a name is a
# string and sorts like one, which is not how "is this newer" works.
# What a comparator reads. `version` is for people; a name is a string and
# sorts like one, which is not how "is this newer" works.
"version_code": code,
"size": size,
# Computed by CI over the same bytes it uploaded, so a client can tell a
# truncated download from a complete one BEFORE handing it to the
# installer. Not a trust anchor — the signature is that.
# truncated download from a complete one BEFORE handing it to an installer.
# Not a trust anchor — the signature is that.
"sha256": digest,
"url": DOWNLOAD_PATH,
# A PATH, never an absolute URL: the client joins it to the base it is
# already linked to, so a compromised or misconfigured server cannot
# redirect the download somewhere else. `core/src/sync/client.rs` relies on
# this and says so.
"url": platform.download_path,
}
if platform.signed:
# The signature travels WITH the metadata rather than behind its own route.
# It is ~100 bytes, it is public wherever these bundles are published, and
# the updater needs the version and the signature in the same breath — one
# request that cannot return a signature belonging to a different build.
try:
sig = (root / platform.signature).read_text(encoding="utf-8").strip()
except OSError:
return None
if not sig:
return None
release["signature"] = sig
def _resolve() -> tuple[Path, dict] | None:
"""Which directory this server serves from, and what is in it.
return release
def _resolve(platform: Platform) -> tuple[Path, dict] | None:
"""Which directory this server serves a platform from, and what is in it.
The operator's drop-in beats the baked copy — someone who deliberately put a
build on the volume wants that build, not whatever the image happened to ship
with. A directory holding a broken or half-copied pair does NOT shadow the
image: it simply is not a client, so the search moves on.
build on the volume wants that build, older or not. A directory holding a
broken or half-copied pair does NOT shadow the image: it simply is not a
client, so the search moves on.
"""
for root in (Path(Config.client_root()), BAKED_ROOT):
release = _read(root)
# Both wrapped in Path(), not just the first. The asymmetry was arbitrary and it
# fails quietly: `_read` catches the TypeError a str `/` str raises and reports
# "no client here", so a directory that is perfectly fine reads as empty.
for root in (Path(Config.client_root()), Path(BAKED_ROOT)):
release = _read(root, platform)
if release is not None:
return root, release
return None
def android_release() -> dict | None:
"""What Android build this server holds, or None if it holds none."""
resolved = _resolve()
def release(platform_id: str) -> dict | None:
"""What build of one client this server holds, or None if it holds none."""
platform = BY_ID.get(platform_id)
if platform is None:
return None
resolved = _resolve(platform)
return resolved[1] if resolved else None
def advertisement() -> dict:
"""The `/api/config` fragment describing this server's Android client.
def releases() -> dict[str, dict]:
"""Every client this server can hand out, keyed by platform id.
An empty dict when there is none, so the key is ABSENT rather than null — a
client testing for the key gets one unambiguous answer instead of having to
distinguish "no client" from "old server that never had this field".
Platforms it holds nothing for are ABSENT rather than present-and-null, so a
caller can test for the key instead of distinguishing "no build" from "a
server that never had this platform".
"""
release = android_release()
return {"android_client": release} if release else {}
found = {p.id: _resolve(p) for p in PLATFORMS}
return {pid: r[1] for pid, r in found.items() if r is not None}
@bp.get("/api/client/android")
async def android_metadata():
"""Version and digest without the 55 MiB. What an updater polls."""
release = android_release()
if release is None:
return jsonify({"error": "this server has no Android client"}), 404
return jsonify(release)
def android_release() -> dict | None:
"""What Android build this server holds, or None.
Kept as its own name because the back-compatible `/api/config` key below is
about Android specifically, and because saying so reads better than
`release("android")` at the two call sites that mean the phone.
"""
return release("android")
@bp.get(DOWNLOAD_PATH)
def advertisement() -> dict:
"""The `/api/config` fragment describing this server's clients.
Two keys, deliberately, and the older one is not deprecated here:
`clients` is the whole table, which is what the web UI renders the downloads
section from.
`android_client` is what phones in the field already read. It costs one
duplicated dict to not strand every installed Android client, and retiring it
is a later decision made when nothing polls it — not a tidy-up done in the
change that introduces its replacement.
Both are ABSENT rather than null when empty, so a client testing for a key gets
one unambiguous answer instead of having to distinguish "no client" from "an
old server that never had this field".
"""
data: dict = {}
found = releases()
if found:
data["clients"] = found
if "android" in found:
data["android_client"] = found["android"]
return data
@bp.get("/api/client")
async def client_index():
"""Everything this server holds, in one request.
The downloads UI needs all five to decide what to lead with, and five requests
to answer one question is five chances to render half a page.
"""
return jsonify({"clients": releases()})
@bp.get("/api/client/<platform_id>")
async def client_metadata(platform_id: str):
"""Version and digest without the payload. What an updater polls.
Public, because a client has to be able to ask "is there something newer?"
cheaply — before it has a token, in the case of a first pairing.
An unknown platform and a platform with no build both 404. They are the same
answer to the caller ("not here"), and distinguishing them would only tell an
unauthenticated stranger which platforms this build of the server knows about.
"""
found = release(platform_id)
if found is None:
return jsonify({"error": f"this server has no {platform_id} client"}), 404
return jsonify(found)
@bp.get("/api/client/<platform_id>/download")
@login_required
async def android_download():
"""The APK itself.
async def client_download(platform_id: str):
"""The bytes themselves.
Authenticated — by session cookie from a browser, or by device bearer token
from a client updating itself; `login_required` accepts either. The metadata
above is public because a client has to be able to ask "is there something
newer?" cheaply, but the bytes are not for anyone who can reach the port.
above is public; the bytes are not for anyone who can reach the port.
"""
resolved = _resolve()
platform = BY_ID.get(platform_id)
if platform is None:
return jsonify({"error": f"unknown client platform '{platform_id}'"}), 404
resolved = _resolve(platform)
if resolved is None:
return jsonify({"error": "this server has no Android client"}), 404
# From the SAME directory the advertisement came from, or a drop-in appearing
# between the two calls would serve bytes the metadata does not describe.
return jsonify({"error": f"this server has no {platform_id} client"}), 404
# From the SAME directory the metadata came from, or a drop-in appearing between
# the two calls would serve bytes the metadata does not describe.
root, _ = resolved
response = await send_from_directory(root, APK_NAME, mimetype=APK_MIMETYPE)
response = await send_from_directory(root, platform.artifact, mimetype=platform.mimetype)
# Without this some browsers try to render it, and Android's download handler
# wants a filename to hand to the package installer.
response.headers["Content-Disposition"] = f'attachment; filename="{APK_NAME}"'
response.headers["Content-Disposition"] = f'attachment; filename="{platform.artifact}"'
return response
+8 -5
View File
@@ -35,12 +35,15 @@ class Config:
@classmethod
def client_root(cls) -> Path:
"""Where the Android APK this server hands out lives.
"""Where an operator DROPS IN clients for this server to hand out.
Under DATA_DIR rather than baked into the image: the APK is ~55 MiB and an
install that never touches Android should not carry it. Being on the same
mounted volume as uploads also means an operator drops a build there once
and container recreation does not lose it. See client_dist.py.
One directory for every platform; `client_dist.py` picks files out of it by
name. Under DATA_DIR because it is a mounted volume: a build placed here
survives container recreation, and it beats the copy baked into the image,
which is the whole point of the directory existing.
Empty is the ordinary case — the image ships its own set and most operators
never touch this.
"""
return Path(cls.DATA_DIR) / "client"
+63 -24
View File
@@ -27,6 +27,36 @@ async def _label_note_count(db, label_id) -> int:
)
async def _merge_into(db, source: Label, target: Label) -> None:
"""Move every note tagged with `source` onto `target`, then delete `source`.
Shared by the explicit `/merge` route and by a rename that lands on a name
another tag already holds — those are the same operation, and having one body
is what stops them drifting into two answers for one question.
Note-body `#tags` are NOT rewritten, so a note whose body still literally
contains the source `#tag` will re-mint that tag on its next edit. Retiring a
tag means editing it out of the text; a known, documented nuance.
"""
# Notes already carrying the target: a note can't hold the same label twice
# (composite PK), so the source attachment there is just dropped as a dup.
target_notes = set(
(await db.scalars(select(NoteLabel.note_id).where(NoteLabel.label_id == target.id))).all()
)
source_rows = (await db.scalars(select(NoteLabel).where(NoteLabel.label_id == source.id))).all()
by_note = {r.note_id: r.via_tag for r in source_rows}
# Delete the source attachments first, then re-insert under the target — moving
# by delete+insert avoids mutating a composite primary-key column in place.
for row in source_rows:
await db.delete(row)
await db.flush()
for note_id, via_tag in by_note.items():
if note_id not in target_notes:
db.add(NoteLabel(note_id=note_id, label_id=target.id, via_tag=via_tag))
await db.delete(source)
await db.flush()
async def _get_owned_label(db, label_id: str) -> Label | None:
lid = parse_uuid(label_id)
if lid is None:
@@ -58,10 +88,15 @@ async def create_label():
data = await request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
if not name:
return json_error("label name is required", 400)
return json_error("tag name is required", 400)
async with session_scope() as db:
# Idempotent: creating an existing label just returns it.
existing = await db.scalar(select(Label).where(Label.owner_id == g.user_id, Label.name == name))
# Idempotent: creating an existing tag just returns it. Case-INSENSITIVE,
# like the clients' `find_or_create_label` — a case-sensitive match here was
# the path that MINTED the "Groceries" beside "groceries" pair that no synced
# client can hold, since their `labels` index is unique on `lower(name)`.
existing = await db.scalar(
select(Label).where(Label.owner_id == g.user_id, func.lower(Label.name) == name.lower())
)
if existing is not None:
return jsonify(_serialize_label(existing)), 200
label = Label(owner_id=g.user_id, name=name, color=normalize_color(data.get("color")))
@@ -81,17 +116,37 @@ async def update_label(label_id: str):
return json_error("nothing to update", 400)
name = (data.get("name") or "").strip() if has_name else None
if has_name and not name:
return json_error("label name is required", 400)
return json_error("tag name is required", 400)
async with session_scope() as db:
label = await _get_owned_label(db, label_id)
if label is None:
return not_found()
if has_name:
# Case-INSENSITIVE, matching the clients' `find_or_create_label` and the
# local store's `lower(name)` unique index. The Postgres constraint here
# is on the raw name, so the database would happily hold "Groceries"
# beside "groceries" — but no synced client can store both, so letting
# one be made is letting a pull fail later on a phone.
clash = await db.scalar(
select(Label).where(Label.owner_id == g.user_id, Label.name == name, Label.id != label.id)
select(Label).where(
Label.owner_id == g.user_id,
func.lower(Label.name) == name.lower(),
Label.id != label.id,
)
)
if clash is not None:
return json_error("a label with that name already exists", 409)
# Renaming onto an existing tag MERGES the two rather than failing:
# typing an existing tag's name onto this one says they are the same
# thing. The OLDER row survives and takes the new spelling — age is
# the one property that does not depend on which of the two the
# caller happened to be renaming, so A→B and B→A agree. Ties go to
# the incumbent. Mirrors `store::rename_label` exactly.
if clash.created_at <= label.created_at:
survivor, doomed = clash, label
else:
survivor, doomed = label, clash
await _merge_into(db, doomed, survivor)
label = survivor
label.name = name
if has_color:
label.color = normalize_color(data.get("color"))
@@ -128,24 +183,8 @@ async def merge_label(label_id: str):
if source is None or target is None:
return not_found()
if source.id == target.id:
return json_error("cannot merge a label into itself", 400)
# Notes already carrying the target: a note can't hold the same label twice
# (composite PK), so the source attachment there is just dropped as a dup.
target_notes = set(
(await db.scalars(select(NoteLabel.note_id).where(NoteLabel.label_id == target.id))).all()
)
source_rows = (await db.scalars(select(NoteLabel).where(NoteLabel.label_id == source.id))).all()
by_note = {r.note_id: r.via_tag for r in source_rows}
# Delete the source attachments first, then re-insert under the target — moving
# by delete+insert avoids mutating a composite primary-key column in place.
for row in source_rows:
await db.delete(row)
await db.flush()
for note_id, via_tag in by_note.items():
if note_id not in target_notes:
db.add(NoteLabel(note_id=note_id, label_id=target.id, via_tag=via_tag))
await db.delete(source)
await db.flush()
return json_error("cannot merge a tag into itself", 400)
await _merge_into(db, source, target)
count = await _label_note_count(db, target.id)
await db.commit()
return jsonify(_serialize_label(target, count))
+47
View File
@@ -27,3 +27,50 @@ async def test_unknown_api_route_404s(app):
client = app.test_client()
resp = await client.get("/api/does-not-exist")
assert resp.status_code == 404
# --- the version a running server reports ------------------------------------
#
# Note 3127 §5 removed version tags, so this string is the only answer to "which
# build is this?" and nothing exists to contradict it when it is wrong. That makes
# the FALLBACK the interesting case rather than the happy path: it used to be
# `__version__`, so a server run from a checkout reported `0.2.0` — a real-looking
# version naming no build anybody could obtain.
async def reported_version() -> str:
"""What a freshly built app tells /api/health it is.
Built per call rather than through the `app` fixture: the value is read from the
environment in `create_app`, so an app constructed before `monkeypatch` ran would
answer about the wrong environment.
"""
client = create_app().test_client()
return (await (await client.get("/api/health")).get_json())["version"]
async def test_the_version_is_whatever_the_environment_says(monkeypatch):
monkeypatch.setenv("APP_VERSION", "2026.08.29.0443")
assert await reported_version() == "2026.08.29.0443"
async def test_no_version_in_the_environment_reports_unknown(monkeypatch):
"""The honest "I cannot say", not a plausible default.
Also asserted against `__version__` by name rather than against the literal it
happens to hold, so bumping the packaging version cannot make this pass for the
wrong reason.
"""
from thoughtsync import __version__
monkeypatch.delenv("APP_VERSION", raising=False)
reported = await reported_version()
assert reported == "unknown"
assert reported != __version__
async def test_an_empty_version_reports_unknown_too(monkeypatch):
"""`APP_VERSION=` is what a mis-set build arg looks like, and an empty string
renders as a blank space rather than as a missing value."""
monkeypatch.setenv("APP_VERSION", "")
assert await reported_version() == "unknown"
+321 -69
View File
@@ -5,7 +5,16 @@ import pytest
from thoughtsync import client_dist
from thoughtsync.app import create_app
from thoughtsync.client_dist import APK_NAME, MANIFEST_NAME, advertisement, android_release
from thoughtsync.client_dist import (
APK_NAME,
BY_ID,
MANIFEST_NAME,
PLATFORMS,
advertisement,
android_release,
release,
releases,
)
from thoughtsync.config import Config
# DB-free, like the rest of this suite — the test lane runs no Postgres. That is
@@ -13,11 +22,39 @@ from thoughtsync.config import Config
# `/api/config`: the route is a one-line merge of this dict into a payload whose
# other half needs a database, and testing it here tests the part that can be wrong.
#
# The two routes below ARE exercised, because neither opens a session: the metadata
# route only stats files, and the download's 401 is returned before any token
# lookup.
# The routes below ARE exercised, because none opens a session: the metadata route
# only stats files, and the download's 401 is returned before any token lookup.
PAYLOAD = b"not really an apk, but the server only ever stats it"
PAYLOAD = b"not really a client, but the server only ever stats it"
# Every test that is about the MECHANISM rather than about one platform runs
# against all of them. The bugs this module can have — a sidecar describing a
# different build, a half-finished copy shadowing a good one — are not
# platform-specific, and a suite that only ever exercised Android is how the other
# four would ship untested.
ALL_IDS = [p.id for p in PLATFORMS]
# `version_code` is "whatever this platform's comparator reads", and that is not one
# type. Android's install gate compares an integer; the desktop's updater compares
# Tauri's semver key. The tests carry both shapes for the same reason the module
# does — a suite that only ever wrote integers would pass while every desktop
# sidecar CI writes was being rejected.
ANDROID_CODE = 3503708
DESKTOP_CODE = "1.0.3503707"
def code_for(platform_id: str):
return ANDROID_CODE if BY_ID[platform_id].code_is_int else DESKTOP_CODE
def coded(platform_id: str, value: int):
"""`value` in the shape that platform's sidecar carries.
A test writing `version_code=300` gets `300` back from Android and `"300"` from
a desktop platform, because the module preserves each platform's own comparator
type rather than flattening both to int.
"""
return value if BY_ID[platform_id].code_is_int else str(value)
@pytest.fixture(autouse=True)
@@ -26,7 +63,7 @@ def _empty_baked_client(tmp_path, monkeypatch):
In a source checkout `src/thoughtsync/client/` does not exist, so these tests
would pass anyway — but only by accident of where they are run. A built image
has a real APK there, and a test that silently depends on which tree it is in
has real clients there, and a test that silently depends on which tree it is in
is one that will eventually lie.
"""
monkeypatch.setattr(client_dist, "BAKED_ROOT", tmp_path / "baked")
@@ -38,109 +75,251 @@ def app():
return create_app()
def place_client(payload: bytes = PAYLOAD, root: Path | None = None, **overrides) -> dict:
"""Put a client + sidecar where the server looks. Overrides corrupt the pair."""
root = root if root is not None else Config.client_root()
def place(
platform_id: str = "android",
payload: bytes = PAYLOAD,
root: Path | None = None,
signature: str | None = "a signature",
**overrides,
) -> dict:
"""Put one platform's client + sidecar where the server looks.
Overrides corrupt the pair. `signature=None` withholds the `.sig` a signed
bundle needs, which is its own failure mode rather than a variant of the others.
"""
platform = BY_ID[platform_id]
root = root if root is not None else Path(Config.client_root())
root.mkdir(parents=True, exist_ok=True)
(root / APK_NAME).write_bytes(payload)
(root / platform.artifact).write_bytes(payload)
meta = {
"version_name": "0.1.216",
"version_code": 216,
"version_name": "2026.08.30.0307",
"version_code": code_for(platform_id),
"size": len(payload),
"sha256": "ab" * 32,
}
meta.update(overrides)
(root / MANIFEST_NAME).write_text(json.dumps(meta), encoding="utf-8")
(root / platform.sidecar).write_text(json.dumps(meta), encoding="utf-8")
if platform.signed and signature is not None:
(root / platform.signature).write_text(signature, encoding="utf-8")
return meta
def test_absent_client_is_advertised_as_nothing_at_all():
"""The KEY is missing, not null.
# --- the table ---------------------------------------------------------------
A client testing for it then gets one unambiguous answer rather than having to
tell "this server has no APK" apart from "this server predates the feature".
def test_every_platform_has_its_own_filenames():
"""Two platforms sharing an artifact or a sidecar name would overwrite each
other in the one directory they all live in — silently, and the survivor would
be whichever was copied last."""
artifacts = [p.artifact for p in PLATFORMS]
sidecars = [p.sidecar for p in PLATFORMS]
assert len(set(artifacts)) == len(artifacts)
assert len(set(sidecars)) == len(sidecars)
assert not set(artifacts) & set(sidecars)
def test_the_android_names_are_the_ones_already_published():
"""Pinned because renaming them is a tidy-up that strands every installed phone.
The Android lane publishes these exact names and CI bakes them in under them.
"""
assert android_release() is None
assert APK_NAME == "thoughtsync.apk"
assert MANIFEST_NAME == "thoughtsync-android.json"
# --- absence is an ordinary answer -------------------------------------------
@pytest.mark.parametrize("platform_id", ALL_IDS)
def test_an_absent_client_is_no_client(platform_id):
assert release(platform_id) is None
def test_a_server_holding_nothing_advertises_no_keys_at_all():
"""The KEYS are missing, not null.
A client testing for one then gets an unambiguous answer rather than having to
tell "this server has no client" apart from "this server predates the feature".
"""
assert releases() == {}
assert advertisement() == {}
def test_a_present_client_is_advertised_with_what_android_compares():
place_client()
advertised = advertisement()["android_client"]
assert advertised["version"] == "0.1.216"
# The integer is what decides "is this newer", not the name — a name is a
# string and sorts like one.
assert advertised["version_code"] == 216
assert advertised["size"] == len(PAYLOAD)
assert advertised["url"].endswith("/download")
def test_a_sidecar_describing_a_different_build_counts_as_no_client():
"""The likeliest real corruption: a new APK copied over an old sidecar.
@pytest.mark.parametrize("platform_id", ALL_IDS)
def test_a_sidecar_describing_a_different_build_counts_as_no_client(platform_id):
"""The likeliest real corruption: a new artifact copied over an old sidecar.
Serving one build while advertising another is worse than serving none — the
phone would compare versions against a promise the bytes do not keep.
client would compare versions against a promise the bytes do not keep.
"""
place_client(size=999_999)
assert android_release() is None
assert advertisement() == {}
place(platform_id, size=999_999)
assert release(platform_id) is None
def test_an_unreadable_sidecar_counts_as_no_client():
place_client()
(Config.client_root() / MANIFEST_NAME).write_text("{ this is not json", encoding="utf-8")
assert android_release() is None
@pytest.mark.parametrize("platform_id", ALL_IDS)
def test_an_unreadable_sidecar_counts_as_no_client(platform_id):
place(platform_id)
sidecar = Path(Config.client_root()) / BY_ID[platform_id].sidecar
sidecar.write_text("{ this is not json", encoding="utf-8")
assert release(platform_id) is None
def test_a_sidecar_missing_a_field_counts_as_no_client():
root = Config.client_root()
@pytest.mark.parametrize("platform_id", ALL_IDS)
def test_a_sidecar_missing_a_field_counts_as_no_client(platform_id):
platform = BY_ID[platform_id]
root = Path(Config.client_root())
root.mkdir(parents=True, exist_ok=True)
(root / APK_NAME).write_bytes(PAYLOAD)
(root / MANIFEST_NAME).write_text(json.dumps({"version_name": "0.1.216"}), encoding="utf-8")
assert android_release() is None
(root / platform.artifact).write_bytes(PAYLOAD)
(root / platform.sidecar).write_text(json.dumps({"version_name": "x"}), encoding="utf-8")
assert release(platform_id) is None
def test_a_sidecar_with_no_apk_beside_it_counts_as_no_client():
root = Config.client_root()
@pytest.mark.parametrize("platform_id", ALL_IDS)
def test_a_sidecar_with_no_artifact_beside_it_counts_as_no_client(platform_id):
platform = BY_ID[platform_id]
root = Path(Config.client_root())
root.mkdir(parents=True, exist_ok=True)
(root / MANIFEST_NAME).write_text(json.dumps({"version_name": "x", "version_code": 1, "size": 1, "sha256": ""}))
assert android_release() is None
(root / platform.sidecar).write_text(
json.dumps({"version_name": "x", "version_code": 1, "size": 1, "sha256": ""}),
encoding="utf-8",
)
assert release(platform_id) is None
async def test_metadata_endpoint_is_public_so_an_updater_can_ask_cheaply(app):
place_client()
resp = await app.test_client().get("/api/client/android")
assert resp.status_code == 200
assert (await resp.get_json())["version_code"] == 216
def test_androids_code_must_be_an_integer():
"""`ClientRelease` in core/src/sync/client.rs declares it `i64`. A string here
would fail to deserialize on every phone in the field, so a sidecar carrying one
is not a client this server can honestly offer."""
place("android", version_code="1.0.3503707")
assert release("android") is None
async def test_metadata_404s_rather_than_describing_a_client_that_is_not_there(app):
resp = await app.test_client().get("/api/client/android")
assert resp.status_code == 404
def test_the_desktop_keeps_tauris_semver_key_verbatim():
"""It is not an integer and must not be coerced into one: this is the value the
desktop updater compares, and `1.0.3503707` truncated to `1` orders against
nothing."""
place("linux-deb")
assert release("linux-deb")["version_code"] == "1.0.3503707"
async def test_the_bytes_need_authentication_even_though_the_version_does_not(app):
"""Anyone who can reach the port may ask what version exists; only an account
or a linked device may pull the 55 MiB."""
place_client()
resp = await app.test_client().get("/api/client/android/download")
assert resp.status_code == 401
@pytest.mark.parametrize("platform_id", ALL_IDS)
def test_an_empty_version_name_counts_as_no_client(platform_id):
"""A sidecar can be well-formed and still say nothing. A blank would render as
an empty space on the download card, which reads as a layout bug."""
place(platform_id, version_name="")
assert release(platform_id) is None
def test_an_unknown_platform_is_not_a_client():
assert release("blackberry") is None
# --- what a present client reports -------------------------------------------
@pytest.mark.parametrize("platform_id", ALL_IDS)
def test_a_present_client_reports_what_a_comparator_reads(platform_id):
place(platform_id)
found = release(platform_id)
assert found["version"] == "2026.08.30.0307"
# The integer is what decides "is this newer", not the name — a name is a string
# and sorts like one.
assert found["version_code"] == code_for(platform_id)
assert found["size"] == len(PAYLOAD)
assert found["platform"] == platform_id
# A PATH, not an absolute URL: the client joins it to the base it is already
# linked to, so a server cannot redirect the download elsewhere.
assert found["url"] == f"/api/client/{platform_id}/download"
assert not found["url"].startswith("http")
def test_the_android_payload_still_carries_every_field_it_used_to():
"""Phones in the field parse this. Fields may be ADDED — `ClientRelease` in
core/src/sync/client.rs is a plain serde struct and ignores what it does not
know — but none of these may move or change meaning."""
place("android")
found = android_release()
for key in ("version", "version_code", "size", "sha256", "url"):
assert key in found, key
assert found["url"] == "/api/client/android/download"
# --- the signed bundle -------------------------------------------------------
def test_the_appimage_publishes_its_signature_with_its_version():
"""One request returns both, so an updater cannot pair a version with a
signature belonging to a different build."""
place("linux-appimage", signature="minisign output here")
assert release("linux-appimage")["signature"] == "minisign output here"
def test_an_appimage_without_a_signature_is_absent_rather_than_unsigned():
"""It is the only bundle that replaces itself in place, and an update the app
cannot verify is one it will refuse. Offering it unverifiable would turn a
missing file into a failed install on the user's machine."""
place("linux-appimage", signature=None)
assert release("linux-appimage") is None
def test_an_empty_signature_file_is_not_a_signature():
"""A truncated copy leaves a zero-byte file, which reads as present."""
place("linux-appimage", signature=" \n")
assert release("linux-appimage") is None
def test_only_the_appimage_carries_a_signature():
"""A package-manager install cannot replace itself in place, so nothing verifies
one and claiming a signature would imply an update path that does not exist."""
for platform_id in ALL_IDS:
place(platform_id)
found = releases()
assert "signature" in found["linux-appimage"]
for platform_id in ALL_IDS:
if platform_id != "linux-appimage":
assert "signature" not in found[platform_id], platform_id
# --- the set, and precedence within it ---------------------------------------
def test_a_platform_the_server_lacks_is_simply_not_in_the_set():
"""Not null, not an error — a server holding some clients and not others is the
ordinary state, and the UI hides what is absent."""
place("android")
place("windows")
found = releases()
assert set(found) == {"android", "windows"}
def test_the_baked_in_copy_is_used_when_nothing_was_dropped_in():
"""The ordinary case for a self-hoster who just pulled the image."""
place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300)
place("android", root=client_dist.BAKED_ROOT, version_code=300)
assert android_release()["version_code"] == 300
def test_a_dropped_in_build_beats_the_one_the_image_shipped():
"""Someone who deliberately put a build on the volume wants that build."""
place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300)
place_client(version_name="0.1.99", version_code=99)
advertised = android_release()
place("android", root=client_dist.BAKED_ROOT, version_code=300)
place("android", version_code=99)
# Lower version and all — precedence is about intent, not about newness. An
# operator pinning an older client is doing it on purpose.
assert advertised["version_code"] == 99
assert android_release()["version_code"] == 99
def test_precedence_is_decided_per_platform_not_for_the_whole_set():
"""THE trap this table introduces. Dropping in one client must not retract the
other four — "first directory holding anything wins" would mean overriding the
APK silently takes the desktop downloads offline."""
for platform_id in ALL_IDS:
place(platform_id, root=client_dist.BAKED_ROOT, version_code=300)
place("android", version_code=99)
found = releases()
assert found["android"]["version_code"] == 99
for platform_id in ALL_IDS:
if platform_id != "android":
assert found[platform_id]["version_code"] == coded(platform_id, 300), platform_id
assert set(found) == set(ALL_IDS)
def test_a_broken_drop_in_does_not_shadow_the_baked_copy():
@@ -149,6 +328,79 @@ def test_a_broken_drop_in_does_not_shadow_the_baked_copy():
This is the failure the copy-order advice in docs/android-distribution.md is
about, and the server should ride it out rather than go dark.
"""
place_client(root=client_dist.BAKED_ROOT, version_name="0.1.300", version_code=300)
place_client(size=999_999) # sidecar describing a different build
place("android", root=client_dist.BAKED_ROOT, version_code=300)
place("android", size=999_999) # sidecar describing a different build
assert android_release()["version_code"] == 300
# --- the advertisement -------------------------------------------------------
def test_the_advertisement_carries_the_whole_table():
place("linux-deb")
assert set(advertisement()["clients"]) == {"linux-deb"}
def test_the_advertisement_still_carries_the_key_phones_already_read():
"""Not deprecated in the change that introduces its replacement. An installed
Android client reads `android_client`, and one duplicated dict is what it costs
to not strand it."""
place("android")
data = advertisement()
assert data["android_client"] == data["clients"]["android"]
def test_no_android_client_means_no_android_key_even_when_others_are_present():
place("windows")
data = advertisement()
assert "android_client" not in data
assert set(data["clients"]) == {"windows"}
# --- routes ------------------------------------------------------------------
@pytest.mark.parametrize("platform_id", ALL_IDS)
async def test_metadata_endpoint_is_public_so_an_updater_can_ask_cheaply(app, platform_id):
place(platform_id)
resp = await app.test_client().get(f"/api/client/{platform_id}")
assert resp.status_code == 200
assert (await resp.get_json())["version_code"] == code_for(platform_id)
@pytest.mark.parametrize("platform_id", ALL_IDS)
async def test_metadata_404s_rather_than_describing_a_client_that_is_not_there(app, platform_id):
resp = await app.test_client().get(f"/api/client/{platform_id}")
assert resp.status_code == 404
async def test_an_unknown_platform_404s_like_an_absent_one(app):
"""Same answer to the caller either way, and telling them apart would only tell
an unauthenticated stranger which platforms this build of the server knows."""
resp = await app.test_client().get("/api/client/blackberry")
assert resp.status_code == 404
async def test_the_index_returns_everything_in_one_request(app):
place("android")
place("linux-appimage")
resp = await app.test_client().get("/api/client")
assert resp.status_code == 200
assert set((await resp.get_json())["clients"]) == {"android", "linux-appimage"}
async def test_the_index_is_an_empty_set_rather_than_a_404(app):
"""A server with no clients has an answer; it is just an empty one. 404 here
would make the UI treat "nothing to offer" as a broken endpoint."""
resp = await app.test_client().get("/api/client")
assert resp.status_code == 200
assert (await resp.get_json())["clients"] == {}
@pytest.mark.parametrize("platform_id", ALL_IDS)
async def test_the_bytes_need_authentication_even_though_the_version_does_not(app, platform_id):
"""Anyone who can reach the port may ask what version exists; only an account or
a linked device may pull the payload."""
place(platform_id)
resp = await app.test_client().get(f"/api/client/{platform_id}/download")
assert resp.status_code == 401
+87
View File
@@ -543,3 +543,90 @@ async def test_a_revision_inside_the_window_blocks_another(db, owner):
await db.commit()
assert await should_snapshot(db, note.id, note.body, "draft revised") is False
async def test_renaming_a_tag_onto_an_existing_one_merges_into_the_older(app_client, db):
"""Renaming a tag onto a name another tag holds merges them, and the OLDER row
is the survivor — whichever side the caller happened to be renaming.
Runs against a real database because the whole question is about `created_at`
ordering and the note_labels rows moving, neither of which a unit test sees.
Age decides, rather than "the one that already held the name", so that renaming
A→B and renaming B→A land on the same row. If the incumbent won, the survivor
would depend on which way round someone typed it, and two clients racing the
same tidy-up would disagree about which id still exists.
"""
reg = await app_client.post(
"/api/auth/register",
json={"email": "tags@example.test", "password": "a-long-enough-password"},
)
assert reg.status_code == 201
# Two separate requests, so two transactions and two distinct `func.now()`s.
older = await (await app_client.post("/api/labels", json={"name": "grocery"})).get_json()
newer = await (await app_client.post("/api/labels", json={"name": "errands"})).get_json()
one = await (await app_client.post("/api/notes", json={"body": "milk"})).get_json()
two = await (await app_client.post("/api/notes", json={"body": "stamps"})).get_json()
await app_client.put(f"/api/notes/{one['id']}/labels", json={"label_ids": [older["id"]]})
await app_client.put(f"/api/notes/{two['id']}/labels", json={"label_ids": [newer["id"]]})
# Rename the YOUNGER onto the older's name, with different casing — matching is
# case-insensitive, and the survivor must end up spelled the way we asked.
resp = await app_client.patch(f"/api/labels/{newer['id']}", json={"name": "Grocery"})
assert resp.status_code == 200
survivor = await resp.get_json()
assert survivor["id"] == older["id"], "the older row is the one that keeps existing"
assert survivor["name"] == "Grocery", "the survivor takes the spelling that was asked for"
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
assert len(listing) == 1, "the two became one"
assert listing[0]["id"] == older["id"]
assert listing[0]["count"] == 2, "it carries every note from both sides"
async def test_the_rename_merge_survivor_does_not_depend_on_the_direction(app_client, db):
"""The mirror of the test above: rename the OLDER onto the younger's name. The
older still survives — it just changes its name — so the two directions agree."""
reg = await app_client.post(
"/api/auth/register",
json={"email": "tags2@example.test", "password": "a-long-enough-password"},
)
assert reg.status_code == 201
older = await (await app_client.post("/api/labels", json={"name": "grocery"})).get_json()
newer = await (await app_client.post("/api/labels", json={"name": "errands"})).get_json()
resp = await app_client.patch(f"/api/labels/{older['id']}", json={"name": "errands"})
assert resp.status_code == 200
survivor = await resp.get_json()
assert survivor["id"] == older["id"], "age wins in this direction too"
assert survivor["name"] == "errands"
assert newer["id"] != older["id"]
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
assert [lb["id"] for lb in listing] == [older["id"]]
async def test_creating_a_tag_that_differs_only_in_case_returns_the_existing_one(app_client, db):
"""A case-sensitive match here used to mint "Groceries" beside "groceries". No
synced client can hold both — their `labels` index is unique on `lower(name)` —
so the pair was a pull that would fail later, on a phone, with no UI in the path.
"""
reg = await app_client.post(
"/api/auth/register",
json={"email": "tags3@example.test", "password": "a-long-enough-password"},
)
assert reg.status_code == 201
first = await app_client.post("/api/labels", json={"name": "groceries"})
assert first.status_code == 201
second = await app_client.post("/api/labels", json={"name": "Groceries"})
assert second.status_code == 200, "an existing tag is returned, not a second one made"
assert (await second.get_json())["id"] == (await first.get_json())["id"]
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
assert len(listing) == 1