From 1c01e334006c05214dda0deb43bda7ab6da559a9 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 19 May 2026 18:40:27 -0400 Subject: [PATCH] Add files via upload --- README.md | 67 ++++ manifest.json | 32 ++ popup.css | 751 +++++++++++++++++++++++++++++++++++ popup.html | 140 +++++++ popup.js | 1031 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 2021 insertions(+) create mode 100644 README.md create mode 100644 manifest.json create mode 100644 popup.css create mode 100644 popup.html create mode 100644 popup.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..6fc6f0f --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Portage + +**Browser Transitioning Tool** by [pvrz](https://github.com/pvrzz) + +Bulk-migrate chrome extensions and cookies between chromium browsers. Opens chrome web store pages in labeled tab groups for extensions, and provides encrypted JSON export/import for cookies. + +``` +github: https://github.com/pvrzz/browserport +``` + +## install (load unpacked) + +1. unzip somewhere stable (chrome points at the folder, not a copy) +2. open `chrome://extensions` +3. enable developer mode (top right) +4. click "load unpacked" and select the folder +5. pin Portage from the puzzle icon + +## features + +**two-pane interface** — top tabs switch between extensions and cookies. each has installed/imported sub-tabs. + +**extensions** — select the ones you want, "open in tab group" opens all their chrome web store install pages in a labeled cyan tab group. drag the group title to detach into its own window. cross-machine works via an HTML export that's also a standalone clickable page. + +**cookies** — grouped by domain, expand to inspect individual cookies (with secure/httpOnly/session flags). export as JSON with optional AES-GCM encryption (passphrase-protected via PBKDF2). import reads the same format and applies cookies back to the browser, preserving security flags. + +## encryption details + +- PBKDF2 with SHA-256, 250,000 iterations +- AES-GCM 256-bit +- 128-bit random salt, 96-bit random IV per export +- format declares algorithm and parameters for forward compatibility +- runs entirely in your browser via Web Crypto API, no library deps + +## permissions + +| permission | reason | +|---|---| +| `management` | list installed extensions | +| `tabs` + `tabGroups` | create the install tab group | +| `downloads` | save export files | +| `cookies` | read and write cookies for migration | +| `` | required for cookies api to access all domains | + +no network access, no remote scripts, no analytics. ~1030 lines across 5 files, all local. + +## file structure + +``` +portage/ +├── manifest.json mv3 manifest +├── popup.html ui structure +├── popup.css styles +├── popup.js logic + crypto +└── icons/ 16/48/128 png +``` + +## known limits + +- popup closes when it loses focus during operations — don't click outside mid-encryption +- cookies file is essentially session tokens. always encrypt for transfer, never send or email; especially when it's unencrypted +- some sites use device fingerprinting or token rebinding that defeats cookie migration. re-login on important services (banking, financial, work portals) +- sideloaded/dev-mode extensions show a "sideload" badge — they won't have web store pages + +## license + +MIT-style: do whatever, attribute if you fork. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..b530178 --- /dev/null +++ b/manifest.json @@ -0,0 +1,32 @@ +{ + "manifest_version": 3, + "name": "Portage", + "version": "1.0.0", + "description": "Portage - Browser Transitioning Tool. Migrate extensions and cookies between chromium browsers with encrypted exports.", + "author": "pvrz", + "homepage_url": "https://github.com/pvrzz/portage", + "permissions": [ + "management", + "tabs", + "tabGroups", + "downloads", + "cookies" + ], + "host_permissions": [ + "" + ], + "action": { + "default_popup": "popup.html", + "default_title": "Portage", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..bd85600 --- /dev/null +++ b/popup.css @@ -0,0 +1,751 @@ +/* + --------------------------------------- + portage by pvrz + github: https://github.com/pvrzz/portage + file name: popup.css + --------------------------------------- +*/ + +:root { + --bg: #0e1014; + --surface: #181b22; + --surface-2: #1f242d; + --border: #2a2f3a; + --border-strong: #3a4150; + --text: #e8eaef; + --text-dim: #a4abbc; + --text-muted: #6b7387; + --accent: #5eead4; + --accent-dim: #2dd4bf; + --accent-bg: rgba(94, 234, 212, 0.08); + --danger: #f87171; + --warn: #fbbf24; + --radius: 8px; + --radius-sm: 5px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + width: 380px; + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, "Segoe UI", "Helvetica Neue", sans-serif; + font-size: 13px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; +} + +.app { + display: flex; + flex-direction: column; + max-height: 600px; +} + +/* + --------------------------------------- + header + --------------------------------------- +*/ + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px 12px; + border-bottom: 1px solid var(--border); +} + +.header-left { + display: flex; + align-items: center; + gap: 10px; +} + +.logo { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dim) 100%); + color: var(--bg); +} + +.title-block h1 { + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.subtitle { + font-size: 11px; + color: var(--text-muted); + margin-top: 1px; +} + +.icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--border); + background: transparent; + border-radius: var(--radius-sm); + color: var(--text-dim); + cursor: pointer; + transition: all 0.15s ease; +} + +.icon-btn:hover { + background: var(--surface); + color: var(--text); + border-color: var(--border-strong); +} + +.icon-btn:active { + transform: scale(0.95); +} + +/* + --------------------------------------- + type tabs and mode tabs + --------------------------------------- +*/ + +.type-tabs { + display: flex; + padding: 8px 12px 0; + gap: 4px; + background: var(--bg); +} + +.type-tab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 0; + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-dim); + font-size: 12px; + font-weight: 500; + cursor: pointer; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + border-bottom: none; + transition: all 0.15s ease; + font-family: inherit; +} + +.type-tab:hover { + color: var(--text); +} + +.type-tab.active { + background: var(--accent-bg); + color: var(--accent); + border-color: var(--accent); +} + +.mode-tabs { + display: flex; + padding: 0 16px; + border-bottom: 1px solid var(--border); + background: var(--bg); +} + +.mode-tab { + flex: 1; + padding: 10px 0; + background: transparent; + border: none; + color: var(--text-muted); + font-size: 12px; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.15s ease; + font-family: inherit; +} + +.mode-tab:hover { + color: var(--text-dim); +} + +.mode-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* + --------------------------------------- + selection bar + --------------------------------------- +*/ + +.selection-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: var(--surface); + border-bottom: 1px solid var(--border); + font-size: 11px; +} + +.count { + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} + +.count span { + color: var(--accent); + font-weight: 600; +} + +.selection-actions { + display: flex; + gap: 4px; +} + +.text-btn { + padding: 4px 8px; + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-dim); + font-size: 11px; + cursor: pointer; + font-family: inherit; + transition: all 0.15s ease; +} + +.text-btn:hover { + background: var(--surface-2); + color: var(--text); + border-color: var(--border-strong); +} + +/* + --------------------------------------- + list and items + --------------------------------------- +*/ + +.list-container { + flex: 1; + overflow-y: auto; + min-height: 200px; + max-height: 380px; +} + +.list-container::-webkit-scrollbar { + width: 8px; +} + +.list-container::-webkit-scrollbar-track { + background: transparent; +} + +.list-container::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.list-container::-webkit-scrollbar-thumb:hover { + background: var(--border-strong); +} + +.ext-list { + list-style: none; +} + +.ext-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background 0.1s ease; +} + +.ext-item:hover { + background: var(--surface); +} + +.ext-item.selected { + background: var(--accent-bg); +} + +.ext-checkbox { + appearance: none; + width: 16px; + height: 16px; + border: 1.5px solid var(--border-strong); + border-radius: 4px; + background: var(--surface-2); + cursor: pointer; + position: relative; + flex-shrink: 0; + transition: all 0.15s ease; +} + +.ext-checkbox:hover { + border-color: var(--accent); +} + +.ext-checkbox:checked { + background: var(--accent); + border-color: var(--accent); +} + +.ext-checkbox:checked::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 5px; + height: 9px; + border: solid var(--bg); + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.ext-icon { + width: 24px; + height: 24px; + border-radius: 4px; + flex-shrink: 0; + background: var(--surface-2); +} + +.ext-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.ext-name { + font-size: 12.5px; + font-weight: 500; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ext-meta { + display: flex; + gap: 8px; + align-items: center; + font-size: 10.5px; + color: var(--text-muted); + font-family: ui-monospace, "SF Mono", "Cascadia Code", monospace; +} + +.ext-id { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 130px; +} + +.badge { + padding: 1px 5px; + border-radius: 3px; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + font-family: system-ui, sans-serif; +} + +.badge-disabled { + background: rgba(248, 113, 113, 0.15); + color: var(--danger); +} + +.badge-sideload { + background: rgba(251, 191, 36, 0.15); + color: var(--warn); +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; + color: var(--text-dim); + gap: 4px; +} + +.empty-state .muted { + color: var(--text-muted); + font-size: 11px; +} + +.hidden { + display: none !important; +} + +/* + --------------------------------------- + footer and action buttons + --------------------------------------- +*/ + +.footer { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 16px; + background: var(--surface); + border-top: 1px solid var(--border); +} + +.primary-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 9px 12px; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: var(--radius-sm); + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + font-family: inherit; + transition: all 0.15s ease; +} + +.primary-btn:hover:not(:disabled) { + background: var(--accent-dim); + transform: translateY(-1px); +} + +.primary-btn:active:not(:disabled) { + transform: translateY(0); +} + +.primary-btn:disabled { + background: var(--surface-2); + color: var(--text-muted); + cursor: not-allowed; +} + +.secondary-actions { + display: flex; + gap: 8px; +} + +.secondary-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 7px 10px; + background: var(--surface-2); + color: var(--text-dim); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 11.5px; + cursor: pointer; + font-family: inherit; + transition: all 0.15s ease; +} + +.secondary-btn:hover { + background: var(--bg); + color: var(--text); + border-color: var(--border-strong); +} + +/* + --------------------------------------- + status toast + --------------------------------------- +*/ + +.status { + position: absolute; + bottom: 70px; + left: 16px; + right: 16px; + padding: 8px 12px; + background: var(--surface-2); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + font-size: 11.5px; + color: var(--text); + text-align: center; + animation: slide-up 0.2s ease; +} + +.status.success { + border-color: var(--accent); + color: var(--accent); +} + +.status.error { + border-color: var(--danger); + color: var(--danger); +} + +@keyframes slide-up { + from { transform: translateY(8px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +/* + --------------------------------------- + cookie domain groups + --------------------------------------- +*/ + +.domain-group { + border-bottom: 1px solid var(--border); +} + +.domain-header { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + cursor: pointer; + background: var(--surface); + transition: background 0.1s ease; +} + +.domain-header:hover { + background: var(--surface-2); +} + +.domain-header.selected { + background: var(--accent-bg); +} + +.domain-chevron { + color: var(--text-muted); + transition: transform 0.15s ease; + flex-shrink: 0; +} + +.domain-chevron.open { + transform: rotate(90deg); +} + +.domain-name { + flex: 1; + font-size: 12.5px; + font-weight: 500; + font-family: ui-monospace, "SF Mono", monospace; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.domain-count { + font-size: 10.5px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; + padding: 2px 6px; + background: var(--surface-2); + border-radius: 4px; +} + +.cookie-list { + background: var(--bg); + list-style: none; +} + +.cookie-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 16px 6px 36px; + border-top: 1px solid var(--border); + font-size: 11px; + font-family: ui-monospace, "SF Mono", monospace; + cursor: pointer; +} + +.cookie-item:hover { + background: var(--surface); +} + +.cookie-name { + color: var(--text); + font-weight: 500; + flex-shrink: 0; + max-width: 40%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cookie-value { + color: var(--text-muted); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.cookie-flags { + display: flex; + gap: 3px; + flex-shrink: 0; +} + +.cookie-flag { + font-size: 8.5px; + padding: 1px 4px; + border-radius: 3px; + background: var(--surface-2); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + font-family: system-ui, sans-serif; + font-weight: 600; +} + +.cookie-flag.secure { + color: var(--accent); + background: rgba(94, 234, 212, 0.1); +} + +.cookie-flag.httponly { + color: #fbbf24; + background: rgba(251, 191, 36, 0.1); +} + +/* + --------------------------------------- + passphrase modal + --------------------------------------- +*/ + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(14, 16, 20, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + animation: fade-in 0.15s ease; +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal { + width: 340px; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + overflow: hidden; + animation: slide-up 0.2s ease; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid var(--border); +} + +.modal-header h2 { + font-size: 13px; + font-weight: 600; +} + +.modal-body { + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.modal-desc { + font-size: 12px; + color: var(--text-dim); + line-height: 1.5; +} + +.modal-input { + width: 100%; + padding: 8px 10px; + background: var(--bg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 13px; + font-family: ui-monospace, monospace; + outline: none; + transition: border-color 0.15s ease; +} + +.modal-input:focus { + border-color: var(--accent); +} + +.modal-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 11.5px; + color: var(--text-dim); + cursor: pointer; + user-select: none; +} + +.modal-checkbox input { + width: 14px; + height: 14px; + accent-color: var(--accent); +} + +.modal-warn { + padding: 8px 10px; + background: rgba(248, 113, 113, 0.08); + border: 1px solid rgba(248, 113, 113, 0.3); + border-radius: var(--radius-sm); + color: var(--danger); + font-size: 11px; + line-height: 1.45; +} + +.modal-actions { + display: flex; + gap: 8px; + padding: 12px 14px; + border-top: 1px solid var(--border); + background: var(--bg); +} + +.modal-actions .secondary-btn, +.modal-actions .primary-btn { + flex: 1; +} diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..ce343ad --- /dev/null +++ b/popup.html @@ -0,0 +1,140 @@ + + + + + + + portage + + +
+
+
+ +
+

portage

+

loading...

+
+
+
+ +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ 0 selected +
+
+ + + +
+
+ +
+
    + +
    + +
    + +
    + + + +
    +
    + + + + + +
    + + + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..e794336 --- /dev/null +++ b/popup.js @@ -0,0 +1,1031 @@ +// --------------------------------------- +// portage by pvrz +// github: https://github.com/pvrzz/portage +// file name: popup.js +// --------------------------------------- + + +// --------------------------------------- +// state +// --------------------------------------- + +const state = { + type: "extensions", + mode: "installed", + selectedIds: new Set(), + expandedDomains: new Set(), + data: { + extensionsInstalled: [], + extensionsImported: [], + cookiesInstalled: {}, + cookiesImported: {}, + }, +}; + + +// --------------------------------------- +// constants +// --------------------------------------- + +const WEBSTORE_BASE = "https://chromewebstore.google.com/detail"; +const SELF_ID = chrome.runtime.id; +const PBKDF2_ITERATIONS = 250000; +const COOKIES_FORMAT = "portage-cookies"; +const COOKIES_VERSION = 1; +const EXTENSIONS_FORMAT = "portage-extensions"; + + +// --------------------------------------- +// dom refs +// --------------------------------------- + +const $ = (id) => document.getElementById(id); +const els = { + list: $("ext-list"), + emptyState: $("empty-state"), + emptyTitle: $("empty-title"), + emptySub: $("empty-sub"), + subtitle: $("subtitle"), + selectedCount: $("selected-count"), + primaryBtn: $("primary-btn"), + primaryLabel: $("primary-label"), + statusEl: $("status"), + importInput: $("import-input"), + modal: $("modal-backdrop"), + modalTitle: $("modal-title"), + modalDesc: $("modal-desc"), + modalPass: $("modal-passphrase"), + modalPassConfirm: $("modal-passphrase-confirm"), + modalSkipWrap: $("modal-skip-encrypt-wrap"), + modalSkip: $("modal-skip-encrypt"), + modalWarn: $("modal-warn"), + modalConfirm: $("modal-confirm"), + modalCancel: $("modal-cancel"), + modalClose: $("modal-close"), +}; + + +// --------------------------------------- +// initialization +// --------------------------------------- + +document.addEventListener("DOMContentLoaded", async () => { + await loadExtensions(); + await loadCookies(); + attachEventListeners(); + render(); +}); + + +// --------------------------------------- +// data loading +// --------------------------------------- + +async function loadExtensions() { + const all = await chrome.management.getAll(); + state.data.extensionsInstalled = all + .filter((ext) => ext.type === "extension" && ext.id !== SELF_ID) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +async function loadCookies() { + const all = await chrome.cookies.getAll({}); + state.data.cookiesInstalled = groupCookiesByDomain(all); +} + +function groupCookiesByDomain(cookies) { + const grouped = {}; + for (const c of cookies) { + const key = c.domain.startsWith(".") ? c.domain.slice(1) : c.domain; + if (!grouped[key]) grouped[key] = []; + grouped[key].push(c); + } + const sorted = {}; + for (const k of Object.keys(grouped).sort()) { + sorted[k] = grouped[k].sort((a, b) => a.name.localeCompare(b.name)); + } + return sorted; +} + + +// --------------------------------------- +// current view helpers +// --------------------------------------- + +function currentList() { + if (state.type === "extensions") { + return state.mode === "installed" + ? state.data.extensionsInstalled + : state.data.extensionsImported; + } else { + return state.mode === "installed" + ? state.data.cookiesInstalled + : state.data.cookiesImported; + } +} + +function currentItemIds() { + const list = currentList(); + if (state.type === "extensions") { + return list.map((e) => e.id); + } else { + return Object.keys(list); + } +} + + +// --------------------------------------- +// render +// --------------------------------------- + +function render() { + updateSubtitle(); + updateTabsActive(); + updatePrimaryButton(); + updateImportAccept(); + + const list = currentList(); + const empty = state.type === "extensions" + ? list.length === 0 + : Object.keys(list).length === 0; + + if (empty) { + els.list.innerHTML = ""; + els.emptyState.classList.remove("hidden"); + updateEmptyState(); + } else { + els.emptyState.classList.add("hidden"); + if (state.type === "extensions") { + renderExtensionList(list); + } else { + renderCookieGroups(list); + } + } + + updateSelectionUi(); +} + +function updateSubtitle() { + const list = currentList(); + if (state.type === "extensions") { + els.subtitle.textContent = `${list.length} ${state.mode === "installed" ? "installed" : "imported"}`; + } else { + const domainCount = Object.keys(list).length; + let cookieCount = 0; + for (const k of Object.keys(list)) cookieCount += list[k].length; + els.subtitle.textContent = `${cookieCount} cookies · ${domainCount} domains`; + } +} + +function updateTabsActive() { + document.querySelectorAll(".type-tab").forEach((tab) => { + tab.classList.toggle("active", tab.dataset.type === state.type); + }); + document.querySelectorAll(".mode-tab").forEach((tab) => { + tab.classList.toggle("active", tab.dataset.mode === state.mode); + }); +} + +function updatePrimaryButton() { + const hasSelection = state.selectedIds.size > 0; + els.primaryBtn.disabled = !hasSelection; + + if (state.type === "extensions") { + els.primaryLabel.textContent = "open in tab group"; + } else if (state.mode === "installed") { + els.primaryLabel.textContent = "export selected"; + } else { + els.primaryLabel.textContent = "apply to browser"; + } +} + +function updateImportAccept() { + els.importInput.accept = state.type === "extensions" ? ".html,.htm" : ".json"; +} + +function updateEmptyState() { + if (state.type === "extensions") { + els.emptyTitle.textContent = state.mode === "imported" ? "no imported extensions" : "no extensions found"; + els.emptySub.textContent = state.mode === "imported" ? "import an html export to see it here" : "you have no installable extensions"; + } else { + els.emptyTitle.textContent = state.mode === "imported" ? "no imported cookies" : "no cookies found"; + els.emptySub.textContent = state.mode === "imported" ? "import a cookies json file to see them here" : "your browser has no cookies stored"; + } +} + +function renderExtensionList(list) { + els.list.innerHTML = ""; + for (const ext of list) { + const li = document.createElement("li"); + li.className = "ext-item"; + li.dataset.id = ext.id; + if (state.selectedIds.has(ext.id)) li.classList.add("selected"); + + const iconUrl = getBestIcon(ext); + const isDisabled = ext.enabled === false; + const isSideload = + ext.installType && ext.installType !== "normal" && ext.installType !== "admin"; + + li.innerHTML = ` + + ${iconUrl ? `` : `
    `} +
    +
    ${escapeHtml(ext.name)}
    +
    + ${escapeHtml(ext.id)} + ${isDisabled ? 'off' : ""} + ${isSideload ? 'sideload' : ""} +
    +
    + `; + + li.addEventListener("click", (e) => { + if (e.target.classList.contains("ext-checkbox")) return; + toggleSelection(ext.id); + }); + li.querySelector(".ext-checkbox").addEventListener("change", () => { + toggleSelection(ext.id); + }); + + els.list.appendChild(li); + } +} + +function renderCookieGroups(grouped) { + els.list.innerHTML = ""; + for (const domain of Object.keys(grouped)) { + const cookies = grouped[domain]; + const isSelected = state.selectedIds.has(domain); + const isExpanded = state.expandedDomains.has(domain); + + const groupLi = document.createElement("li"); + groupLi.className = "domain-group"; + + const header = document.createElement("div"); + header.className = "domain-header"; + if (isSelected) header.classList.add("selected"); + header.dataset.domain = domain; + + header.innerHTML = ` + + + + + ${escapeHtml(domain)} + ${cookies.length} + `; + + header.addEventListener("click", (e) => { + if (e.target.classList.contains("ext-checkbox")) return; + if (e.target.classList.contains("domain-chevron") || e.target.closest(".domain-chevron")) { + toggleDomainExpansion(domain); + } else { + toggleSelection(domain); + } + }); + + header.querySelector(".ext-checkbox").addEventListener("change", () => { + toggleSelection(domain); + }); + + groupLi.appendChild(header); + + if (isExpanded) { + const cookieList = document.createElement("ul"); + cookieList.className = "cookie-list"; + for (const c of cookies) { + const cookieLi = document.createElement("li"); + cookieLi.className = "cookie-item"; + const flags = []; + if (c.secure) flags.push('s'); + if (c.httpOnly) flags.push('h'); + if (c.session) flags.push('session'); + const truncatedValue = c.value.length > 30 ? c.value.slice(0, 30) + "…" : c.value; + cookieLi.innerHTML = ` + ${escapeHtml(c.name)} + ${escapeHtml(truncatedValue)} + ${flags.join("")} + `; + cookieList.appendChild(cookieLi); + } + groupLi.appendChild(cookieList); + } + + els.list.appendChild(groupLi); + } +} + +function getBestIcon(ext) { + if (!ext.icons || ext.icons.length === 0) return null; + return (ext.icons.find((i) => i.size === 48) || ext.icons.find((i) => i.size === 32) || ext.icons[ext.icons.length - 1]).url; +} + + +// --------------------------------------- +// selection +// --------------------------------------- + +function toggleSelection(id) { + if (state.selectedIds.has(id)) { + state.selectedIds.delete(id); + } else { + state.selectedIds.add(id); + } + render(); +} + +function toggleDomainExpansion(domain) { + if (state.expandedDomains.has(domain)) { + state.expandedDomains.delete(domain); + } else { + state.expandedDomains.add(domain); + } + render(); +} + +function selectAll() { + state.selectedIds = new Set(currentItemIds()); + render(); +} + +function selectNone() { + state.selectedIds.clear(); + render(); +} + +function selectInvert() { + const ids = currentItemIds(); + const next = new Set(); + for (const id of ids) { + if (!state.selectedIds.has(id)) next.add(id); + } + state.selectedIds = next; + render(); +} + +function updateSelectionUi() { + els.selectedCount.textContent = state.selectedIds.size; + updatePrimaryButton(); +} + + +// --------------------------------------- +// primary action dispatcher +// --------------------------------------- + +async function handlePrimaryAction() { + if (state.type === "extensions") { + await openSelectedInTabGroup(); + } else if (state.mode === "installed") { + await exportCookies(); + } else { + await applyCookies(); + } +} + + +// --------------------------------------- +// extension tab group action +// --------------------------------------- + +async function openSelectedInTabGroup() { + const list = currentList(); + const selected = list.filter((e) => state.selectedIds.has(e.id)); + if (selected.length === 0) return; + + if (selected.length > 10) { + if (!confirm(`open ${selected.length} tabs at once?`)) return; + } + + showStatus(`opening ${selected.length} tab${selected.length === 1 ? "" : "s"}...`); + + try { + const tabs = []; + for (const ext of selected) { + const tab = await chrome.tabs.create({ + url: `${WEBSTORE_BASE}/${ext.id}`, + active: false, + }); + tabs.push(tab); + } + + const groupId = await chrome.tabs.group({ tabIds: tabs.map((t) => t.id) }); + await chrome.tabGroups.update(groupId, { + title: state.mode === "imported" ? `portage: migrate ${selected.length}` : `portage (${selected.length})`, + color: "cyan", + collapsed: false, + }); + + showStatus(`opened ${tabs.length} tabs in a group`, "success"); + } catch (err) { + console.error(err); + showStatus(`error: ${err.message}`, "error"); + } +} + + +// --------------------------------------- +// export dispatcher +// --------------------------------------- + +function handleExport() { + if (state.type === "extensions") { + exportExtensionsToHtml(); + } else { + exportCookies(); + } +} + + +// --------------------------------------- +// extension export +// --------------------------------------- + +function exportExtensionsToHtml() { + const list = currentList(); + const toExport = state.selectedIds.size > 0 + ? list.filter((e) => state.selectedIds.has(e.id)) + : list; + + if (toExport.length === 0) { + showStatus("nothing to export", "error"); + return; + } + + const exportData = toExport.map((ext) => ({ + id: ext.id, + name: ext.name, + version: ext.version || "", + enabled: ext.enabled !== false, + homepageUrl: ext.homepageUrl || "", + })); + + const html = buildExportHtml(exportData); + downloadBlob(new Blob([html], { type: "text/html" }), `portage-extensions-${dateStamp()}.html`); + showStatus(`exported ${toExport.length} extensions`, "success"); +} + +function buildExportHtml(data) { + const jsonBlob = JSON.stringify({ + type: EXTENSIONS_FORMAT, + version: 1, + exportedAt: new Date().toISOString(), + extensions: data, + }, null, 2); + + const rows = data.map((ext, i) => ` + + ${i + 1} + ${escapeHtml(ext.name)} + ${escapeHtml(ext.id)} + ${escapeHtml(ext.version)} + ${ext.enabled ? "" : "off"} + install → + + `).join(""); + + return ` + + + +portage export + + + +
    +

    portage export

    +

    ${data.length} extension${data.length === 1 ? "" : "s"} · exported ${new Date().toLocaleString()}

    +
    + + + + re-import this file in portage to use the tab-group flow +
    + + + ${rows} +
    #nameidversionstate
    + +
    + + + +`; +} + + +// --------------------------------------- +// cookie export +// --------------------------------------- + +async function exportCookies() { + if (state.selectedIds.size === 0) { + showStatus("select at least one domain", "error"); + return; + } + + const grouped = state.data.cookiesInstalled; + const collected = []; + for (const domain of state.selectedIds) { + if (grouped[domain]) collected.push(...grouped[domain]); + } + + if (collected.length === 0) { + showStatus("no cookies in selection", "error"); + return; + } + + openModal({ + title: "encrypt cookies?", + description: `${collected.length} cookies from ${state.selectedIds.size} domain${state.selectedIds.size === 1 ? "" : "s"} will be exported. encryption is strongly recommended — this file contains your login sessions.`, + mode: "encrypt", + onConfirm: async ({ passphrase, skip }) => { + const payload = { + type: COOKIES_FORMAT, + version: COOKIES_VERSION, + exportedAt: new Date().toISOString(), + encrypted: !skip, + }; + + if (skip) { + payload.cookies = collected; + } else { + try { + const encrypted = await encryptJson({ cookies: collected }, passphrase); + Object.assign(payload, encrypted); + } catch (err) { + showStatus(`encryption failed: ${err.message}`, "error"); + return; + } + } + + const json = JSON.stringify(payload, null, 2); + downloadBlob(new Blob([json], { type: "application/json" }), `portage-cookies-${dateStamp()}.json`); + showStatus(`exported ${collected.length} cookies${skip ? " (unencrypted)" : ""}`, "success"); + }, + }); +} + + +// --------------------------------------- +// import dispatcher +// --------------------------------------- + +function handleImport(file) { + if (state.type === "extensions") { + importExtensionsFromHtml(file); + } else { + importCookiesFromJson(file); + } +} + + +// --------------------------------------- +// extension import +// --------------------------------------- + +function importExtensionsFromHtml(file) { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const html = e.target.result; + const match = html.match(/