Commit Graph
44 Commits
Author SHA1 Message Date
bvandeusen bc22f8e249 Remove [[wiki-links]], backlinks and the graph
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Android / Kotlin + Rust (APK) (push) Failing after 1m56s
Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.

So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.

This subsumes 982d24c rather than reverting it. That commit bound links to a
note id so a rename would stop rewriting other notes' bodies — real infra, but
infra for a feature that is now gone, and nothing it added survives. Alembic
0023 stays in the chain anyway: it shipped in an image and may already be
applied, and deleting an applied revision strands a database's version pointer.
0024 drops the table and takes the column with it. The history stays honest
about the fact that it existed for a day.

Two things deliberately kept, because they were serving recall and only
incidentally serving links:

- `/api/notes/titles` and the titles store. The command palette lists them so
  you can jump to a note by name. `resolve()` — the name→note lookup that only
  linking needed — is gone.
- `display_title`. Every note still has a name for search results and export
  filenames. What that name is FOR changed; that it exists did not.

`notes/links.py` is now `notes/tags.py`, holding the #tag→label reconciliation
it always also owned. A file called links.py with no links in it would have been
exactly the drift this removal is meant to end.

Also swept out on the way: `_escape_like`, whose only caller was link-search,
and the `graph` icon. Nothing lost that a person typed — note_links was always
derived, and the `[[text]]` is still sitting in every body it was written in.
2026-08-22 12:00:57 -04:00
bvandeusenandClaude Opus 5 e696b23417 core: give consumers an in-memory store instead of a rusqlite dependency
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m56s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m51s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The extraction left update.rs's tests reaching for rusqlite and uuid directly to
build a Db — crates that now belong to the core alone, so clippy failed on
unresolved imports. The Windows job had already compiled the whole installer, so
this was only ever the test module.

Adding rusqlite as a dev-dependency of the desktop crate would have fixed it and
quietly undone part of the point: the desktop is not supposed to know what the
store is made of. So the core exposes open_in_memory() instead, which is what the
caller actually wanted, and the Android bindings will want the same thing when
they get tests.

uuid went the same way. It was generating unique scratch-directory names, which a
process id plus a counter does without a dependency — process id separates
concurrent cargo test runs, the counter separates tests within a run. The comment
right above it already said nothing there was worth a new dependency.

Verified the boundary holds in both directions afterwards: the desktop crate
references none of rusqlite/uuid/chrono/reqwest/sha2, and the core references no
tauri.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:22:24 -04:00
bvandeusenandClaude Opus 5 0a7480cf9b core: extract the store and sync engine into a shared crate (M12 step 1)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 48s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m50s
Desktop (Tauri) / Update manifest (push) Skipped
Android becomes a native Kotlin client over this same code (Scribe note 2730), so
the local store and sync engine stop being modules of the desktop app and become
`thoughtsync-core`, a crate with no UI framework in it at all.

This is a move, not a rewrite, and the measurement is why: every file in local/
and sync/ already carried ZERO Tauri references — 4,980 of 6,372 lines. The
coupling was 473 lines of command shim, which stays behind in the desktop crate
as src/commands/. Kept as git renames so history follows the files.

The desktop imports them under their old names (`use thoughtsync_core::{local,
sync}`) so every call site reads exactly as before. What moved is where they
live, not what they are.

Two things a workspace changes that are easy to miss, both caught before pushing:

[profile.release] now lives at the workspace ROOT. Cargo silently ignores
profiles declared by a non-root member — leaving it in the desktop crate would
have dropped lto/strip/opt-level from every release build with only a warning.

And a workspace shares ONE target dir, so the bundles moved from
desktop/src-tauri/target to target/. Thirteen references across publish-release,
debundle-graphics, verify.sh, package-prebuilt and the workflow now point there.
Pinning target-dir back would have been the smaller diff, but the Android lane
also produces Rust artifacts and they do not belong under desktop/.

Also retires the Tauri Android lane in the same push rather than leaving a path
that is being replaced: gen/android, android.yml and docs/android-dev.md are
gone, the mobile_entry_point attribute with them, and the lib drops to rlib —
staticlib/cdylib existed for Tauri mobile, and the .so Android loads will be
built from the core crate instead. Rule 22, no parallel path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:12:26 -04:00
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 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 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
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
2026-07-27 10:33:17 -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 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 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 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
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 c3855b0ff1 desktop: rustfmt the summary() count closure
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m42s
cargo fmt --check (run 2856) wanted the long closure body wrapped in a block.
Formatting only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-25 10:19:25 -04:00
bvandeusenandClaude Opus 4.8 f325402902 desktop: robust startup + operation logging (portability troubleshooting)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 9s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 32s
There was essentially no logging — useless for proving the app renders across
different environments. Add real observability:

