AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -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()?)))
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")];
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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\"" \
|
||||
|
||||
@@ -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 ;;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
//!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¬e_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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user