The plumbing has been bound since step 4 — probe, link by password or token, unlink, sync — with nothing on top of it. Until this commit the phone was a good standalone notes app that could not be the SAME notes as the desktop, which is the point of the project. Structurally a port of the desktop's SyncView.vue: same probe-then-link order, same copy wherever the copy was already right. The two surfaces pair with the same servers, and a difference in wording here would read as a difference in behaviour. BEING UNLINKED IS NOT A PROBLEM, and the screen is written around that. It leads with "Working offline on this device" and says what connecting would ADD. A local-first app that frames its resting state as unfinished setup is lying about what it is. The drawer badge follows the same rule: it says nothing at all when unlinked, rather than "Off". Probe before credentials. A typo that reaches a stranger's server should cost a round trip, not a password — so the address is checked first, what answered is shown (name, version, compatibility), and only then does a sign-in form appear. An incompatible server never gets one; the core would refuse the link anyway, and collecting a password to throw away is worse than not asking. CLEARTEXT IS NOW PERMITTED, deliberately and not silently. Android blocks plain http from API 28, and the core explicitly supports a self-hosted server on a LAN — `http://192.168.1.10:8000` is a case it has a test for. The platform default would make this app unusable for exactly the people it is built for, with a transport error they could do nothing about. A network-security-config would be tighter in principle but matches domains and IP literals, not CIDR ranges, so it cannot express "my own network". The other half of the trade is a warning that appears the moment a probed address starts with http:// and BEFORE any credential field: anyone on the same network can read your password and your notes. Credentials never enter the view model. The address, email and device name are `rememberSaveable` so a rotation doesn't cost a retype; the password and the token are plain `remember` on purpose — rememberSaveable persists into the instance-state bundle, and a secret has no business being written there to save four seconds of typing. They reach the core as a `Credentials` sealed type and die with the composable. That sealed type also fixed a bug detekt surfaced by complaining about a six-parameter function: `link_with_token` takes NO device name (the token was already minted against a named device in the web app), so the flat argument list meant the form collected one in token mode and silently dropped it. The field now exists only on the password path. Threading, which differs by call and is easy to get wrong in one direction: probe / linkWithPassword / linkWithToken / unlink / syncNow are Rust async through uniffi, so Kotlin sees suspend functions already driven by tokio and awaits them directly — wrapping them in Dispatchers.IO would park a thread to wait on something that never blocks one. syncStatus and hasPending are ordinary blocking FFI into SQLite and do need it. A sync that changed anything tells the board to reload, because a pull can have rewritten every note it is holding. Wired explicitly at the one place that owns both view models rather than through a shared event bus. A no-op sync deliberately does not, so the board never flashes its loading state for nothing. Sync results are kept RAW in state and turned into sentences in the UI, where stringResource is in scope — the same split Time.kt draws for timestamps. The summary counts what MOVED; batches, pages, noop and cursor are all real numbers and none of them answer "are my notes in step". Rejections are surfaced rather than swallowed: only a person can resolve them. So is a revoke that didn't land — someone disconnecting to retire a phone has to be told a live credential is still out there, and has to still find it when they come back to check, so it is a persistent notice and not a toast. Also here: `Panel`/`Notice` extracted as shared tinted chrome, drawn from the same note palette the cards use rather than Material's errorContainer, so a warning is the same yellow a note can be. `PlainTextField` gained a visual transformation for the password field. `formatReminder` became `formatInstant` now that "last synced" reads it too. Verified locally per ci-requirements.md: ktlint and detekt clean in ci-rust-android:1.97, uniffi bindings generated from a host build and read to confirm ULong on the summary counters, `Compatibility.Ok`/`RevokeOutcome. Unsupported` being objects, and all five sync calls being suspend. Every R.string/R.plurals reference cross-checked for existence, kind and format arity. A symbol-resolution pass over the whole package caught a composable a bad edit had deleted — ktlint and detekt both parse without resolving, so neither could see it. Not done: no automatic sync. The desktop is manual-only too, so this is parity rather than a gap, but pull-to-refresh on the board is the obvious phone-native follow-up. Worth an operator decision, not changed here: allowBackup is still true, so Android's cloud backup now includes a device token as well as the notes. Good for restoring to a new phone, and a wider blast radius than before this commit. Scribe #2777 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ThoughtSync
Self-hosted personal thought-capture web app in the FabledSword family — a
Google-Keep-style masonry post-it board for capturing disparate thoughts in
under a second, designed to grow into a lightweight second brain (labels, search,
[[wiki-links]], graph).
- Stack: Quart (async Python) + Vue 3 + PostgreSQL, Docker, Fabled-Git CI.
- Auth: native email + password (signed-cookie sessions). Multi-user with the family owner + direct-share + group-share ACL.
- Web-first. An Android companion is a later, conditional milestone.
Layout
src/thoughtsync/ Quart app (app factory, auth, models, ACL, config, db)
alembic/ async migrations (schema built via `alembic upgrade head`)
tests/ DB-free unit tests (pytest)
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
Dockerfile 2-stage build (Vue -> python:3.12-slim runtime)
docker-compose.yml local app + Postgres stack
Development
Backend (needs a Postgres reachable at THOUGHTSYNC_DATABASE_URL):
pip install -e ".[dev]"
alembic upgrade head
hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000
Frontend (proxies /api to :5000):
cd frontend
npm install
npm run dev # http://localhost:5173
Or run it in Docker. Hot-reload dev stack (edit code, changes reload live) — Postgres + backend + Vite dev server, app at http://localhost:5173:
docker compose -f docker-compose.dev.yml up
Or the full production image (SPA baked in) at http://localhost:5000:
docker compose up --build
Deploy (self-host)
A complete two-container stack (app + Postgres) you can copy and run. Save it as
docker-compose.yml, change the two CHANGE_ME passwords (they must match), then
docker compose up -d:
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: thoughtsync
POSTGRES_PASSWORD: CHANGE_ME # change this
POSTGRES_DB: thoughtsync
volumes:
- thoughtsync-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U thoughtsync"]
interval: 5s
timeout: 5s
retries: 10
app:
image: git.fabledsword.com/bvandeusen/thoughtsync:latest # :dev for the current dev build
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:CHANGE_ME@db:5432/thoughtsync
volumes:
- thoughtsync-data:/var/thoughtsync # uploaded images; omit if you don't use attachments
ports:
- "5000:5000"
volumes:
thoughtsync-db:
thoughtsync-data:
Then open http://<host>:5000 and register — the first account becomes the admin.
- Only
THOUGHTSYNC_DATABASE_URLis required.THOUGHTSYNC_SECRET_KEYis optional; if unset, a signing key is generated and persisted in the database (sessions survive restarts). - Uploaded images live under the
thoughtsync-datavolume at/var/thoughtsync. - The app waits for the database and runs migrations (
alembic upgrade head) automatically on start. - Image tags:
:latest(stable, built frommain) ·:dev(latestdevbuild) ·:<git-sha>(immutable, for pinning / rollback). - Install as an app (PWA): ThoughtSync is installable ("Add to Home Screen" / the
browser's install button) for an app-like window. Browsers only offer install over a
secure context, so put the app behind a reverse proxy terminating HTTPS (or reach
it via
localhost) — plainhttp://<host>:5000won't show the install prompt.
Milestones
- M0 — Foundation & Identity ✅: Quart+Vue+Postgres skeleton, native auth, sharing-ACL spine, Fabled-Git CI.
- M1 — Capture Core ✅: note model + masonry board (quick-add, colors, pin, edit-in-place, archive, trash+restore).
- M1.5 — Roles & DB-backed Settings ✅: first user is admin, admin Settings UI,
DATABASE_URLis the only required env. - M2 — Organize: labels/tags, search, checklists, attachments.
- M3 — Second-brain seeds:
[[wiki-links]], backlinks, graph view, reminders. - M4 — Launch hardening: responsive/PWA, settings UI, polish.
Work happens on dev; main is protected (PR-only).