Core and desktop comments describe the code to a stranger (#6224)
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 1m3s
CI & Build / integration (push) Successful in 1m37s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m3s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m42s
Android / Build the server image (push) Successful in 1s
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Successful in 1m3s
CI & Build / integration (push) Successful in 1m37s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m3s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m42s
Android / Build the server image (push) Successful in 1s
Milestone 546 "Bring the repo to the comment standard (rule 32) and compose defaults (rule 198)", step #6224 "Comment standard sweep: Rust core and desktop client". Doc comments and comments across inkwell-core, the Tauri app and desktop/packaging now say what the code does and the non-obvious why in the present tense. Gone: milestone tags (M10.x, M11.3, M13, M304, M315, M314, milestone 325), issue and Scribe-note numbers, family-idea and family-rule citations, the run numbers and commit hashes behind past bugs, and the narration of what used to be (the stable channel before it held its own bundles, CARGO_PKG_VERSION as the shown version, the MANIFEST_TAG split, the store's missing tests, the old PKGBUILD). The schema's migration notes keep what each step does and why, without "until now". The forge is called the forge rather than Forgejo. Hazards stay as facts: the dev-rolling tag that must not shadow the branch, the manifest matched on this build's version, the APK's fixed name surviving the prune, the LIKE guard in v9, -wal/-shm moving with the renamed store, panic = abort ending the app. 34 files, comments only: a comment-stripping diff of every changed file against HEAD is identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Rust workspace. The framework-free client core, and the two shims that wrap it:
|
# 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.
|
# 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
|
# 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]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["core", "desktop/src-tauri", "android/ffi", "android/bindgen"]
|
members = ["core", "desktop/src-tauri", "android/ffi", "android/bindgen"]
|
||||||
|
|||||||
+8
-9
@@ -9,15 +9,15 @@ edition = "2021"
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
log = { 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.
|
# 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.
|
# SQLite's own lower() folds ASCII only, and the server folds every letter.
|
||||||
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
|
rusqlite = { version = "0.32", features = ["bundled", "functions"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
# RFC3339 timestamps for created_at/updated_at/remind_at (Date.parse-able on the JS side).
|
# 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"] }
|
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
|
# 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
|
# 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
|
# Without this the build dies at `openssl-sys`: "Could not find directory of
|
||||||
# OpenSSL installation".
|
# OpenSSL installation".
|
||||||
#
|
#
|
||||||
# `vendored` compiles OpenSSL from source with the NDK toolchain. The alternative
|
# `vendored` compiles OpenSSL from source with the NDK toolchain. Not rustls on
|
||||||
# was rustls on Android only, which builds faster — but rustls ships its own root
|
# Android, though it builds faster: rustls ships its own root store, so the phone
|
||||||
# store, so the phone would trust a DIFFERENT set of certificates than the desktop
|
# would trust a DIFFERENT set of certificates than the desktop does, and a
|
||||||
# does. A self-hosted server behind a private or enterprise CA would then work on
|
# self-hosted server behind a private or enterprise CA would work on one surface
|
||||||
# one surface and fail on another, and "the surfaces behave the same" is worth more
|
# and fail on another.
|
||||||
# than build minutes.
|
|
||||||
#
|
#
|
||||||
# Declared as a direct dependency purely to turn the feature on: cargo's feature
|
# 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.
|
# 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;
|
//! 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
|
//! 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
|
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! - `local` — the source of truth. Works with no server and no account.
|
//! - `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.
|
//! - `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
|
//! letters, digits, `_` and `-`. On save these become labels attached with
|
||||||
//! `via_tag = true`. The server and the web run the same cases
|
//! `via_tag = true`. The server and the web run the same cases
|
||||||
//! (core/testdata/grammar.json).
|
//! (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
|
//! table of items beside it, so a list can sit between two paragraphs instead of
|
||||||
//! only after them.
|
//! only after them.
|
||||||
//!
|
//!
|
||||||
@@ -17,9 +17,6 @@
|
|||||||
//! and the rewriters below are how one is edited.
|
//! and the rewriters below are how one is edited.
|
||||||
//!
|
//!
|
||||||
//! Dedupes case-insensitively, preserving first-seen order.
|
//! 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.
|
/// 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;
|
let mut i = 0;
|
||||||
while i < chars.len() {
|
while i < chars.len() {
|
||||||
if chars[i] == '#' {
|
if chars[i] == '#' {
|
||||||
// Start of line or after whitespace, and nothing else. Any non-tag
|
// Start of line or after whitespace, and nothing else, so `(#todo)` and a
|
||||||
// character used to count, which made `(#todo)` a tag here and plain
|
// pasted URL's `/#section` are not tags. All three implementations share
|
||||||
// text on the server, and made the `/#section` of a pasted URL a label.
|
// this rule.
|
||||||
// Whitespace is the rule all three implementations now share (#5166).
|
|
||||||
let boundary = i == 0 || chars[i - 1].is_whitespace();
|
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).
|
// 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() {
|
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.
|
/// 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
|
/// The card colours a tag where it was typed rather than repeating it as a chip.
|
||||||
/// colours the token where it was typed instead. To do that a renderer needs the
|
/// To do that a renderer needs the
|
||||||
/// SPAN, not just the name, and asking it to find the name again would be a second
|
/// 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.
|
/// grammar quietly disagreeing with this one about what `##a` or `#1` is.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -142,8 +138,8 @@ fn collapse_blank_runs(text: &str) -> String {
|
|||||||
/// else is left exactly as written.
|
/// else is left exactly as written.
|
||||||
///
|
///
|
||||||
/// The MIRROR of `split_body_tags` in the server's `notes/tags.py`, and it has to stay
|
/// 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
|
/// one: a note lifted differently here than there would change the moment it synced.
|
||||||
/// moment it synced. Same discipline, and the same reason, as `DerivedTint`.
|
/// Same discipline, and the same reason, as `DerivedTint`.
|
||||||
///
|
///
|
||||||
/// The conservative reading of "standalone" is deliberate. A trailing tag is
|
/// 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,
|
/// 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
|
/// `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
|
/// 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
|
/// one. `inline` tags stay derived. That is what `via_tag` means: backed by text still
|
||||||
/// here on — backed by text still in the body.
|
/// in the body.
|
||||||
pub fn lift_standalone_tags(body: &str) -> (Vec<String>, Vec<String>, String) {
|
pub fn lift_standalone_tags(body: &str) -> (Vec<String>, Vec<String>, String) {
|
||||||
let mut standalone: Vec<String> = Vec::new();
|
let mut standalone: Vec<String> = Vec::new();
|
||||||
let mut inline: 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
|
/// 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
|
/// 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
|
/// 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 {
|
pub(crate) fn fold(name: &str) -> String {
|
||||||
name.to_lowercase()
|
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.
|
// 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
|
/// 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
|
/// its identity; the wire carries text and checked, never an id.
|
||||||
/// wire ever carried (`push.rs` sent text and checked, never an id).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct DerivedItem {
|
pub struct DerivedItem {
|
||||||
pub text: String,
|
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
|
/// 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.
|
/// 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).
|
/// The items ARE lines of the body, so it strips the task marker off: a list-only
|
||||||
/// What it needs instead is to strip the task marker off: a list-only note is still
|
/// note is named by its first item, and calling that note "- [ ] milk" would be showing
|
||||||
/// 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
|
/// 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.
|
/// naming the note "", which is what a half-typed list would otherwise do.
|
||||||
pub fn display_title(body: &str) -> String {
|
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
|
/// 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
|
/// 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
|
/// cosmetic: the migrations that fold checklist rows into bodies use the same layout,
|
||||||
/// layout, so an export taken before the migration and one taken after have to
|
/// so every surface lands on byte-identical bodies.
|
||||||
/// agree byte for byte.
|
|
||||||
///
|
///
|
||||||
/// `checked` is a parameter rather than always false because the two migrations that
|
/// `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
|
/// 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 ──────────────────────────────────────────────
|
// ── lifting standalone tags ──────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case.
|
// 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
|
// A note lifted differently here than there would change the moment it synced,
|
||||||
// moment it synced, so these are the cases that file agrees to.
|
// so these are the cases that file agrees to.
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lifts_a_line_that_is_nothing_but_tags() {
|
fn lifts_a_line_that_is_nothing_but_tags() {
|
||||||
@@ -662,8 +655,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn append_spaces_like_the_exporter() {
|
fn append_spaces_like_the_exporter() {
|
||||||
// Prose then a blank line then the list — byte-for-byte what
|
// Prose then a blank line then the list — byte-for-byte what
|
||||||
// import_export.py:_note_markdown writes, which is what the server
|
// import_export.py:_note_markdown writes, which is the layout the server
|
||||||
// migration will fold existing rows into.
|
// migration folds rows into.
|
||||||
assert_eq!(append_item("a note", "milk", false), "a note\n\n- [ ] milk");
|
assert_eq!(append_item("a note", "milk", false), "a note\n\n- [ ] milk");
|
||||||
// Nothing between consecutive items.
|
// Nothing between consecutive items.
|
||||||
let one = "a note\n\n- [ ] milk";
|
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.
|
/// Open a migrated, in-memory store.
|
||||||
///
|
///
|
||||||
/// Exists so a CONSUMER can test against a real schema without taking a rusqlite
|
/// 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
|
/// dependency of its own just to build a `Db`.
|
||||||
/// crate was doing before the core was extracted. The Android bindings will want the
|
|
||||||
/// same thing.
|
|
||||||
pub fn open_in_memory() -> rusqlite::Result<Db> {
|
pub fn open_in_memory() -> rusqlite::Result<Db> {
|
||||||
Ok(Db(Mutex::new(memory_conn()?)))
|
Ok(Db(Mutex::new(memory_conn()?)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ pub struct Note {
|
|||||||
pub previews: Vec<LinkPreview>,
|
pub previews: Vec<LinkPreview>,
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
pub updated_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
|
/// someone shared with it. Named and valued as the server's, so the shared
|
||||||
/// frontend gates the editor identically either way.
|
/// frontend gates the editor identically either way.
|
||||||
pub permission: String,
|
pub permission: String,
|
||||||
@@ -119,7 +119,7 @@ pub struct Label {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub count: Option<i64>,
|
pub count: Option<i64>,
|
||||||
/// The newest `updated_at` among the untrashed notes carrying it, or None when
|
/// 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>,
|
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
|
//! 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
|
//! 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() {
|
fn a_note_just_inside_the_window_survives() {
|
||||||
// The comparison is STRICTLY older than the cutoff, so a note with a minute
|
// 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
|
// 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,
|
// wall clock — the sweep reads `now` microseconds after the row is stamped.
|
||||||
// which is precisely how the first version of this test failed.
|
|
||||||
let conn = db();
|
let conn = db();
|
||||||
let almost = Duration::days(30) - Duration::minutes(1);
|
let almost = Duration::days(30) - Duration::minutes(1);
|
||||||
trashed_note_aged(&conn, "boundary", almost);
|
trashed_note_aged(&conn, "boundary", almost);
|
||||||
|
|||||||
+35
-44
@@ -112,13 +112,11 @@ CREATE TABLE sync_state (
|
|||||||
INSERT INTO sync_state (id) VALUES (1);
|
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
|
// A permanent delete is recorded here until the server acknowledges it. Dropping the
|
||||||
// ever existed. Offline, that means the delete can never be pushed — and the next
|
// row alone leaves no record that it existed, so an offline delete could never be
|
||||||
// pull would faithfully resurrect the note from the server. A deletion that undoes
|
// pushed and the next pull would resurrect the note.
|
||||||
// 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.
|
|
||||||
const SCHEMA_V2: &str = r#"
|
const SCHEMA_V2: &str = r#"
|
||||||
CREATE TABLE pending_deletes (
|
CREATE TABLE pending_deletes (
|
||||||
entity TEXT NOT NULL, -- 'note' | 'label'
|
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,
|
// 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.
|
// 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;
|
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
|
// Retention needs an age: without a timestamp there is no telling a note trashed this
|
||||||
// Trash view and nothing else. Retention needs an age: without a timestamp there is
|
// morning from one trashed last spring, so an offline device could never expire its
|
||||||
// no way to tell a note trashed this morning from one trashed last spring, so an
|
// own trash, and the UI couldn't warn anyone before it did.
|
||||||
// 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.
|
// 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
|
// Once linked, the server's policy is the one that applies, so showing this device's
|
||||||
// device's offline default would put a countdown on screen that doesn't match what
|
// offline default would put a countdown on screen that doesn't match what happens.
|
||||||
// happens — a wrong deadline is worse than none.
|
|
||||||
const SCHEMA_V4: &str = r#"
|
const SCHEMA_V4: &str = r#"
|
||||||
ALTER TABLE notes ADD COLUMN trashed_at TEXT;
|
ALTER TABLE notes ADD COLUMN trashed_at TEXT;
|
||||||
UPDATE notes SET trashed_at = updated_at WHERE trashed = 1;
|
UPDATE notes SET trashed_at = updated_at WHERE trashed = 1;
|
||||||
ALTER TABLE sync_state ADD COLUMN server_retention_days INTEGER;
|
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
|
// 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
|
// 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
|
// v6: drops `kind`. A checklist is something a note HAS, not something a note IS —
|
||||||
// a note IS — the column was a mode flag with no enum and no constraint behind it,
|
// the column was a mode flag with no enum and no constraint behind it. A note that
|
||||||
// and `note_items` was never tied to it. Dropping it loses nothing: a note that was
|
// was 'list' keeps every one of its items.
|
||||||
// 'list' keeps every one of its items.
|
|
||||||
//
|
//
|
||||||
// SQLite has supported DROP COLUMN since 3.35 (2021); rusqlite bundles well past it.
|
// SQLite has supported DROP COLUMN since 3.35 (2021); rusqlite bundles well past it.
|
||||||
const SCHEMA_V6: &str = r#"
|
const SCHEMA_V6: &str = r#"
|
||||||
ALTER TABLE notes DROP COLUMN kind;
|
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 —
|
// 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).
|
// 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;
|
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
|
// 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.
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// v9 (M315): `notes.color` is gone. A card is one neutral surface now and colour lives
|
// v9: drops `notes.color`. A card is one neutral surface and colour lives only on a
|
||||||
// only on a tag, so the column was written by a picker nothing read and read by nothing
|
// tag; `labels.color` is untouched.
|
||||||
// at all. `labels.color` is untouched — that is the colour that survived.
|
|
||||||
//
|
//
|
||||||
// The saved-filter sweep is the second half and not optional. `params` is opaque JSON
|
// 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
|
// 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
|
// 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.
|
// corrupt blob must keep whatever it holds, not become NULL.
|
||||||
//
|
//
|
||||||
@@ -275,12 +268,11 @@ UPDATE saved_filters
|
|||||||
AND params LIKE '%"color"%';
|
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
|
// A file attached here, offline, is not on the server yet, and `uploaded = 0` is the
|
||||||
// the server. A file attached here, offline, is not, and `uploaded = 0` is the queue
|
// queue push drains once the note has landed. Rows from before this step all came
|
||||||
// push drains once the note has landed. The rows already here all came from the
|
// from the server, which is why the default is 1.
|
||||||
// server, which is why the default is 1.
|
|
||||||
//
|
//
|
||||||
// `upload_error` holds a refusal that retrying won't fix — over the size limit, an id
|
// `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
|
// 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;
|
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
|
// `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
|
// 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.
|
// 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.
|
// `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
|
// first pull from a server offering `shares` starts again from zero (see
|
||||||
// `engine::run_cycle`). A pull applies idempotently, so starting over costs a
|
// `engine::run_cycle`). A pull applies idempotently, so starting over costs a
|
||||||
// download and nothing else.
|
// 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;
|
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
|
// `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
|
// 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
|
// 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.
|
// 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
|
// `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.
|
// more: the shared notes it holds carry their owner's pins until it does.
|
||||||
const SCHEMA_V12: &str = r#"
|
const SCHEMA_V12: &str = r#"
|
||||||
ALTER TABLE notes ADD COLUMN state_at TEXT;
|
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.
|
// own and nothing else holds a copy.
|
||||||
const SCHEMA_V13: &str = r#"
|
const SCHEMA_V13: &str = r#"
|
||||||
DROP TABLE saved_filters;
|
DROP TABLE saved_filters;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
// v14 (#5385): tag names compare by `fold`, which lowercases every letter. Until now
|
// v14: tag names compare by `fold`, which lowercases every letter. SQLite's lower()
|
||||||
// the index and the queries used SQLite's lower(), which folds ASCII only, so "Café"
|
// folds ASCII only, so with it "Café" and "CAFÉ" could be two tags on a device while
|
||||||
// and "CAFÉ" could be two tags on a device while the server held one.
|
// the server held one.
|
||||||
//
|
//
|
||||||
// Pairs that already exist are merged before the index can be rebuilt. THE OLDER ROW
|
// 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
|
// 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 {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn v7_db() -> Connection {
|
||||||
let conn = Connection::open_in_memory().expect("open");
|
let conn = Connection::open_in_memory().expect("open");
|
||||||
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
|
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
|
||||||
@@ -602,8 +594,7 @@ mod tests {
|
|||||||
.collect::<rusqlite::Result<Vec<String>>>()
|
.collect::<rusqlite::Result<Vec<String>>>()
|
||||||
.expect("collect");
|
.expect("collect");
|
||||||
assert!(!columns.iter().any(|c| c == "color"));
|
assert!(!columns.iter().any(|c| c == "color"));
|
||||||
// The one that survived. Getting this wrong would take every tag's colour with
|
// The one that stays. Getting this wrong would take every tag's colour with it.
|
||||||
// it, which is the whole thing M315 was keeping.
|
|
||||||
let mut stmt = conn.prepare("PRAGMA table_info(labels)").expect("pragma");
|
let mut stmt = conn.prepare("PRAGMA table_info(labels)").expect("pragma");
|
||||||
let label_columns: Vec<String> = stmt
|
let label_columns: Vec<String> = stmt
|
||||||
.query_map([], |r| r.get::<_, String>(1))
|
.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.
|
/// 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
|
/// A `- [ ] milk` line IS the item. The id is the item's ORDINAL rather than a uuid:
|
||||||
/// uuid — which is all it ever amounted to anyway, since `push.rs` sent text and
|
/// sync carries text and checked, never an id, and the ordinal is what the rewriters
|
||||||
/// checked and never an id, and both sides replaced the whole list on every sync. It
|
/// in `derive` take, so a UI holding an id can act on it directly.
|
||||||
/// is also exactly what the rewriters in `derive` take, so a UI holding an id can act
|
|
||||||
/// on it directly.
|
|
||||||
fn items_of(body: &str) -> Vec<ChecklistItem> {
|
fn items_of(body: &str) -> Vec<ChecklistItem> {
|
||||||
derive::extract_items(body)
|
derive::extract_items(body)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -122,10 +120,9 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
|||||||
permission, shared, shared_by_id, shared_by_name
|
permission, shared, shared_by_id, shared_by_name
|
||||||
FROM notes WHERE id = ?1",
|
FROM notes WHERE id = ?1",
|
||||||
[id],
|
[id],
|
||||||
// By NAME, not by position. Dropping `color` shifted every column after it
|
// By NAME, not by position: dropping a column shifts every one after it, and
|
||||||
// and the two timestamps were missed (fa89da1): created_at read updated_at
|
// a positional read would then load the wrong timestamps with nothing
|
||||||
// and updated_at read trashed_at, with nothing failing. A name either
|
// failing. A name either resolves or errors.
|
||||||
// resolves or errors.
|
|
||||||
|r| {
|
|r| {
|
||||||
Ok(Note {
|
Ok(Note {
|
||||||
id: r.get("id")?,
|
id: r.get("id")?,
|
||||||
@@ -166,11 +163,11 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
|||||||
Ok(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
|
// 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
|
// 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;
|
// 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,
|
// 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.
|
// 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
|
/// Attach the note's tag labels, LIFT its standalone tags out of the body, and write
|
||||||
/// the shortened body back.
|
/// the shortened body back.
|
||||||
///
|
///
|
||||||
/// NAMED FOR THE MUTATION. It used to be `sync_tags` and only touched label rows; it
|
/// NAMED FOR THE MUTATION: it rewrites `notes.body`, and every caller writes the body
|
||||||
/// now rewrites `notes.body`, and every caller writes the body just before calling —
|
/// just before calling, so this overwrites what they wrote, on purpose.
|
||||||
/// so this overwrites what they wrote, on purpose.
|
|
||||||
///
|
///
|
||||||
/// `display_title` needs no attention here, unlike on the server: the core derives it
|
/// `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
|
/// on READ (`derive::display_title`, called from `load_note`) rather than storing
|
||||||
/// it, so there is no persisted copy to go stale.
|
/// 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`
|
/// 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
|
/// standalone lifted out, attached as an ORDINARY label. Nothing derives it, and
|
||||||
/// more, and the way to remove it becomes the chip's ×.
|
/// the way to remove it is the chip's ×.
|
||||||
/// inline left in place, attached via_tag = 1, still detached when its text
|
/// inline left in place, attached via_tag = 1, detached when its text goes.
|
||||||
/// goes. Unchanged from before.
|
|
||||||
///
|
///
|
||||||
/// Mirrors `_lift_and_reconcile_tags` in the server's `notes/tags.py`.
|
/// 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<()> {
|
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(f) = &q.facets {
|
||||||
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
|
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
|
||||||
// One placeholder, one bind. This pushed the pattern twice after the
|
// One placeholder, one bind: rusqlite refuses a count mismatch, which
|
||||||
// title column went (95aa10c), and rusqlite refuses a count mismatch,
|
// would fail every desktop search.
|
||||||
// so every desktop search failed.
|
|
||||||
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
|
sql.push_str(" AND body LIKE ? ESCAPE '\\'");
|
||||||
binds.push(format!("%{}%", escape_like(text)));
|
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
|
/// 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 const SNOOZE_MAX_MINUTES: i64 = 60 * 24 * 30;
|
||||||
|
|
||||||
pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result<Note> {
|
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 total = ordered_ids.len() as i64;
|
||||||
let at = now();
|
let at = now();
|
||||||
for (i, id) in ordered_ids.iter().enumerate() {
|
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.
|
// stamped as its own state rather than as an edit.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE notes
|
"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.
|
/// 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
|
/// It cannot simply be an UPDATE: `idx_labels_name` is unique on `lower(name)`, so a
|
||||||
/// the bare statement failed with a raw SQLite "UNIQUE constraint failed" that
|
/// bare statement fails with a raw SQLite "UNIQUE constraint failed". Merging is the
|
||||||
/// reached the user as database internals. Merging is the operator's call, and it
|
/// reading that matches what a person means — typing an existing tag's name onto
|
||||||
/// is the reading that matches what a person means — typing an existing tag's name
|
/// this one says "these are the same thing."
|
||||||
/// onto this one says "these are the same thing."
|
|
||||||
///
|
///
|
||||||
/// THE OLDER ROW SURVIVES, and takes the new spelling. Older rather than "the one
|
/// 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
|
/// that already held the name" because age is the property neither participant's
|
||||||
@@ -1118,11 +1111,9 @@ pub fn merge_labels(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
//! The store had no tests of its own until two bugs shipped in it unnoticed:
|
//! Every test here runs a real query against a real schema: reading columns by
|
||||||
//! `load_note` reading its timestamps from the wrong columns after one was
|
//! the wrong position, or binding a pattern twice for one placeholder, is the
|
||||||
//! dropped (fa89da1), and the board's text facet binding its pattern twice for
|
//! kind of thing only that catches.
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -1158,7 +1149,7 @@ mod tests {
|
|||||||
notes.iter().map(|n| n.id.as_str()).collect()
|
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`.
|
/// A note someone else owns, as the feed leaves it: clean, and held at `permission`.
|
||||||
fn shared(conn: &Connection, id: &str, permission: &str) {
|
fn shared(conn: &Connection, id: &str, permission: &str) {
|
||||||
@@ -1583,8 +1574,8 @@ mod tests {
|
|||||||
assert!(edited.labels.is_empty());
|
assert!(edited.labels.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every letter folds, accented ones included, as on the server (#5385). With
|
/// Every letter folds, accented ones included, as on the server; SQLite's lower()
|
||||||
/// SQLite's lower() the second note minted a "CAFÉ" tag of its own.
|
/// would mint a second "CAFÉ" tag.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_tag_spelled_with_different_accented_capitals_is_the_same_tag() {
|
fn a_tag_spelled_with_different_accented_capitals_is_the_same_tag() {
|
||||||
let conn = db();
|
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
|
//! 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
|
//! 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
|
// A synced note's attachment `url` is the SERVER's relative path
|
||||||
// (`/api/notes/<id>/attachments/<aid>`). In the desktop webview that resolves
|
// (`/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
|
/// 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
|
/// `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.
|
/// `<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 {
|
fn content_type_for(mime: &str) -> String {
|
||||||
const RENDERABLE: &[&str] = &["image/", "audio/", "video/"];
|
const RENDERABLE: &[&str] = &["image/", "audio/", "video/"];
|
||||||
let familiar = (RENDERABLE.iter().any(|p| mime.starts_with(p)) && mime != "image/svg+xml")
|
let familiar = (RENDERABLE.iter().any(|p| mime.starts_with(p)) && mime != "image/svg+xml")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! HTTP transport to an Inkwell server.
|
//! HTTP transport to an Inkwell server.
|
||||||
//!
|
//!
|
||||||
//! Covers the compatibility handshake (M10.6) and device-token auth (M10.7a). The
|
//! Covers the compatibility handshake and device-token auth. The engine that moves
|
||||||
//! engine that moves notes — push, pull, cursor — grows on top of the same client,
|
//! notes — push, pull, cursor — is built on the same client,
|
||||||
//! which is why the timeout, identity headers and error vocabulary live here rather
|
//! which is why the timeout, identity headers and error vocabulary live here rather
|
||||||
//! than inline at each call site.
|
//! 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
|
/// 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
|
/// 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
|
/// 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);
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
/// Bulk transfers get much longer: a first full sync can be thousands of notes, and
|
/// 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}"))
|
.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
|
// 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
|
// 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,
|
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.
|
/// reaches whoever is in it at the time.
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
pub struct ShareGroup {
|
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
|
//! The app is local-first: it never *needs* a server. When the user links one, this
|
||||||
//! one, this module decides whether the two can actually talk — before a single
|
//! module decides whether the two can actually talk — before a single note moves.
|
||||||
//! note moves. The sync engine (M10.7) consults it on link and on every sync.
|
//! The sync engine consults it on link and on every sync.
|
||||||
//!
|
//!
|
||||||
//! The contract is two integers per side, versioning the WIRE PROTOCOL separately
|
//! The contract is two integers per side, versioning the WIRE PROTOCOL separately
|
||||||
//! from either program's release version:
|
//! from either program's release version:
|
||||||
@@ -22,28 +22,24 @@ use std::sync::OnceLock;
|
|||||||
|
|
||||||
/// The sync wire protocol this client speaks.
|
/// The sync wire protocol this client speaks.
|
||||||
///
|
///
|
||||||
/// v4 (M315): `color` left the note. NOT a floor raise on either side — see the note
|
/// v4: `color` left the note. NOT a floor raise on either side — see the note on
|
||||||
/// on [`MIN_SERVER_PROTOCOL_VERSION`].
|
/// [`MIN_SERVER_PROTOCOL_VERSION`].
|
||||||
/// v5 (#5168): files attached here upload, and removed attachments and previews are
|
/// v5: files attached here upload, and removed attachments and previews are pushed.
|
||||||
/// pushed. Additive: both go only to a server advertising `attachment_sync`.
|
/// Additive: both go only to a server advertising `attachment_sync`.
|
||||||
/// v6 (#5175): notes shared with this account, asked for with `shares`.
|
/// v6: notes shared with this account, asked for with `shares`.
|
||||||
/// v7 (#5176): this account's own pin, archive and position on a shared note, sent
|
/// v7: this account's own pin, archive and position on a shared note, sent
|
||||||
/// only to a server advertising `shared_state`.
|
/// only to a server advertising `shared_state`.
|
||||||
pub const CLIENT_PROTOCOL_VERSION: u32 = 7;
|
pub const CLIENT_PROTOCOL_VERSION: u32 = 7;
|
||||||
|
|
||||||
/// The oldest server protocol this client can drive — the symmetric half of the
|
/// The oldest server protocol this client can drive — the symmetric half of the
|
||||||
/// server's `min_client_protocol_version`.
|
/// server's `min_client_protocol_version`.
|
||||||
///
|
///
|
||||||
/// STAYS AT 3 ACROSS v4, and the v2 precedent is the reason to say why rather than
|
/// STAYS AT 3 through v4. Dropping a field a client sends and expects back is
|
||||||
/// leave it looking like an oversight. v2 dropped `kind` and `title` and DID move both
|
/// breaking (v2 dropped `kind` and `title` and moved both floors), but `color` is not
|
||||||
/// floors, on the rule that "dropping a field a client sends and expects back is
|
/// expected back: a v3 client reading a v4 server gets `"default"` from serde's
|
||||||
/// breaking". `color` fails that test on the second half: a v3 client reading a v4
|
/// default and draws the colour it derives locally, and a v3 client PUSHING `color` to
|
||||||
/// server gets `"default"` from serde's default and draws the colour it derives
|
/// a v4 server has the key ignored, since the server reads its payload key by key.
|
||||||
/// locally, which is a board that looks exactly like the one it drew yesterday. A v3
|
/// Neither direction errors or loses anything a person can see.
|
||||||
/// 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.
|
|
||||||
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 3;
|
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 3;
|
||||||
|
|
||||||
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the
|
/// 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`.
|
/// 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
|
/// 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.
|
/// parse failure — which would look to the user like they mistyped the URL.
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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
|
/// 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.
|
/// log or gate on client identity without a separate handshake round-trip.
|
||||||
pub fn client_headers() -> [(&'static str, String); 2] {
|
pub fn client_headers() -> [(&'static str, String); 2] {
|
||||||
// `unidentified/unknown`, never a plausible default. Nothing reads this header
|
// `unidentified/unknown`, never a plausible default: a wrong value in a header
|
||||||
// today, which is exactly why a wrong value could sit in it for months: the
|
// nothing checks would sit unnoticed, while an obviously missing one is caught by
|
||||||
// first person to look at a server log is the first person who could catch it,
|
// the first person who reads a server log.
|
||||||
// and only if what they see is obviously a host that never introduced itself.
|
|
||||||
let agent = CLIENT_AGENT
|
let agent = CLIENT_AGENT
|
||||||
.get()
|
.get()
|
||||||
.cloned()
|
.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
|
/// 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
|
/// 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,
|
/// 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
|
/// anyone on the path could read the token and act as this device until it is
|
||||||
@@ -363,7 +358,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_without_protocol_fields_is_too_old() {
|
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 {
|
let info = ServerInfo {
|
||||||
site_name: Some("Inkwell".into()),
|
site_name: Some("Inkwell".into()),
|
||||||
version: Some("0.0.9".into()),
|
version: Some("0.0.9".into()),
|
||||||
@@ -473,10 +468,9 @@ mod tests {
|
|||||||
fn server_info_tolerates_unknown_and_absent_fields() {
|
fn server_info_tolerates_unknown_and_absent_fields() {
|
||||||
// Forward compatibility: a NEWER server sending fields we've never heard of
|
// Forward compatibility: a NEWER server sending fields we've never heard of
|
||||||
// must not break the handshake.
|
// must not break the handshake.
|
||||||
// Versions come from the constants, not literals: this test is about unknown
|
// Versions and features come from the constants, not literals: this test is
|
||||||
// FIELDS, and pinning the numbers made it fail the moment the protocol moved
|
// about unknown FIELDS, and pinned values would fail it whenever the protocol
|
||||||
// to v2 — for a reason that has nothing to do with what it checks. The feature
|
// moves.
|
||||||
// list likewise: a literal one went stale when `attachment_sync` was added.
|
|
||||||
let features: Vec<&str> = REQUIRED_FEATURES
|
let features: Vec<&str> = REQUIRED_FEATURES
|
||||||
.iter()
|
.iter()
|
||||||
.chain(OPTIONAL_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
|
//! 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
|
//! 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,
|
base_url: &str,
|
||||||
token: &str,
|
token: &str,
|
||||||
) -> Result<SyncOutcome, String> {
|
) -> Result<SyncOutcome, String> {
|
||||||
// A device linked to a public plain-http:// address before links were checked
|
// A device linked to a public plain-http:// address stops here, rather than
|
||||||
// stops here, rather than sending its token in the clear on every cycle. Its
|
// sending its token in the clear on every cycle. Its sync error says to re-link
|
||||||
// sync error says to re-link over https:// (family idea #5105, practice 13).
|
// over https://.
|
||||||
super::compat::refuse_public_cleartext(base_url)?;
|
super::compat::refuse_public_cleartext(base_url)?;
|
||||||
|
|
||||||
// What this server can do, asked before anything is sent: files attached here,
|
// 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.
|
//! Linking a device to a server, and unlinking it.
|
||||||
//!
|
//!
|
||||||
//! One flow for every client. The desktop and Android each wrote it out, the same
|
//! One flow for every client. What differs between them is only how the token is
|
||||||
//! steps in the same order; what differs between them is only how the token is
|
|
||||||
//! kept (Android seals it, `state::TokenSeal`) and how the result is reported.
|
//! kept (Android seals it, `state::TokenSeal`) and how the result is reported.
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! The app is local-first: `local` is the source of truth and everything works
|
//! 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.
|
//! 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.
|
//! be talked to at all. Pure decision logic, no I/O.
|
||||||
//! - `client` — HTTP transport: the handshake call and device-token auth.
|
//! - `client` — HTTP transport: the handshake call and device-token auth.
|
||||||
//! - `state` — the persisted link record (server, token, change-feed cursor).
|
//! - `state` — the persisted link record (server, token, change-feed cursor).
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
//! - `pull` / `push` — the two halves of that cycle.
|
//! - `pull` / `push` — the two halves of that cycle.
|
||||||
//! - `wire` — the delta-feed JSON shapes, as the server sends them.
|
//! - `wire` — the delta-feed JSON shapes, as the server sends them.
|
||||||
//! - `blobs` — attachment bytes on disk, filed under their own sha256.
|
//! - `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,
|
//! The UI surface that drives this lives in whichever client is wrapping the crate,
|
||||||
//! not here.
|
//! 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
|
//! 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
|
//! integer cursor is a total-order watermark over both (docs/sync.md). We loop pages
|
||||||
@@ -694,10 +694,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn children_are_replaced_not_merged() {
|
fn children_are_replaced_not_merged() {
|
||||||
// Was written over checklist items; they are lines of the body now (M304), so
|
// A delta is the note's FULL current state, so a child the server dropped has
|
||||||
// attachments carry the point instead. It is the same property either way: a
|
// to disappear locally rather than linger.
|
||||||
// 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 conn = db();
|
||||||
let mut first = note("n1", 1);
|
let mut first = note("n1", 1);
|
||||||
first.attachments = vec![attachment("a1"), attachment("a2")];
|
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
|
//! 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
|
//! 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")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub body: Option<String>,
|
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.
|
/// `None` and the key never reaches the wire.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
@@ -100,8 +100,8 @@ pub struct Change {
|
|||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
/// When this account last pinned, archived or moved a note someone else owns
|
/// 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
|
/// `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
|
/// the server weighs them against this rather than `edited_at`, which stays the
|
||||||
/// time of the text.
|
/// time of the text.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -154,7 +154,7 @@ pub struct Accepts {
|
|||||||
/// failure.
|
/// failure.
|
||||||
pub attachment_sync: bool,
|
pub attachment_sync: bool,
|
||||||
/// This account's own pin, archive and position on a note someone else owns
|
/// This account's own pin, archive and position on a note someone else owns
|
||||||
/// (`shared_state`, #5176).
|
/// (`shared_state`).
|
||||||
pub shared_state: bool,
|
pub shared_state: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,9 +281,9 @@ struct NoteRow {
|
|||||||
recurrence: Option<String>,
|
recurrence: Option<String>,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
updated_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,
|
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>,
|
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
|
//! 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
|
//! 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
|
//! The server address and token come from the CALLER, read with
|
||||||
//! `state::credentials` and the caller's seal. A stored token may be sealed
|
//! `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:…`
|
//! (Android keeps it under a Keystore key), and the server refuses `sealed:…` as a
|
||||||
//! as the bearer token, so every share call was refused (#5381).
|
//! bearer token.
|
||||||
|
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
//! setups. Protecting the database file is the portable trade.
|
//! setups. Protecting the database file is the portable trade.
|
||||||
//!
|
//!
|
||||||
//! A client that has somewhere better to keep a key passes a [`TokenSeal`], and the
|
//! 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
|
//! token is stored sealed. Android does, with a key in the Keystore; the desktop
|
||||||
//! #5105, practice 12); the desktop does not.
|
//! does not.
|
||||||
|
|
||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
use serde::Serialize;
|
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
|
/// 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
|
/// 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
|
/// 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.
|
/// (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
|
/// `sync_state.shares_synced`. Each level changed what the feed says about notes a
|
||||||
/// device may already hold, below the cursor it already has.
|
/// 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;
|
pub const SHARES: i64 = 1;
|
||||||
/// A shared note's pin, archive and position are this account's own (#5176). Before,
|
/// A shared note's pin, archive and position are this account's own. A device still
|
||||||
/// the feed sent the owner's, and a device holding those would keep showing them
|
/// holding the owner's values would show them until something next changed the note,
|
||||||
/// until something next changed the note.
|
/// so reaching this level re-reads the feed.
|
||||||
pub const SHARED_STATE: i64 = 2;
|
pub const SHARED_STATE: i64 = 2;
|
||||||
|
|
||||||
/// Start the change feed over the first time this device pulls at a sharing `level`
|
/// 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(())
|
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.
|
/// after a page has been fully applied.
|
||||||
pub fn set_cursor(conn: &Connection, cursor: i64) -> rusqlite::Result<()> {
|
pub fn set_cursor(conn: &Connection, cursor: i64) -> rusqlite::Result<()> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
# libwayland-* into usr/lib. On many end-user systems (Arch/CachyOS, NVIDIA,
|
# libwayland-* into usr/lib. On many end-user systems (Arch/CachyOS, NVIDIA,
|
||||||
# Wayland) those bundled copies clash with the running kernel driver + Mesa and
|
# 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
|
# 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
|
# fallbacks (WEBKIT_DISABLE_DMABUF_RENDERER etc.) cannot rescue it. Per the
|
||||||
# AppImage excludelist these libraries MUST come from the host. We strip exactly
|
# AppImage excludelist these libraries MUST come from the host. We strip exactly
|
||||||
# that graphics/display subset -- keeping webkit/gtk/javascriptcore bundled for
|
# 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
|
# 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.
|
# already compiled — no makepkg, no Arch container, no second Rust build.
|
||||||
#
|
#
|
||||||
# WHY prebuilt: the source PKGBUILD this replaces required every user to install
|
# WHY prebuilt: a source PKGBUILD would make every user install rust + nodejs +
|
||||||
# rust + nodejs + npm and compile for minutes before they could open the app.
|
# npm and compile for minutes before they could open the app.
|
||||||
# Distribution means shipping a package people can just install, so the one-command
|
# 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
|
# installer (install.sh) can `pacman -U` a real native package on Arch/CachyOS
|
||||||
# instead of falling back to the AppImage.
|
# instead of falling back to the AppImage.
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
# SONAME at runtime, and those sonames are identical on both distros; SQLite is
|
# SONAME at runtime, and those sonames are identical on both distros; SQLite is
|
||||||
# compiled in (rusqlite "bundled"). glibc symbol versioning is forward-compatible,
|
# 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
|
# 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
|
# direction. Using the host's graphics stack is also what avoids the bundled-lib
|
||||||
# (bundled-lib EGL_BAD_PARAMETER black window) fixed.
|
# EGL_BAD_PARAMETER black window.
|
||||||
#
|
#
|
||||||
# A .pkg.tar.* is just a tar whose FIRST entry is .PKGINFO, so we assemble it
|
# 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.
|
# 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}"
|
OUT_DIR="${1:-$REPO_ROOT/target/release/bundle/arch}"
|
||||||
|
|
||||||
PKGNAME="inkwell"
|
PKGNAME="inkwell"
|
||||||
# The names this package used to ship under: `thoughtsync` until the app became
|
# Earlier names of this package: `thoughtsync` and `thoughtsync-desktop`. pacman
|
||||||
# Inkwell (Scribe note 5071), `thoughtsync-desktop` before that. pacman needs both
|
# needs both conflict and replace to retire them: without them a `pacman -U`
|
||||||
# conflict and replace to retire them: without them a `pacman -U` of the renamed
|
# installs ALONGSIDE the old one.
|
||||||
# package installs ALONGSIDE the old one (issue 2075).
|
|
||||||
REPLACES=(thoughtsync thoughtsync-desktop)
|
REPLACES=(thoughtsync thoughtsync-desktop)
|
||||||
PKGREL=1
|
PKGREL=1
|
||||||
PKGDESC="Inkwell desktop — local-first Keep-style thought capture"
|
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.
|
# 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
|
# Not tauri.conf.json: dev builds override the version on the command line and the
|
||||||
# overriding the version on the command line (M10.9) — the file still says 0.1.0, so
|
# file says 0.1.0, so reading it would stamp the package with a version its binary
|
||||||
# the pacman package came out stamped 0.1.0 around a binary reporting 0.1.132. A
|
# doesn't report.
|
||||||
# package that lies about its version is exactly what makes a later "which build is
|
|
||||||
# this?" question unanswerable.
|
|
||||||
# `|| true` so a miss falls through to the explicit error below rather than
|
# `|| true` so a miss falls through to the explicit error below rather than
|
||||||
# aborting on pipefail with no explanation.
|
# aborting on pipefail with no explanation.
|
||||||
# The ORDERING KEY: pacman compares this, and it must match the filename the
|
# 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
|
# 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
|
# 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
|
# 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=()
|
MTREE_ENTRY=()
|
||||||
if command -v bsdtar >/dev/null 2>&1; then
|
if command -v bsdtar >/dev/null 2>&1; then
|
||||||
( cd "$STAGE" && LANG=C bsdtar -czf .MTREE --format=mtree \
|
( 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
|
# Verify the .deb that `cargo tauri build` produced is a correct, installable
|
||||||
# native package — BEFORE the one-command installer starts handing it to people.
|
# 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
|
# WHY: install.sh prefers the .deb on every Debian/Ubuntu machine, and its Depends
|
||||||
# nothing had ever inspected it, let alone installed it. Its Depends line was
|
# line is whatever tauri infers. The failure mode we're guarding is a
|
||||||
# entirely whatever tauri inferred, unread. The failure mode we're guarding is a
|
|
||||||
# user running install.sh and getting either an apt resolution error or an app
|
# 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
|
# that installs and then won't launch because a library it needs was never
|
||||||
# declared.
|
# declared.
|
||||||
@@ -14,10 +13,9 @@
|
|||||||
# 1. Print the control file + contents — the generated metadata becomes ground
|
# 1. Print the control file + contents — the generated metadata becomes ground
|
||||||
# truth in the build log instead of an assumption.
|
# truth in the build log instead of an assumption.
|
||||||
# 2. Naming: the binary is /usr/bin/inkwell and the generated .desktop
|
# 2. Naming: the binary is /usr/bin/inkwell and the generated .desktop
|
||||||
# entry's StartupWMClass matches it. The app used to identify itself three
|
# entry's StartupWMClass matches it. This keeps the .deb — the only channel
|
||||||
# different ways depending on install channel (issue 2075); this is what
|
# whose entry Tauri generates for us — from drifting away from the two we
|
||||||
# keeps the .deb — the only channel whose entry Tauri generates for us —
|
# write by hand.
|
||||||
# from drifting away from the two we write by hand.
|
|
||||||
# 3. dpkg-shlibdeps: the canonical Debian answer for "what does this ELF
|
# 3. dpkg-shlibdeps: the canonical Debian answer for "what does this ELF
|
||||||
# actually need". Compared against what the package declares.
|
# actually need". Compared against what the package declares.
|
||||||
# 4. Every declared dependency resolves to a real package in apt (catches a
|
# 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
|
# 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
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
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.
|
# 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
|
# 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
|
# as kebab-case(productName) with no override, so a productName with a hump
|
||||||
# `thought-sync` (issue 2075). "Inkwell" has no hump to split, so the package and
|
# ("ThoughtSync") splits into `thought-sync`. "Inkwell" has none, so the package
|
||||||
# the command finally agree, and a productName that brought the split back would
|
# and the command agree; a productName that brought a split back would also break
|
||||||
# also break the `conflicts`/`replaces` retiring the old package.
|
# the `conflicts`/`replaces` retiring the old package.
|
||||||
CANON="inkwell"
|
CANON="inkwell"
|
||||||
|
|
||||||
package="$(dpkg-deb -f "$DEB" Package 2>/dev/null || true)"
|
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)."
|
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
|
# 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
|
# and the bundler have diverged — the drift that groups the app under a different
|
||||||
# a different taskbar icon depending on how it was installed.
|
# taskbar icon depending on how it was installed.
|
||||||
entry="$(find "$WORK/root/usr/share/applications" -name '*.desktop' -print -quit 2>/dev/null || true)"
|
entry="$(find "$WORK/root/usr/share/applications" -name '*.desktop' -print -quit 2>/dev/null || true)"
|
||||||
if [ -z "$entry" ]; then
|
if [ -z "$entry" ]; then
|
||||||
note_fail "no .desktop entry in the package — the app would not appear in any menu."
|
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`.
|
# stable (default) — the rolling build from every merge to `main`.
|
||||||
# dev — the rolling build from every green push to `dev`.
|
# 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
|
# 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
|
# current build, so the tag alone names the newest one. The two channels are the
|
||||||
# M314 step 3, when `main` started publishing — before that it was a manifest-only
|
# same shape, and nothing here special-cases either.
|
||||||
# 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.
|
|
||||||
# Pick one with `--channel dev` or `INKWELL_CHANNEL=dev`. Through a pipe the options go
|
# Pick one with `--channel dev` or `INKWELL_CHANNEL=dev`. Through a pipe the options go
|
||||||
# after a `--`: curl -fsSL <url> | sh -s -- --channel dev
|
# after a `--`: curl -fsSL <url> | sh -s -- --channel dev
|
||||||
#
|
#
|
||||||
@@ -36,7 +32,7 @@
|
|||||||
# * everything else (Fedora/openSUSE/…) -> the de-bundled AppImage,
|
# * everything else (Fedora/openSUSE/…) -> the de-bundled AppImage,
|
||||||
# installed user-locally (no sudo). The AppImage's graphics libs are
|
# 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
|
# 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
|
# POSIX sh (dash-safe) so `curl … | sh` works everywhere. Dependency-light and
|
||||||
# auditable on purpose — read it before you pipe it.
|
# auditable on purpose — read it before you pipe it.
|
||||||
@@ -122,9 +118,8 @@ if [ -n "$server" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- architecture gate ------------------------------------------------------
|
# --- architecture gate ------------------------------------------------------
|
||||||
# Only x86_64 is built today; arm64 will be added when the CI matrix grows. The
|
# Only x86_64 is built. The release-asset naming carries the arch, but with one
|
||||||
# release-asset naming carries the arch, but since only one arch ships now we
|
# arch shipping we match by file extension below and just guard the arch here.
|
||||||
# match by file extension below and just guard the arch here.
|
|
||||||
arch="$(uname -m)"
|
arch="$(uname -m)"
|
||||||
case "$arch" in
|
case "$arch" in
|
||||||
x86_64 | amd64) : ;;
|
x86_64 | amd64) : ;;
|
||||||
@@ -148,7 +143,7 @@ fetch() {
|
|||||||
# The download against the digest its server published, BEFORE it reaches pacman,
|
# 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
|
# 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
|
# 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() {
|
verify() {
|
||||||
[ -n "$2" ] || return 0
|
[ -n "$2" ] || return 0
|
||||||
if have sha256sum; then
|
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
|
# channel — which is exactly what an installer wants and what the in-app updater
|
||||||
# already reads.
|
# already reads.
|
||||||
# The channel's RELEASE TAG, which is not always its name: `dev` publishes on the
|
# 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
|
# tag `dev-rolling`, because a tag called `dev` would shadow the branch of the same
|
||||||
# and broke `git push origin dev` (Scribe #2184). Same mapping as
|
# name and break `git push origin dev`. Same mapping as
|
||||||
# packaging/channel-tag.sh — inlined because this script is fetched alone through a
|
# packaging/channel-tag.sh — inlined because this script is fetched alone through a
|
||||||
# pipe and has nothing to source.
|
# pipe and has nothing to source.
|
||||||
case "$channel" in
|
case "$channel" in
|
||||||
@@ -269,9 +264,9 @@ revoke_token() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Tell the app which channel it was installed from. The installer is the only thing
|
# 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
|
# that knows; without this the app keeps its own `stable` default, and a dev install
|
||||||
# checked the stable feed — which advertises an OLDER version — reporting "up to date"
|
# would check the stable feed, which advertises an OLDER version, and report "up to
|
||||||
# forever (issue 2183).
|
# date" forever.
|
||||||
#
|
#
|
||||||
# A plain file rather than a write into the app's SQLite store: shell has no business
|
# 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.
|
# 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.
|
# 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
|
# 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
|
# left alone on that path, because a server's channel is whatever its image was built
|
||||||
# with and nothing here can know it.
|
# with and nothing here can know it.
|
||||||
record_channel() {
|
record_channel() {
|
||||||
@@ -323,8 +318,8 @@ native_update_note() {
|
|||||||
|
|
||||||
# --- native pacman path (Arch/CachyOS/Manjaro) ------------------------------
|
# --- native pacman path (Arch/CachyOS/Manjaro) ------------------------------
|
||||||
# Preferred over the AppImage on Arch: pacman pulls webkit2gtk-4.1 itself and the
|
# 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
|
# app then runs against the host graphics stack, which is what avoids the
|
||||||
# EGL_BAD_PARAMETER black window (issue 2021) from coming back. It also means the
|
# EGL_BAD_PARAMETER black window. It also means the
|
||||||
# app is tracked by the package manager and uninstalls cleanly.
|
# app is tracked by the package manager and uninstalls cleanly.
|
||||||
if have pacman && [ -n "$pkg_url" ]; then
|
if have pacman && [ -n "$pkg_url" ]; then
|
||||||
say "Arch-family system detected — installing the native pacman package"
|
say "Arch-family system detected — installing the native pacman package"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
# 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
|
# 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.
|
# 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
|
# 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
|
# `if: startsWith(github.ref, 'refs/tags/v')`). This script never creates a tag; it
|
||||||
# action (rule 2) — this script never creates a tag, it only publishes a Release
|
# only publishes a Release for a tag that already exists.
|
||||||
# for a tag that already exists.
|
|
||||||
#
|
#
|
||||||
# The build + de-bundle steps run first; this consumes their output:
|
# The build + de-bundle steps run first; this consumes their output:
|
||||||
# target/release/bundle/appimage/*.AppImage (de-bundled)
|
# target/release/bundle/appimage/*.AppImage (de-bundled)
|
||||||
@@ -20,7 +19,7 @@
|
|||||||
# target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
# target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||||
#
|
#
|
||||||
# Instance-agnostic: server + repo come from the runner's github.* context
|
# 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
|
# 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.
|
# 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
|
# 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 —
|
# 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
|
# `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.
|
# `/releases/latest/download/<asset>` route for an updater to point at.
|
||||||
RELEASE_TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}"
|
RELEASE_TAG="${RELEASE_TAG:-$GITHUB_REF_NAME}"
|
||||||
RELEASE_PRERELEASE="${RELEASE_PRERELEASE:-false}"
|
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).
|
# 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
|
# 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.
|
# 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
|
# They only exist when the build ran with a signing key; nullglob drops them
|
||||||
# them silently otherwise, which is the correct behaviour for an unsigned build.
|
# 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 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
|
# — the same nullglob arrangement that lets the Linux and Windows jobs share one
|
||||||
# share one release. Its two files are staged under android/dist by the workflow:
|
# release. Its two files are staged under android/dist by the workflow: a STABLY
|
||||||
# a STABLY NAMED apk (a fixed name is the whole point of the fixed `dev` tag —
|
# NAMED apk (a fixed name is the whole point of the fixed `dev` tag — the forge
|
||||||
# Forgejo has no /releases/latest/download route) and the sidecar the server reads
|
# 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.
|
# its version out of, because an APK keeps that in a binary manifest.
|
||||||
ASSETS=(
|
ASSETS=(
|
||||||
"$BUNDLE_ROOT"/appimage/*.AppImage
|
"$BUNDLE_ROOT"/appimage/*.AppImage
|
||||||
@@ -77,8 +76,7 @@ ASSETS=(
|
|||||||
)
|
)
|
||||||
# nullglob drops PATTERNS that match nothing — it does nothing for a path with no
|
# 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
|
# 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
|
# that isn't there (exit 26). The Android entries above are that shape. Filter on existence
|
||||||
# adding them broke the desktop publish that had been working. Filter on existence
|
|
||||||
# instead, which is what the array actually means and covers every entry rather
|
# instead, which is what the array actually means and covers every entry rather
|
||||||
# than only the ones that happen to contain a `*`.
|
# than only the ones that happen to contain a `*`.
|
||||||
present=()
|
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
|
# — otherwise someone following the instructions here lands on a tagged build and
|
||||||
# wonders why the version they were sent isn't what they got.
|
# 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
|
# 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
|
# release that prunes its own assets behaves differently from a versioned one and a
|
||||||
# reader deserves to know which they are looking at.
|
# 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
|
# 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
|
# named `dev` would shadow the branch. CHANNEL_LABEL is the name a person reads in
|
||||||
# reads in the release title, so the rename does not leak into it.
|
# the release title, so the tag name doesn't leak into it.
|
||||||
if [ "$TAG" = "dev-rolling" ]; then
|
if [ "$TAG" = "dev-rolling" ]; then
|
||||||
CHANNEL_LABEL='dev (rolling)'
|
CHANNEL_LABEL='dev (rolling)'
|
||||||
INSTALL_TAIL='sh -s -- --channel dev'
|
INSTALL_TAIL='sh -s -- --channel dev'
|
||||||
# Backticks BARE, not `\``. The heredoc below is unquoted, so there the backslash
|
# 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.
|
# 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
|
# 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.'
|
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
|
elif [ "$TAG" = "stable" ]; then
|
||||||
CHANNEL_LABEL='stable'
|
CHANNEL_LABEL='stable'
|
||||||
@@ -155,11 +153,11 @@ JSON
|
|||||||
|
|
||||||
# An explicit body, replacing the install instructions above.
|
# An explicit body, replacing the install instructions above.
|
||||||
#
|
#
|
||||||
# Used by the RELEASE lane, whose job is a changelog rather than artifacts (M314
|
# Used by the RELEASE lane, whose job is a changelog rather than artifacts. It goes
|
||||||
# step 7). It goes through this script rather than making its own API calls so that
|
# through this script rather than making its own API calls so that the
|
||||||
# the create-or-PATCH-on-409 path is shared: a fixed-tag release that only ever
|
# create-or-PATCH-on-409 path is shared: a fixed-tag release that only ever POSTs
|
||||||
# POSTs keeps whatever text its FIRST build wrote, which is #2182 exactly, and
|
# keeps whatever text its FIRST build wrote, and a second copy of that path is one
|
||||||
# re-implementing that correctly in a second place is how it comes back.
|
# more place to get it wrong.
|
||||||
#
|
#
|
||||||
# CONTRACT: already JSON-escaped, without surrounding quotes. The caller knows
|
# 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
|
# 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 ------------------------------------------------------
|
# --- upload each asset ------------------------------------------------------
|
||||||
for asset in "${ASSETS[@]}"; do
|
for asset in "${ASSETS[@]}"; do
|
||||||
name="$(basename "$asset")"
|
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".
|
# a duplicate name). Match the "id" that precedes this asset's "name".
|
||||||
old_id="$(printf '%s' "$existing" \
|
old_id="$(printf '%s' "$existing" \
|
||||||
| grep -oE "\"id\"[[:space:]]*:[[:space:]]*[0-9]+[^}]*\"name\"[[:space:]]*:[[:space:]]*\"$name\"" \
|
| 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.
|
# wrong build, and there is nothing downstream that could catch it.
|
||||||
: "${DISPLAY_VERSION:?DISPLAY_VERSION is required (the human-readable version)}"
|
: "${DISPLAY_VERSION:?DISPLAY_VERSION is required (the human-readable version)}"
|
||||||
|
|
||||||
# The manifest is published to the release that HOLDS the bundles. There is no
|
# The manifest is published to the release that HOLDS the bundles: both channels
|
||||||
# second place any more.
|
# are rolling releases that hold their own bundles, so there is no second place.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||||
AUTH=(-H "Authorization: token $GITHUB_TOKEN")
|
AUTH=(-H "Authorization: token $GITHUB_TOKEN")
|
||||||
@@ -66,12 +59,12 @@ platform_entry() {
|
|||||||
local target="$1" pattern="$2" bundle sig_name
|
local target="$1" pattern="$2" bundle sig_name
|
||||||
# Matched on THIS build's version, not just the file extension.
|
# Matched on THIS build's version, not just the file extension.
|
||||||
#
|
#
|
||||||
# The rolling dev release accumulates every build's assets, and picking the first
|
# The rolling dev release accumulates every build's assets, and the first
|
||||||
# extension match returned the OLDEST one — so the manifest advertised the new
|
# extension match would be the OLDEST one: the manifest would advertise the new
|
||||||
# version while pointing at an old binary. The client would install the older
|
# version while pointing at an old binary, and the client would install the older
|
||||||
# build, still be told the newer version was available, and update forever. The
|
# build, still be told the newer one was available, and update forever. The
|
||||||
# signature check couldn't catch it either: the old bundle's signature is
|
# signature check can't catch it: the old bundle's signature is valid FOR THE OLD
|
||||||
# perfectly valid FOR THE OLD BUNDLE.
|
# BUNDLE.
|
||||||
bundle="$(printf '%s\n' "$names" | grep -F "_${APP_VERSION}_" | grep -E "$pattern" | head -1 || true)"
|
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
|
# 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.
|
# 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
|
# the one `publish-release.sh` created or refreshed moments earlier, so it is
|
||||||
# guaranteed to exist by the time this runs.
|
# 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.
|
# and these files are rewritten on every publish by design.
|
||||||
replace_asset() {
|
replace_asset() {
|
||||||
local path="$1" name="$2" escaped old_id
|
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
|
# 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
|
# 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
|
# matches $APP_VERSION here and a version-stamped name would be pruned on
|
||||||
# every desktop push regardless. That is exactly what happened on run
|
# every desktop push regardless.
|
||||||
# 4098, which swept the APK run 4092 had just published.
|
|
||||||
case "$asset_name" in
|
case "$asset_name" in
|
||||||
latest.json|inkwell-desktop.json|inkwell.apk|inkwell-android.json) continue ;;
|
latest.json|inkwell-desktop.json|inkwell.apk|inkwell-android.json) continue ;;
|
||||||
*"$APP_VERSION"*) 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
|
# 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
|
# are overridden per build by `cargo tauri build --config '{"version": ...}'` with
|
||||||
# the value `packaging/version.sh key desktop` derives. See #3144.
|
# the value `packaging/version.sh key desktop` derives.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
description = "Inkwell desktop — local-first Keep-style thought capture"
|
description = "Inkwell desktop — local-first Keep-style thought capture"
|
||||||
authors = ["bvandeusen"]
|
authors = ["bvandeusen"]
|
||||||
@@ -24,9 +17,8 @@ autobins = false
|
|||||||
# App logic lives in the library (Tauri v2 pattern: a single `run()` entry point);
|
# App logic lives in the library (Tauri v2 pattern: a single `run()` entry point);
|
||||||
# main.rs is a thin shim.
|
# main.rs is a thin shim.
|
||||||
#
|
#
|
||||||
# rlib only. The staticlib/cdylib types existed for Tauri mobile, which Android no
|
# rlib only: Android is a native Kotlin client over `inkwell-core`, and the .so it
|
||||||
# longer uses — it is a native Kotlin client over `inkwell-core` instead
|
# loads is built from that crate, not this one.
|
||||||
# (Scribe note 2730), and the .so it loads is built from that crate, not this one.
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "inkwell_desktop_lib"
|
name = "inkwell_desktop_lib"
|
||||||
crate-type = ["rlib"]
|
crate-type = ["rlib"]
|
||||||
@@ -37,7 +29,7 @@ crate-type = ["rlib"]
|
|||||||
# name. (Written without braces on purpose: `tauri android init` round-trips this
|
# name. (Written without braces on purpose: `tauri android init` round-trips this
|
||||||
# file through a TOML serializer that reformats brace tokens inside comments.)
|
# 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
|
# 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
|
# 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.
|
# kebab-casing, which the AppImage bundler applies to the binary name on its way in.
|
||||||
[[bin]]
|
[[bin]]
|
||||||
@@ -49,7 +41,7 @@ tauri-build = { version = "2", features = [] }
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# The framework-free client core: local SQLite store + sync engine. Shared with the
|
# 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" }
|
inkwell-core = { path = "../../core" }
|
||||||
|
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
@@ -59,10 +51,10 @@ log = { workspace = true }
|
|||||||
# Startup + operation logging to stdout AND a persistent file, so portability
|
# Startup + operation logging to stdout AND a persistent file, so portability
|
||||||
# issues are diagnosable from any environment.
|
# issues are diagnosable from any environment.
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
# In-app updates (M10.9). Signature verification is minisign; the public half lives
|
# In-app updates. Signature verification is minisign; the public half lives in
|
||||||
# in tauri.conf.json and the private half only ever as a CI secret. Desktop only —
|
# 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
|
# 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"
|
tauri-plugin-updater = "2"
|
||||||
|
|
||||||
# The system-wide quick-capture hotkey. Desktop only by nature — Android has no
|
# The system-wide quick-capture hotkey. Desktop only by nature — Android has no
|
||||||
@@ -70,7 +62,7 @@ tauri-plugin-updater = "2"
|
|||||||
# intent filter instead.
|
# intent filter instead.
|
||||||
tauri-plugin-global-shortcut = "2"
|
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
|
# 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
|
# timer only runs while its window does. Used from Rust only, so no capability
|
||||||
# grants it to the webview.
|
# grants it to the webview.
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
//! The desktop syncs on its own (#5167).
|
//! 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
|
||||||
//! Until this existed, the only thing that ever called the sync engine was the
|
//! with a periodic WorkManager job and a flush when the app leaves the foreground.
|
||||||
//! "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.
|
|
||||||
//!
|
//!
|
||||||
//! ## When a cycle runs
|
//! ## When a cycle runs
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Tauri command surface for the local store — the offline mirror of the REST API
|
//! 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
|
//! 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
|
//! delegates to `store`; errors are stringified for the JS boundary. adapters/local.ts
|
||||||
//! (M10.5) invokes these.
|
//! invokes these.
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tauri::State;
|
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())
|
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
|
/// 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
|
/// 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
|
//! 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 serde::{Deserialize, Serialize};
|
||||||
use tauri::{AppHandle, State};
|
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())
|
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
|
// The Share dialog's calls, straight to the linked server. Unlinked, each answers
|
||||||
// `sharing::NEEDS_SERVER`, which the dialog shows as it is.
|
// `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
|
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]
|
#[tauri::command]
|
||||||
pub async fn shares_share(
|
pub async fn shares_share(
|
||||||
note_id: String,
|
note_id: String,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! One-time move of a ThoughtSync install's data into Inkwell's.
|
//! 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
|
//! 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
|
//! 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
|
//! 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
|
//! hold committed writes not yet checkpointed into the main file, and a database
|
||||||
//! renamed without them silently loses them.
|
//! 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.
|
//! call in `lib.rs` once no install is still on the old identifier.
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -42,7 +42,7 @@ pub enum Outcome {
|
|||||||
///
|
///
|
||||||
/// Entry by entry rather than renaming the directory, because `new_dir` can already
|
/// 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
|
/// 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
|
/// 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.
|
/// 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> {
|
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
|
// 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
|
// 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
|
// 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.")?;
|
let entry = desktop_entry_path().ok_or("Cannot resolve the applications dir.")?;
|
||||||
if let Some(parent) = entry.parent() {
|
if let Some(parent) = entry.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| format!("create applications dir: {e}"))?;
|
fs::create_dir_all(parent).map_err(|e| format!("create applications dir: {e}"))?;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Inkwell desktop (Tauri v2).
|
//! Inkwell desktop (Tauri v2).
|
||||||
//!
|
//!
|
||||||
//! The window loads the shared Vue 3 frontend (`../../frontend`), which reaches the
|
//! 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`.
|
//! `invoke`.
|
||||||
//!
|
//!
|
||||||
//! This crate is the DESKTOP WRAPPER, not the core. The on-device SQLite store and
|
//! 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.
|
/// 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
|
/// 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
|
/// in the UI, and `log_environment`, which puts it in the log — and one fact belongs
|
||||||
/// spent several issues on one fact held in two places (2181, 2182, 2183).
|
/// in one place.
|
||||||
///
|
///
|
||||||
/// `option_env!`, not `env!`: a local `cargo tauri build` sets nothing, and this has
|
/// `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
|
/// 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
|
/// plausible-looking default: with no version tags, this string is the only answer to
|
||||||
/// "which build is this?" now that there are no version tags, so there is nothing
|
/// "which build is this?", so an honest "I cannot say" is the only safe wrong answer.
|
||||||
/// left to contradict it if it lies. 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
|
/// NOT `CARGO_PKG_VERSION`: `cargo tauri build --config '{"version": ...}'` overrides
|
||||||
/// every build ever shipped: `cargo tauri build --config '{"version": ...}'`
|
/// `tauri.conf.json`, not Cargo's own metadata, so Cargo.toml's literal would reach
|
||||||
/// overrides `tauri.conf.json`, not Cargo's own metadata, so the literal `0.2.0` in
|
/// the UI and the log regardless of what was built.
|
||||||
/// Cargo.toml is what reached the UI and the log regardless of what was built.
|
|
||||||
///
|
///
|
||||||
/// NOT the ordering key either. That value — `1.0.<minutes>`, which the override
|
/// 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
|
/// 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
|
/// filenames and `latest.json` and must never be shown to a person. Two values, two
|
||||||
/// values, two audiences. `update.rs` deliberately still reads the key, through
|
/// audiences. `update.rs` reads the key, through `app.package_info().version`,
|
||||||
/// `app.package_info().version`, because a comparator is exactly what it is.
|
/// because a comparator is exactly what it is.
|
||||||
const DISPLAY_VERSION: Option<&str> = option_env!("INKWELL_DISPLAY_VERSION");
|
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
|
/// 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 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
|
// the Android client binds through uniffi. Aliased to short names so the call
|
||||||
// names so every call site below reads exactly as it did when they were modules of
|
// sites below read as if they were modules of this crate.
|
||||||
// this crate — the extraction changed where they live, not what they are.
|
|
||||||
use inkwell_core::{local, sync};
|
use inkwell_core::{local, sync};
|
||||||
|
|
||||||
/// The app's own window, as `tauri.conf.json` names it. The capture window is the
|
/// The app's own window, as `tauri.conf.json` names it. The capture window is the
|
||||||
@@ -91,7 +87,7 @@ pub fn run() {
|
|||||||
])
|
])
|
||||||
.build(),
|
.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
|
// 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
|
// build without a signing key still starts normally and simply reports that
|
||||||
// updates aren't configured.
|
// updates aren't configured.
|
||||||
@@ -102,8 +98,8 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||||
// System notifications for due reminders, raised by the `reminders` worker.
|
// System notifications for due reminders, raised by the `reminders` worker.
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
// Attachment bytes are served to the webview from the local blob store
|
// 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
|
// Registered on the BUILDER because a scheme has to exist before
|
||||||
// the webview is created; the directory it reads from arrives later, in
|
// the webview is created; the directory it reads from arrives later, in
|
||||||
// `setup`, via `blobs::publish_root`.
|
// `setup`, via `blobs::publish_root`.
|
||||||
.register_uri_scheme_protocol(sync::blobs::BLOB_SCHEME, |_ctx, request| {
|
.register_uri_scheme_protocol(sync::blobs::BLOB_SCHEME, |_ctx, request| {
|
||||||
@@ -143,9 +139,9 @@ pub fn run() {
|
|||||||
let db = local::open(&db_path)?;
|
let db = local::open(&db_path)?;
|
||||||
log::info!("local store ready — {}", local::summary(&db));
|
log::info!("local store ready — {}", local::summary(&db));
|
||||||
// Before anything can ask what channel we're on: the installer left a note
|
// 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);
|
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);
|
update::adopt_installer_server(&db, &dir);
|
||||||
sweep_local_trash(&db);
|
sweep_local_trash(&db);
|
||||||
// Before the store is handed to the app: `restore` needs to read the
|
// 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);
|
capture::restore(app.handle(), &db);
|
||||||
app.manage(db);
|
app.manage(db);
|
||||||
// Attachment bytes live beside the database, filed by content hash, so a
|
// 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))?;
|
let blobs = sync::blobs::BlobStore::new(dir.join(local::BLOBS_DIR))?;
|
||||||
log::info!("attachment store ready: {}", blobs.root().display());
|
log::info!("attachment store ready: {}", blobs.root().display());
|
||||||
// Hand the directory to the URI-scheme handler registered below, which
|
// 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
|
//! 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
|
//! raises a toast and, if allowed, a page `Notification`. Inside this app the page's
|
||||||
//! not enough. The page's timer only does anything while the webview is running
|
//! timer runs only while the webview is running it, and WebKitGTK's page
|
||||||
//! it, and WebKitGTK's page notifications go nowhere. So a reminder that came due
|
//! notifications go nowhere, so on its own it misses a reminder that comes due while
|
||||||
//! while Inkwell sat minimised or behind another window was never announced.
|
//! Inkwell sits minimised or behind another window.
|
||||||
//!
|
//!
|
||||||
//! This worker owns announcing on the desktop instead. Every [`TICK`] it asks the
|
//! 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:
|
//! 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`,
|
//! 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
|
//! `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
|
//! an asset on a release whose TAG NEVER CHANGES — verified necessary, because
|
||||||
//! Forgejo has no `/releases/latest/download/<asset>` route (it 404s), so "newest"
|
//! the forge has no `/releases/latest/download/<asset>` route (it 404s), so "newest"
|
||||||
//! cannot be named in a URL. A fixed tag can.
|
//! 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.
|
||||||
//! `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 feed is Fabled-Git by default, deliberately: this app is usable having never
|
//! 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
|
//! 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.
|
//! 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
|
//! 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
|
//! 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
|
//! 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.
|
//! can pass on official builds; it cannot substitute its own.
|
||||||
//!
|
//!
|
||||||
//! Updates are signed. The public half is baked into `tauri.conf.json`; the private
|
//! 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
|
//! half exists only as a CI secret — a release signing key that has passed through
|
||||||
//! signing key that has passed through anyone else's hands is not a signing key.
|
//! other hands is not a signing key.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -107,10 +102,10 @@ impl Channel {
|
|||||||
|
|
||||||
/// The release tag holding this channel's builds — not the channel's name.
|
/// 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
|
/// A tag named `dev` would shadow the branch of the same name and make
|
||||||
/// name, so `git push origin dev` failed as ambiguous (Scribe #2184). The channel
|
/// `git push origin dev` ambiguous, so the dev channel publishes on `dev-rolling`.
|
||||||
/// is still `dev` everywhere a person sees it and in the stored pref; only the tag
|
/// The channel is `dev` everywhere a person sees it and in the stored pref.
|
||||||
/// moved. Mirrors `packaging/channel-tag.sh`, which is what CI publishes with.
|
/// Mirrors `packaging/channel-tag.sh`, which is what CI publishes with.
|
||||||
fn release_tag(self) -> &'static str {
|
fn release_tag(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Channel::Stable => "stable",
|
Channel::Stable => "stable",
|
||||||
@@ -127,8 +122,8 @@ impl Channel {
|
|||||||
///
|
///
|
||||||
/// `read_source` is THE ONE READER. Three things hold an opinion about this — the
|
/// `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
|
/// 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
|
/// `tauri.conf.json` — and one decision with several holders goes wrong as soon as
|
||||||
/// holders and only one of them set. So: the markers only ever feed the prefs
|
/// 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
|
/// (`adopt_installer_*`), the prefs are what is read, and the config's endpoint is
|
||||||
/// never consulted, because every check below names its own.
|
/// never consulted, because every check below names its own.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[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.";
|
"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
|
/// 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 {
|
fn no_server_build(base: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{base} has no desktop build to update from. Ask whoever runs it, or switch \
|
"{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.
|
/// 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.
|
/// 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`
|
/// Without this, `install.sh --channel dev` would leave the pref at its `stable`
|
||||||
/// default, so a dev build checked the stable feed — which advertises an OLDER
|
/// default, and a dev build would check the stable feed, which advertises an OLDER
|
||||||
/// version — and reported "up to date" forever (issue 2183). Two places held the same
|
/// version, and report "up to date" forever.
|
||||||
/// decision and only one of them was set.
|
|
||||||
///
|
///
|
||||||
/// Comparing against the last-adopted value, rather than only seeding when the pref is
|
/// 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
|
/// 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
|
/// 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
|
/// the installer on a DIFFERENT channel is honoured (it has). Seeding only when unset
|
||||||
/// have fixed only the very first install and left the second one silently wrong.
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// `cargo test` runs apart, the counter keeps tests within one run apart — without
|
||||||
/// is all uuid was doing here, without the dependency (the core owns uuid now,
|
/// a uuid dependency for two test filenames.
|
||||||
/// and pulling it into the desktop crate for two test filenames would be silly).
|
|
||||||
fn scratch_path(tag: &str) -> std::path::PathBuf {
|
fn scratch_path(tag: &str) -> std::path::PathBuf {
|
||||||
static SEQ: AtomicU32 = AtomicU32::new(0);
|
static SEQ: AtomicU32 = AtomicU32::new(0);
|
||||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||||
@@ -613,7 +606,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reinstalling_on_a_different_channel_moves_the_app() {
|
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.
|
// install dev. The pref is already set, but the user just asked for dev.
|
||||||
let db = db();
|
let db = db();
|
||||||
adopt_installer_channel(&db, &data_dir_with_marker("stable"));
|
adopt_installer_channel(&db, &data_dir_with_marker("stable"));
|
||||||
@@ -649,7 +642,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn no_channel_feed_is_named_like_a_branch() {
|
fn no_channel_feed_is_named_like_a_branch() {
|
||||||
// A release tag spelled like a branch makes `git push origin <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.
|
// branches this repo pushes.
|
||||||
for channel in [Channel::Stable, Channel::Dev] {
|
for channel in [Channel::Stable, Channel::Dev] {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -718,7 +711,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn with_no_server_recorded_updates_come_from_the_forge() {
|
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();
|
let db = db();
|
||||||
assert_eq!(read_source(&db), Ok(Source::Forge(Channel::Stable)));
|
assert_eq!(read_source(&db), Ok(Source::Forge(Channel::Stable)));
|
||||||
set_channel(&db, Channel::Dev);
|
set_channel(&db, Channel::Dev);
|
||||||
|
|||||||
Reference in New Issue
Block a user