6c0153be1e3b4c825bd57fd4230e986035416bc0
25
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6c0153be1e |
desktop: a global hotkey opens a small window to write in, now with its lockfile
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 29s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m5s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m22s
Restores |
||
|
|
10ea15bef0 |
Revert the desktop hotkey: a new crate needs a Cargo.lock this machine cannot write
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m7s
Desktop (Tauri) / Update manifest (push) Successful in 4s
`42e06da` added `tauri-plugin-global-shortcut` to Cargo.toml without updating
Cargo.lock, and every cargo invocation in CI passes `--locked`. Both desktop
jobs failed on the same line before compiling anything:
error: cannot update the lock file ... because --locked was passed
So this says nothing about whether the code is right — clippy never ran. The
gate did exactly its job.
There is no Rust toolchain on this workstation (rule 10 — CI verifies), and a
lockfile is the one artifact CI is deliberately forbidden to generate. Hand-
writing the entries is not a real option: it needs the exact checksum and the
whole transitive tree, and a wrong checksum fails harder than a missing one.
Reverted rather than left red, because a red `dev` blocks everything behind it
and the Android half of #1899 is green and unaffected at
|
||
|
|
42e06da576 |
desktop: a global hotkey opens a small window to write in, and nothing else
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 30s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 5s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 35s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 35s
The other half of #1899. Press the combination anywhere and a 520x220 window arrives over whatever you were doing; type, Ctrl/Cmd+Enter, it is gone. The board never comes forward, which is the whole point — bringing the app up to write one line is the friction this removes. ## There is no default shortcut, deliberately A global shortcut is the one setting here that can collide with software this app knows nothing about. Any default is a key combination taken away from something on somebody's machine, silently, at install time. So the feature is OFF until a combination is chosen, and choosing one is how it turns on. CommandOrControl+Shift+N is offered as a one-click suggestion, never applied on the user's behalf. ## Stored and live are reported separately `CaptureShortcut` carries both `shortcut` and `registered`, because they genuinely disagree: a combination another app grabbed first is saved and does nothing when pressed, and on Wayland a compositor may refuse global grabs outright. Saying only "your shortcut is X" would be a lie with a keystroke attached, so the settings row says "saved but isn't active — something else is holding it". `capture_shortcut_set` registers BEFORE storing, so a combination the system refuses is never written down as though it worked. Registration at startup is best-effort and logged: a shortcut that worked when it was chosen can be taken by something installed later, and the app must still open. ## Two windows, one database, no shared store The capture window runs a second copy of the frontend with its own Pinia stores, so a note saved there is invisible to the board until it is told. It is told — `capture_done(saved)` emits to `main`, and BoardView reloads. The emit failing is cosmetic (the note is already in SQLite) so it is logged, not raised. The window is opened at `index.html?capture=1` rather than at `/capture` because the bundled assets are served as FILES: a path with no file behind it 404s in the production build while routing fine under the dev server. The router turns the query into the route. It is hidden rather than closed on the way out, and it keeps its text. A capture interrupted by something more urgent is still there on the next press, which is what makes Escape safe to press. A failed save also keeps the window open holding the text — hiding it would throw away the only copy of something just written in order to report a problem you could retry your way out of. ## Where the setting lives Rule 25 says a tunable belongs in the UI, and this one has to be. It sits in the desktop's Sync screen beside the update channel, not in admin Settings: that screen is the SERVER's and bounces on desktop anyway, while this is a property of one installation on one machine. Persisted with the same `store::set_pref` the update channel uses. No @tauri-apps/api dependency was added — everything routes through `invoke` and the `withGlobalTauri` global, as the rest of the bridge does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
c40916699b |
sync: the client header said "desktop" from every phone, and named the wrong version
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m55s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m37s
`client_headers()` built `thoughtsync-desktop/{CARGO_PKG_VERSION}`, and both
halves were wrong.
This crate is compiled into the Android app as well as the desktop one, so
every phone in the field announced itself as a desktop. And CARGO_PKG_VERSION
here is the CORE crate's version — a number no build stamps and no user has
ever seen — where the thing a reader of that header wants is the app's own
build (note 3127 §5: with no version tags, the artifact's self-report is the
only answer to "which build is this?").
The core cannot know either value, so the host says them. `set_client_agent`
is a OnceLock the desktop fills in `run()` and Android fills in
`ThoughtSyncApplication.onCreate`, before anything can sync. A host that never
introduces itself sends `thoughtsync-unidentified/unknown` rather than a
plausible default: nothing reads this header today, which is exactly why a
wrong value could sit in it for months — the first person to look at a server
log is the first who could catch it, and only if what they see is obviously a
host that never said who it was.
Android's version comes from the INSTALLED package, through a new
`Context.installedVersionName()` that the foot of the Sync screen now shares.
One answer to "which build is on this phone", so the line a person quotes in a
bug report and the line in the server's log cannot disagree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
|
||
|
|
f992439588 |
version: every surface can say which build it is, and two of them were lying
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m50s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
Note 3127 §5 removed version tags, so an artifact's self-report is now the only
answer to "which build is this?" — and nothing exists to contradict it when it
is wrong. Three surfaces gain a dim build line: the foot of the web rail, the
login screen, and the foot of Sync on Android.
The login screen because "I can't sign in" is a bug report like any other, and
requiring an account to read a build number withholds it from exactly the people
who can't get past that page. `/api/config` is already public.
Two of the values it was going to show were wrong, which is the part worth
knowing about.
The DESKTOP reported `env!("CARGO_PKG_VERSION")` from `config_get` and from the
startup log. `cargo tauri build --config '{"version": ...}'` overrides
tauri.conf.json, not Cargo's own metadata — so both read the literal `0.2.0` in
Cargo.toml, on every build ever shipped. They now read a display version baked in
by the lane through `option_env!`, hoisted to the crate root because two readers
of one fact is how this repo keeps producing 2181-2183. Not the ordering key
either: `1.0.<minutes>` is the opaque value Tauri's updater compares and must
never be shown to a person, and `update.rs` still reads it because a comparator
is exactly what it is (rule 149).
The SERVER fell back to `__version__` when APP_VERSION was absent, so a server
run from a checkout reported `0.2.0` — a real-looking version naming no build
anybody could obtain. `__init__.py` already asserted the honest answer was
"APP_VERSION being missing, which app.py already handles"; it did not, and a
comment claiming a behaviour two files away is how that stayed true-sounding.
Now an explicit "unknown", with the packaging version left where "unknown" is
not a legal value.
Android reads the INSTALLED package's versionName rather than BuildConfig, so it
reports what is actually on the phone.
Everything renders "unknown" rather than blank when it cannot say. A blank looks
like a layout bug; a plausible default cannot be caught by anything.
build.rs gets `rerun-if-env-changed` for the baked value: cargo does not track an
`option_env!` variable on its own, and the desktop lane having no cache today is
what makes that easy to forget the day one is added.
|
||
|
|
7033995975 |
search is a facet on the board, not a place you go
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Operator (note 2930): tags exist so you can *"filter during a search"*. The server has always been able to do that — `GET /api/notes` composes `?q=` with `?label=` and the rest into one AND-ed query. The frontend never reached it. The header search box navigated to `/search`, and that view called a DIFFERENT endpoint — `GET /api/notes/search?q=`, full text only, no facets at all. So the one screen you landed on when you searched was the one screen where you could not narrow by tag. Tag filtering lived on the board's FilterBar, which is where you weren't searching. Two search boxes, two endpoints, and only the hidden one did what tags are for. Now the header box writes `?q=` into the board's URL beside whatever labels are already there, and stays on the lens you're in — searching while looking at Trash searches Trash. The box READS from the URL rather than holding its own copy, so it stays in step with the Filters panel's Clear and with a saved view opened from the sidebar. Deleted: `SearchView.vue`, its route, `GET /api/notes/search`, `repo.notes.search` and both adapter implementations, and the `notes_search` Tauri command whose only caller was the adapter entry. FilterBar loses its own "Search text…" input — it was the same facet, hidden behind a collapsed panel, duplicating a box that is always on screen. Filters now does what its name says: narrowing. The header does searching. `core::store::search` STAYS. Android calls it through the FFI (`search_notes`) and has its own search surface — which has the same no-tag-filter gap the web just lost, and deserves the same fix on its own terms rather than as a rider here. |
||
|
|
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
|
||
|
|
0a7480cf9b |
core: extract the store and sync engine into a shared crate (M12 step 1)
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>
|
||
|
|
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> |
||
|
|
edf52da97f |
desktop: the installer's channel choice now reaches the app (issue 2183)
`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. |
||
|
|
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 |
||
|
|
c40263967d |
desktop: render synced attachments instead of broken images (task 2114)
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
b5f7dc2635 |
M10.7c: push + the full sync cycle (task 2106)
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
|
||
|
|
dc8b2d360d |
M10.7b: pull the change feed into the local store (task 2105)
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 |
||
|
|
bbb2fd9b1c |
M10.7a: link/unlink a server — device auth + sync_state (task 2104)
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 |
||
|
|
4eb92942d0 |
M10.6: HTTP transport for the handshake (task 1995)
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |