From a5ad2b94ed8c85c58091da64fa21a4c789fb1e85 Mon Sep 17 00:00:00 2001 From: careck Date: Wed, 10 Jun 2026 13:55:33 +1000 Subject: [PATCH 01/19] feat: add local_only flag to FieldDefinition schema parsing --- .../src/core/scripting/display_helpers.rs | 6 +++ krillnotes-core/src/core/scripting/schema.rs | 10 +++++ krillnotes-core/src/core/scripting/tests.rs | 5 +++ krillnotes-core/src/core/workspace/tests.rs | 37 +++++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/krillnotes-core/src/core/scripting/display_helpers.rs b/krillnotes-core/src/core/scripting/display_helpers.rs index 115dc048..2fbec0a3 100644 --- a/krillnotes-core/src/core/scripting/display_helpers.rs +++ b/krillnotes-core/src/core/scripting/display_helpers.rs @@ -966,6 +966,7 @@ mod tests { max_value: None, pattern: None, pattern_message: None, + local_only: false, }], title_can_view: true, title_can_edit: true, @@ -1039,6 +1040,7 @@ mod tests { max_value: None, pattern: None, pattern_message: None, + local_only: false, }], title_can_view: true, title_can_edit: true, @@ -1103,6 +1105,7 @@ mod tests { max_value: None, pattern: None, pattern_message: None, + local_only: false, }], title_can_view: true, title_can_edit: true, @@ -1176,6 +1179,7 @@ mod tests { max_value: None, pattern: None, pattern_message: None, + local_only: false, }], title_can_view: true, title_can_edit: true, @@ -1270,6 +1274,7 @@ mod tests { max_value: None, pattern: None, pattern_message: None, + local_only: false, }], title_can_view: true, title_can_edit: true, @@ -1738,6 +1743,7 @@ mod tests { max_value: None, pattern: None, pattern_message: None, + local_only: false, }], title_can_view: true, title_can_edit: true, diff --git a/krillnotes-core/src/core/scripting/schema.rs b/krillnotes-core/src/core/scripting/schema.rs index fa3ad853..527c3a1b 100644 --- a/krillnotes-core/src/core/scripting/schema.rs +++ b/krillnotes-core/src/core/scripting/schema.rs @@ -129,6 +129,10 @@ pub struct FieldDefinition { /// If `None`, falls back to "Value does not match pattern: {pattern}". #[serde(default)] pub pattern_message: Option, + /// When `true`, values for this field are never synced to peers. + /// Each peer stores their own independent value locally. + #[serde(default)] + pub local_only: bool, } /// A named group of fields with optional conditional visibility. @@ -376,6 +380,11 @@ impl Schema { .get("pattern_message") .and_then(|v| v.clone().try_cast::()); + let local_only = field_map + .get("local_only") + .and_then(|v| v.clone().try_cast::()) + .unwrap_or(false); + Ok(FieldDefinition { name: field_name, field_type, @@ -393,6 +402,7 @@ impl Schema { max_value, pattern, pattern_message, + local_only, }) } diff --git a/krillnotes-core/src/core/scripting/tests.rs b/krillnotes-core/src/core/scripting/tests.rs index 1566b1b2..5657a7ae 100644 --- a/krillnotes-core/src/core/scripting/tests.rs +++ b/krillnotes-core/src/core/scripting/tests.rs @@ -271,6 +271,7 @@ fn test_default_fields() { max_value: None, pattern: None, pattern_message: None, + local_only: false, }, FieldDefinition { name: "count".to_string(), @@ -289,6 +290,7 @@ fn test_default_fields() { max_value: None, pattern: None, pattern_message: None, + local_only: false, }, ], title_can_view: true, @@ -353,6 +355,7 @@ fn test_date_field_default() { max_value: None, pattern: None, pattern_message: None, + local_only: false, }], title_can_view: true, title_can_edit: true, @@ -396,6 +399,7 @@ fn test_email_field_default() { max_value: None, pattern: None, pattern_message: None, + local_only: false, }], title_can_view: true, title_can_edit: true, @@ -2649,6 +2653,7 @@ fn test_default_field_for_note_link_is_none() { max_value: None, pattern: None, pattern_message: None, + local_only: false, }], title_can_view: true, title_can_edit: true, diff --git a/krillnotes-core/src/core/workspace/tests.rs b/krillnotes-core/src/core/workspace/tests.rs index 2cb6b60d..31dc073d 100644 --- a/krillnotes-core/src/core/workspace/tests.rs +++ b/krillnotes-core/src/core/workspace/tests.rs @@ -6696,3 +6696,40 @@ fn test_restore_attachment_rejects_invalid_salt_hex() { "expected AttachmentEncryption for invalid salt hex, got: {err:?}" ); } + +// ── Local-only fields ────────────────────────────────────────────── + +#[test] +fn test_schema_local_only_field_parsed() { + let temp = NamedTempFile::new().unwrap(); + let mut ws = Workspace::create( + temp.path(), + "", + "id-1", + ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]), + test_gate(), + None, + temp.path().parent().unwrap(), + ) + .unwrap(); + + let script = r#" + // @name: Local Test + // @description: Test local-only fields + schema("LocalTest", #{ + version: 1, + fields: [ + #{ name: "public_field", type: "text" }, + #{ name: "private_notes", type: "text", local_only: true }, + ] + }); + "#; + ws.create_user_script(script).expect("create_user_script should succeed"); + + let schema = ws.script_registry().get_schema("LocalTest").unwrap(); + let fields = schema.all_fields(); + let public_f = fields.iter().find(|f| f.name == "public_field").unwrap(); + let private_f = fields.iter().find(|f| f.name == "private_notes").unwrap(); + assert!(!public_f.local_only); + assert!(private_f.local_only); +} From 02596d30a4919daeb1855d85a2eff59a11e16c49 Mon Sep 17 00:00:00 2001 From: careck Date: Wed, 10 Jun 2026 13:57:23 +1000 Subject: [PATCH 02/19] feat: add is_field_local_only helper for sync filtering --- krillnotes-core/src/core/workspace/sync.rs | 20 +++++++++++++ krillnotes-core/src/core/workspace/tests.rs | 33 +++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/krillnotes-core/src/core/workspace/sync.rs b/krillnotes-core/src/core/workspace/sync.rs index 721c3f3c..347454fe 100644 --- a/krillnotes-core/src/core/workspace/sync.rs +++ b/krillnotes-core/src/core/workspace/sync.rs @@ -302,6 +302,26 @@ impl Workspace { Ok(ops) } + // ── Local-only field helpers ──────────────────────────────────── + + /// Returns `true` if `field_name` is marked `local_only` in the schema for `schema_name`. + /// Returns `false` if the schema or field is unknown (safe default: don't suppress sync). + pub fn is_field_local_only(&self, schema_name: &str, field_name: &str) -> bool { + self.script_registry + .get_schema(schema_name) + .ok() + .map_or(false, |s| { + s.all_fields().iter().any(|f| f.name == field_name && f.local_only) + }) + } + + /// Checks if an UpdateField op targets a local_only field by looking up the note's schema. + fn is_op_field_local_only(&self, note_id: &str, field_name: &str) -> bool { + self.get_note(note_id) + .ok() + .map_or(false, |note| self.is_field_local_only(¬e.schema, field_name)) + } + /// Apply a single operation received from a remote peer. /// /// Returns `Ok(true)` if the operation was inserted and applied to the working tables, diff --git a/krillnotes-core/src/core/workspace/tests.rs b/krillnotes-core/src/core/workspace/tests.rs index 31dc073d..aa6e2c1c 100644 --- a/krillnotes-core/src/core/workspace/tests.rs +++ b/krillnotes-core/src/core/workspace/tests.rs @@ -6733,3 +6733,36 @@ fn test_schema_local_only_field_parsed() { assert!(!public_f.local_only); assert!(private_f.local_only); } + +#[test] +fn test_is_field_local_only() { + let temp = NamedTempFile::new().unwrap(); + let mut ws = Workspace::create( + temp.path(), + "", + "id-1", + ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]), + test_gate(), + None, + temp.path().parent().unwrap(), + ) + .unwrap(); + + let script = r#" + // @name: LO Test + // @description: Test local-only + schema("LoTest", #{ + version: 1, + fields: [ + #{ name: "public_f", type: "text" }, + #{ name: "private_f", type: "text", local_only: true }, + ] + }); + "#; + ws.create_user_script(script).unwrap(); + + assert!(!ws.is_field_local_only("LoTest", "public_f")); + assert!(ws.is_field_local_only("LoTest", "private_f")); + assert!(!ws.is_field_local_only("UnknownSchema", "whatever")); + assert!(!ws.is_field_local_only("LoTest", "unknown_field")); +} From b3366bbb3909fffe2f6e8e8851597dc7c540c76c Mon Sep 17 00:00:00 2001 From: careck Date: Wed, 10 Jun 2026 14:01:26 +1000 Subject: [PATCH 03/19] feat: filter local_only fields from sync deltas and snapshots - UpdateField ops targeting local-only fields dropped from operations_since - CreateNote ops have local-only field values stripped - Snapshot notes have local-only field values stripped --- krillnotes-core/src/core/workspace/sync.rs | 97 ++++++++++++----- krillnotes-core/src/core/workspace/tests.rs | 115 ++++++++++++++++++++ 2 files changed, 187 insertions(+), 25 deletions(-) diff --git a/krillnotes-core/src/core/workspace/sync.rs b/krillnotes-core/src/core/workspace/sync.rs index 347454fe..7b531a3b 100644 --- a/krillnotes-core/src/core/workspace/sync.rs +++ b/krillnotes-core/src/core/workspace/sync.rs @@ -22,6 +22,21 @@ impl Workspace { let user_scripts = self.list_user_scripts()?; let attachments = self.list_all_attachments()?; let permission_ops = self.collect_permission_ops()?; + + let notes: Vec = notes + .into_iter() + .map(|mut note| { + if let Ok(schema) = self.script_registry.get_schema(¬e.schema) { + for fd in schema.all_fields() { + if fd.local_only { + note.fields.remove(&fd.name); + } + } + } + note + }) + .collect(); + log::debug!(target: "krillnotes::sync", "snapshot: {} notes, {} scripts, {} attachments, {} permission ops", notes.len(), user_scripts.len(), attachments.len(), permission_ops.len()); @@ -178,22 +193,18 @@ impl Workspace { rows }; - let mut ops: Vec = op_jsons + let ops: Vec = op_jsons .iter() .filter_map(|json| serde_json::from_str(json).ok()) - .collect(); - - // Filter local-only retracts (propagate = false) in Rust - // (the propagate flag is inside the JSON blob, not a SQL column). - ops.retain(|op| { - !matches!( - op, - Operation::RetractOperation { - propagate: false, - .. + .filter(|op| !matches!(op, Operation::RetractOperation { propagate: false, .. })) + .filter(|op| match op { + Operation::UpdateField { note_id, field, .. } => { + !self.is_op_field_local_only(note_id, field) } - ) - }); + _ => true, + }) + .map(|op| self.strip_local_only_from_create_note(op)) + .collect(); Ok(ops) } @@ -283,21 +294,18 @@ impl Workspace { rows }; - let mut ops: Vec<(Operation, String)> = rows + let ops: Vec<(Operation, String)> = rows .into_iter() .filter_map(|(json, vb)| serde_json::from_str(&json).ok().map(|op| (op, vb))) - .collect(); - - // Filter local-only retracts (propagate = false) - ops.retain(|(op, _)| { - !matches!( - op, - Operation::RetractOperation { - propagate: false, - .. + .filter(|(op, _)| !matches!(op, Operation::RetractOperation { propagate: false, .. })) + .filter(|(op, _)| match op { + Operation::UpdateField { note_id, field, .. } => { + !self.is_op_field_local_only(note_id, field) } - ) - }); + _ => true, + }) + .map(|(op, vb)| (self.strip_local_only_from_create_note(op), vb)) + .collect(); Ok(ops) } @@ -322,6 +330,45 @@ impl Workspace { .map_or(false, |note| self.is_field_local_only(¬e.schema, field_name)) } + /// Returns `true` if any field in the schema is marked `local_only`. + fn has_local_only_fields(&self, schema_name: &str) -> bool { + self.script_registry + .get_schema(schema_name) + .ok() + .map_or(false, |s| s.all_fields().iter().any(|f| f.local_only)) + } + + /// Clones a CreateNote op with local_only fields removed from its fields map. + /// Returns the op unchanged if it's not a CreateNote or has no local-only fields. + fn strip_local_only_from_create_note(&self, op: Operation) -> Operation { + if let Operation::CreateNote { ref schema, ref fields, .. } = op { + if fields.is_empty() || !self.has_local_only_fields(schema) { + return op; + } + } else { + return op; + } + if let Operation::CreateNote { + operation_id, timestamp, device_id, note_id, parent_id, + position, schema, title, mut fields, created_by, signature, + } = op + { + if let Ok(s) = self.script_registry.get_schema(&schema) { + for fd in s.all_fields() { + if fd.local_only { + fields.remove(&fd.name); + } + } + } + Operation::CreateNote { + operation_id, timestamp, device_id, note_id, parent_id, + position, schema, title, fields, created_by, signature, + } + } else { + unreachable!() + } + } + /// Apply a single operation received from a remote peer. /// /// Returns `Ok(true)` if the operation was inserted and applied to the working tables, diff --git a/krillnotes-core/src/core/workspace/tests.rs b/krillnotes-core/src/core/workspace/tests.rs index aa6e2c1c..b33f4f84 100644 --- a/krillnotes-core/src/core/workspace/tests.rs +++ b/krillnotes-core/src/core/workspace/tests.rs @@ -6766,3 +6766,118 @@ fn test_is_field_local_only() { assert!(!ws.is_field_local_only("UnknownSchema", "whatever")); assert!(!ws.is_field_local_only("LoTest", "unknown_field")); } + +#[test] +fn test_operations_since_filters_local_only_update_field() { + let temp = NamedTempFile::new().unwrap(); + let mut ws = Workspace::create( + temp.path(), + "", + "id-1", + ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]), + test_gate(), + None, + temp.path().parent().unwrap(), + ) + .unwrap(); + + let script = "// @name: LoSync\n// @description: test\nschema(\"LoSync\", #{\n version: 1,\n fields: [\n #{ name: \"shared_text\", type: \"text\" },\n #{ name: \"private_text\", type: \"text\", local_only: true },\n ]\n});"; + ws.create_user_script(script).unwrap(); + + let root = ws.list_all_notes().unwrap()[0].clone(); + let note_id = ws + .create_note(&root.id, AddPosition::AsChild, "LoSync") + .unwrap(); + + ws.update_note( + ¬e_id, + "".to_string(), + BTreeMap::from([ + ("shared_text".to_string(), FieldValue::Text("hello".into())), + ("private_text".to_string(), FieldValue::Text("secret".into())), + ]), + ) + .unwrap(); + + let ops = ws.operations_since(None, "other-device").unwrap(); + for op in &ops { + if let Operation::UpdateField { field, .. } = op { + assert_ne!(field, "private_text", "local_only field must not appear in operations_since"); + } + } + assert!( + ops.iter().any(|op| matches!(op, Operation::UpdateField { field, .. } if field == "shared_text")), + "shared field must still appear" + ); +} + +#[test] +fn test_operations_since_strips_local_only_from_create_note() { + let temp = NamedTempFile::new().unwrap(); + let mut ws = Workspace::create( + temp.path(), + "", + "id-1", + ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]), + test_gate(), + None, + temp.path().parent().unwrap(), + ) + .unwrap(); + + let script = "// @name: LoCreate\n// @description: test\nschema(\"LoCreate\", #{\n version: 1,\n fields: [\n #{ name: \"shared_f\", type: \"text\" },\n #{ name: \"private_f\", type: \"text\", local_only: true },\n ]\n});"; + ws.create_user_script(script).unwrap(); + + let root = ws.list_all_notes().unwrap()[0].clone(); + ws.create_note(&root.id, AddPosition::AsChild, "LoCreate").unwrap(); + + let ops = ws.operations_since(None, "other-device").unwrap(); + for op in &ops { + if let Operation::CreateNote { schema, fields, .. } = op { + if schema == "LoCreate" { + assert!(!fields.contains_key("private_f"), "local_only field must be stripped from CreateNote"); + // shared_f may or may not be present depending on default values + } + } + } +} + +#[test] +fn test_snapshot_strips_local_only_fields() { + let temp = NamedTempFile::new().unwrap(); + let mut ws = Workspace::create( + temp.path(), + "", + "id-1", + ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]), + test_gate(), + None, + temp.path().parent().unwrap(), + ) + .unwrap(); + + let script = "// @name: LoSnap\n// @description: test\nschema(\"LoSnap\", #{\n version: 1,\n fields: [\n #{ name: \"shared\", type: \"text\" },\n #{ name: \"private\", type: \"text\", local_only: true },\n ]\n});"; + ws.create_user_script(script).unwrap(); + + let root = ws.list_all_notes().unwrap()[0].clone(); + let note_id = ws + .create_note(&root.id, AddPosition::AsChild, "LoSnap") + .unwrap(); + ws.update_note( + ¬e_id, + "".to_string(), + BTreeMap::from([ + ("shared".to_string(), FieldValue::Text("visible".into())), + ("private".to_string(), FieldValue::Text("secret".into())), + ]), + ) + .unwrap(); + + let snapshot_bytes = ws.to_snapshot_json().unwrap(); + let snapshot: serde_json::Value = serde_json::from_slice(&snapshot_bytes).unwrap(); + let notes = snapshot["notes"].as_array().unwrap(); + let lo_note = notes.iter().find(|n| n["id"] == note_id).unwrap(); + let fields = lo_note["fields"].as_object().unwrap(); + assert!(fields.contains_key("shared"), "shared field must be in snapshot"); + assert!(!fields.contains_key("private"), "local_only field must be stripped from snapshot"); +} From c362beaa0718ec9f8246a9635522cd50a8b81269 Mon Sep 17 00:00:00 2001 From: careck Date: Wed, 10 Jun 2026 14:03:02 +1000 Subject: [PATCH 04/19] docs: document local_only field property in SCRIPTING.md --- SCRIPTING.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/SCRIPTING.md b/SCRIPTING.md index 81600f88..905fee81 100644 --- a/SCRIPTING.md +++ b/SCRIPTING.md @@ -162,6 +162,7 @@ Each entry in `fields` is a map: options: ["A", "B"], // required for "select" fields max: 5, // required for "rating" fields default_value: "Draft", // optional — initial value for new notes (any matching type) + local_only: false, // optional — when true, value is never synced to peers (default: false) min: 0, // optional — minimum for "number" fields max: 100, // optional — maximum for "number" fields (also star count for "rating") pattern: "^[A-Z]+$", // optional — regex for "text"/"email" fields @@ -175,6 +176,30 @@ Each entry in `fields` is a map: `can_edit: false` marks a derived/computed field — it can be written by an `on_save` hook but users cannot change it directly. +### Local-Only Fields + +Fields marked `local_only: true` are visible to all peers (the schema is shared), but each +peer stores their own value independently. Values are never included in sync deltas, snapshots, +or exports (unless the user opts in during export). + +Use cases: personal annotations, draft notes, private ratings on shared data, or per-device +preferences. + +```rhai +schema("SharedDocument", #{ + version: 1, + fields: [ + #{ name: "content", type: "textarea" }, + #{ name: "status", type: "select", options: ["Draft", "Review", "Final"] }, + #{ name: "my_notes", type: "textarea", local_only: true }, + #{ name: "my_rating", type: "rating", max: 5, local_only: true }, + ] +}); +``` + +In this example, `content` and `status` sync normally between peers, while `my_notes` and +`my_rating` remain private to each device. + --- ## 3. Field types From 857a2cc65642dc2008fb344c10b668d16dfa42ef Mon Sep 17 00:00:00 2001 From: careck Date: Wed, 10 Jun 2026 14:04:19 +1000 Subject: [PATCH 05/19] feat: add include_local_only option to workspace export When exporting a workspace, local-only fields can now be stripped from notes by passing include_local_only=false. The Tauri export command exposes this as a new boolean parameter. --- krillnotes-core/src/core/export.rs | 23 ++++++++++- krillnotes-core/src/core/export_tests.rs | 40 +++++++++---------- .../src-tauri/src/commands/workspace.rs | 12 +++--- 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/krillnotes-core/src/core/export.rs b/krillnotes-core/src/core/export.rs index ccce1277..acf0ccb8 100644 --- a/krillnotes-core/src/core/export.rs +++ b/krillnotes-core/src/core/export.rs @@ -219,6 +219,7 @@ pub fn export_workspace( workspace: &Workspace, writer: W, password: Option<&str>, + include_local_only: bool, ) -> Result<(), ExportError> { let notes = workspace .list_all_notes() @@ -236,7 +237,7 @@ pub fn export_workspace( }; // Write notes.json — strip identity fields so the archive is identity-neutral - let notes = notes + let notes: Vec = notes .into_iter() .map(|mut n| { n.created_by = String::new(); @@ -244,6 +245,26 @@ pub fn export_workspace( n }) .collect(); + + // Optionally strip local-only fields so they are not shared + let notes = if include_local_only { + notes + } else { + notes + .into_iter() + .map(|mut note| { + if let Ok(schema) = workspace.script_registry().get_schema(¬e.schema) { + for fd in schema.all_fields() { + if fd.local_only { + note.fields.remove(&fd.name); + } + } + } + note + }) + .collect() + }; + let export_notes = ExportNotes { version: 1, app_version: APP_VERSION.to_string(), diff --git a/krillnotes-core/src/core/export_tests.rs b/krillnotes-core/src/core/export_tests.rs index dbb81dcf..f2c76967 100644 --- a/krillnotes-core/src/core/export_tests.rs +++ b/krillnotes-core/src/core/export_tests.rs @@ -73,7 +73,7 @@ fn test_export_workspace_creates_valid_zip() { // Export to a buffer let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap(); // Read back the zip and verify structure let reader = Cursor::new(&buf); @@ -131,7 +131,7 @@ fn test_peek_import_reads_metadata() { // Export to a buffer let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap(); // Peek at the export let result = peek_import(Cursor::new(&buf), None).unwrap(); @@ -186,7 +186,7 @@ fn test_round_trip_export_import() { // Export let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap(); // Import into a new workspace file let temp_dst = NamedTempFile::new().unwrap(); @@ -267,7 +267,7 @@ fn test_export_archive_is_identity_neutral() { .unwrap(); let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap(); let mut archive = zip::ZipArchive::new(Cursor::new(&buf)).unwrap(); @@ -325,7 +325,7 @@ fn test_round_trip_preserves_script_category() { .unwrap(); let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap(); let temp_dst = NamedTempFile::new().unwrap(); import_workspace( @@ -379,7 +379,7 @@ fn test_export_includes_workspace_json() { .unwrap(); let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap(); let mut archive = zip::ZipArchive::new(Cursor::new(&buf)).unwrap(); let ws_file = archive.by_name("workspace.json").unwrap(); @@ -408,7 +408,7 @@ fn test_round_trip_preserves_tags() { ws.update_note_tags(&root.id, vec!["rust".into()]).unwrap(); let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap(); let temp_dst = NamedTempFile::new().unwrap(); import_workspace( @@ -496,7 +496,7 @@ fn test_export_with_password_creates_encrypted_zip() { .unwrap(); let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), Some("hunter2")).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), Some("hunter2"), true).unwrap(); // notes.json should be marked as encrypted. // Use by_index_raw to read metadata without decrypting. @@ -525,7 +525,7 @@ fn test_export_without_password_creates_plain_zip() { .unwrap(); let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap(); let reader = Cursor::new(&buf); let mut archive = ZipArchive::new(reader).unwrap(); @@ -551,7 +551,7 @@ fn test_read_entry_wrong_password_returns_invalid_password() { ) .unwrap(); let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), Some("correct")).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), Some("correct"), true).unwrap(); // Try to read an entry with the wrong password let mut archive = ZipArchive::new(Cursor::new(&buf)).unwrap(); @@ -573,7 +573,7 @@ fn test_peek_import_returns_encrypted_archive_error_when_no_password() { .unwrap(); let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), Some("s3cr3t")).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), Some("s3cr3t"), true).unwrap(); let err = peek_import(Cursor::new(&buf), None).unwrap_err(); assert!(matches!(err, ExportError::EncryptedArchive), "got: {err:?}"); @@ -594,7 +594,7 @@ fn test_peek_import_with_correct_password_succeeds() { .unwrap(); let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), Some("s3cr3t")).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), Some("s3cr3t"), true).unwrap(); let result = peek_import(Cursor::new(&buf), Some("s3cr3t")).unwrap(); assert_eq!(result.app_version, APP_VERSION); @@ -616,7 +616,7 @@ fn test_peek_import_with_wrong_password_returns_invalid_password() { .unwrap(); let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), Some("s3cr3t")).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), Some("s3cr3t"), true).unwrap(); let err = peek_import(Cursor::new(&buf), Some("wrong-password")).unwrap_err(); assert!(matches!(err, ExportError::InvalidPassword), "got: {err:?}"); @@ -643,7 +643,7 @@ fn test_import_stamps_importer_identity_on_notes() { .unwrap(); let mut buf = Vec::new(); - export_workspace(&ws_a, Cursor::new(&mut buf), None).unwrap(); + export_workspace(&ws_a, Cursor::new(&mut buf), None, true).unwrap(); // Import as identity B (different key) let temp_dst = NamedTempFile::new().unwrap(); @@ -708,7 +708,7 @@ fn test_encrypted_round_trip_import() { // Export with password let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), Some("mypass")).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), Some("mypass"), true).unwrap(); // Import with correct password → should succeed let temp_dst = NamedTempFile::new().unwrap(); @@ -883,7 +883,7 @@ fn test_workspace_metadata_roundtrip() { ws.set_workspace_metadata(&meta).unwrap(); let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap(); let temp_dst = NamedTempFile::new().unwrap(); import_workspace( @@ -951,7 +951,7 @@ fn test_export_includes_attachments() { .unwrap(); let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap(); let mut archive = zip::ZipArchive::new(Cursor::new(&buf)).unwrap(); assert!( @@ -988,7 +988,7 @@ fn test_import_restores_attachments() { .unwrap(); let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap(); let dir_dst = tempfile::tempdir().unwrap(); let db_dst = dir_dst.path().join("notes.db"); @@ -1105,7 +1105,7 @@ fn test_peek_import_includes_workspace_metadata() { ws.set_workspace_metadata(&meta).unwrap(); let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap(); let result = peek_import(Cursor::new(&buf), None).unwrap(); let peeked = result.metadata.expect("metadata should be present"); @@ -1163,7 +1163,7 @@ fn test_import_makes_importer_the_owner() { // Export (Task 1 strips owner_pubkey from archive) let mut buf = Vec::new(); - export_workspace(&ws, Cursor::new(&mut buf), None).unwrap(); + export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap(); // Import with a DIFFERENT key (key B) let key_b = ed25519_dalek::SigningKey::from_bytes(&[2u8; 32]); diff --git a/krillnotes-desktop/src-tauri/src/commands/workspace.rs b/krillnotes-desktop/src-tauri/src/commands/workspace.rs index 5c9041e1..ae946055 100644 --- a/krillnotes-desktop/src-tauri/src/commands/workspace.rs +++ b/krillnotes-desktop/src-tauri/src/commands/workspace.rs @@ -742,6 +742,7 @@ pub fn export_workspace_cmd( state: State<'_, AppState>, path: String, password: Option, + include_local_only: bool, ) -> std::result::Result<(), String> { let label = window.label(); let workspaces = state.workspaces.lock().expect("Mutex poisoned"); @@ -752,10 +753,11 @@ pub fn export_workspace_cmd( } let file = std::fs::File::create(&path).map_err(|e| e.to_string())?; - krillnotes_core::export_workspace(workspace, file, password.as_deref()).map_err(|e| { - log::error!("export_workspace failed: {e}"); - e.to_string() - }) + krillnotes_core::export_workspace(workspace, file, password.as_deref(), include_local_only) + .map_err(|e| { + log::error!("export_workspace failed: {e}"); + e.to_string() + }) } /// Reads metadata from an export archive without creating a workspace. @@ -1321,7 +1323,7 @@ pub fn duplicate_workspace( let mut tmp_file = tempfile::tempfile().map_err(|e| format!("Failed to create temp file: {e}"))?; - krillnotes_core::export_workspace(&workspace, &mut tmp_file, Some(&source_password)) + krillnotes_core::export_workspace(&workspace, &mut tmp_file, Some(&source_password), true) .map_err(|e| e.to_string())?; // Import from temp file into dest folder From 542dcb9314f08b31cb8bb76496ca3dc1424d7da2 Mon Sep 17 00:00:00 2001 From: careck Date: Wed, 10 Jun 2026 14:04:32 +1000 Subject: [PATCH 06/19] feat: show local-only indicator on field labels in UI - Add localOnly: boolean to TypeScript FieldDefinition interface - Add localOnlyHint i18n key to all 7 locale files (en, de, es, fr, ja, ko, zh) - Show lock icon + hint text above local-only fields in edit mode (InfoPanel) - Show lock icon next to field label in read-only mode (FieldDisplay) - Covers top-level fields and field groups in both edit and view modes --- .../src/components/FieldDisplay.tsx | 17 +++- .../src/components/InfoPanel.tsx | 92 +++++++++++++------ krillnotes-desktop/src/i18n/locales/de.json | 7 +- krillnotes-desktop/src/i18n/locales/en.json | 7 +- krillnotes-desktop/src/i18n/locales/es.json | 7 +- krillnotes-desktop/src/i18n/locales/fr.json | 7 +- krillnotes-desktop/src/i18n/locales/ja.json | 7 +- krillnotes-desktop/src/i18n/locales/ko.json | 7 +- krillnotes-desktop/src/i18n/locales/zh.json | 7 +- krillnotes-desktop/src/types.ts | 1 + 10 files changed, 113 insertions(+), 46 deletions(-) diff --git a/krillnotes-desktop/src/components/FieldDisplay.tsx b/krillnotes-desktop/src/components/FieldDisplay.tsx index 86f4d02f..80a19f35 100644 --- a/krillnotes-desktop/src/components/FieldDisplay.tsx +++ b/krillnotes-desktop/src/components/FieldDisplay.tsx @@ -68,9 +68,11 @@ interface FieldDisplayProps { value: FieldValue; max?: number; noteId: string; + localOnly?: boolean; + localOnlyHint?: string; } -function FieldDisplay({ fieldName, fieldType, value, max = 5, noteId }: FieldDisplayProps) { +function FieldDisplay({ fieldName, fieldType, value, max = 5, noteId, localOnly, localOnlyHint }: FieldDisplayProps) { const { t } = useTranslation(); const renderValue = () => { if ('Number' in value && fieldType === 'rating') { @@ -128,7 +130,18 @@ function FieldDisplay({ fieldName, fieldType, value, max = 5, noteId }: FieldDis return ( <>
- {humaniseKey(fieldName)} + + {humaniseKey(fieldName)} + {localOnly && ( + + )} +
{renderValue()} diff --git a/krillnotes-desktop/src/components/InfoPanel.tsx b/krillnotes-desktop/src/components/InfoPanel.tsx index eb200ee7..f69a460b 100644 --- a/krillnotes-desktop/src/components/InfoPanel.tsx +++ b/krillnotes-desktop/src/components/InfoPanel.tsx @@ -504,21 +504,35 @@ function InfoPanel({ selectedNote, onNoteUpdated, onDeleteRequest, requestEditMo <> {/* Top-level fields */} {schemaInfo.fields.filter(field => field.canEdit).map(field => ( - handleFieldBlur(field.name, field)} - onChange={(value) => handleFieldChange(field.name, value)} - /> +
+ {field.localOnly && ( + + + + + + {t('fields.localOnlyHint')} + + )} + handleFieldBlur(field.name, field)} + onChange={(value) => handleFieldChange(field.name, value)} + /> +
))} {/* Field groups */} @@ -544,21 +558,35 @@ function InfoPanel({ selectedNote, onNoteUpdated, onDeleteRequest, requestEditMo {!isCollapsed && (
{group.fields.filter(f => f.canEdit).map(field => ( - handleFieldBlur(field.name, field)} - onChange={(value) => handleFieldChange(field.name, value)} - /> +
+ {field.localOnly && ( + + + + + + {t('fields.localOnlyHint')} + + )} + handleFieldBlur(field.name, field)} + onChange={(value) => handleFieldChange(field.name, value)} + /> +
))}
)} @@ -598,6 +626,8 @@ function InfoPanel({ selectedNote, onNoteUpdated, onDeleteRequest, requestEditMo value={selectedNote.fields[field.name] ?? defaultValueForFieldType(field.fieldType)} max={field.max} noteId={selectedNote.id} + localOnly={field.localOnly} + localOnlyHint={t('fields.localOnlyHint')} /> ))} @@ -628,6 +658,8 @@ function InfoPanel({ selectedNote, onNoteUpdated, onDeleteRequest, requestEditMo value={selectedNote.fields[field.name] ?? defaultValueForFieldType(field.fieldType)} max={field.max} noteId={selectedNote.id} + localOnly={field.localOnly} + localOnlyHint={t('fields.localOnlyHint')} /> ))} diff --git a/krillnotes-desktop/src/i18n/locales/de.json b/krillnotes-desktop/src/i18n/locales/de.json index 91ef3af5..cfd5f4c1 100644 --- a/krillnotes-desktop/src/i18n/locales/de.json +++ b/krillnotes-desktop/src/i18n/locales/de.json @@ -195,7 +195,9 @@ "archiveProtected": "Dieses Archiv ist passwortgeschützt", "archiveHint": "Gib das Passwort ein, das beim Export des Arbeitsbereichs verwendet wurde.", "setWorkspaceTitle": "Arbeitsbereich-Passwort festlegen", - "importedWorkspaceTitle": "Passwort für importierten Arbeitsbereich festlegen" + "importedWorkspaceTitle": "Passwort für importierten Arbeitsbereich festlegen", + "includeLocalOnly": "Lokale Felddaten einbeziehen", + "includeLocalOnlyHint": "Wenn deaktiviert, sind als lokal markierte Felder im Export leer." }, "import": { "title": "Arbeitsbereich importieren", @@ -265,7 +267,8 @@ "imageNotFound": "Bild nicht gefunden", "deleted": "(gelöscht)", "hiddenDataExists": "(ausgeblendet — Daten vorhanden)", - "openOnInstagram": "Auf Instagram öffnen ↗" + "openOnInstagram": "Auf Instagram öffnen ↗", + "localOnlyHint": "Nur lokal — wird nicht mit Peers synchronisiert" }, "tags": { "addPlaceholder": "Tag hinzufügen…", diff --git a/krillnotes-desktop/src/i18n/locales/en.json b/krillnotes-desktop/src/i18n/locales/en.json index 6873b007..9bf2f076 100644 --- a/krillnotes-desktop/src/i18n/locales/en.json +++ b/krillnotes-desktop/src/i18n/locales/en.json @@ -195,7 +195,9 @@ "archiveProtected": "This archive is password-protected", "archiveHint": "Enter the password used when the workspace was exported.", "setWorkspaceTitle": "Set Workspace Password", - "importedWorkspaceTitle": "Set Password for Imported Workspace" + "importedWorkspaceTitle": "Set Password for Imported Workspace", + "includeLocalOnly": "Include local-only field data", + "includeLocalOnlyHint": "When unchecked, fields marked as local-only will be empty in the export." }, "import": { "title": "Import Workspace", @@ -265,7 +267,8 @@ "imageNotFound": "Image not found", "deleted": "(deleted)", "hiddenDataExists": "(hidden — data exists)", - "openOnInstagram": "Open on Instagram ↗" + "openOnInstagram": "Open on Instagram ↗", + "localOnlyHint": "Local only — not synced to peers" }, "tags": { "addPlaceholder": "Add tag…", diff --git a/krillnotes-desktop/src/i18n/locales/es.json b/krillnotes-desktop/src/i18n/locales/es.json index 69a5d706..66fd011c 100644 --- a/krillnotes-desktop/src/i18n/locales/es.json +++ b/krillnotes-desktop/src/i18n/locales/es.json @@ -195,7 +195,9 @@ "archiveProtected": "Este archivo está protegido con contraseña", "archiveHint": "Introduce la contraseña usada al exportar el espacio de trabajo.", "setWorkspaceTitle": "Establecer contraseña del espacio de trabajo", - "importedWorkspaceTitle": "Establecer contraseña para el espacio de trabajo importado" + "importedWorkspaceTitle": "Establecer contraseña para el espacio de trabajo importado", + "includeLocalOnly": "Incluir datos de campos locales", + "includeLocalOnlyHint": "Cuando está desmarcado, los campos marcados como solo locales estarán vacíos en la exportación." }, "import": { "title": "Importar espacio de trabajo", @@ -265,7 +267,8 @@ "imageNotFound": "Imagen no encontrada", "deleted": "(eliminado)", "hiddenDataExists": "(oculto — existen datos)", - "openOnInstagram": "Abrir en Instagram ↗" + "openOnInstagram": "Abrir en Instagram ↗", + "localOnlyHint": "Solo local — no se sincroniza con pares" }, "tags": { "addPlaceholder": "Añadir etiqueta…", diff --git a/krillnotes-desktop/src/i18n/locales/fr.json b/krillnotes-desktop/src/i18n/locales/fr.json index 96fa8343..bf77f1de 100644 --- a/krillnotes-desktop/src/i18n/locales/fr.json +++ b/krillnotes-desktop/src/i18n/locales/fr.json @@ -195,7 +195,9 @@ "archiveProtected": "Cette archive est protégée par un mot de passe", "archiveHint": "Saisissez le mot de passe utilisé lors de l'export de l'espace de travail.", "setWorkspaceTitle": "Définir le mot de passe de l'espace de travail", - "importedWorkspaceTitle": "Définir le mot de passe pour l'espace de travail importé" + "importedWorkspaceTitle": "Définir le mot de passe pour l'espace de travail importé", + "includeLocalOnly": "Inclure les données des champs locaux", + "includeLocalOnlyHint": "Si décoché, les champs marqués comme locaux uniquement seront vides dans l'export." }, "import": { "title": "Importer un espace de travail", @@ -265,7 +267,8 @@ "imageNotFound": "Image introuvable", "deleted": "(supprimé)", "hiddenDataExists": "(masqué — données présentes)", - "openOnInstagram": "Ouvrir sur Instagram ↗" + "openOnInstagram": "Ouvrir sur Instagram ↗", + "localOnlyHint": "Local uniquement — non synchronisé avec les pairs" }, "tags": { "addPlaceholder": "Ajouter un tag…", diff --git a/krillnotes-desktop/src/i18n/locales/ja.json b/krillnotes-desktop/src/i18n/locales/ja.json index 09fa5446..fb9aacca 100644 --- a/krillnotes-desktop/src/i18n/locales/ja.json +++ b/krillnotes-desktop/src/i18n/locales/ja.json @@ -195,7 +195,9 @@ "archiveProtected": "このアーカイブはパスワードで保護されています", "archiveHint": "ワークスペースのエクスポート時に使用したパスワードを入力してください。", "setWorkspaceTitle": "ワークスペースのパスワードを設定", - "importedWorkspaceTitle": "インポートしたワークスペースのパスワードを設定" + "importedWorkspaceTitle": "インポートしたワークスペースのパスワードを設定", + "includeLocalOnly": "ローカル専用フィールドデータを含める", + "includeLocalOnlyHint": "チェックを外すと、ローカル専用のフィールドはエクスポートで空になります。" }, "import": { "title": "ワークスペースをインポート", @@ -265,7 +267,8 @@ "imageNotFound": "画像が見つかりません", "deleted": "(削除済み)", "hiddenDataExists": "(非表示 — データあり)", - "openOnInstagram": "Instagramで開く ↗" + "openOnInstagram": "Instagramで開く ↗", + "localOnlyHint": "ローカルのみ — ピアとは同期されません" }, "tags": { "addPlaceholder": "タグを追加…", diff --git a/krillnotes-desktop/src/i18n/locales/ko.json b/krillnotes-desktop/src/i18n/locales/ko.json index 59c71fe2..31bf9be6 100644 --- a/krillnotes-desktop/src/i18n/locales/ko.json +++ b/krillnotes-desktop/src/i18n/locales/ko.json @@ -195,7 +195,9 @@ "archiveProtected": "이 아카이브는 비밀번호로 보호되어 있습니다", "archiveHint": "작업 공간을 내보낼 때 사용한 비밀번호를 입력하세요.", "setWorkspaceTitle": "작업 공간 비밀번호 설정", - "importedWorkspaceTitle": "가져온 작업 공간의 비밀번호 설정" + "importedWorkspaceTitle": "가져온 작업 공간의 비밀번호 설정", + "includeLocalOnly": "로컬 전용 필드 데이터 포함", + "includeLocalOnlyHint": "선택 해제 시 로컬 전용으로 표시된 필드는 내보내기에서 비어 있습니다." }, "import": { "title": "작업 공간 가져오기", @@ -265,7 +267,8 @@ "imageNotFound": "이미지를 찾을 수 없습니다", "deleted": "(삭제됨)", "hiddenDataExists": "(숨김 — 데이터 있음)", - "openOnInstagram": "Instagram에서 열기 ↗" + "openOnInstagram": "Instagram에서 열기 ↗", + "localOnlyHint": "로컬 전용 — 피어와 동기화되지 않음" }, "tags": { "addPlaceholder": "태그 추가…", diff --git a/krillnotes-desktop/src/i18n/locales/zh.json b/krillnotes-desktop/src/i18n/locales/zh.json index ccc39898..8ad00e2e 100644 --- a/krillnotes-desktop/src/i18n/locales/zh.json +++ b/krillnotes-desktop/src/i18n/locales/zh.json @@ -195,7 +195,9 @@ "archiveProtected": "此归档已受密码保护", "archiveHint": "请输入导出工作区时使用的密码。", "setWorkspaceTitle": "设置工作区密码", - "importedWorkspaceTitle": "为导入的工作区设置密码" + "importedWorkspaceTitle": "为导入的工作区设置密码", + "includeLocalOnly": "包含仅本地字段数据", + "includeLocalOnlyHint": "取消勾选后,标记为仅本地的字段在导出中将为空。" }, "import": { "title": "导入工作区", @@ -265,7 +267,8 @@ "imageNotFound": "图片未找到", "deleted": "(已删除)", "hiddenDataExists": "(已隐藏 — 数据存在)", - "openOnInstagram": "在 Instagram 上打开 ↗" + "openOnInstagram": "在 Instagram 上打开 ↗", + "localOnlyHint": "仅本地 — 不会同步到对等节点" }, "tags": { "addPlaceholder": "添加标签…", diff --git a/krillnotes-desktop/src/types.ts b/krillnotes-desktop/src/types.ts index 8e2c0a6d..9638c8a2 100644 --- a/krillnotes-desktop/src/types.ts +++ b/krillnotes-desktop/src/types.ts @@ -71,6 +71,7 @@ export interface FieldDefinition { pattern?: string; // text/email fields: regex validation pattern patternMessage?: string; // custom error message for pattern validation failure hasDefault: boolean; // true if a default_value is set in the schema + localOnly: boolean; // true if this field is not synced to peers } export interface FieldGroup { From ab2c49ca339985fd149a4e53983b986dffaad03d Mon Sep 17 00:00:00 2001 From: careck Date: Wed, 10 Jun 2026 14:04:37 +1000 Subject: [PATCH 07/19] feat: add include local-only data checkbox to export dialog --- krillnotes-desktop/src/App.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/krillnotes-desktop/src/App.tsx b/krillnotes-desktop/src/App.tsx index 06df3215..50006a3f 100644 --- a/krillnotes-desktop/src/App.tsx +++ b/krillnotes-desktop/src/App.tsx @@ -68,6 +68,7 @@ function App() { // Global snapshot polling for all unlocked identities (no workspace needed). useGlobalSnapshotPolling(); + const [exportIncludeLocalOnly, setExportIncludeLocalOnly] = useState(true); const [isRootOwner, setIsRootOwner] = useState(false); const [sharingIndicatorMode, setSharingIndicatorMode] = useState<'off' | 'auto' | 'on'>('auto'); const refreshSettings = () => { @@ -143,7 +144,7 @@ function App() { if (!path) return; - await invoke('export_workspace_cmd', { path, password }); + await invoke('export_workspace_cmd', { path, password, includeLocalOnly: exportIncludeLocalOnly }); statusSetter(t('workspace.exportSuccess')); } catch (error) { statusSetter(t('workspace.exportFailed', { error: String(error) }), true); @@ -323,6 +324,18 @@ function App() { {t('dialogs.password.mismatch')} )} +
- +
+ {!account.sessionValid && ( + + )} + +
diff --git a/krillnotes-desktop/src/components/RelayBookDialog.tsx b/krillnotes-desktop/src/components/RelayBookDialog.tsx index 4fa5b6c9..52932228 100644 --- a/krillnotes-desktop/src/components/RelayBookDialog.tsx +++ b/krillnotes-desktop/src/components/RelayBookDialog.tsx @@ -44,6 +44,11 @@ export default function RelayBookDialog({ identityUuid, identityName, onClose }: load(); } + function handleRenewed() { + setEditing(null); + load(); + } + return (
@@ -121,6 +126,7 @@ export default function RelayBookDialog({ identityUuid, identityName, onClose }: identityUuid={identityUuid} account={editing} onDeleted={handleDeleted} + onRenewed={handleRenewed} onClose={() => setEditing(null)} /> )} diff --git a/krillnotes-desktop/src/i18n/locales/de.json b/krillnotes-desktop/src/i18n/locales/de.json index cfd5f4c1..72ddec5d 100644 --- a/krillnotes-desktop/src/i18n/locales/de.json +++ b/krillnotes-desktop/src/i18n/locales/de.json @@ -585,7 +585,9 @@ "title": "Relay-Konto", "status": "Status", "delete": "Löschen", - "confirmDelete": "Löschen bestätigen" + "confirmDelete": "Löschen bestätigen", + "renewSession": "Sitzung erneuern", + "renewing": "Wird erneuert..." }, "onboard": { "title": "Peer einrichten", diff --git a/krillnotes-desktop/src/i18n/locales/en.json b/krillnotes-desktop/src/i18n/locales/en.json index 9bf2f076..77d9058b 100644 --- a/krillnotes-desktop/src/i18n/locales/en.json +++ b/krillnotes-desktop/src/i18n/locales/en.json @@ -585,7 +585,9 @@ "title": "Relay Account", "status": "Status", "delete": "Delete", - "confirmDelete": "Confirm Delete" + "confirmDelete": "Confirm Delete", + "renewSession": "Renew Session", + "renewing": "Renewing..." }, "onboard": { "title": "Onboard Peer", diff --git a/krillnotes-desktop/src/i18n/locales/es.json b/krillnotes-desktop/src/i18n/locales/es.json index 66fd011c..915401fa 100644 --- a/krillnotes-desktop/src/i18n/locales/es.json +++ b/krillnotes-desktop/src/i18n/locales/es.json @@ -585,7 +585,9 @@ "title": "Cuenta de Relay", "status": "Estado", "delete": "Eliminar", - "confirmDelete": "Confirmar eliminación" + "confirmDelete": "Confirmar eliminación", + "renewSession": "Renovar sesión", + "renewing": "Renovando..." }, "onboard": { "title": "Incorporar par", diff --git a/krillnotes-desktop/src/i18n/locales/fr.json b/krillnotes-desktop/src/i18n/locales/fr.json index bf77f1de..b0abc37f 100644 --- a/krillnotes-desktop/src/i18n/locales/fr.json +++ b/krillnotes-desktop/src/i18n/locales/fr.json @@ -585,7 +585,9 @@ "title": "Compte Relay", "status": "Statut", "delete": "Supprimer", - "confirmDelete": "Confirmer la suppression" + "confirmDelete": "Confirmer la suppression", + "renewSession": "Renouveler la session", + "renewing": "Renouvellement..." }, "onboard": { "title": "Intégrer un pair", diff --git a/krillnotes-desktop/src/i18n/locales/ja.json b/krillnotes-desktop/src/i18n/locales/ja.json index fb9aacca..0082ec8e 100644 --- a/krillnotes-desktop/src/i18n/locales/ja.json +++ b/krillnotes-desktop/src/i18n/locales/ja.json @@ -585,7 +585,9 @@ "title": "リレーアカウント", "status": "ステータス", "delete": "削除", - "confirmDelete": "削除を確認" + "confirmDelete": "削除を確認", + "renewSession": "セッションを更新", + "renewing": "更新中..." }, "onboard": { "title": "ピアのオンボーディング", diff --git a/krillnotes-desktop/src/i18n/locales/ko.json b/krillnotes-desktop/src/i18n/locales/ko.json index 31bf9be6..4c760931 100644 --- a/krillnotes-desktop/src/i18n/locales/ko.json +++ b/krillnotes-desktop/src/i18n/locales/ko.json @@ -585,7 +585,9 @@ "title": "릴레이 계정", "status": "상태", "delete": "삭제", - "confirmDelete": "삭제 확인" + "confirmDelete": "삭제 확인", + "renewSession": "세션 갱신", + "renewing": "갱신 중..." }, "onboard": { "title": "피어 온보딩", diff --git a/krillnotes-desktop/src/i18n/locales/zh.json b/krillnotes-desktop/src/i18n/locales/zh.json index 8ad00e2e..84422041 100644 --- a/krillnotes-desktop/src/i18n/locales/zh.json +++ b/krillnotes-desktop/src/i18n/locales/zh.json @@ -585,7 +585,9 @@ "title": "中继账户", "status": "状态", "delete": "删除", - "confirmDelete": "确认删除" + "confirmDelete": "确认删除", + "renewSession": "续期会话", + "renewing": "续期中..." }, "onboard": { "title": "接入对等节点", From 3064cbd07d168829255bcb5ce43bcc2bba88f8e2 Mon Sep 17 00:00:00 2001 From: careck Date: Fri, 12 Jun 2026 15:22:41 +1000 Subject: [PATCH 14/19] fix: always auto-login relay clients instead of checking local expiry Sessions can be invalidated server-side before the local 30-day expiry (server restart, concurrent login, admin action). Remove the session_expires_at check from all 8 relay client build sites so auto-login always runs when stored credentials are available. --- .../src-tauri/src/commands/receive_poll.rs | 2 +- .../src-tauri/src/commands/swarm.rs | 15 ++++++--------- krillnotes-desktop/src-tauri/src/commands/sync.rs | 12 ++++-------- 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs index 925b8324..a7bc2594 100644 --- a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs +++ b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs @@ -308,7 +308,7 @@ pub async fn poll_receive_workspace( log::debug!("poll_receive_workspace: using relay account {} on {}", acct.email, acct.relay_url); let mut token = acct.session_token.clone(); let mut login_failed = false; - if acct.session_expires_at < chrono::Utc::now() && !acct.password.is_empty() { + if !acct.password.is_empty() { let client = RelayClient::new(&acct.relay_url); match client.login(&acct.email, &acct.password, &acct.device_public_key) { Ok(session) => token = session.session_token, diff --git a/krillnotes-desktop/src-tauri/src/commands/swarm.rs b/krillnotes-desktop/src-tauri/src/commands/swarm.rs index 616519dc..7dc55d7b 100644 --- a/krillnotes-desktop/src-tauri/src/commands/swarm.rs +++ b/krillnotes-desktop/src-tauri/src/commands/swarm.rs @@ -854,12 +854,11 @@ pub async fn send_snapshot_via_relay( let relay_password = relay_account.password.clone(); let relay_device_key = relay_account.device_public_key.clone(); let session_token = relay_account.session_token.clone(); - let session_expires = relay_account.session_expires_at; tokio::task::spawn_blocking(move || -> std::result::Result<(), String> { - // Auto-login if session expired. + // Auto-login unconditionally (server may invalidate sessions before local expiry). let mut token = session_token; - if session_expires < chrono::Utc::now() && !relay_password.is_empty() { + if !relay_password.is_empty() { let client = RelayClient::new(&relay_url); match client.login(&relay_email, &relay_password, &relay_device_key) { Ok(session) => token = session.session_token, @@ -1201,12 +1200,11 @@ pub async fn send_self_snapshot_via_relay( let relay_password = relay_account.password.clone(); let relay_device_key = relay_account.device_public_key.clone(); let session_token = relay_account.session_token.clone(); - let session_expires = relay_account.session_expires_at; tokio::task::spawn_blocking(move || -> std::result::Result<(), String> { - // Auto-login if session expired. + // Auto-login unconditionally (server may invalidate sessions before local expiry). let mut token = session_token; - if session_expires < chrono::Utc::now() && !relay_password.is_empty() { + if !relay_password.is_empty() { let client = RelayClient::new(&relay_url); match client.login(&relay_email, &relay_password, &relay_device_key) { Ok(session) => token = session.session_token, @@ -1317,13 +1315,12 @@ pub async fn list_devices_on_relay( let relay_password = relay_account.password.clone(); let relay_device_key = relay_account.device_public_key.clone(); let session_token = relay_account.session_token.clone(); - let session_expires = relay_account.session_expires_at; let devices = tokio::task::spawn_blocking( move || -> std::result::Result, String> { - // Auto-login if session expired. + // Auto-login unconditionally (server may invalidate sessions before local expiry). let mut token = session_token; - if session_expires < chrono::Utc::now() && !relay_password.is_empty() { + if !relay_password.is_empty() { let client = RelayClient::new(&relay_url); match client.login(&relay_email, &relay_password, &relay_device_key) { Ok(session) => token = session.session_token, diff --git a/krillnotes-desktop/src-tauri/src/commands/sync.rs b/krillnotes-desktop/src-tauri/src/commands/sync.rs index 2db5a815..d3e58447 100644 --- a/krillnotes-desktop/src-tauri/src/commands/sync.rs +++ b/krillnotes-desktop/src-tauri/src/commands/sync.rs @@ -186,7 +186,7 @@ pub async fn poll_sync( if let Some(acct) = relay_accounts.first() { let mut token = acct.session_token.clone(); // Auto-login if session expired and password stored - if acct.session_expires_at < chrono::Utc::now() && !acct.password.is_empty() { + if !acct.password.is_empty() { let client = RelayClient::new(&acct.relay_url); match client.login(&acct.email, &acct.password, &acct.device_public_key) { Ok(session) => token = session.session_token, @@ -369,9 +369,7 @@ pub async fn share_invite_link( let relay_url_result = tokio::task::spawn_blocking(move || -> Result { let mut token = relay_account.session_token.clone(); - if relay_account.session_expires_at < chrono::Utc::now() - && !relay_account.password.is_empty() - { + if !relay_account.password.is_empty() { let client = RelayClient::new(&relay_account.relay_url); match client.login( &relay_account.email, @@ -523,9 +521,7 @@ pub async fn create_relay_invite( let relay_url_result = tokio::task::spawn_blocking(move || -> Result { let mut token = relay_account.session_token.clone(); - if relay_account.session_expires_at < chrono::Utc::now() - && !relay_account.password.is_empty() - { + if !relay_account.password.is_empty() { let client = RelayClient::new(&relay_account.relay_url); match client.login( &relay_account.email, @@ -705,7 +701,7 @@ pub async fn send_invite_response_via_relay( // Auto-login if needed. let mut token = relay_account.session_token.clone(); - if relay_account.session_expires_at < chrono::Utc::now() && !relay_account.password.is_empty() { + if !relay_account.password.is_empty() { let client = RelayClient::new(&relay_account.relay_url); match client.login(&relay_account.email, &relay_account.password, &relay_account.device_public_key) { Ok(session) => token = session.session_token, From cdb6816552ed6dfb9d9151ebaa0a46362a24eab8 Mon Sep 17 00:00:00 2001 From: careck Date: Fri, 12 Jun 2026 15:26:34 +1000 Subject: [PATCH 15/19] fix: use seconds-precision RFC 3339 timestamps for relay invite API The relay server rejects nanosecond-precision timestamps from chrono's default to_rfc3339(). Switch to to_rfc3339_opts(SecondsFormat::Secs, true) which produces clean "2026-06-19T05:23:45Z" format. --- .../src-tauri/src/commands/sync.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/krillnotes-desktop/src-tauri/src/commands/sync.rs b/krillnotes-desktop/src-tauri/src/commands/sync.rs index d3e58447..9497aa35 100644 --- a/krillnotes-desktop/src-tauri/src/commands/sync.rs +++ b/krillnotes-desktop/src-tauri/src/commands/sync.rs @@ -8,6 +8,7 @@ use crate::AppState; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use chrono::SecondsFormat; use krillnotes_core::core::sync::relay::{RelayAccount, RelayChannel, RelayClient}; use krillnotes_core::core::sync::{FolderChannel, SyncContext, SyncEngine, SyncEvent}; use std::sync::Arc; @@ -343,7 +344,8 @@ pub async fn share_invite_link( // Compute expiry timestamp. let expires_at = { let days = expires_in_days.unwrap_or(7) as i64; - (chrono::Utc::now() + chrono::Duration::days(days)).to_rfc3339() + (chrono::Utc::now() + chrono::Duration::days(days)) + .to_rfc3339_opts(SecondsFormat::Secs, true) }; // Build relay client with auto-login. @@ -486,7 +488,9 @@ pub async fn create_relay_invite( krillnotes_core::get_or_create_seed_device_id(&crate::settings::home_dir()).ok(); short.map(|s| format!("{}:identity:{}", s, uuid)) }, - expires_at: record.expires_at.map(|dt| dt.to_rfc3339()), + expires_at: record + .expires_at + .map(|dt| dt.to_rfc3339_opts(SecondsFormat::Secs, true)), scope_note_id: record.scope_note_id.clone(), scope_note_title: record.scope_note_title.clone(), offered_role: record.offered_role.clone(), @@ -503,8 +507,11 @@ pub async fn create_relay_invite( // Compute expiry timestamp (use record's expiry or default 7 days from now). let expires_at = record .expires_at - .map(|dt| dt.to_rfc3339()) - .unwrap_or_else(|| (chrono::Utc::now() + chrono::Duration::days(7)).to_rfc3339()); + .map(|dt| dt.to_rfc3339_opts(SecondsFormat::Secs, true)) + .unwrap_or_else(|| { + (chrono::Utc::now() + chrono::Duration::days(7)) + .to_rfc3339_opts(SecondsFormat::Secs, true) + }); // Build relay client with auto-login. let relay_account = { @@ -697,7 +704,7 @@ pub async fn send_invite_response_via_relay( e.to_string() })?; let payload_b64 = BASE64.encode(&bytes); - let expires_at = (chrono::Utc::now() + chrono::Duration::days(expires_in_days as i64)).to_rfc3339(); + let expires_at = (chrono::Utc::now() + chrono::Duration::days(expires_in_days as i64)).to_rfc3339_opts(SecondsFormat::Secs, true); // Auto-login if needed. let mut token = relay_account.session_token.clone(); From 3921f4f3491e4a327f17fec9e8ea18e1561ed130 Mon Sep 17 00:00:00 2001 From: careck Date: Fri, 12 Jun 2026 15:54:16 +1000 Subject: [PATCH 16/19] fix: restore relay session expiry check with 5-minute buffer The previous "always auto-login" approach triggered rate limiting (429) when multiple relay operations fired in quick succession. Restore the expiry check but with a 5-minute buffer to proactively refresh sessions approaching expiry while avoiding unnecessary login attempts. --- .../src-tauri/src/commands/receive_poll.rs | 3 ++- .../src-tauri/src/commands/swarm.rs | 21 +++++++++++++------ .../src-tauri/src/commands/sync.rs | 20 ++++++++++++------ 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs index a7bc2594..65205f39 100644 --- a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs +++ b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs @@ -308,7 +308,8 @@ pub async fn poll_receive_workspace( log::debug!("poll_receive_workspace: using relay account {} on {}", acct.email, acct.relay_url); let mut token = acct.session_token.clone(); let mut login_failed = false; - if !acct.password.is_empty() { + // Re-login when session expires within 5 min (buffer for clock skew / server-side invalidation). + if acct.session_expires_at < chrono::Utc::now() + chrono::Duration::minutes(5) && !acct.password.is_empty() { let client = RelayClient::new(&acct.relay_url); match client.login(&acct.email, &acct.password, &acct.device_public_key) { Ok(session) => token = session.session_token, diff --git a/krillnotes-desktop/src-tauri/src/commands/swarm.rs b/krillnotes-desktop/src-tauri/src/commands/swarm.rs index 7dc55d7b..10d9ee85 100644 --- a/krillnotes-desktop/src-tauri/src/commands/swarm.rs +++ b/krillnotes-desktop/src-tauri/src/commands/swarm.rs @@ -854,11 +854,14 @@ pub async fn send_snapshot_via_relay( let relay_password = relay_account.password.clone(); let relay_device_key = relay_account.device_public_key.clone(); let session_token = relay_account.session_token.clone(); + let session_expires = relay_account.session_expires_at; tokio::task::spawn_blocking(move || -> std::result::Result<(), String> { - // Auto-login unconditionally (server may invalidate sessions before local expiry). + // Re-login when session expires within 5 min (buffer for clock skew / server-side invalidation). let mut token = session_token; - if !relay_password.is_empty() { + if session_expires < chrono::Utc::now() + chrono::Duration::minutes(5) + && !relay_password.is_empty() + { let client = RelayClient::new(&relay_url); match client.login(&relay_email, &relay_password, &relay_device_key) { Ok(session) => token = session.session_token, @@ -1200,11 +1203,14 @@ pub async fn send_self_snapshot_via_relay( let relay_password = relay_account.password.clone(); let relay_device_key = relay_account.device_public_key.clone(); let session_token = relay_account.session_token.clone(); + let session_expires = relay_account.session_expires_at; tokio::task::spawn_blocking(move || -> std::result::Result<(), String> { - // Auto-login unconditionally (server may invalidate sessions before local expiry). + // Re-login when session expires within 5 min (buffer for clock skew / server-side invalidation). let mut token = session_token; - if !relay_password.is_empty() { + if session_expires < chrono::Utc::now() + chrono::Duration::minutes(5) + && !relay_password.is_empty() + { let client = RelayClient::new(&relay_url); match client.login(&relay_email, &relay_password, &relay_device_key) { Ok(session) => token = session.session_token, @@ -1315,12 +1321,15 @@ pub async fn list_devices_on_relay( let relay_password = relay_account.password.clone(); let relay_device_key = relay_account.device_public_key.clone(); let session_token = relay_account.session_token.clone(); + let session_expires = relay_account.session_expires_at; let devices = tokio::task::spawn_blocking( move || -> std::result::Result, String> { - // Auto-login unconditionally (server may invalidate sessions before local expiry). + // Re-login when session expires within 5 min (buffer for clock skew / server-side invalidation). let mut token = session_token; - if !relay_password.is_empty() { + if session_expires < chrono::Utc::now() + chrono::Duration::minutes(5) + && !relay_password.is_empty() + { let client = RelayClient::new(&relay_url); match client.login(&relay_email, &relay_password, &relay_device_key) { Ok(session) => token = session.session_token, diff --git a/krillnotes-desktop/src-tauri/src/commands/sync.rs b/krillnotes-desktop/src-tauri/src/commands/sync.rs index 9497aa35..52cec838 100644 --- a/krillnotes-desktop/src-tauri/src/commands/sync.rs +++ b/krillnotes-desktop/src-tauri/src/commands/sync.rs @@ -186,8 +186,10 @@ pub async fn poll_sync( // first relay account. Multi-relay support requires SyncEngine architecture changes. if let Some(acct) = relay_accounts.first() { let mut token = acct.session_token.clone(); - // Auto-login if session expired and password stored - if !acct.password.is_empty() { + // Re-login when session expires within 5 min (buffer for clock skew / server-side invalidation). + if acct.session_expires_at < chrono::Utc::now() + chrono::Duration::minutes(5) + && !acct.password.is_empty() + { let client = RelayClient::new(&acct.relay_url); match client.login(&acct.email, &acct.password, &acct.device_public_key) { Ok(session) => token = session.session_token, @@ -371,7 +373,10 @@ pub async fn share_invite_link( let relay_url_result = tokio::task::spawn_blocking(move || -> Result { let mut token = relay_account.session_token.clone(); - if !relay_account.password.is_empty() { + // Re-login when session expires within 5 min (buffer for clock skew / server-side invalidation). + if relay_account.session_expires_at < chrono::Utc::now() + chrono::Duration::minutes(5) + && !relay_account.password.is_empty() + { let client = RelayClient::new(&relay_account.relay_url); match client.login( &relay_account.email, @@ -528,7 +533,10 @@ pub async fn create_relay_invite( let relay_url_result = tokio::task::spawn_blocking(move || -> Result { let mut token = relay_account.session_token.clone(); - if !relay_account.password.is_empty() { + // Re-login when session expires within 5 min (buffer for clock skew / server-side invalidation). + if relay_account.session_expires_at < chrono::Utc::now() + chrono::Duration::minutes(5) + && !relay_account.password.is_empty() + { let client = RelayClient::new(&relay_account.relay_url); match client.login( &relay_account.email, @@ -706,9 +714,9 @@ pub async fn send_invite_response_via_relay( let payload_b64 = BASE64.encode(&bytes); let expires_at = (chrono::Utc::now() + chrono::Duration::days(expires_in_days as i64)).to_rfc3339_opts(SecondsFormat::Secs, true); - // Auto-login if needed. + // Re-login when session expires within 5 min (buffer for clock skew / server-side invalidation). let mut token = relay_account.session_token.clone(); - if !relay_account.password.is_empty() { + if relay_account.session_expires_at < chrono::Utc::now() + chrono::Duration::minutes(5) && !relay_account.password.is_empty() { let client = RelayClient::new(&relay_account.relay_url); match client.login(&relay_account.email, &relay_account.password, &relay_account.device_public_key) { Ok(session) => token = session.session_token, From 5e6dc98d8b01da1735785989d44f68d1930c0520 Mon Sep 17 00:00:00 2001 From: careck Date: Fri, 12 Jun 2026 16:03:41 +1000 Subject: [PATCH 17/19] fix: add retry-on-401 for relay polling and auto-login for snapshot polling When the relay server invalidates a session before the local expiry timestamp, the 5-minute buffer proactive refresh doesn't help. Add reactive retry logic to poll_receive_workspace: if ensure_mailbox returns 401, re-login and rebuild the client before proceeding. Also add proactive auto-login to poll_all_identity_snapshots which previously used raw session tokens without any refresh logic. --- .../src-tauri/src/commands/receive_poll.rs | 62 +++++++++++++++++-- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs index 65205f39..087adbc8 100644 --- a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs +++ b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs @@ -303,7 +303,7 @@ pub async fn poll_receive_workspace( // ── Relay channel ──────────────────────────────────────────────────── // Build an optional relay client (None if no relay accounts). - let relay_client: Option = if has_relay { + let mut relay_client: Option = if has_relay { let acct = &relay_accounts[0]; log::debug!("poll_receive_workspace: using relay account {} on {}", acct.email, acct.relay_url); let mut token = acct.session_token.clone(); @@ -326,12 +326,43 @@ pub async fn poll_receive_workspace( None }; - if let Some(ref client) = relay_client { - // Ensure mailbox so we can receive bundles for this workspace. - if let Err(e) = client.ensure_mailbox(&workspace_id_clone) { - log::warn!("poll_receive_workspace: ensure_mailbox failed: {e}"); + // Ensure mailbox exists; re-login reactively if server rejected the token. + { + let mut auth_rejected = false; + if let Some(ref client) = relay_client { + match client.ensure_mailbox(&workspace_id_clone) { + Ok(_) => {} + Err(ref e) if matches!(e, KrillnotesError::RelayAuthExpired { .. }) => { + auth_rejected = true; + log::warn!("poll_receive_workspace: session expired server-side, will re-login"); + } + Err(ref e) => { + log::warn!("poll_receive_workspace: ensure_mailbox failed: {e}"); + } + } } + if auth_rejected && has_relay && !relay_accounts[0].password.is_empty() { + let acct = &relay_accounts[0]; + log::info!("poll_receive_workspace: re-logging in to {}", acct.relay_url); + let login_client = RelayClient::new(&acct.relay_url); + match login_client.login(&acct.email, &acct.password, &acct.device_public_key) { + Ok(session) => { + let refreshed = RelayClient::new(&acct.relay_url) + .with_session_token(&session.session_token); + if let Err(e) = refreshed.ensure_mailbox(&workspace_id_clone) { + log::warn!("poll_receive_workspace: ensure_mailbox after re-login: {e}"); + } + relay_client = Some(refreshed); + } + Err(e) => { + log::warn!("poll_receive_workspace: re-login failed: {e}"); + relay_client = None; + } + } + } + } + if let Some(ref client) = relay_client { // List all pending relay bundles (single API call). let all_bundles = match client.list_bundles(&device_id) { Ok(b) => b, @@ -1046,8 +1077,27 @@ pub async fn poll_all_identity_snapshots( let connections: Vec = relay_accounts .into_iter() .map(|account| { + let mut token = account.session_token.clone(); + // Re-login when session expires within 5 min. + if account.session_expires_at + < chrono::Utc::now() + chrono::Duration::minutes(5) + && !account.password.is_empty() + { + let login_client = RelayClient::new(&account.relay_url); + match login_client.login( + &account.email, + &account.password, + &account.device_public_key, + ) { + Ok(session) => token = session.session_token, + Err(e) => log::warn!( + "poll_all_identity_snapshots: auto-login failed for {}: {e}", + account.relay_url + ), + } + } let client = RelayClient::new(&account.relay_url) - .with_session_token(&account.session_token); + .with_session_token(&token); RelayConnection { account, client } }) .collect(); From 867cdf0adcdc96715ff0d285b5e9427962916036 Mon Sep 17 00:00:00 2001 From: careck Date: Fri, 12 Jun 2026 16:12:59 +1000 Subject: [PATCH 18/19] fix: add pre-flight token check with retry-on-401 for poll_sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SyncEngine receives a RelayChannel at construction and uses it internally for all relay operations. If the token is expired server-side, every operation fails. Add a pre-flight ensure_mailbox check before building the RelayChannel — if 401, re-login and use the fresh token. --- .../src-tauri/src/commands/sync.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/krillnotes-desktop/src-tauri/src/commands/sync.rs b/krillnotes-desktop/src-tauri/src/commands/sync.rs index 52cec838..68b0177d 100644 --- a/krillnotes-desktop/src-tauri/src/commands/sync.rs +++ b/krillnotes-desktop/src-tauri/src/commands/sync.rs @@ -199,6 +199,32 @@ pub async fn poll_sync( ), } } + // Pre-flight: verify token is valid; re-login reactively on 401. + { + let test_client = RelayClient::new(&acct.relay_url).with_session_token(&token); + if let Err(e) = test_client.ensure_mailbox(&workspace_id_str) { + if matches!( + &e, + krillnotes_core::KrillnotesError::RelayAuthExpired { .. } + ) && !acct.password.is_empty() + { + log::info!("poll_sync: session rejected by server, re-logging in"); + let login_client = RelayClient::new(&acct.relay_url); + match login_client.login( + &acct.email, + &acct.password, + &acct.device_public_key, + ) { + Ok(session) => token = session.session_token, + Err(e2) => { + log::warn!("poll_sync: re-login failed: {e2}") + } + } + } else { + log::warn!("poll_sync: ensure_mailbox pre-check failed: {e}"); + } + } + } let relay_client = RelayClient::new(&acct.relay_url).with_session_token(&token); engine.register_channel(Box::new(RelayChannel::new( relay_client, From 61aaa0afe87868a614d8c0f65b40bae7e32831dd Mon Sep 17 00:00:00 2001 From: careck Date: Fri, 12 Jun 2026 16:25:20 +1000 Subject: [PATCH 19/19] fix: persist refreshed relay session tokens to prevent re-login loops After a successful retry-on-401 re-login, the new session token was only used for the current poll cycle. The next cycle read the same stale token from the RelayAccountManager, triggering another re-login attempt and eventually hitting the rate limiter. Now both poll_receive_workspace and poll_sync persist the refreshed token via save_relay_account after re-login, so subsequent cycles use the fresh token without re-authenticating. --- .../src-tauri/src/commands/receive_poll.rs | 20 +++++++++-- .../src-tauri/src/commands/sync.rs | 33 +++++++++++++++++-- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs index 087adbc8..9fe3b99f 100644 --- a/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs +++ b/krillnotes-desktop/src-tauri/src/commands/receive_poll.rs @@ -268,6 +268,7 @@ pub async fn poll_receive_workspace( let invite_managers_arc = Arc::clone(&state.invite_managers); let workspaces_arc = Arc::clone(&state.workspaces); let contact_managers_arc = Arc::clone(&state.contact_managers); + let relay_account_managers_arc = Arc::clone(&state.relay_account_managers); let workspace_id_clone = workspace_id.clone(); let workspace_label_clone = workspace_label.clone(); @@ -352,6 +353,22 @@ pub async fn poll_receive_workspace( if let Err(e) = refreshed.ensure_mailbox(&workspace_id_clone) { log::warn!("poll_receive_workspace: ensure_mailbox after re-login: {e}"); } + // Persist refreshed token so future poll cycles don't re-login. + if let Ok(managers) = relay_account_managers_arc.lock() { + if let Some(mgr) = managers.get(&identity_uuid) { + if let Ok(accounts) = mgr.list_relay_accounts() { + if let Some(mut stored) = accounts.into_iter().find(|a| { + a.relay_account_id == acct.relay_account_id + }) { + stored.session_token = session.session_token.clone(); + stored.session_expires_at = + chrono::Utc::now() + chrono::Duration::days(30); + let _ = mgr.save_relay_account(&stored); + log::info!("poll_receive_workspace: persisted refreshed session token"); + } + } + } + } relay_client = Some(refreshed); } Err(e) => { @@ -1096,8 +1113,7 @@ pub async fn poll_all_identity_snapshots( ), } } - let client = RelayClient::new(&account.relay_url) - .with_session_token(&token); + let client = RelayClient::new(&account.relay_url).with_session_token(&token); RelayConnection { account, client } }) .collect(); diff --git a/krillnotes-desktop/src-tauri/src/commands/sync.rs b/krillnotes-desktop/src-tauri/src/commands/sync.rs index 68b0177d..a2b981e2 100644 --- a/krillnotes-desktop/src-tauri/src/commands/sync.rs +++ b/krillnotes-desktop/src-tauri/src/commands/sync.rs @@ -173,6 +173,7 @@ pub async fn poll_sync( // outer runtime context is still present on the thread, causing a panic on drop. let workspaces_arc = Arc::clone(&state.workspaces); let contact_managers_arc = Arc::clone(&state.contact_managers); + let relay_account_managers_arc = Arc::clone(&state.relay_account_managers); let events = tokio::task::spawn_blocking(move || -> Result, String> { let mut engine = SyncEngine::new(); @@ -186,13 +187,17 @@ pub async fn poll_sync( // first relay account. Multi-relay support requires SyncEngine architecture changes. if let Some(acct) = relay_accounts.first() { let mut token = acct.session_token.clone(); + let mut session_refreshed = false; // Re-login when session expires within 5 min (buffer for clock skew / server-side invalidation). if acct.session_expires_at < chrono::Utc::now() + chrono::Duration::minutes(5) && !acct.password.is_empty() { let client = RelayClient::new(&acct.relay_url); match client.login(&acct.email, &acct.password, &acct.device_public_key) { - Ok(session) => token = session.session_token, + Ok(session) => { + token = session.session_token; + session_refreshed = true; + } Err(e) => log::warn!( "poll_sync: inline auto-login failed for {}: {e}", acct.relay_url @@ -200,7 +205,7 @@ pub async fn poll_sync( } } // Pre-flight: verify token is valid; re-login reactively on 401. - { + if !session_refreshed { let test_client = RelayClient::new(&acct.relay_url).with_session_token(&token); if let Err(e) = test_client.ensure_mailbox(&workspace_id_str) { if matches!( @@ -215,7 +220,10 @@ pub async fn poll_sync( &acct.password, &acct.device_public_key, ) { - Ok(session) => token = session.session_token, + Ok(session) => { + token = session.session_token; + session_refreshed = true; + } Err(e2) => { log::warn!("poll_sync: re-login failed: {e2}") } @@ -225,6 +233,25 @@ pub async fn poll_sync( } } } + // Persist refreshed token so future poll cycles don't re-login. + if session_refreshed { + if let Ok(managers) = relay_account_managers_arc.lock() { + if let Some(mgr) = managers.get(&identity_uuid) { + if let Ok(accounts) = mgr.list_relay_accounts() { + if let Some(mut stored) = accounts + .into_iter() + .find(|a| a.relay_account_id == acct.relay_account_id) + { + stored.session_token = token.clone(); + stored.session_expires_at = + chrono::Utc::now() + chrono::Duration::days(30); + let _ = mgr.save_relay_account(&stored); + log::info!("poll_sync: persisted refreshed session token"); + } + } + } + } + } let relay_client = RelayClient::new(&acct.relay_url).with_session_token(&token); engine.register_channel(Box::new(RelayChannel::new( relay_client,