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
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:
+76
-5
@@ -94,15 +94,42 @@ pub fn set_link(conn: &Connection, server_url: &str, device_token: &str) -> rusq
|
||||
let keep_cursor = read(conn)?.server_url.as_deref() == Some(server_url);
|
||||
conn.execute(
|
||||
"UPDATE sync_state
|
||||
SET server_url = ?1,
|
||||
device_token = ?2,
|
||||
last_cursor = CASE WHEN ?3 THEN last_cursor ELSE NULL END
|
||||
SET server_url = ?1,
|
||||
device_token = ?2,
|
||||
last_cursor = CASE WHEN ?3 THEN last_cursor ELSE NULL END,
|
||||
shares_synced = CASE WHEN ?3 THEN shares_synced ELSE 0 END
|
||||
WHERE id = 1",
|
||||
params![server_url, device_token, keep_cursor],
|
||||
)?;
|
||||
if !keep_cursor {
|
||||
forget_shared_notes(conn)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the notes other people shared with the account this device was linked to.
|
||||
/// They were only ever here through that link: kept after it ends, they would sit
|
||||
/// on the board as notes nobody here can edit and nothing would ever update.
|
||||
fn forget_shared_notes(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM notes WHERE permission <> 'owner'", [])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start the change feed over the first time this device pulls with shares (#5175).
|
||||
///
|
||||
/// Notes shared before this build sit below the cursor the device already holds, so
|
||||
/// without a restart they would only arrive once something next changed them.
|
||||
/// Returns whether it restarted. Once per link: `set_link` to another server and
|
||||
/// `clear_link` both reset the flag.
|
||||
pub fn begin_shares(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
let restarted = conn.execute(
|
||||
"UPDATE sync_state SET last_cursor = NULL, shares_synced = 1
|
||||
WHERE id = 1 AND shares_synced = 0",
|
||||
[],
|
||||
)?;
|
||||
Ok(restarted > 0)
|
||||
}
|
||||
|
||||
/// Forget the server entirely.
|
||||
///
|
||||
/// Clears the cursor as well as the credentials: a cursor left behind would, on the
|
||||
@@ -111,11 +138,11 @@ pub fn clear_link(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE sync_state
|
||||
SET server_url = NULL, device_token = NULL, last_cursor = NULL,
|
||||
last_sync_at = NULL, server_retention_days = NULL
|
||||
last_sync_at = NULL, server_retention_days = NULL, shares_synced = 0
|
||||
WHERE id = 1",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
forget_shared_notes(conn)
|
||||
}
|
||||
|
||||
/// Remember the linked server's trash-retention window (0 = it never purges).
|
||||
@@ -281,6 +308,50 @@ mod tests {
|
||||
assert!(state.device_token.is_none());
|
||||
}
|
||||
|
||||
fn seed(conn: &Connection, id: &str, permission: &str) {
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, body, created_at, updated_at, permission)
|
||||
VALUES (?1, 'x', '2026-01-01', '2026-01-01', ?2)",
|
||||
params![id, permission],
|
||||
)
|
||||
.expect("seed");
|
||||
}
|
||||
|
||||
fn note_ids(conn: &Connection) -> Vec<String> {
|
||||
let mut stmt = conn.prepare("SELECT id FROM notes ORDER BY id").unwrap();
|
||||
let rows = stmt.query_map([], |r| r.get(0)).unwrap();
|
||||
rows.collect::<rusqlite::Result<_>>().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlinking_drops_the_notes_others_shared_and_keeps_our_own() {
|
||||
let conn = db();
|
||||
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
||||
seed(&conn, "mine", "owner");
|
||||
seed(&conn, "theirs", "view");
|
||||
seed(&conn, "editable", "edit");
|
||||
// Re-linking the same server keeps everything.
|
||||
set_link(&conn, "https://a.example.com", "tok-2").expect("relink");
|
||||
assert_eq!(note_ids(&conn), ["editable", "mine", "theirs"]);
|
||||
clear_link(&conn).expect("unlink");
|
||||
assert_eq!(note_ids(&conn), ["mine"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_pull_with_shares_starts_over_once_per_link() {
|
||||
let conn = db();
|
||||
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
||||
set_cursor(&conn, 500).expect("cursor");
|
||||
assert!(begin_shares(&conn).expect("begin"));
|
||||
assert_eq!(read(&conn).expect("read").last_cursor, 0);
|
||||
set_cursor(&conn, 600).expect("cursor");
|
||||
assert!(!begin_shares(&conn).expect("again"), "only the first time");
|
||||
assert_eq!(read(&conn).expect("read").last_cursor, 600);
|
||||
// Another server is a fresh start, shares included.
|
||||
set_link(&conn, "https://b.example.com", "tok-2").expect("relink");
|
||||
assert!(begin_shares(&conn).expect("new server"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlink_clears_the_last_sync_stamp() {
|
||||
// Otherwise a freshly-linked server would claim it synced at a time that
|
||||
|
||||
Reference in New Issue
Block a user