Commit Graph
150 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 e8d6a4f423 android: vendor OpenSSL so the Rust core links (task 1864)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m41s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android (Tauri) / Android APK (debug) (push) Successful in 3m40s
First Android build failed at openssl-sys: "Could not find directory of OpenSSL
installation". reqwest is pinned to native-tls, which is right for Windows — it
resolves to schannel there and keeps C and assembly out of the cross-compile —
but on Android it resolves to OpenSSL, and there is no Android OpenSSL in the
image to link against.

Vendored rather than rustls. rustls builds faster and was the obvious fix, but it
ships its own root store, so the phone would trust a different set of
certificates than the desktop: a self-hosted server behind a private or
enterprise CA would work on one surface and fail on another. Peer surfaces that
quietly disagree about who to trust is a worse outcome than a slower build, so
one TLS stack stays everywhere and OpenSSL gets compiled from source with the NDK
toolchain — which is what perl and make are in ci-tauri-android for.

Scoped to cfg(target_os = "android") so nothing changes for the Linux, Windows or
web lanes; declared as a direct dependency purely to flip the feature, since
cargo's unification then applies it to the copy native-tls pulls in.

Cargo.lock regenerated in the same commit, per the documented procedure — the
--locked gates in every lane fail otherwise. openssl-src 300.6.1+3.6.3 joins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:11:24 -04:00
bvandeusenandClaude Opus 5 1f140c7457 android: scaffold the Tauri mobile lane and build a debug APK in CI
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Android (Tauri) / Android APK (debug) (push) Failing after 21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m26s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The phone client is Tauri v2 mobile (operator decision), so it reuses the Vue
frontend and the Rust store and sync engine that already exist rather than
becoming a third implementation to keep in step by hand.

gen/android is committed. tauri android init generated it, its own .gitignore
already excludes the build outputs and every keystore file, and CI must not have
to regenerate a project that manifest edits will accumulate in.

What the scaffold confirms is that the image's JDK pin was load-bearing rather
than incidental: Tauri templated Gradle 8.14.3 with AGP 8.11.0, and CI-android's
versions.env records that JDK 25 needs Gradle 9.1.0+ and that anything older
fails with an opaque "25.0.3" message. Picking 17 for ci-tauri-android avoided
exactly that. namespace and applicationId came out as com.fabledsword.thoughtsync,
matching the desktop identifier, so the app-data story stays consistent.

The lane builds a DEBUG APK for arm64 only. Release APKs need signing, and the
keystore has to be generated by the operator and never pass through CI logs or an
agent session — the constraint recorded for the updater key applies unchanged.
Gradle's throwaway debug keystore needs nothing from anyone, so this can prove the
app compiles and packages today and grow a signed job when a key exists. arm64 is
every real device; the image carries the other three ABIs, so widening is a word.

