Skip to main content

max / balanced_breakfast

sync: auto-poll OAuth callback + native keyring backends Replace the manual "Check Status" button in the SyncKit OAuth flow with automatic polling against a /result endpoint on the local callback server. The server now serves three things: - The OAuth redirect (?code, ?state, ?error), storing the parsed result - /result JSON polling endpoint (status: pending|success|error) - Continues serving /result for 30s post-callback to absorb late polls Frontend setInterval(1s) polls /result, completes auth automatically, and falls back to manual code entry if polling never resolves. Also enables platform-native keyring backends (apple-native, linux-native, windows-native) so OAuth refresh tokens land in the OS keychain rather than the file-based fallback. CSP allows http://127.0.0.1 in connect-src for the polling fetch. Cargo.toml bumped to 0.3.1 to match tauri.conf.json. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-13 03:35 UTC
Commit: f1d84b8a3d0b7a66e3872309cea8d2f6ec2f3ed9
Parent: 06f90de
6 files changed, +220 insertions, -47 deletions
M Cargo.lock +32 -4
@@ -192,7 +192,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
192 192
193 193 [[package]]
194 194 name = "balanced-breakfast-desktop"
195 - version = "0.3.0"
195 + version = "0.3.1"
196 196 dependencies = [
197 197 "base64 0.22.1",
198 198 "bb-core",
@@ -2340,7 +2340,12 @@ version = "3.6.3"
2340 2340 source = "registry+https://github.com/rust-lang/crates.io-index"
2341 2341 checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
2342 2342 dependencies = [
2343 + "byteorder",
2344 + "linux-keyutils",
2343 2345 "log",
2346 + "security-framework 2.11.1",
2347 + "security-framework 3.6.0",
2348 + "windows-sys 0.60.2",
2344 2349 "zeroize",
2345 2350 ]
2346 2351
@@ -2446,6 +2451,16 @@ dependencies = [
2446 2451 ]
2447 2452
2448 2453 [[package]]
2454 + name = "linux-keyutils"
2455 + version = "0.2.5"
2456 + source = "registry+https://github.com/rust-lang/crates.io-index"
2457 + checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
2458 + dependencies = [
2459 + "bitflags 2.10.0",
2460 + "libc",
2461 + ]
2462 +
2463 + [[package]]
2449 2464 name = "linux-raw-sys"
2450 2465 version = "0.11.0"
2451 2466 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2662,7 +2677,7 @@ dependencies = [
2662 2677 "openssl-probe",
2663 2678 "openssl-sys",
2664 2679 "schannel",
2665 - "security-framework",
2680 + "security-framework 3.6.0",
2666 2681 "security-framework-sys",
2667 2682 "tempfile",
2668 2683 ]
@@ -4100,7 +4115,7 @@ dependencies = [
4100 4115 "openssl-probe",
4101 4116 "rustls-pki-types",
4102 4117 "schannel",
4103 - "security-framework",
4118 + "security-framework 3.6.0",
4104 4119 ]
4105 4120
4106 4121 [[package]]
@@ -4127,7 +4142,7 @@ dependencies = [
4127 4142 "rustls-native-certs",
4128 4143 "rustls-platform-verifier-android",
4129 4144 "rustls-webpki",
4130 - "security-framework",
4145 + "security-framework 3.6.0",
4131 4146 "security-framework-sys",
4132 4147 "webpki-root-certs",
4133 4148 "windows-sys 0.61.2",
@@ -4254,6 +4269,19 @@ dependencies = [
4254 4269
4255 4270 [[package]]
4256 4271 name = "security-framework"
4272 + version = "2.11.1"
4273 + source = "registry+https://github.com/rust-lang/crates.io-index"
4274 + checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
4275 + dependencies = [
4276 + "bitflags 2.10.0",
4277 + "core-foundation 0.9.4",
4278 + "core-foundation-sys",
4279 + "libc",
4280 + "security-framework-sys",
4281 + ]
4282 +
4283 + [[package]]
4284 + name = "security-framework"
4257 4285 version = "3.6.0"
4258 4286 source = "registry+https://github.com/rust-lang/crates.io-index"
4259 4287 checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38"
M Cargo.toml +1 -1
@@ -36,7 +36,7 @@ base64 = "0.22"
36 36 rand = "0.8"
37 37
38 38 # Keychain access
39 - keyring = "3"
39 + keyring = { version = "3", features = ["apple-native", "linux-native", "windows-native"] }
40 40
41 41 # Concurrency
42 42 parking_lot = "0.12"
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "balanced-breakfast-desktop"
3 - version = "0.3.0"
3 + version = "0.3.1"
4 4 edition = "2024"
5 5
6 6 [[bin]]
@@ -95,7 +95,7 @@
95 95 }
96 96
97 97 /**
98 - * Show the OAuth code entry / check-status UI after browser auth.
98 + * Show the authenticating UI and poll the callback server for the result.
99 99 * @param {string} expectedState - PKCE state parameter.
100 100 * @param {string} codeVerifier - PKCE code verifier.
101 101 * @param {number} port - Local callback server port.
@@ -106,26 +106,15 @@
106 106
107 107 const div = document.createElement('div');
108 108 div.innerHTML =
109 - '<p>After signing in, you should have been redirected. ' +
110 - 'If the redirect completed automatically, click "Check Status" below.</p>';
109 + '<p>Waiting for authentication in your browser...</p>';
111 110
112 - const checkBtn = document.createElement('button');
113 - checkBtn.className = 'btn btn-primary sync-check-btn';
114 - checkBtn.textContent = 'Check Status';
115 - checkBtn.onclick = async () => {
116 - try {
117 - const status = await BB.api.sync.status();
118 - if (status.authenticated) {
119 - BB.ui.showToast('Connected!', 'success');
120 - renderState(body, status);
121 - } else {
122 - BB.ui.showToast('Not yet authenticated. Please try again.', 'error');
123 - }
124 - } catch (err) {
125 - BB.ui.showToast('Error: ' + (err.message || err), 'error');
126 - }
127 - };
128 - div.appendChild(checkBtn);
111 + const spinner = document.createElement('div');
112 + spinner.className = 'sync-auth-spinner';
113 + spinner.style.textAlign = 'center';
114 + spinner.style.padding = '1rem 0';
115 + spinner.style.color = 'var(--text-secondary)';
116 + spinner.textContent = 'Polling for callback...';
117 + div.appendChild(spinner);
129 118
130 119 // Manual code entry as fallback
131 120 const details = document.createElement('details');
@@ -164,6 +153,48 @@
164 153 details.appendChild(form);
165 154 div.appendChild(details);
166 155 body.appendChild(div);
156 +
157 + // Auto-poll the callback server for the OAuth result
158 + pollCallbackResult(port, expectedState, codeVerifier);
159 + }
160 +
161 + /**
162 + * Poll the local callback server's /result endpoint until auth completes.
163 + * @param {number} port - Callback server port.
164 + * @param {string} expectedState - PKCE state parameter.
165 + * @param {string} codeVerifier - PKCE code verifier.
166 + */
167 + function pollCallbackResult(port, expectedState, codeVerifier) {
168 + let attempts = 0;
169 + const maxAttempts = 300; // 5 minutes at 1s intervals
170 +
171 + const pollInterval = setInterval(async () => {
172 + attempts++;
173 + if (attempts > maxAttempts) {
174 + clearInterval(pollInterval);
175 + BB.ui.showToast('Authentication timed out', 'error');
176 + return;
177 + }
178 +
179 + try {
180 + const resp = await fetch(`http://127.0.0.1:${port}/result`);
181 + if (resp.status === 200) {
182 + const data = await resp.json();
183 +
184 + if (data.status === 'pending') return;
185 +
186 + clearInterval(pollInterval);
187 +
188 + if (data.status === 'success' && data.code) {
189 + await completeAuth(data.code, data.state, expectedState, codeVerifier, port);
190 + } else if (data.status === 'error') {
191 + BB.ui.showToast('Auth error: ' + (data.error || 'Unknown error'), 'error');
192 + }
193 + }
194 + } catch (_) {
195 + // Server not ready yet, keep polling
196 + }
197 + }, 1000);
167 198 }
168 199
169 200 /**
@@ -100,9 +100,18 @@ fn generate_state() -> String {
100 100 /// Shared flag to signal previous callback servers to stop.
101 101 static CALLBACK_CANCEL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
102 102
103 + /// Stored callback state for the /result polling endpoint.
104 + #[derive(Clone)]
105 + enum StoredCallback {
106 + Pending,
107 + Success { code: String, state: String },
108 + Error { error: String },
109 + }
110 +
103 111 /// Start a minimal HTTP server on a random port that waits for the OAuth redirect.
104 - /// Returns the port. The server accepts one connection, parses the query string,
105 - /// responds with a success/error page, then shuts down.
112 + /// Returns the port. The server handles:
113 + /// - The browser redirect with `?code=...&state=...` (stores result, returns HTML)
114 + /// - `/result` polling endpoint (returns JSON: pending, success, or error)
106 115 /// Any previously running callback server is cancelled via the shared generation counter.
107 116 fn start_callback_server() -> Result<u16, ApiError> {
108 117 let listener = std::net::TcpListener::bind("127.0.0.1:0")
@@ -120,11 +129,13 @@ fn start_callback_server() -> Result<u16, ApiError> {
120 129
121 130 std::thread::spawn(move || {
122 131 use std::io::{Read, Write};
132 + use std::sync::{Arc, Mutex};
123 133
124 - let timeout = std::time::Instant::now() + std::time::Duration::from_secs(300);
134 + let stored = Arc::new(Mutex::new(StoredCallback::Pending));
135 + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);
136 + let mut callback_received = false;
125 137
126 - while std::time::Instant::now() < timeout {
127 - // Check if a newer callback server has been started
138 + while std::time::Instant::now() < deadline {
128 139 if CALLBACK_CANCEL.load(std::sync::atomic::Ordering::Relaxed) != generation {
129 140 break;
130 141 }
@@ -134,38 +145,141 @@ fn start_callback_server() -> Result<u16, ApiError> {
134 145 let n = stream.read(&mut buf).unwrap_or(0);
135 146 let request = String::from_utf8_lossy(&buf[..n]);
136 147
137 - // Parse GET /callback?code=xxx&state=xxx
138 - let has_code = request
148 + let path = request
139 149 .lines()
140 150 .next()
141 151 .and_then(|line| line.split_whitespace().nth(1))
142 - .and_then(|path| path.split('?').nth(1))
143 - .map(|query| query.split('&').any(|pair| pair.starts_with("code=")))
144 - .unwrap_or(false);
152 + .unwrap_or("/");
153 +
154 + let path_only = path.split('?').next().unwrap_or(path);
155 +
156 + // Handle /result polling endpoint
157 + if path_only == "/result" {
158 + let json = match &*stored.lock().unwrap() {
159 + StoredCallback::Pending => r#"{"status":"pending"}"#.to_string(),
160 + StoredCallback::Success { code, state } => {
161 + format!(
162 + r#"{{"status":"success","code":"{}","state":"{}"}}"#,
163 + code.replace('"', "\\\""),
164 + state.replace('"', "\\\"")
165 + )
166 + }
167 + StoredCallback::Error { error } => {
168 + format!(
169 + r#"{{"status":"error","error":"{}"}}"#,
170 + error.replace('"', "\\\"")
171 + )
172 + }
173 + };
174 + let response = format!(
175 + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}",
176 + json.len(), json
177 + );
178 + let _ = stream.write_all(response.as_bytes());
179 + let _ = stream.flush();
180 + continue;
181 + }
182 +
183 + // Parse query parameters for the OAuth callback
184 + let query = path.split('?').nth(1).unwrap_or("");
185 + let mut code = None;
186 + let mut cb_state = None;
187 + let mut error = None;
188 +
189 + for param in query.split('&') {
190 + if let Some((key, value)) = param.split_once('=') {
191 + match key {
192 + "code" => code = Some(value.to_string()),
193 + "state" => cb_state = Some(value.to_string()),
194 + "error" => error = Some(value.to_string()),
195 + _ => {}
196 + }
197 + }
198 + }
145 199
146 - if has_code {
200 + if let Some(err) = error {
201 + *stored.lock().unwrap() = StoredCallback::Error { error: err };
202 + let body = "<html><body><h1>Authentication failed</h1><p>You can close this tab.</p></body></html>";
203 + let response = format!(
204 + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}",
205 + body.len(), body
206 + );
207 + let _ = stream.write_all(response.as_bytes());
208 + let _ = stream.flush();
209 + callback_received = true;
210 + } else if let (Some(code), Some(state)) = (code, cb_state) {
211 + *stored.lock().unwrap() = StoredCallback::Success {
212 + code: code.clone(),
213 + state: state.clone(),
214 + };
147 215 let body = "<html><body><h1>Authenticated</h1><p>You can close this tab and return to Balanced Breakfast.</p></body></html>";
148 216 let response = format!(
149 - "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
217 + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}",
150 218 body.len(), body
151 219 );
152 220 let _ = stream.write_all(response.as_bytes());
153 221 let _ = stream.flush();
154 - break;
222 + callback_received = true;
155 223 }
156 -
157 - let body = "Waiting for authentication...";
158 - let response = format!(
159 - "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
160 - body.len(), body
161 - );
162 - let _ = stream.write_all(response.as_bytes());
163 224 }
164 225 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
165 226 std::thread::sleep(std::time::Duration::from_millis(100));
166 227 }
167 228 Err(_) => break,
168 229 }
230 +
231 + // After callback, keep serving /result for 30s then exit
232 + if callback_received {
233 + let poll_deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
234 + while std::time::Instant::now() < poll_deadline {
235 + if CALLBACK_CANCEL.load(std::sync::atomic::Ordering::Relaxed) != generation {
236 + break;
237 + }
238 + match listener.accept() {
239 + Ok((mut stream, _)) => {
240 + let mut buf = [0u8; 4096];
241 + let n = stream.read(&mut buf).unwrap_or(0);
242 + let request = String::from_utf8_lossy(&buf[..n]);
243 +
244 + let path = request
245 + .lines()
246 + .next()
247 + .and_then(|line| line.split_whitespace().nth(1))
248 + .unwrap_or("/");
249 +
250 + if path.starts_with("/result") {
251 + let json = match &*stored.lock().unwrap() {
252 + StoredCallback::Pending => r#"{"status":"pending"}"#.to_string(),
253 + StoredCallback::Success { code, state } => {
254 + format!(
255 + r#"{{"status":"success","code":"{}","state":"{}"}}"#,
256 + code.replace('"', "\\\""),
257 + state.replace('"', "\\\"")
258 + )
259 + }
260 + StoredCallback::Error { error } => {
261 + format!(
262 + r#"{{"status":"error","error":"{}"}}"#,
263 + error.replace('"', "\\\"")
264 + )
265 + }
266 + };
267 + let response = format!(
268 + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}",
269 + json.len(), json
270 + );
271 + let _ = stream.write_all(response.as_bytes());
272 + let _ = stream.flush();
273 + }
274 + }
275 + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
276 + std::thread::sleep(std::time::Duration::from_millis(100));
277 + }
278 + Err(_) => break,
279 + }
280 + }
281 + break;
282 + }
169 283 }
170 284 });
171 285
@@ -21,7 +21,7 @@
21 21 }
22 22 ],
23 23 "security": {
24 - "csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src ipc: http://ipc.localhost"
24 + "csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src ipc: http://ipc.localhost http://127.0.0.1"
25 25 }
26 26 },
27 27 "bundle": {