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
12pub 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) → 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>"##;