max / balanced_breakfast
15 files changed,
+320 insertions,
-148 deletions
| @@ -198,6 +198,7 @@ dependencies = [ | |||
| 198 | 198 | "sha2", | |
| 199 | 199 | "sqlx", | |
| 200 | 200 | "synckit-client", | |
| 201 | + | "tagtree", | |
| 201 | 202 | "tauri", | |
| 202 | 203 | "tauri-build", | |
| 203 | 204 | "tauri-plugin-dialog", | |
| @@ -234,12 +235,12 @@ name = "bb-core" | |||
| 234 | 235 | version = "0.3.0" | |
| 235 | 236 | dependencies = [ | |
| 236 | 237 | "aes-gcm", | |
| 237 | - | "ammonia", | |
| 238 | 238 | "base64 0.22.1", | |
| 239 | 239 | "bb-db", | |
| 240 | 240 | "bb-feed", | |
| 241 | 241 | "bb-interface", | |
| 242 | 242 | "chrono", | |
| 243 | + | "docengine", | |
| 243 | 244 | "html2text", | |
| 244 | 245 | "keyring", | |
| 245 | 246 | "parking_lot", | |
| @@ -972,6 +973,15 @@ dependencies = [ | |||
| 972 | 973 | ] | |
| 973 | 974 | ||
| 974 | 975 | [[package]] | |
| 976 | + | name = "docengine" | |
| 977 | + | version = "0.3.0" | |
| 978 | + | dependencies = [ | |
| 979 | + | "ammonia", | |
| 980 | + | "pulldown-cmark", | |
| 981 | + | "serde", | |
| 982 | + | ] | |
| 983 | + | ||
| 984 | + | [[package]] | |
| 975 | 985 | name = "dotenvy" | |
| 976 | 986 | version = "0.15.7" | |
| 977 | 987 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1439,6 +1449,15 @@ dependencies = [ | |||
| 1439 | 1449 | ] | |
| 1440 | 1450 | ||
| 1441 | 1451 | [[package]] | |
| 1452 | + | name = "getopts" | |
| 1453 | + | version = "0.2.24" | |
| 1454 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1455 | + | checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" | |
| 1456 | + | dependencies = [ | |
| 1457 | + | "unicode-width 0.2.2", | |
| 1458 | + | ] | |
| 1459 | + | ||
| 1460 | + | [[package]] | |
| 1442 | 1461 | name = "getrandom" | |
| 1443 | 1462 | version = "0.1.16" | |
| 1444 | 1463 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1736,7 +1755,7 @@ dependencies = [ | |||
| 1736 | 1755 | "markup5ever 0.12.1", | |
| 1737 | 1756 | "tendril", | |
| 1738 | 1757 | "thiserror 1.0.69", | |
| 1739 | - | "unicode-width", | |
| 1758 | + | "unicode-width 0.1.13", | |
| 1740 | 1759 | ] | |
| 1741 | 1760 | ||
| 1742 | 1761 | [[package]] | |
| @@ -3450,6 +3469,25 @@ dependencies = [ | |||
| 3450 | 3469 | ] | |
| 3451 | 3470 | ||
| 3452 | 3471 | [[package]] | |
| 3472 | + | name = "pulldown-cmark" | |
| 3473 | + | version = "0.12.2" | |
| 3474 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3475 | + | checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" | |
| 3476 | + | dependencies = [ | |
| 3477 | + | "bitflags 2.10.0", | |
| 3478 | + | "getopts", | |
| 3479 | + | "memchr", | |
| 3480 | + | "pulldown-cmark-escape", | |
| 3481 | + | "unicase", | |
| 3482 | + | ] | |
| 3483 | + | ||
| 3484 | + | [[package]] | |
| 3485 | + | name = "pulldown-cmark-escape" | |
| 3486 | + | version = "0.11.0" | |
| 3487 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3488 | + | checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" | |
| 3489 | + | ||
| 3490 | + | [[package]] | |
| 3453 | 3491 | name = "quick-xml" | |
| 3454 | 3492 | version = "0.38.4" | |
| 3455 | 3493 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4844,6 +4882,10 @@ dependencies = [ | |||
| 4844 | 4882 | ] | |
| 4845 | 4883 | ||
| 4846 | 4884 | [[package]] | |
| 4885 | + | name = "tagtree" | |
| 4886 | + | version = "0.3.0" | |
| 4887 | + | ||
| 4888 | + | [[package]] | |
| 4847 | 4889 | name = "tao" | |
| 4848 | 4890 | version = "0.34.5" | |
| 4849 | 4891 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -5760,6 +5802,12 @@ dependencies = [ | |||
| 5760 | 5802 | ] | |
| 5761 | 5803 | ||
| 5762 | 5804 | [[package]] | |
| 5805 | + | name = "unicase" | |
| 5806 | + | version = "2.9.0" | |
| 5807 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5808 | + | checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" | |
| 5809 | + | ||
| 5810 | + | [[package]] | |
| 5763 | 5811 | name = "unicode-bidi" | |
| 5764 | 5812 | version = "0.3.18" | |
| 5765 | 5813 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -5799,6 +5847,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 5799 | 5847 | checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" | |
| 5800 | 5848 | ||
| 5801 | 5849 | [[package]] | |
| 5850 | + | name = "unicode-width" | |
| 5851 | + | version = "0.2.2" | |
| 5852 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5853 | + | checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" | |
| 5854 | + | ||
| 5855 | + | [[package]] | |
| 5802 | 5856 | name = "universal-hash" | |
| 5803 | 5857 | version = "0.5.1" | |
| 5804 | 5858 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -53,4 +53,6 @@ bb-core = { path = "crates/bb-core" } | |||
| 53 | 53 | bb-feed = { path = "crates/bb-feed" } | |
| 54 | 54 | bb-db = { path = "crates/bb-db" } | |
| 55 | 55 | synckit-client = { path = "../synckit-client" } | |
| 56 | + | tauri = "2.10.2" | |
| 56 | 57 | tauri-plugin-updater = "2" | |
| 58 | + | tagtree = { path = "../tagtree" } |
| @@ -46,7 +46,7 @@ url = "2" | |||
| 46 | 46 | regex = "1" | |
| 47 | 47 | ||
| 48 | 48 | # HTML sanitization for untrusted feed content | |
| 49 | - | ammonia = "4" | |
| 49 | + | docengine = { path = "../../../docengine" } | |
| 50 | 50 | ||
| 51 | 51 | # Article extraction (reader view) | |
| 52 | 52 | readable-readability = "0.4" |
| @@ -18,14 +18,14 @@ use std::path::Path; | |||
| 18 | 18 | const PREFIX: &str = "bb_enc:v1:"; | |
| 19 | 19 | ||
| 20 | 20 | /// Generate a random 256-bit encryption key. | |
| 21 | - | pub fn generate_key() -> [u8; 32] { | |
| 21 | + | fn generate_key() -> [u8; 32] { | |
| 22 | 22 | let mut key = [0u8; 32]; | |
| 23 | 23 | rand::thread_rng().fill_bytes(&mut key); | |
| 24 | 24 | key | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | /// Load an encryption key from a file, or generate and save one if it doesn't exist. | |
| 28 | - | pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], String> { | |
| 28 | + | fn load_or_create_key(path: &Path) -> Result<[u8; 32], String> { | |
| 29 | 29 | if path.exists() { | |
| 30 | 30 | let data = std::fs::read(path).map_err(|e| format!("Failed to read encryption key: {e}"))?; | |
| 31 | 31 | if data.len() != 32 { |
| @@ -189,8 +189,15 @@ impl Orchestrator { | |||
| 189 | 189 | return Ok(0); | |
| 190 | 190 | }; | |
| 191 | 191 | ||
| 192 | - | let plugins = self.plugins.read().await; | |
| 193 | - | let result = match plugins.fetch(plugin_id, None) { | |
| 192 | + | // Acquire the read lock only for the fetch call, then release it | |
| 193 | + | // before any async DB operations to avoid holding the lock across | |
| 194 | + | // blocking network I/O. | |
| 195 | + | let fetch_result = { | |
| 196 | + | let plugins = self.plugins.read().await; | |
| 197 | + | plugins.fetch(plugin_id, None) | |
| 198 | + | }; | |
| 199 | + | ||
| 200 | + | let result = match fetch_result { | |
| 194 | 201 | Ok(r) => r, | |
| 195 | 202 | Err(e) => { | |
| 196 | 203 | // Record fetch failure before propagating | |
| @@ -233,7 +240,7 @@ impl Orchestrator { | |||
| 233 | 240 | // Sanitize HTML in body to strip scripts, event handlers, and | |
| 234 | 241 | // other dangerous markup from untrusted feed/plugin content. | |
| 235 | 242 | if let Some(ref body) = create_item.body { | |
| 236 | - | create_item.body = Some(ammonia::clean(body)); | |
| 243 | + | create_item.body = Some(docengine::sanitize_html(body)); | |
| 237 | 244 | } | |
| 238 | 245 | ||
| 239 | 246 | match self.db.items().upsert(create_item).await { |
| @@ -58,6 +58,9 @@ toml.workspace = true | |||
| 58 | 58 | tracing.workspace = true | |
| 59 | 59 | tracing-subscriber.workspace = true | |
| 60 | 60 | ||
| 61 | + | # Tag standard | |
| 62 | + | tagtree.workspace = true | |
| 63 | + | ||
| 61 | 64 | # Desktop-only plugins | |
| 62 | 65 | [target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies] | |
| 63 | 66 | tauri-plugin-shell = "2.3.5" |
| @@ -95,6 +95,10 @@ | |||
| 95 | 95 | ||
| 96 | 96 | // --- Sync: cloud sync via MNW SyncKit --- | |
| 97 | 97 | sync: { | |
| 98 | + | /** Validate an API key against the server. Returns app name. */ | |
| 99 | + | testApiKey: (apiKey) => invoke('sync_test_api_key', { apiKey }), | |
| 100 | + | /** Save an API key and create the sync client. */ | |
| 101 | + | saveApiKey: (apiKey) => invoke('sync_save_api_key', { apiKey }), | |
| 98 | 102 | /** Get current sync status. */ | |
| 99 | 103 | status: () => invoke('sync_status'), | |
| 100 | 104 | /** Start OAuth2 PKCE auth flow, returns auth URL + PKCE params. */ |
| @@ -38,21 +38,86 @@ | |||
| 38 | 38 | } | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | - | // ── State 1: Connect ── | |
| 41 | + | // ── State 1: API Key Setup ── | |
| 42 | 42 | ||
| 43 | 43 | function renderConnect(container) { | |
| 44 | 44 | const div = document.createElement('div'); | |
| 45 | 45 | div.className = 'sync-connect'; | |
| 46 | 46 | div.innerHTML = | |
| 47 | - | '<p>Connect to Makenot.work to sync your feeds, read state, and preferences across devices.</p>' + | |
| 48 | - | '<p>Data is end-to-end encrypted. The server never sees your plaintext data.</p>'; | |
| 47 | + | '<p>Sync your feeds and preferences across devices via Makenot.work.</p>' + | |
| 48 | + | '<p style="margin-bottom: 0.5rem;"><a href="https://makenot.work/docs/synckit-api" target="_blank">Get an API key</a></p>'; | |
| 49 | 49 | ||
| 50 | - | const btn = document.createElement('button'); | |
| 51 | - | btn.className = 'btn btn-primary'; | |
| 52 | - | btn.textContent = 'Connect to Makenot.work'; | |
| 53 | - | btn.onclick = startAuth; | |
| 54 | - | div.appendChild(btn); | |
| 50 | + | const group = document.createElement('div'); | |
| 51 | + | group.className = 'form-group'; | |
| 52 | + | const label = document.createElement('label'); | |
| 53 | + | label.textContent = 'API Key'; | |
| 54 | + | const row = document.createElement('div'); | |
| 55 | + | row.style.display = 'flex'; | |
| 56 | + | row.style.gap = '0.5rem'; | |
| 57 | + | const input = document.createElement('input'); | |
| 58 | + | input.className = 'form-input'; | |
| 59 | + | input.type = 'password'; | |
| 60 | + | input.placeholder = 'sk_...'; | |
| 61 | + | input.style.flex = '1'; | |
| 62 | + | const testBtn = document.createElement('button'); | |
| 63 | + | testBtn.className = 'btn'; | |
| 64 | + | testBtn.textContent = 'Test'; | |
| 65 | + | ||
| 66 | + | const statusDiv = document.createElement('div'); | |
| 67 | + | statusDiv.style.fontSize = '0.875rem'; | |
| 68 | + | statusDiv.style.marginTop = '0.5rem'; | |
| 69 | + | statusDiv.style.display = 'none'; | |
| 70 | + | ||
| 71 | + | const saveBtn = document.createElement('button'); | |
| 72 | + | saveBtn.className = 'btn btn-primary'; | |
| 73 | + | saveBtn.textContent = 'Save & Connect'; | |
| 74 | + | saveBtn.style.marginTop = '0.75rem'; | |
| 75 | + | saveBtn.style.display = 'none'; | |
| 76 | + | ||
| 77 | + | testBtn.onclick = async () => { | |
| 78 | + | const key = input.value.trim(); | |
| 79 | + | if (!key) return; | |
| 80 | + | testBtn.disabled = true; | |
| 81 | + | testBtn.textContent = 'Testing...'; | |
| 82 | + | statusDiv.style.display = 'block'; | |
| 83 | + | statusDiv.textContent = 'Validating...'; | |
| 84 | + | statusDiv.style.color = ''; | |
| 85 | + | saveBtn.style.display = 'none'; | |
| 86 | + | try { | |
| 87 | + | const appName = await BB.api.sync.testApiKey(key); | |
| 88 | + | statusDiv.style.color = 'var(--accent-green, green)'; | |
| 89 | + | statusDiv.textContent = 'Valid \u2014 ' + appName; | |
| 90 | + | saveBtn.style.display = ''; | |
| 91 | + | } catch (err) { | |
| 92 | + | statusDiv.style.color = 'var(--accent-red, red)'; | |
| 93 | + | statusDiv.textContent = err.message || String(err); | |
| 94 | + | saveBtn.style.display = 'none'; | |
| 95 | + | } finally { | |
| 96 | + | testBtn.disabled = false; | |
| 97 | + | testBtn.textContent = 'Test'; | |
| 98 | + | } | |
| 99 | + | }; | |
| 100 | + | ||
| 101 | + | saveBtn.onclick = async () => { | |
| 102 | + | const key = input.value.trim(); | |
| 103 | + | if (!key) return; | |
| 104 | + | try { | |
| 105 | + | await BB.api.sync.saveApiKey(key); | |
| 106 | + | BB.ui.showToast('API key saved!', 'success'); | |
| 107 | + | const status = await BB.api.sync.status(); | |
| 108 | + | renderState(container, status); | |
| 109 | + | } catch (err) { | |
| 110 | + | BB.ui.showToast('Failed to save: ' + (err.message || err), 'error'); | |
| 111 | + | } | |
| 112 | + | }; | |
| 55 | 113 | ||
| 114 | + | row.appendChild(input); | |
| 115 | + | row.appendChild(testBtn); | |
| 116 | + | group.appendChild(label); | |
| 117 | + | group.appendChild(row); | |
| 118 | + | div.appendChild(group); | |
| 119 | + | div.appendChild(statusDiv); | |
| 120 | + | div.appendChild(saveBtn); | |
| 56 | 121 | container.appendChild(div); | |
| 57 | 122 | } | |
| 58 | 123 |
| @@ -181,11 +181,15 @@ pub async fn create_feed( | |||
| 181 | 181 | ||
| 182 | 182 | let name = validate_feed_input(&input.name, &input.config, &schema)?; | |
| 183 | 183 | ||
| 184 | + | // Serialize the plaintext config for the duplicate check before we move | |
| 185 | + | // `input.config` into the encryption step (avoids an extra clone). | |
| 186 | + | let new_config_str = serde_json::to_string(&input.config).unwrap_or_default(); | |
| 187 | + | ||
| 184 | 188 | let db = state.orchestrator.database(); | |
| 185 | 189 | ||
| 186 | - | // Encrypt Secret-type fields before storage | |
| 190 | + | // Encrypt Secret-type fields before storage (mutates in place, no clone) | |
| 187 | 191 | let config = { | |
| 188 | - | let mut cfg = input.config.clone(); | |
| 192 | + | let mut cfg = input.config; | |
| 189 | 193 | if let Some(key) = state.orchestrator.encryption_key() { | |
| 190 | 194 | let plugins = state.orchestrator.plugins(); | |
| 191 | 195 | let plugins = plugins.read().await; | |
| @@ -217,7 +221,6 @@ pub async fn create_feed( | |||
| 217 | 221 | plugins.get_config_schema(&input.busser_id) | |
| 218 | 222 | }; | |
| 219 | 223 | ||
| 220 | - | let new_config_str = serde_json::to_string(&input.config).unwrap_or_default(); | |
| 221 | 224 | for feed in &existing { | |
| 222 | 225 | let mut existing_config = feed.config_json(); | |
| 223 | 226 | // Decrypt existing config for comparison against plaintext input | |
| @@ -337,22 +340,19 @@ pub async fn update_feed( | |||
| 337 | 340 | ||
| 338 | 341 | let name = validate_feed_input(&name, &config, &schema)?; | |
| 339 | 342 | ||
| 340 | - | // Encrypt Secret-type fields before storage | |
| 341 | - | let encrypted_config = { | |
| 342 | - | let mut cfg = config.clone(); | |
| 343 | - | if let Some(key) = state.orchestrator.encryption_key() { | |
| 344 | - | let plugins = state.orchestrator.plugins(); | |
| 345 | - | let plugins = plugins.read().await; | |
| 346 | - | if let Some(schema) = plugins.get_config_schema(&busser_id) { | |
| 347 | - | bb_core::crypto::encrypt_config_secrets(&mut cfg, &schema, key); | |
| 348 | - | } | |
| 343 | + | // Encrypt Secret-type fields before storage (mutate in place, no clone) | |
| 344 | + | let mut config = config; | |
| 345 | + | if let Some(key) = state.orchestrator.encryption_key() { | |
| 346 | + | let plugins = state.orchestrator.plugins(); | |
| 347 | + | let plugins = plugins.read().await; | |
| 348 | + | if let Some(schema) = plugins.get_config_schema(&busser_id) { | |
| 349 | + | bb_core::crypto::encrypt_config_secrets(&mut config, &schema, key); | |
| 349 | 350 | } | |
| 350 | - | cfg | |
| 351 | - | }; | |
| 351 | + | } | |
| 352 | 352 | ||
| 353 | 353 | // Update name and config | |
| 354 | 354 | db.feeds().update_name(feed_id, &name).await?; | |
| 355 | - | let config_str = serde_json::to_string(&encrypted_config) | |
| 355 | + | let config_str = serde_json::to_string(&config) | |
| 356 | 356 | .map_err(|e| ApiError::internal(format!("Failed to serialize config: {}", e)))?; | |
| 357 | 357 | db.feeds().update_config(feed_id, &config_str).await?; | |
| 358 | 358 | ||
| @@ -428,6 +428,13 @@ pub async fn delete_feeds_by_busser( | |||
| 428 | 428 | Ok(()) | |
| 429 | 429 | } | |
| 430 | 430 | ||
| 431 | + | /// Tag rules for Balanced Breakfast: shallow hierarchy, no required semantic prefix. | |
| 432 | + | const BB_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig { | |
| 433 | + | max_depth: 3, | |
| 434 | + | max_length: 80, | |
| 435 | + | semantic_depth: 0, | |
| 436 | + | }; | |
| 437 | + | ||
| 431 | 438 | /// Set tags on all feeds belonging to a busser. | |
| 432 | 439 | #[tauri::command] | |
| 433 | 440 | #[instrument(skip_all)] | |
| @@ -436,6 +443,11 @@ pub async fn set_feed_tags( | |||
| 436 | 443 | busser_id: String, | |
| 437 | 444 | tags: Vec<String>, | |
| 438 | 445 | ) -> Result<(), ApiError> { | |
| 446 | + | for tag in &tags { | |
| 447 | + | tagtree::validate_with(tag, &BB_TAG_CONFIG) | |
| 448 | + | .map_err(|e| ApiError::bad_request(format!("invalid tag: {}", e.0)))?; | |
| 449 | + | } | |
| 450 | + | ||
| 439 | 451 | let db = state.orchestrator.database(); | |
| 440 | 452 | let feeds = db.feeds().get_by_busser(&busser_id).await?; | |
| 441 | 453 |
| @@ -114,7 +114,9 @@ pub struct ItemsFilter { | |||
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | 116 | /// Convert a `FeedItem` (from `FeedGenerator`) to a compact summary response. | |
| 117 | - | fn feed_item_to_summary(item: &FeedItem) -> ItemSummaryResponse { | |
| 117 | + | /// | |
| 118 | + | /// Takes ownership of the `FeedItem` to move fields instead of cloning. | |
| 119 | + | fn feed_item_to_summary(item: FeedItem) -> ItemSummaryResponse { | |
| 118 | 120 | let published_at = match chrono::DateTime::from_timestamp(item.meta.published_at, 0) { | |
| 119 | 121 | Some(dt) => dt.format(bb_db::TIMESTAMP_FMT).to_string(), | |
| 120 | 122 | None => { | |
| @@ -129,7 +131,7 @@ fn feed_item_to_summary(item: &FeedItem) -> ItemSummaryResponse { | |||
| 129 | 131 | } | |
| 130 | 132 | }; | |
| 131 | 133 | ||
| 132 | - | let id = match item.db_id.clone() { | |
| 134 | + | let id = match item.db_id { | |
| 133 | 135 | Some(id) => id, | |
| 134 | 136 | None => { | |
| 135 | 137 | tracing::warn!( | |
| @@ -140,19 +142,21 @@ fn feed_item_to_summary(item: &FeedItem) -> ItemSummaryResponse { | |||
| 140 | 142 | } | |
| 141 | 143 | }; | |
| 142 | 144 | ||
| 145 | + | let time_ago = format_time_ago(&published_at); | |
| 146 | + | ||
| 143 | 147 | ItemSummaryResponse { | |
| 144 | 148 | id, | |
| 145 | - | external_id: item.id.item_id.clone(), | |
| 146 | - | source_id: item.id.source.clone(), | |
| 147 | - | source_name: item.meta.source_name.clone(), | |
| 148 | - | author: item.bite.author.clone(), | |
| 149 | - | text: item.bite.text.clone(), | |
| 150 | - | secondary: item.bite.secondary.clone(), | |
| 151 | - | indicator: item.bite.indicator.clone(), | |
| 152 | - | title: item.content.title.clone(), | |
| 153 | - | url: item.content.url.clone(), | |
| 154 | - | published_at: published_at.clone(), | |
| 155 | - | time_ago: format_time_ago(&published_at), | |
| 149 | + | external_id: item.id.item_id, | |
| 150 | + | source_id: item.id.source, | |
| 151 | + | source_name: item.meta.source_name, | |
| 152 | + | author: item.bite.author, | |
| 153 | + | text: item.bite.text, | |
| 154 | + | secondary: item.bite.secondary, | |
| 155 | + | indicator: item.bite.indicator, | |
| 156 | + | title: item.content.title, | |
| 157 | + | url: item.content.url, | |
| 158 | + | published_at, | |
| 159 | + | time_ago, | |
| 156 | 160 | score: item.meta.score, | |
| 157 | 161 | is_read: item.is_read, | |
| 158 | 162 | is_starred: item.is_starred, | |
| @@ -260,7 +264,7 @@ pub async fn list_items( | |||
| 260 | 264 | let result = generator.get_items(page).await?; | |
| 261 | 265 | ||
| 262 | 266 | let summaries: Vec<ItemSummaryResponse> = | |
| 263 | - | result.items.iter().map(feed_item_to_summary).collect(); | |
| 267 | + | result.items.into_iter().map(feed_item_to_summary).collect(); | |
| 264 | 268 | ||
| 265 | 269 | Ok(ItemsListResponse { | |
| 266 | 270 | items: summaries, |
| @@ -88,9 +88,10 @@ pub async fn get_plugin_schema( | |||
| 88 | 88 | .ok_or_else(|| ApiError::not_found(format!("Plugin {} schema not found", id)))?; | |
| 89 | 89 | ||
| 90 | 90 | // Convert internal ConfigFieldType enums to string tags for the frontend. | |
| 91 | + | // Uses into_iter() to move fields out of the owned schema instead of cloning. | |
| 91 | 92 | let fields: Vec<ConfigFieldResponse> = schema | |
| 92 | 93 | .fields | |
| 93 | - | .iter() | |
| 94 | + | .into_iter() | |
| 94 | 95 | .map(|f| { | |
| 95 | 96 | let field_type = match f.field_type { | |
| 96 | 97 | bb_interface::ConfigFieldType::Text => "text", | |
| @@ -103,14 +104,14 @@ pub async fn get_plugin_schema( | |||
| 103 | 104 | }; | |
| 104 | 105 | ||
| 105 | 106 | ConfigFieldResponse { | |
| 106 | - | key: f.key.clone(), | |
| 107 | - | label: f.label.clone(), | |
| 108 | - | description: f.description.clone(), | |
| 107 | + | key: f.key, | |
| 108 | + | label: f.label, | |
| 109 | + | description: f.description, | |
| 109 | 110 | field_type: field_type.to_string(), | |
| 110 | 111 | required: f.required, | |
| 111 | - | default: f.default.clone(), | |
| 112 | - | options: f.options.clone(), | |
| 113 | - | placeholder: f.placeholder.clone(), | |
| 112 | + | default: f.default, | |
| 113 | + | options: f.options, | |
| 114 | + | placeholder: f.placeholder, | |
| 114 | 115 | } | |
| 115 | 116 | }) | |
| 116 | 117 | .collect(); | |
| @@ -118,7 +119,7 @@ pub async fn get_plugin_schema( | |||
| 118 | 119 | Ok(PluginSchemaResponse { | |
| 119 | 120 | id, | |
| 120 | 121 | name, | |
| 121 | - | description: schema.description.to_string(), | |
| 122 | + | description: schema.description, | |
| 122 | 123 | fields, | |
| 123 | 124 | }) | |
| 124 | 125 | } |
| @@ -4,13 +4,24 @@ | |||
| 4 | 4 | //! via OAuth2 PKCE flow, managing encryption, manual sync, and settings. | |
| 5 | 5 | ||
| 6 | 6 | use super::error::ApiError; | |
| 7 | - | use crate::state::AppState; | |
| 7 | + | use crate::state::{self, AppState}; | |
| 8 | 8 | use crate::sync_service; | |
| 9 | 9 | use serde::{Deserialize, Serialize}; | |
| 10 | 10 | use std::sync::Arc; | |
| 11 | 11 | use tauri::{Emitter, State}; | |
| 12 | 12 | use tracing::instrument; | |
| 13 | 13 | ||
| 14 | + | // ── Helpers ── | |
| 15 | + | ||
| 16 | + | /// Extract the sync client from state (clones the Arc for use across await points). | |
| 17 | + | fn get_sync_client(state: &AppState) -> Option<Arc<synckit_client::SyncKitClient>> { | |
| 18 | + | state.sync_client.read().clone() | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | fn require_sync_client(state: &AppState) -> Result<Arc<synckit_client::SyncKitClient>, ApiError> { | |
| 22 | + | get_sync_client(state).ok_or_else(|| ApiError::bad_request("Sync is not configured")) | |
| 23 | + | } | |
| 24 | + | ||
| 14 | 25 | // ── Types ── | |
| 15 | 26 | ||
| 16 | 27 | #[derive(Debug, Serialize)] | |
| @@ -89,7 +100,7 @@ fn generate_state() -> String { | |||
| 89 | 100 | /// Start a minimal HTTP server on a random port that waits for the OAuth redirect. | |
| 90 | 101 | /// Returns the port. The server accepts one connection, parses the query string, | |
| 91 | 102 | /// responds with a success/error page, then shuts down. | |
| 92 | - | fn start_callback_server() -> Result<(u16, tokio::sync::oneshot::Receiver<CallbackResult>), ApiError> { | |
| 103 | + | fn start_callback_server() -> Result<u16, ApiError> { | |
| 93 | 104 | let listener = std::net::TcpListener::bind("127.0.0.1:0") | |
| 94 | 105 | .map_err(|e| ApiError::internal(format!("Failed to bind callback server: {}", e)))?; | |
| 95 | 106 | let port = listener | |
| @@ -100,13 +111,10 @@ fn start_callback_server() -> Result<(u16, tokio::sync::oneshot::Receiver<Callba | |||
| 100 | 111 | .set_nonblocking(true) | |
| 101 | 112 | .map_err(|e| ApiError::internal(format!("Failed to set non-blocking: {}", e)))?; | |
| 102 | 113 | ||
| 103 | - | let (tx, rx) = tokio::sync::oneshot::channel(); | |
| 104 | - | ||
| 105 | 114 | std::thread::spawn(move || { | |
| 106 | 115 | use std::io::{Read, Write}; | |
| 107 | 116 | ||
| 108 | 117 | let timeout = std::time::Instant::now() + std::time::Duration::from_secs(300); | |
| 109 | - | let mut sender = Some(tx); | |
| 110 | 118 | ||
| 111 | 119 | while std::time::Instant::now() < timeout { | |
| 112 | 120 | match listener.accept() { | |
| @@ -116,38 +124,23 @@ fn start_callback_server() -> Result<(u16, tokio::sync::oneshot::Receiver<Callba | |||
| 116 | 124 | let request = String::from_utf8_lossy(&buf[..n]); | |
| 117 | 125 | ||
| 118 | 126 | // Parse GET /callback?code=xxx&state=xxx | |
| 119 | - | if let Some(query) = request | |
| 127 | + | let has_code = request | |
| 120 | 128 | .lines() | |
| 121 | 129 | .next() | |
| 122 | 130 | .and_then(|line| line.split_whitespace().nth(1)) | |
| 123 | 131 | .and_then(|path| path.split('?').nth(1)) | |
| 124 | - | { | |
| 125 | - | let params: std::collections::HashMap<_, _> = query | |
| 126 | - | .split('&') | |
| 127 | - | .filter_map(|pair| { | |
| 128 | - | let mut parts = pair.splitn(2, '='); | |
| 129 | - | Some((parts.next()?, parts.next().unwrap_or(""))) | |
| 130 | - | }) | |
| 131 | - | .collect(); | |
| 132 | - | ||
| 133 | - | if let Some(code) = params.get("code") { | |
| 134 | - | let state = params.get("state").unwrap_or(&"").to_string(); | |
| 135 | - | let body = "<html><body><h1>Authenticated</h1><p>You can close this tab and return to Balanced Breakfast.</p></body></html>"; | |
| 136 | - | let response = format!( | |
| 137 | - | "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", | |
| 138 | - | body.len(), body | |
| 139 | - | ); | |
| 140 | - | let _ = stream.write_all(response.as_bytes()); | |
| 141 | - | let _ = stream.flush(); | |
| 142 | - | ||
| 143 | - | if let Some(tx) = sender.take() { | |
| 144 | - | let _ = tx.send(CallbackResult { | |
| 145 | - | code: code.to_string(), | |
| 146 | - | state, | |
| 147 | - | }); | |
| 148 | - | } | |
| 149 | - | break; | |
| 150 | - | } | |
| 132 | + | .map(|query| query.split('&').any(|pair| pair.starts_with("code="))) | |
| 133 | + | .unwrap_or(false); | |
| 134 | + | ||
| 135 | + | if has_code { | |
| 136 | + | let body = "<html><body><h1>Authenticated</h1><p>You can close this tab and return to Balanced Breakfast.</p></body></html>"; | |
| 137 | + | let response = format!( | |
| 138 | + | "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", | |
| 139 | + | body.len(), body | |
| 140 | + | ); | |
| 141 | + | let _ = stream.write_all(response.as_bytes()); | |
| 142 | + | let _ = stream.flush(); | |
| 143 | + | break; | |
| 151 | 144 | } | |
| 152 | 145 | ||
| 153 | 146 | let body = "Waiting for authentication..."; | |
| @@ -165,23 +158,47 @@ fn start_callback_server() -> Result<(u16, tokio::sync::oneshot::Receiver<Callba | |||
| 165 | 158 | } | |
| 166 | 159 | }); | |
| 167 | 160 | ||
| 168 | - | Ok((port, rx)) | |
| 161 | + | Ok(port) | |
| 169 | 162 | } | |
| 170 | 163 | ||
| 171 | - | #[allow(dead_code)] | |
| 172 | - | struct CallbackResult { | |
| 173 | - | code: String, | |
| 174 | - | state: String, | |
| 164 | + | // ── Commands ── | |
| 165 | + | ||
| 166 | + | /// Validate an API key against the server. Returns the app name on success. | |
| 167 | + | #[tauri::command] | |
| 168 | + | #[instrument(skip_all)] | |
| 169 | + | pub async fn sync_test_api_key(api_key: String) -> Result<String, ApiError> { | |
| 170 | + | let server_url = std::env::var("BB_SYNC_SERVER_URL") | |
| 171 | + | .unwrap_or_else(|_| state::SYNC_SERVER_URL.to_string()); | |
| 172 | + | let app_name = synckit_client::validate_api_key(&server_url, &api_key) | |
| 173 | + | .await | |
| 174 | + | .map_err(|e| ApiError::internal(format!("Invalid API key: {}", e)))?; | |
| 175 | + | Ok(app_name) | |
| 175 | 176 | } | |
| 176 | 177 | ||
| 177 | - | // ── Commands ── | |
| 178 | + | /// Save an API key and create a SyncKit client. Returns true on success. | |
| 179 | + | #[tauri::command] | |
| 180 | + | #[instrument(skip_all)] | |
| 181 | + | pub async fn sync_save_api_key( | |
| 182 | + | state: State<'_, Arc<AppState>>, | |
| 183 | + | api_key: String, | |
| 184 | + | ) -> Result<bool, ApiError> { | |
| 185 | + | state::save_api_key(&state.data_dir, &api_key); | |
| 186 | + | let server_url = std::env::var("BB_SYNC_SERVER_URL") | |
| 187 | + | .unwrap_or_else(|_| state::SYNC_SERVER_URL.to_string()); | |
| 188 | + | let client = synckit_client::SyncKitClient::new(synckit_client::SyncKitConfig { | |
| 189 | + | server_url, | |
| 190 | + | api_key, | |
| 191 | + | }); | |
| 192 | + | *state.sync_client.write() = Some(Arc::new(client)); | |
| 193 | + | Ok(true) | |
| 194 | + | } | |
| 178 | 195 | ||
| 179 | 196 | #[tauri::command] | |
| 180 | 197 | #[instrument(skip_all)] | |
| 181 | 198 | pub async fn sync_status( | |
| 182 | 199 | state: State<'_, Arc<AppState>>, | |
| 183 | 200 | ) -> Result<SyncStatusResponse, ApiError> { | |
| 184 | - | let (configured, encryption_ready, has_server_key) = match &state.sync_client { | |
| 201 | + | let (configured, encryption_ready, has_server_key) = match get_sync_client(&state) { | |
| 185 | 202 | Some(client) => { | |
| 186 | 203 | let enc_ready = client.has_master_key().unwrap_or(false); | |
| 187 | 204 | let authenticated = client.session_info().ok().flatten().is_some(); | |
| @@ -195,9 +212,7 @@ pub async fn sync_status( | |||
| 195 | 212 | None => (false, false, None), | |
| 196 | 213 | }; | |
| 197 | 214 | ||
| 198 | - | let authenticated = state | |
| 199 | - | .sync_client | |
| 200 | - | .as_ref() | |
| 215 | + | let authenticated = get_sync_client(&state) | |
| 201 | 216 | .is_some_and(|c| c.session_info().ok().flatten().is_some()); | |
| 202 | 217 | ||
| 203 | 218 | let pool = state.orchestrator.database().pool(); | |
| @@ -245,16 +260,13 @@ pub async fn sync_status( | |||
| 245 | 260 | pub async fn sync_start_auth( | |
| 246 | 261 | state: State<'_, Arc<AppState>>, | |
| 247 | 262 | ) -> Result<SyncAuthStartResponse, ApiError> { | |
| 248 | - | let client = state | |
| 249 | - | .sync_client | |
| 250 | - | .as_ref() | |
| 251 | - | .ok_or_else(|| ApiError::bad_request("Sync is not configured"))?; | |
| 263 | + | let client = require_sync_client(&state)?; | |
| 252 | 264 | ||
| 253 | 265 | let code_verifier = generate_code_verifier(); | |
| 254 | 266 | let code_challenge = generate_code_challenge(&code_verifier); | |
| 255 | 267 | let csrf_state = generate_state(); | |
| 256 | 268 | ||
| 257 | - | let (port, _rx) = start_callback_server()?; | |
| 269 | + | let port = start_callback_server()?; | |
| 258 | 270 | ||
| 259 | 271 | let auth_url = client.build_authorize_url(port, &csrf_state, &code_challenge); | |
| 260 | 272 | ||
| @@ -272,10 +284,7 @@ pub async fn sync_complete_auth( | |||
| 272 | 284 | state: State<'_, Arc<AppState>>, | |
| 273 | 285 | input: SyncAuthCompleteInput, | |
| 274 | 286 | ) -> Result<bool, ApiError> { | |
| 275 | - | let client = state | |
| 276 | - | .sync_client | |
| 277 | - | .as_ref() | |
| 278 | - | .ok_or_else(|| ApiError::bad_request("Sync is not configured"))?; | |
| 287 | + | let client = require_sync_client(&state)?; | |
| 279 | 288 | ||
| 280 | 289 | if input.state != input.expected_state { | |
| 281 | 290 | return Err(ApiError::bad_request("OAuth state mismatch")); | |
| @@ -302,7 +311,7 @@ pub async fn sync_disconnect( | |||
| 302 | 311 | state: State<'_, Arc<AppState>>, | |
| 303 | 312 | ) -> Result<bool, ApiError> { | |
| 304 | 313 | // Clear in-memory session and master key | |
| 305 | - | if let Some(client) = &state.sync_client { | |
| 314 | + | if let Some(client) = get_sync_client(&state) { | |
| 306 | 315 | let _ = client.clear_session(); | |
| 307 | 316 | } | |
| 308 | 317 | ||
| @@ -319,10 +328,7 @@ pub async fn sync_now( | |||
| 319 | 328 | state: State<'_, Arc<AppState>>, | |
| 320 | 329 | app: tauri::AppHandle, | |
| 321 | 330 | ) -> Result<sync_service::SyncResult, ApiError> { | |
| 322 | - | let client = state | |
| 323 | - | .sync_client | |
| 324 | - | .as_ref() | |
| 325 | - | .ok_or_else(|| ApiError::bad_request("Sync is not configured"))?; | |
| 331 | + | let client = require_sync_client(&state)?; | |
| 326 | 332 | ||
| 327 | 333 | if client.session_info().unwrap_or(None).is_none() { | |
| 328 | 334 | return Err(ApiError::bad_request("Not authenticated")); | |
| @@ -344,7 +350,7 @@ pub async fn sync_now( | |||
| 344 | 350 | .map_err(|e| ApiError::internal(format!("Failed to create initial snapshot: {}", e)))?; | |
| 345 | 351 | } | |
| 346 | 352 | ||
| 347 | - | let result = sync_service::perform_sync(pool, client).await?; | |
| 353 | + | let result = sync_service::perform_sync(pool, &client).await?; | |
| 348 | 354 | ||
| 349 | 355 | if result.pulled > 0 { | |
| 350 | 356 | let _ = app.emit("sync:changes-applied", ()); | |
| @@ -362,10 +368,7 @@ pub async fn sync_setup_encryption_new( | |||
| 362 | 368 | state: State<'_, Arc<AppState>>, | |
| 363 | 369 | password: String, | |
| 364 | 370 | ) -> Result<bool, ApiError> { | |
| 365 | - | let client = state | |
| 366 | - | .sync_client | |
| 367 | - | .as_ref() | |
| 368 | - | .ok_or_else(|| ApiError::bad_request("Sync is not configured"))?; | |
| 371 | + | let client = require_sync_client(&state)?; | |
| 369 | 372 | ||
| 370 | 373 | client | |
| 371 | 374 | .setup_encryption_new(&password) | |
| @@ -381,10 +384,7 @@ pub async fn sync_setup_encryption_existing( | |||
| 381 | 384 | state: State<'_, Arc<AppState>>, | |
| 382 | 385 | password: String, | |
| 383 | 386 | ) -> Result<bool, ApiError> { | |
| 384 | - | let client = state | |
| 385 | - | .sync_client | |
| 386 | - | .as_ref() | |
| 387 | - | .ok_or_else(|| ApiError::bad_request("Sync is not configured"))?; | |
| 387 | + | let client = require_sync_client(&state)?; | |
| 388 | 388 | ||
| 389 | 389 | client | |
| 390 | 390 | .setup_encryption_existing(&password) |
| @@ -87,6 +87,8 @@ pub fn build_app() -> tauri::Builder<tauri::Wry> { | |||
| 87 | 87 | commands::import_opml, | |
| 88 | 88 | commands::list_themes, | |
| 89 | 89 | commands::get_theme, | |
| 90 | + | commands::sync_test_api_key, | |
| 91 | + | commands::sync_save_api_key, | |
| 90 | 92 | commands::sync_status, | |
| 91 | 93 | commands::sync_start_auth, | |
| 92 | 94 | commands::sync_complete_auth, |
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | //! Application state wrapping the Orchestrator | |
| 2 | 2 | use bb_core::{Orchestrator, OrchestratorConfig}; | |
| 3 | 3 | use bb_db::parse_timestamp; | |
| 4 | + | use parking_lot::RwLock; | |
| 4 | 5 | use std::path::PathBuf; | |
| 5 | 6 | use std::sync::Arc; | |
| 6 | 7 | use synckit_client::{SyncKitClient, SyncKitConfig}; | |
| @@ -8,11 +9,16 @@ use tauri::{AppHandle, Emitter, Manager}; | |||
| 8 | 9 | use tokio::task::AbortHandle; | |
| 9 | 10 | use tracing::{debug, error, info, warn}; | |
| 10 | 11 | ||
| 12 | + | /// Default sync server URL. | |
| 13 | + | pub const SYNC_SERVER_URL: &str = "https://makenot.work"; | |
| 14 | + | ||
| 11 | 15 | /// Application state wrapping the Orchestrator | |
| 12 | 16 | pub struct AppState { | |
| 13 | 17 | pub orchestrator: Orchestrator, | |
| 14 | - | /// SyncKit client for cloud sync (None if not configured). | |
| 15 | - | pub sync_client: Option<SyncKitClient>, | |
| 18 | + | /// SyncKit client for cloud sync (None until configured via API key). | |
| 19 | + | pub sync_client: RwLock<Option<Arc<SyncKitClient>>>, | |
| 20 | + | /// App data directory for key persistence. | |
| 21 | + | pub data_dir: PathBuf, | |
| 16 | 22 | /// Handle to abort the background auto-fetch task on shutdown. | |
| 17 | 23 | auto_fetch_handle: parking_lot::Mutex<Option<AbortHandle>>, | |
| 18 | 24 | /// Handle to abort the background stale-item cleanup task on shutdown. | |
| @@ -87,35 +93,15 @@ impl AppState { | |||
| 87 | 93 | // Initialize plugins from DB | |
| 88 | 94 | init_plugins_from_db(&orchestrator).await; | |
| 89 | 95 | ||
| 90 | - | // Initialize SyncKit client from env vars (optional) | |
| 91 | - | let sync_client = match ( | |
| 92 | - | std::env::var("BB_SYNC_SERVER_URL"), | |
| 93 | - | std::env::var("BB_SYNC_API_KEY"), | |
| 94 | - | ) { | |
| 95 | - | (Ok(server_url), Ok(api_key)) => { | |
| 96 | - | info!(%server_url, "SyncKit client configured"); | |
| 97 | - | let client = SyncKitClient::new(SyncKitConfig { server_url, api_key }); | |
| 98 | - | ||
| 99 | - | // Try to restore session from keychain | |
| 100 | - | match client.try_load_key_from_keychain() { | |
| 101 | - | Ok(true) => info!("Sync encryption key loaded from keychain"), | |
| 102 | - | Ok(false) => debug!("No sync encryption key in keychain"), | |
| 103 | - | Err(e) => warn!(error = %e, "Failed to load sync encryption key"), | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | Some(client) | |
| 107 | - | } | |
| 108 | - | _ => { | |
| 109 | - | debug!("SyncKit not configured (BB_SYNC_SERVER_URL / BB_SYNC_API_KEY not set)"); | |
| 110 | - | None | |
| 111 | - | } | |
| 112 | - | }; | |
| 96 | + | // Initialize SyncKit client from saved key or env vars | |
| 97 | + | let sync_client = load_sync_client(&app_data_dir); | |
| 113 | 98 | ||
| 114 | 99 | info!("Application state initialized"); | |
| 115 | 100 | ||
| 116 | 101 | Ok(Self { | |
| 117 | 102 | orchestrator, | |
| 118 | - | sync_client, | |
| 103 | + | sync_client: RwLock::new(sync_client.map(Arc::new)), | |
| 104 | + | data_dir: app_data_dir, | |
| 119 | 105 | auto_fetch_handle: parking_lot::Mutex::new(None), | |
| 120 | 106 | cleanup_handle: parking_lot::Mutex::new(None), | |
| 121 | 107 | }) | |
| @@ -166,6 +152,39 @@ impl Drop for AppState { | |||
| 166 | 152 | } | |
| 167 | 153 | } | |
| 168 | 154 | ||
| 155 | + | /// Load API key from the saved file, falling back to env var. | |
| 156 | + | pub fn load_api_key(data_dir: &std::path::Path) -> Option<String> { | |
| 157 | + | let key_path = data_dir.join("sync_api_key"); | |
| 158 | + | if let Ok(key) = std::fs::read_to_string(&key_path) { | |
| 159 | + | let key = key.trim().to_string(); | |
| 160 | + | if !key.is_empty() { | |
| 161 | + | return Some(key); | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | std::env::var("BB_SYNC_API_KEY").ok() | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// Save API key to the data directory. | |
| 168 | + | pub fn save_api_key(data_dir: &std::path::Path, api_key: &str) { | |
| 169 | + | let key_path = data_dir.join("sync_api_key"); | |
| 170 | + | let _ = std::fs::write(&key_path, api_key); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | /// Create a SyncKitClient from the saved or env-var API key. | |
| 174 | + | fn load_sync_client(data_dir: &std::path::Path) -> Option<SyncKitClient> { | |
| 175 | + | let api_key = load_api_key(data_dir)?; | |
| 176 | + | let server_url = std::env::var("BB_SYNC_SERVER_URL") | |
| 177 | + | .unwrap_or_else(|_| SYNC_SERVER_URL.to_string()); | |
| 178 | + | info!(%server_url, "SyncKit client configured"); | |
| 179 | + | let client = SyncKitClient::new(SyncKitConfig { server_url, api_key }); | |
| 180 | + | match client.try_load_key_from_keychain() { | |
| 181 | + | Ok(true) => info!("Sync encryption key loaded from keychain"), | |
| 182 | + | Ok(false) => debug!("No sync encryption key in keychain"), | |
| 183 | + | Err(e) => warn!(error = %e, "Failed to load sync encryption key"), | |
| 184 | + | } | |
| 185 | + | Some(client) | |
| 186 | + | } | |
| 187 | + | ||
| 169 | 188 | /// Find the plugins directory, preferring dev-mode project root | |
| 170 | 189 | fn find_plugins_dir(app: &AppHandle) -> PathBuf { | |
| 171 | 190 | // In dev mode, use the project-root plugins/ directory |
| @@ -34,8 +34,7 @@ pub fn start_sync_scheduler(app: AppHandle) { | |||
| 34 | 34 | warn!(error = %e, "Sync changelog retention check failed"); | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | - | let sync_client = state.sync_client.as_ref(); | |
| 38 | - | let client = match sync_client { | |
| 37 | + | let client: Arc<synckit_client::SyncKitClient> = match state.sync_client.read().clone() { | |
| 39 | 38 | Some(c) => c, | |
| 40 | 39 | None => continue, | |
| 41 | 40 | }; | |
| @@ -107,7 +106,7 @@ pub fn start_sync_scheduler(app: AppHandle) { | |||
| 107 | 106 | } | |
| 108 | 107 | ||
| 109 | 108 | // Perform sync | |
| 110 | - | match sync_service::perform_sync(pool, client).await { | |
| 109 | + | match sync_service::perform_sync(pool, &client).await { | |
| 111 | 110 | Ok(result) => { | |
| 112 | 111 | consecutive_failures = 0; | |
| 113 | 112 | backoff_until = None; |