- tauri-plugin-log -> stdout (so `2>&1 | tee` captures a run) AND a persistent
  file in the app log dir (grabbable after the fact on any machine). Level Info.
- Startup diagnostics: app version, OS/arch, the Linux display/session stack
  (XDG_SESSION_TYPE, desktop, Wayland/X11, GDK_BACKEND), the WebKit render-
  hardening vars actually in effect, resolved log + data dirs, DB open/migrate
  result, and note/label counts.
- log_event command + a frontend logEvent() helper: boot line (data source +
  WebKit user-agent) from main.ts, first-route config/session/destination from
  the router guard, and — via the bridge invoke() wrapper — every failed Tauri
  command named with its error, so a broken basic function is self-identifying.

Task 2040.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-25 10:18:06 -04:00
bvandeusenandClaude Opus 4.8 3958db0a8b desktop M10.4: fix stmt lifetime in reminders/search
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m20s
Both chained `stmt.query_map(...)?.collect()?` as the block's tail expression,
so the prepared statement (a block local) was dropped before the borrow held by
the mapped rows ended (E0597). Bind `let rows` first, matching every other query
in the file. rustc error, so this also unblocks test + the release build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 23:10:22 -04:00
bvandeusenandClaude Opus 4.8 dbe1425bd2 desktop M10.4: apply rustfmt (line wrapping only)
cargo fmt --check output from CI run 2849, applied verbatim: wraps long fn
signatures, query/execute calls, and method chains. No logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 23:05:04 -04:00
bvandeusenandClaude Opus 4.8 ed1b3aa814 desktop M10.4: Rust local SQLite store + Tauri commands
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 24s
The on-device core that makes the desktop app work with no server and no login.

- rusqlite (bundled SQLite, so no system libsqlite dependency to vary across
  builds); uuid v4 ids; RFC3339/Date.toISOString-compatible timestamps.
- Schema mirroring the note model: notes, labels, note_labels (with via_tag),
  checklist_items, attachments, link_previews, note_revisions, saved_filters,
  plus per-row sync_revision/dirty + a sync_state row for the M10.7 engine.
  user_version-gated migrations.
- derive.rs: pure [[wiki-link]] + #tag scanners (mirror the frontend inline
  rules, no regex dep) with unit tests; #tags re-sync via_tag labels on save,
  [[links]] drive backlinks at query time (derived, never stored).
- store.rs: the full repository surface (facet/label/date/text list, create,
  PATCH-semantics update, pin/archive/color/kind, checklist items, labels CRUD
  + merge, reminders complete/snooze, reorder, trash/restore/delete, revisions
  + restore, titles/search/backlinks/link-search, saved filters).
- commands.rs: ~38 #[tauri::command]s over a Mutex<Connection> in managed state.
- lib.rs: opens the DB in the platform app-data dir on setup; synthetic offline
  config/user so the auth-gated router resolves with no login.

Attachment upload / URL unfurl / import are intentionally deferred (network/file
concerns); adapters/local.ts (M10.5) wires all of the above via invoke.

Task 1993.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 22:54:58 -04:00
bvandeusenandClaude Opus 4.8 98d918dda2 M10 (task 2022, issue 2021): Arch pacman package + commit icon set (native, system libs)
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m27s
Native-first fix for the AppImage black window: a PKGBUILD that builds from source,
linked against SYSTEM graphics libs, so it renders on the host driver.
- desktop/packaging/arch/{PKGBUILD,thoughtsync.desktop,README.md}: `makepkg -si`
  installs /usr/bin/thoughtsync + .desktop + icon; deps webkit2gtk-4.1/gtk3/...;
  builds the frontend + `cargo build --release` (no tauri-cli). Uses the host
  graphics stack -> avoids EGL_BAD_PARAMETER.
