mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-04 02:16:56 +02:00
v1.90.0.0 feat: make browser cookie imports explicit and safe (#2964)
* fix(browse): prepare reliable cookie import wave for validation * ci: sequence quality and behavior for validation branch * fix(browse): isolate Windows qualification and preserve native diagnostics * test(browse): cover cookie workflow quality and isolate Windows user paths * test(browse): trace native member startup and initialize fresh folders * fix(browse): keep Windows member stdin alive through EOF * fix(browse): latch native timeouts and compare contained Edge startup * test(browse): verify native version metadata and actual Windows argv * test(browse): qualify Dia import on isolated macOS CI * fix(browse): require picker origin for session mutations * fix(browse): bound credential reads through stream completion * test(browse): inspect owned Windows process arguments natively * test(evals): preserve passing coverage during cookie repair reruns * test(browse): isolate Dia qualification in a fresh macOS account * test(browse): pass bounded integer timeouts to native Mac probes * test(browse): distinguish Windows profile initialization from containment * test(browse): await descendant pipe readiness before parent exit * test(browse): initialize and restore isolated macOS Keychain state * test(browse): initialize Windows fixture folders before qualification * test(ci): pin the same Node runtime across Windows checks * test(browse): distinguish native macOS browser preflight stages * test(browse): isolate Windows descendant console lifetime * test(browse): preserve native receipts and identify fixture lock holders * test(browse): prepare dependency resolution before native Mac worker startup * test(ci): include lock and close checks in native diagnostics * test(browse): preserve native owner probe stages and subprocess deadlines * fix(browse): classify Chromium profile-in-use exit precisely * test(browse): retain Mac qualification evidence through cleanup failures * test(browse): bound Mac fixture paths and retire its owned user domain * test(browse): accept vanished fixture entries without weakening cleanup * test(browse): identify probe-created macOS user domains safely * test(browse): observe Mac user domains without targeting them first * test(browse): use passive fresh-user ownership throughout Mac qualification * test(browse): distinguish profile and registered-home Keychain lookups * test(browse): qualify Dia under one registered account home * test(browse): identify Dia startup and owned process-group failures * test(browse): classify bounded Dia startup diagnostics without leaking output * fix(test): preserve native Mac sandboxing and reap owned browser children * fix(browse): preserve Chromium sandboxing for native profile imports * test(browse): inspect signed Mach-O architecture without launching Xcode tools * test(browse): sample pending Dia startup and reap on all cleanup paths * test(browse): compare protected Dia launches in fresh Bun and Node accounts * test(browse): inspect isolated Mac GUI readiness without browser access * v1.90.0.0 fix: bind cookie picker actions to their document * test: validate cookie guards and fit nested launch fixtures * ci: configure the bundled Chromium sandbox helper * fix(browse): classify Playwright authentication timeouts * test: retain bounded Windows lifecycle diagnostics * test(cso): reuse bounded NTFS precision candidates * test(review): handle explicit preservation choices safely * test(browse): remove owned fixture directories with explicit primitives * test(review): distinguish descriptive reuse from edit commitments * test: admit only the approved unscored cookie workflow refusal * test: keep the Office Hours judge mock export-complete * fix: keep dependency-free CI planners independent of the model SDK * test: observe the exact holder after a native fixture unlink failure * fix: start seeded PTY observations at owned readiness * test: acquire identity-bound Windows deletion admission before profile resets * test: preserve qualified Git index bits without authorizing mutations
This commit is contained in:
1 parent
730a1017d1
commit
a84b0b5b6d
111 files changed
+14996
-1057
No files matched your search
+297
-276
@@ -7,8 +7,32 @@
|
||||
* No cookie values exposed anywhere.
|
||||
*/
|
||||
|
||||
export function getCookiePickerHTML(serverPort: number): string {
|
||||
export function getCookiePickerHTML(serverPort: number, options: {
|
||||
pickerInstance?: string;
|
||||
browser?: string;
|
||||
profile?: string;
|
||||
targetOrigin?: string;
|
||||
verifyAuth?: boolean;
|
||||
clearStorage?: boolean;
|
||||
verificationAvailable?: boolean;
|
||||
storageResetAvailable?: boolean;
|
||||
} = {}): string {
|
||||
const baseUrl = `http://127.0.0.1:${serverPort}`;
|
||||
let targetOrigin: string | undefined;
|
||||
try {
|
||||
const target = new URL(options.targetOrigin ?? '');
|
||||
if (['http:', 'https:'].includes(target.protocol)) targetOrigin = target.origin;
|
||||
} catch {}
|
||||
const config = JSON.stringify({
|
||||
pickerInstance: options.pickerInstance,
|
||||
browser: options.browser,
|
||||
profile: options.profile,
|
||||
targetOrigin,
|
||||
verifyAuth: options.verifyAuth === true,
|
||||
clearStorage: options.clearStorage === true,
|
||||
verificationAvailable: options.verificationAvailable === true,
|
||||
storageResetAvailable: options.storageResetAvailable !== false,
|
||||
}).replace(/[<>&\u2028\u2029]/g, character => '\\u' + character.charCodeAt(0).toString(16).padStart(4, '0'));
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -24,6 +48,8 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
color: #e0e0e0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ─── Header ──────────────────────────── */
|
||||
@@ -58,7 +84,8 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
/* ─── Layout ──────────────────────────── */
|
||||
.container {
|
||||
display: flex;
|
||||
height: calc(100vh - 53px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.panel {
|
||||
flex: 1;
|
||||
@@ -255,7 +282,7 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
.banner {
|
||||
padding: 10px 20px;
|
||||
font-size: 13px;
|
||||
display: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -269,6 +296,26 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
border-bottom: 1px solid #112233;
|
||||
color: #60a5fa;
|
||||
}
|
||||
.banner.warning {
|
||||
background: #241b0a;
|
||||
border-bottom: 1px solid #49330d;
|
||||
color: #fbbf24;
|
||||
}
|
||||
.target-options {
|
||||
border: 0;
|
||||
border-bottom: 1px solid #222;
|
||||
padding: 10px 24px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.target-options legend { padding-top: 10px; color: #ccc; }
|
||||
.target-options label { display: flex; align-items: flex-start; gap: 8px; line-height: 1.5; }
|
||||
.target-options input { margin-top: 3px; accent-color: #60a5fa; }
|
||||
.target-options small { color: #888; }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
button:focus-visible, input:focus-visible { outline: 2px solid #60a5fa; outline-offset: 3px; }
|
||||
.banner .banner-text { flex: 1; }
|
||||
.banner .banner-close, .banner .banner-retry {
|
||||
background: none;
|
||||
@@ -309,9 +356,14 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
<span class="port">localhost:${serverPort}</span>
|
||||
</div>
|
||||
|
||||
<p class="subtitle">Select the domains of cookies you want to import to GStack Browser. You'll be able to browse those sites with the same login as your other browser.</p>
|
||||
<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>
|
||||
|
||||
<div id="banner" class="banner"></div>
|
||||
<fieldset class="target-options">
|
||||
<legend>Captured target: <span id="target-origin">No HTTP(S) target bound</span></legend>
|
||||
<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>
|
||||
<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>
|
||||
</fieldset>
|
||||
<div id="banner" class="banner info" role="status" aria-live="polite" aria-atomic="true">Choose a browser and profile to inspect cookie domains.</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Left Panel: Source Browser -->
|
||||
@@ -320,7 +372,7 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
<div id="browser-pills" class="browser-pills"></div>
|
||||
<div id="profile-pills" class="profile-pills" style="display:none"></div>
|
||||
<div class="search-wrap">
|
||||
<input type="text" class="search-input" id="search" placeholder="Search domains..." />
|
||||
<input type="text" class="search-input" id="search" placeholder="Search domains..." aria-label="Search cookie domains" />
|
||||
</div>
|
||||
<div class="domain-list" id="source-domains">
|
||||
<div class="loading-row"><span class="spinner"></span> Detecting browsers...</div>
|
||||
@@ -338,15 +390,42 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="picker-config" type="application/json">${config}</script>
|
||||
<script>
|
||||
(function() {
|
||||
const BASE = '${baseUrl}';
|
||||
const config = JSON.parse(document.getElementById('picker-config').textContent);
|
||||
let activeBrowser = null;
|
||||
let activeProfile = 'Default';
|
||||
let configuredBrowser = null;
|
||||
let activeProfile = null;
|
||||
let allProfiles = [];
|
||||
let allDomains = [];
|
||||
let importedSet = {}; // domain → count
|
||||
let inflight = {}; // domain → true (prevents double-click)
|
||||
let importedSet = Object.create(null);
|
||||
let generation = 0;
|
||||
let mutation = false;
|
||||
const errorMessages = {
|
||||
picker_changed: 'This picker is stale because another picker was opened. Reopen the picker from the intended page before continuing.',
|
||||
keychain_denied: 'Keychain access was denied. Allow access in the OS permission prompt or settings, then retry manually.',
|
||||
keychain_timeout: 'Credential lookup timed out. Check for a pending OS permission prompt, then retry manually.',
|
||||
keychain_error: 'Credential lookup failed. Check the OS credential store or sign in manually in GStack Browser.',
|
||||
db_locked: 'The source cookie database is busy. Close the source browser, then retry manually.',
|
||||
db_corrupt: 'The source cookie database is invalid or corrupt. Choose another profile or sign in manually in GStack Browser.',
|
||||
db_permission: 'Cookie database access was denied. Check source-profile permissions, then retry manually.',
|
||||
db_read_error: 'Cookie data could not be read from this profile. Choose another profile or sign in manually in GStack Browser.',
|
||||
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.',
|
||||
storage_reset_unsupported: 'Storage reset requires a Chromium target. Import cookies without storage reset on other browsers.',
|
||||
profile_required: 'Choose a source profile explicitly; multiple or unavailable profiles cannot be selected automatically.',
|
||||
target_changed: 'The captured target changed or is unavailable. Reopen the picker from the intended HTTP(S) page.',
|
||||
target_closed: 'The captured target is closed. Reopen the picker from the intended HTTP(S) page.',
|
||||
target_mismatch: 'The selected cookies do not match the captured target. Select its cookie domain or reopen the picker from the intended page.',
|
||||
not_supported: 'Native cookie import is unsupported for this browser or runtime. Sign in manually in GStack Browser.',
|
||||
native_profile_unsupported: 'This browser profile does not support native cookie extraction. Sign in manually in GStack Browser.',
|
||||
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.',
|
||||
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.',
|
||||
native_supervision_failed: 'Native browser supervision could not start. Sign in manually in GStack Browser.',
|
||||
native_timeout: 'Native cookie extraction timed out. Sign in manually in GStack Browser.',
|
||||
browser_running: 'The source browser is already running. Close it yourself before retrying, or sign in manually in GStack Browser.',
|
||||
};
|
||||
|
||||
const $pills = document.getElementById('browser-pills');
|
||||
const $profilePills = document.getElementById('profile-pills');
|
||||
@@ -357,337 +436,279 @@ export function getCookiePickerHTML(serverPort: number): string {
|
||||
const $btnImportAll = document.getElementById('btn-import-all');
|
||||
const $importedFooter = document.getElementById('imported-footer');
|
||||
const $banner = document.getElementById('banner');
|
||||
|
||||
// ─── Banner ────────────────────────────
|
||||
function showBanner(msg, type, retryFn) {
|
||||
$banner.className = 'banner ' + type;
|
||||
$banner.style.display = 'flex';
|
||||
let html = '<span class="banner-text">' + escHtml(msg) + '</span>';
|
||||
if (retryFn) {
|
||||
html += '<button class="banner-retry" id="banner-retry">Retry</button>';
|
||||
}
|
||||
html += '<button class="banner-close" id="banner-close">×</button>';
|
||||
$banner.innerHTML = html;
|
||||
document.getElementById('banner-close').onclick = () => { $banner.style.display = 'none'; };
|
||||
if (retryFn) {
|
||||
document.getElementById('banner-retry').onclick = () => {
|
||||
$banner.style.display = 'none';
|
||||
retryFn();
|
||||
};
|
||||
}
|
||||
}
|
||||
const $clearStorage = document.getElementById('clear-storage');
|
||||
const $verifyAuth = document.getElementById('verify-auth');
|
||||
const canVerify = !!config.targetOrigin && config.verificationAvailable;
|
||||
const canReset = !!config.targetOrigin && config.storageResetAvailable;
|
||||
document.getElementById('target-origin').textContent = config.targetOrigin || 'No HTTP(S) target bound';
|
||||
$clearStorage.checked = canReset && config.clearStorage;
|
||||
$clearStorage.disabled = !canReset;
|
||||
$verifyAuth.checked = canVerify && config.verifyAuth;
|
||||
$verifyAuth.disabled = !canVerify;
|
||||
if (canVerify) document.getElementById('verification-help').textContent = 'Optional. Uses the server-configured exact identity assertion; the identity is never displayed here.';
|
||||
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function errorMessage(error) {
|
||||
return error && Object.hasOwn(errorMessages, error.code) ? errorMessages[error.code]
|
||||
: 'Inspect the source and destination before retrying, or reopen the picker.';
|
||||
}
|
||||
|
||||
function showBanner(message, type, retry) {
|
||||
$banner.className = 'banner ' + type;
|
||||
$banner.innerHTML = '<span class="banner-text">' + escHtml(message) + '</span>';
|
||||
if (retry) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'banner-retry';
|
||||
button.textContent = 'Retry';
|
||||
button.disabled = mutation;
|
||||
button.onclick = () => { if (!mutation) retry(); };
|
||||
$banner.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── API ────────────────────────────────
|
||||
async function api(path, opts) {
|
||||
const res = await fetch(BASE + '/cookie-picker' + path, { ...opts, credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.error || 'Request failed');
|
||||
err.code = data.code;
|
||||
err.action = data.action;
|
||||
throw err;
|
||||
const headers = new Headers(opts && opts.headers);
|
||||
headers.set('X-Gstack-Picker-Instance', config.pickerInstance || '');
|
||||
const response = await fetch(BASE + '/cookie-picker' + path, { ...opts, headers, credentials: 'same-origin' });
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
const error = new Error('Cookie picker request failed.');
|
||||
if (data && typeof data.code === 'string' && Object.hasOwn(errorMessages, data.code)) error.code = data.code;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Init ───────────────────────────────
|
||||
async function init() {
|
||||
try {
|
||||
const [browserData, importedData] = await Promise.all([
|
||||
api('/browsers'),
|
||||
api('/imported'),
|
||||
]);
|
||||
|
||||
// Populate imported state
|
||||
for (const entry of importedData.domains) {
|
||||
importedSet[entry.domain] = entry.count;
|
||||
const [browserData, importedData] = await Promise.all([api('/browsers'), api('/imported')]);
|
||||
for (const entry of importedData.domains || []) {
|
||||
if (Number.isFinite(entry.count) && entry.count > 0) importedSet[entry.domain] = entry.count;
|
||||
}
|
||||
renderImported();
|
||||
|
||||
// Render browser pills
|
||||
const browsers = browserData.browsers;
|
||||
if (browsers.length === 0) {
|
||||
const browsers = browserData.browsers || [];
|
||||
$pills.innerHTML = '';
|
||||
for (const browser of browsers) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'pill';
|
||||
button.dataset.browser = browser.name;
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
button.innerHTML = '<span class="dot"></span>' + escHtml(browser.name);
|
||||
button.onclick = () => selectBrowser(browser.name);
|
||||
$pills.appendChild(button);
|
||||
}
|
||||
if (!browsers.length) {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">No Chromium browsers detected</div>';
|
||||
showBanner('No supported source browsers were detected.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
$pills.innerHTML = '';
|
||||
browsers.forEach(b => {
|
||||
const pill = document.createElement('button');
|
||||
pill.className = 'pill';
|
||||
pill.innerHTML = '<span class="dot"></span>' + escHtml(b.name);
|
||||
pill.onclick = () => selectBrowser(b.name);
|
||||
$pills.appendChild(pill);
|
||||
});
|
||||
|
||||
// Auto-select first browser
|
||||
selectBrowser(browsers[0].name);
|
||||
} catch (err) {
|
||||
showBanner(err.message, 'error', init);
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Select Browser ────────────────────
|
||||
async function selectBrowser(name) {
|
||||
activeBrowser = name;
|
||||
activeProfile = 'Default';
|
||||
|
||||
// Update pills
|
||||
$pills.querySelectorAll('.pill').forEach(p => {
|
||||
p.classList.toggle('active', p.textContent === name);
|
||||
});
|
||||
|
||||
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading...</div>';
|
||||
$sourceFooter.textContent = '';
|
||||
$search.value = '';
|
||||
|
||||
try {
|
||||
// Fetch profiles for this browser
|
||||
const profileData = await api('/profiles?browser=' + encodeURIComponent(name));
|
||||
allProfiles = profileData.profiles || [];
|
||||
|
||||
if (allProfiles.length > 1) {
|
||||
// Show profile pills when multiple profiles exist
|
||||
$profilePills.style.display = 'flex';
|
||||
renderProfilePills();
|
||||
// Auto-select profile with the most recent/largest cookie DB, or Default
|
||||
activeProfile = allProfiles[0].name;
|
||||
} else {
|
||||
$profilePills.style.display = 'none';
|
||||
activeProfile = allProfiles.length === 1 ? allProfiles[0].name : 'Default';
|
||||
const selected = config.browser
|
||||
? browsers.find(browser => [browser.name, ...(Array.isArray(browser.aliases) ? browser.aliases : [])]
|
||||
.some(name => typeof name === 'string' && name.toLowerCase() === config.browser.trim().toLowerCase()))
|
||||
: browsers[0];
|
||||
configuredBrowser = config.browser && selected ? selected.name : null;
|
||||
if (selected) await selectBrowser(selected.name);
|
||||
else {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Choose an available source browser</div>';
|
||||
showBanner('The requested browser is unavailable. Choose an available browser explicitly.', 'warning');
|
||||
}
|
||||
|
||||
await loadDomains();
|
||||
} catch (err) {
|
||||
showBanner(err.message, 'error', err.action === 'retry' ? () => selectBrowser(name) : null);
|
||||
} catch (error) {
|
||||
showBanner('Could not load the cookie picker. ' + errorMessage(error), 'error', init);
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load</div>';
|
||||
$profilePills.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Render Profile Pills ─────────────
|
||||
function renderProfilePills() {
|
||||
let html = '';
|
||||
for (const p of allProfiles) {
|
||||
const isActive = p.name === activeProfile;
|
||||
const label = p.displayName || p.name;
|
||||
html += '<button class="profile-pill' + (isActive ? ' active' : '') + '" data-profile="' + escHtml(p.name) + '">' + escHtml(label) + '</button>';
|
||||
async function selectBrowser(name) {
|
||||
if (mutation) return;
|
||||
const selection = ++generation;
|
||||
activeBrowser = name;
|
||||
activeProfile = null;
|
||||
allProfiles = [];
|
||||
allDomains = [];
|
||||
$search.value = '';
|
||||
$profilePills.innerHTML = '';
|
||||
$profilePills.style.display = 'none';
|
||||
$btnImportAll.style.display = 'none';
|
||||
$sourceFooter.textContent = '';
|
||||
$pills.querySelectorAll('button').forEach(button => {
|
||||
const active = button.dataset.browser === name;
|
||||
button.classList.toggle('active', active);
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading profiles...</div>';
|
||||
showBanner('Loading profiles from ' + name + '.', 'info');
|
||||
try {
|
||||
const data = await api('/profiles?browser=' + encodeURIComponent(name));
|
||||
if (selection !== generation) return;
|
||||
allProfiles = data.profiles || [];
|
||||
const explicit = config.profile && configuredBrowser === name;
|
||||
const requested = explicit ? config.profile : data.recommendedProfile;
|
||||
activeProfile = allProfiles.some(profile => profile.name === requested) ? requested : null;
|
||||
renderProfilePills();
|
||||
if (!activeProfile) {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">' + (allProfiles.length ? 'Choose a profile to inspect its domains' : 'No profiles found') + '</div>';
|
||||
showBanner(explicit ? 'The requested profile is unavailable. Choose a profile explicitly.'
|
||||
: allProfiles.length ? 'Choose a profile. No unambiguous profile was recommended.' : 'No source profiles were found.', 'warning');
|
||||
return;
|
||||
}
|
||||
await loadDomains(selection, name, activeProfile);
|
||||
} catch (error) {
|
||||
if (selection !== generation) return;
|
||||
showBanner('Could not load profiles for ' + name + '. ' + errorMessage(error), 'error', () => selectBrowser(name));
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load profiles</div>';
|
||||
}
|
||||
$profilePills.innerHTML = html;
|
||||
}
|
||||
|
||||
$profilePills.querySelectorAll('.profile-pill').forEach(btn => {
|
||||
btn.addEventListener('click', () => selectProfile(btn.dataset.profile));
|
||||
function renderProfilePills() {
|
||||
$profilePills.style.display = allProfiles.length ? 'flex' : 'none';
|
||||
$profilePills.innerHTML = allProfiles.map(profile => {
|
||||
const active = profile.name === activeProfile;
|
||||
const label = (profile.displayName || profile.name) + ' (' + profile.name + ')' + (profile.unavailable ? ' — could not inspect' : '');
|
||||
return '<button class="profile-pill' + (active ? ' active' : '') + '" aria-pressed="' + active + '" data-profile="' + escHtml(profile.name) + '"' + (mutation ? ' disabled' : '') + '>' + escHtml(label) + '</button>';
|
||||
}).join('');
|
||||
$profilePills.querySelectorAll('button').forEach(button => {
|
||||
button.onclick = () => selectProfile(button.dataset.profile);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Select Profile ───────────────────
|
||||
async function selectProfile(profileName) {
|
||||
activeProfile = profileName;
|
||||
async function selectProfile(name) {
|
||||
if (mutation || !allProfiles.some(profile => profile.name === name)) return;
|
||||
const selection = ++generation;
|
||||
activeProfile = name;
|
||||
allDomains = [];
|
||||
$search.value = '';
|
||||
renderProfilePills();
|
||||
$profilePills.querySelectorAll('button').forEach(button => { if (button.dataset.profile === name) button.focus(); });
|
||||
await loadDomains(selection, activeBrowser, name);
|
||||
}
|
||||
|
||||
async function loadDomains(selection, browser, profile) {
|
||||
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading domains...</div>';
|
||||
$sourceFooter.textContent = '';
|
||||
$search.value = '';
|
||||
|
||||
await loadDomains();
|
||||
}
|
||||
|
||||
// ─── Load Domains ─────────────────────
|
||||
async function loadDomains() {
|
||||
$btnImportAll.style.display = 'none';
|
||||
showBanner('Loading domains from ' + browser + ' (' + profile + ').', 'info');
|
||||
try {
|
||||
const data = await api('/domains?browser=' + encodeURIComponent(activeBrowser) + '&profile=' + encodeURIComponent(activeProfile));
|
||||
allDomains = data.domains;
|
||||
const data = await api('/domains?browser=' + encodeURIComponent(browser) + '&profile=' + encodeURIComponent(profile));
|
||||
if (selection !== generation) return;
|
||||
allDomains = data.domains || [];
|
||||
renderSourceDomains();
|
||||
} catch (err) {
|
||||
showBanner(err.message, 'error', err.action === 'retry' ? () => loadDomains() : null);
|
||||
showBanner(allDomains.length ? 'Ready to import from ' + browser + ' (' + profile + '). Authentication has not been checked.'
|
||||
: 'No cookie domains found in ' + browser + ' (' + profile + ').', allDomains.length ? 'info' : 'warning');
|
||||
} catch (error) {
|
||||
if (selection !== generation) return;
|
||||
showBanner('Could not read domains from ' + browser + ' (' + profile + '). ' + errorMessage(error), 'error', () => selectProfile(profile));
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load domains</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Render Source Domains ─────────────
|
||||
function renderSourceDomains() {
|
||||
const query = $search.value.toLowerCase();
|
||||
const filtered = query
|
||||
? allDomains.filter(d => d.domain.toLowerCase().includes(query))
|
||||
: allDomains;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">' +
|
||||
(query ? 'No matching domains' : 'No cookie domains found') + '</div>';
|
||||
$sourceFooter.textContent = '';
|
||||
const filtered = allDomains.filter(domain => domain.domain.toLowerCase().includes(query));
|
||||
$btnImportAll.style.display = filtered.length && activeProfile ? '' : 'none';
|
||||
$btnImportAll.disabled = mutation || !activeProfile;
|
||||
$btnImportAll.textContent = 'Import All (' + filtered.length + ')';
|
||||
$sourceFooter.textContent = allDomains.length ? allDomains.length + ' domains · ' + allDomains.reduce((sum, domain) => sum + domain.count, 0).toLocaleString() + ' cookies' : '';
|
||||
if (!filtered.length) {
|
||||
$sourceDomains.innerHTML = '<div class="imported-empty">' + (!activeProfile ? 'Choose a profile to inspect its domains' : query ? 'No matching domains' : 'No cookie domains found') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const d of filtered) {
|
||||
const isImported = d.domain in importedSet;
|
||||
const isInflight = inflight[d.domain];
|
||||
html += '<div class="domain-row">';
|
||||
html += '<span class="domain-name">' + escHtml(d.domain) + '</span>';
|
||||
html += '<span class="domain-count">' + d.count + '</span>';
|
||||
if (isInflight) {
|
||||
html += '<span class="btn-add" disabled><span class="spinner" style="width:12px;height:12px;border-width:1.5px;"></span></span>';
|
||||
} else if (isImported) {
|
||||
html += '<span class="btn-add imported">✓</span>';
|
||||
} else {
|
||||
html += '<button class="btn-add" data-domain="' + escHtml(d.domain) + '" title="Import">+</button>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
$sourceDomains.innerHTML = html;
|
||||
|
||||
// Total counts
|
||||
const totalDomains = allDomains.length;
|
||||
const totalCookies = allDomains.reduce((s, d) => s + d.count, 0);
|
||||
$sourceFooter.textContent = totalDomains + ' domains · ' + totalCookies.toLocaleString() + ' cookies';
|
||||
|
||||
// Show/hide Import All button
|
||||
const unimported = filtered.filter(d => !(d.domain in importedSet) && !inflight[d.domain]);
|
||||
if (unimported.length > 0) {
|
||||
$btnImportAll.style.display = '';
|
||||
$btnImportAll.disabled = false;
|
||||
$btnImportAll.textContent = 'Import All (' + unimported.length + ')';
|
||||
} else {
|
||||
$btnImportAll.style.display = 'none';
|
||||
}
|
||||
|
||||
// Click handlers
|
||||
$sourceDomains.querySelectorAll('.btn-add[data-domain]').forEach(btn => {
|
||||
btn.addEventListener('click', () => importDomain(btn.dataset.domain));
|
||||
$sourceDomains.innerHTML = filtered.map(domain => {
|
||||
const label = (domain.domain in importedSet ? 'Reimport ' : 'Import ') + domain.domain;
|
||||
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 ? '↻' : '+') + '</button></div>';
|
||||
}).join('');
|
||||
$sourceDomains.querySelectorAll('button').forEach(button => {
|
||||
button.onclick = () => importDomains([button.dataset.domain]);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Import Domain ─────────────────────
|
||||
async function importDomain(domain) {
|
||||
if (inflight[domain] || domain in importedSet) return;
|
||||
inflight[domain] = true;
|
||||
function setMutationBusy(busy) {
|
||||
mutation = busy;
|
||||
$pills.querySelectorAll('button').forEach(button => { button.disabled = busy; });
|
||||
$profilePills.querySelectorAll('button').forEach(button => { button.disabled = busy; });
|
||||
$search.disabled = busy;
|
||||
$clearStorage.disabled = busy || !canReset;
|
||||
$verifyAuth.disabled = busy || !canVerify;
|
||||
renderSourceDomains();
|
||||
renderImported();
|
||||
}
|
||||
|
||||
async function importDomains(domains) {
|
||||
if (mutation || !activeBrowser || !activeProfile || !domains.length) return;
|
||||
const request = { browser: activeBrowser, profile: activeProfile, domains: domains.slice(),
|
||||
clearStorage: canReset && $clearStorage.checked, verifyAuth: canVerify && $verifyAuth.checked };
|
||||
setMutationBusy(true);
|
||||
showBanner('Importing from ' + request.browser + ' (' + request.profile + '). Keep this picker open.', 'info');
|
||||
try {
|
||||
const data = await api('/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ browser: activeBrowser, domains: [domain], profile: activeProfile }),
|
||||
});
|
||||
|
||||
if (data.domainCounts) {
|
||||
for (const [d, count] of Object.entries(data.domainCounts)) {
|
||||
importedSet[d] = (importedSet[d] || 0) + count;
|
||||
}
|
||||
const data = await api('/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request) });
|
||||
const imported = Number.isFinite(data.imported) && data.imported > 0 ? data.imported : 0;
|
||||
const failed = Number.isFinite(data.failed) && data.failed > 0 ? data.failed : 0;
|
||||
for (const [domain, count] of Object.entries(data.domainCounts || {})) {
|
||||
if (imported > 0 && Number.isFinite(count) && count > 0) importedSet[domain] = count;
|
||||
}
|
||||
renderImported();
|
||||
} catch (err) {
|
||||
showBanner('Import failed for ' + domain + ': ' + err.message, 'error',
|
||||
err.action === 'retry' ? () => importDomain(domain) : null);
|
||||
const partial = data.outcome === 'partial' || (imported > 0 && failed > 0);
|
||||
let type = data.outcome === 'failed' || data.reset === 'failed' ? 'error' : partial || !imported ? 'warning' : 'info';
|
||||
let message = (data.outcome === 'failed' ? 'Import failed. ' : partial ? 'Partial import. ' : !imported ? 'No cookies imported. ' : 'Cookies imported. ')
|
||||
+ imported + ' imported; ' + failed + ' failed. Source: ' + request.browser + ' (' + request.profile + ').';
|
||||
if (typeof data.message === 'string' && data.message) message += ' ' + data.message;
|
||||
const failureLabels = { unsupported_encryption: 'unsupported encryption', decryption_failed: 'decryption failed', native_unrecovered: 'not recovered by native import' };
|
||||
for (const [reason, count] of Object.entries(data.failureReasons || {})) {
|
||||
if (Object.hasOwn(failureLabels, reason) && Number.isFinite(count) && count > 0) message += ' ' + failureLabels[reason] + ': ' + count + '.';
|
||||
}
|
||||
message += data.reset === 'cleared' ? ' Storage cleared for ' + config.targetOrigin + '.'
|
||||
: data.reset === 'failed' ? ' Storage reset failed; storage may be partially cleared.' : ' Storage preserved.';
|
||||
if (!request.verifyAuth) message += ' Authentication not checked.';
|
||||
else if (imported > 0 && data.verification && data.verification.verified === true) message += ' Authentication verified on the captured target.';
|
||||
else {
|
||||
const reasons = { not_configured: 'identity assertion not configured', no_cookies_imported: 'no cookies imported',
|
||||
identity_missing: 'visible identity missing', identity_ambiguous: 'multiple visible identities', identity_mismatch: 'identity did not match',
|
||||
login_redirect: 'login page detected', target_changed: 'target changed', target_closed: 'target closed',
|
||||
timeout: 'check timed out', http_error: 'unsuccessful HTTP response', reset_failed: 'storage reset failed', application_failed: 'cookie application failed' };
|
||||
const reason = data.verification && data.verification.reason;
|
||||
message += ' Authentication not verified' + (Object.hasOwn(reasons, reason) ? ': ' + reasons[reason] : '') + '.';
|
||||
if (type === 'info') type = 'warning';
|
||||
}
|
||||
showBanner(message, type);
|
||||
} catch (error) {
|
||||
showBanner('Import did not complete for ' + request.browser + ' (' + request.profile + '). ' + errorMessage(error) + ' Authentication was not verified.', 'error');
|
||||
} finally {
|
||||
delete inflight[domain];
|
||||
renderSourceDomains();
|
||||
setMutationBusy(false);
|
||||
if (domains.length === 1) {
|
||||
$sourceDomains.querySelectorAll('button').forEach(button => { if (button.dataset.domain === domains[0]) button.focus(); });
|
||||
} else $btnImportAll.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Import All ───────────────────────
|
||||
async function importAll() {
|
||||
const query = $search.value.toLowerCase();
|
||||
const filtered = query
|
||||
? allDomains.filter(d => d.domain.toLowerCase().includes(query))
|
||||
: allDomains;
|
||||
const toImport = filtered.filter(d => !(d.domain in importedSet) && !inflight[d.domain]);
|
||||
if (toImport.length === 0) return;
|
||||
$btnImportAll.onclick = () => importDomains(allDomains.filter(domain => domain.domain.toLowerCase().includes($search.value.toLowerCase())).map(domain => domain.domain));
|
||||
|
||||
$btnImportAll.disabled = true;
|
||||
$btnImportAll.textContent = 'Importing...';
|
||||
|
||||
const domains = toImport.map(d => d.domain);
|
||||
try {
|
||||
const data = await api('/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ browser: activeBrowser, domains: domains, profile: activeProfile }),
|
||||
});
|
||||
|
||||
if (data.domainCounts) {
|
||||
for (const [d, count] of Object.entries(data.domainCounts)) {
|
||||
importedSet[d] = (importedSet[d] || 0) + count;
|
||||
}
|
||||
}
|
||||
renderImported();
|
||||
} catch (err) {
|
||||
showBanner('Import all failed: ' + err.message, 'error',
|
||||
err.action === 'retry' ? () => importAll() : null);
|
||||
} finally {
|
||||
renderSourceDomains();
|
||||
}
|
||||
}
|
||||
|
||||
$btnImportAll.addEventListener('click', importAll);
|
||||
|
||||
// ─── Render Imported ───────────────────
|
||||
function renderImported() {
|
||||
const entries = Object.entries(importedSet).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
$importedDomains.innerHTML = '<div class="imported-empty">No cookies imported yet</div>';
|
||||
$importedFooter.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const [domain, count] of entries) {
|
||||
const isInflight = inflight['remove:' + domain];
|
||||
html += '<div class="domain-row">';
|
||||
html += '<span class="domain-name">' + escHtml(domain) + '</span>';
|
||||
html += '<span class="domain-count">' + count + '</span>';
|
||||
if (isInflight) {
|
||||
html += '<span class="btn-trash" disabled><span class="spinner" style="width:12px;height:12px;border-width:1.5px;border-top-color:#f87171;"></span></span>';
|
||||
} else {
|
||||
html += '<button class="btn-trash" data-domain="' + escHtml(domain) + '" title="Remove">🗑</button>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
$importedDomains.innerHTML = html;
|
||||
|
||||
const totalCookies = entries.reduce((s, e) => s + e[1], 0);
|
||||
$importedFooter.textContent = entries.length + ' domains · ' + totalCookies.toLocaleString() + ' cookies imported';
|
||||
|
||||
// Click handlers
|
||||
$importedDomains.querySelectorAll('.btn-trash[data-domain]').forEach(btn => {
|
||||
btn.addEventListener('click', () => removeDomain(btn.dataset.domain));
|
||||
});
|
||||
$importedFooter.textContent = entries.length ? entries.length + ' domains · ' + entries.reduce((sum, entry) => sum + entry[1], 0).toLocaleString() + ' cookies imported' : '';
|
||||
$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' : '') + '>🗑</button></div>').join('')
|
||||
: '<div class="imported-empty">No cookies imported yet</div>';
|
||||
$importedDomains.querySelectorAll('button').forEach(button => { button.onclick = () => removeDomain(button.dataset.domain); });
|
||||
}
|
||||
|
||||
// ─── Remove Domain ─────────────────────
|
||||
async function removeDomain(domain) {
|
||||
if (inflight['remove:' + domain]) return;
|
||||
inflight['remove:' + domain] = true;
|
||||
renderImported();
|
||||
|
||||
if (mutation) return;
|
||||
setMutationBusy(true);
|
||||
showBanner('Removing imported cookies for ' + domain + '.', 'info');
|
||||
try {
|
||||
await api('/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domains: [domain] }),
|
||||
});
|
||||
await api('/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domains: [domain] }) });
|
||||
delete importedSet[domain];
|
||||
renderImported();
|
||||
renderSourceDomains(); // update checkmarks
|
||||
} catch (err) {
|
||||
showBanner('Remove failed for ' + domain + ': ' + err.message, 'error',
|
||||
err.action === 'retry' ? () => removeDomain(domain) : null);
|
||||
showBanner('Removed imported cookies for ' + domain + '. Storage was not cleared.', 'info');
|
||||
} catch (error) {
|
||||
showBanner('Cookie removal did not complete. ' + errorMessage(error), 'error');
|
||||
} finally {
|
||||
delete inflight['remove:' + domain];
|
||||
renderImported();
|
||||
setMutationBusy(false);
|
||||
const first = $importedDomains.querySelector('button') || $sourceDomains.querySelector('button');
|
||||
if (first) first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Search ────────────────────────────
|
||||
$search.addEventListener('input', renderSourceDomains);
|
||||
|
||||
// ─── Start ─────────────────────────────
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in new issue
Block a user