core: shared notes on the desktop and phone, and Share from the desktop
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m14s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 9m20s

The core pulls with shares from a server offering them (protocol 6): a note
says how it is held (owner, edit, view) and who shared it, and a revoked note
leaves the device. The first such pull starts the feed over once, so notes
shared before this build arrive. The store refuses what a share doesn't allow
(view: everything; edit: anything but the text), push sends only the text of
someone else's note, and their notes stay out of trash, reminders and
reordering. Unlinking drops them.

The Share dialog's calls go to the linked server over the device token, as
Tauri commands and through the FFI. The desktop now offers Share and "Shared
with me"; unlinked, the dialog says sharing needs a server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 15:33:16 -04:00
co-authored by Claude Opus 5.5
parent 75928c7afd
commit aa36b43dc3
21 changed files with 996 additions and 53 deletions
+120 -14
View File
@@ -149,6 +149,18 @@ pub fn apply_page(conn: &Connection, page: &wire::ChangesPage) -> rusqlite::Resu
summary.notes_applied += 1;
}
// After the notes, never before: a page can carry a note AND its revocation only
// when the revocation is the newer of the two (sharing again deletes an older
// one on the server), so the revocation is the one that has to win. Only a note
// someone else owns can be revoked; this account's own are never touched.
for id in &page.revoked {
let removed = tx.execute(
"DELETE FROM notes WHERE id = ?1 AND permission <> 'owner'",
params![id],
)?;
summary.notes_deleted += removed;
}
state::set_cursor(&tx, page.cursor)?;
tx.commit()?;
Ok(summary)
@@ -239,23 +251,38 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
};
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time
// never changes, and the server's copy is the same value anyway.
// A server without `shares` sends no permission, and every note it sends is ours.
let permission = match note.permission.as_deref() {
Some("edit") => "edit",
Some("view") => "view",
_ => "owner",
};
let (shared_by_id, shared_by_name) = match &note.shared_by {
Some(by) => (Some(by.id.as_str()), Some(by.display_name.as_str())),
None => (None, None),
};
conn.execute(
"INSERT INTO notes (id, body, position, pinned, archived,
trashed, remind_at, recurrence, created_at, updated_at,
sync_revision, trashed_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0)
sync_revision, trashed_at, dirty,
permission, shared, shared_by_id, shared_by_name)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0, ?13, ?14, ?15, ?16)
ON CONFLICT(id) DO UPDATE SET
body = excluded.body,
position = excluded.position,
pinned = excluded.pinned,
archived = excluded.archived,
trashed = excluded.trashed,
remind_at = excluded.remind_at,
recurrence = excluded.recurrence,
updated_at = excluded.updated_at,
sync_revision = excluded.sync_revision,
trashed_at = excluded.trashed_at,
dirty = 0",
body = excluded.body,
position = excluded.position,
pinned = excluded.pinned,
archived = excluded.archived,
trashed = excluded.trashed,
remind_at = excluded.remind_at,
recurrence = excluded.recurrence,
updated_at = excluded.updated_at,
sync_revision = excluded.sync_revision,
trashed_at = excluded.trashed_at,
dirty = 0,
permission = excluded.permission,
shared = excluded.shared,
shared_by_id = excluded.shared_by_id,
shared_by_name = excluded.shared_by_name",
params![
note.id,
note.body,
@@ -269,6 +296,10 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
updated,
note.sync_revision,
trashed_at,
permission,
note.shared,
shared_by_id,
shared_by_name,
],
)?;
@@ -409,6 +440,7 @@ pub async fn run(
blobs: &BlobStore,
base_url: &str,
token: &str,
shares: bool,
) -> Result<PullSummary, String> {
let mut total = PullSummary::default();
@@ -418,7 +450,7 @@ pub async fn run(
state::read(&conn).map_err(|e| e.to_string())?.last_cursor
};
let page = client::fetch_changes(base_url, token, since).await?;
let page = client::fetch_changes(base_url, token, since, shares).await?;
// Trust the data over the flag: a server that claims more pages without
// advancing the cursor would spin this loop forever.
@@ -505,6 +537,9 @@ mod tests {
labels: vec![],
attachments: vec![],
previews: vec![],
permission: None,
shared: false,
shared_by: None,
}
}
@@ -525,6 +560,7 @@ mod tests {
labels,
cursor,
has_more: false,
revoked: vec![],
}
}
@@ -565,6 +601,76 @@ mod tests {
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
}
fn shared_note(id: &str, revision: i64, permission: &str) -> wire::Note {
let mut n = note(id, revision);
n.permission = Some(permission.into());
n.shared = true;
n.shared_by = Some(wire::SharedBy {
id: "u-owner".into(),
display_name: "Robin".into(),
});
n
}
#[test]
fn a_shared_note_lands_saying_who_shared_it_and_how() {
let conn = db();
apply_page(&conn, &page(vec![shared_note("n1", 1, "edit")], vec![], 1)).expect("apply");
let held: (String, i64, String) = conn
.query_row(
"SELECT permission, shared, shared_by_name FROM notes WHERE id = 'n1'",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.expect("row");
assert_eq!(held, ("edit".to_string(), 1, "Robin".to_string()));
// A server without shares sends no permission: the note is this account's own.
apply_page(&conn, &page(vec![note("n2", 2)], vec![], 2)).expect("apply");
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM notes WHERE id = 'n2' AND permission = 'owner'"
),
1
);
}
#[test]
fn a_revoked_note_leaves_and_an_owned_one_never_does() {
let conn = db();
apply_page(
&conn,
&page(
vec![shared_note("theirs", 1, "view"), note("mine", 2)],
vec![],
2,
),
)
.expect("apply");
let mut revoked = page(vec![], vec![], 3);
revoked.revoked = vec!["theirs".into(), "mine".into(), "unknown".into()];
let summary = apply_page(&conn, &revoked).expect("apply");
assert_eq!(summary.notes_deleted, 1);
let left: Vec<String> = {
let mut stmt = conn.prepare("SELECT id FROM notes").unwrap();
let rows = stmt.query_map([], |r| r.get(0)).unwrap();
rows.collect::<rusqlite::Result<_>>().unwrap()
};
assert_eq!(left, ["mine"]);
}
#[test]
fn a_revocation_beside_its_note_in_one_page_wins() {
// The server deletes an older revocation when it shares again, so a page holds
// both only when the revocation is the newer: the note must not survive it.
let conn = db();
let mut both = page(vec![shared_note("n1", 4, "view")], vec![], 5);
both.revoked = vec!["n1".into()];
apply_page(&conn, &both).expect("apply");
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
}
#[test]
fn trashed_is_not_a_tombstone() {
// `trashed` is ordinary state that keeps syncing; only `purged_at` deletes.