Triggered by frontend/** as well as desktop/**, because generate_context! compiles
the frontend into the app — the same reasoning that widened desktop.yml. Android,
desktop and web are peers on one quality bar, and a frontend commit that skipped
this lane would ship a stale phone build.

Green here will mean it BUILT. A Linux runner cannot execute an APK, so nothing in
this lane proves the app runs, renders, or is usable by finger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:01:32 -04:00
bvandeusenandClaude Opus 5 be0eb94225 frontend: reorder cards with Pointer Events so touch can do it at all (task 2697)
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m12s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Native HTML5 drag-and-drop never fires from touch — the API predates it and was
never wired to it — so on a phone reordering did nothing whatsoever, and the grip
that starts it was hover-gated on top of that. Pointer Events cover mouse, touch
and stylus on one code path instead of two.

The awkward part is hit-testing. Native DnD routed dragover/drop to whatever was
under the cursor, so each card learned on its own that it was the target. A
captured pointer sends every move to the element that captured it, so the dragged
card has to hit-test for itself and publish the result where the other cards can
see it — hence the shared refs in useCardDrag. It reads the DOM via
elementFromPoint rather than tracking geometry because the board is a CSS masonry:
visual order isn't derivable from model order, and cards reflow as the column
count changes. Asking the browser what is actually under the finger is the only
answer that stays true.

Capture is what makes the gesture survive crossing a card boundary; touch-action:
none claims it from the browser's scrolling; a 6px threshold keeps a tap from
becoming a drag; and pointercancel is handled so a system interruption leaves no
half-set state.

The parent contract is unchanged apart from `drop` now carrying the target's ID
rather than its note — the dragged card finds its target in the DOM, so an id is
all it can know without a second lookup. BoardView keeps its own tracking of what
was picked up; that it now duplicates the composable's draggingId is real, and
noted for the DRY pass rather than expanded into here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 16:39:37 -04:00
bvandeusenandClaude Opus 5 f5837cd985 frontend: hover-revealed controls stay put where hovering is impossible (task 2697)
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 29s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 2m36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 2m36s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
A finger cannot hover, and on a note card the hover toolbar is the only way to
pin, colour or archive — so on a phone those notes could not be acted on at all.
Same for deleting a checklist item, a saved view, an attachment or a preview.

Marked rather than rewritten inline: one `.hover-reveal` class on the five
elements and a single rule that says what it is for. The `group-hover:` reveal
stays in the markup because the trigger differs per component (named groups);
only the fallback is shared. `@media (hover: none)` asks the device directly,
which is more honest than inferring from viewport width — a narrow window on a
laptop still hovers, and a large tablet still doesn't. It sits after the Tailwind
directives so it beats the opacity-0/pointer-events-none utilities on source
order without !important.

Tap targets follow the same shape: p-1.5 around an 18px icon lands near 30px,
which is fine for a cursor and too small for a thumb. Bumped to 44px on coarse
pointers only, so desktop chrome doesn't inflate.

The drag grip is deliberately NOT revealed yet. Reordering still uses HTML5
drag-and-drop, which never fires from touch, so showing the handle would only
promise something that does nothing. It comes with the pointer-events rewrite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 16:36:58 -04:00
bvandeusenandClaude Opus 5 e7ee16c6cf frontend: dialogs keep focus, and a skip link past the chrome (task 1999)
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m11s
Desktop (Tauri) / Update manifest (push) Successful in 5s
BaseModal declared role="dialog" aria-modal="true" and then enforced none of it.
Focus never moved into the panel, so Escape — handled ON the panel — did nothing
at all in LabelsModal, the integration prompt and the shortcuts modal. Only the
command palette escaped correctly, and only because it happens to focus its own
input. Tab walked straight out of the dialog into the page that aria-modal had
just told assistive tech was inert, and closing dropped focus to <body> so the
next Tab restarted from the top of the document.

All three are one contract, so it lives in BaseModal rather than in each of the
four callers: focus in on open, Tab trapped, focus restored to the opener. The
panel takes tabindex="-1" so it can hold focus itself when it wraps nothing
focusable. CommandPalette's input focus still wins, because a child's mounted
hook runs before its parent's.

The skip link is the other half. The header and sidebar are a dozen-odd tab stops
that repeat on every navigation, and a keyboard user walked all of them again to
reach their notes. <main> takes tabindex="-1" as well, because several browsers
scroll to a bare anchor without moving focus to it — which would have made the
link look like it worked while leaving the next Tab back at the top.

The rest of the audit came back clean: no click handlers on non-focusable
elements, and all 30 focus:outline-none uses already pair with a focus-visible
ring. M3.5's keyboard pass held up; the gaps were in focus management, not
styling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 12:54:08 -04:00
bvandeusenandClaude Opus 5 d6646a64fb desktop: remove two dead ends from the shell, and stop the launch flash (task 1999)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 39s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Sign out was a trap on the desktop, not an action. It nulls the synthetic local
user and redirects to /login, but the offline adapter rejects every sign-in with
"there's no account to sign in to" — so the only way back into your own notes was
to restart the app. There is nothing to sign out of; the notes are on this
machine either way.

Linked devices was a quieter version of the same thing: it lists the tokens a
SERVER has issued to native clients, and the desktop is one of those clients, so
offline the list is always empty and issuing a token rejects. Its actual
relationship with a server already has a home at /sync. Also hid the account
name, which named a login the app doesn't have.

/account is now blocked in the router too, not merely hidden — the mirror of the
existing requiresDesktop guard — so a typed URL or a restored history entry
can't reach the dead end either. Deliberately not applied to /login and
/register: bouncing those on desktop would loop against the requiresAuth guard
whenever a session is missing.

The launch flash is the window painting before the webview does, showing the
platform default white through the gap — worst on a dark-mode desktop, and
widened by the software rendering we force on Linux. Set from the live system
theme rather than app.windows[].backgroundColor, because that config carries one
static colour and either choice would fix half of users while introducing the
same flash for the other half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 12:46:38 -04:00
bvandeusenandClaude Opus 5 3a1496e5fa frontend: honor prefers-reduced-motion, and let frontend work reach the desktop
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 31s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m33s
Two halves of the same gap. The app had no reduced-motion handling at all — the
setting appeared nowhere in the frontend — and the desktop build didn't rebuild
on frontend changes, so shared UI work shipped to the web and silently never
reached the desktop app.

The CSS guard is global and blunt so it catches every Tailwind `transition`
already scattered through the components, and catches M7's motion work without
each new component having to remember. Near-zero durations rather than `none`,
so transitionend/animationend still fire and nothing waiting on them hangs.
useReducedMotion covers what CSS can't reach: JS-driven motion, where the honest
response to the preference is no animation at all rather than a faster one. It's
reactive because the setting can change while the app is open.

The path filter was narrowed to the adapter/bridge directories against a
"~20-40 min" build cost recorded in the header. Measured runs are 4-5 minutes,
so that cost isn't there, and the frontend is compiled into the binary by
generate_context! — any part of it changing means the shipped desktop app is
stale. Desktop, web and Android are peer surfaces on one quality bar, so shared
frontend work has to reach all of them by construction rather than by whichever
directory it happened to touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 11:51:07 -04:00
bvandeusenandClaude Opus 5 659237ccc6 desktop: the empty board explains where your notes live (task 1999)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 25s
A fresh desktop install has no login — auth_me returns a synthetic local user so
the shared router's guard resolves — but nothing said so. You landed on a bare
board with no way to tell whether the app was storing your thoughts on this
machine, waiting for a credential, or quietly shipping them somewhere.

The start state is the empty board itself, not a welcome modal or an onboarding
gate. The product exists to take a thought in under a second; spending that
second on a dialog taxes the one thing it is for. It also means there is no
"seen it" flag to persist, migrate, or let drift out of step with reality — the
message retires itself the moment a first note exists, which is exactly when it
stops being true that you have nothing here.

Shown only when the app is unlinked: offering to connect a server to someone who
already has one is noise. The status read is best-effort and never awaited, so
the board renders at full speed regardless; if it fails we keep showing the
offline copy, which is the honest reading of "we know of no server".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 11:37:29 -04:00
bvandeusenandClaude Opus 5 c883fd2eb6 desktop: one name across all three install channels (issue 2075)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m6s
Desktop (Tauri) / Update manifest (push) Successful in 6s
The app answered to three different names depending on how it arrived, and the
part that actually hurt was WM_CLASS. Reading tauri-bundler settles what it is:
the generated .desktop template writes StartupWMClass={{exec}} where exec is
main_binary_name, and tao creates its GtkApplication with a NULL app id
(enableGTKAppId defaults off), so GTK falls back to the program name. WM_CLASS
is the binary name, nothing else.

Which inverts this issue's premise. The rename could not break grouping,
because two channels weren't grouping in the first place: pacman ships
/usr/bin/thoughtsync and the AppImage's AppRun execs thoughtsync-desktop, while
all three hand-written entries hardcoded StartupWMClass=ThoughtSync — a string
no binary in any channel has ever reported. Only the .deb worked, and only
because Tauri generates its entry from the binary and never consulted us.

So: thoughtsync everywhere, carried by the build target itself via Cargo [[bin]]
plus mainBinaryName rather than by the install path, since the target name is
what the desktop reads. The pacman package sheds its -desktop suffix and
declares conflict+replaces so an upgrade retires the old one instead of landing
beside it and fighting over /usr/bin/thoughtsync.

The .deb verifier now asserts binary path, Exec and StartupWMClass all agree,
which is the part that keeps this fixed: the .deb's entry is the one no human
writes, so it's the one that drifts silently.

Package: thought-sync stays. tauri-bundler derives it as kebab-case(productName)
with no override, and rewriting a control archive on every build is a poor trade
for one uninstall command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:57:17 -04:00
bvandeusenandClaude Opus 5 5c1ae574f6 desktop: commit Cargo.lock and gate CI on it (issue 2102)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m37s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The desktop crate is a binary, and binaries commit their lockfile. Without one
every run re-resolved the graph: a tagged .deb/.AppImage/.exe couldn't be
rebuilt from its tag, any semver-compatible upstream release landed
automatically on the next build — the failure mode hardest to read, because the
commit that broke it changed nothing relevant — and Renovate had no lockfile to
bump, leaving Rust dependency movement invisible to the Dashboard.

Generated with cargo generate-lockfile inside ci-tauri:1.97, the same image CI
builds in, so the format and the picked versions are what CI would have chosen
itself. That takes the artifact-upload round-trip the issue proposed off the
table: ci-requirements.md already blesses the image for cargo fmt, and resolving
a dependency graph is no more a build than formatting is. 503 packages.

Enforcement goes on each job's FIRST cargo invocation rather than the bundle
build: cargo clippy --locked on Linux, and its own cargo fetch --locked step on
Windows, whose only crate-graph command is otherwise the cross-compile itself.
Drift fails in the first thirty seconds instead of thirty minutes in, and
everything after the gate in that job compiles the recorded versions anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:41:18 -04:00
bvandeusenandClaude Opus 5 2cfe049f9c sync: unlinking a device now revokes its token on the server (issue 2110)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Unlink was local-only. It cleared the server URL, token and cursor from the
device, and left the bearer token valid on the server indefinitely — so someone
who unlinked because the laptop was being sold or handed on believed they had
revoked access when they hadn't.

The blocker was identification, not intent: a token pasted from the web app
never carried a device id, and /api/auth/me describes the user, not the device
row, so DELETE /devices/<id> could only ever have worked for one of the two ways
this app can be linked. DELETE /api/auth/devices/self keys off the token in the
Authorization header instead, which the caller always holds — one route that
works for both paths, owner-scoped like the rest, and no local schema change.

Unlinking is never blocked on the network. Wanting to stop syncing is a local
decision, so the revoke is attempted first, its outcome carried back, and the
link cleared either way. When the token survives — server unreachable, or older
than the route — the Sync screen says so in place, with where to revoke it. A
toast would have been the wrong shape for that: it disappears, and this is
exactly what someone returns to the screen to check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:31:06 -04:00
bvandeusen edf52da97f desktop: the installer's channel choice now reaches the app (issue 2183)
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m19s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m3s
`install.sh --channel dev` set the channel in the installer and nowhere
else. The app kept its `stable` default, stable advertises 0.1.0, and
0.1.0 is older than any dev build — so every update check said "up to
date", forever, and the user had to know to go set it themselves.

The installer now records the channel as a plain file in the app-data
dir; the app adopts it at startup. A file rather than a write into the
app's SQLite store, because shell has no business knowing that schema.

Adoption compares against the value last adopted, not against "is the
pref unset". Seeding only when unset would have fixed the first install
and left the second silently wrong: install stable, then install dev,
and the pref is already set so dev never takes. Comparing to the last
marker makes both directions work — an in-app channel switch survives
the next launch, and re-running the installer on a different channel is
honoured.

An unreadable marker is ignored rather than read as `stable`, so a
truncated file can't move someone off the channel they're on.
2026-08-15 21:38:58 -04:00
bvandeusen c1464228df docs: Fabled-Git, not Forgejo, where the instance is meant
Four references to "Forgejo" actually meant this instance, which has run Gitea
since the migration: the registry push, the missing /releases/latest/download
route, the API a packaging script resolves URLs against, and the 422 on an
illegal JSON escape.

Kept as-is — these are genuinely about the upstream Forgejo project, not us:
the `forgejo/upload-artifact` mirror and "the Forgejo project's fork".

Prose only — no workflow, path, or script change. Scribe issue #2272.
2026-07-31 23:44:29 -04:00
bvandeusenandClaude Opus 5 505904b1e5 ci: swap artifact upload to the mirrored action (issue 2270)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m42s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m32s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Both desktop upload steps used actions/upload-artifact@v3, which reports
success while Gitea stores the result in a format its v4-only artifact API
will never serve back — 110 artifacts on this repo are on disk, have valid
DB rows, and are invisible to the REST API, the web download route and the
MCP tools alike. Green jobs producing nothing retrievable.

Point both at bvandeusen/upload-artifact (pull mirror of the Forgejo
project's fork, GHES refusal disabled), pinned by SHA because the mirror
auto-syncs. Not actions/upload-artifact@v4: its isGhes() throws on the
hostname before opening a connection, so no server-side change reaches it.

Also drop continue-on-error and set if-no-files-found: error on both steps.
Between them, a failed or empty upload was reported as a green run — the
same silence that let this go unnoticed for a month.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:27:57 -04:00
bvandeusenandClaude Opus 5 13e48672c0 packaging: bare backticks — a heredoc's backslash isn't the JSON's
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m16s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Run 2981 built everything and then died posting the release: HTTP 422,
"invalid escape sequence \`". The body's other backticks are written \` because
they sit in an UNQUOTED heredoc, where that backslash is the shell's and is gone
before any JSON exists. Copying the idiom into a single-quoted variable changed
what it meant — single quotes already stop substitution, so the backslash
survived into the body as an escape JSON has no rule for.

bash -n passes either way; it checks syntax, not what a string becomes. So parse
the assembled body for every branch it can take instead, and write down the
recipe next to the one for formatting Rust.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKsUY9Z45KQd34V956hZ9Q
2026-07-27 23:10:42 -04:00
bvandeusenandClaude Opus 5 8b6dfab3a7 ci-requirements: the two things that cost a cycle each to rediscover
`git push origin dev` fails outright now that the rolling channel put a TAG
named `dev` beside the branch, and the error names neither. And nothing in CI
lints the packaging shell scripts, so a broken installer surfaces when a user
runs it rather than when it's built — record how to check them locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKsUY9Z45KQd34V956hZ9Q
2026-07-27 23:04:41 -04:00
bvandeusenandClaude Opus 5 d8b0cd9b96 packaging: the installer learns the same two channels the app updates on
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 2m36s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 4m32s
Desktop (Tauri) / Update manifest (push) Has been skipped
install.sh asked /releases/latest and installed whatever came back. That is
v0.1.0 today, which predates the updater, and it was about to get worse: the
`stable` pointer release write-manifest.sh creates is non-prerelease and holds
only latest.json, so from the next v* tag onward it would have WON
/releases/latest and the installer would have found nothing to install.

So resolve a channel instead of a "latest". `--channel stable|dev` (or
TS_CHANNEL), default stable, named to match update.rs's Channel exactly. dev
reads /releases/tags/dev. stable reads the pointer's own latest.json, takes its
version, and installs that v* release — the same file the app reads, so the
installer and the updater cannot disagree about what stable means.

Two things found on the way. The dev release's description still told people to
run the stable command, and always would have: publish-release.sh writes a body
only when it CREATES a release, and a fixed-tag release is only created once, so
the text froze at the first build. The 409 path now PATCHes it. And the asset
greps were unanchored, so a .AppImage.sig URL could match as the bundle URL —
harmless by coincidence, not by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKsUY9Z45KQd34V956hZ9Q
2026-07-27 23:03:12 -04:00
bvandeusenandClaude Opus 5 6f47af8d96 ci: point the manifest at THIS build, and stop the dev release growing forever
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m40s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m29s
Desktop (Tauri) / Update manifest (push) Successful in 7s
Two halves of one mistake, both visible on the dev release right now: the
manifest said 0.1.134 and pointed at ThoughtSync_0.1.132_amd64.AppImage.

The rolling channel accumulates every build's assets, and the manifest picked
its bundle by file extension with `head -1` — the OLDEST match. A client would
have been told 0.1.134 was available, downloaded 0.1.132, installed it, and
been offered 0.1.134 again. Forever.

Signature verification could not have caught it. The old bundle's signature is
perfectly valid for the old bundle; nothing about it says "this isn't the build
the manifest claims". Selection is now matched on the build's own version
string, so the manifest can only ever describe the binary it was written for.

The accumulation is the other half. Nothing can reach a superseded build once
the manifest moves on, and an AppImage is ~100 MB — three pushes had already
left 300 MB of unreachable binaries on the Git host. A rolling channel now
prunes everything but the current build once the manifest points at it.
Versioned releases are untouched: that IS the archive, and the stable pointer's
URLs aim into it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-27 15:40:54 -04:00
bvandeusenandClaude Opus 5 acff95f920 ci: re-sign the AppImage after de-bundling, or Linux updates can never verify
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m45s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m48s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The de-bundle step deletes the AppImage and repackages it without the host
graphics libraries — necessary, and it runs AFTER tauri signed the original.
So the .sig published on the release described a file that no longer existed,
and every Linux in-app update would have failed signature verification.

Worth naming the failure mode: the error would have said the signature didn't
match, which points at the key, the manifest, or the download — anywhere except
"a later build step rewrote the file after signing it". The Windows lane hid it
too, because nothing post-processes the NSIS installer, so the one platform
already verified working was the one platform that couldn't reveal the bug.

Signs the file that actually ships, and fails the build if no .sig comes out
rather than quietly publishing an unverifiable bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-27 15:16:13 -04:00
bvandeusenandClaude Opus 5 3ca3eba6d5 packaging: stamp the pacman package with the version actually built
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m32s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m26s
Desktop (Tauri) / Update manifest (push) Successful in 4s
It read the version straight out of tauri.conf.json, which was correct until
dev builds started overriding the version on the command line — the file still
says 0.1.0, so release `dev` came out carrying a pacman package labelled 0.1.0
around a binary that reports 0.1.132.

Nothing breaks from it (a pacman install can't self-update anyway), but a
package that lies about its version is exactly what makes a later "which build
is this?" impossible to answer. Now uses the same build-version.sh the bundles
and the manifest do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-27 15:07:40 -04:00
bvandeusenandClaude Opus 5 02c932260e Updater signing key, a rising dev version, and a production compose
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m37s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Three things, all needed before the update loop can be tested.

The public signing key is committed. Verified before trusting it: algorithm
`Ed`, key ID 90E96FEA2F6D9B6A matching its own comment, 32-byte Ed25519 key.

Dev builds now carry a version that RISES. Every build took its version from
Cargo.toml, so each one was 0.1.0 — an installed 0.1.0 would read a manifest
advertising 0.1.0, conclude it was current, and never update. The rolling
channel would have looked broken while working exactly as written. Dev builds
are now 0.1.<ci-run-number>, from one helper shared by both bundle jobs and the
manifest writer, because three separate derivations of "what version is this"
is three chances for the binary and the manifest to disagree.

Plain semver, not a `-dev.N` prerelease: prerelease versions sort BELOW the
release they qualify, so a tagged build would never update to a newer dev one,
and Windows installer metadata wants a numeric X.Y.Z regardless. Bumping the
minor still beats any dev build on the old line — 0.2.0 > 0.1.2932.

The Windows job also gets the signing environment it was missing, so its NSIS
installer is signed too. Without that the manifest would have had a Linux entry
and nothing for the platform actually being tested.

docker-compose.yml is now the production stack, per request: it pulls the
published image instead of building, keeps Postgres OFF the host network, sets
restart policies, health checks and log rotation, and refuses to start without
a POSTGRES_PASSWORD rather than shipping a known one. Volume names are
deliberately unchanged so an existing deployment upgrades in place instead of
silently coming up against an empty database. Development keeps its own
clearly-named file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
stable dev
2026-07-27 10:33:17 -04:00
bvandeusenandClaude Opus 5 1f294c4ad8 ci-requirements: record how to format the Rust lane without a local toolchain
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 19:27:39 -04:00
bvandeusenandClaude Opus 5 2e8717a057 desktop: rustfmt the two new preference helpers
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m4s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Verified locally this time rather than in CI. The ci-tauri image is already on
this machine, so `cargo fmt --check` can run in a throwaway container against
the exact toolchain CI uses — no test run, no build, no local stack, just the
formatter. Four consecutive pushes had failed on formatting alone; that class
of failure is now catchable before it costs a cycle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 19:27:27 -04:00
bvandeusenandClaude Opus 5 d6734cf7a0 desktop: in-app updates, two channels, signed, fed by fixed-tag releases
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 30s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m59s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m23s
Desktop (Tauri) / Update manifest (push) Has been skipped
There was no in-place update anywhere. The app never checked, downloaded or
applied anything, and the only published release predates the whole sync arc —
so `install.sh` would hand out a build with no sync in it. Installing from
per-run CI artifacts, which is what's been happening, is not something an
updater can point at: ephemeral, auth-gated, no stable URL.

Two channels, switchable in the app: `stable` follows tagged releases, `dev`
follows every green push.

The feed is a Fabled-Git release asset, not a ThoughtSync server route. This
reverses the lean recorded in task 1998, and the reason matters — a
server-hosted feed can only reach a desktop that has linked a server, and
local-first-with-no-server is the whole premise. An unlinked install has to be
able to update itself.

Each channel reads a `latest.json` on a release whose TAG NEVER MOVES.
That's forced, not stylistic: Forgejo has no /releases/latest/download/<asset>
route (verified — it 404s with no redirect), so "newest" cannot be named in a
URL. `dev` carries the rolling bundles; `stable` is a pointer release holding
only the manifest, whose URLs aim at the versioned release's assets, so nothing
is duplicated.

The manifest is written by a third job that runs after both bundle jobs. They
build in separate workspaces and neither can see the other's output, but one
manifest has to describe both platforms — generating it inside either job would
silently omit the other, and a missing platform reads to a user as "no update
available" rather than as a broken feed. It reads what actually landed on the
release, so it can never advertise a bundle that failed to upload.

Signing is gated on the secret existing, in the script rather than an `if:`
(the secrets context isn't reliably available to step conditions). No key means
no updater artifacts and no publish: a feed the app would refuse to verify is
worse than no feed, because it looks like it works. CI stays green until the
key lands.

On Linux the updater can only replace an AppImage — a deb or pacman install is
owned by its package manager and must never be overwritten underneath it. The
app detects that case up front and says so, instead of failing halfway through
with a permissions error nobody can read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 19:03:12 -04:00
bvandeusenandClaude Opus 5 b7c0820230 desktop: rustfmt the blob-store literal in the scheme handler
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m49s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m53s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 17:39:12 -04:00
bvandeusenandClaude Opus 5 c40263967d desktop: render synced attachments instead of broken images (task 2114)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m47s
A synced note carried the SERVER's relative attachment path
(/api/notes/<id>/attachments/<aid>). In the webview that resolves against the
app origin and 404s, so every synced image rendered broken even though the
bytes were already on disk from M10.7d. The absolute server URL wouldn't have
worked either: that route wants a bearer token the webview never sends, and it
would put an offline app on the network to show a file it already has.

The bytes now come off disk over a custom URI scheme, served straight from the
content-addressed blob store. The webview caches and range-requests them like
any other resource — which a data: URI would have thrown away — and the URL is
immutable-cacheable because a content address can never describe different
bytes.

Two things worth knowing about the shape of this:

The URL is rewritten in `load_attachments`, the single place the desktop
builds an attachment for the UI. NoteCard and NoteEditor are untouched, so
there's no second render site to drift.

The scheme's URL form is NOT the same on every platform: `scheme://localhost/`
on Linux and macOS, `http://scheme.localhost/` on Windows and Android. Getting
it wrong breaks exactly one channel, silently, and a headless CI runner can
never tell you.

The mime rides in the URL, and this scheme is an origin of its own, so an
attachment claiming to be text/html would run as a document there. Only media
families are echoed back; everything else is served as an opaque download,
which is the right treatment for an arbitrary file anyway. Path safety is
inherited rather than re-implemented — the handler reads through BlobStore,
which already refuses anything that isn't a bare sha256.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 17:16:44 -04:00
bvandeusenandClaude Opus 5 d634801bd3 desktop: rustfmt the retention query and one assert
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m55s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m53s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 16:46:45 -04:00
bvandeusenandClaude Opus 5 7a77a0e1b9 desktop: fix a retention test that raced the wall clock
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m50s
`a_note_exactly_at_the_boundary_survives` stamped a note 30 days ago and then
asked the sweep — which reads `now` microseconds later — whether it was
strictly older than 30 days. It was, by those microseconds. The assertion was
wrong, not the code: an exact tie isn't observable against a wall clock.

Now stamps a note with a minute of its window still to run, which is the
property actually worth pinning: the comparison is strictly-older, so a note
inside the window is kept.

Also rewrote the row scan as plain statements. The `filter_map` over
`query_map` swallowed real rusqlite errors through `.ok()?` on the way to
skipping unparseable timestamps — the two cases deserve different treatment,
and only the second should be silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 16:25:32 -04:00
bvandeusenandClaude Opus 5 e64d67e904 Expire trash after 30 days, and make the deadline something you can see
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m12s
Trash had no end. A note sat in /trash until someone emptied it by hand, and
its attachment BYTES sat on disk the whole time — the pile-up the operator
asked about. Nothing purged; there was no scheduler at all.

Retention is server-owned: `trash_retention_days` (default 30, 0 = keep
forever) in the settings registry, so it lands in admin Settings with no
migration and takes effect without a restart. A background sweep started in
before_serving does the work. Clients learn about a purge the way they learn
about any deletion — as a tombstone on the delta feed.

An auto-purge nobody can see coming is data loss on a timer, so the window is
now visible: /api/config publishes it, notes carry `deleted_at`, Trash leads
with the policy, and each card counts down. The countdown rounds DOWN — saying
"1 day left" for a note with ten minutes on the clock is the one error here
that actually costs someone a note.

Three things this turned up on the way:

- `DELETE /api/notes/<id>` hard-deleted the row, leaving no tombstone at all.
  A permanent delete in the web UI never reached a linked device, which would
  keep its copy forever and push it back on the next edit. It now purges
  through the same path as everything else.
- The purge left `note_revisions` and `note_link_previews` behind. A revision
  holds the full body, so the text of a "permanently deleted" note was still
  sitting in the database.
- `deleted_at` now SURVIVES a purge instead of being cleared. It's still true,
  and it means every query that says "not trashed" excludes tombstones for
  free — without it a content-less row reads as a perfectly normal active note
  and shows up on the board as a blank card.

Desktop keeps its own clock only when there's nobody else to keep one: the
sweep runs at startup on an UNLINKED device and refuses otherwise. A linked
client that expired notes on its own schedule could destroy something the
server was deliberately keeping, then push that delete upstream. Local policy
must never outrank the server's — so it also adopts the server's window for
the countdown rather than showing its offline default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 16:20:13 -04:00
bvandeusenandClaude Opus 5 6f35e6e6d8 Confirm irreversible deletes, which sync just made far more consequential
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 31s
The trash model itself was already right and needed no change: notes soft-
delete (`trashed` locally, `deleted_at` server-side), Trash is a real view,
restore works, permanent deletion is a separate second step only offered on
an already-trashed note, `trash()` shows an Undo toast, and nothing auto-
purges — trash persists until someone acts. Sync carries all of it: a trashed
note syncs WITH its content, and only `purged_at` deletes a client's copy.

What was missing is the guard on the irreversible step. "Delete forever" and
label deletion were one click, silent, with no confirmation — and M10.7 has
changed what that costs. Before, a mis-click lost a note on one machine.
Now it pushes a tombstone that deletes it from every linked device, and the
local tombstone survives to make sure it gets there.

Both guards live in the STORE, not the call sites: NoteCard and NoteEditor
both offer delete-forever, and duplicating the copy is how two prompts drift
until one of them stops matching what actually happens.

The copy names the real consequence — "deleted from every device you sync
with" — because that's the part a user cannot infer from a button in a Trash
view. The label prompt also says the notes themselves are kept, since that's
what people actually worry about when deleting a label.

Labels deliberately get a confirmation but NOT a trash of their own. A label
is organization, not content; the reversible middle step notes get would be
ceremony around something that costs nothing to recreate.

Saved-filter deletion already confirmed (AppShell), so these two were the
outliers, not a new convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 15:40:33 -04:00
bvandeusenandClaude Opus 5 810da43f56 desktop: rustfmt the blob-store test
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m51s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m57s
One hunk from run 2911. Clippy and all 67 tests — including the six new blob
tests and the path-traversal guard — had already passed on the same code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:46:25 -04:00
bvandeusenandClaude Opus 5 ed623a7bef M10.7d: download attachment bytes into a content-addressed store (task 2107)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 35s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m47s
The client half of task 1942's server work. Metadata already rides the delta
feed; this fetches the payload so a synced image exists on the device.

Blobs are filed under their own sha256, so the same image attached to five
notes is stored once and re-downloading it is free — the dedupe the task asks
for falls out of content addressing rather than needing bookkeeping.

The hash is also the integrity check, applied on the way IN. Bytes that don't
hash to what the server advertised are refused rather than filed under a name
that lies about them — and because the blob then still counts as missing, the
next sync simply tries again.

SECURITY: the hash arrives in a server response and becomes a FILENAME, so it
is validated as 64 hex characters before touching the filesystem. Without
that, a hostile or buggy server could send "../../..." and steer a write
outside the blob directory. Tested.

A failed attachment never fails the sync. Notes are the primary data and have
already landed; aborting here would let one unreachable file block every
future sync. Counted, logged, surfaced in the UI as "they'll retry on the
next sync", and retried because the blob is still absent.

sha2 is pure Rust, so the Windows cross-compile lane pays nothing for it —
the constraint recorded in ci-requirements.md.

SPLIT, deliberately: this stores the bytes but does NOT yet render them in
the webview. That half needs a custom URI scheme or the asset protocol, whose
URL form differs by platform (Windows uses http://scheme.localhost/, others
scheme://localhost/) — and CI cannot verify webview rendering at all, being
headless with no webview. Guessing at it here would ship an unverifiable
change on the most fragile lane. Follow-up filed; synced images will show as
broken until it lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:43:34 -04:00
bvandeusenandClaude Opus 5 6bef07ff83 desktop: rustfmt the SyncOutcome literal
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m2s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m56s
One hunk from run 2908. Clippy, all 61 tests, and vue-tsc (run 2907) had
already passed on the same code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:33:35 -04:00
bvandeusenandClaude Opus 5 fe683595df M10.7e: desktop Sync settings screen (task 2108)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m4s
The surface that turns the engine into a feature (rule 27). Desktop-only —
the web build IS a server's UI, so a "connect a server" screen there would be
nonsense; the route redirects to the board and the nav entry is hidden.

UNLINKED IS THE RESTING STATE, not an incomplete setup. The empty case leads
with "Working offline on this device — everything works without a server",
because a screen that framed the default as a problem would push people into
configuring something they may never need. The app is local-first; this is
opt-in.

Probe before credentials. "Check" shows who actually answered — site name,
version, and the M10.6 verdict — before any password or token is typed. An
incompatible server is shown in red and the sign-in fields never appear, so
you cannot hand a credential to something that can't use it. `degraded` names
the missing capabilities rather than staying quiet and letting a feature
mysteriously do nothing.

Both credential paths, matching the Rust side: email+password (a fresh
install has no session to mint a token from) or a pasted device token (for
anyone who'd rather not type a password into a desktop app). Secrets are
cleared from component state the moment they're exchanged.

Disconnect states plainly that the token stays valid server-side and points
at Account -> Linked devices, rather than implying a remote revoke that
didn't happen (issue 2110). Wording avoids "revoke" for exactly that reason.

Push rejections are surfaced verbatim after a sync, never swallowed — a
duplicate label name is the realistic case and only a person can resolve it.

Adds schema v3: last_sync_at. The cursor can't answer "am I up to date?" —
it's a revision watermark, not a time, and it doesn't move at all when a sync
legitimately finds nothing new, so "synced a moment ago, nothing new" would
be indistinguishable from "never synced". Stamped only after BOTH halves of
the cycle succeed; a stamp after a partial cycle would claim currency the
data doesn't have. Cleared on unlink so a new server can't inherit it.

run_cycle now returns the post-cycle status, so the UI updates from one
round-trip instead of chasing every sync with a status call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:30:37 -04:00
bvandeusenandClaude Opus 5 75b2d096ec desktop: rustfmt the push module
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m12s
Seven hunks, applied verbatim from run 2903's cargo fmt --check diff.

The reordered job already paid off: clippy and all 60 tests ran and passed
in that same run, so this is known to be formatting only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:23:38 -04:00
bvandeusenandClaude Opus 5 b5f7dc2635 M10.7c: push + the full sync cycle (task 2106)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m46s
Local -> server, then push-then-pull as the only ordering the UI can invoke.

LOCAL TOMBSTONES (schema v2). Found while writing push: delete_forever and
remove_label just DROPPED the row, leaving no record it existed. Offline that
means the delete can never be pushed — and the next pull faithfully
resurrects the note from the server. A deletion that undoes itself is about
the worst thing sync can do, so deletes now record into pending_deletes until
the server acknowledges them. merge_labels had the same hole.

merge_labels also moved memberships without marking the affected notes dirty.
A note's label set only reaches the server via the note itself, so a merge
looked done locally and never synced. Now marked before the delete cascades
the rows away.

Result handling, per status:
  created/applied -> clear dirty, store the returned sync_revision
  noop            -> clear dirty, drop the tombstone (a row the server never
                     saw, created and deleted entirely offline)
  kept            -> clear dirty WITHOUT touching content. Re-pushing would
                     lose the same last-write-wins comparison forever; the
                     following pull adopts the server's version.
  rejected        -> stay dirty and surface the reason. A duplicate label name
                     is the realistic case and only a human can resolve it.

The subtle one is `kept` plus a skewed clock. Normally the server's kept
revision sits above our cursor, so the next pull fetches it anyway. If the
clock makes a genuinely later local edit look older, that revision can be
BELOW the cursor — the pull skips it and the stale local copy stays on screen
with nothing marking it wrong. So a kept result at or below the cursor
rewinds the cursor to re-fetch that note. Both directions tested.

label_ids carries MANUAL memberships only. Tag-sourced ones are re-derived
server-side from the body; sending them would convert them into manual
assignments that no longer disappear when the #tag is deleted from the text.

engine::run_cycle is push-then-pull, and a failed push ABORTS before the
pull — pulling anyway would overwrite the exact rows we just failed to save,
turning a recoverable network error into lost work. sync_pull is removed from
the command surface accordingly: offering a bare pull would hand the UI a way
to discard unsent edits. sync_now and sync_has_pending replace it.

Both loops have anti-spin guards: push stops when a batch clears nothing,
pull stops when the cursor doesn't advance.

15 push tests against an in-memory database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:20:09 -04:00
bvandeusenandClaude Opus 5 2e32ecda6e desktop: rustfmt the pull tests; run fmt after clippy/test
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m20s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m8s
Two macro-argument splits and a stray blank line, applied verbatim from run
2900's cargo fmt --check diff.

Also reorders the Linux job so `cargo fmt --check` runs AFTER clippy and the
tests. Fail-fast ordering would normally put the cheapest check first, but
there is no Rust toolchain on the workstation, so this lane is verified
entirely in CI — and a formatting nit failing first SKIPS clippy and the
tests, making a whole cycle teach nothing but whitespace. That has now cost
four cycles in this session alone. It still runs before the 20-40 minute
bundle build, so a fmt failure doesn't burn that either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:09:07 -04:00
bvandeusenandClaude Opus 5 dc8b2d360d M10.7b: pull the change feed into the local store (task 2105)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 26s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m53s
Server -> local. sync/wire.rs mirrors the delta-feed JSON exactly as
notes/serialize.py sends it; sync/pull.rs applies it.

ATOMICITY IS THE POINT. The cursor is written in the SAME transaction as the
page it describes. A cursor committed ahead of its data would skip those rows
forever while reporting a clean sync — the worst kind of failure, because
nothing looks wrong. A test forces a mid-page failure and asserts the cursor
stayed put.

Every degradation leans toward re-downloading rather than skipping: an
unparseable cursor means full sync, wire fields are all defaulted so a newer
server adding a field (or an older one omitting one) yields a partial note
instead of a rejected page, and a page that fails rolls back whole.

Labels are applied before notes so a membership never references a row that
doesn't exist. A note also carries enough of its labels to materialize them,
because notes and labels page from ONE shared sequence and a note can arrive
referencing a label whose own delta landed in an earlier page.

via_tag is applied verbatim rather than re-deriving #tags from the body. The
server already reconciled them on save, and re-deriving would go through the
local find-or-create path, which marks new labels dirty — pushing them
straight back. Sync churn manufactured out of nothing.

Duplicate-label merge, the subtle one: a label created offline can collide by
name with one the server already had under a different id. Both sides enforce
one label per name, so the server's row has to win — but simply deleting the
local duplicate would CASCADE its note_labels away, stripping the label off
notes this pull never mentions, with no later page to repair it. So we free
the name, insert the server's row, re-point the memberships, then drop the
husk. Tested.

Children (items/attachments/previews/labels) are replaced wholesale rather
than diffed: a delta carries the note's FULL state, so what arrived IS the
complete set, and diffing could strand a row the server no longer has.

The loop trusts the data over the flag — a server claiming has_more without
advancing its cursor stops with an error instead of spinning forever.

Pull can overwrite a row with unpushed local edits. The documented cycle is
push-then-pull (M10.7c), so that should never happen; when it does it's
counted as clobbered_dirty and logged rather than hidden.

17 tests, all against an in-memory database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 00:05:27 -04:00
bvandeusenandClaude Opus 5 7d9a6509f3 desktop: rustfmt the M10.7a state tests
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m55s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m52s
Four macro-argument splits, applied verbatim from run 2895's
cargo fmt --check diff. No logic change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 23:38:49 -04:00
bvandeusenandClaude Opus 5 bbb2fd9b1c M10.7a: link/unlink a server — device auth + sync_state (task 2104)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 27s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m55s
The pairing step. Nothing else in the sync arc can move until this works.

sync/state.rs owns the link record in the sync_state row M10.4 already put
in the local schema. Two safety properties are the reason it isn't just
three setters:

- Linking a DIFFERENT server resets the change-feed cursor. A cursor is only
  meaningful against the server that issued it; carrying one across would
  silently skip every change on the new server below that watermark — data
  loss wearing the costume of a successful sync. Re-linking the SAME server
  (a token refresh) keeps it, so a routine re-auth doesn't force a full
  re-download.
- Unlink clears the cursor too, so a later link can't inherit a watermark
  from a server that never issued it.

An unparseable or absent cursor reads as 0 (full sync). That direction is
always safe: a redundant re-sync costs time, a too-high cursor costs notes.
Likewise a half-written row (server but no token) reports NOT linked.

state::Status deliberately has no device_token field — it crosses into the
webview, and a long-lived bearer token has no business reachable from page
scripts. A test asserts the token never appears in its serialization.

Token lives in the app-data SQLite file, not an OS keyring: the keyring
crate needs libsecret/DBus on Linux, which adds a C dependency to a binary
that has to cross-compile and fails outright on headless/minimal-WM setups —
the same class of environment assumption behind the black-window bug.

sync_link runs the M10.6 handshake FIRST and refuses an incompatible server
before any credential is sent. Two credential paths, because neither covers
everyone: device-login (a fresh install has no session to mint a token from)
and a pasted token (some users would rather not type a password into a
desktop app). A pasted token is verified against /api/auth/me before being
stored — auth.py's login_required accepts bearer — since an unverified paste
would turn a copy/paste slip into a failure surfacing at the next sync, far
from its cause.

The store lock is taken only after all network work: a std MutexGuard isn't
Send so it cannot cross an await, and holding the store for a round-trip
would freeze every note operation in the UI.

Unlink is LOCAL only — the token stays valid server-side until revoked under
Account -> Linked devices. A pasted token arrives without its device id, so
a reliable remote revoke isn't possible from here; the UI must say so rather
than imply a revoke that didn't happen. Follow-up filed.

No UI yet — that's M10.7e.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 23:35:51 -04:00
bvandeusenandClaude Opus 5 9118680bb1 docs: record the CI consequences of the M10.6 TLS dependency
ci-requirements.md is the contract with CI-Runner (rule 39), so the two
things a future image change could silently break belong in it:

libssl-dev + pkg-config in ci-tauri are now load-bearing — native-tls
compiles against OpenSSL on Linux, so a slim-down of that image would fail
the Rust build at openssl-sys rather than anywhere obvious.

The TLS backend choice is a property of the WINDOWS lane, not a dependency
detail: native-tls resolves to schannel on windows-msvc, keeping C/assembly
out of the cross-compile. Swapping to rustls would pull in ring/aws-lc-rs
and their assembler — the same class of dependency that broke that lane
before. Flagged so it's treated as a lane change, not a version bump.

Also documented why libssl3 is left covered TRANSITIVELY rather than
declared. dpkg-shlibdeps now lists it, and verify.sh passes it through
webkit's recursive closure. Declaring it directly would be worse, not
better: the package name is release-dependent (libssl3 on bookworm,
libssl3t64 after the time_t transition), so hardcoding it freezes the .deb
to the build distro, whereas webkit's closure adapts. verify.sh fails loudly
if webkit ever stops pulling OpenSSL, which is what makes that safe.

Docs only — triggers no workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 22:53:31 -04:00
bvandeusenandClaude Opus 5 4eb92942d0 M10.6: HTTP transport for the handshake (task 1995)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m4s
Adds the client's first outbound call: GET {server}/api/config, carrying
X-ThoughtSync-Client and X-ThoughtSync-Protocol, feeding compat::evaluate.

Deliberately its OWN commit. This introduces the first HTTP+TLS stack into a
crate that cross-compiles to Windows from Linux via cargo-xwin — the lane
that has already broken once on a transitive C dependency (libsqlite3-sys
needing llvm-lib). Landing it alone means a failure here has exactly one
possible cause, instead of surfacing mid-way through M10.7's much larger
change where it would be expensive to bisect.

TLS backend is native-tls, NOT rustls, and that is the whole point of the
choice: on x86_64-pc-windows-msvc native-tls resolves to `schannel`, which
is pure-Rust bindings to the OS TLS stack, so nothing C or assembly has to
cross-compile on the fragile lane. rustls would pull in ring/aws-lc-rs and
their assembler. On Linux native-tls uses OpenSSL, whose headers ci-tauri
already ships (libssl-dev, part of Tauri's own Linux prerequisites).

Verified from run 2884's log rather than assumed: tokio and http are already
in the Windows tree via tauri, but no HTTP client and no TLS stack were —
so this genuinely is new surface there, not a no-op.

probe() distinguishes "never got a usable answer" (Err) from "answered, but
we can't work with it" (Ok + verdict). Those need very different messages:
one is "check what you typed", the other is "update something". Transport
errors are translated out of reqwest's Display, which is accurate but reads
like a stack trace.

Still no UI — M10.7 owns the link/settings surface that calls server_probe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 22:47:10 -04:00
bvandeusenandClaude Opus 5 4b4bfe67ad desktop: rustfmt the client-header tuple
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m55s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m49s
Applied verbatim from run 2886's cargo fmt --check diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 22:42:57 -04:00
bvandeusenandClaude Opus 5 fbbe877c46 M10.6: client↔server sync protocol handshake (task 1995)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 42s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m34s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 14s
Version the sync WIRE PROTOCOL separately from either program's release
version, so a self-hosted server and the desktop app can sit on different
releases and still work out whether they can talk.

Each side declares two numbers — what it speaks, and the oldest counterpart
it accepts. Either side can therefore mark a change breaking without the
other shipping in step, which is the whole point: no app↔server lockstep.

Server advertises on the existing public /api/config (a client must be able
to ask "can I talk to you?" before it holds a device token, or even has an
account): sync_protocol_version, min_client_protocol_version, sync_features.

sync_features exists because a version number can only say newer/older. An
ADDITIVE change earns a capability name instead of a minimum bump, so a
newer client meeting an older server drops that one feature and syncs the
rest, rather than refusing. Raising a minimum is reserved for genuinely
breaking changes — it's the switch that hard-blocks the other side.

Client half is pure decision logic (sync/compat.rs), no I/O, so every branch
is unit-testable — there's no live-server lane in CI. Three outcomes: ok /
degraded{unavailable} / incompatible{reason, client_must_update}. The last
names which side can fix it, so the message is actionable. A server that
predates the handshake sends no protocol fields at all; that reads as
"update the server", deliberately not as a parse error, which would look to
the user like they mistyped the URL.

normalize_base_url defaults a bare host to https://, never http:// —
silently downgrading would put a long-lived device token on the wire in
cleartext because someone omitted five characters. Plain HTTP on a trusted
LAN stays supported; the user types http:// and thereby chooses it.

Transport (the actual fetch) lands next, separately: it needs an HTTP/TLS
stack, and that's a real risk to the Windows cross-compile lane, so it gets
its own CI run to bisect against rather than riding along with this.

No UI here by design — the link/settings surface it feeds is M10.7's, per
this task's own sequencing.

Policy documented in docs/sync.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 22:40:25 -04:00
bvandeusenandClaude Opus 5 5b471f5dd4 desktop: generate the Windows icon set in the cross-compile job
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m49s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m37s
tauri-build needs icons/icon.ico to emit the Windows Resource file, and the
repo only carries the PNG set the Linux bundles use — run 2881 failed with
"icons/icon.ico not found".

Generated in-job from the committed 1024px app-icon.png rather than committing
a hand-made .ico, so there stays one icon of record that can't silently drift
from the brand art. Scoped to the windows job; the Linux bundles don't need it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 20:37:33 -04:00
bvandeusenandClaude Opus 5 ab961f13ce desktop: cross-compiled Windows NSIS installer lane (task 2015)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 48s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m40s
Adds a `windows` job to desktop.yml on the new ci-tauri-win image, producing a
Windows -setup.exe without any Windows hardware. A Windows container can't run
on a Linux host, so cross-compilation is the only route: --runner cargo-xwin
supplies the MSVC CRT/SDK (pre-warmed into the image) and links with lld-link,
and makensis builds the installer.

NSIS only. .msi needs WiX v3, a Windows program — per Tauri, ".msi installers
can only be created on Windows". It comes back if a Windows node ever exists.

Kept as a separate job so a Windows-side failure can never block the Linux
artifacts, which are the primary product today. publish-release.sh now globs
the windows target root too; nullglob means each job uploads only what its own
workspace contains, and the release is created once and reused via the 409
path, so both jobs can publish to the same release safely.

No app code changes were needed. The AppImage self-integration UI already
gates on is_appimage (AccountView.vue:131, DesktopIntegrationPrompt.vue:22),
and $APPIMAGE is never set on Windows, so the OOBE prompt and Settings toggle
hide themselves.

Recorded plainly in ci-requirements.md that this is the weakest-verified lane
we have: Tauri calls Linux->Windows cross-compilation "not tested as much" and
a last resort, and a Linux runner cannot execute a Windows binary. Green means
it built. A real Windows machine check is mandatory before trusting a release,
and installers are unsigned until a certificate exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 20:14:55 -04:00
bvandeusenandClaude Opus 5 dc68386d1a desktop: point the install command at a branch that exists
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m27s
The advertised curl URL referenced raw/branch/main, but main has never been
created (creating it is rejected by a branch-protection rule that matches the
name even with no branch behind it), so the one-command install 404'd. dev is
currently the repo's only branch and serves the script fine now that the repo
is public — verified 200, with all three v0.1.0 release assets resolving and
the AppImage downloading in full.

Flagged in the header to move back to main once that branch exists, so the
public install command stops tracking day-to-day work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 19:19:05 -04:00
bvandeusenandClaude Opus 5 85ca7c2a2d desktop: drop the redundant deb depends, correct the pacman docs
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m28s
Follow-up to 8a8b2b1, driven by what run 2872's new checks actually printed.

The .deb verification did its job on its first run: tauri already infers
exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so declaring the same two in
tauri.conf.json produced a control file listing each of them twice. Removed
the declaration — verify.sh is the real guard, and it fails the build if
inference ever stops covering what the binary links.

The pacman step revealed ci-tauri carries neither zstd nor bsdtar, so packages
currently ship as .pkg.tar.xz with no .MTREE. Both are working outcomes
(pacman reads xz; only `pacman -Qkk` needs .MTREE), but the docs promised
.zst, so the README, the release notes and ci-requirements.md now describe
what the build actually produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
v0.1.0
2026-07-25 18:26:24 -04:00
bvandeusenandClaude Opus 5 8a8b2b17e6 desktop: prebuilt pacman package + verified .deb (tasks 2022, 2074)
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m41s
Native packages the installer can actually fetch, before task 2014 wires up
the fetching.

Arch (task 2022, re-scoped): the source PKGBUILD is gone — asking every user
to install rust+node and compile for minutes isn't distribution. Replaced by
desktop/packaging/arch/package-prebuilt.sh, which wraps the binary the Linux
job already built into a .pkg.tar.zst. No second Rust build, no Arch CI image:
the binary bundles nothing and resolves webkit/gtk/soup by soname, identical
on both distros, with SQLite compiled in and glibc used in the safe
built-old/run-new direction. CI is Debian and has no pacman, so the step logs
.PKGINFO plus the full file listing for audit instead of pretending to verify.

Debian (task 2074): install.sh hands the .deb to every Debian/Ubuntu user and
nothing had ever inspected it. tauri.conf.json now declares
libwebkit2gtk-4.1-0 + libgtk-3-0 explicitly rather than trusting inference —
and deliberately declares no appindicator or sqlite dep, since tauri is built
with features=[] and rusqlite is "bundled". desktop/packaging/deb/verify.sh
prints the generated control file, cross-checks it against what the ELF
actually needs via dpkg-shlibdeps, confirms every declared dep exists in apt,
and clean-container installs when a docker CLI is available.

Both artifacts join the run artifact and the tagged release; install.sh grows
a pacman branch so Arch/CachyOS gets a native install instead of the AppImage
fallback. Still no release cut (rule 2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-25 18:19:30 -04:00
bvandeusenandClaude Opus 4.8 36c05f5029 desktop: release-publish pipeline + one-command Linux installer
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m45s
Phase A of the desktop Release + install path (M10 / tasks 2014, 1998):

- .forgejo/workflows/desktop.yml: tag-gated "Publish release" step +
  contents:write. On a v* tag the build now publishes a Fabled-Git Release
  with the de-bundled AppImage + .deb attached — a stable, versioned fetch
  target (Actions artifacts are ephemeral/test-only). Dormant on dev/main.
- desktop/packaging/publish-release.sh: creates/reuses the Release via the
  Forgejo API using the runner-injected token; idempotent asset replace.
- desktop/packaging/install.sh: curl|sh one-command installer — native .deb
  on Debian/Ubuntu, de-bundled AppImage everywhere else (installed to
  ~/Applications/ThoughtSync.AppImage, matching src/integration.rs so the app
  sees itself integrated). AppImage path needs no sudo.

Plumbing only — no release cut (rule 2); activates on the operator's first
v* tag. In-app self-update (tauri-plugin-updater + signed latest.json) is
Phase B, gated on the operator's signing key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-25 14:13:50 -04:00