mirror of
https://github.com/pvrzz/Portage.git
synced 2026-08-12 03:46:30 +00:00
1032 lines
31 KiB
JavaScript
1032 lines
31 KiB
JavaScript
// ---------------------------------------
|
|
// 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 = `
|
|
<input type="checkbox" class="ext-checkbox" ${state.selectedIds.has(ext.id) ? "checked" : ""}>
|
|
${iconUrl ? `<img class="ext-icon" src="${escapeAttr(iconUrl)}" alt="">` : `<div class="ext-icon"></div>`}
|
|
<div class="ext-info">
|
|
<div class="ext-name">${escapeHtml(ext.name)}</div>
|
|
<div class="ext-meta">
|
|
<span class="ext-id" title="${escapeAttr(ext.id)}">${escapeHtml(ext.id)}</span>
|
|
${isDisabled ? '<span class="badge badge-disabled">off</span>' : ""}
|
|
${isSideload ? '<span class="badge badge-sideload">sideload</span>' : ""}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<input type="checkbox" class="ext-checkbox" ${isSelected ? "checked" : ""}>
|
|
<svg class="domain-chevron ${isExpanded ? "open" : ""}" viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M9 18l6-6-6-6"/>
|
|
</svg>
|
|
<span class="domain-name" title="${escapeAttr(domain)}">${escapeHtml(domain)}</span>
|
|
<span class="domain-count">${cookies.length}</span>
|
|
`;
|
|
|
|
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('<span class="cookie-flag secure">s</span>');
|
|
if (c.httpOnly) flags.push('<span class="cookie-flag httponly">h</span>');
|
|
if (c.session) flags.push('<span class="cookie-flag">session</span>');
|
|
const truncatedValue = c.value.length > 30 ? c.value.slice(0, 30) + "…" : c.value;
|
|
cookieLi.innerHTML = `
|
|
<span class="cookie-name" title="${escapeAttr(c.name)}">${escapeHtml(c.name)}</span>
|
|
<span class="cookie-value" title="${escapeAttr(c.value)}">${escapeHtml(truncatedValue)}</span>
|
|
<span class="cookie-flags">${flags.join("")}</span>
|
|
`;
|
|
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) => `
|
|
<tr>
|
|
<td class="num">${i + 1}</td>
|
|
<td class="name">${escapeHtml(ext.name)}</td>
|
|
<td class="id"><code>${escapeHtml(ext.id)}</code></td>
|
|
<td class="version">${escapeHtml(ext.version)}</td>
|
|
<td class="status">${ext.enabled ? "" : "off"}</td>
|
|
<td class="link"><a href="${WEBSTORE_BASE}/${escapeAttr(ext.id)}" target="_blank" rel="noopener">install →</a></td>
|
|
</tr>
|
|
`).join("");
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>portage export</title>
|
|
<style>
|
|
:root { --bg:#0e1014;--surface:#181b22;--border:#2a2f3a;--text:#e8eaef;--dim:#a4abbc;--muted:#6b7387;--accent:#5eead4; }
|
|
* { margin:0;padding:0;box-sizing:border-box; }
|
|
body { background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;line-height:1.5;padding:32px 24px;min-height:100vh; }
|
|
.container { max-width:900px;margin:0 auto; }
|
|
h1 { font-size:22px;font-weight:600;margin-bottom:4px; }
|
|
.meta { color:var(--muted);font-size:13px;margin-bottom:24px; }
|
|
.toolbar { display:flex;gap:12px;margin-bottom:16px;padding:12px;background:var(--surface);border:1px solid var(--border);border-radius:8px; }
|
|
button, .btn { padding:8px 14px;background:var(--accent);color:var(--bg);border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;text-decoration:none;display:inline-block; }
|
|
button:hover, .btn:hover { opacity:0.9; }
|
|
.btn-secondary { background:transparent;color:var(--dim);border:1px solid var(--border); }
|
|
table { width:100%;border-collapse:collapse;background:var(--surface);border:1px solid var(--border);border-radius:8px;overflow:hidden; }
|
|
th, td { padding:10px 12px;text-align:left;font-size:13px; }
|
|
th { background:var(--bg);color:var(--dim);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid var(--border); }
|
|
tr { border-bottom:1px solid var(--border); }
|
|
tr:last-child { border-bottom:none; }
|
|
tr:hover td { background:rgba(94,234,212,0.04); }
|
|
.num { color:var(--muted);width:32px; }
|
|
.name { font-weight:500; }
|
|
code { font-family:ui-monospace,monospace;font-size:11.5px;color:var(--dim); }
|
|
.version { color:var(--muted);font-size:12px; }
|
|
.status { color:#f87171;font-size:11px; }
|
|
a { color:var(--accent);text-decoration:none; }
|
|
a:hover { text-decoration:underline; }
|
|
.footer { margin-top:24px;color:var(--muted);font-size:11px;text-align:center; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>portage export</h1>
|
|
<p class="meta">${data.length} extension${data.length === 1 ? "" : "s"} · exported ${new Date().toLocaleString()}</p>
|
|
<div class="toolbar">
|
|
<button onclick="openAll()">open all install pages</button>
|
|
<button class="btn-secondary" onclick="copyJson()">copy json</button>
|
|
<span style="flex:1"></span>
|
|
<span style="color:var(--muted);font-size:12px;align-self:center;">re-import this file in portage to use the tab-group flow</span>
|
|
</div>
|
|
<table>
|
|
<thead><tr><th>#</th><th>name</th><th>id</th><th>version</th><th>state</th><th></th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
<div class="footer">generated by portage by pvrz</div>
|
|
</div>
|
|
<script id="portage-payload" type="application/json">
|
|
${jsonBlob}
|
|
</script>
|
|
<script>
|
|
function openAll() {
|
|
const ext = JSON.parse(document.getElementById("portage-payload").textContent);
|
|
if (!confirm("open " + ext.extensions.length + " tabs?")) return;
|
|
ext.extensions.forEach(e => window.open("${WEBSTORE_BASE}/" + e.id, "_blank"));
|
|
}
|
|
function copyJson() {
|
|
const t = document.getElementById("portage-payload").textContent.trim();
|
|
navigator.clipboard.writeText(t).then(() => alert("copied"));
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
|
|
// ---------------------------------------
|
|
// 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(/<script id="portage-payload"[^>]*>([\s\S]*?)<\/script>/) ||
|
|
html.match(/<script id="ext-migrator-payload"[^>]*>([\s\S]*?)<\/script>/);
|
|
if (!match) {
|
|
showStatus("not a valid portage export file", "error");
|
|
return;
|
|
}
|
|
const data = JSON.parse(match[1]);
|
|
if (!data.extensions || !Array.isArray(data.extensions)) {
|
|
showStatus("invalid file format", "error");
|
|
return;
|
|
}
|
|
state.data.extensionsImported = data.extensions;
|
|
state.type = "extensions";
|
|
state.mode = "imported";
|
|
state.selectedIds = new Set(data.extensions.map((e) => e.id));
|
|
render();
|
|
showStatus(`imported ${data.extensions.length} extensions`, "success");
|
|
} catch (err) {
|
|
showStatus(`parse error: ${err.message}`, "error");
|
|
}
|
|
};
|
|
reader.onerror = () => showStatus("failed to read file", "error");
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
|
|
// ---------------------------------------
|
|
// cookie import
|
|
// ---------------------------------------
|
|
|
|
function importCookiesFromJson(file) {
|
|
const reader = new FileReader();
|
|
reader.onload = async (e) => {
|
|
try {
|
|
const payload = JSON.parse(e.target.result);
|
|
if (payload.type !== COOKIES_FORMAT && payload.type !== "extension-migrator-cookies") {
|
|
showStatus("not a valid cookies export file", "error");
|
|
return;
|
|
}
|
|
|
|
if (payload.encrypted) {
|
|
openModal({
|
|
title: "decrypt cookies",
|
|
description: "this file is encrypted. enter the passphrase used to export it.",
|
|
mode: "decrypt",
|
|
onConfirm: async ({ passphrase }) => {
|
|
try {
|
|
const decrypted = await decryptJson(payload, passphrase);
|
|
if (!decrypted.cookies || !Array.isArray(decrypted.cookies)) {
|
|
showStatus("decrypted but invalid format", "error");
|
|
return;
|
|
}
|
|
loadImportedCookies(decrypted.cookies);
|
|
} catch (err) {
|
|
showStatus("decryption failed — wrong passphrase?", "error");
|
|
}
|
|
},
|
|
});
|
|
} else {
|
|
if (!Array.isArray(payload.cookies)) {
|
|
showStatus("invalid file format", "error");
|
|
return;
|
|
}
|
|
loadImportedCookies(payload.cookies);
|
|
}
|
|
} catch (err) {
|
|
showStatus(`parse error: ${err.message}`, "error");
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
function loadImportedCookies(cookies) {
|
|
state.data.cookiesImported = groupCookiesByDomain(cookies);
|
|
state.type = "cookies";
|
|
state.mode = "imported";
|
|
state.selectedIds = new Set(Object.keys(state.data.cookiesImported));
|
|
render();
|
|
showStatus(`imported ${cookies.length} cookies from ${state.selectedIds.size} domains`, "success");
|
|
}
|
|
|
|
|
|
// ---------------------------------------
|
|
// apply cookies to browser
|
|
// ---------------------------------------
|
|
|
|
async function applyCookies() {
|
|
const grouped = state.data.cookiesImported;
|
|
const cookies = [];
|
|
for (const domain of state.selectedIds) {
|
|
if (grouped[domain]) cookies.push(...grouped[domain]);
|
|
}
|
|
|
|
if (cookies.length === 0) {
|
|
showStatus("no cookies in selection", "error");
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`apply ${cookies.length} cookies to the current browser? this will overwrite existing cookies with the same name+domain+path.`)) {
|
|
return;
|
|
}
|
|
|
|
showStatus(`applying ${cookies.length} cookies...`);
|
|
|
|
let success = 0;
|
|
let failed = 0;
|
|
const errors = [];
|
|
|
|
for (const cookie of cookies) {
|
|
try {
|
|
await setCookie(cookie);
|
|
success++;
|
|
} catch (err) {
|
|
failed++;
|
|
if (errors.length < 5) errors.push(`${cookie.domain}/${cookie.name}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
if (failed === 0) {
|
|
showStatus(`applied ${success} cookies`, "success");
|
|
} else {
|
|
console.warn("failed cookies:", errors);
|
|
showStatus(`applied ${success}, failed ${failed} (see console)`, failed > success ? "error" : "");
|
|
}
|
|
}
|
|
|
|
async function setCookie(cookie) {
|
|
const domain = cookie.domain.startsWith(".") ? cookie.domain.slice(1) : cookie.domain;
|
|
const protocol = cookie.secure ? "https" : "http";
|
|
const url = `${protocol}://${domain}${cookie.path || "/"}`;
|
|
|
|
const details = {
|
|
url,
|
|
name: cookie.name,
|
|
value: cookie.value,
|
|
path: cookie.path || "/",
|
|
secure: !!cookie.secure,
|
|
httpOnly: !!cookie.httpOnly,
|
|
};
|
|
|
|
if (!cookie.hostOnly) {
|
|
details.domain = cookie.domain;
|
|
}
|
|
|
|
const validSameSite = ["no_restriction", "lax", "strict", "unspecified"];
|
|
if (cookie.sameSite && validSameSite.includes(cookie.sameSite)) {
|
|
details.sameSite = cookie.sameSite;
|
|
}
|
|
|
|
if (!cookie.session && cookie.expirationDate) {
|
|
if (cookie.expirationDate * 1000 < Date.now()) {
|
|
throw new Error("expired");
|
|
}
|
|
details.expirationDate = cookie.expirationDate;
|
|
}
|
|
|
|
if (details.sameSite === "no_restriction" && !details.secure) {
|
|
details.secure = true;
|
|
}
|
|
|
|
await chrome.cookies.set(details);
|
|
}
|
|
|
|
|
|
// ---------------------------------------
|
|
// crypto (web crypto api)
|
|
// ---------------------------------------
|
|
|
|
async function deriveKey(passphrase, salt, iterations = PBKDF2_ITERATIONS) {
|
|
const enc = new TextEncoder();
|
|
const baseKey = await crypto.subtle.importKey(
|
|
"raw",
|
|
enc.encode(passphrase),
|
|
{ name: "PBKDF2" },
|
|
false,
|
|
["deriveKey"]
|
|
);
|
|
return crypto.subtle.deriveKey(
|
|
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
|
|
baseKey,
|
|
{ name: "AES-GCM", length: 256 },
|
|
false,
|
|
["encrypt", "decrypt"]
|
|
);
|
|
}
|
|
|
|
async function encryptJson(obj, passphrase) {
|
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const key = await deriveKey(passphrase, salt);
|
|
const plaintext = new TextEncoder().encode(JSON.stringify(obj));
|
|
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
|
|
return {
|
|
kdf: "PBKDF2-SHA256",
|
|
iterations: PBKDF2_ITERATIONS,
|
|
salt: bufToBase64(salt),
|
|
iv: bufToBase64(iv),
|
|
ciphertext: bufToBase64(new Uint8Array(ciphertext)),
|
|
};
|
|
}
|
|
|
|
async function decryptJson(payload, passphrase) {
|
|
const salt = base64ToBuf(payload.salt);
|
|
const iv = base64ToBuf(payload.iv);
|
|
const ciphertext = base64ToBuf(payload.ciphertext);
|
|
const iterations = payload.iterations || PBKDF2_ITERATIONS;
|
|
const key = await deriveKey(passphrase, salt, iterations);
|
|
const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
|
|
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
}
|
|
|
|
function bufToBase64(buf) {
|
|
let s = "";
|
|
for (let i = 0; i < buf.length; i++) s += String.fromCharCode(buf[i]);
|
|
return btoa(s);
|
|
}
|
|
|
|
function base64ToBuf(b64) {
|
|
const bin = atob(b64);
|
|
const buf = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
|
return buf;
|
|
}
|
|
|
|
|
|
// ---------------------------------------
|
|
// modal
|
|
// ---------------------------------------
|
|
|
|
let modalCallback = null;
|
|
|
|
function openModal({ title, description, mode, onConfirm }) {
|
|
els.modalTitle.textContent = title;
|
|
els.modalDesc.textContent = description;
|
|
els.modalPass.value = "";
|
|
els.modalPassConfirm.value = "";
|
|
els.modalSkip.checked = false;
|
|
els.modalWarn.classList.add("hidden");
|
|
|
|
if (mode === "encrypt") {
|
|
els.modalPassConfirm.classList.remove("hidden");
|
|
els.modalSkipWrap.classList.remove("hidden");
|
|
} else {
|
|
els.modalPassConfirm.classList.add("hidden");
|
|
els.modalSkipWrap.classList.add("hidden");
|
|
}
|
|
|
|
modalCallback = { mode, onConfirm };
|
|
els.modal.classList.remove("hidden");
|
|
setTimeout(() => els.modalPass.focus(), 50);
|
|
}
|
|
|
|
function closeModal() {
|
|
els.modal.classList.add("hidden");
|
|
modalCallback = null;
|
|
}
|
|
|
|
async function confirmModal() {
|
|
if (!modalCallback) return;
|
|
const { mode, onConfirm } = modalCallback;
|
|
|
|
if (mode === "encrypt" && els.modalSkip.checked) {
|
|
if (!confirm("export cookies as plain json? anyone with this file can use your sessions.")) return;
|
|
closeModal();
|
|
onConfirm({ passphrase: null, skip: true });
|
|
return;
|
|
}
|
|
|
|
const passphrase = els.modalPass.value;
|
|
if (!passphrase || passphrase.length < 8) {
|
|
showModalWarn("passphrase must be at least 8 characters");
|
|
return;
|
|
}
|
|
|
|
if (mode === "encrypt") {
|
|
if (passphrase !== els.modalPassConfirm.value) {
|
|
showModalWarn("passphrases don't match");
|
|
return;
|
|
}
|
|
}
|
|
|
|
closeModal();
|
|
onConfirm({ passphrase, skip: false });
|
|
}
|
|
|
|
function showModalWarn(message) {
|
|
els.modalWarn.textContent = message;
|
|
els.modalWarn.classList.remove("hidden");
|
|
}
|
|
|
|
|
|
// ---------------------------------------
|
|
// status toast
|
|
// ---------------------------------------
|
|
|
|
let statusTimer = null;
|
|
|
|
function showStatus(message, type = "") {
|
|
els.statusEl.textContent = message;
|
|
els.statusEl.className = `status ${type}`;
|
|
els.statusEl.classList.remove("hidden");
|
|
if (statusTimer) clearTimeout(statusTimer);
|
|
statusTimer = setTimeout(() => els.statusEl.classList.add("hidden"), 3000);
|
|
}
|
|
|
|
|
|
// ---------------------------------------
|
|
// utils
|
|
// ---------------------------------------
|
|
|
|
function dateStamp() {
|
|
return new Date().toISOString().split("T")[0];
|
|
}
|
|
|
|
function downloadBlob(blob, filename) {
|
|
const url = URL.createObjectURL(blob);
|
|
chrome.downloads.download({ url, filename, saveAs: true }, () => {
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
});
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
}
|
|
|
|
function escapeAttr(s) {
|
|
return String(s).replace(/"/g, """).replace(/'/g, "'");
|
|
}
|
|
|
|
|
|
// ---------------------------------------
|
|
// tab switching
|
|
// ---------------------------------------
|
|
|
|
function switchType(type) {
|
|
if (state.type === type) return;
|
|
state.type = type;
|
|
state.selectedIds.clear();
|
|
state.expandedDomains.clear();
|
|
render();
|
|
}
|
|
|
|
function switchMode(mode) {
|
|
if (state.mode === mode) return;
|
|
state.mode = mode;
|
|
state.selectedIds.clear();
|
|
state.expandedDomains.clear();
|
|
render();
|
|
}
|
|
|
|
|
|
// ---------------------------------------
|
|
// event listeners
|
|
// ---------------------------------------
|
|
|
|
function attachEventListeners() {
|
|
$("refresh-btn").addEventListener("click", async () => {
|
|
if (state.type === "extensions") {
|
|
await loadExtensions();
|
|
} else {
|
|
await loadCookies();
|
|
}
|
|
if (state.mode === "installed") {
|
|
state.selectedIds.clear();
|
|
}
|
|
render();
|
|
showStatus("refreshed", "success");
|
|
});
|
|
|
|
$("select-all").addEventListener("click", selectAll);
|
|
$("select-none").addEventListener("click", selectNone);
|
|
$("select-invert").addEventListener("click", selectInvert);
|
|
|
|
els.primaryBtn.addEventListener("click", handlePrimaryAction);
|
|
$("export-btn").addEventListener("click", handleExport);
|
|
|
|
$("import-btn").addEventListener("click", () => els.importInput.click());
|
|
els.importInput.addEventListener("change", (e) => {
|
|
const file = e.target.files[0];
|
|
if (file) handleImport(file);
|
|
e.target.value = "";
|
|
});
|
|
|
|
document.querySelectorAll(".type-tab").forEach((tab) => {
|
|
tab.addEventListener("click", () => switchType(tab.dataset.type));
|
|
});
|
|
document.querySelectorAll(".mode-tab").forEach((tab) => {
|
|
tab.addEventListener("click", () => switchMode(tab.dataset.mode));
|
|
});
|
|
|
|
els.modalConfirm.addEventListener("click", confirmModal);
|
|
els.modalCancel.addEventListener("click", closeModal);
|
|
els.modalClose.addEventListener("click", closeModal);
|
|
els.modal.addEventListener("click", (e) => {
|
|
if (e.target === els.modal) closeModal();
|
|
});
|
|
[els.modalPass, els.modalPassConfirm].forEach((input) => {
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") confirmModal();
|
|
if (e.key === "Escape") closeModal();
|
|
});
|
|
});
|
|
}
|