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