Skip to main content

bear_cli/cloudkit/
auth_server.rs

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
13/// Opens a browser-based Apple sign-in page and waits for the `ckWebAuthToken`
14/// to be POSTed back, then returns it.
15pub 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) &rarr; 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>"##;