- Commit the icon set (desktop/src-tauri/icons/*.png, un-gitignored) so BOTH
  `cargo build` (pacman) and `cargo tauri build` (deb/appimage) work without a
  generate step; bundle.icon -> the 4 committed PNGs; drop the `cargo tauri icon`
  step from desktop.yml.
- Broaden the WebKit software-render hardening (lib.rs) to ALL Linux (was
  AppImage-scoped) so the native build also renders if system WebKit is finicky.

Can't CI-test the PKGBUILD (Arch-only; CI is Debian) -- operator builds locally.
desktop.yml re-verifies the deb+AppImage build with the committed icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 17:02:40 -04:00
bvandeusenandClaude Opus 4.8 3f5b87682e issue 2021: harden Linux WebKit rendering (fixes black AppImage window)
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m12s
WebKitGTK's DMA-BUF/EGL renderer fails to init on many Linux GPU/driver/Wayland
setups -> 'EGL_BAD_PARAMETER' -> black window (known WebKitGTK issue, not app code).
Per Tauri's Linux-graphics guidance, set the software-fallback env vars at startup
before the webview is created, scoped to AppImage launches (native installs keep GPU
accel): __NV_DISABLE_EXPLICIT_SYNC / WEBKIT_DISABLE_DMABUF_RENDERER /
WEBKIT_DISABLE_COMPOSITING_MODE, each only if the user already set it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 14:38:27 -04:00
bvandeusenandClaude Opus 4.8 404e256760 M10 (2013): rustfmt integration.rs (wrap perm chain)
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m3s
cargo fmt --check wanted the fs::metadata(p).map_err(..)?.permissions() chain
wrapped (>100 cols). Fixes desktop build #2829; the frontend already typechecked
clean in that run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 13:26:28 -04:00
bvandeusenandClaude Opus 4.8 877a6a572f M10 (task 2013): integrated AppImage — app self-integration (OOBE + Account toggle)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 26s
CI & Build / Build & push image (push) Successful in 33s
The Linux AppImage can now install itself into the applications menu, so it behaves
like an installed app instead of a loose file.

- Rust (desktop/src-tauri/src/integration.rs): integration_status / integrate_desktop
  / unintegrate_desktop commands — detect $APPIMAGE, copy the AppImage to
  ~/Applications, write ~/.local/share/applications/thoughtsync.desktop + embedded
  icon, update-desktop-database. Registered in lib.rs.
- Frontend: withGlobalTauri exposes window.__TAURI__.core.invoke; desktop/bridge.ts
  (isDesktop + typed invoke, NO @tauri-apps/api dep -> web bundle unaffected);
  DesktopIntegrationPrompt (first-run OOBE, remembered) mounted in App.vue;
  AccountView "Desktop app" add/remove control. All desktop-guarded -> no-ops on web.
- desktop.yml: upload the .deb + .AppImage as a run artifact (continue-on-error) so
  the build is downloadable for hand-testing.

Verified by CI: ci.yml (vue-tsc) for the frontend, desktop.yml (cargo + tauri build)
for the Rust + AppImage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 13:24:20 -04:00
bvandeusenandClaude Opus 4.8 1607c77ee5 M10.8: desktop CI lane (.forgejo/workflows/desktop.yml) + app-icon source
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m19s
Separate workflow (not ci.yml) so the ~20-40min Rust/AppImage build only runs on
desktop/** changes, not every backend/frontend push. Builds on the ci-tauri:1.97
image: frontend build -> `cargo tauri icon app-icon.png` (from a committed 1024px
source PNG, sidestepping SVG-input questions) -> cargo fmt --check -> clippy -D
warnings -> cargo test -> `cargo tauri build` (deb + AppImage).
APPIMAGE_EXTRACT_AND_RUN=1 for FUSE-less CI containers. Verifies the M10.2 scaffold
end-to-end and is the foundation for the integrated-AppImage work (task 2013).
Also updates ci-requirements.md with the desktop lane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 12:26:24 -04:00
bvandeusenandClaude Opus 4.8 db24d7ea18 M10.2: Tauri v2 desktop scaffold (desktop/) — Vue frontend as the webview
New desktop/ Tauri v2 project (Linux-first, cross-platform-ready):
- src-tauri: Cargo.toml (lib + thin main.rs shim), build.rs, lib.rs (Builder
  entry point), tauri.conf.json (frontendDist -> ../../frontend/dist, devUrl
  :5173, deb+appimage bundles), capabilities/default.json (core:default),
  .gitignore.
- The shared Vue 3 frontend is the sibling ../frontend; before-commands cd via
  "$(git rev-parse --show-toplevel)/frontend" since frontend and src-tauri are
  siblings, not nested.
- Icons generated from frontend/public/icon.svg via `cargo tauri icon` in CI
  (M10.8), not committed.

Boots the shared UI in a native window. The local data adapter (M10.3/M10.5)
and CI build verification (M10.8) follow. desktop/** is not yet in the CI paths
filter — added with the desktop lane in M10.8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-24 11:13:09 -04:00