1use std::sync::{Arc, Mutex};
2use std::time::Duration;
3
4use anyhow::{Result, anyhow};
5use tiny_http::{Header, Method, Response, Server};
6
7use super::client::API_TOKEN;
8use crate::verbose;
9
10const PREFERRED_PORT: u16 = 19222;
11const TIMEOUT_SECS: u64 = 120;
12
13pub fn acquire_token() -> Result<String> {
16 let server = Server::http(format!("127.0.0.1:{PREFERRED_PORT}"))
17 .or_else(|_| Server::http("127.0.0.1:0"))
18 .map_err(|e| anyhow!("failed to start auth server: {e}"))?;
19
20 let port = server
21 .server_addr()
22 .to_ip()
23 .map(|a| a.port())
24 .unwrap_or(PREFERRED_PORT);
25 eprintln!("Opening http://localhost:{port}/ in Safari...");
26 eprintln!("Sign in with your Apple ID. The window will close automatically.");
27 verbose::eprintln(1, format!("[auth] listening on 127.0.0.1:{port}"));
28 open_browser(port);
29
30 let token: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
31 let deadline = std::time::Instant::now() + Duration::from_secs(TIMEOUT_SECS);
32
33 loop {
34 if std::time::Instant::now() >= deadline {
35 return Err(anyhow!("auth timed out after {TIMEOUT_SECS}s"));
36 }
37
38 match server.recv_timeout(Duration::from_millis(200))? {
39 None => continue,
40 Some(request) => {
41 let got_token = handle_request(request, port, &token)?;
42 if got_token {
43 return token
44 .lock()
45 .unwrap()
46 .clone()
47 .ok_or_else(|| anyhow!("token was set but is empty"));
48 }
49 }
50 }
51 }
52}
53
54fn handle_request(
55 mut request: tiny_http::Request,
56 port: u16,
57 token: &Arc<Mutex<Option<String>>>,
58) -> Result<bool> {
59 let raw_url = request.url().to_string();
60 let path = raw_url.split('?').next().unwrap_or("/").to_string();
61 if verbose::enabled(3) {
62 verbose::eprintln(
63 3,
64 format!(
65 "[auth] {} {}",
66 request.method().as_str(),
67 redact_auth_url(&raw_url)
68 ),
69 );
70 }
71
72 match (request.method(), path.as_str()) {
73 (Method::Get, "/") | (Method::Get, "/index.html") => {
74 if let Some(t) = extract_token_from_url(&raw_url) {
75 verbose::eprintln(2, "[auth] captured ckWebAuthToken from request URL");
76 *token.lock().unwrap() = Some(t);
77 let response = Response::from_data(success_html().as_bytes().to_vec())
78 .with_header(content_type("text/html; charset=utf-8"));
79 request.respond(response)?;
80 return Ok(true);
81 }
82
83 let html = auth_html(port);
84 let response = Response::from_data(html.into_bytes())
85 .with_header(content_type("text/html; charset=utf-8"));
86 request.respond(response)?;
87 }
88
89 (Method::Get, "/callback") => {
90 if let Some(t) = extract_token_from_url(&raw_url) {
91 verbose::eprintln(2, "[auth] captured ckWebAuthToken from callback URL");
92 *token.lock().unwrap() = Some(t);
93 let response = Response::from_data(success_html().as_bytes().to_vec())
94 .with_header(content_type("text/html; charset=utf-8"));
95 request.respond(response)?;
96 return Ok(true);
97 }
98
99 let response = Response::from_data(callback_html(port).into_bytes())
100 .with_header(content_type("text/html; charset=utf-8"));
101 request.respond(response)?;
102 }
103
104 (Method::Post, "/callback") => {
105 let mut body = String::new();
106 request.as_reader().read_to_string(&mut body).unwrap_or(0);
107 if verbose::enabled(3) {
108 verbose::eprintln(
109 3,
110 format!("[auth] callback body: {}", redact_auth_body(&body)),
111 );
112 }
113
114 let cors = Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap();
115 if let Some(t) = extract_token_from_json(&body) {
116 verbose::eprintln(2, "[auth] captured ckWebAuthToken from callback JSON");
117 *token.lock().unwrap() = Some(t);
118 let response = Response::from_data(b"{\"status\":\"ok\"}".to_vec())
119 .with_header(content_type("application/json"))
120 .with_header(cors);
121 request.respond(response)?;
122 return Ok(true);
123 } else {
124 let response = Response::from_data(b"{\"error\":\"Missing token\"}".to_vec())
125 .with_status_code(400)
126 .with_header(content_type("application/json"))
127 .with_header(cors);
128 request.respond(response)?;
129 }
130 }
131
132 (Method::Options, _) => {
133 let response = Response::empty(204)
134 .with_header(Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap())
135 .with_header(
136 Header::from_bytes("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
137 .unwrap(),
138 )
139 .with_header(
140 Header::from_bytes("Access-Control-Allow-Headers", "Content-Type").unwrap(),
141 );
142 request.respond(response)?;
143 }
144
145 (Method::Get, "/health") => {
146 let response = Response::from_data(b"{\"status\":\"ok\"}".to_vec())
147 .with_header(content_type("application/json"));
148 request.respond(response)?;
149 }
150
151 (Method::Get, "/favicon.ico") => {
152 request.respond(Response::empty(204))?;
153 }
154
155 _ => {
156 request.respond(Response::empty(404))?;
157 }
158 }
159
160 Ok(false)
161}
162
163fn content_type(value: &str) -> Header {
164 Header::from_bytes("Content-Type", value).unwrap()
165}
166
167fn open_browser(port: u16) {
168 let url = format!("http://localhost:{port}/");
169
170 let safari = std::process::Command::new("open")
171 .args(["-a", "Safari", &url])
172 .spawn();
173
174 if safari.is_err() {
175 let _ = std::process::Command::new("open").arg(url).spawn();
176 }
177}
178
179fn extract_token_from_json(body: &str) -> Option<String> {
180 let pos = body.find("\"token\"")?;
181 let after = body[pos + 7..].trim_start().strip_prefix(':')?.trim_start();
182 let after = after.strip_prefix('"')?;
183 let end = after.find('"')?;
184 let t = &after[..end];
185 if t.is_empty() {
186 None
187 } else {
188 Some(t.to_string())
189 }
190}
191
192fn extract_token_from_url(url: &str) -> Option<String> {
193 let query = url.split_once('?')?.1;
194 for pair in query.split('&') {
195 let (key, value) = pair.split_once('=')?;
196 if key == "ckWebAuthToken" {
197 let token = percent_decode(value);
198 if !token.is_empty() {
199 return Some(token);
200 }
201 }
202 }
203 None
204}
205
206fn percent_decode(input: &str) -> String {
207 let bytes = input.as_bytes();
208 let mut out = String::with_capacity(input.len());
209 let mut i = 0;
210
211 while i < bytes.len() {
212 match bytes[i] {
213 b'%' if i + 2 < bytes.len() => {
214 if let (Some(a), Some(b)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
215 out.push((a * 16 + b) as char);
216 i += 3;
217 continue;
218 }
219 out.push('%');
220 i += 1;
221 }
222 b'+' => {
223 out.push(' ');
224 i += 1;
225 }
226 b => {
227 out.push(b as char);
228 i += 1;
229 }
230 }
231 }
232
233 out
234}
235
236fn redact_auth_url(url: &str) -> String {
237 redact_query_value(url, "ckWebAuthToken=")
238}
239
240fn redact_auth_body(body: &str) -> String {
241 if let Some(token) = extract_token_from_json(body) {
242 body.replace(&token, &redact_secret(&token))
243 } else {
244 body.to_string()
245 }
246}
247
248fn redact_query_value(url: &str, key: &str) -> String {
249 let Some(start) = url.find(key) else {
250 return url.to_string();
251 };
252 let value_start = start + key.len();
253 let value_end = url[value_start..]
254 .find('&')
255 .map(|offset| value_start + offset)
256 .unwrap_or(url.len());
257 let value = &url[value_start..value_end];
258 let replacement = redact_secret(value);
259
260 let mut out = String::with_capacity(url.len());
261 out.push_str(&url[..value_start]);
262 out.push_str(&replacement);
263 out.push_str(&url[value_end..]);
264 out
265}
266
267fn redact_secret(value: &str) -> String {
268 if value.len() <= 8 {
269 "***".to_string()
270 } else {
271 format!("{}...{}", &value[..4], &value[value.len() - 4..])
272 }
273}
274
275fn hex_val(b: u8) -> Option<u8> {
276 match b {
277 b'0'..=b'9' => Some(b - b'0'),
278 b'a'..=b'f' => Some(10 + (b - b'a')),
279 b'A'..=b'F' => Some(10 + (b - b'A')),
280 _ => None,
281 }
282}
283
284fn success_html() -> &'static str {
285 r#"<!DOCTYPE html>
286<html lang="en">
287<head>
288 <meta charset="UTF-8">
289 <meta name="viewport" content="width=device-width, initial-scale=1.0">
290 <title>bear-cli — Authenticated</title>
291 <style>
292 body {
293 margin: 0;
294 min-height: 100vh;
295 display: grid;
296 place-items: center;
297 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
298 background: #f9fafb;
299 color: #111827;
300 }
301 .card {
302 width: min(420px, calc(100vw - 32px));
303 padding: 28px;
304 border: 1px solid #e5e7eb;
305 border-radius: 16px;
306 background: #fff;
307 text-align: center;
308 }
309 h1 { margin: 0 0 8px; font-size: 22px; }
310 p { margin: 0; color: #4b5563; line-height: 1.5; }
311 </style>
312</head>
313<body>
314 <div class="card">
315 <h1>Authenticated</h1>
316 <p>The CloudKit token was received. You can close this tab.</p>
317 </div>
318</body>
319</html>"#
320}
321
322fn callback_html(port: u16) -> String {
323 CALLBACK_HTML_TEMPLATE.replace("__PORT__", &port.to_string())
324}
325
326fn auth_html(port: u16) -> String {
327 AUTH_HTML_TEMPLATE
328 .replace("__PORT__", &port.to_string())
329 .replace("__API_TOKEN__", API_TOKEN)
330}
331
332const AUTH_HTML_TEMPLATE: &str = r##"<!DOCTYPE html>
333<html lang="en">
334<head>
335 <meta charset="UTF-8">
336 <meta name="viewport" content="width=device-width, initial-scale=1.0">
337 <title>bear-cli — Sign In</title>
338 <style>
339 :root {
340 --bg:#ffffff; --bg-card:#f9fafb; --text:#1a1a2e; --text-muted:#6b7280;
341 --accent:#dd4c4f; --accent-hover:#c43c3f; --border:#e5e7eb;
342 --input-bg:#ffffff; --input-border:#d1d5db; --code-bg:#e5e7eb;
343 }
344 @media (prefers-color-scheme: dark) {
345 :root {
346 --bg:#0f1117; --bg-card:#1a1b23; --text:#e2e8f0; --text-muted:#94a3b8;
347 --accent:#dd4c4f; --accent-hover:#e86568; --border:#2d2d3d;
348 --input-bg:#1e1f2a; --input-border:#3d3e4d; --code-bg:#2d2d3d;
349 }
350 }
351 * { margin:0; padding:0; box-sizing:border-box; }
352 body {
353 font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
354 background:var(--bg); min-height:100vh;
355 display:flex; align-items:center; justify-content:center;
356 color:var(--text); -webkit-font-smoothing:antialiased;
357 }
358 .container {
359 background:var(--bg-card); border:1px solid var(--border);
360 border-radius:16px; padding:36px; max-width:380px; width:90%; text-align:center;
361 }
362 h1 { font-size:22px; font-weight:700; margin-bottom:6px; letter-spacing:-0.3px; }
363 .subtitle { font-size:14px; color:var(--text-muted); margin-bottom:32px; line-height:1.5; }
364 #apple-sign-in-button { position:absolute; width:1px; height:1px; overflow:hidden; opacity:0; pointer-events:none; }
365 .custom-apple-btn {
366 display:none; align-items:center; justify-content:center; gap:8px;
367 padding:0 28px; height:46px; background:var(--accent); color:#fff;
368 border:none; border-radius:10px; font-size:15px; font-weight:600;
369 cursor:pointer; transition:background 0.2s;
370 }
371 .custom-apple-btn:hover { background:var(--accent-hover); }
372 #apple-sign-out-button { display:none; }
373 .status { margin-top:20px; font-size:13px; color:var(--text-muted); min-height:20px; }
374 .status.error { color:#ef4444; }
375 .status.success { color:#22c55e; font-weight:600; }
376 .manual {
377 display:none; margin-top:28px; padding:20px;
378 background:#f3f4f6; border:1px solid var(--border);
379 border-radius:10px; text-align:left; font-size:13px; line-height:1.7; color:var(--text-muted);
380 }
381 @media (prefers-color-scheme:dark) { .manual { background:#161722; } }
382 .manual strong { color:var(--text); }
383 .manual ol { margin:10px 0 12px 18px; }
384 .manual a { color:var(--accent); text-decoration:none; }
385 .manual code {
386 background:var(--code-bg); padding:2px 6px; border-radius:4px;
387 font-size:11px; color:var(--text);
388 font-family:'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
389 }
390 .manual input {
391 width:100%; font-size:12px; background:var(--input-bg); color:var(--text);
392 border:1px solid var(--input-border); border-radius:8px;
393 padding:10px 12px; margin-top:10px; outline:none; transition:border-color 0.2s;
394 font-family:'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
395 }
396 .manual input:focus { border-color:var(--accent); }
397 .manual button {
398 margin-top:10px; padding:9px 20px; background:var(--accent); color:#fff;
399 border:none; border-radius:8px; font-size:13px; font-weight:600;
400 cursor:pointer; transition:background 0.2s;
401 }
402 .manual button:hover { background:var(--accent-hover); }
403 </style>
404</head>
405<body>
406 <div class="container">
407 <h1>bear-cli</h1>
408 <p class="subtitle">Sign in with your Apple ID to connect to your Bear notes via iCloud.</p>
409 <button class="custom-apple-btn" id="custom-apple-btn"
410 onclick="document.querySelector('#apple-sign-in-button .apple-auth-button').click()">
411 <svg width="16" height="19" viewBox="0 0 16 19" fill="none">
412 <path d="M13.2 9.94c-.02-2.08 1.7-3.08 1.78-3.13-1-1.4-2.5-1.6-3.02-1.62-1.27-.13-2.52.76-3.17.76-.67 0-1.68-.74-2.77-.72A4.08 4.08 0 002.57 7.4c-1.5 2.58-.38 6.38 1.05 8.47.72 1.02 1.56 2.17 2.67 2.13 1.08-.04 1.49-.69 2.79-.69 1.29 0 1.66.69 2.78.66 1.16-.02 1.88-1.03 2.58-2.06.83-1.18 1.16-2.34 1.18-2.4-.03-.01-2.24-.85-2.26-3.4l-.14.83zM10.93 3.52A3.75 3.75 0 0011.8.5a3.86 3.86 0 00-2.5 1.3 3.6 3.6 0 00-.9 2.9 3.2 3.2 0 002.53-1.18z" fill="#fff"/>
413 </svg>
414 Sign in with Apple
415 </button>
416 <div id="apple-sign-in-button"></div>
417 <div id="apple-sign-out-button"></div>
418 <script>
419 (function() {
420 var btn = document.getElementById('apple-sign-in-button');
421 var custom = document.getElementById('custom-apple-btn');
422 var obs = new MutationObserver(function() {
423 if (btn.querySelector('.apple-auth-button')) {
424 obs.disconnect();
425 custom.style.display = 'inline-flex';
426 }
427 });
428 obs.observe(btn, {childList:true, subtree:true});
429 })();
430 </script>
431 <p class="status" id="status">Loading CloudKit JS...</p>
432 <div class="manual" id="manual">
433 <strong>Manual token entry</strong>
434 <ol>
435 <li>Open <a href="https://web.bear.app" target="_blank">web.bear.app</a> and sign in</li>
436 <li>Open DevTools (Cmd+Option+I) → Network tab</li>
437 <li>Look for requests to <code>apple-cloudkit.com</code></li>
438 <li>Copy the <code>ckWebAuthToken</code> value from any request URL</li>
439 </ol>
440 <input type="text" id="manual-token" placeholder="Paste ckWebAuthToken here">
441 <button onclick="submitManualToken()">Submit Token</button>
442 </div>
443 </div>
444 <script>
445 const PORT = __PORT__;
446
447 function setStatus(msg, cls) {
448 const el = document.getElementById('status');
449 el.textContent = msg;
450 el.className = 'status' + (cls ? ' ' + cls : '');
451 }
452
453 function showManual() {
454 document.getElementById('manual').style.display = 'block';
455 }
456
457 let capturedToken = null;
458
459 function checkURLForToken(url) {
460 if (typeof url === 'string' && url.includes('ckWebAuthToken=')) {
461 const m = url.match(/ckWebAuthToken=([^&]+)/);
462 if (m && m[1] !== 'null' && m[1] !== 'undefined') {
463 capturedToken = decodeURIComponent(m[1]);
464 return true;
465 }
466 }
467 return false;
468 }
469
470 if (checkURLForToken(window.location.href)) {
471 setStatus('Received token from redirect URL. Finalizing sign-in...');
472 if (window.history && window.history.replaceState) {
473 window.history.replaceState({}, document.title, '/');
474 }
475 setTimeout(function() {
476 sendToken(capturedToken).then(function(sent) {
477 if (!sent) {
478 setStatus('Could not reach CLI. Use the manual flow below.', 'error');
479 showManual();
480 document.getElementById('manual-token').value = capturedToken;
481 }
482 });
483 }, 0);
484 }
485
486 function looksLikeToken(value) {
487 return typeof value === 'string' && value.length > 20 && value !== 'null' && value !== 'undefined';
488 }
489
490 function scanValueForToken(value, seen) {
491 if (looksLikeToken(value)) {
492 return value;
493 }
494 if (!value || typeof value !== 'object') {
495 return null;
496 }
497 if (!seen) {
498 seen = new Set();
499 }
500 if (seen.has(value)) {
501 return null;
502 }
503 seen.add(value);
504
505 if (Array.isArray(value)) {
506 for (const item of value) {
507 const found = scanValueForToken(item, seen);
508 if (found) return found;
509 }
510 return null;
511 }
512
513 const keys = Object.keys(value);
514 const preferred = keys.filter(function(key) {
515 const lower = key.toLowerCase();
516 return lower.includes('token') || lower.includes('auth') || lower.includes('session');
517 });
518 const ordered = preferred.concat(keys.filter(function(key) { return !preferred.includes(key); }));
519
520 for (const key of ordered) {
521 const found = scanValueForToken(value[key], seen);
522 if (found) return found;
523 }
524 return null;
525 }
526
527 function captureToken(candidate) {
528 if (looksLikeToken(candidate)) {
529 capturedToken = candidate;
530 return true;
531 }
532 const found = scanValueForToken(candidate);
533 if (found) {
534 capturedToken = found;
535 return true;
536 }
537 return false;
538 }
539
540 function inspectStorage() {
541 const stores = [window.localStorage, window.sessionStorage];
542 for (const store of stores) {
543 if (!store) continue;
544 for (let i = 0; i < store.length; i++) {
545 const key = store.key(i);
546 if (!key) continue;
547 let raw = null;
548 try { raw = store.getItem(key); } catch (e) {}
549 if (!raw) continue;
550 if (captureToken(raw)) return true;
551 try {
552 if (captureToken(JSON.parse(raw))) return true;
553 } catch (e) {}
554 }
555 }
556 return false;
557 }
558
559 function inspectContainerSession(container) {
560 try {
561 const sessions = container && container._sessions;
562 if (!sessions) return false;
563 if (captureToken(sessions.production)) return true;
564 return captureToken(sessions);
565 } catch (e) {
566 return false;
567 }
568 }
569
570 const origOpen = XMLHttpRequest.prototype.open;
571 XMLHttpRequest.prototype.open = function(method, url) {
572 checkURLForToken(url);
573 return origOpen.apply(this, arguments);
574 };
575
576 const origFetch = window.fetch;
577 window.fetch = function(input, init) {
578 checkURLForToken(typeof input === 'string' ? input : (input && input.url) || '');
579 return origFetch.apply(this, arguments);
580 };
581
582 window.addEventListener('message', function(event) {
583 try {
584 const d = (typeof event.data === 'string') ? JSON.parse(event.data) : event.data;
585 const t = d && (d.ckWebAuthToken || d.webAuthToken || d.authToken || d.ckSession);
586 if (!captureToken(t)) {
587 captureToken(d);
588 const callbackUrl = d && (d.callbackUrl || d.url);
589 if (callbackUrl) checkURLForToken(callbackUrl);
590 }
591 } catch(e) {}
592 });
593
594 async function sendToken(token) {
595 try {
596 const r = await fetch('http://localhost:' + PORT + '/callback', {
597 method: 'POST',
598 headers: {'Content-Type': 'application/json'},
599 body: JSON.stringify({token: token})
600 });
601 if (r.ok) {
602 document.getElementById('custom-apple-btn').style.display = 'none';
603 document.getElementById('apple-sign-in-button').style.display = 'none';
604 document.getElementById('manual').style.display = 'none';
605 var n = 5;
606 setStatus('Authenticated! Closing in ' + n + 's...', 'success');
607 var t = setInterval(function() {
608 n--;
609 if (n <= 0) {
610 clearInterval(t);
611 window.close();
612 setTimeout(function() {
613 setStatus('Authenticated! You can close this tab.', 'success');
614 }, 500);
615 } else {
616 setStatus('Authenticated! Closing in ' + n + 's...', 'success');
617 }
618 }, 1000);
619 return true;
620 }
621 } catch(e) {}
622 return false;
623 }
624
625 function submitManualToken() {
626 const t = document.getElementById('manual-token').value.trim();
627 if (t) sendToken(t);
628 else setStatus('Paste a token first', 'error');
629 }
630
631 async function onSignedIn(container) {
632 setStatus('Signed in. Retrieving token...');
633 if (inspectContainerSession(container) || inspectStorage()) {
634 const sent = await sendToken(capturedToken);
635 if (sent) return;
636 }
637 if (!capturedToken) {
638 try {
639 const db = container.getDatabaseWithDatabaseScope(CloudKit.DatabaseScope.PRIVATE);
640 await db.performQuery({recordType:'SFNoteTag'}, {zoneName:'Notes'}, {resultsLimit:1}).catch(function() {});
641 } catch(e) {}
642 }
643 if (!capturedToken) {
644 inspectContainerSession(container);
645 inspectStorage();
646 }
647 if (capturedToken) {
648 const sent = await sendToken(capturedToken);
649 if (!sent) {
650 setStatus('Could not reach CLI. Use the manual flow below.', 'error');
651 showManual();
652 document.getElementById('manual-token').value = capturedToken;
653 }
654 } else {
655 setStatus('Could not extract token automatically.', 'error');
656 showManual();
657 }
658 }
659 </script>
660 <script src="https://cdn.apple-cloudkit.com/ck/2/cloudkit.js"
661 onerror="setStatus('CloudKit JS failed to load.', 'error'); showManual();"></script>
662 <script>
663 (function() {
664 if (typeof CloudKit === 'undefined') {
665 setStatus('CloudKit JS not available.', 'error');
666 showManual();
667 return;
668 }
669 CloudKit.configure({
670 containers: [{
671 containerIdentifier: 'iCloud.net.shinyfrog.bear',
672 apiTokenAuth: {
673 apiToken: '__API_TOKEN__',
674 persist: true,
675 signInButton: { id: 'apple-sign-in-button', theme: 'white' },
676 signOutButton: { id: 'apple-sign-out-button' }
677 },
678 environment: 'production'
679 }]
680 });
681 var container = CloudKit.getDefaultContainer();
682 container.setUpAuth().then(function(uid) {
683 setStatus('');
684 if (uid) onSignedIn(container);
685 }).catch(function() {
686 setStatus('CloudKit auth setup failed. Use manual flow.', 'error');
687 showManual();
688 });
689 container.whenUserSignsIn().then(function() {
690 onSignedIn(container);
691 }).catch(function() {});
692 })();
693 </script>
694</body>
695</html>"##;
696
697const CALLBACK_HTML_TEMPLATE: &str = r##"<!DOCTYPE html>
698<html lang="en">
699<head>
700 <meta charset="UTF-8">
701 <meta name="viewport" content="width=device-width, initial-scale=1.0">
702 <title>bear-cli — Completing Sign-In</title>
703 <style>
704 body {
705 margin: 0;
706 min-height: 100vh;
707 display: grid;
708 place-items: center;
709 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
710 background: #f9fafb;
711 color: #111827;
712 }
713 .card {
714 width: min(420px, calc(100vw - 32px));
715 padding: 28px;
716 border: 1px solid #e5e7eb;
717 border-radius: 16px;
718 background: #fff;
719 text-align: center;
720 }
721 h1 { margin: 0 0 8px; font-size: 22px; }
722 p { margin: 0; color: #4b5563; line-height: 1.5; }
723 </style>
724</head>
725<body>
726 <div class="card">
727 <h1>Completing Sign-In</h1>
728 <p id="status">Passing the CloudKit session back to bear-cli...</p>
729 </div>
730 <script>
731 const PORT = __PORT__;
732
733 function extractToken(url) {
734 const match = String(url || '').match(/[?&]ckWebAuthToken=([^&]+)/);
735 return match ? decodeURIComponent(match[1]) : null;
736 }
737
738 async function finish() {
739 const href = window.location.href;
740 const token = extractToken(href);
741
742 if (window.opener && !window.opener.closed) {
743 try {
744 window.opener.postMessage({ callbackUrl: href }, '*');
745 } catch (e) {}
746 }
747
748 if (token) {
749 try {
750 const r = await fetch('http://localhost:' + PORT + '/callback', {
751 method: 'POST',
752 headers: { 'Content-Type': 'application/json' },
753 body: JSON.stringify({ token: token })
754 });
755 if (r.ok) {
756 document.getElementById('status').textContent = 'Authenticated. You can close this tab.';
757 window.close();
758 return;
759 }
760 } catch (e) {}
761 }
762
763 document.getElementById('status').textContent = 'Waiting for the main window to finish sign-in...';
764 setTimeout(function() { window.close(); }, 1200);
765 }
766
767 finish();
768 </script>
769</body>
770</html>"##;