67b9ea2938c8cd004f60046d4303d066fa06b2ad
152
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
67b9ea2938 |
frontend: one grid for every lens, and a cross-fade between surfaces (task 1913)
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 59s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android (Tauri) / Android APK (debug) (push) Successful in 4m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m32s
Desktop (Tauri) / Update manifest (push) Successful in 5s
"The same board, re-filtered" has to be literally true to read as true. The column classes were copy-pasted into five places — the board's pinned and other sections, its non-board branch, search, and timeline — so a lens could drift from home by a single edit. One already had: the FLIP reflow from 1914 landed on the board's three grids and left search and timeline popping. NoteGrid is now the only file that knows how the masonry is laid out or how it moves, and search and timeline gained the motion by adopting it. It takes activeId rather than an index. The board splits its notes across two grids, so index-based focus made the call site do offset arithmetic (focusedIndex === pinnedNotes.length + i) against a list the grid didn't own. The lens cross-fade is deliberately UNKEYED, which is the whole trick. Board, archive, trash and label all render the same BoardView; keying the transition on the route would remount it, blanking the board and refetching — exactly the page-change feeling this is meant to remove. Unkeyed, Vue transitions only when the component TYPE changes (board to search to timeline to graph), and moving between the board's own lenses stays an in-place reflow that NoteGrid animates. The two behaviours fall out of one rule rather than needing to be special-cased. Out is quicker than in because mode="out-in" makes the durations additive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
18a58fb5da |
frontend: the board glides and the editor grows from its card (task 1914)
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 1m0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Android (Tauri) / Android APK (debug) (push) Successful in 4m25s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m8s
Two of M7's motion targets. prefers-reduced-motion was already in place from the 1999 pass and gates both of these for free. FILTERED REFLOW. The three card grids become TransitionGroups sharing one transition name, so "how the board moves" is defined once in CSS rather than three times in markup. Vue's TransitionGroup does the FLIP itself — measure before, measure after, transition the difference away — so no animation dependency, which the task called for. Leavers are deliberately NOT pulled out of flow with position:absolute, the usual TransitionGroup trick. This masonry is CSS multi-column, and an absolutely positioned child escapes its column to the container's origin: a note would fly diagonally across the board on its way out. Keeping leavers in flow costs a small settle when the element is finally removed, so the leave is the shortest of the three durations. EDITOR CONTINUITY. useNoteEditor.open() is the one place that knows which card was clicked, so that is where the card's on-screen centre is captured; the editor panel then scales from that point. Deliberately not a true shared-element morph: scaling by the real card-to-panel ratio distorts the text on the way, and a card is often a third of the modal, so an honest ratio reads as a zoom rather than a transition. The task sanctioned a good-enough scale/position tween; this is that. A point rather than a rect, because nothing needs the card's size and a point survives the card being filtered away while the editor is open. Consumed on read, so a compose — which has no card — cannot inherit the origin of whatever was edited before it and grow from an arbitrary corner. The animation lives inside NoteEditor rather than in the five views that render it: the leave has to finish BEFORE the host unmounts, so the component owns its own visibility and tells the host when it is done. visible starts true with `appear`, because the panel lives inside that v-if and would not exist to measure otherwise. The origin is measured with offsetLeft/offsetTop rather than getBoundingClientRect — enter-from has already applied scale(0.94) by then, so the bounding rect is of the shrunken panel and the origin would land off by a few pixels. Offsets are layout geometry and ignore transforms. Durations are 140-220ms. The brief is continuity, so a card should read as having moved, not as having performed. NOT verified: motion is a visual property and there is no frontend test lane, no device, and no app run here. vue-tsc proves it compiles. Whether it FEELS right is an operator live pass, which is what M7's own verification section asks for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
1f140c7457 |
android: scaffold the Tauri mobile lane and build a debug APK in CI
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> |
||
|
|
be0eb94225 |
frontend: reorder cards with Pointer Events so touch can do it at all (task 2697)
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m12s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Native HTML5 drag-and-drop never fires from touch — the API predates it and was never wired to it — so on a phone reordering did nothing whatsoever, and the grip that starts it was hover-gated on top of that. Pointer Events cover mouse, touch and stylus on one code path instead of two. The awkward part is hit-testing. Native DnD routed dragover/drop to whatever was under the cursor, so each card learned on its own that it was the target. A captured pointer sends every move to the element that captured it, so the dragged card has to hit-test for itself and publish the result where the other cards can see it — hence the shared refs in useCardDrag. It reads the DOM via elementFromPoint rather than tracking geometry because the board is a CSS masonry: visual order isn't derivable from model order, and cards reflow as the column count changes. Asking the browser what is actually under the finger is the only answer that stays true. Capture is what makes the gesture survive crossing a card boundary; touch-action: none claims it from the browser's scrolling; a 6px threshold keeps a tap from becoming a drag; and pointercancel is handled so a system interruption leaves no half-set state. The parent contract is unchanged apart from `drop` now carrying the target's ID rather than its note — the dragged card finds its target in the DOM, so an id is all it can know without a second lookup. BoardView keeps its own tracking of what was picked up; that it now duplicates the composable's draggingId is real, and noted for the DRY pass rather than expanded into here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f5837cd985 |
frontend: hover-revealed controls stay put where hovering is impossible (task 2697)
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 29s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 2m36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 2m36s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
A finger cannot hover, and on a note card the hover toolbar is the only way to pin, colour or archive — so on a phone those notes could not be acted on at all. Same for deleting a checklist item, a saved view, an attachment or a preview. Marked rather than rewritten inline: one `.hover-reveal` class on the five elements and a single rule that says what it is for. The `group-hover:` reveal stays in the markup because the trigger differs per component (named groups); only the fallback is shared. `@media (hover: none)` asks the device directly, which is more honest than inferring from viewport width — a narrow window on a laptop still hovers, and a large tablet still doesn't. It sits after the Tailwind directives so it beats the opacity-0/pointer-events-none utilities on source order without !important. Tap targets follow the same shape: p-1.5 around an 18px icon lands near 30px, which is fine for a cursor and too small for a thumb. Bumped to 44px on coarse pointers only, so desktop chrome doesn't inflate. The drag grip is deliberately NOT revealed yet. Reordering still uses HTML5 drag-and-drop, which never fires from touch, so showing the handle would only promise something that does nothing. It comes with the pointer-events rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e7ee16c6cf |
frontend: dialogs keep focus, and a skip link past the chrome (task 1999)
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m11s
Desktop (Tauri) / Update manifest (push) Successful in 5s
BaseModal declared role="dialog" aria-modal="true" and then enforced none of it. Focus never moved into the panel, so Escape — handled ON the panel — did nothing at all in LabelsModal, the integration prompt and the shortcuts modal. Only the command palette escaped correctly, and only because it happens to focus its own input. Tab walked straight out of the dialog into the page that aria-modal had just told assistive tech was inert, and closing dropped focus to <body> so the next Tab restarted from the top of the document. All three are one contract, so it lives in BaseModal rather than in each of the four callers: focus in on open, Tab trapped, focus restored to the opener. The panel takes tabindex="-1" so it can hold focus itself when it wraps nothing focusable. CommandPalette's input focus still wins, because a child's mounted hook runs before its parent's. The skip link is the other half. The header and sidebar are a dozen-odd tab stops that repeat on every navigation, and a keyboard user walked all of them again to reach their notes. <main> takes tabindex="-1" as well, because several browsers scroll to a bare anchor without moving focus to it — which would have made the link look like it worked while leaving the next Tab back at the top. The rest of the audit came back clean: no click handlers on non-focusable elements, and all 30 focus:outline-none uses already pair with a focus-visible ring. M3.5's keyboard pass held up; the gaps were in focus management, not styling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
3a1496e5fa |
frontend: honor prefers-reduced-motion, and let frontend work reach the desktop
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 31s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m33s
Two halves of the same gap. The app had no reduced-motion handling at all — the setting appeared nowhere in the frontend — and the desktop build didn't rebuild on frontend changes, so shared UI work shipped to the web and silently never reached the desktop app. The CSS guard is global and blunt so it catches every Tailwind `transition` already scattered through the components, and catches M7's motion work without each new component having to remember. Near-zero durations rather than `none`, so transitionend/animationend still fire and nothing waiting on them hangs. useReducedMotion covers what CSS can't reach: JS-driven motion, where the honest response to the preference is no animation at all rather than a faster one. It's reactive because the setting can change while the app is open. The path filter was narrowed to the adapter/bridge directories against a "~20-40 min" build cost recorded in the header. Measured runs are 4-5 minutes, so that cost isn't there, and the frontend is compiled into the binary by generate_context! — any part of it changing means the shipped desktop app is stale. Desktop, web and Android are peer surfaces on one quality bar, so shared frontend work has to reach all of them by construction rather than by whichever directory it happened to touch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
659237ccc6 |
desktop: the empty board explains where your notes live (task 1999)
A fresh desktop install has no login — auth_me returns a synthetic local user so the shared router's guard resolves — but nothing said so. You landed on a bare board with no way to tell whether the app was storing your thoughts on this machine, waiting for a credential, or quietly shipping them somewhere. The start state is the empty board itself, not a welcome modal or an onboarding gate. The product exists to take a thought in under a second; spending that second on a dialog taxes the one thing it is for. It also means there is no "seen it" flag to persist, migrate, or let drift out of step with reality — the message retires itself the moment a first note exists, which is exactly when it stops being true that you have nothing here. Shown only when the app is unlinked: offering to connect a server to someone who already has one is noise. The status read is best-effort and never awaited, so the board renders at full speed regardless; if it fails we keep showing the offline copy, which is the honest reading of "we know of no server". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c883fd2eb6 |
desktop: one name across all three install channels (issue 2075)
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>
|
||
|
|
5c1ae574f6 |
desktop: commit Cargo.lock and gate CI on it (issue 2102)
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
c1464228df |
docs: Fabled-Git, not Forgejo, where the instance is meant
Four references to "Forgejo" actually meant this instance, which has run Gitea since the migration: the registry push, the missing /releases/latest/download route, the API a packaging script resolves URLs against, and the 422 on an illegal JSON escape. Kept as-is — these are genuinely about the upstream Forgejo project, not us: the `forgejo/upload-artifact` mirror and "the Forgejo project's fork". Prose only — no workflow, path, or script change. Scribe issue #2272. |
||
|
|
505904b1e5 |
ci: swap artifact upload to the mirrored action (issue 2270)
Both desktop upload steps used actions/upload-artifact@v3, which reports success while Gitea stores the result in a format its v4-only artifact API will never serve back — 110 artifacts on this repo are on disk, have valid DB rows, and are invisible to the REST API, the web download route and the MCP tools alike. Green jobs producing nothing retrievable. Point both at bvandeusen/upload-artifact (pull mirror of the Forgejo project's fork, GHES refusal disabled), pinned by SHA because the mirror auto-syncs. Not actions/upload-artifact@v4: its isGhes() throws on the hostname before opening a connection, so no server-side change reaches it. Also drop continue-on-error and set if-no-files-found: error on both steps. Between them, a failed or empty upload was reported as a green run — the same silence that let this go unnoticed for a month. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
13e48672c0 |
packaging: bare backticks — a heredoc's backslash isn't the JSON's
Run 2981 built everything and then died posting the release: HTTP 422, "invalid escape sequence \`". The body's other backticks are written \` because they sit in an UNQUOTED heredoc, where that backslash is the shell's and is gone before any JSON exists. Copying the idiom into a single-quoted variable changed what it meant — single quotes already stop substitution, so the backslash survived into the body as an escape JSON has no rule for. bash -n passes either way; it checks syntax, not what a string becomes. So parse the assembled body for every branch it can take instead, and write down the recipe next to the one for formatting Rust. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MKsUY9Z45KQd34V956hZ9Q |
||
|
|
8b6dfab3a7 |
ci-requirements: the two things that cost a cycle each to rediscover
`git push origin dev` fails outright now that the rolling channel put a TAG named `dev` beside the branch, and the error names neither. And nothing in CI lints the packaging shell scripts, so a broken installer surfaces when a user runs it rather than when it's built — record how to check them locally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MKsUY9Z45KQd34V956hZ9Q |
||
|
|
d8b0cd9b96 |
packaging: the installer learns the same two channels the app updates on
install.sh asked /releases/latest and installed whatever came back. That is v0.1.0 today, which predates the updater, and it was about to get worse: the `stable` pointer release write-manifest.sh creates is non-prerelease and holds only latest.json, so from the next v* tag onward it would have WON /releases/latest and the installer would have found nothing to install. So resolve a channel instead of a "latest". `--channel stable|dev` (or TS_CHANNEL), default stable, named to match update.rs's Channel exactly. dev reads /releases/tags/dev. stable reads the pointer's own latest.json, takes its version, and installs that v* release — the same file the app reads, so the installer and the updater cannot disagree about what stable means. Two things found on the way. The dev release's description still told people to run the stable command, and always would have: publish-release.sh writes a body only when it CREATES a release, and a fixed-tag release is only created once, so the text froze at the first build. The 409 path now PATCHes it. And the asset greps were unanchored, so a .AppImage.sig URL could match as the bundle URL — harmless by coincidence, not by construction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MKsUY9Z45KQd34V956hZ9Q |
||
|
|
6f47af8d96 |
ci: point the manifest at THIS build, and stop the dev release growing forever
Two halves of one mistake, both visible on the dev release right now: the manifest said 0.1.134 and pointed at ThoughtSync_0.1.132_amd64.AppImage. The rolling channel accumulates every build's assets, and the manifest picked its bundle by file extension with `head -1` — the OLDEST match. A client would have been told 0.1.134 was available, downloaded 0.1.132, installed it, and been offered 0.1.134 again. Forever. Signature verification could not have caught it. The old bundle's signature is perfectly valid for the old bundle; nothing about it says "this isn't the build the manifest claims". Selection is now matched on the build's own version string, so the manifest can only ever describe the binary it was written for. The accumulation is the other half. Nothing can reach a superseded build once the manifest moves on, and an AppImage is ~100 MB — three pushes had already left 300 MB of unreachable binaries on the Git host. A rolling channel now prunes everything but the current build once the manifest points at it. Versioned releases are untouched: that IS the archive, and the stable pointer's URLs aim into it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
acff95f920 |
ci: re-sign the AppImage after de-bundling, or Linux updates can never verify
The de-bundle step deletes the AppImage and repackages it without the host graphics libraries — necessary, and it runs AFTER tauri signed the original. So the .sig published on the release described a file that no longer existed, and every Linux in-app update would have failed signature verification. Worth naming the failure mode: the error would have said the signature didn't match, which points at the key, the manifest, or the download — anywhere except "a later build step rewrote the file after signing it". The Windows lane hid it too, because nothing post-processes the NSIS installer, so the one platform already verified working was the one platform that couldn't reveal the bug. Signs the file that actually ships, and fails the build if no .sig comes out rather than quietly publishing an unverifiable bundle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
3ca3eba6d5 |
packaging: stamp the pacman package with the version actually built
It read the version straight out of tauri.conf.json, which was correct until dev builds started overriding the version on the command line — the file still says 0.1.0, so release `dev` came out carrying a pacman package labelled 0.1.0 around a binary that reports 0.1.132. Nothing breaks from it (a pacman install can't self-update anyway), but a package that lies about its version is exactly what makes a later "which build is this?" impossible to answer. Now uses the same build-version.sh the bundles and the manifest do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
02c932260e |
Updater signing key, a rising dev version, and a production compose
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_01SreJkbxB4gx8pPsu8QbLPistable dev |
||
|
|
1f294c4ad8 |
ci-requirements: record how to format the Rust lane without a local toolchain
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
2e8717a057 |
desktop: rustfmt the two new preference helpers
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 |
||
|
|
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 |
||
|
|
b7c0820230 |
desktop: rustfmt the blob-store literal in the scheme handler
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 |
||
|
|
d634801bd3 |
desktop: rustfmt the retention query and one assert
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
7a77a0e1b9 |
desktop: fix a retention test that raced the wall clock
`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 |
||
|
|
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 |
||
|
|
6f35e6e6d8 |
Confirm irreversible deletes, which sync just made far more consequential
The trash model itself was already right and needed no change: notes soft- delete (`trashed` locally, `deleted_at` server-side), Trash is a real view, restore works, permanent deletion is a separate second step only offered on an already-trashed note, `trash()` shows an Undo toast, and nothing auto- purges — trash persists until someone acts. Sync carries all of it: a trashed note syncs WITH its content, and only `purged_at` deletes a client's copy. What was missing is the guard on the irreversible step. "Delete forever" and label deletion were one click, silent, with no confirmation — and M10.7 has changed what that costs. Before, a mis-click lost a note on one machine. Now it pushes a tombstone that deletes it from every linked device, and the local tombstone survives to make sure it gets there. Both guards live in the STORE, not the call sites: NoteCard and NoteEditor both offer delete-forever, and duplicating the copy is how two prompts drift until one of them stops matching what actually happens. The copy names the real consequence — "deleted from every device you sync with" — because that's the part a user cannot infer from a button in a Trash view. The label prompt also says the notes themselves are kept, since that's what people actually worry about when deleting a label. Labels deliberately get a confirmation but NOT a trash of their own. A label is organization, not content; the reversible middle step notes get would be ceremony around something that costs nothing to recreate. Saved-filter deletion already confirmed (AppShell), so these two were the outliers, not a new convention. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
810da43f56 |
desktop: rustfmt the blob-store test
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 |
||
|
|
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 |
||
|
|
6bef07ff83 |
desktop: rustfmt the SyncOutcome literal
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 |
||
|
|
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 |
||
|
|
75b2d096ec |
desktop: rustfmt the push module
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 |
||
|
|
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
|
||
|
|
2e32ecda6e |
desktop: rustfmt the pull tests; run fmt after clippy/test
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 |
||
|
|
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 |
||
|
|
7d9a6509f3 |
desktop: rustfmt the M10.7a state tests
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 |
||
|
|
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 |
||
|
|
9118680bb1 |
docs: record the CI consequences of the M10.6 TLS dependency
ci-requirements.md is the contract with CI-Runner (rule 39), so the two things a future image change could silently break belong in it: libssl-dev + pkg-config in ci-tauri are now load-bearing — native-tls compiles against OpenSSL on Linux, so a slim-down of that image would fail the Rust build at openssl-sys rather than anywhere obvious. The TLS backend choice is a property of the WINDOWS lane, not a dependency detail: native-tls resolves to schannel on windows-msvc, keeping C/assembly out of the cross-compile. Swapping to rustls would pull in ring/aws-lc-rs and their assembler — the same class of dependency that broke that lane before. Flagged so it's treated as a lane change, not a version bump. Also documented why libssl3 is left covered TRANSITIVELY rather than declared. dpkg-shlibdeps now lists it, and verify.sh passes it through webkit's recursive closure. Declaring it directly would be worse, not better: the package name is release-dependent (libssl3 on bookworm, libssl3t64 after the time_t transition), so hardcoding it freezes the .deb to the build distro, whereas webkit's closure adapts. verify.sh fails loudly if webkit ever stops pulling OpenSSL, which is what makes that safe. Docs only — triggers no workflow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
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
|
||
|
|
4b4bfe67ad |
desktop: rustfmt the client-header tuple
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 |
||
|
|
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
|
||
|
|
5b471f5dd4 |
desktop: generate the Windows icon set in the cross-compile job
tauri-build needs icons/icon.ico to emit the Windows Resource file, and the repo only carries the PNG set the Linux bundles use — run 2881 failed with "icons/icon.ico not found". Generated in-job from the committed 1024px app-icon.png rather than committing a hand-made .ico, so there stays one icon of record that can't silently drift from the brand art. Scoped to the windows job; the Linux bundles don't need it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
ab961f13ce |
desktop: cross-compiled Windows NSIS installer lane (task 2015)
Adds a `windows` job to desktop.yml on the new ci-tauri-win image, producing a Windows -setup.exe without any Windows hardware. A Windows container can't run on a Linux host, so cross-compilation is the only route: --runner cargo-xwin supplies the MSVC CRT/SDK (pre-warmed into the image) and links with lld-link, and makensis builds the installer. NSIS only. .msi needs WiX v3, a Windows program — per Tauri, ".msi installers can only be created on Windows". It comes back if a Windows node ever exists. Kept as a separate job so a Windows-side failure can never block the Linux artifacts, which are the primary product today. publish-release.sh now globs the windows target root too; nullglob means each job uploads only what its own workspace contains, and the release is created once and reused via the 409 path, so both jobs can publish to the same release safely. No app code changes were needed. The AppImage self-integration UI already gates on is_appimage (AccountView.vue:131, DesktopIntegrationPrompt.vue:22), and $APPIMAGE is never set on Windows, so the OOBE prompt and Settings toggle hide themselves. Recorded plainly in ci-requirements.md that this is the weakest-verified lane we have: Tauri calls Linux->Windows cross-compilation "not tested as much" and a last resort, and a Linux runner cannot execute a Windows binary. Green means it built. A real Windows machine check is mandatory before trusting a release, and installers are unsigned until a certificate exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
dc68386d1a |
desktop: point the install command at a branch that exists
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m27s
The advertised curl URL referenced raw/branch/main, but main has never been created (creating it is rejected by a branch-protection rule that matches the name even with no branch behind it), so the one-command install 404'd. dev is currently the repo's only branch and serves the script fine now that the repo is public — verified 200, with all three v0.1.0 release assets resolving and the AppImage downloading in full. Flagged in the header to move back to main once that branch exists, so the public install command stops tracking day-to-day work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
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
v0.1.0
|