Skill Tracer
Skills › gstack › browse › cookie-picker-ui.ts

cookie-picker-ui.ts

717 lines · browse/src/cookie-picker-ui.ts at 3f79d81

1/**
2 * Cookie picker UI — self-contained HTML page
3 *
4 * Dark theme, two-panel layout, vanilla HTML/CSS/JS.
5 * Left: source browser domains with search + import buttons.
6 * Right: imported domains with trash buttons.
7 * No cookie values exposed anywhere.
8 */
9
10export function getCookiePickerHTML(serverPort: number, options: {
11 pickerInstance?: string;
12 browser?: string;
13 profile?: string;
14 targetOrigin?: string;
15 verifyAuth?: boolean;
16 clearStorage?: boolean;
17 verificationAvailable?: boolean;
18 storageResetAvailable?: boolean;
19} = {}): string {
20 const baseUrl = `http://127.0.0.1:${serverPort}`;
21 let targetOrigin: string | undefined;
22 try {
23 const target = new URL(options.targetOrigin ?? '');
24 if (['http:', 'https:'].includes(target.protocol)) targetOrigin = target.origin;
25 } catch {}
26 const config = JSON.stringify({
27 pickerInstance: options.pickerInstance,
28 browser: options.browser,
29 profile: options.profile,
30 targetOrigin,
31 verifyAuth: options.verifyAuth === true,
32 clearStorage: options.clearStorage === true,
33 verificationAvailable: options.verificationAvailable === true,
34 storageResetAvailable: options.storageResetAvailable !== false,
35 }).replace(/[<>&\u2028\u2029]/g, character => '\\u' + character.charCodeAt(0).toString(16).padStart(4, '0'));
37 return `<!DOCTYPE html>
38<html lang="en">
39<head>
40<meta charset="utf-8">
41<meta name="viewport" content="width=device-width, initial-scale=1">
42<title>Cookie Import — gstack browse</title>
43<style>
44 * { margin: 0; padding: 0; box-sizing: border-box; }
45 body {
46 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
47 background: #0a0a0a;
48 color: #e0e0e0;
49 height: 100vh;
50 overflow: hidden;
51 display: flex;
52 flex-direction: column;
53 }
55 /* ─── Header ──────────────────────────── */
56 .header {
57 display: flex;
58 align-items: center;
59 justify-content: space-between;
60 padding: 16px 24px;
61 border-bottom: 1px solid #222;
62 background: #0f0f0f;
63 }
64 .header h1 {
65 font-size: 16px;
66 font-weight: 600;
67 color: #fff;
68 }
69 .header .port {
70 font-size: 12px;
71 color: #666;
72 font-family: 'SF Mono', 'Fira Code', monospace;
73 }
75 .subtitle {
76 padding: 10px 24px 12px;
77 font-size: 13px;
78 color: #999;
79 line-height: 1.5;
80 border-bottom: 1px solid #222;
81 background: #0f0f0f;
82 }
84 /* ─── Layout ──────────────────────────── */
85 .container {
86 display: flex;
87 flex: 1;
88 min-height: 0;
89 }
90 .panel {
91 flex: 1;
92 display: flex;
93 flex-direction: column;
94 overflow: hidden;
95 }
96 .panel-left {
97 border-right: 1px solid #222;
98 }
99 .panel-header {
100 padding: 16px 20px 12px;
101 font-size: 11px;
102 font-weight: 600;
103 text-transform: uppercase;
104 letter-spacing: 0.5px;
105 color: #888;
106 }
108 /* ─── Browser Pills ───────────────────── */
109 .browser-pills {
110 display: flex;
111 gap: 8px;
112 padding: 0 20px 12px;
113 flex-wrap: wrap;
114 }
115 .pill {
116 padding: 6px 14px;
117 border-radius: 20px;
118 border: 1px solid #333;
119 background: #1a1a1a;
120 color: #aaa;
121 font-size: 13px;
122 cursor: pointer;
123 transition: all 0.15s;
124 display: flex;
125 align-items: center;
126 gap: 6px;
127 }
128 .pill:hover { border-color: #555; color: #ddd; }
129 .pill.active {
130 border-color: #4ade80;
131 background: #0a2a14;
132 color: #4ade80;
133 }
134 .pill .dot {
135 width: 6px; height: 6px;
136 border-radius: 50%;
137 background: #4ade80;
138 }
140 /* ─── Profile Pills ─────────────────── */
141 .profile-pills {
142 display: flex;
143 gap: 6px;
144 padding: 0 20px 12px;
145 flex-wrap: wrap;
146 }
147 .profile-pill {
148 padding: 4px 10px;
149 border-radius: 14px;
150 border: 1px solid #2a2a2a;
151 background: #141414;
152 color: #888;
153 font-size: 12px;
154 cursor: pointer;
155 transition: all 0.15s;
156 }
157 .profile-pill:hover { border-color: #444; color: #bbb; }
158 .profile-pill.active {
159 border-color: #60a5fa;
160 background: #0a1a2a;
161 color: #60a5fa;
162 }
164 /* ─── Search ──────────────────────────── */
165 .search-wrap {
166 padding: 0 20px 12px;
167 }
168 .search-input {
169 width: 100%;
170 padding: 8px 12px;
171 border-radius: 8px;
172 border: 1px solid #333;
173 background: #141414;
174 color: #e0e0e0;
175 font-size: 13px;
176 outline: none;
177 transition: border-color 0.15s;
178 }
179 .search-input::placeholder { color: #555; }
180 .search-input:focus { border-color: #555; }
182 /* ─── Domain List ─────────────────────── */
183 .domain-list {
184 flex: 1;
185 overflow-y: auto;
186 padding: 0 12px;
187 }
188 .domain-list::-webkit-scrollbar { width: 6px; }
189 .domain-list::-webkit-scrollbar-track { background: transparent; }
190 .domain-list::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
192 .domain-row {
193 display: flex;
194 align-items: center;
195 padding: 8px 10px;
196 border-radius: 6px;
197 transition: background 0.1s;
198 gap: 8px;
199 }
200 .domain-row:hover { background: #1a1a1a; }
201 .domain-name {
202 flex: 1;
203 font-family: 'SF Mono', 'Fira Code', monospace;
204 font-size: 13px;
205 color: #ccc;
206 overflow: hidden;
207 text-overflow: ellipsis;
208 white-space: nowrap;
209 }
210 .domain-count {
211 font-size: 12px;
212 color: #666;
213 font-family: 'SF Mono', 'Fira Code', monospace;
214 min-width: 28px;
215 text-align: right;
216 }
217 .btn-add, .btn-trash {
218 width: 28px; height: 28px;
219 border-radius: 6px;
220 border: 1px solid #333;
221 background: #1a1a1a;
222 color: #888;
223 font-size: 16px;
224 cursor: pointer;
225 display: flex;
226 align-items: center;
227 justify-content: center;
228 transition: all 0.15s;
229 flex-shrink: 0;
230 }
231 .btn-add:hover { border-color: #4ade80; color: #4ade80; background: #0a2a14; }
232 .btn-trash:hover { border-color: #f87171; color: #f87171; background: #2a0a0a; }
233 .btn-add:disabled, .btn-trash:disabled {
234 opacity: 0.3;
235 cursor: not-allowed;
236 pointer-events: none;
237 }
238 .btn-add.imported {
239 border-color: #333;
240 color: #4ade80;
241 background: transparent;
242 cursor: default;
243 font-size: 14px;
244 }
246 /* ─── Footer ──────────────────────────── */
247 .panel-footer {
248 padding: 12px 20px;
249 border-top: 1px solid #222;
250 font-size: 12px;
251 color: #666;
252 display: flex;
253 align-items: center;
254 justify-content: space-between;
255 }
256 .btn-import-all {
257 padding: 4px 12px;
258 border-radius: 6px;
259 border: 1px solid #333;
260 background: #1a1a1a;
261 color: #4ade80;
262 font-size: 12px;
263 cursor: pointer;
264 transition: all 0.15s;
265 }
266 .btn-import-all:hover { border-color: #4ade80; background: #0a2a14; }
267 .btn-import-all:disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
269 /* ─── Imported Panel ──────────────────── */
270 .imported-empty {
271 flex: 1;
272 display: flex;
273 align-items: center;
274 justify-content: center;
275 color: #444;
276 font-size: 13px;
277 padding: 20px;
278 text-align: center;
279 }
281 /* ─── Banner ──────────────────────────── */
282 .banner {
283 padding: 10px 20px;
284 font-size: 13px;
285 display: flex;
286 align-items: center;
287 gap: 10px;
288 }
289 .banner.error {
290 background: #1a0a0a;
291 border-bottom: 1px solid #3a1111;
292 color: #f87171;
293 }
294 .banner.info {
295 background: #0a1a2a;
296 border-bottom: 1px solid #112233;
297 color: #60a5fa;
298 }
299 .banner.warning {
300 background: #241b0a;
301 border-bottom: 1px solid #49330d;
302 color: #fbbf24;
303 }
304 .target-options {
305 border: 0;
306 border-bottom: 1px solid #222;
307 padding: 10px 24px;
308 font-size: 12px;
309 color: #aaa;
310 display: grid;
311 gap: 8px;
312 }
313 .target-options legend { padding-top: 10px; color: #ccc; }
314 .target-options label { display: flex; align-items: flex-start; gap: 8px; line-height: 1.5; }
315 .target-options input { margin-top: 3px; accent-color: #60a5fa; }
316 .target-options small { color: #888; }
317 button:disabled { opacity: 0.4; cursor: not-allowed; }
318 button:focus-visible, input:focus-visible { outline: 2px solid #60a5fa; outline-offset: 3px; }
319 .banner .banner-text { flex: 1; }
320 .banner .banner-close, .banner .banner-retry {
321 background: none;
322 border: 1px solid currentColor;
323 color: inherit;
324 padding: 3px 10px;
325 border-radius: 4px;
326 cursor: pointer;
327 font-size: 12px;
328 }
330 /* ─── Spinner ─────────────────────────── */
331 .spinner {
332 display: inline-block;
333 width: 14px; height: 14px;
334 border: 2px solid #333;
335 border-top-color: #4ade80;
336 border-radius: 50%;
337 animation: spin 0.6s linear infinite;
338 }
339 @keyframes spin { to { transform: rotate(360deg); } }
341 .loading-row {
342 display: flex;
343 align-items: center;
344 justify-content: center;
345 padding: 40px;
346 gap: 10px;
347 color: #666;
348 font-size: 13px;
349 }
350</style>
351</head>
352<body>
354<div class="header">
355 <h1>Cookie Import</h1>
356 <span class="port">localhost:${serverPort}</span>
357</div>
359<p class="subtitle">Copy cookies from the browser and profile you choose to this GStack Browser session. Copying cookies does not prove that you are signed in. Cookies are shared by tabs in this session.</p>
361<fieldset class="target-options">
362 <legend>Captured target: <span id="target-origin">No HTTP(S) target bound</span></legend>
363 <label><input id="clear-storage" type="checkbox"><span>Clear storage for this target origin before importing.<br><small>Chromium targets only. Clears origin localStorage (shared across tabs) and this target tab's sessionStorage only. Other origins and other tabs' sessionStorage are preserved.</small></span></label>
364 <label><input id="verify-auth" type="checkbox"><span>Reload the captured target and verify the configured account identity.<br><small id="verification-help">Requires an explicitly configured identity assertion and an HTTP(S) target.</small></span></label>
365</fieldset>
366<div id="banner" class="banner info" role="status" aria-live="polite" aria-atomic="true">Choose a browser and profile to inspect cookie domains.</div>
368<div class="container">
369 <!-- Left Panel: Source Browser -->
370 <div class="panel panel-left">
371 <div class="panel-header">Source Browser</div>
372 <div id="browser-pills" class="browser-pills"></div>
373 <div id="profile-pills" class="profile-pills" style="display:none"></div>
374 <div class="search-wrap">
375 <input type="text" class="search-input" id="search" placeholder="Search domains..." aria-label="Search cookie domains" />
376 </div>
377 <div class="domain-list" id="source-domains">
378 <div class="loading-row"><span class="spinner"></span> Detecting browsers...</div>
379 </div>
380 <div class="panel-footer" id="source-footer"><span id="source-footer-text"></span><button class="btn-import-all" id="btn-import-all" style="display:none">Import All</button></div>
381 </div>
383 <!-- Right Panel: Imported -->
384 <div class="panel panel-right">
385 <div class="panel-header">Imported to Session</div>
386 <div class="domain-list" id="imported-domains">
387 <div class="imported-empty">No cookies imported yet</div>
388 </div>
389 <div class="panel-footer" id="imported-footer"></div>
390 </div>
391</div>
393<script id="picker-config" type="application/json">${config}</script>
394<script>
395(function() {
396 const BASE = '${baseUrl}';
397 const config = JSON.parse(document.getElementById('picker-config').textContent);
398 let activeBrowser = null;
399 let configuredBrowser = null;
400 let activeProfile = null;
401 let allProfiles = [];
402 let allDomains = [];
403 let importedSet = Object.create(null);
404 let generation = 0;
405 let mutation = false;
406 const errorMessages = {
407 picker_changed: 'This picker is stale because another picker was opened. Reopen the picker from the intended page before continuing.',
408 keychain_denied: 'Keychain access was denied. Allow access in the OS permission prompt or settings, then retry manually.',
409 keychain_timeout: 'Credential lookup timed out. Check for a pending OS permission prompt, then retry manually.',
410 keychain_error: 'Credential lookup failed. Check the OS credential store or sign in manually in GStack Browser.',
411 db_locked: 'The source cookie database is busy. Close the source browser, then retry manually.',
412 db_corrupt: 'The source cookie database is invalid or corrupt. Choose another profile or sign in manually in GStack Browser.',
413 db_permission: 'Cookie database access was denied. Check source-profile permissions, then retry manually.',
414 db_read_error: 'Cookie data could not be read from this profile. Choose another profile or sign in manually in GStack Browser.',
415 sqlite_unavailable: 'Cookie import needs Node.js 22.13 or newer with built-in SQLite enabled. Upgrade the runtime or sign in manually in GStack Browser.',
416 storage_reset_unsupported: 'Storage reset requires a Chromium target. Import cookies without storage reset on other browsers.',
417 profile_required: 'Choose a source profile explicitly; multiple or unavailable profiles cannot be selected automatically.',
418 target_changed: 'The captured target changed or is unavailable. Reopen the picker from the intended HTTP(S) page.',
419 target_closed: 'The captured target is closed. Reopen the picker from the intended HTTP(S) page.',
420 target_mismatch: 'The selected cookies do not match the captured target. Select its cookie domain or reopen the picker from the intended page.',
421 not_supported: 'Native cookie import is unsupported for this browser or runtime. Sign in manually in GStack Browser.',
422 native_profile_unsupported: 'This browser profile does not support native cookie extraction. Sign in manually in GStack Browser.',
423 native_unqualified: 'Native extraction is disabled because process ownership and cleanup are not qualified for this browser and runtime. Sign in manually in GStack Browser.',
424 native_cleanup_failed: 'Native browser cleanup could not be confirmed. Inspect the source browser before any manual retry, or sign in manually in GStack Browser.',
425 native_supervision_failed: 'Native browser supervision could not start. Sign in manually in GStack Browser.',
426 native_timeout: 'Native cookie extraction timed out. Sign in manually in GStack Browser.',
427 browser_running: 'The source browser is already running. Close it yourself before retrying, or sign in manually in GStack Browser.',
428 };
430 const $pills = document.getElementById('browser-pills');
431 const $profilePills = document.getElementById('profile-pills');
432 const $search = document.getElementById('search');
433 const $sourceDomains = document.getElementById('source-domains');
434 const $importedDomains = document.getElementById('imported-domains');
435 const $sourceFooter = document.getElementById('source-footer-text');
436 const $btnImportAll = document.getElementById('btn-import-all');
437 const $importedFooter = document.getElementById('imported-footer');
438 const $banner = document.getElementById('banner');
439 const $clearStorage = document.getElementById('clear-storage');
440 const $verifyAuth = document.getElementById('verify-auth');
441 const canVerify = !!config.targetOrigin && config.verificationAvailable;
442 const canReset = !!config.targetOrigin && config.storageResetAvailable;
443 document.getElementById('target-origin').textContent = config.targetOrigin || 'No HTTP(S) target bound';
444 $clearStorage.checked = canReset && config.clearStorage;
445 $clearStorage.disabled = !canReset;
446 $verifyAuth.checked = canVerify && config.verifyAuth;
447 $verifyAuth.disabled = !canVerify;
448 if (canVerify) document.getElementById('verification-help').textContent = 'Optional. Uses the server-configured exact identity assertion; the identity is never displayed here.';
450 function escHtml(s) {
451 return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
452 }
454 function errorMessage(error) {
455 return error && Object.hasOwn(errorMessages, error.code) ? errorMessages[error.code]
456 : 'Inspect the source and destination before retrying, or reopen the picker.';
457 }
459 function showBanner(message, type, retry) {
460 $banner.className = 'banner ' + type;
461 $banner.innerHTML = '<span class="banner-text">' + escHtml(message) + '</span>';
462 if (retry) {
463 const button = document.createElement('button');
464 button.className = 'banner-retry';
465 button.textContent = 'Retry';
466 button.disabled = mutation;
467 button.onclick = () => { if (!mutation) retry(); };
468 $banner.appendChild(button);
469 }
470 }
472 async function api(path, opts) {
473 const headers = new Headers(opts && opts.headers);
474 headers.set('X-Gstack-Picker-Instance', config.pickerInstance || '');
475 const response = await fetch(BASE + '/cookie-picker' + path, { ...opts, headers, credentials: 'same-origin' });
476 const data = await response.json();
477 if (!response.ok) {
478 const error = new Error('Cookie picker request failed.');
479 if (data && typeof data.code === 'string' && Object.hasOwn(errorMessages, data.code)) error.code = data.code;
480 throw error;
481 }
482 return data;
483 }
485 async function init() {
486 try {
487 const [browserData, importedData] = await Promise.all([api('/browsers'), api('/imported')]);
488 for (const entry of importedData.domains || []) {
489 if (Number.isFinite(entry.count) && entry.count > 0) importedSet[entry.domain] = entry.count;
490 }
491 renderImported();
492 const browsers = browserData.browsers || [];
493 $pills.innerHTML = '';
494 for (const browser of browsers) {
495 const button = document.createElement('button');
496 button.className = 'pill';
497 button.dataset.browser = browser.name;
498 button.setAttribute('aria-pressed', 'false');
499 button.innerHTML = '<span class="dot"></span>' + escHtml(browser.name);
500 button.onclick = () => selectBrowser(browser.name);
501 $pills.appendChild(button);
502 }
503 if (!browsers.length) {
504 $sourceDomains.innerHTML = '<div class="imported-empty">No Chromium browsers detected</div>';
505 showBanner('No supported source browsers were detected.', 'warning');
506 return;
507 }
508 const selected = config.browser
509 ? browsers.find(browser => [browser.name, ...(Array.isArray(browser.aliases) ? browser.aliases : [])]
510 .some(name => typeof name === 'string' && name.toLowerCase() === config.browser.trim().toLowerCase()))
511 : browsers[0];
512 configuredBrowser = config.browser && selected ? selected.name : null;
513 if (selected) await selectBrowser(selected.name);
514 else {
515 $sourceDomains.innerHTML = '<div class="imported-empty">Choose an available source browser</div>';
516 showBanner('The requested browser is unavailable. Choose an available browser explicitly.', 'warning');
517 }
518 } catch (error) {
519 showBanner('Could not load the cookie picker. ' + errorMessage(error), 'error', init);
520 $sourceDomains.innerHTML = '<div class="imported-empty">Failed to load</div>';
521 }
522 }
524 async function selectBrowser(name) {
525 if (mutation) return;
526 const selection = ++generation;
527 activeBrowser = name;
528 activeProfile = null;
529 allProfiles = [];
530 allDomains = [];
531 $search.value = '';
532 $profilePills.innerHTML = '';
533 $profilePills.style.display = 'none';
534 $btnImportAll.style.display = 'none';
535 $sourceFooter.textContent = '';
536 $pills.querySelectorAll('button').forEach(button => {
537 const active = button.dataset.browser === name;
538 button.classList.toggle('active', active);
539 button.setAttribute('aria-pressed', String(active));
540 });
541 $sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading profiles...</div>';
542 showBanner('Loading profiles from ' + name + '.', 'info');
543 try {
544 const data = await api('/profiles?browser=' + encodeURIComponent(name));
545 if (selection !== generation) return;
546 allProfiles = data.profiles || [];
547 const explicit = config.profile && configuredBrowser === name;
548 const requested = explicit ? config.profile : data.recommendedProfile;
549 activeProfile = allProfiles.some(profile => profile.name === requested) ? requested : null;
550 renderProfilePills();
551 if (!activeProfile) {
552 $sourceDomains.innerHTML = '<div class="imported-empty">' + (allProfiles.length ? 'Choose a profile to inspect its domains' : 'No profiles found') + '</div>';
553 showBanner(explicit ? 'The requested profile is unavailable. Choose a profile explicitly.'
554 : allProfiles.length ? 'Choose a profile. No unambiguous profile was recommended.' : 'No source profiles were found.', 'warning');
555 return;
556 }
557 await loadDomains(selection, name, activeProfile);
558 } catch (error) {
559 if (selection !== generation) return;
560 showBanner('Could not load profiles for ' + name + '. ' + errorMessage(error), 'error', () => selectBrowser(name));
561 $sourceDomains.innerHTML = '<div class="imported-empty">Failed to load profiles</div>';
562 }
563 }
565 function renderProfilePills() {
566 $profilePills.style.display = allProfiles.length ? 'flex' : 'none';
567 $profilePills.innerHTML = allProfiles.map(profile => {
568 const active = profile.name === activeProfile;
569 const label = (profile.displayName || profile.name) + ' (' + profile.name + ')' + (profile.unavailable ? ' — could not inspect' : '');
570 return '<button class="profile-pill' + (active ? ' active' : '') + '" aria-pressed="' + active + '" data-profile="' + escHtml(profile.name) + '"' + (mutation ? ' disabled' : '') + '>' + escHtml(label) + '</button>';
571 }).join('');
572 $profilePills.querySelectorAll('button').forEach(button => {
573 button.onclick = () => selectProfile(button.dataset.profile);
574 });
575 }
577 async function selectProfile(name) {
578 if (mutation || !allProfiles.some(profile => profile.name === name)) return;
579 const selection = ++generation;
580 activeProfile = name;
581 allDomains = [];
582 $search.value = '';
583 renderProfilePills();
584 $profilePills.querySelectorAll('button').forEach(button => { if (button.dataset.profile === name) button.focus(); });
585 await loadDomains(selection, activeBrowser, name);
586 }
588 async function loadDomains(selection, browser, profile) {
589 $sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading domains...</div>';
590 $sourceFooter.textContent = '';
591 $btnImportAll.style.display = 'none';
592 showBanner('Loading domains from ' + browser + ' (' + profile + ').', 'info');
593 try {
594 const data = await api('/domains?browser=' + encodeURIComponent(browser) + '&profile=' + encodeURIComponent(profile));
595 if (selection !== generation) return;
596 allDomains = data.domains || [];
597 renderSourceDomains();
598 showBanner(allDomains.length ? 'Ready to import from ' + browser + ' (' + profile + '). Authentication has not been checked.'
599 : 'No cookie domains found in ' + browser + ' (' + profile + ').', allDomains.length ? 'info' : 'warning');
600 } catch (error) {
601 if (selection !== generation) return;
602 showBanner('Could not read domains from ' + browser + ' (' + profile + '). ' + errorMessage(error), 'error', () => selectProfile(profile));
603 $sourceDomains.innerHTML = '<div class="imported-empty">Failed to load domains</div>';
604 }
605 }
607 function renderSourceDomains() {
608 const query = $search.value.toLowerCase();
609 const filtered = allDomains.filter(domain => domain.domain.toLowerCase().includes(query));
610 $btnImportAll.style.display = filtered.length && activeProfile ? '' : 'none';
611 $btnImportAll.disabled = mutation || !activeProfile;
612 $btnImportAll.textContent = 'Import All (' + filtered.length + ')';
613 $sourceFooter.textContent = allDomains.length ? allDomains.length + ' domains · ' + allDomains.reduce((sum, domain) => sum + domain.count, 0).toLocaleString() + ' cookies' : '';
614 if (!filtered.length) {
615 $sourceDomains.innerHTML = '<div class="imported-empty">' + (!activeProfile ? 'Choose a profile to inspect its domains' : query ? 'No matching domains' : 'No cookie domains found') + '</div>';
616 return;
617 }
618 $sourceDomains.innerHTML = filtered.map(domain => {
619 const label = (domain.domain in importedSet ? 'Reimport ' : 'Import ') + domain.domain;
620 return '<div class="domain-row"><span class="domain-name">' + escHtml(domain.domain) + '</span><span class="domain-count">' + escHtml(domain.count) + '</span><button class="btn-add" data-domain="' + escHtml(domain.domain) + '" title="' + escHtml(label) + '" aria-label="' + escHtml(label) + '"' + (mutation ? ' disabled' : '') + '>' + (domain.domain in importedSet ? '&#8635;' : '+') + '</button></div>';
621 }).join('');
622 $sourceDomains.querySelectorAll('button').forEach(button => {
623 button.onclick = () => importDomains([button.dataset.domain]);
624 });
625 }
627 function setMutationBusy(busy) {
628 mutation = busy;
629 $pills.querySelectorAll('button').forEach(button => { button.disabled = busy; });
630 $profilePills.querySelectorAll('button').forEach(button => { button.disabled = busy; });
631 $search.disabled = busy;
632 $clearStorage.disabled = busy || !canReset;
633 $verifyAuth.disabled = busy || !canVerify;
634 renderSourceDomains();
635 renderImported();
636 }
638 async function importDomains(domains) {
639 if (mutation || !activeBrowser || !activeProfile || !domains.length) return;
640 const request = { browser: activeBrowser, profile: activeProfile, domains: domains.slice(),
641 clearStorage: canReset && $clearStorage.checked, verifyAuth: canVerify && $verifyAuth.checked };
642 setMutationBusy(true);
643 showBanner('Importing from ' + request.browser + ' (' + request.profile + '). Keep this picker open.', 'info');
644 try {
645 const data = await api('/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request) });
646 const imported = Number.isFinite(data.imported) && data.imported > 0 ? data.imported : 0;
647 const failed = Number.isFinite(data.failed) && data.failed > 0 ? data.failed : 0;
648 for (const [domain, count] of Object.entries(data.domainCounts || {})) {
649 if (imported > 0 && Number.isFinite(count) && count > 0) importedSet[domain] = count;
650 }
651 const partial = data.outcome === 'partial' || (imported > 0 && failed > 0);
652 let type = data.outcome === 'failed' || data.reset === 'failed' ? 'error' : partial || !imported ? 'warning' : 'info';
653 let message = (data.outcome === 'failed' ? 'Import failed. ' : partial ? 'Partial import. ' : !imported ? 'No cookies imported. ' : 'Cookies imported. ')
654 + imported + ' imported; ' + failed + ' failed. Source: ' + request.browser + ' (' + request.profile + ').';
655 if (typeof data.message === 'string' && data.message) message += ' ' + data.message;
656 const failureLabels = { unsupported_encryption: 'unsupported encryption', decryption_failed: 'decryption failed', native_unrecovered: 'not recovered by native import' };
657 for (const [reason, count] of Object.entries(data.failureReasons || {})) {
658 if (Object.hasOwn(failureLabels, reason) && Number.isFinite(count) && count > 0) message += ' ' + failureLabels[reason] + ': ' + count + '.';
659 }
660 message += data.reset === 'cleared' ? ' Storage cleared for ' + config.targetOrigin + '.'
661 : data.reset === 'failed' ? ' Storage reset failed; storage may be partially cleared.' : ' Storage preserved.';
662 if (!request.verifyAuth) message += ' Authentication not checked.';
663 else if (imported > 0 && data.verification && data.verification.verified === true) message += ' Authentication verified on the captured target.';
664 else {
665 const reasons = { not_configured: 'identity assertion not configured', no_cookies_imported: 'no cookies imported',
666 identity_missing: 'visible identity missing', identity_ambiguous: 'multiple visible identities', identity_mismatch: 'identity did not match',
667 login_redirect: 'login page detected', target_changed: 'target changed', target_closed: 'target closed',
668 timeout: 'check timed out', http_error: 'unsuccessful HTTP response', reset_failed: 'storage reset failed', application_failed: 'cookie application failed' };
669 const reason = data.verification && data.verification.reason;
670 message += ' Authentication not verified' + (Object.hasOwn(reasons, reason) ? ': ' + reasons[reason] : '') + '.';
671 if (type === 'info') type = 'warning';
672 }
673 showBanner(message, type);
674 } catch (error) {
675 showBanner('Import did not complete for ' + request.browser + ' (' + request.profile + '). ' + errorMessage(error) + ' Authentication was not verified.', 'error');
676 } finally {
677 setMutationBusy(false);
678 if (domains.length === 1) {
679 $sourceDomains.querySelectorAll('button').forEach(button => { if (button.dataset.domain === domains[0]) button.focus(); });
680 } else $btnImportAll.focus();
681 }
682 }
684 $btnImportAll.onclick = () => importDomains(allDomains.filter(domain => domain.domain.toLowerCase().includes($search.value.toLowerCase())).map(domain => domain.domain));
686 function renderImported() {
687 const entries = Object.entries(importedSet).sort((a, b) => b[1] - a[1]);
688 $importedFooter.textContent = entries.length ? entries.length + ' domains · ' + entries.reduce((sum, entry) => sum + entry[1], 0).toLocaleString() + ' cookies imported' : '';
689 $importedDomains.innerHTML = entries.length ? entries.map(([domain, count]) => '<div class="domain-row"><span class="domain-name">' + escHtml(domain) + '</span><span class="domain-count">' + escHtml(count) + '</span><button class="btn-trash" data-domain="' + escHtml(domain) + '" aria-label="' + escHtml('Remove ' + domain) + '" title="Remove"' + (mutation ? ' disabled' : '') + '>&#128465;</button></div>').join('')
690 : '<div class="imported-empty">No cookies imported yet</div>';
691 $importedDomains.querySelectorAll('button').forEach(button => { button.onclick = () => removeDomain(button.dataset.domain); });
692 }
694 async function removeDomain(domain) {
695 if (mutation) return;
696 setMutationBusy(true);
697 showBanner('Removing imported cookies for ' + domain + '.', 'info');
698 try {
699 await api('/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domains: [domain] }) });
700 delete importedSet[domain];
701 showBanner('Removed imported cookies for ' + domain + '. Storage was not cleared.', 'info');
702 } catch (error) {
703 showBanner('Cookie removal did not complete. ' + errorMessage(error), 'error');
704 } finally {
705 setMutationBusy(false);
706 const first = $importedDomains.querySelector('button') || $sourceDomains.querySelector('button');
707 if (first) first.focus();
708 }
709 }
711 $search.addEventListener('input', renderSourceDomains);
712 init();
713})();
714</script>
715</body>
716</html>`;