AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
34 changed files with 300 additions and 384 deletions
Showing only changes of commit 01b53118b9 - Show all commits
+1 -1
View File
@@ -1,7 +1,7 @@
# Rust workspace. The framework-free client core, and the two shims that wrap it:
# the Tauri desktop app and the uniffi bindings the native Android client loads.
# Neither shim owns the core — that is the reason it is a crate at all rather than a
# module inside the desktop app (Scribe note 2730).
# module inside the desktop app.
[workspace]
resolver = "2"
members = ["core", "desktop/src-tauri", "android/ffi", "android/bindgen"]
+8 -9
View File
@@ -9,15 +9,15 @@ edition = "2021"
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# Local-first store (M10.4): bundled = compile SQLite in, so there's no system
# Local-first store: bundled = compile SQLite in, so there's no system
# libsqlite dependency to vary across the AppImage / native / Windows / Android builds.
# functions = the `fold` SQL function tag names are compared and indexed by (#5385):
# functions = the `fold` SQL function tag names are compared and indexed by:
# SQLite's own lower() folds ASCII only, and the server folds every letter.
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
uuid = { version = "1", features = ["v4"] }
# RFC3339 timestamps for created_at/updated_at/remind_at (Date.parse-able on the JS side).
chrono = { version = "0.4", default-features = false, features = ["clock"] }
# HTTP for the opt-in server handshake (M10.6) and the sync engine (M10.7).
# HTTP for the opt-in server handshake and the sync engine.
#
# native-tls, NOT rustls, deliberately: on x86_64-pc-windows-msvc native-tls resolves
# to `schannel` — pure-Rust bindings to the OS TLS stack — so nothing C or assembly
@@ -43,12 +43,11 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"]
# Without this the build dies at `openssl-sys`: "Could not find directory of
# OpenSSL installation".
#
# `vendored` compiles OpenSSL from source with the NDK toolchain. The alternative
# was rustls on Android only, which builds faster — but rustls ships its own root
# store, so the phone would trust a DIFFERENT set of certificates than the desktop
# does. A self-hosted server behind a private or enterprise CA would then work on
# one surface and fail on another, and "the surfaces behave the same" is worth more
# than build minutes.
# `vendored` compiles OpenSSL from source with the NDK toolchain. Not rustls on
# Android, though it builds faster: rustls ships its own root store, so the phone
# would trust a DIFFERENT set of certificates than the desktop does, and a
# self-hosted server behind a private or enterprise CA would work on one surface
# and fail on another.
#
# Declared as a direct dependency purely to turn the feature on: cargo's feature
# unification applies it to the copy `native-tls` pulls in transitively.
+1 -4
View File
@@ -2,10 +2,7 @@
//!
//! Deliberately free of any UI framework. The desktop wraps it in Tauri commands;
//! the Android client binds it through uniffi. Neither owns it, and a change to
//! either must not require touching this crate — that separation is the whole point
//! (see Scribe note 2730). It was already true before the split: every file here
//! carried zero Tauri references, which is what made the extraction a move rather
//! than a rewrite.
//! either must not require touching this crate — that separation is the whole point.
//!
//! - `local` — the source of truth. Works with no server and no account.
//! - `sync` — entirely opt-in. Nothing in it runs until a server is linked.
+20 -27
View File
@@ -6,7 +6,7 @@
//! letters, digits, `_` and `-`. On save these become labels attached with
//! `via_tag = true`. The server and the web run the same cases
//! (core/testdata/grammar.json).
//! - `- [ ] item`: a checklist item. The body IS the checklist (M304) — there is no
//! - `- [ ] item`: a checklist item. The body IS the checklist — there is no
//! table of items beside it, so a list can sit between two paragraphs instead of
//! only after them.
//!
@@ -17,9 +17,6 @@
//! and the rewriters below are how one is edited.
//!
//! Dedupes case-insensitively, preserving first-seen order.
//!
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
//! capture-and-recall surface, and a linking system is organization.
/// Every `#tag` in ONE line, as `(start, end, name)` in char indices.
///
@@ -31,10 +28,9 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
let mut i = 0;
while i < chars.len() {
if chars[i] == '#' {
// Start of line or after whitespace, and nothing else. Any non-tag
// character used to count, which made `(#todo)` a tag here and plain
// text on the server, and made the `/#section` of a pasted URL a label.
// Whitespace is the rule all three implementations now share (#5166).
// Start of line or after whitespace, and nothing else, so `(#todo)` and a
// pasted URL's `/#section` are not tags. All three implementations share
// this rule.
let boundary = i == 0 || chars[i - 1].is_whitespace();
// A tag must start with a letter (so "#1" or a bare "#" is not a tag).
if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
@@ -54,8 +50,8 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
/// One `#tag` and exactly where it sits, for a renderer drawing the body itself.
///
/// The card no longer prints a chip for a tag whose text is still in the note — it
/// colours the token where it was typed instead. To do that a renderer needs the
/// The card colours a tag where it was typed rather than repeating it as a chip.
/// To do that a renderer needs the
/// SPAN, not just the name, and asking it to find the name again would be a second
/// grammar quietly disagreeing with this one about what `##a` or `#1` is.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -142,8 +138,8 @@ fn collapse_blank_runs(text: &str) -> String {
/// else is left exactly as written.
///
/// The MIRROR of `split_body_tags` in the server's `notes/tags.py`, and it has to stay
/// one: a note lifted differently here than there would change under the operator the
/// moment it synced. Same discipline, and the same reason, as `DerivedTint`.
/// one: a note lifted differently here than there would change the moment it synced.
/// Same discipline, and the same reason, as `DerivedTint`.
///
/// The conservative reading of "standalone" is deliberate. A trailing tag is
/// ambiguous and the text does not say which it is — `buy milk #grocery` is filing,
@@ -152,8 +148,8 @@ fn collapse_blank_runs(text: &str) -> String {
///
/// `standalone` tags become ORDINARY labels (`via_tag = 0`): nothing is left to derive
/// them from, so the row becomes the record and the chip's × becomes the way to remove
/// one. `inline` tags stay derived exactly as before. That is what `via_tag` means from
/// here on — backed by text still in the body.
/// one. `inline` tags stay derived. That is what `via_tag` means: backed by text still
/// in the body.
pub fn lift_standalone_tags(body: &str) -> (Vec<String>, Vec<String>, String) {
let mut standalone: Vec<String> = Vec::new();
let mut inline: Vec<String> = Vec::new();
@@ -220,7 +216,7 @@ fn is_tag_char(c: char) -> bool {
/// How two tag names compare: equal once every letter is lowercased, accented ones
/// included, so "Café" and "CAFÉ" are one tag. The server's `labeling.named` folds
/// the same way (Postgres `lower`, Python `str.lower`), and the store's `fold` SQL
/// function is this (#5385).
/// function is this.
pub(crate) fn fold(name: &str) -> String {
name.to_lowercase()
}
@@ -248,8 +244,7 @@ fn push_unique(out: &mut Vec<String>, candidate: &str) {
// parse it would make a half-typed list stop being a list.
/// A checklist item, as found in the body. Its position in the returned vector is
/// its identity — the same thing `position` meant when these were rows, and all the
/// wire ever carried (`push.rs` sent text and checked, never an id).
/// its identity; the wire carries text and checked, never an id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DerivedItem {
pub text: String,
@@ -361,9 +356,8 @@ pub const DISPLAY_TITLE_CAP: usize = 200;
/// twice, and they have to agree or a synced note is called different things on either
/// side of the wire. Both run the `display_titles` cases in testdata/grammar.json.
///
/// It no longer needs the items, because the items ARE lines of the body now (M304).
/// What it needs instead is to strip the task marker off: a list-only note is still
/// named by its first item, and calling that note "- [ ] milk" would be showing
/// The items ARE lines of the body, so it strips the task marker off: a list-only
/// note is named by its first item, and calling that note "- [ ] milk" would be showing
/// someone the storage rather than the note. An empty item is skipped rather than
/// naming the note "", which is what a half-typed list would otherwise do.
pub fn display_title(body: &str) -> String {
@@ -431,9 +425,8 @@ pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String {
///
/// Spaced exactly as `import_export.py:_note_markdown` writes a list — a blank line
/// between prose and the list, and nothing between consecutive items. That is not
/// cosmetic: the server migration folds existing rows into bodies using the same
/// layout, so an export taken before the migration and one taken after have to
/// agree byte for byte.
/// cosmetic: the migrations that fold checklist rows into bodies use the same layout,
/// so every surface lands on byte-identical bodies.
///
/// `checked` is a parameter rather than always false because the two migrations that
/// fold existing rows into bodies have to carry the state those rows were in. A new
@@ -513,8 +506,8 @@ mod tests {
// ── lifting standalone tags ──────────────────────────────────────────────
//
// The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case.
// A note lifted differently here than there would change under the operator the
// moment it synced, so these are the cases that file agrees to.
// A note lifted differently here than there would change the moment it synced,
// so these are the cases that file agrees to.
#[test]
fn lifts_a_line_that_is_nothing_but_tags() {
@@ -662,8 +655,8 @@ mod tests {
#[test]
fn append_spaces_like_the_exporter() {
// Prose then a blank line then the list — byte-for-byte what
// import_export.py:_note_markdown writes, which is what the server
// migration will fold existing rows into.
// import_export.py:_note_markdown writes, which is the layout the server
// migration folds rows into.
assert_eq!(append_item("a note", "milk", false), "a note\n\n- [ ] milk");
// Nothing between consecutive items.
let one = "a note\n\n- [ ] milk";
+1 -3
View File
@@ -73,9 +73,7 @@ pub fn open(path: &Path) -> rusqlite::Result<Db> {
/// Open a migrated, in-memory store.
///
/// Exists so a CONSUMER can test against a real schema without taking a rusqlite
/// dependency of its own just to build a `Db` — which is exactly what the desktop
/// crate was doing before the core was extracted. The Android bindings will want the
/// same thing.
/// dependency of its own just to build a `Db`.
pub fn open_in_memory() -> rusqlite::Result<Db> {
Ok(Db(Mutex::new(memory_conn()?)))
}
+2 -2
View File
@@ -45,7 +45,7 @@ pub struct Note {
pub previews: Vec<LinkPreview>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
/// How this account holds the note (#5175): `owner`, or `edit`/`view` for one
/// How this account holds the note: `owner`, or `edit`/`view` for one
/// someone shared with it. Named and valued as the server's, so the shared
/// frontend gates the editor identically either way.
pub permission: String,
@@ -119,7 +119,7 @@ pub struct Label {
#[serde(skip_serializing_if = "Option::is_none")]
pub count: Option<i64>,
/// The newest `updated_at` among the untrashed notes carrying it, or None when
/// nothing does. The drawer keeps the few most recent tags in reach (#5931).
/// nothing does. The drawer keeps the few most recent tags in reach.
pub last_used: Option<String>,
}
+2 -3
View File
@@ -1,4 +1,4 @@
//! Trash retention for a device with no server (M11.3).
//! Trash retention for a device with no server.
//!
//! The server owns this policy whenever there IS one: a linked client learns about
//! every permanent deletion from the delta feed, as a tombstone, and does exactly
@@ -123,8 +123,7 @@ mod tests {
fn a_note_just_inside_the_window_survives() {
// The comparison is STRICTLY older than the cutoff, so a note with a minute
// of its 30 days still to run is kept. An exact tie isn't testable against a
// wall clock — the sweep reads `now` microseconds after the row is stamped,
// which is precisely how the first version of this test failed.
// wall clock — the sweep reads `now` microseconds after the row is stamped.
let conn = db();
let almost = Duration::days(30) - Duration::minutes(1);
trashed_note_aged(&conn, "boundary", almost);
+35 -44
View File
@@ -112,13 +112,11 @@ CREATE TABLE sync_state (
INSERT INTO sync_state (id) VALUES (1);
"#;
// v2 (M10.7c): local tombstones.
// v2: local tombstones.
//
// A permanent delete previously just dropped the row, which left NO record that it
// ever existed. Offline, that means the delete can never be pushed — and the next
// pull would faithfully resurrect the note from the server. A deletion that undoes
// itself is about the worst outcome sync can produce, so deletes are now recorded
// here until they've been acknowledged by the server and cleared.
// A permanent delete is recorded here until the server acknowledges it. Dropping the
// row alone leaves no record that it existed, so an offline delete could never be
// pushed and the next pull would resurrect the note.
const SCHEMA_V2: &str = r#"
CREATE TABLE pending_deletes (
entity TEXT NOT NULL, -- 'note' | 'label'
@@ -128,7 +126,7 @@ CREATE TABLE pending_deletes (
);
"#;
// v3 (M10.7e): when the last successful sync finished.
// v3: when the last successful sync finished.
//
// The cursor alone can't answer "is this up to date?" — it's a revision watermark,
// not a time, and it doesn't move at all when a sync legitimately finds nothing new.
@@ -137,24 +135,21 @@ const SCHEMA_V3: &str = r#"
ALTER TABLE sync_state ADD COLUMN last_sync_at TEXT;
"#;
// v4 (M11.3): WHEN a note was trashed.
// v4: WHEN a note was trashed.
//
// The table only ever recorded THAT a note was trashed, which is enough to draw a
// Trash view and nothing else. Retention needs an age: without a timestamp there is
// no way to tell a note trashed this morning from one trashed last spring, so an
// offline device could never expire its own trash — and the UI couldn't warn anyone
// before it did.
// Retention needs an age: without a timestamp there is no telling a note trashed this
// morning from one trashed last spring, so an offline device could never expire its
// own trash, and the UI couldn't warn anyone before it did.
// It also records the LINKED server's retention window, captured from /api/config.
// Once linked, the server's policy is the one that actually applies, so showing this
// device's offline default would put a countdown on screen that doesn't match what
// happens — a wrong deadline is worse than none.
// Once linked, the server's policy is the one that applies, so showing this device's
// offline default would put a countdown on screen that doesn't match what happens.
const SCHEMA_V4: &str = r#"
ALTER TABLE notes ADD COLUMN trashed_at TEXT;
UPDATE notes SET trashed_at = updated_at WHERE trashed = 1;
ALTER TABLE sync_state ADD COLUMN server_retention_days INTEGER;
"#;
// v5 (M10.9): small key/value app preferences.
// v5: small key/value app preferences.
//
// The first entry is the update channel, which is neither note data nor part of the
// server link — so it belongs in neither `notes` nor `sync_state`. Generic on
@@ -166,17 +161,16 @@ CREATE TABLE prefs (
);
"#;
// v6 (M13 step 2): `kind` is gone. A checklist is something a note HAS, not something
// a note IS — the column was a mode flag with no enum and no constraint behind it,
// and `note_items` was never tied to it. Dropping it loses nothing: a note that was
// 'list' keeps every one of its items.
// v6: drops `kind`. A checklist is something a note HAS, not something a note IS —
// the column was a mode flag with no enum and no constraint behind it. A note that
// was 'list' keeps every one of its items.
//
// SQLite has supported DROP COLUMN since 3.35 (2021); rusqlite bundles well past it.
const SCHEMA_V6: &str = r#"
ALTER TABLE notes DROP COLUMN kind;
"#;
// v7 (M13 step 3): the title field is gone. A note is a body plus optional items, and
// v7: drops the title field. A note is a body plus optional items, and
// its NAME is the first non-empty line of that body, falling back to its first item —
// derived at read time, never stored (see store::display_title).
//
@@ -186,7 +180,7 @@ ALTER TABLE notes DROP COLUMN title;
ALTER TABLE note_revisions DROP COLUMN title;
"#;
// v8 (M304): `checklist_items` is gone. The body IS the checklist — a `- [ ] milk`
// v8: folds `checklist_items` into bodies. The body IS the checklist — a `- [ ] milk`
// line is the item — so a list can sit between two paragraphs instead of only after
// them, which a side table could never express no matter how it was styled.
//
@@ -251,13 +245,12 @@ fn migrate_v8(conn: &Connection) -> rusqlite::Result<()> {
Ok(())
}
// v9 (M315): `notes.color` is gone. A card is one neutral surface now and colour lives
// only on a tag, so the column was written by a picker nothing read and read by nothing
// at all. `labels.color` is untouched — that is the colour that survived.
// v9: drops `notes.color`. A card is one neutral surface and colour lives only on a
// tag; `labels.color` is untouched.
//
// The saved-filter sweep is the second half and not optional. `params` is opaque JSON
// and a stored view could carry `"color": "teal"`; with the facet gone that key would
// sit there forever, and a view that silently filters on a field the app no longer has
// sit there forever, and a view that silently filters on a field the app doesn't have
// is worse than one that visibly lost a criterion. Guarded on `json_valid` because a
// corrupt blob must keep whatever it holds, not become NULL.
//
@@ -275,12 +268,11 @@ UPDATE saved_filters
AND params LIKE '%"color"%';
"#;
// v10 (#5168): an attachment can be created on THIS device.
// v10: an attachment can be created on THIS device.
//
// Until now every attachment row arrived on the delta feed, so every one was already on
// the server. A file attached here, offline, is not, and `uploaded = 0` is the queue
// push drains once the note has landed. The rows already here all came from the
// server, which is why the default is 1.
// A file attached here, offline, is not on the server yet, and `uploaded = 0` is the
// queue push drains once the note has landed. Rows from before this step all came
// from the server, which is why the default is 1.
//
// `upload_error` holds a refusal that retrying won't fix — over the size limit, an id
// already in use, bytes that don't match their hash. A row carrying one leaves the
@@ -292,15 +284,15 @@ ALTER TABLE attachments ADD COLUMN uploaded INTEGER NOT NULL DEFAULT 1;
ALTER TABLE attachments ADD COLUMN upload_error TEXT;
"#;
// v11 (#5175): notes other people shared with this account.
// v11: notes other people shared with this account.
//
// The feed now carries them, so a note says how it is held: `permission` is `owner`,
// The feed carries them, so a note says how it is held: `permission` is `owner`,
// `edit` (its text may change here) or `view`. Every note already on a device is the
// account's own, which is why the default is `owner`. `shared` is whether the owner
// has shared it with anyone, and `shared_by_*` names the owner of one shared with us.
//
// `shares_synced` is whether this device has pulled with shares since it was linked.
// The notes shared before this build sit below the cursor it already holds, so the
// Notes shared before a device reaches this step sit below the cursor it holds, so the
// first pull from a server offering `shares` starts again from zero (see
// `engine::run_cycle`). A pull applies idempotently, so starting over costs a
// download and nothing else.
@@ -312,29 +304,29 @@ ALTER TABLE notes ADD COLUMN shared_by_name TEXT;
ALTER TABLE sync_state ADD COLUMN shares_synced INTEGER NOT NULL DEFAULT 0;
"#;
// v12 (#5176): a shared note's pin, archive and position are this account's own.
// v12: a shared note's pin, archive and position are this account's own.
//
// `state_at` is when they last changed here, on a note someone else owns. It is kept
// apart from `updated_at`, which stays the time of the note's TEXT: pinning a copy
// whose text is behind the owner's must not make that text look like the newer
// edit when it is pushed. Null on our own notes, where `updated_at` covers both.
//
// `sync_state.shares_synced` now holds a level rather than a flag (see
// `sync_state.shares_synced` holds a level rather than a flag (see
// `state::SHARED_STATE`), and a device reaching this level pulls everything once
// more: the shared notes it holds carry their owner's pins until it does.
const SCHEMA_V12: &str = r#"
ALTER TABLE notes ADD COLUMN state_at TEXT;
"#;
// v13 (#5180): saved views are gone. They never synced, so this device's set was its
// v13: drops saved views. They never synced, so this device's set was its
// own and nothing else holds a copy.
const SCHEMA_V13: &str = r#"
DROP TABLE saved_filters;
"#;
// v14 (#5385): tag names compare by `fold`, which lowercases every letter. Until now
// the index and the queries used SQLite's lower(), which folds ASCII only, so "Café"
// and "CAFÉ" could be two tags on a device while the server held one.
// v14: tag names compare by `fold`, which lowercases every letter. SQLite's lower()
// folds ASCII only, so with it "Café" and "CAFÉ" could be two tags on a device while
// the server held one.
//
// Pairs that already exist are merged before the index can be rebuilt. THE OLDER ROW
// SURVIVES, as in `store::rename_label`, so every device holding the same pair picks
@@ -452,7 +444,7 @@ fn register_fold(conn: &Connection) -> rusqlite::Result<()> {
mod tests {
use super::*;
/// A database as it stood before M304 — items still in their own table.
/// A database at v7, with items still in their own table.
fn v7_db() -> Connection {
let conn = Connection::open_in_memory().expect("open");
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
@@ -602,8 +594,7 @@ mod tests {
.collect::<rusqlite::Result<Vec<String>>>()
.expect("collect");
assert!(!columns.iter().any(|c| c == "color"));
// The one that survived. Getting this wrong would take every tag's colour with
// it, which is the whole thing M315 was keeping.
// The one that stays. Getting this wrong would take every tag's colour with it.
let mut stmt = conn.prepare("PRAGMA table_info(labels)").expect("pragma");
let label_columns: Vec<String> = stmt
.query_map([], |r| r.get::<_, String>(1))
+28 -37
View File
@@ -48,11 +48,9 @@ fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<NoteLab
/// The note's checklist, read out of its body. No query, because there is no table.
///
/// A `- [ ] milk` line IS the item (M304). The id is the item's ORDINAL rather than a
/// uuid — which is all it ever amounted to anyway, since `push.rs` sent text and
/// checked and never an id, and both sides replaced the whole list on every sync. It
/// is also exactly what the rewriters in `derive` take, so a UI holding an id can act
/// on it directly.
/// A `- [ ] milk` line IS the item. The id is the item's ORDINAL rather than a uuid:
/// sync carries text and checked, never an id, and the ordinal is what the rewriters
/// in `derive` take, so a UI holding an id can act on it directly.
fn items_of(body: &str) -> Vec<ChecklistItem> {
derive::extract_items(body)
.into_iter()
@@ -122,10 +120,9 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
permission, shared, shared_by_id, shared_by_name
FROM notes WHERE id = ?1",
[id],
// By NAME, not by position. Dropping `color` shifted every column after it
// and the two timestamps were missed (fa89da1): created_at read updated_at
// and updated_at read trashed_at, with nothing failing. A name either
// resolves or errors.
// By NAME, not by position: dropping a column shifts every one after it, and
// a positional read would then load the wrong timestamps with nothing
// failing. A name either resolves or errors.
|r| {
Ok(Note {
id: r.get("id")?,
@@ -166,11 +163,11 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
Ok(note)
}
// ---- notes shared with this account (#5175) ---------------------------------
// ---- notes shared with this account ------------------------------------------
//
// A note someone else owns arrives with `permission` `edit` (its text may change
// here) or `view` (it may not). Its pin, archive and place on this board are this
// account's own either way (#5176). Everything else about it — trash, reminders,
// account's own either way. Everything else about it — trash, reminders,
// labels, files — stays its owner's. The server enforces the same split;
// refusing here as well tells the person at once, instead of letting an edit be made,
// queued, and then thrown away by the next sync.
@@ -268,21 +265,19 @@ fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<Strin
/// Attach the note's tag labels, LIFT its standalone tags out of the body, and write
/// the shortened body back.
///
/// NAMED FOR THE MUTATION. It used to be `sync_tags` and only touched label rows; it
/// now rewrites `notes.body`, and every caller writes the body just before calling —
/// so this overwrites what they wrote, on purpose.
/// NAMED FOR THE MUTATION: it rewrites `notes.body`, and every caller writes the body
/// just before calling, so this overwrites what they wrote, on purpose.
///
/// `display_title` needs no attention here, unlike on the server: the core derives it
/// on READ (`derive::display_title`, called from `load_note`) rather than storing
/// it, so there is no persisted copy to go stale.
///
/// The two kinds of tag are handled differently, and that difference IS what `via_tag`
/// means from here on — backed by text still in the body:
/// means — backed by text still in the body:
///
/// standalone lifted out, attached as an ORDINARY label. Nothing derives it any
/// more, and the way to remove it becomes the chip's ×.
/// inline left in place, attached via_tag = 1, still detached when its text
/// goes. Unchanged from before.
/// standalone lifted out, attached as an ORDINARY label. Nothing derives it, and
/// the way to remove it is the chip's ×.
/// inline left in place, attached via_tag = 1, detached when its text goes.
///
/// Mirrors `_lift_and_reconcile_tags` in the server's `notes/tags.py`.
fn lift_and_sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
@@ -384,9 +379,8 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
if let Some(f) = &q.facets {
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
// One placeholder, one bind. This pushed the pattern twice after the
// title column went (95aa10c), and rusqlite refuses a count mismatch,
// so every desktop search failed.
// One placeholder, one bind: rusqlite refuses a count mismatch, which
// would fail every desktop search.
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
binds.push(format!("%{}%", escape_like(text)));
}
@@ -681,7 +675,7 @@ pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result<Note>
}
/// The longest snooze, in minutes: 30 days. The server's snooze route clamps to the
/// same range (`notes.SNOOZE_MAX_MINUTES`), so a device and the web agree (#5386).
/// same range (`notes.SNOOZE_MAX_MINUTES`), so a device and the web agree.
pub const SNOOZE_MAX_MINUTES: i64 = 60 * 24 * 30;
pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result<Note> {
@@ -870,7 +864,7 @@ pub fn reorder(conn: &Connection, ordered_ids: &[String]) -> rusqlite::Result<()
let total = ordered_ids.len() as i64;
let at = now();
for (i, id) in ordered_ids.iter().enumerate() {
// A note someone shared with us moves on this account's board only (#5176),
// A note someone shared with us moves on this account's board only,
// stamped as its own state rather than as an edit.
conn.execute(
"UPDATE notes
@@ -1024,11 +1018,10 @@ pub fn create_label(conn: &Connection, name: &str) -> rusqlite::Result<Label> {
/// Rename a label. Renaming ONTO a name another label already holds MERGES the two.
///
/// It cannot simply be an UPDATE: `idx_labels_name` is unique on `lower(name)`, so
/// the bare statement failed with a raw SQLite "UNIQUE constraint failed" that
/// reached the user as database internals. Merging is the operator's call, and it
/// is the reading that matches what a person means — typing an existing tag's name
/// onto this one says "these are the same thing."
/// It cannot simply be an UPDATE: `idx_labels_name` is unique on `lower(name)`, so a
/// bare statement fails with a raw SQLite "UNIQUE constraint failed". Merging is the
/// reading that matches what a person means — typing an existing tag's name onto
/// this one says "these are the same thing."
///
/// THE OLDER ROW SURVIVES, and takes the new spelling. Older rather than "the one
/// that already held the name" because age is the property neither participant's
@@ -1118,11 +1111,9 @@ pub fn merge_labels(
#[cfg(test)]
mod tests {
//! The store had no tests of its own until two bugs shipped in it unnoticed:
//! `load_note` reading its timestamps from the wrong columns after one was
//! dropped (fa89da1), and the board's text facet binding its pattern twice for
//! one placeholder (95aa10c). Both are the kind of thing only a real query
//! against a real schema catches, which is what every test here runs.
//! Every test here runs a real query against a real schema: reading columns by
//! the wrong position, or binding a pattern twice for one placeholder, is the
//! kind of thing only that catches.
use super::*;
@@ -1158,7 +1149,7 @@ mod tests {
notes.iter().map(|n| n.id.as_str()).collect()
}
// ---- notes shared with this account (#5175) --------------------------------
// ---- notes shared with this account ----------------------------------------
/// A note someone else owns, as the feed leaves it: clean, and held at `permission`.
fn shared(conn: &Connection, id: &str, permission: &str) {
@@ -1583,8 +1574,8 @@ mod tests {
assert!(edited.labels.is_empty());
}
/// Every letter folds, accented ones included, as on the server (#5385). With
/// SQLite's lower() the second note minted a "CAFÉ" tag of its own.
/// Every letter folds, accented ones included, as on the server; SQLite's lower()
/// would mint a second "CAFÉ" tag.
#[test]
fn a_tag_spelled_with_different_accented_capitals_is_the_same_tag() {
let conn = db();
+3 -3
View File
@@ -1,4 +1,4 @@
//! Local storage for attachment bytes (M10.7d).
//! Local storage for attachment bytes.
//!
//! Content-addressed: a blob is filed under its own sha256, so the same image
//! attached to five notes is stored once and re-downloading it is free. The hash is
@@ -94,7 +94,7 @@ impl BlobStore {
}
}
// --- Serving blobs to the webview (M10.7f) -----------------------------------
// --- Serving blobs to the webview ---------------------------------------------
//
// A synced note's attachment `url` is the SERVER's relative path
// (`/api/notes/<id>/attachments/<aid>`). In the desktop webview that resolves
@@ -180,7 +180,7 @@ pub fn urldecode(value: &str) -> String {
/// there. Echoing is safe only for the families that can't carry script, and
/// `image/` is not quite one of them: `image/svg+xml` is a document that runs its own
/// `<script>`, so it is served as an opaque download like everything else unfamiliar.
/// The web made the same exclusion for the same reason (#1981).
/// The web makes the same exclusion for the same reason.
fn content_type_for(mime: &str) -> String {
const RENDERABLE: &[&str] = &["image/", "audio/", "video/"];
let familiar = (RENDERABLE.iter().any(|p| mime.starts_with(p)) && mime != "image/svg+xml")
+5 -5
View File
@@ -1,7 +1,7 @@
//! HTTP transport to an Inkwell server.
//!
//! Covers the compatibility handshake (M10.6) and device-token auth (M10.7a). The
//! engine that moves notes — push, pull, cursor — grows on top of the same client,
//! Covers the compatibility handshake and device-token auth. The engine that moves
//! notes — push, pull, cursor — is built on the same client,
//! which is why the timeout, identity headers and error vocabulary live here rather
//! than inline at each call site.
//!
@@ -20,7 +20,7 @@ use super::wire;
/// Timeout for the short request/response calls in this module. Kept tight because a
/// user is watching a button while they run, and the most common mistake — a wrong
/// host on a LAN — fails by hanging rather than refusing, so an unbounded wait would
/// just look frozen. The sync engine's bulk transfers will need their own, longer one.
/// just look frozen. Bulk transfers have their own, longer ones.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// Bulk transfers get much longer: a first full sync can be thousands of notes, and
@@ -408,7 +408,7 @@ pub async fn push_changes<T: Serialize>(
.map_err(|e| format!("Couldn't read the push reply from {base_url}: {e}"))
}
// --- sharing (#5175) -----------------------------------------------------------
// --- sharing -------------------------------------------------------------------
//
// The Share dialog on a linked device asks the server directly, over the device
// token: who is on the instance, and who a note is shared with. Shares are the
@@ -424,7 +424,7 @@ pub struct Member {
pub email: String,
}
/// A group the admin made, which a note can be shared with (#5177). Sharing with it
/// A group the admin made, which a note can be shared with. Sharing with it
/// reaches whoever is in it at the time.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ShareGroup {
+26 -32
View File
@@ -1,8 +1,8 @@
//! Client<->server compatibility handshake (M10.6).
//! Client<->server compatibility handshake.
//!
//! The desktop app is local-first: it never *needs* a server. When the user links
//! one, this module decides whether the two can actually talk — before a single
//! note moves. The sync engine (M10.7) consults it on link and on every sync.
//! The app is local-first: it never *needs* a server. When the user links one, this
//! module decides whether the two can actually talk — before a single note moves.
//! The sync engine consults it on link and on every sync.
//!
//! The contract is two integers per side, versioning the WIRE PROTOCOL separately
//! from either program's release version:
@@ -22,28 +22,24 @@ use std::sync::OnceLock;
/// The sync wire protocol this client speaks.
///
/// v4 (M315): `color` left the note. NOT a floor raise on either side — see the note
/// on [`MIN_SERVER_PROTOCOL_VERSION`].
/// v5 (#5168): files attached here upload, and removed attachments and previews are
/// pushed. Additive: both go only to a server advertising `attachment_sync`.
/// v6 (#5175): notes shared with this account, asked for with `shares`.
/// v7 (#5176): this account's own pin, archive and position on a shared note, sent
/// v4: `color` left the note. NOT a floor raise on either side — see the note on
/// [`MIN_SERVER_PROTOCOL_VERSION`].
/// v5: files attached here upload, and removed attachments and previews are pushed.
/// Additive: both go only to a server advertising `attachment_sync`.
/// v6: notes shared with this account, asked for with `shares`.
/// v7: this account's own pin, archive and position on a shared note, sent
/// only to a server advertising `shared_state`.
pub const CLIENT_PROTOCOL_VERSION: u32 = 7;
/// The oldest server protocol this client can drive — the symmetric half of the
/// server's `min_client_protocol_version`.
///
/// STAYS AT 3 ACROSS v4, and the v2 precedent is the reason to say why rather than
/// leave it looking like an oversight. v2 dropped `kind` and `title` and DID move both
/// floors, on the rule that "dropping a field a client sends and expects back is
/// breaking". `color` fails that test on the second half: a v3 client reading a v4
/// server gets `"default"` from serde's default and draws the colour it derives
/// locally, which is a board that looks exactly like the one it drew yesterday. A v3
/// client PUSHING `color` to a v4 server has the key ignored — the server reads its
/// payload key by key and never validates the shape. Neither direction errors, and
/// neither loses anything a person can see; `title` was the note's NAME, and this is a
/// field that no longer renders anywhere.
/// STAYS AT 3 through v4. Dropping a field a client sends and expects back is
/// breaking (v2 dropped `kind` and `title` and moved both floors), but `color` is not
/// expected back: a v3 client reading a v4 server gets `"default"` from serde's
/// default and draws the colour it derives locally, and a v3 client PUSHING `color` to
/// a v4 server has the key ignored, since the server reads its payload key by key.
/// Neither direction errors or loses anything a person can see.
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 3;
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the
@@ -64,7 +60,7 @@ pub const OPTIONAL_FEATURES: &[&str] = &[
/// The handshake fields of `GET /api/config`.
///
/// Every protocol field is optional because a server predating M10.6 simply won't
/// Every protocol field is optional because a server older than the handshake won't
/// send them. That case has to read as "this server is too old to sync", not as a
/// parse failure — which would look to the user like they mistyped the URL.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -190,7 +186,7 @@ pub fn evaluate(info: &ServerInfo) -> Compatibility {
///
/// The core can't know this: it is compiled into both the desktop and the Android
/// app, and its own crate version is a number no build stamps. The app's build is
/// what a reader of the header wants (note 3127 §5).
/// what a reader of the header wants.
///
/// So the host names itself. `OnceLock` because identity is fixed for the life of
/// the process and a second caller should be ignored rather than race the first.
@@ -208,10 +204,9 @@ pub fn set_client_agent(name: &str, version: &str) {
/// Headers this client puts on every request to a linked server, so the server can
/// log or gate on client identity without a separate handshake round-trip.
pub fn client_headers() -> [(&'static str, String); 2] {
// `unidentified/unknown`, never a plausible default. Nothing reads this header
// today, which is exactly why a wrong value could sit in it for months: the
// first person to look at a server log is the first person who could catch it,
// and only if what they see is obviously a host that never introduced itself.
// `unidentified/unknown`, never a plausible default: a wrong value in a header
// nothing checks would sit unnoticed, while an obviously missing one is caught by
// the first person who reads a server log.
let agent = CLIENT_AGENT
.get()
.cloned()
@@ -259,7 +254,7 @@ pub fn normalize_base_url(raw: &str) -> Option<String> {
/// Whether a normalized base URL may carry this device's token, as far as the wire
/// goes: true for any `https://` server, and for plain `http://` only when the
/// host is private (family idea #5105, practice 13).
/// host is private.
///
/// Plain HTTP on a home network is fine and stays supported. Across the internet,
/// anyone on the path could read the token and act as this device until it is
@@ -363,7 +358,7 @@ mod tests {
#[test]
fn server_without_protocol_fields_is_too_old() {
// A pre-M10.6 server: /api/config parses, but carries no protocol block.
// A server older than the handshake: /api/config parses, but carries no protocol block.
let info = ServerInfo {
site_name: Some("Inkwell".into()),
version: Some("0.0.9".into()),
@@ -473,10 +468,9 @@ mod tests {
fn server_info_tolerates_unknown_and_absent_fields() {
// Forward compatibility: a NEWER server sending fields we've never heard of
// must not break the handshake.
// Versions come from the constants, not literals: this test is about unknown
// FIELDS, and pinning the numbers made it fail the moment the protocol moved
// to v2 — for a reason that has nothing to do with what it checks. The feature
// list likewise: a literal one went stale when `attachment_sync` was added.
// Versions and features come from the constants, not literals: this test is
// about unknown FIELDS, and pinned values would fail it whenever the protocol
// moves.
let features: Vec<&str> = REQUIRED_FEATURES
.iter()
.chain(OPTIONAL_FEATURES.iter())
+4 -4
View File
@@ -1,4 +1,4 @@
//! The sync cycle (M10.7c).
//! The sync cycle.
//!
//! Deliberately the ONLY way the UI can sync. Push and pull are each usable on their
//! own inside this crate, but exposing them separately would let a caller pull
@@ -37,9 +37,9 @@ pub async fn run_cycle(
base_url: &str,
token: &str,
) -> Result<SyncOutcome, String> {
// A device linked to a public plain-http:// address before links were checked
// stops here, rather than sending its token in the clear on every cycle. Its
// sync error says to re-link over https:// (family idea #5105, practice 13).
// A device linked to a public plain-http:// address stops here, rather than
// sending its token in the clear on every cycle. Its sync error says to re-link
// over https://.
super::compat::refuse_public_cleartext(base_url)?;
// What this server can do, asked before anything is sent: files attached here,
+1 -2
View File
@@ -1,7 +1,6 @@
//! Linking a device to a server, and unlinking it.
//!
//! One flow for every client. The desktop and Android each wrote it out, the same
//! steps in the same order; what differs between them is only how the token is
//! One flow for every client. What differs between them is only how the token is
//! kept (Android seals it, `state::TokenSeal`) and how the result is reported.
use rusqlite::Connection;
+2 -2
View File
@@ -3,7 +3,7 @@
//! The app is local-first: `local` is the source of truth and everything works
//! unlinked. Nothing in here runs until the user links a server.
//!
//! - `compat` — the version/capability handshake (M10.6): whether a given server can
//! - `compat` — the version/capability handshake: whether a given server can
//! be talked to at all. Pure decision logic, no I/O.
//! - `client` — HTTP transport: the handshake call and device-token auth.
//! - `state` — the persisted link record (server, token, change-feed cursor).
@@ -12,7 +12,7 @@
//! - `pull` / `push` — the two halves of that cycle.
//! - `wire` — the delta-feed JSON shapes, as the server sends them.
//! - `blobs` — attachment bytes on disk, filed under their own sha256.
//! - `sharing` — the Share dialog's calls, straight to the server (#5175).
//! - `sharing` — the Share dialog's calls, straight to the server.
//!
//! The UI surface that drives this lives in whichever client is wrapping the crate,
//! not here.
+3 -5
View File
@@ -1,4 +1,4 @@
//! Pull: bring a server's changes into the local store (M10.7b).
//! Pull: bring a server's changes into the local store.
//!
//! The feed is a single monotonic sequence shared by notes and labels, so one
//! integer cursor is a total-order watermark over both (docs/sync.md). We loop pages
@@ -694,10 +694,8 @@ mod tests {
#[test]
fn children_are_replaced_not_merged() {
// Was written over checklist items; they are lines of the body now (M304), so
// attachments carry the point instead. It is the same property either way: a
// delta is the note's FULL current state, so a child the server dropped has to
// disappear locally rather than linger.
// A delta is the note's FULL current state, so a child the server dropped has
// to disappear locally rather than linger.
let conn = db();
let mut first = note("n1", 1);
first.attachments = vec![attachment("a1"), attachment("a2")];
+7 -7
View File
@@ -1,4 +1,4 @@
//! Push: send local changes to the server and apply what it says (M10.7c).
//! Push: send local changes to the server and apply what it says.
//!
//! Three sources feed a push: rows flagged `dirty` (created or edited locally), rows
//! in `pending_deletes` (permanently deleted locally — see `local::schema` v2 for why
@@ -78,7 +78,7 @@ pub struct Change {
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
/// A LABEL's colour. A note has none since M315, so a note change leaves this
/// A LABEL's colour. A note has none, so a note change leaves this
/// `None` and the key never reaches the wire.
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
@@ -100,8 +100,8 @@ pub struct Change {
pub created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// When this account last pinned, archived or moved a note someone else owns
/// (#5176): `pinned`, `archived` and `position` on such a note are its own, and
/// When this account last pinned, archived or moved a note someone else owns:
/// `pinned`, `archived` and `position` on such a note are its own, and
/// the server weighs them against this rather than `edited_at`, which stays the
/// time of the text.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -154,7 +154,7 @@ pub struct Accepts {
/// failure.
pub attachment_sync: bool,
/// This account's own pin, archive and position on a note someone else owns
/// (`shared_state`, #5176).
/// (`shared_state`).
pub shared_state: bool,
}
@@ -281,9 +281,9 @@ struct NoteRow {
recurrence: Option<String>,
created_at: String,
updated_at: String,
/// `owner`, or `edit` or `view` for a note someone shared with us (#5175).
/// `owner`, or `edit` or `view` for a note someone shared with us.
permission: String,
/// When this account last pinned, archived or moved it, if it isn't ours (#5176).
/// When this account last pinned, archived or moved it, if it isn't ours.
state_at: Option<String>,
}
+3 -3
View File
@@ -1,4 +1,4 @@
//! Sharing a note from a linked device (#5175).
//! Sharing a note from a linked device.
//!
//! Shares belong to the server: who is on the instance and who a note is shared
//! with are never kept here, so each call goes straight to the server over the
@@ -8,8 +8,8 @@
//!
//! The server address and token come from the CALLER, read with
//! `state::credentials` and the caller's seal. A stored token may be sealed
//! (Android keeps it under a Keystore key), and reading it raw here sent `sealed:…`
//! as the bearer token, so every share call was refused (#5381).
//! (Android keeps it under a Keystore key), and the server refuses `sealed:…` as a
//! bearer token.
use rusqlite::params;
+8 -8
View File
@@ -10,8 +10,8 @@
//! setups. Protecting the database file is the portable trade.
//!
//! A client that has somewhere better to keep a key passes a [`TokenSeal`], and the
//! token is stored sealed. Android does, with a key in the Keystore (family idea
//! #5105, practice 12); the desktop does not.
//! token is stored sealed. Android does, with a key in the Keystore; the desktop
//! does not.
use rusqlite::{params, Connection};
use serde::Serialize;
@@ -177,7 +177,7 @@ pub fn open_token(
/// The server address and the token to send it, or None when this device isn't
/// linked. The ONE read of a link for a call to the server: every client goes
/// through here, so none can send a token as stored when it is sealed (#5381).
/// through here, so none can send a token as stored when it is sealed.
///
/// With a `seal`, the stored token is opened ([`open_token`]); a client without one
/// (the desktop keeps its token plain) passes None and gets it as stored.
@@ -217,11 +217,11 @@ fn forget_shared_notes(conn: &Connection) -> rusqlite::Result<()> {
/// `sync_state.shares_synced`. Each level changed what the feed says about notes a
/// device may already hold, below the cursor it already has.
///
/// Shared notes arrive at all (#5175).
/// Shared notes arrive at all.
pub const SHARES: i64 = 1;
/// A shared note's pin, archive and position are this account's own (#5176). Before,
/// the feed sent the owner's, and a device holding those would keep showing them
/// until something next changed the note.
/// A shared note's pin, archive and position are this account's own. A device still
/// holding the owner's values would show them until something next changed the note,
/// so reaching this level re-reads the feed.
pub const SHARED_STATE: i64 = 2;
/// Start the change feed over the first time this device pulls at a sharing `level`
@@ -291,7 +291,7 @@ pub fn mark_synced(conn: &Connection, when: &str) -> rusqlite::Result<()> {
Ok(())
}
/// Advance the consumed-change watermark. Called by the pull loop (M10.7b) only
/// Advance the consumed-change watermark. Called by the pull loop only
/// after a page has been fully applied.
pub fn set_cursor(conn: &Connection, cursor: i64) -> rusqlite::Result<()> {
conn.execute(
@@ -6,7 +6,7 @@
# libwayland-* into usr/lib. On many end-user systems (Arch/CachyOS, NVIDIA,
# Wayland) those bundled copies clash with the running kernel driver + Mesa and
# abort with "Could not create default EGL display: EGL_BAD_PARAMETER" -> a black
# window (issue 2021). They load before any renderer choice, so the runtime env
# window. They load before any renderer choice, so the runtime env
# fallbacks (WEBKIT_DISABLE_DMABUF_RENDERER etc.) cannot rescue it. Per the
# AppImage excludelist these libraries MUST come from the host. We strip exactly
# that graphics/display subset -- keeping webkit/gtk/javascriptcore bundled for
+11 -14
View File
@@ -3,8 +3,8 @@
# Build a PREBUILT Arch package (.pkg.tar.zst) from the binary the desktop CI job
# already compiled — no makepkg, no Arch container, no second Rust build.
#
# WHY prebuilt: the source PKGBUILD this replaces required every user to install
# rust + nodejs + npm and compile for minutes before they could open the app.
# WHY prebuilt: a source PKGBUILD would make every user install rust + nodejs +
# npm and compile for minutes before they could open the app.
# Distribution means shipping a package people can just install, so the one-command
# installer (install.sh) can `pacman -U` a real native package on Arch/CachyOS
# instead of falling back to the AppImage.
@@ -14,8 +14,8 @@
# SONAME at runtime, and those sonames are identical on both distros; SQLite is
# compiled in (rusqlite "bundled"). glibc symbol versioning is forward-compatible,
# and we build on Debian's older glibc and run on Arch's newer one — the safe
# direction. Using the host's graphics stack is also exactly what keeps issue 2021
# (bundled-lib EGL_BAD_PARAMETER black window) fixed.
# direction. Using the host's graphics stack is also what avoids the bundled-lib
# EGL_BAD_PARAMETER black window.
#
# A .pkg.tar.* is just a tar whose FIRST entry is .PKGINFO, so we assemble it
# directly rather than nesting an Arch container over the docker socket.
@@ -33,10 +33,9 @@ BINARY="$REPO_ROOT/target/release/inkwell"
OUT_DIR="${1:-$REPO_ROOT/target/release/bundle/arch}"
PKGNAME="inkwell"
# The names this package used to ship under: `thoughtsync` until the app became
# Inkwell (Scribe note 5071), `thoughtsync-desktop` before that. pacman needs both
# conflict and replace to retire them: without them a `pacman -U` of the renamed
# package installs ALONGSIDE the old one (issue 2075).
# Earlier names of this package: `thoughtsync` and `thoughtsync-desktop`. pacman
# needs both conflict and replace to retire them: without them a `pacman -U`
# installs ALONGSIDE the old one.
REPLACES=(thoughtsync thoughtsync-desktop)
PKGREL=1
PKGDESC="Inkwell desktop — local-first Keep-style thought capture"
@@ -58,11 +57,9 @@ DEPENDS=(webkit2gtk-4.1 gtk3)
# Single source of truth for the version: the SAME helper the bundle build uses.
#
# It used to read tauri.conf.json directly, which was right until dev builds began
# overriding the version on the command line (M10.9) — the file still says 0.1.0, so
# the pacman package came out stamped 0.1.0 around a binary reporting 0.1.132. A
# package that lies about its version is exactly what makes a later "which build is
# this?" question unanswerable.
# Not tauri.conf.json: dev builds override the version on the command line and the
# file says 0.1.0, so reading it would stamp the package with a version its binary
# doesn't report.
# `|| true` so a miss falls through to the explicit error below rather than
# aborting on pipefail with no explanation.
# The ORDERING KEY: pacman compares this, and it must match the filename the
@@ -127,7 +124,7 @@ INSTALLED_SIZE="$(du -sb "$STAGE" | cut -f1)"
# pacman uses .MTREE for `pacman -Qkk` file verification; it is NOT required to
# install, and generating it needs bsdtar (libarchive-tools), which the Debian CI
# image may not carry. Emit it when we can, skip loudly when we can't, rather
# than adding an apt install to the job (rule 5: the image is the toolchain).
# than adding an apt install to the job (the image is the toolchain).
MTREE_ENTRY=()
if command -v bsdtar >/dev/null 2>&1; then
( cd "$STAGE" && LANG=C bsdtar -czf .MTREE --format=mtree \
+12 -14
View File
@@ -3,9 +3,8 @@
# Verify the .deb that `cargo tauri build` produced is a correct, installable
# native package — BEFORE the one-command installer starts handing it to people.
#
# WHY: install.sh prefers the .deb on every Debian/Ubuntu machine, but until now
# nothing had ever inspected it, let alone installed it. Its Depends line was
# entirely whatever tauri inferred, unread. The failure mode we're guarding is a
# WHY: install.sh prefers the .deb on every Debian/Ubuntu machine, and its Depends
# line is whatever tauri infers. The failure mode we're guarding is a
# user running install.sh and getting either an apt resolution error or an app
# that installs and then won't launch because a library it needs was never
# declared.
@@ -14,10 +13,9 @@
# 1. Print the control file + contents — the generated metadata becomes ground
# truth in the build log instead of an assumption.
# 2. Naming: the binary is /usr/bin/inkwell and the generated .desktop
# entry's StartupWMClass matches it. The app used to identify itself three
# different ways depending on install channel (issue 2075); this is what
# keeps the .deb — the only channel whose entry Tauri generates for us —
# from drifting away from the two we write by hand.
# entry's StartupWMClass matches it. This keeps the .deb — the only channel
# whose entry Tauri generates for us — from drifting away from the two we
# write by hand.
# 3. dpkg-shlibdeps: the canonical Debian answer for "what does this ELF
# actually need". Compared against what the package declares.
# 4. Every declared dependency resolves to a real package in apt (catches a
@@ -28,7 +26,7 @@
#
# Check 5 is opportunistic on purpose: the build image is not guaranteed to carry
# a docker CLI, and adding one at job time would violate "the image is the
# toolchain" (rule 5). Checks 1-4 are self-contained and always run.
# toolchain". Checks 1-4 are self-contained and always run.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -74,10 +72,10 @@ echo " (binary: ${BIN#"$WORK/root"})"
# purpose — this literal IS the contract the three install channels are held to.
#
# The control file's `Package:` field is held to it too. tauri-bundler derives it
# as kebab-case(productName) with no override, which split "ThoughtSync" into
# `thought-sync` (issue 2075). "Inkwell" has no hump to split, so the package and
# the command finally agree, and a productName that brought the split back would
# also break the `conflicts`/`replaces` retiring the old package.
# as kebab-case(productName) with no override, so a productName with a hump
# ("ThoughtSync") splits into `thought-sync`. "Inkwell" has none, so the package
# and the command agree; a productName that brought a split back would also break
# the `conflicts`/`replaces` retiring the old package.
CANON="inkwell"
package="$(dpkg-deb -f "$DEB" Package 2>/dev/null || true)"
@@ -89,8 +87,8 @@ installed_bin="${BIN#"$WORK/root"}"
note_fail "binary is at $installed_bin, expected /usr/bin/$CANON (mainBinaryName in tauri.conf.json)."
# Tauri generates this entry from the binary name, so a mismatch means the config
# and the bundler have diverged — exactly the drift that made the app group under
# a different taskbar icon depending on how it was installed.
# and the bundler have diverged — the drift that groups the app under a different
# taskbar icon depending on how it was installed.
entry="$(find "$WORK/root/usr/share/applications" -name '*.desktop' -print -quit 2>/dev/null || true)"
if [ -z "$entry" ]; then
note_fail "no .desktop entry in the package — the app would not appear in any menu."
+14 -19
View File
@@ -16,12 +16,8 @@
# stable (default) — the rolling build from every merge to `main`.
# dev — the rolling build from every green push to `dev`.
# Both are fixed-tag releases: the tag never moves and the assets are pruned to the
# current build, so the tag alone names the newest one. `stable` only became one in
# M314 step 3, when `main` started publishing — before that it was a manifest-only
# pointer at whatever `v*` tag somebody had last cut, and this script carried a
# fallback that chased the `v*` release its manifest named. That came out once
# `main` had published to `stable` for real (`b6673c6`); the two channels are the
# same shape now and nothing here should special-case one of them again.
# current build, so the tag alone names the newest one. The two channels are the
# same shape, and nothing here special-cases either.
# Pick one with `--channel dev` or `INKWELL_CHANNEL=dev`. Through a pipe the options go
# after a `--`: curl -fsSL <url> | sh -s -- --channel dev
#
@@ -36,7 +32,7 @@
# * everything else (Fedora/openSUSE/…) -> the de-bundled AppImage,
# installed user-locally (no sudo). The AppImage's graphics libs are
# stripped in CI (see debundle-graphics.sh), so it uses the host GPU stack
# and renders where a stock Tauri AppImage would black-window (issue 2021).
# and renders where a stock Tauri AppImage would black-window.
#
# POSIX sh (dash-safe) so `curl … | sh` works everywhere. Dependency-light and
# auditable on purpose — read it before you pipe it.
@@ -122,9 +118,8 @@ if [ -n "$server" ]; then
fi
# --- architecture gate ------------------------------------------------------
# Only x86_64 is built today; arm64 will be added when the CI matrix grows. The
# release-asset naming carries the arch, but since only one arch ships now we
# match by file extension below and just guard the arch here.
# Only x86_64 is built. The release-asset naming carries the arch, but with one
# arch shipping we match by file extension below and just guard the arch here.
arch="$(uname -m)"
case "$arch" in
x86_64 | amd64) : ;;
@@ -148,7 +143,7 @@ fetch() {
# The download against the digest its server published, BEFORE it reaches pacman,
# dpkg or a menu entry: a truncated file installs as something broken rather than
# failing. The forge path publishes no digest per bundle, so it passes an empty one
# and this checks nothing there; that is the forge path exactly as it always was.
# and this checks nothing there.
verify() {
[ -n "$2" ] || return 0
if have sha256sum; then
@@ -231,8 +226,8 @@ say "Finding the latest Inkwell build on the $channel channel…"
# channel — which is exactly what an installer wants and what the in-app updater
# already reads.
# The channel's RELEASE TAG, which is not always its name: `dev` publishes on the
# tag `dev-rolling`, because a tag called `dev` shadowed the branch of the same name
# and broke `git push origin dev` (Scribe #2184). Same mapping as
# tag `dev-rolling`, because a tag called `dev` would shadow the branch of the same
# name and break `git push origin dev`. Same mapping as
# packaging/channel-tag.sh — inlined because this script is fetched alone through a
# pipe and has nothing to source.
case "$channel" in
@@ -269,9 +264,9 @@ revoke_token() {
}
# Tell the app which channel it was installed from. The installer is the only thing
# that knows, and without this the app kept its own `stable` default and a dev install
# checked the stable feed — which advertises an OLDER version — reporting "up to date"
# forever (issue 2183).
# that knows; without this the app keeps its own `stable` default, and a dev install
# would check the stable feed, which advertises an OLDER version, and report "up to
# date" forever.
#
# A plain file rather than a write into the app's SQLite store: shell has no business
# knowing that schema, and a file it can't misread is the narrowest possible contract.
@@ -282,7 +277,7 @@ revoke_token() {
# both sides hardcode it, so a change to the identifier has to change both.
#
# From a server, the sibling `install-server` is written instead: which server the app
# came from, for the updater to follow (milestone 325, step 6). The channel marker is
# came from, for the updater to follow. The channel marker is
# left alone on that path, because a server's channel is whatever its image was built
# with and nothing here can know it.
record_channel() {
@@ -323,8 +318,8 @@ native_update_note() {
# --- native pacman path (Arch/CachyOS/Manjaro) ------------------------------
# Preferred over the AppImage on Arch: pacman pulls webkit2gtk-4.1 itself and the
# app then runs against the host graphics stack, which is what keeps the
# EGL_BAD_PARAMETER black window (issue 2021) from coming back. It also means the
# app then runs against the host graphics stack, which is what avoids the
# EGL_BAD_PARAMETER black window. It also means the
# app is tracked by the package manager and uninstalls cleanly.
if have pacman && [ -n "$pkg_url" ]; then
say "Arch-family system detected — installing the native pacman package"
+22 -24
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#
# Publish the built desktop bundles as assets on a Fabled-Git (Forgejo) Release.
# Publish the built desktop bundles as assets on a Fabled-Git Release.
#
# WHY: CI Actions artifacts are ephemeral, per-run, and auth-gated — useless as a
# distribution/fetch target. The install script (install.sh) and the in-app
@@ -9,9 +9,8 @@
# and each asset has a permanent browser_download_url.
#
# WHEN: CI-only, and ONLY on a `v*` tag build (the workflow gates this step with
# `if: startsWith(github.ref, 'refs/tags/v')`). Cutting the tag is the operator's
# action (rule 2) — this script never creates a tag, it only publishes a Release
# for a tag that already exists.
# `if: startsWith(github.ref, 'refs/tags/v')`). This script never creates a tag; it
# only publishes a Release for a tag that already exists.
#
# The build + de-bundle steps run first; this consumes their output:
# target/release/bundle/appimage/*.AppImage (de-bundled)
@@ -20,7 +19,7 @@
# target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
#
# Instance-agnostic: server + repo come from the runner's github.* context
# (Forgejo populates them for compatibility), so nothing is hardcoded to one host.
# (the forge populates them for compatibility), so nothing is hardcoded to one host.
#
# Idempotent: re-running for the same tag reuses the existing Release and
# replaces same-named assets, so a re-run (or workflow_dispatch retry) is safe.
@@ -34,7 +33,7 @@ set -euo pipefail
# The release to publish to. Defaults to the pushed tag (the versioned, stable
# case). The channel publish steps call this with the channel's fixed tag —
# `stable`, or `dev-rolling` for the dev channel (packaging/channel-tag.sh) — to
# maintain a rolling release whose tag never moves, because Forgejo has no
# maintain a rolling release whose tag never moves, because the forge has no
# `/releases/latest/download/<asset>` route for an updater to point at.
RELEASE_TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}"
RELEASE_PRERELEASE="${RELEASE_PRERELEASE:-false}"
@@ -57,13 +56,13 @@ shopt -s nullglob
# can see the other's bundles. The release is created once and reused (409 path).
# The `.sig` files are the updater's whole trust story — a bundle published without
# its signature is one the app will refuse, so they ship together or not at all.
# They only exist when the build ran with a signing key (M10.9); nullglob drops
# them silently otherwise, which is the correct behaviour for an unsigned build.
# They only exist when the build ran with a signing key; nullglob drops them
# silently otherwise, which is the correct behaviour for an unsigned build.
# The Android client publishes here too, from its own job and its own workspace
# — the same nullglob arrangement that already lets the Linux and Windows jobs
# share one release. Its two files are staged under android/dist by the workflow:
# a STABLY NAMED apk (a fixed name is the whole point of the fixed `dev` tag —
# Forgejo has no /releases/latest/download route) and the sidecar the server reads
# — the same nullglob arrangement that lets the Linux and Windows jobs share one
# release. Its two files are staged under android/dist by the workflow: a STABLY
# NAMED apk (a fixed name is the whole point of the fixed `dev` tag — the forge
# has no /releases/latest/download route) and the sidecar the server reads
# its version out of, because an APK keeps that in a binary manifest.
ASSETS=(
"$BUNDLE_ROOT"/appimage/*.AppImage
@@ -77,8 +76,7 @@ ASSETS=(
)
# nullglob drops PATTERNS that match nothing — it does nothing for a path with no
# wildcard in it, which stays in the array as a literal and reaches curl as a file
# that isn't there (exit 26). The Android entries above are exactly that shape, and
# adding them broke the desktop publish that had been working. Filter on existence
# that isn't there (exit 26). The Android entries above are that shape. Filter on existence
# instead, which is what the array actually means and covers every entry rather
# than only the ones that happen to contain a `*`.
present=()
@@ -120,20 +118,20 @@ first_id() { grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE
# — otherwise someone following the instructions here lands on a tagged build and
# wonders why the version they were sent isn't what they got.
#
# Both CHANNELS are rolling pointer releases (M314 step 3): `dev` republishes on
# Both CHANNELS are rolling pointer releases: `dev` republishes on
# every green push to dev, `stable` on every merge to main. Each says so, because a
# release that prunes its own assets behaves differently from a versioned one and a
# reader deserves to know which they are looking at.
# Keyed on the TAG, which for the dev channel is `dev-rolling`, not `dev` — a tag
# named `dev` shadowed the branch (Scribe #2184). CHANNEL_LABEL is the name a person
# reads in the release title, so the rename does not leak into it.
# named `dev` would shadow the branch. CHANNEL_LABEL is the name a person reads in
# the release title, so the tag name doesn't leak into it.
if [ "$TAG" = "dev-rolling" ]; then
CHANNEL_LABEL='dev (rolling)'
INSTALL_TAIL='sh -s -- --channel dev'
# Backticks BARE, not `\``. The heredoc below is unquoted, so there the backslash
# is the shell's — it suppresses command substitution and never reaches the JSON.
# Here single quotes already do that job, so a backslash would survive into the
# body as `\``, which is not a legal JSON escape: Forgejo answers 422.
# body as `\``, which is not a legal JSON escape: the forge answers 422.
CHANNEL_NOTE='\n\nThis is the rolling **dev** channel: republished on every green push to `dev`, and pruned to the current build.'
elif [ "$TAG" = "stable" ]; then
CHANNEL_LABEL='stable'
@@ -155,11 +153,11 @@ JSON
# An explicit body, replacing the install instructions above.
#
# Used by the RELEASE lane, whose job is a changelog rather than artifacts (M314
# step 7). It goes through this script rather than making its own API calls so that
# the create-or-PATCH-on-409 path is shared: a fixed-tag release that only ever
# POSTs keeps whatever text its FIRST build wrote, which is #2182 exactly, and
# re-implementing that correctly in a second place is how it comes back.
# Used by the RELEASE lane, whose job is a changelog rather than artifacts. It goes
# through this script rather than making its own API calls so that the
# create-or-PATCH-on-409 path is shared: a fixed-tag release that only ever POSTs
# keeps whatever text its FIRST build wrote, and a second copy of that path is one
# more place to get it wrong.
#
# CONTRACT: already JSON-escaped, without surrounding quotes. The caller knows
# whether it has a JSON encoder; this script cannot assume python3 is on PATH in
@@ -190,7 +188,7 @@ existing="$(api GET "$API/releases/$RELEASE_ID/assets")"
# --- upload each asset ------------------------------------------------------
for asset in "${ASSETS[@]}"; do
name="$(basename "$asset")"
# If an asset with this name already exists, delete it first (Forgejo rejects
# If an asset with this name already exists, delete it first (the forge rejects
# a duplicate name). Match the "id" that precedes this asset's "name".
old_id="$(printf '%s' "$existing" \
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"$name\"" \
+10 -18
View File
@@ -30,15 +30,8 @@ set -euo pipefail
# wrong build, and there is nothing downstream that could catch it.
: "${DISPLAY_VERSION:?DISPLAY_VERSION is required (the human-readable version)}"
# The manifest is published to the release that HOLDS the bundles. There is no
# second place any more.
#
# There used to be: `MANIFEST_TAG` let the manifest live on a `stable` pointer
# release while the bundles sat on a versioned `v*` one, because the app can only
# read a URL that never changes and a versioned tag is not that. M314 step 3 made
# `stable` a rolling release that holds its own bundles, exactly like `dev`, so the
# split had nothing left to bridge — and a parameter that can only ever be passed
# its own default is a branch nobody exercises and a comment that goes stale.
# The manifest is published to the release that HOLDS the bundles: both channels
# are rolling releases that hold their own bundles, so there is no second place.
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
AUTH=(-H "Authorization: token $GITHUB_TOKEN")
@@ -66,12 +59,12 @@ platform_entry() {
local target="$1" pattern="$2" bundle sig_name
# Matched on THIS build's version, not just the file extension.
#
# The rolling dev release accumulates every build's assets, and picking the first
# extension match returned the OLDEST one — so the manifest advertised the new
# version while pointing at an old binary. The client would install the older
# build, still be told the newer version was available, and update forever. The
# signature check couldn't catch it either: the old bundle's signature is
# perfectly valid FOR THE OLD BUNDLE.
# The rolling dev release accumulates every build's assets, and the first
# extension match would be the OLDEST one: the manifest would advertise the new
# version while pointing at an old binary, and the client would install the older
# build, still be told the newer one was available, and update forever. The
# signature check can't catch it: the old bundle's signature is valid FOR THE OLD
# BUNDLE.
bundle="$(printf '%s\n' "$names" | grep -F "_${APP_VERSION}_" | grep -E "$pattern" | head -1 || true)"
# A hard failure, not a skip: reaching here means this platform's build didn't
# upload, and the whole point is to never advertise a bundle that isn't there.
@@ -125,7 +118,7 @@ cat "$work/latest.json"
# the one `publish-release.sh` created or refreshed moments earlier, so it is
# guaranteed to exist by the time this runs.
# Replace rather than duplicate: Forgejo rejects a second asset with the same name,
# Replace rather than duplicate: the forge rejects a second asset with the same name,
# and these files are rewritten on every publish by design.
replace_asset() {
local path="$1" name="$2" escaped old_id
@@ -191,8 +184,7 @@ if [ "${PRUNE_OLD_ASSETS:-false}" = "true" ]; then
# The Android client is on that list for a second reason too: it is built
# by a DIFFERENT workflow with its own run number, so its version never
# matches $APP_VERSION here and a version-stamped name would be pruned on
# every desktop push regardless. That is exactly what happened on run
# 4098, which swept the APK run 4092 had just published.
# every desktop push regardless.
case "$asset_name" in
latest.json|inkwell-desktop.json|inkwell.apk|inkwell-android.json) continue ;;
*"$APP_VERSION"*) continue ;;
+9 -17
View File
@@ -4,14 +4,7 @@ name = "inkwell-desktop"
#
# Cargo requires a version here, and Tauri reads one from `tauri.conf.json` — both
# are overridden per build by `cargo tauri build --config '{"version": ...}'` with
# the value `packaging/version.sh key desktop` derives. See #3144.
#
# It used to matter: the old scheme took its base from this line and appended the CI
# run number on dev, so `0.2.<run>` on dev sat against a bare `0.2.0` on main and
# every dev build outranked every stable one. The remedy was "remember to bump the
# minor before tagging" — documented in a comment, enforced nowhere, and #2183 is
# what that looked like in the field. A scheme needing a human to remember something
# before each release has not removed the decision, only hidden it.
# the value `packaging/version.sh key desktop` derives.
version = "0.2.0"
description = "Inkwell desktop — local-first Keep-style thought capture"
authors = ["bvandeusen"]
@@ -24,9 +17,8 @@ autobins = false
# App logic lives in the library (Tauri v2 pattern: a single `run()` entry point);
# main.rs is a thin shim.
#
# rlib only. The staticlib/cdylib types existed for Tauri mobile, which Android no
# longer uses — it is a native Kotlin client over `inkwell-core` instead
# (Scribe note 2730), and the .so it loads is built from that crate, not this one.
# rlib only: Android is a native Kotlin client over `inkwell-core`, and the .so it
# loads is built from that crate, not this one.
[lib]
name = "inkwell_desktop_lib"
crate-type = ["rlib"]
@@ -37,7 +29,7 @@ crate-type = ["rlib"]
# name. (Written without braces on purpose: `tauri android init` round-trips this
# file through a TOML serializer that reformats brace tokens inside comments.)
# So the canonical name has to be carried by the build target itself, not just by
# the path it gets installed to (issue 2075). The crate is `inkwell-desktop`, so
# the path it gets installed to. The crate is `inkwell-desktop`, so
# the executable is named here rather than inferred. `inkwell` is also fixed under
# kebab-casing, which the AppImage bundler applies to the binary name on its way in.
[[bin]]
@@ -49,7 +41,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
# The framework-free client core: local SQLite store + sync engine. Shared with the
# Android client, which binds the same crate through uniffi (Scribe note 2730).
# Android client, which binds the same crate through uniffi.
inkwell-core = { path = "../../core" }
tauri = { version = "2", features = [] }
@@ -59,10 +51,10 @@ log = { workspace = true }
# Startup + operation logging to stdout AND a persistent file, so portability
# issues are diagnosable from any environment.
tauri-plugin-log = "2"
# In-app updates (M10.9). Signature verification is minisign; the public half lives
# in tauri.conf.json and the private half only ever as a CI secret. Desktop only —
# In-app updates. Signature verification is minisign; the public half lives in
# tauri.conf.json and the private half only ever as a CI secret. Desktop only —
# the plugin declares android support level "none", which is why the Android client
# gets a server-served update path instead (Scribe note 2725).
# gets a server-served update path instead.
tauri-plugin-updater = "2"
# The system-wide quick-capture hotkey. Desktop only by nature — Android has no
@@ -70,7 +62,7 @@ tauri-plugin-updater = "2"
# intent filter instead.
tauri-plugin-global-shortcut = "2"
# System notifications for due reminders (#5171), raised from the reminder worker in
# System notifications for due reminders, raised from the reminder worker in
# Rust. The page's own `Notification` goes nowhere in WebKitGTK, and the page's
# timer only runs while its window does. Used from Rust only, so no capability
# grants it to the webview.
+3 -7
View File
@@ -1,10 +1,6 @@
//! The desktop syncs on its own (#5167).
//!
//! Until this existed, the only thing that ever called the sync engine was the
//! "Sync now" button on the Sync screen. A linked desktop sat on its edits until
//! someone went looking for that button, and never heard about a note written on
//! the phone. Android has done better from the start (a periodic WorkManager job,
//! plus a flush when the app leaves the foreground); this is the desktop's version.
//! The desktop syncs on its own, so a linked desktop sends its edits and hears about
//! notes written elsewhere without anyone pressing "Sync now". Android does the same
//! with a periodic WorkManager job and a flush when the app leaves the foreground.
//!
//! ## When a cycle runs
//!
+2 -2
View File
@@ -1,7 +1,7 @@
//! Tauri command surface for the local store — the offline mirror of the REST API
//! the frontend's repository seam calls. Each command locks the shared connection and
//! delegates to `store`; errors are stringified for the JS boundary. adapters/local.ts
//! (M10.5) invokes these.
//! invokes these.
use serde_json::Value;
use tauri::State;
@@ -109,7 +109,7 @@ pub fn notes_update_item(
store::update_item(&conn, &id, &item_id, &changes).map_err(|e| e.to_string())
}
/// Attach a file to a note, online or not (#5168).
/// Attach a file to a note, online or not.
///
/// The file's bytes are the raw IPC body — a JSON number array would be several
/// times the file's size — and its note, name and type ride in headers. Header values
+4 -4
View File
@@ -1,7 +1,7 @@
//! Tauri commands for pairing with a server (M10.7a).
//! Tauri commands for pairing with a server.
//!
//! Linking is opt-in and reversible; the app is fully usable having never touched
//! any of this. The Settings UI (M10.7e) drives these.
//! any of this. The Sync screen drives these.
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, State};
@@ -167,7 +167,7 @@ pub fn sync_has_pending(db: State<'_, Db>) -> Result<bool, String> {
push::has_pending(&conn).map_err(|e| e.to_string())
}
// --- sharing (#5175) -----------------------------------------------------------
// --- sharing -------------------------------------------------------------------
//
// The Share dialog's calls, straight to the linked server. Unlinked, each answers
// `sharing::NEEDS_SERVER`, which the dialog shows as it is.
@@ -184,7 +184,7 @@ pub async fn shares_list(note_id: String, db: State<'_, Db>) -> Result<Vec<NoteS
sharing::list(&db, &url, &token, &note_id).await
}
/// Share with a person (`user_id`) or a group (`group_id`, #5177): exactly one.
/// Share with a person (`user_id`) or a group (`group_id`): exactly one.
#[tauri::command]
pub async fn shares_share(
note_id: String,
+3 -3
View File
@@ -1,6 +1,6 @@
//! One-time move of a ThoughtSync install's data into Inkwell's.
//!
//! The app was renamed ThoughtSync → Inkwell (Scribe note 5071), and the Tauri
//! The app was renamed ThoughtSync → Inkwell, and the Tauri
//! identifier with it. The app-data dir is named after the identifier, so the
//! renamed app looks in `com.fabledsword.inkwell` and would start EMPTY beside the
//! old `com.fabledsword.thoughtsync`, which still holds every note. Local-first means
@@ -12,7 +12,7 @@
//! hold committed writes not yet checkpointed into the main file, and a database
//! renamed without them silently loses them.
//!
//! A removable shim, deliberately (family rule 22): delete this module and its one
//! A removable shim, deliberately: delete this module and its one
//! call in `lib.rs` once no install is still on the old identifier.
use std::fs;
@@ -42,7 +42,7 @@ pub enum Outcome {
///
/// Entry by entry rather than renaming the directory, because `new_dir` can already
/// exist without a store: the installer writes its channel marker there before the
/// app first runs (update.rs, issue 2183), and the webview may create its own files
/// app first runs (update.rs), and the webview may create its own files
/// before setup. Anything already in `new_dir` wins; the old copy of it is left in
/// place, and so is `old_dir` itself unless it ends up empty.
pub fn adopt_legacy_data(old_dir: &Path, new_dir: &Path) -> io::Result<Outcome> {
+1 -1
View File
@@ -94,7 +94,7 @@ pub fn integrate_desktop() -> Result<IntegrationStatus, String> {
// StartupWMClass is the exception: it must be the BINARY name, because GTK
// derives the window's WM_CLASS from the executable and the desktop matches the
// two to group the taskbar icon. Not the product name, and not this AppImage's
// filename — AppRun execs `usr/bin/inkwell` inside it (issue 2075).
// filename — AppRun execs `usr/bin/inkwell` inside it.
let entry = desktop_entry_path().ok_or("Cannot resolve the applications dir.")?;
if let Some(parent) = entry.parent() {
fs::create_dir_all(parent).map_err(|e| format!("create applications dir: {e}"))?;
+19 -23
View File
@@ -1,7 +1,7 @@
//! Inkwell desktop (Tauri v2).
//!
//! The window loads the shared Vue 3 frontend (`../../frontend`), which reaches the
//! store and sync engine through the `frontend/src/adapters/` seam (M10.3) over Tauri
//! store and sync engine through the `frontend/src/adapters/` seam over Tauri
//! `invoke`.
//!
//! This crate is the DESKTOP WRAPPER, not the core. The on-device SQLite store and
@@ -20,26 +20,23 @@ mod update;
/// The build a PERSON reads, baked in by the desktop lane at compile time.
///
/// Lives at the crate root because it has two readers — `config_get`, which puts it
/// in the UI, and `log_environment`, which puts it in the log — and this repo has
/// spent several issues on one fact held in two places (2181, 2182, 2183).
/// in the UI, and `log_environment`, which puts it in the log — and one fact belongs
/// in one place.
///
/// `option_env!`, not `env!`: a local `cargo tauri build` sets nothing, and this has
/// to keep compiling. `None` becomes "unknown" at each call site rather than a
/// plausible-looking default — note 3127 §5 makes this string the only answer to
/// "which build is this?" now that there are no version tags, so there is nothing
/// left to contradict it if it lies. An honest "I cannot say" is the only safe wrong
/// answer.
/// plausible-looking default: with no version tags, this string is the only answer to
/// "which build is this?", so an honest "I cannot say" is the only safe wrong answer.
///
/// NOT `CARGO_PKG_VERSION`, which both readers used to use, and which was wrong on
/// every build ever shipped: `cargo tauri build --config '{"version": ...}'`
/// overrides `tauri.conf.json`, not Cargo's own metadata, so the literal `0.2.0` in
/// Cargo.toml is what reached the UI and the log regardless of what was built.
/// NOT `CARGO_PKG_VERSION`: `cargo tauri build --config '{"version": ...}'` overrides
/// `tauri.conf.json`, not Cargo's own metadata, so Cargo.toml's literal would reach
/// the UI and the log regardless of what was built.
///
/// NOT the ordering key either. That value — `1.0.<minutes>`, which the override
/// above does set — is the opaque value Tauri's updater compares; it lands in bundle
/// filenames and `latest.json` and must never be shown to a person (#3144). Two
/// values, two audiences. `update.rs` deliberately still reads the key, through
/// `app.package_info().version`, because a comparator is exactly what it is.
/// filenames and `latest.json` and must never be shown to a person. Two values, two
/// audiences. `update.rs` reads the key, through `app.package_info().version`,
/// because a comparator is exactly what it is.
const DISPLAY_VERSION: Option<&str> = option_env!("INKWELL_DISPLAY_VERSION");
/// The baked build, or the honest "I cannot say". The only way in — the const is
@@ -49,9 +46,8 @@ pub(crate) fn display_version() -> &'static str {
}
// The store and the sync engine live in the shared `inkwell-core` crate, which
// the Android client binds through uniffi (Scribe note 2730). Aliased to their old
// names so every call site below reads exactly as it did when they were modules of
// this crate — the extraction changed where they live, not what they are.
// the Android client binds through uniffi. Aliased to short names so the call
// sites below read as if they were modules of this crate.
use inkwell_core::{local, sync};
/// The app's own window, as `tauri.conf.json` names it. The capture window is the
@@ -91,7 +87,7 @@ pub fn run() {
])
.build(),
)
// In-app updates (M10.9). Registering the plugin is inert on its own — it
// In-app updates. Registering the plugin is inert on its own — it
// reads its config only when `update_check`/`update_install` ask it to, so a
// build without a signing key still starts normally and simply reports that
// updates aren't configured.
@@ -102,8 +98,8 @@ pub fn run() {
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
// System notifications for due reminders, raised by the `reminders` worker.
.plugin(tauri_plugin_notification::init())
// Attachment bytes are served to the webview from the local blob store
// (M10.7f). Registered on the BUILDER because a scheme has to exist before
// Attachment bytes are served to the webview from the local blob store.
// Registered on the BUILDER because a scheme has to exist before
// the webview is created; the directory it reads from arrives later, in
// `setup`, via `blobs::publish_root`.
.register_uri_scheme_protocol(sync::blobs::BLOB_SCHEME, |_ctx, request| {
@@ -143,9 +139,9 @@ pub fn run() {
let db = local::open(&db_path)?;
log::info!("local store ready — {}", local::summary(&db));
// Before anything can ask what channel we're on: the installer left a note
// in this directory saying which one the user picked (issue 2183).
// in this directory saying which one the user picked.
update::adopt_installer_channel(&db, &dir);
// And which server, when it installed from one (milestone 325 step 6).
// And which server, when it installed from one.
update::adopt_installer_server(&db, &dir);
sweep_local_trash(&db);
// Before the store is handed to the app: `restore` needs to read the
@@ -153,7 +149,7 @@ pub fn run() {
capture::restore(app.handle(), &db);
app.manage(db);
// Attachment bytes live beside the database, filed by content hash, so a
// synced image is readable with no network (M10.7d).
// synced image is readable with no network.
let blobs = sync::blobs::BlobStore::new(dir.join(local::BLOBS_DIR))?;
log::info!("attachment store ready: {}", blobs.root().display());
// Hand the directory to the URI-scheme handler registered below, which
+5 -5
View File
@@ -1,10 +1,10 @@
//! Reminders reach you when the window isn't in front (#5171).
//! Reminders reach you when the window isn't in front.
//!
//! The shared frontend's reminder check is a 45-second timer in the page, which
//! raises a toast and, if allowed, a page `Notification`. Inside this app that was
//! not enough. The page's timer only does anything while the webview is running
//! it, and WebKitGTK's page notifications go nowhere. So a reminder that came due
//! while Inkwell sat minimised or behind another window was never announced.
//! raises a toast and, if allowed, a page `Notification`. Inside this app the page's
//! timer runs only while the webview is running it, and WebKitGTK's page
//! notifications go nowhere, so on its own it misses a reminder that comes due while
//! Inkwell sits minimised or behind another window.
//!
//! This worker owns announcing on the desktop instead. Every [`TICK`] it asks the
//! local store which reminders have come due, and announces each occurrence once:
+24 -31
View File
@@ -1,22 +1,17 @@
//! In-app updates (M10.9).
//! In-app updates.
//!
//! Two channels, because two audiences: `stable` follows every merge to `main`,
//! `dev` follows every green push to `dev`. Each reads a `latest.json` published as
//! an asset on a release whose TAG NEVER CHANGES — verified necessary, because
//! Forgejo has no `/releases/latest/download/<asset>` route (it 404s), so "newest"
//! cannot be named in a URL. A fixed tag can.
//!
//! `stable` followed tagged `v*` releases until M314 step 3, and its manifest pointed
//! at bundles living on a different release. It holds its own bundles now, exactly as
//! `dev` always has — so a build reaches stable users with no tag cut anywhere, which
//! is the whole point of the change. NOTHING HERE MOVED: this code only ever read
//! `<channel>/latest.json`, and that is still where the manifest lands.
//! the forge has no `/releases/latest/download/<asset>` route (it 404s), so "newest"
//! cannot be named in a URL. A fixed tag can. Each channel's release holds its own
//! bundles, so a build reaches stable users with no tag cut anywhere.
//!
//! The feed is Fabled-Git by default, deliberately: this app is usable having never
//! linked a server, and an install that can't reach its own updates because it isn't
//! paired with anything would contradict the whole local-first premise.
//!
//! An Inkwell server can be the feed instead (milestone 325 step 6) — the one a
//! An Inkwell server can be the feed instead — the one a
//! self-hoster installed from, whose forge they may have no access to. That reads
//! alarming until you notice what does NOT move: the server hands out the same
//! signed AppImage, and the updater checks it against the public key baked into
@@ -24,8 +19,8 @@
//! can pass on official builds; it cannot substitute its own.
//!
//! Updates are signed. The public half is baked into `tauri.conf.json`; the private
//! half exists only as a CI secret, and is generated by the operator — a release
//! signing key that has passed through anyone else's hands is not a signing key.
//! half exists only as a CI secret — a release signing key that has passed through
//! other hands is not a signing key.
use std::path::Path;
@@ -107,10 +102,10 @@ impl Channel {
/// The release tag holding this channel's builds — not the channel's name.
///
/// `dev` used to be both, and a tag named `dev` shadowed the branch of the same
/// name, so `git push origin dev` failed as ambiguous (Scribe #2184). The channel
/// is still `dev` everywhere a person sees it and in the stored pref; only the tag
/// moved. Mirrors `packaging/channel-tag.sh`, which is what CI publishes with.
/// A tag named `dev` would shadow the branch of the same name and make
/// `git push origin dev` ambiguous, so the dev channel publishes on `dev-rolling`.
/// The channel is `dev` everywhere a person sees it and in the stored pref.
/// Mirrors `packaging/channel-tag.sh`, which is what CI publishes with.
fn release_tag(self) -> &'static str {
match self {
Channel::Stable => "stable",
@@ -127,8 +122,8 @@ impl Channel {
///
/// `read_source` is THE ONE READER. Three things hold an opinion about this — the
/// installer's marker files, this app's prefs, and `plugins.updater.endpoints` in
/// `tauri.conf.json` — and issues 2181–2183 were each one decision with several
/// holders and only one of them set. So: the markers only ever feed the prefs
/// `tauri.conf.json` — and one decision with several holders goes wrong as soon as
/// only one of them is set. So: the markers only ever feed the prefs
/// (`adopt_installer_*`), the prefs are what is read, and the config's endpoint is
/// never consulted, because every check below names its own.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -200,7 +195,7 @@ const NOT_AN_ADDRESS: &str =
"That isn't a server address. It should look like https://notes.example.com.";
/// A server that answered with no AppImage. Never "up to date": the app would sit
/// on its build forever believing it current, which is #2183's shape again.
/// on its build forever believing it current.
fn no_server_build(base: &str) -> String {
format!(
"{base} has no desktop build to update from. Ask whoever runs it, or switch \
@@ -256,16 +251,15 @@ fn self_update_blocker() -> Option<String> {
/// adopted. Called once at startup, before the frontend can ask what the channel is.
///
/// The installer knows which channel the user asked for; the app is what acts on it.
/// Until this existed, `install.sh --channel dev` left the pref at its `stable`
/// default, so a dev build checked the stable feed — which advertises an OLDER
/// version — and reported "up to date" forever (issue 2183). Two places held the same
/// decision and only one of them was set.
/// Without this, `install.sh --channel dev` would leave the pref at its `stable`
/// default, and a dev build would check the stable feed, which advertises an OLDER
/// version, and report "up to date" forever.
///
/// Comparing against the last-adopted value, rather than only seeding when the pref is
/// unset, is what makes both directions work: choosing a channel in the app sticks
/// across launches (the marker hasn't changed since we adopted it), while re-running
/// the installer on a DIFFERENT channel is honoured (it has). Seeding-when-unset would
/// have fixed only the very first install and left the second one silently wrong.
/// the installer on a DIFFERENT channel is honoured (it has). Seeding only when unset
/// would honour the first install and silently ignore the second.
///
/// Every failure is logged and stepped over. No marker at all is the ordinary state of
/// a build installed some other way — from source, from a downloaded AppImage, or by a
@@ -552,9 +546,8 @@ mod tests {
}
/// A scratch path nothing else will collide with. Process id keeps concurrent
/// `cargo test` runs apart, the counter keeps tests within one run apart — which
/// is all uuid was doing here, without the dependency (the core owns uuid now,
/// and pulling it into the desktop crate for two test filenames would be silly).
/// `cargo test` runs apart, the counter keeps tests within one run apart — without
/// a uuid dependency for two test filenames.
fn scratch_path(tag: &str) -> std::path::PathBuf {
static SEQ: AtomicU32 = AtomicU32::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
@@ -613,7 +606,7 @@ mod tests {
#[test]
fn reinstalling_on_a_different_channel_moves_the_app() {
// The half a seed-only-when-unset fix would have missed: install stable, then
// The case seeding only when unset would miss: install stable, then
// install dev. The pref is already set, but the user just asked for dev.
let db = db();
adopt_installer_channel(&db, &data_dir_with_marker("stable"));
@@ -649,7 +642,7 @@ mod tests {
#[test]
fn no_channel_feed_is_named_like_a_branch() {
// A release tag spelled like a branch makes `git push origin <branch>`
// ambiguous once the tag is fetched (Scribe #2184). `dev` and `main` are the
// ambiguous once the tag is fetched. `dev` and `main` are the
// branches this repo pushes.
for channel in [Channel::Stable, Channel::Dev] {
assert!(
@@ -718,7 +711,7 @@ mod tests {
#[test]
fn with_no_server_recorded_updates_come_from_the_forge() {
// The operator's own installs: nothing here may change for them.
// An install that never came from a server keeps the forge feed it always had.
let db = db();
assert_eq!(read_source(&db), Ok(Source::Forge(Channel::Stable)));
set_channel(&db, Channel::Dev);