Skip to main content

max / balanced_breakfast

TagTree + DocEngine integration, API key setup UI, ownership optimizations - ammonia replaced with docengine::sanitize_html - Tag validation via tagtree::validate_with (depth 3, length 80) - Sync settings: API key test/save UI replaces OAuth-only connect - sync_client changed to RwLock<Option<Arc<SyncKitClient>>> - feed_item_to_summary takes ownership (moves instead of clones) - Orchestrator RwLock held shorter across async boundary - crypto key functions narrowed to private visibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-22 05:30 UTC
Commit: 311818fe624001e1b946045ee1623c846475a9bb
Parent: bb246f2
15 files changed, +320 insertions, -148 deletions
M Cargo.lock +56 -2
@@ -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"
M Cargo.toml +2
@@ -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;