9a3c4ec377dce6c237850c7c4762941133acb9af
233
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9a3c4ec377 |
android: ticking a box on a card threw the editor open on top of it
Reported from the device: tapping a checkbox on the board checks it AND opens the note. The "and" is the tell — both things happened, so this was never a tap landing on the wrong target. `mutate` ends with `editing = updated ?: state.editing`. That is right for an editor action, where the reloaded note refreshes a screen already on display. But `editing != null` IS "the editor is up" — it is what MainActivity's `when` selects on — so calling `mutate` from the BOARD, where editing is null, wrote the mutated note into it and opened the editor as a side effect of saving. Fixed at `mutate` rather than at the caller, because the caller was not wrong: any board-initiated mutation would have done this, and toggleItem is simply the first one to exist. It now refreshes an open editor and cannot open a closed one. The comment claimed the narrower behaviour all along — "so an open editor shows its own change" — which is what the code should have been doing and wasn't. |
||
|
|
315c5f19e6 |
android: detekt caught a callback that never reached the cards
Not a style finding. `onToggleItem` was added to BoardScreen's signature and read
inside NoteBoard — which is a separate top-level composable, not a nested one, so
the two were never connected. detekt reported it as an unused parameter; the
compiler would have called it an unresolved reference. Neither had run: Kotlin is
compiled at the "Unit tests" step, which is gated behind detekt, so nothing in this
lane had type-checked the Android changes yet.
Threaded properly now, which is what makes ticking a box from the board actually
work rather than merely appear to.
Two more from reading it again with that in mind:
* `when { item != null -> … onToggleItem(index, …) }` would not have compiled.
Kotlin does not infer that a non-null item implies a non-null index, so the
index stayed `Int?` against an `Int` parameter. Both are in the condition now.
* continueChecklist had six returns against detekt's limit of two. Collapsed to
one `when`, with the two intermediate values guarded on `typedNewline` —
`caret - 1` is only a real index once it is known to be the newline just typed.
|
||
|
|
1a66d9c3a8 |
tests: pin the export against writing every checklist twice
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 15s
M304 step 7. The code change landed with the server half — _note_markdown's `if items:` branch went, and the export payload stopped carrying an items array — but neither had a test, and the failure mode is quiet: every list appears twice in an export, then twice again when that export is imported back. Three cases, and the third is the one worth having. An export taken BEFORE this milestone has a body with no task lines and a separate items array, so importing one still has to fold the checklist in. That is the same fold the Keep importer does, and the reason _insert_note still accepts items at all — asymmetric on purpose: the export stopped writing them, the import did not stop reading them. |
||
|
|
77b1a87712 |
android: ktlint on the import order and a leftover blank line
Inserting the checklistLines import after Note split Note from NoteLabel, and removing the checklistOpen state left two blank lines behind it. Both are the formatter only — the bindings built and the Rust lanes were already green. |
||
|
|
68b2a5dc8d |
android: a checklist is lines of the note here too
M304 step 6, and the surface with the least room to hide: Android has no markdown renderer at all, so the card was about to show every list twice — once as literal `- [ ] milk` in the body preview, and again as the glyph rows underneath. Same bug the web had, one commit later. The card now renders the body LINE BY LINE and draws a checkbox where one belongs, which is what puts a list between two paragraphs instead of always after them. The glyphs became tappable while they were being rewritten: ticking something off from the board without opening the note is the common gesture, and the web just gained it. The tap target is the glyph, not the row — tapping the TEXT still opens the note, the way tapping anywhere else on a card does. Kotlin gets no parser. Three implementations of the grammar is the price already paid; a fourth in Compose would be a fourth place for a checklist to change shape when it syncs. So the core exposes three pure functions instead — `checklist_lines`, `checklist_continuation`, `checklist_toggle_at` — and Kotlin does the caret arithmetic around them. Those are FREE functions, not methods, and that is the interesting constraint. The editor's body field is LOCAL state on an idle-debounced autosave, so anything that edits a checklist there has to rewrite the text the field is holding, not a row the store would hand back a moment later. Going through the store would overwrite whatever was being typed. The BOARD has no such problem — nothing there is holding a half-typed body — so the card's toggle goes through the store as usual. `toggle_at` addresses an item by LINE and COLUMN rather than a text offset, because the two sides do not count the same way: Compose measures in UTF-16 units and Rust in bytes, so the same number means different places in a note with an emoji in it. A line number is identical in every encoding, and so is a column inside the marker, which is ASCII at the start of its line. In the editor: the toolbar button inserts `- [ ] ` at the caret — the only toolbar action needing no saved note, so it works on an empty compose box the moment it opens — and Enter continues the list, or ends it on an empty item. Continuation is recognised by SHAPE inside onValueChange (exactly one more character, and it is a newline) rather than by a key event, so a paste or an autocorrect falls through untouched. EditorChecklist.kt and the four item actions are gone (rule 22). Adding, renaming, ticking or deleting an item is editing text now, and the editor already does that — through SaveText, with the same autosave and the same revision window as any other edit. KNOWN GAP, not an oversight: tapping a checkbox inside the EDITOR does nothing yet. Material3's TextField does not expose onTextLayout, so mapping a tap to a character offset means either moving the body to BasicTextField or intercepting pointer events ahead of the field — both real changes to the surface this operator uses most, and neither verifiable without a device. `checklist_toggle_at` lands here, tested, so that task is pure UI. Ticking from the board works today. |
||
|
|
3cab054684 |
web: task lines render as checkboxes where they sit in the note
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 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 25s
CI & Build / Build & push image (push) Successful in 41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m55s
Desktop (Tauri) / Update manifest (push) Successful in 4s
M304 step 5. The server already returns a derived `items` array, so the web kept working across the last two commits — but it was rendering every list TWICE: once as literal `- [ ] milk` bullets inside the body, and again as the separate NoteChecklist block underneath. This is the commit that makes the body the only place a checklist appears. markdown.ts gains a `task` block, matched BEFORE the plain bullet — which would otherwise swallow the marker and leave the brackets showing, the same ordering reason `code` is matched before emphasis in INLINE_RE. Each item carries its ordinal across the WHOLE document, because that is what an item's id means everywhere else now; counting per block would have made the second list's checkboxes toggle the first list's items. The card's preview clamp is why that ordinal is safe there: it only ever drops lines from the end, so a visible item's index is the same whether or not the body was truncated. MarkdownText emits a toggle rather than reaching for the store. Ticking a box rewrites a line of someone's note, and a renderer used in several places should not be the thing deciding that is allowed — the card passes `toggleable` and wires it, a read-only render does not and the boxes are inert. Not wrapped in a <label> either: on a card the text is the note's own words and clicking it opens the note, so only the box toggles. In the editor, the toolbar button stops revealing a section and inserts `- [ ] ` at the caret. That makes it the one toolbar action needing no persisted note to hang anything off — ensureDraft is gone from it, and it works on an empty compose box the moment it opens. Enter on a task line continues the list, and on an EMPTY one clears the marker; without that second half a list would be impossible to get out of. Indent and bullet are carried over rather than normalised, because continuing someone's `*` list with a `-` is an edit they did not ask for. NoteChecklist.vue is deleted (rule 22). The store's item methods stay: they are the repository seam the REST routes and Tauri commands both implement, not the old path. CI cannot check any of this beyond types — there are no frontend tests, only vue-tsc. It wants a real browser pass. |
||
|
|
fe1f72ae1b |
tests: the display-title tests still passed the argument that went away
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 18s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 30s
Three of them called derive_display_title(body, first_item). I updated the call sites in src/ and not these — the integration lane and the linter both passed, because a stale keyword argument is only a TypeError at the moment it runs. Rewritten rather than deleted. The property the fallback existed to protect is still real — a note that is only a checklist has to have a name — it is just reached differently now: an item IS a body line, so the first one is simply the first line with its marker stripped. The new cases pin the two edges that rule introduces: an empty item must not name a note "", and a list of nothing but empty items still has no name. |
||
|
|
761c3b5e82 |
server: the body is the checklist here too, and note_items is dropped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 8s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
M304 steps 3 and the server half of 4. The client half landed in 668f7fa; these belong in one deploy, and the protocol floor below is what enforces that. notes/checklist.py is the Python half of a grammar that now exists three times — here, core/src/local/derive.rs, and (next) frontend/src/notes/markdown.ts. That triplication is the deliberate cost: the alternative is a round trip to the server before a phone can draw a checkbox. Each copy names the other two, and each is tested against the same table of cases, including the near-misses that must stay prose: `-[ ] x`, `- []`, `- [ ]x`, a `[ ]` mid-sentence. Routes: add/update/delete items stop touching rows and rewrite note.body, all through one _rewrite_body that runs the same sequence the PATCH route runs for a body change — because it IS a body change. Revisions, #tag reconciliation, the name, and link unfurls therefore happen in one place rather than three routes each remembering to. The reorder route is gone (rule 22). Reordering a checklist is moving a line, and no client ever called it — the only reference in the tree was a test asserting the route existed. The API still returns `items`, DERIVED from the body on the way out. That is not a second source of truth and it cannot disagree with the body it came from; it keeps the web client working across the rest of this milestone and saves any consumer that only wants to draw checkboxes from carrying a parser. Export drops its separate items block, in both formats. The body already ends with those exact lines, so writing them again would double every checklist in an export and then double it again on re-import. Import still ACCEPTS items, because a Keep takeout has a list and not a blob; it folds them in before the Note is built, so display_title and _reconcile_tags both see the finished text. Protocol 3 on both sides now. A v2 client is refused rather than half-served — which matters more than I first said: _apply_note_items returned early on an absent `items` key, so an un-bumped v3 client against a v2 server would not have LOST the rows, it would have kept them and then had the migration fold them a second time. Duplicated lists rather than missing ones. The floor prevents both. Migration 0027 folds every existing row into its note's body and drops the table. It inlines its own copy of the fold on purpose — a migration has to keep producing what it produced the day it ran — and a test pins that copy against the app's until they are allowed to diverge. updated_at is deliberately untouched: a client holding an unpushed edit keeps the newer timestamp, so last-write-wins keeps its work instead of the migration silently winning. The downgrade is honest rather than faithful. It recreates an empty note_items and leaves the bodies alone, because once items are lines nothing distinguishes one this migration wrote from one somebody typed, and a downgrade that guessed would eat hand-written lists. Recreating the table is still necessary: 0015's downgrade drops a trigger ON note_items, and IF EXISTS covers the trigger, not the table. |
||
|
|
32dafca148 |
core: what rustfmt actually wanted
Three from the checker's own diff. Two are the rule I had guessed at: when a call overflows and its last argument is a closure, rustfmt keeps the earlier arguments on the line and expands the closure into a block, rather than putting every argument on its own line. The third is a stray double blank line before the test module. |
||
|
|
668f7faf03 |
core: the body is the checklist, and checklist_items is gone
M304 steps 2 and the client half of 4, together — they cannot be separated. A
commit where the store writes items into the body while push.rs still reads them
from a table is one that silently pushes the wrong list, and dev publishes to the
dev channel on every green build.
Store:
* load_items becomes items_of(body) — a parse, not a query. An item's id is its
ORDINAL, which is all it ever amounted to: push.rs sent text and checked and
never an id, and both sides replaced the whole list on every sync.
* add_item / update_item / delete_item route through update_note, so they get
revision snapshotting, #tag re-derivation and the dirty/updated_at bookkeeping
without any of it being written a second time.
* create_note folds its items: input into the body, and syncs tags from the
FOLDED body — an item can carry a #tag too.
* display_title no longer takes items, because items ARE body lines now. It
strips the task marker instead: a list-only note is still named by its first
item, and calling that note "- [ ] milk" would show someone the storage.
Wire: items leave it. A second copy of data already in the body field of the same
message is how the two come to disagree. CLIENT_PROTOCOL_VERSION and
MIN_SERVER_PROTOCOL_VERSION go to 3, which is what makes this safe to land before
the server: a v3 client refuses a v2 server outright rather than pushing a body
whose list the old _apply_note_items would then delete.
Schema v8 folds every existing row into its note's body before dropping the
table. Written in Rust, not SQL: the fold has to produce exactly what
derive::append_item produces, and group_concat only gained a guaranteed ORDER BY
in SQLite 3.44 — a checklist that quietly reordered itself during a migration
would be a poor way to learn that. updated_at and dirty are deliberately left
alone, because the server's migration folds the same rows the same way and both
sides land on identical bodies; marking every note dirty would push a body the
server already has, from every device at once.
NOT deployable yet. The server still speaks v2 and still has note_items, so a
client built from this will refuse to sync until the server half lands.
|
||
|
|
d0e3e48943 |
core: rustfmt splits on fn_call_width, not max_width
Three assertions I had collapsed to one line because they fit inside max_width=100. rustfmt's fn_call_width is 60 and applies to the ARGUMENT list, so a call can sit well under the line limit and still be split vertically. Clippy and the tests were already green; this is the formatter only. |
||
|
|
1045db318b |
core: derive checklist items from the body, the way tags already are
First step of M304. Additive on its own — nothing calls this yet — so it can be read and tested before anything depends on it. A checklist is currently a TABLE, and a table can only ever render after the body, because a row has no idea where in the note it belongs. That is why "inline with the note" is not a styling problem: prose, three checkboxes, then more prose is not expressible at all today. derive.rs already owns "structure derived from body text" for #tags and says so in its module doc. Task lines join it rather than opening a second home for the same idea. The difference between the two is worth stating and now is: tags MATERIALISE into label rows because the board queries by label; items materialise into nothing, because nothing queries them. Their only readers are the card, the editor, and display_title. The grammar is fixed here because three languages will implement it — derive.rs, notes/checklist.py, notes/markdown.ts — and any difference between two of them is a checklist that changes shape when it syncs. `*` is accepted since markdown.ts already takes it for a plain bullet, and a rule that allowed `* item` but not `* [ ] item` would be one nobody could guess. `- [ ]` with nothing after it parses as an empty item: that is what pressing Enter on a list leaves behind, and refusing it would make a half-typed list stop being a list. `- [X]` parses and normalises to lowercase on the first rewrite, so round trips are stable. append_item spaces its output exactly as import_export.py:_note_markdown does. That is not cosmetic — the server migration will fold existing rows into bodies with the same layout, so an export taken before it and one taken after have to agree byte for byte. A stale index is inert rather than fatal: the index comes from a UI that may be a moment behind the store, and a late tap should do nothing rather than panic. |
||
|
|
65af37d159 |
android: a checkmark to leave, and asking for a checklist stops writing a blank one
Two reports from the same device pass. **The exit is a checkmark.** It shipped as the word "Done" one commit ago, on the argument that a tick in a NOTES app reads as a checklist item to anyone who has used one. Overruled by the operator, and the filled treatment is what settles the objection anyway: a tonal button in the note's own colour is plainly a control, where a bare glyph beside a checklist would not be. It carries "Done" as its content description, so the argument survives where it actually mattered — read aloud. **Starting a checklist wrote an empty item**, purely so the section would have something to render. That left a blank row with the always-present add-row beneath it — two empty fields, and the caret in the lower one. Whether a checklist is SHOWING is view state, not a row in the store: the toolbar reveals the section and focuses the add row, and nothing reaches SQLite until an item has words in it. EditorAction.AddChecklist is gone rather than repurposed (rule 22), which makes the first real item the action that can create a body-less note — a note named from its first item, which the core already does. |
||
|
|
8257e1035c |
android: put the way out of a note back within reach
Moving the toolbar to the top took the back arrow with it, and left the only exit from a full-screen editor in the top-left corner — the furthest point on the display from a right-handed thumb, reached over the whole note to get to. Reported on the first device pass, and correctly. So the footer carries a Done as well as the timestamp. With the keyboard up it sits directly above the thumb, which is where a hand already is for every other part of writing a note. The top-left arrow stays. Two affordances for one action is usually clutter, but this is the case that earns it: the arrow is what habit, the system back gesture and TalkBack all expect of a full-screen surface, and removing it would strand the reflex to strike a duplicate costing one icon slot. A word rather than a checkmark, on the same argument the overflow menu makes: a tick in a notes app is a checklist item to anyone who has used one, and "Done" cannot be misread, including aloud. EditorSavedLine is now EditorFooter, since it is no longer only a line. |
||
|
|
bca9e16bd0 | android: ktlint wants that body expression on one line | ||
|
|
9ea2a2f9b6 |
android: the toolbar moves to the top, and the note says when it saved
The capture sheet's drag handle cost a strip of screen and did nothing a back gesture does not already do. The toolbar takes that strip instead, which is where it belonged once one surface served both writing and editing: the keyboard owns the bottom of the display for most of a note's life, so a bar down there spends its time riding on the IME. The bottom is now the answer to "did that land". There is no save button — writes are continuous, so a button offering to do what already happened would be a lie with a tap attached — but that left nothing on screen saying the work was safe. Not saved yet → Saving… → Edited just now is the whole lifecycle in the corner, and someone who watches it once never has to be told that closing a note keeps it. DateUtils formats the relative part, so plurals and "yesterday" are not this app's problem to solve twice. Shape: the screen keeps the sheet's rounded top and its gap below the status bar, so opening a note still reads as something rising over the board. Full height rather than a real ModalBottomSheet — a sheet spends a writing session negotiating with the IME for the bottom half of the display, and the swipe-down it buys is a gesture back already does. Both content colours on the card are spelled out. Surface and Scaffold each default theirs to contentColorFor(their container), which returns Unspecified for anything that is not a colour-scheme role; a note tint never is. That is the same default that made the last toolbar invisible in dark mode, latent in two more places. Also: the running LinearProgressIndicator is gone, since the corner line now says the same thing without moving the text; and the SaveText comment in BoardViewModel still claimed saves happened on close. |
||
|
|
ce6a1093a3 |
android: writing a note and editing one are the same surface
The + button raised a capture sheet with a single text field. The editor is
a screen with a toolbar. So a note being WRITTEN could not be given a
colour, a reminder or a checklist — those live on the toolbar, and the sheet
had none. To make a checklist you wrote a note, saved it, reopened it, and
found a control you had never seen.
ComposeSheet is deleted. + opens the editor on an unsaved draft.
A draft is a real Note carrying DRAFT_ID (the empty string) rather than a
null. Note has eighteen fields and the editor reads eight of them; threading
nullability through all of that to express "not saved yet" would spread the
concept across a screen that should not have to know about it. A real id is
a uuid, so the sentinel cannot collide.
It becomes a row on its first save, and the first save is now an autosave:
the editor writes a second after typing stops. That is affordable because
|
||
|
|
2707054563 |
A write should not cost a revision
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 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 7m42s
Every body change snapshotted into history — core/src/local/store.rs and notes/__init__.py both — so a write was expensive, and the clients compensated by writing as rarely as they could. BoardViewModel says it outright: "Saved on close rather than per keystroke, so a session of typing costs one write and one revision snapshot." That is durability paying for version history. An app kill mid-session lost everything typed, so that the revision list would stay tidy. The safety property is worth more than the feature it was subsidising, and no comparable product makes this trade: Keep and Apple Notes write continuously with no history, Docs and Notion write continuously and coalesce history behind the scenes, Obsidian debounces and snapshots on an interval. Save-on-close is the outlier, and this coupling is why we had it. A body change now earns a snapshot only if it is the first of an editing session — the body actually differs, and the note carries no revision from the last ten minutes. Session granularity falls out of the window rather than being declared. A snapshot stores the body as it was BEFORE the edit, so the first write of a sitting captures the note as you found it and every write after it inside the window adds nothing. One revision per sitting, with no commit flag for a client to send and no wire surface to carry it. That is why it is a time rule and not a protocol one. sync.py applies pushed bodies through the same check, so a client autosaving every second cannot make the server snapshot every second either — which a client-declared commit point could not have guaranteed without a protocol bump. Restoring a revision still snapshots unconditionally: a considered act, not a keystroke, and it stays undoable. Unblocks idle-debounced autosave, an honest updated_at, and the "Edited just now" line the editor is getting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
24685556b7 |
android: open an existing note ready to keep writing
Opening a note put no cursor anywhere, so carrying on cost a tap into the body and usually a second one to drag the caret past the existing text. The compose sheet has always focused its field on open; the editor never did, and continuing a note is the more common act of the two. Focus the body on open, caret at the end. Not for a trashed note — that renders read-only and a keyboard over a record you cannot edit is noise. Keyed on note.id so the reused editor re-requests when pointed at a different note. The caret position is why the body state moves from String to TextFieldValue: a String field always starts its selection at offset zero, so focusing one lands the cursor before the first character — the wrong end of a note you meant to continue. PlainTextField gains a TextFieldValue overload for it, and the two overloads share one colours definition rather than growing a second copy of the "no box" treatment this file exists to keep in one place. I recorded this backwards in Scribe 2947 — as the keyboard opening unwanted, when the report was the opposite. The source having no FocusRequester was the tell, and I read it as a mystery instead of as evidence I had the direction wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
50e2d308ea |
android: the editor toolbar was black icons on a near-black bar
Reported as "I'm unable to see a toolbar in the editor on android, is there one?" — and it was rendering the whole time. EditorBottomBar passed containerColor but no contentColor, so Material3 defaulted it to contentColorFor(containerColor). That maps a colour-SCHEME ROLE to its `on-` pair and returns Color.Unspecified for anything else. A note tint is never a role: the default note is 0xFF171717 while the dark scheme's surface is 0xFF0A0A0A. So contentColor resolved to Unspecified, Surface published it as LocalContentColor, Icon took it as its tint, and an unspecified tint applies no colour filter — leaving the icons-core vectors their intrinsic black, on a near-black bar. Every note colour, both themes, only visible in dark. The top bar escaped it because topAppBarColors(containerColor = …) overrides the container and leaves the icon colours at their scheme defaults. Also inset the bar for the keyboard. enableEdgeToEdge makes the manifest's adjustResize a no-op and Scaffold does not inset its bottomBar slot, so the bar would sit under the IME the moment anyone typed — a second way to not see it. imePadding moves to the bar; the content Column drops its own, since Scaffold now measures the bar at its lifted height and the inset reaches the content through innerPadding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
77c5422951 |
ci: a failing lane must not publish an image
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 16s
build gated on lint + typecheck only, so run 4293 failed its test lane and
pushed :dev and :09b5f87 regardless — the deployed server was running a
build whose tests were red.
The comment justified this by saying DB-backed testing happened manually
against the dev image rather than on every push. That was true when it was
written and stopped being true at
|
||
|
|
c851b901df |
The proxy-hops test still read the value from Config
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 15s
|
||
|
|
abe01da5f7 |
compose: say which of the three deployment shapes you're in
The operator asked why `THOUGHTSYNC_BIND` isn't just defaulted to the safe value. Fair question, and the answer exposed that my own advice was incomplete: I told them to set it to 127.0.0.1 without asking where their proxy runs, and for a proxy inside Docker that is the wrong fix. There are three shapes, not two: 1. **LAN, no proxy** — the default. Binds every interface so a phone and a desktop can reach the server. This is why the default is NOT the locked-down value: a server reachable only from the machine it runs on isn't hardened, it's broken, and that is the primary documented use of this app. 2. **Proxy in Docker** — delete the `ports:` block entirely. The proxy reaches the app over the compose network; publishing a host port is a second, unauthenticated way in that bypasses whatever the proxy does about TLS. Safer than 127.0.0.1, because there is no host port to reach even from the host. 3. **Proxy on the host** — `THOUGHTSYNC_BIND=127.0.0.1`. The compose file now spells out all three where the decision is made, and `docs/public-hosting.md` item 4 asks where your proxy runs before telling you what to do, plus how to check: `curl http://<lan-ip>:5000/api/health` from another machine should NOT answer once you're proxied. No default changed. Changing it would silently break every LAN install on the next `docker compose pull` — the phone would just stop syncing, with nothing saying why. |
||
|
|
09b5f874b6 |
Security values move into the Settings UI
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 9s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Failing after 12s
CI & Build / Build & push image (push) Successful in 32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Operator: *"proxy hops defaults to 1 and should be in the settings UI not in the envs, we need the security values to be in the UI."* Overrules the call I made yesterday, and rule 25 is on your side — I argued deployment-topology, but the operator has to be able to SEE what protects them, and reading a container's environment is not seeing. Six new settings in a **Security** group: trusted proxy hops (default 1), the per-account and per-address sign-in limits with their shared window, and the sign-up limit with its own. `THOUGHTSYNC_TRUSTED_PROXY_HOPS` is gone; the rate limits are no longer hardcoded constants. **The hard part was keeping the throttle cheap.** It consults these BEFORE opening a database connection — deliberately, because a refused attempt is meant to cost nothing, and the hop count is needed to know who is even asking. A query per attempt would undo both. So there is a small cache seeded from the registry defaults (the app works with no database at all, which is what the DB-free unit lane relies on), loaded at boot, and refreshed on every settings save — the same live-update contract `session_ttl_days` already had. `SlidingWindow` now takes its limit and window as SUPPLIERS rather than values, so a saved number applies to the next attempt instead of the next deploy. **Bounds are rejected, not clamped.** A hop count of 99 would trust anything a caller sent; a sign-in limit of 0 would lock every account out permanently. Both now fail validation with a message naming the range, and the number input carries min/max so the browser objects first. Silently storing a different number than the one typed is how somebody ends up believing a protection is set to something it is not. `MAX_BUCKETS` stays a constant on purpose: it protects the limiter from itself rather than the app from a caller, and there is no operator judgment to apply. Two integration tests, because the whole point is the round trip: a dangerous value refused, a legitimate one reaching the cache the throttle reads and persisting; and every Security row reaching the admin payload with bounds and a description that explains itself. |
||
|
|
a85c53ba2c |
Trust proxy headers by hop count, and log every credential event
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) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 32s
Operator, before exposing the instance: *"I'd expect that we should have a proxy hops setting for how many proxy hops we should trust a shared real-ip at… and is there any session logging."* Neither existed, and the first one was a real hole. **The address was forgeable.** `client_address()` read the LEFTMOST `X-Forwarded-For` entry — nominally "the original client", and precisely the one a caller controls, because anything they send arrives before what proxies append. So `curl -H "X-Forwarded-For: 1.2.3.4"`, rotated per request, minted a fresh rate-limit bucket every time. Concretely: stuffing ONE account stayed limited (the account key is unforgeable and that is why it exists), but spraying MANY accounts from one source was not — each account got its own budget, and the per-address cap meant to bound the total was defeated by a header. On a LAN that is nothing. It is not nothing on a public host. Now it counts in from the RIGHT by `THOUGHTSYNC_TRUSTED_PROXY_HOPS`, default 1. Each hop appends what it saw, so the rightmost entries are the ones our own infrastructure wrote and a forged prefix lands to the left of them where it can never be selected — proven for the honest, forged, padded, CDN and shorter-than-configured cases. 0 ignores the header entirely; 2 is Cloudflare in front of a proxy. Too high is the dangerous direction, so a header shorter than configured falls back to the socket address rather than reaching further left. `X-Forwarded-Proto` had the same bug and now shares the same rule. Both live in a new `proxy.py` rather than being written twice — two places holding one decision is how issue 2183 happened, and this is the same decision. Env rather than the Settings UI, against rule 25's usual pull: it is deployment topology rather than preference, and the limiter consults it BEFORE opening a database connection, which is the entire point of checking a throttle before doing expensive work. Easy to move if that reads wrong. **And there was no logging at all** — `auth.py` had no logger, and the only record of anything was `device_tokens.last_used_at`. Sign-ins, failures, throttle trips, new accounts and device-token issuance now all log, with the attempted email and the trusted address. Deliberately including the email: it is the operator's own server, and "somebody failed a login" without saying against which account is not actionable. `basicConfig` at INFO in `create_app`, because hypercorn configures its own loggers and leaves the root at WARNING — without it every line above would have gone nowhere, which is a worse failure than not writing them. This is the app log, not an audit table. Not queryable, not retained past log rotation. The table is task 2939; this is what makes the next few days observable. |
||
|
|
2141a0ac45 |
Registration closes itself once the instance has an owner
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) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Successful in 28s
Operator: *"registration should be open only for the first user and they get granted admin privileges. then registration is closed."* The old shape had a window in it. The first account was always allowed and became admin; every account after that was gated by `allow_registration` — which defaulted to ON. So the door stayed open between "my account exists" and "I remembered to turn it off in Settings", and on a public host that gap is the entire exposure: it starts the moment DNS resolves and lasts until someone remembers. Now the door shuts as a CONSEQUENCE of the admin account existing, in the same transaction that creates it. Not "defaults closed" — that would still need the first person to get in somehow. There is no window to remember, because there is no window. Re-opening it is a deliberate act in Settings → Access: turn it on, have the person register, turn it off. Crude, and it is the only mechanism there is — **there is no invite system**, not even a stub. That is real work (a token table, admin create/revoke, a redemption flow, expiry) and is filed as later work rather than smuggled into a release. An integration test covers it, because it is the interaction between two writes in one transaction: first register → 201 and `is_admin: true`; the setting is then false; a second register → 403; re-open deliberately and a third → 201, not admin. **This does not retroactively close an instance that already has users.** The close fires on first-account creation, so a server whose admin predates this keeps whatever the setting was — which was on. `docs/public-hosting.md` now says so explicitly, and step 1 of the checklist is "check" rather than "do" for exactly that reason. |
||
|
|
1aca294b95 |
Bump to 0.2.0 — a release at 0.1.0 would have been a downgrade
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 7s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 14s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m0s
Found while preparing the release, and it would have quietly defeated the point of cutting one. The version does NOT come from the tag. `desktop/packaging/build-version.sh` reads `desktop/src-tauri/Cargo.toml`, and on `dev` it appends the CI run number (`0.1.269`) while on a tag or `main` it ships the file's value verbatim — which was still `0.1.0`. So tagging today would have published a "release" numbered BELOW every dev build already out there, and below the 0.1.227 on the operator's phone. The desktop updater compares semver: an installed build would have read the stable manifest, seen a version older than its own, and correctly concluded it was already current. The APK would have installed (versionCode is the run number and keeps rising) while displaying a version that reads as going backwards. build-version.sh predicted this in its own comment: "Bumping the minor in Cargo.toml still wins over any dev build on the old line, which is the ordering you want: 0.2.0 > 0.1.2932." Bumped in four places, which is every one that can be read by something: - `desktop/src-tauri/Cargo.toml` — the actual source; everything else derives - `tauri.conf.json` — overridden at build time by `--config`, but a checked-in value that lies is exactly how issue 2183 happened - `pyproject.toml` + `__init__.py` — the server's APP_VERSION fallback when no BUILD_VERSION is injected `core` and `android/ffi` stay at 0.1.0 deliberately: internal library crates whose version reaches no surface, and versioning workspace libs independently of the app is normal. Cargo.lock regenerated with `cargo fetch` per ci-requirements — one line, the version itself. Verified: a tag build now yields 0.2.0 and a dev build 0.2.270, so stable is an upgrade for every existing install and dev stays ahead of stable. |
||
|
|
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. |
||
|
|
de72d27bd4 |
URLs unfurl on their own, and a lone link becomes the note
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m16s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Operator: *"I'd like for URLs to unfurl. To be the whole note when the note is a single URL, and to be a compact slot on the bottom of the note when the URL is inline. We also need to support multiple URLs in a single note."* Less new machinery than it sounds: `unfurl.py` already fetched and parsed OG tags, SSRF-hardened, and `note_link_previews` was already `UNIQUE(note_id, url)` — so several URLs per note has worked at the storage layer all along. What was missing was that it needed a button, had one size, and drew that size in the wrong place. **Automatic, and never in the way.** New `unfurl_queue.py` detects a body's URLs and fetches them on a background task AFTER the note is committed. Capture speed is the product: an unfurl is a five-second timeout against a host nobody controls, and a note has to persist the instant someone stops typing. Scheduled from create, from a body edit, and from a synced push — so a linked desktop or Android client gets previews too, on its next pull. An unlinked one has no server to ask and simply has none, which is the honest consequence of being offline. Safe to call on every save: it re-reads what's cached and does nothing when nothing is new. Capped at five URLs per note, silent on every failure (a link that won't fetch isn't an error the person needs — the note is fine, the link is still there), and it re-checks before storing, so a slow fetch can't resurrect a preview for a URL that was deleted while it was in flight. **Two presentations.** A note whose body is nothing but a URL renders as its preview and nothing else — printing the raw URL under a card that already says where it goes is saying the same thing twice, badly. Until the fetch lands, or if it never does, the URL stands in, so the card is never blank. Anything else gets a compact strip. **And the strip moved.** Previews were rendered ABOVE the body, which put a stranger's headline where the note's own first line should be — worse now that the first line IS the note's name. They sit at the foot of the card now, under the note's own words. The editor's "Preview example.com" button is gone with the manual path; removing an unwanted preview stays, and stays editor-only. Nine tests: three on detection (order, dedupe, sentence-punctuation trimming, non-http rejection) in the unit lane, and three in the integration lane for what only a real database shows — the upsert landing on the right row, a second pass fetching nothing, and a preview NOT being stored for a URL that left the body. |
||
|
|
c99cbb3e14 |
cards: clamp the web note preview, as Android always has
CI & Build / Build now, or wait for Android? (push) Successful in 3s
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 / integration (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m16s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m24s
Desktop (Tauri) / Update manifest (push) Successful in 4s
M13 step 4 asked for no bold first line, and step 3 already delivered that — removing `note.title` took the card's <h3> and the Android editor's bold field with it. What step 4 also asked for, and hadn't been done, was the other half: "be willing to spend something small on legibility that isn't weight on the first line." The web card rendered the entire body. Android has always clamped to eight lines (`MAX_PREVIEW_LINES`), so one long note produced a card taller than the screen on the web and pushed the rest of the board off it — a real asymmetry between two surfaces that are supposed to be peers. It matters more without a title. The first line used to be what your eye caught; with one weight throughout, an unbounded card is just a wall, and the note beside it is the one you were actually looking for. Clamped in the STRING, not with CSS `line-clamp` — that needs a `-webkit-box` and behaves unreliably around the block elements MarkdownText emits (lists, quotes, fenced code). Doing it before the parse is deterministic, matches Android's semantics exactly, and skips parsing a body the card was never going to show. |
||
|
|
6f21db85a1 |
ci: an integration lane, so the migrations are finally run by something
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) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 25s
26 Alembic revisions and none had ever been executed by CI. `alembic upgrade head` ran for the first time when the operator's container started, and the schema the migrations build had never been checked against the models that read it. M13 dropped three columns and rebuilt a STORED GENERATED column with nothing watching but a server boot. Copied from FabledScribe's `integration` job, which had already solved the parts that are easy to get wrong — and which are family rules precisely because they were: a separator-free job key with no `name:` (act_runner derives the service container name from the truncated display name, and the discovery step filters `docker ps` by it), bridge-IP resolution because service hostnames aren't routable on this runner, and a Python readiness wait because `run:` is busybox sh with no `/dev/tcp`. `postgres:16-alpine` to match the production compose. The schema is built by real migrations, never metadata.create_all — that step IS the migration test. Six tests, each pinning something that has only ever been checked by hand: - an ORM insert against the migrated schema, which is the model/migration agreement nothing has verified until now; - `notes.title`, `notes.kind` and `note_revisions.title` are actually gone, and `note_links` with them — a silently no-op migration shows up here; - the rebuilt `search_vector` indexes both the name and the body, which matters because 0026 had to DROP and recreate a generated column rather than alter it; - a note keeps its body AND its items, the shape step 2 made normal; - `_apply_note_items` leaves items alone when a change doesn't mention them — the data-loss path step 2 removed, pinned so its return would be caught; - a note with no body is still named by its first item, the hole that made removing the title unsafe until checklists stopped being their own kind. Runs for visibility; does not gate the build, matching `test` and Scribe. No local equivalent: running it means standing up Postgres on the workstation, which rule 12 reserves for an explicit request. Documented in ci-requirements alongside the Rust, Kotlin and frontend gates. |
||
|
|
924ddb20db |
notes: saveEdit still asked for a title
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m10s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The one thing step 3 missed, and the typecheck lane caught it: `saveEdit`'s parameter type still declared `title`, so the editor's call — correctly no longer passing one — didn't match. I gated Rust locally and not the frontend. Both are now in ci-requirements, including WHY the frontend one has to be `npm run build` rather than `vue-tsc --noEmit`: the typecheck only reads the script block, so a malformed template sails past it and fails `vite build` in a different workflow, which is exactly how the stray `</div>` got two commits away from where it was written. |
||
|
|
95aa10c2c3 |
Remove the title field — a note is named by its first line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME stays — search results, export filenames and the command palette all need one — but nothing is typed into it any more. `display_title` is now the first non-empty line of the body, falling back to the first checklist item. That fallback is what step 2 bought, and the reason this could not go first: a checklist had no body to be named from, so the title was its only name. Now every note has a body, and a note that is only a checklist is named by its first item. Gone everywhere: the column and note_revisions.title (0026), the field on the core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7), `normalize_title`, the wire field, the FFI record and `NoteEdit::Title` / `ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and the Android title field in both the compose sheet and the editor. **The search vector had to be rebuilt, not just left alone.** `notes.search_vector` is a STORED GENERATED column whose expression names `title` — Postgres refuses to drop a column another generated column depends on. It is dropped and recreated over `display_title` at weight A, which keeps the original intent: a note's NAME ranks above the rest of its body. **An imported title becomes the note's first body line.** Keep notes carry one, and so does any ThoughtSync export taken before this. Dropping it would silently lose text someone wrote; folding it in puts it exactly where a name now lives, so the note arrives named as it was. Skipped when the body already opens with that line, so re-importing an export this code produced doesn't stack duplicates. Two smaller things fell out. The Android editor loses its bold first field — one weight throughout, because the first line is the note's name but not a different KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s justification comment moved to `ClearRemindAt`, which is now the surviving example of why NoteEdit is a list rather than a struct of options. Protocol note corrected to say what actually shipped: v2 is "no kind, no title", one bump for the pair. Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests all green before pushing. It caught four things — orphaned serde attributes where fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview still has one), nine retention fixtures inserting a dropped column, and four rustfmt diffs. |
||
|
|
6d778f26a7 |
Fix the ktlint and compat-test failures, and start using the Rust gate
Two more from the step-2 removals: **Two unused Kotlin imports** — `FilterChip` (the Note/List switch) and `Icons.Filled.Create` (the "switch to a note" icon), both orphaned when their callers went. ktlint treats them as errors. **`server_info_tolerates_unknown_and_absent_fields`** pinned `sync_protocol_version: 1` as a literal, so bumping the protocol to v2 made it fail for a reason that has nothing to do with what it tests. It is about unknown FIELDS; the versions now come from `CLIENT_PROTOCOL_VERSION`, like every other test in that file already did. The bigger fix is the habit. `ci-requirements.md` has documented since 2026-08-18 that the operator authorised running fmt/clippy/test against the CI image locally, and I had not been doing it. All three now pass here — 116 tests, clippy clean, fmt clean — and every Rust failure in this milestone so far would have been caught by them in under a minute instead of by CI, several commits downstream. Noted in ci-requirements so the next session doesn't relearn it: a removal is exactly the change that looks too safe to check. |
||
|
|
33e9278975 |
Fix three breaks the removals left behind
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m53s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m4s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m27s
**`snapshot_revision` was deleted with `create_titled`** ( |
||
|
|
c46a4a7709 |
A checklist is something a note has, not something a note is
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) Successful in 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 8s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 13s
Android / Kotlin + Rust (APK) (push) Failing after 1m43s
`kind` was never a type. A plain TEXT column with no enum and no CHECK behind
it, compared against a hardcoded ("text", "list") tuple in six places;
`note_items` was always an ordinary child table keyed by note_id; serialization
already emitted `items` whatever the kind; and the Android editor already
toggled between the two losslessly, saying so in a comment. The storage has
modelled "a body plus optional checkable items" the whole time. This deletes the
gates that forbade it.
Every surface: the create/PATCH gates, the ?kind= filter and its saved-filter
facet, the three import/export branches, the column (alembic 0025); the core's
`kind` field, its SQLite column (user_version 6), the sync wire, push and pull;
the FFI records and `NoteEdit::Kind`; and on Android `NoteKind.kt`, `DraftKind`,
the compose sheet's Note/List switch, and the branches in the card, the editor
and the chrome.
The editor's note⇄list toggle becomes "Add a checklist" — on both the web and
Android. It is not a conversion any more: nothing moves, nothing is swapped, the
body stays exactly where it is and the note gains somewhere to put items. The
card renders both, in order.
Two things that fell out of the merge rather than being aimed at:
- The Keep importer was DISCARDING `textContent` whenever a note also had
`listContent`, because the target could only hold one. Both survive now, and
the test says so.
- Markdown export wrote the body OR the checklist. It writes both.
Protocol goes to v2, floor included: dropping a field a v1 client sends and
expects back is breaking. `title` leaves in step 3 and lands in the same
generation, so it needs no further bump. This is the change that will make the
0.1.227 build on the operator's phone refuse to sync — the in-app updater is
independent of the handshake and remains the recovery path.
The V1 SQLite schema deliberately KEEPS the kind column. V1 is the historical
schema and every later block alters it, so removing it there would make a fresh
database run V1 without the column and then v6's DROP COLUMN against a column
that never existed — "no such column: kind" on every new install.
|
||
|
|
229076c82d |
sync: stop deleting a note's checklist items because it isn't a "list"
`_apply_note_items` didn't ignore items on a non-list note — it deleted them. That was survivable only because nothing in the product could produce a note holding both a body and items. M13 makes exactly that the normal shape: a checklist is something a note HAS, not something a note IS. Against that shape this guard is a data-loss path — the first sync after adding a checklist to a note would wipe it. Landing it before the UI that can create the state, so there is never a window where the two disagree. `kind` itself, and the rest of the merge, follow. |
||
|
|
ad21eac5bc |
editor: drop the adapter import that went with backlinks
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 27s
CI & Build / Build & push image (push) Failing after 22s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 30s
Desktop (Tauri) / Update manifest (push) Skipped
`repo` reached the editor for exactly two calls — `repo.notes.backlinks` and `repo.notes.linkSearch` — and both left with the linking system. vue-tsc runs with noUnusedLocals, so one stale import failed the whole shared-frontend build and took both desktop lanes down with it (TS6133). My local sweep checked for dangling *references*; it never checked the inverse, that every import still has one. It does now, across all fifteen files that removal touched — `repo` was the only one. |
||
|
|
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
|
||
|
|
982d24c83b |
links: bind a [[link]] to a note, not to a string
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 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s
A wiki-link was stored only as normalized TEXT, so a note's NAME was the edge. Renaming it broke every inbound link — and the fix that shipped for that (task 1848, option b) was `_rename_inbound_links`: rewrite the `[[Old Name]]` text inside the body of every note that linked to the renamed one. That works while an explicit title exists to hold still. It stops being defensible the moment a note's name is just its first body line, which is where M13 is going: fixing a typo in your opening sentence would silently edit other notes' words, with nothing to opt out to. So this lands first, before the title comes out, and that window never ships. `note_links` gains `target_id`, bound when the link is written. `target_norm` stays and is what an UNRESOLVED link carries — linking to a note that doesn't exist yet is a supported way to create one, so a link has to be able to name a target that isn't there. Resolution reads the id, falling back to the name only where nothing was bound, which is what lets a forward link connect the moment its target appears. `_claim_unresolved_links` then binds it, so the fallback is a transitional state rather than a permanent one. `_rename_inbound_links` and `rewrite_link_title` are gone. What replaced them touches link rows only: a note's text is never modified by something happening to a different note. The client can no longer resolve links for itself, and that is the point. It used to look `[[text]]` up in a client-side name index, which only held together BECAUSE renaming rewrote the text everywhere. Now the written text can name something the target is no longer called, and only the server holds the binding — so each note serializes its resolved links (`norm`, `id`, and the target's name as it stands NOW). A renamed note reads correctly everywhere it is linked from, without a single body having been edited. Unresolved links are simply absent and fall through to the create-on-click affordance that already existed; so does the offline desktop store, which derives links at query time and has no binding to send. The name-fallback join is owner-scoped everywhere it appears. Bound ids were resolved owner-scoped when written, but matching on display_title alone would have let two users who each have a note called "Groceries" see the other's id and name through an unresolved link (rule 47). The new behaviour is all SQL and this suite runs without a database, so the dead helpers' tests are removed rather than replaced. This repo has no integration lane to hold that ground — noted, not papered over. |
||
|
|
bacedea8a3 |
tests: seed the throttle counters on the clock the routes actually read
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 19s
The three route tests stamped their pre-loaded hits at t=0..9 through the injected clock, then called a route that reads `time.monotonic()`. Against a trailing window those hits are fifteen minutes stale on arrival, so they were pruned before they could refuse anything, the request carried on to the database that this suite doesn't have, and the assertion read `500 == 429`. The window's own tests keep the injected clock — they pass the same one to both sides, which is what makes them deterministic and instant. Only the tests that hand off to a route need the real one. |
||
|
|
b6152ec18b |
server: harden the surfaces a public deployment leaves exposed
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Failing after 11s
CI & Build / Build & push image (push) Successful in 33s
On a LAN the login form is reachable by people you already trust. Exposed, it is reachable by everyone, and nothing in front of it was counting. Three credential routes — /login, /register and /device-login — now throttle. Every attempt is counted against BOTH the account and the calling address, and either can refuse it. The account key is the one that matters and the one that cannot be forged: it stops stuffing against a known email no matter how many addresses the attempts arrive from. The address key bounds one source spraying many accounts, and is best-effort by nature — behind a proxy it comes from X-Forwarded-For, which a caller can set to anything if the app is exposed directly. That is exactly why it isn't the only key. The check runs BEFORE the password is verified, which is the other half of what this protects. bcrypt is deliberately slow; an unauthenticated caller who can trigger it without limit has a CPU exhaustion primitive as well as a guessing one. Sliding rather than fixed windows, because a fixed one lets twice the limit through across a boundary. Bucket count is capped so a rotating forged header can't turn the limiter into the exhaustion it prevents. A sign-in against an email with no account now spends a real bcrypt against a throwaway hash first. Without it "no such account" returned in microseconds while a wrong password took ~100ms, which is a reliable oracle for which emails are registered here. Every response carries a CSP with script-src 'self', object-src 'none' and frame-ancestors 'none', plus nosniff, a referrer policy and a permissions policy. The app has no inline and no third-party scripts, so this concedes nothing; the exceptions are honest — inline STYLE (Vue writes it itself for v-show and the FLIP), and remote images (a link preview renders the og:image of an arbitrary host, over either scheme, since a LAN install is served over http). HSTS only where the request already arrived over TLS, and scoped to the one host: no includeSubDomains, no preload, neither of which is this app's to commit. X-Forwarded-Proto detection moved into one `_is_https()` — the session cookie's Secure flag and HSTS are the same question, and answering it twice is how the two drift apart. docs/public-hosting.md is the rest of it: the four things only the operator can do (close registration, terminate TLS and forward the scheme, stop publishing the app port, back up the attachment volume as well as the database), and an honest list of what the app does NOT have — no email verification, no password reset, no second factor, no per-user quota, no audit log. Those aren't blockers for an instance whose accounts are people you know. They're the reason not to leave signups open to strangers. |
||
|
|
16f86bef93 |
web: make the board usable on a phone, not just reachable
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 34s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m6s
Desktop (Tauri) / Update manifest (push) Successful in 6s
The controls a card carries were always-visible overlays on a touch device — correct as far as it went (task 2697: a finger cannot hover, and the pill is the only way to pin or archive), but they were still absolutely positioned, so they sat ON the note's own title. A card reading "thought sync tauri app" rendered as "ught sync tauri app" with the grip parked over the first three characters, and the four-icon pill covering the right half of the first line. Placement is now CSS's decision. One element each, two placements: where a pointer can hover they lift out of flow into the floating top-corner pills they have always been; where nothing can hover they stay in flow as a footer row, which cannot overlap anything by construction. Keyed on hover rather than width, for the same reason `.hover-reveal` already is — a narrow window on a laptop still hovers, a wide tablet still doesn't. The colour popover moved inside the action set so it follows it, and opens into the card from either end. The header was sharing one phone-width row between a menu button, the logo, the lens name, a search field and four icons; everything in it was truncated, the lens down to "N…" and the search box to an empty pill. It wraps now, so search takes its own line below sm, and account / settings / sign-out move into the drawer where there is room to name them rather than guess at a glyph. One input, moved by CSS — duplicating it would have meant two `searchInput` refs and a `/` shortcut that focuses the wrong one. Also closes the other half of task 2706, which was waiting on a device to look at: `viewport-fit=cover` together with the `env(safe-area-inset-*)` padding that makes it safe (sides on body, top on the sticky header, bottom on the board and the drawer), and `100dvh` behind an @supports so the app box follows the visual viewport when the keyboard opens instead of the layout viewport. Both halves in one change, as that task insisted. And the composer no longer tells a phone to "Press Enter". |
||
|
|
81695fa0c8 |
android: update the app from the server it syncs with (2727, M12 step 7)
Closes M12. The phone can now notice that its server has a newer build and install it, instead of the operator copying an APK to a device by hand. **A PackageInstaller session, not an install intent.** The obvious route — ACTION_VIEW on the APK — is exactly what on-device install heuristics are tuned against, and it is what produced the "bypassing Android security" warning on Minstrel (Scribe note 2437). It also never tells the OS that this app is the legitimate updater of its own package, and it returns nothing: a failed install is indistinguishable from someone dismissing the dialog. The session says who is doing what, and on Android 12+ declares no user action required — which, with UPDATE_PACKAGES_WITHOUT_USER_ACTION, removes the confirmation entirely on the UPDATE path. Only there: Android will not let an app quietly put a NEW package on a device, which is right. It also only applies when the new build carries the same signing key as the installed one, which is why signing had to land first. Two things from that research deliberately NOT done: `setRequestUpdateOwnership` was chased and turned out to be a red herring, and REQUEST_INSTALL_PACKAGES is not the differentiator either — Mihon declares it too. The mechanism was the whole difference. **The outcome comes back.** `commit` takes an IntentSender and the result lands at `UpdateReceiver`, so a failure can be shown rather than guessed at, and STATUS_PENDING_USER_ACTION is handled — that is the ordinary path below API 31 and still possible above it, since the OS is entitled to ask anyway. Someone declining is reported as no error at all: calling a deliberate choice a failure is how an app sounds broken when it is not. **The network work stays in Rust.** Two FFI additions — `clientUpdate` and `downloadClientUpdate` — because the device token lives in the core, and pulling it into Kotlin to make an HTTP call would spread the one secret this app holds across two languages for nothing. The core also owns the comparison, so the rule "version CODE decides, never the name" lives in the layer that has to get it right for every surface. The download is streamed to disk, not buffered: 55 MiB in memory on a phone is how an update gets killed halfway through. It lands in `update.apk.part` and is renamed only once size and sha256 both match, so an interrupted download can never be mistaken for a finished one. The digest is not a trust anchor — the signature is, and Android checks it — but it catches a truncated transfer before the installer is bothered with it. The advertised path is joined to the base URL this device is LINKED to rather than followed as given, so a server cannot point the download at a host nobody agreed to. **Updates are linked-only, and it says so.** An unlinked install has no update path, so it gets one sentence explaining where updates come from rather than a Check button that silently finds nothing — the same lesson as the desktop's unlink copy (issue 2110). And the "install unknown apps" grant is asked for BEFORE downloading, so nobody spends 55 MiB to be told no. Every Android API here was read out of `android-36/android.jar` with javap first, and the two new FFI methods out of freshly generated bindings, rather than recalled: `suspend fun clientUpdate(installedVersionCode: Long): ClientUpdate?` and `downloadClientUpdate(destPath: String)`. Also fixes `check-symbols.py`, which reported four false positives on `UpdateOutcome.Result` — its object-member index collected functions and properties but not nested TYPES, and a data class inside an object is an ordinary member. |
||
|
|
0cf77336d4 |
ci: build the server image after the Android lane, not alongside it
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 10s
Android / Kotlin + Rust (APK) (push) Successful in 7m19s
Baking the newest client into every image left two holes, both raised by the operator. **An Android-only push never rebuilt the image.** `ci.yml` does not trigger on `android/**`, so a new APK could be published and no image would ever pick it up until some unrelated server change came along. **A push touching both raced.** Both workflows start at once; the image build would fetch the PREVIOUS client and there would be no second build to correct it — `:<sha>` is the immutable rollback unit (rule 46), so rebuilding it with different content would make it neither immutable nor a rollback unit. Ordering now runs the other way: the Android lane finishes, then calls the image build. `ci.yml` gains a `gate` job that stands down on any push touching the Android app, and `android.yml` dispatches `ci.yml` when it is done. One image per commit, containing the client from that commit. Cases: - **server only** — ci builds immediately; the newest published client is already the right one. - **Android only** — ci does not trigger at all; the Android lane dispatches it afterwards. - **both** — ci's push run stands down, the Android lane dispatches it. Exactly one image. - **tag** — always builds. The Android lane does not run on tags, so waiting for a call that never comes would mean a release tag with no image. The dispatch is `always()`, so a FAILED Android build still lets the server image through with the previous client. The alternative is a broken Android lane silently blocking server delivery, which is a worse failure than a slightly old APK. Two details that would each have made this quietly wrong: The gate diffs the whole PUSHED RANGE (`event.before..HEAD`, full fetch), not `HEAD^..HEAD`. A three-commit push whose Android change sat in the first would otherwise have looked Android-free and raced anyway — silently, which is the worst version of this bug. The dispatch is `curl -fsS`, not `|| true`. If that call ever stops working the symptom is server images silently never being built for Android pushes, which nobody would notice until wondering why the app stopped updating. The gate's path list has to match android.yml's trigger, and two places holding one decision is the recurring failure in this repo (issues 2181-2183). It is a `git diff` rather than a config precisely so the decision is visible in the log, and both sides carry a comment pointing at the other. |
||
|
|
010e9a2f85 |
server: bake the newest Android client into every image (operator call)
Reverses the placement decision made an hour ago. That one put the APK only on the data volume, reasoning that ~55 MiB should not be charged to installs that never touch Android. The operator's call is that ending the manual copy is worth the megabytes, and it is their deployment. CI now fetches the newest published client into the build context immediately before the image build, so `:dev`, `:latest` and `:<version>` all ship one and a `docker compose pull` delivers a new server and a new client together. **Always the rolling `dev` release — the newest build there is.** A versioned image therefore carries the newest client rather than one pinned to that version. Deliberate: the two negotiate a sync protocol version before linking, so a mismatch is caught by the handshake, and pinning would buy nothing the handshake does not already provide. **Fetched by the JOB, never by the Dockerfile.** The release is private, and a token used inside a build ends up in the context or a layer. **It cannot fail the image build.** No release yet, a network blip, a first-ever build — all of them log a warning and produce an image with no client, which is a state the server already supports. Half a pair is cleaned up rather than shipped: a sidecar without its APK is worse than neither, because the server would be describing something it cannot serve. **The volume still wins.** `DATA_DIR/client/` is checked first and the baked copy second, so an operator who deliberately drops a build in gets that build — and a BROKEN drop-in falls through to the image's copy rather than taking the feature offline, which is what makes the copy-order advice survivable instead of load-bearing. Three tests cover the precedence, including that last case. The baked copy lives inside the package, not under DATA_DIR: that path is a volume mount, and anything the image wrote there would disappear behind it the moment one is attached. `client/.keep` is tracked so `COPY client/` cannot fail on a tree where the CI step never ran; the artifacts themselves are gitignored, since a 55 MiB binary does not belong in git history and is re-fetched on every build anyway. |
||
|
|
43ebb6eceb |
packaging: the rolling-release prune was eating the Android client
Run 4092 published `thoughtsync.apk` to the `dev` release. Run 4098 removed it, four minutes later, and both runs were green. `write-manifest.sh` prunes the rolling channel to stop ~100 MB AppImages accumulating forever, keeping `latest.json` and anything whose name contains the current `$APP_VERSION`. The Android assets deliberately have no version in their names — a fixed name is the only addressable URL on a tag that never moves, which is the entire reason the `dev` release exists — so they matched neither rule and were swept. They would have been swept even if they HAD carried a version: Android is a different workflow with its own run number, so its version never equals the desktop's `$APP_VERSION` in this script. The keep-list is now about fixed names rather than about `latest.json` specifically, which is what the rule always meant. A fixed-name asset is self-limiting — each publish replaces the same name — so the accumulation this prune exists to prevent cannot happen to one. Worth noting how this presented: two green runs and a missing file. Nothing failed, and the only way to see it was to ask the release what it actually held rather than trusting that a step named "Publish" had published. |
||
|
|
e6da720e6b |
packaging: drop assets that aren't there, instead of trusting nullglob
`d77a798` added the Android client to publish-release.sh's asset list and broke the desktop lane's publish, which had been working (run 4094, curl exit 26 — "couldn't read local file"). The Android lane published fine, which is what made the shape of the mistake clear. `shopt -s nullglob` drops PATTERNS that match nothing. The two entries I added — `android/dist/thoughtsync.apk` and its sidecar — contain no wildcard, so they are not patterns at all: globbing leaves them in the array verbatim and curl is handed a path to a file that does not exist. In the Android job those files are there, so it worked; in the desktop job they never are, so it did not. Every entry is now filtered on existence, which is what the array has always meant. That covers the literal paths and the globs alike, rather than relying on each future entry containing a `*` to be safe — the trap that just cost a run. Verified both ways before pushing: a literal missing path survives nullglob and is removed by the filter, and an all-empty result still exits cleanly under `set -u`. |
||
|
|
d77a79859c |
server: hand out the Android client this server syncs with (2726)
CI & Build / Python tests (push) Successful in 11s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 5m32s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 8m8s
A self-hoster should not need an account on someone else's forge to get the app
for their own notes. The Fabled-Git instance is private — which is why
`install.sh` already cannot fetch for anyone but the operator — so a release page
is no use as a distribution point. The server holding the notes is something the
person already trusts and already reaches.
It also keeps the pair in step by construction. Client and server negotiate a
sync protocol version before linking, so a server that also serves the client
cannot hand out a phone it is unable to talk to.
**Two files, and both must be present**: `thoughtsync.apk` and a
`thoughtsync-android.json` sidecar carrying `{version_name, version_code, size,
sha256}`. The sidecar exists because an APK keeps its version in a binary AXML
manifest, which Python cannot read and which is not worth putting `aapt` on a
Quart server to reach. CI writes it beside the APK, where the values are already
known — including the digest, computed over the same bytes it uploads, so a
phone can tell a truncated download from a complete one before handing it to the
installer. Not a trust anchor; the signature is that.
**Under DATA_DIR, not baked into the image.** Baking charges ~55 MiB to every
self-hoster including everyone who never touches Android. `/var/thoughtsync` is
already the mounted volume that holds attachments, so a build dropped there
survives container recreation.
**Absence is an ordinary state, not an error.** No APK means the key is absent
from `/api/config` — absent rather than null, so a client testing for it cannot
confuse "this server has no client" with "this server predates the field" — the
web UI hides the card instead of offering a button that 404s, and the metadata
route answers 404. A server whose owner does not use Android is not misconfigured.
**A mismatched pair also counts as no client.** If the sidecar's recorded size
does not match the file on disk, the two did not arrive together; serving one
build while advertising another is worse than serving none, because the phone
would compare versions against a promise the bytes do not keep. That makes the
copy order in docs/android-distribution.md load-bearing, and it is written down
there: APK first, sidecar last.
**The version is public, the bytes are not.** An updater has to be able to ask
"is there something newer?" cheaply and before it has done anything; 55 MiB is
not for anyone who can reach the port. `login_required` already accepts either a
session cookie or a device bearer token, so the browser and a linked phone both
work with no second auth path.
The Android lane now publishes both files to the same rolling `dev` release the
desktop bundles use, reusing `publish-release.sh` — its nullglob asset list was
already built for several jobs in separate workspaces publishing to one release,
which is exactly this. Signed builds only: publishing an unsigned APK would offer
people something they cannot install over what they already have.
Nine tests, DB-free like the rest of the suite — this lane runs no Postgres, so
the advertisement is asserted through `advertisement()` rather than through
`/api/config`, whose other half needs a database. Both routes ARE exercised,
because neither opens a session.
|
||
|
|
6589be2b0f |
android: unit tests are a debug-only task, and the artifact name says variant
Android / Kotlin + Rust (APK) (push) Successful in 6m52s
Run 4082: `Task 'testReleaseUnitTest' not found`. AGP creates unit-test tasks only for `testBuildType`, which is debug — so pairing the test task with the packaged variant was wrong from the start. It was only paired to stop two Gradle invocations asking for different Cargo profiles and paying the four-minute cross-compile twice. With the profile pinned to debug (#2810) that reason is gone, so the step goes back to `testDebugUnitTest` unconditionally. Costs one extra Kotlin compile and buys the type-check on the variant an emulator build would actually use. Also: the artifact was named from the Cargo profile, which is now always "debug" — so a signed release APK would have been uploaded as `thoughtsync-android-debug-<sha>`. Same word, two different things. It is named from the APK's variant now, and the two outputs are kept separate so they cannot be confused again. |