A universal browser data swap — any browser to any browser
+
+ This browser
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Scan this browser, then carry your data across
+
Portage gathers your bookmarks, history, cookies, tabs, sessions and reading list into one portable JSON bundle, and writes your extension list to a separate checklist file. Because it's just JSON, you can import it into any browser running Portage — Chrome ↔ Firefox, or the same browser on a new machine or profile. Nothing leaves your machine, and the data is wiped from memory the moment you export.
+
+
+
+
+
+
+
+
+
+
+
Encrypt this bundle Recommended
+
AES-256-GCM with PBKDF2. The file is unreadable on disk without your passphrase.
+
+
+
+
+
+
+
+
+
+
+
+
+ —
+
+
+
+
+
+
+
+
+
+
+ There is no recovery. If you lose this passphrase, nobody — including you — can open the bundle.
+
+
+
+
+
+
Preparing…0%
+
+
+
+
+
0Total items
+
0Migratable
+
0 KBBundle size
+
0Selected
+
+
+
Your data
+
+
+
+
+
+
Your bundle can contain cookies and session tokens
+
Keep encryption on (above) so the .portage.json is unreadable on disk. Portage holds your data only in this page's memory and wipes it after export — it's never written to extension storage. Either way, delete the file once you've imported.
+
+
+
+
+
+
+
+
+
+
+
Drop a .portage.json bundle here
+
…or click to choose a bundle exported from any browser — Chrome, Firefox, or the same one. Encrypted bundles will ask for your passphrase.
+
+
+
+
+
+
+
+
Bundle loaded
+
+
+
+
+
+
+
+
+
+
Importing…0%
+
+
+
+
+
0In bundle
+
0Selected
+
0Imported
+
0Skipped / failed
+
+
+
What's in this bundle
+
+
+
+
+
+
+
+
+
+
Ready to export 0 items across 0 categories.
+
+
+
+
+
+
+
+
+
diff --git a/chrome/dashboard.js b/chrome/dashboard.js
new file mode 100644
index 0000000..385bd3d
--- /dev/null
+++ b/chrome/dashboard.js
@@ -0,0 +1,637 @@
+/*
+Portage by pvrz
+https://github.com/pvrzz/Portage/
+File Name: dashboard.js
+*/
+
+const api = typeof browser !== "undefined" ? browser : chrome;
+const IS_FIREFOX = navigator.userAgent.includes("Firefox");
+const SOURCE = IS_FIREFOX ? "firefox" : "chrome";
+const SOURCE_LABEL = IS_FIREFOX ? "Firefox" : "Chrome";
+const HAS_READINGLIST = typeof api.readingList !== "undefined" && typeof api.readingList?.query === "function";
+
+document.getElementById("srcBadge").textContent = SOURCE_LABEL;
+document.getElementById("exHeroTitle").textContent = `Scan ${SOURCE_LABEL}, then carry your data across`;
+document.getElementById("scanLabel").textContent = `Scan ${SOURCE_LABEL}`;
+
+const SVG = {
+ bookmarks: '',
+ history: '',
+ cookies: '',
+ tabs: '',
+ sessions: '',
+ reading: '',
+ extensions: '',
+ check: '',
+ arrow: '',
+ download: '',
+ lock: '',
+ eye: '',
+};
+const iconSvg = (i) => ``;
+const tick = () => new Promise((r) => setTimeout(r, 0));
+const IMPORTABLE = /^(https?|ftp):/i;
+
+function saveFile(filename, text, mime) {
+ const url = URL.createObjectURL(new Blob([text], { type: mime }));
+ const a = document.createElement("a");
+ a.href = url; a.download = filename;
+ document.body.appendChild(a); a.click(); a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
+}
+function humanSize(b) {
+ if (b < 1024) return b + " B";
+ if (b < 1048576) return (b / 1024).toFixed(0) + " KB";
+ return (b / 1048576).toFixed(1) + " MB";
+}
+function stamp() {
+ const d = new Date(), p = (n) => String(n).padStart(2, "0");
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
+}
+function countBookmarks(tree) {
+ let n = 0; (function w(a) { for (const x of a) { if (x.url) n++; if (x.children) w(x.children); } })(tree); return n;
+}
+function countSessions(list) {
+ let n = 0; for (const s of list) n += s.type === "window" ? (s.tabs?.length || 0) : 1; return n;
+}
+function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (m) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[m])); }
+function extensionsTxt(list) {
+ const L = [];
+ L.push("Portage — extension reinstall checklist");
+ L.push(`Exported from ${SOURCE_LABEL} · ${new Date().toLocaleString()} · ${list.length} extensions`);
+ L.push("");
+ L.push("Tip: paste this whole list into an AI assistant and ask it to find each one as an");
+ L.push("add-on for your new browser — or search the names yourself with the links below.");
+ L.push("");
+ list.forEach((e, i) => {
+ L.push(`${i + 1}. ${e.name} (v${e.version || "?"})`);
+ if (e.description) L.push(` ${e.description}`);
+ if (e.homepageUrl) L.push(` Home: ${e.homepageUrl}`);
+ L.push(` Firefox: https://addons.mozilla.org/firefox/search/?q=${encodeURIComponent(e.name)}`);
+ L.push(` Chrome: https://chromewebstore.google.com/search/${encodeURIComponent(e.name)}`);
+ L.push("");
+ });
+ return L.join("\n");
+}
+function toast(msg, icon) {
+ const wrap = document.getElementById("toasts");
+ const el = document.createElement("div");
+ el.className = "toast";
+ el.innerHTML = iconSvg(SVG[icon] || SVG.check) + `${msg}`;
+ wrap.appendChild(el);
+ setTimeout(() => { el.style.transition = "opacity .3s, transform .3s"; el.style.opacity = "0"; el.style.transform = "translateX(20px)"; }, 3400);
+ setTimeout(() => el.remove(), 3800);
+}
+
+function bufToB64(buf) {
+ const bytes = new Uint8Array(buf); let bin = ""; const chunk = 0x8000;
+ for (let i = 0; i < bytes.length; i += chunk) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
+ return btoa(bin);
+}
+function b64ToBuf(b64) {
+ const bin = atob(b64); const len = bin.length; const bytes = new Uint8Array(len);
+ for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i);
+ return bytes.buffer;
+}
+async function deriveKey(passphrase, salt, iterations) {
+ const baseKey = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase), "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 encryptBundle(obj, passphrase) {
+ const iterations = 250000;
+ const salt = crypto.getRandomValues(new Uint8Array(16));
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const key = await deriveKey(passphrase, salt, iterations);
+ const plaintext = new TextEncoder().encode(JSON.stringify({ meta: obj.meta, data: obj.data }));
+ const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
+ return {
+ portage: {
+ format: "portage-bundle-encrypted", version: 1,
+ source: obj.portage.source, generator: obj.portage.generator, exportedAt: obj.portage.exportedAt,
+ cipher: "AES-GCM", keyLength: 256, kdf: "PBKDF2", hash: "SHA-256", iterations,
+ salt: bufToB64(salt), iv: bufToB64(iv),
+ },
+ ciphertext: bufToB64(ct),
+ };
+}
+async function decryptBundle(wrapper, passphrase) {
+ const p = wrapper.portage;
+ const salt = new Uint8Array(b64ToBuf(p.salt));
+ const iv = new Uint8Array(b64ToBuf(p.iv));
+ const key = await deriveKey(passphrase, salt, p.iterations || 250000);
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, b64ToBuf(wrapper.ciphertext));
+ const inner = JSON.parse(new TextDecoder().decode(pt));
+ return { portage: { format: "portage-bundle", version: 1, source: p.source, generator: p.generator, exportedAt: p.exportedAt }, meta: inner.meta, data: inner.data };
+}
+function passStrength(p) {
+ if (!p) return { pct: 0, label: "—" };
+ let s = Math.min(p.length * 4, 40);
+ if (/[a-z]/.test(p)) s += 10;
+ if (/[A-Z]/.test(p)) s += 10;
+ if (/[0-9]/.test(p)) s += 10;
+ if (/[^A-Za-z0-9]/.test(p)) s += 15;
+ if (p.length >= 16) s += 15;
+ s = Math.min(s, 100);
+ return { pct: s, label: s < 35 ? "Weak" : s < 60 ? "Fair" : s < 80 ? "Good" : "Strong" };
+}
+
+async function collectBookmarks() { const tree = await api.bookmarks.getTree(); return { data: tree, count: countBookmarks(tree) }; }
+async function collectHistory() {
+ const items = await api.history.search({ text: "", startTime: 0, maxResults: 1000000 });
+ const data = items.map((i) => ({ url: i.url, title: i.title || "", lastVisitTime: i.lastVisitTime || 0, visitCount: i.visitCount || 0, typedCount: i.typedCount || 0 }));
+ return { data, count: data.length };
+}
+async function collectCookies() {
+ const all = await api.cookies.getAll({});
+ const data = all.map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path, secure: c.secure, httpOnly: c.httpOnly, sameSite: c.sameSite, expirationDate: c.expirationDate, hostOnly: c.hostOnly, session: c.session }));
+ return { data, count: data.length };
+}
+async function collectTabs() {
+ const tabs = await api.tabs.query({});
+ const data = tabs.map((t) => ({ url: t.url || t.pendingUrl, title: t.title || "", pinned: t.pinned, index: t.index, windowId: t.windowId })).filter((t) => t.url);
+ return { data, count: data.length };
+}
+async function collectSessions() {
+ const s = await api.sessions.getRecentlyClosed({ maxResults: 25 });
+ const data = s.map((x) => {
+ if (x.tab) return { type: "tab", url: x.tab.url, title: x.tab.title || "" };
+ if (x.window) return { type: "window", tabs: (x.window.tabs || []).map((t) => ({ url: t.url, title: t.title || "" })) };
+ return null;
+ }).filter(Boolean);
+ return { data, count: countSessions(data) };
+}
+async function collectReadingList() {
+ const items = await api.readingList.query({});
+ const data = items.map((i) => ({ url: i.url, title: i.title || "", hasBeenRead: i.hasBeenRead, creationTime: i.creationTime, lastUpdateTime: i.lastUpdateTime }));
+ return { data, count: data.length };
+}
+async function collectExtensions() {
+ const all = await api.management.getAll();
+ const data = all.filter((e) => e.type === "extension" && e.id !== api.runtime.id)
+ .map((e) => ({ id: e.id, name: e.name, version: e.version, enabled: e.enabled, description: e.description, homepageUrl: e.homepageUrl, installType: e.installType }));
+ return { data, count: data.length };
+}
+
+async function importBookmarks(tree, onProg) {
+ const total = countBookmarks(tree); let done = 0, imported = 0, skipped = 0;
+ const root = await api.bookmarks.create({ title: `Portage Import — ${new Date().toLocaleString()}` });
+ async function walk(nodes, parentId) {
+ for (const node of nodes) {
+ if (node.url) {
+ try { if (!IMPORTABLE.test(node.url)) skipped++; else { await api.bookmarks.create({ parentId, title: node.title || node.url, url: node.url }); imported++; } }
+ catch { skipped++; }
+ if (++done % 50 === 0) { onProg(done, total); await tick(); }
+ } else if (node.children) {
+ let pid = parentId;
+ if (node.title) { const f = await api.bookmarks.create({ parentId, title: node.title }); pid = f.id; }
+ await walk(node.children, pid);
+ }
+ }
+ }
+ await walk(tree, root.id); onProg(total, total); return { imported, skipped };
+}
+async function importHistory(items, onProg) {
+ const total = items.length; let imported = 0, skipped = 0;
+ for (let i = 0; i < total; i++) {
+ const it = items[i];
+ try {
+ if (!IMPORTABLE.test(it.url)) skipped++;
+ else {
+ const details = IS_FIREFOX ? { url: it.url, title: it.title || undefined, visitTime: it.lastVisitTime || Date.now() } : { url: it.url };
+ await api.history.addUrl(details); imported++;
+ }
+ } catch { skipped++; }
+ if (i % 100 === 0) { onProg(i, total); await tick(); }
+ }
+ onProg(total, total); return { imported, skipped };
+}
+function mapSameSite(s) { return s === "strict" ? "strict" : s === "lax" ? "lax" : "no_restriction"; }
+async function importCookies(items, onProg) {
+ const total = items.length; let imported = 0, skipped = 0;
+ for (let i = 0; i < total; i++) {
+ const c = items[i];
+ try {
+ const host = (c.domain || "").replace(/^\./, "");
+ if (!host) skipped++;
+ else {
+ const url = (c.secure ? "https" : "http") + "://" + host + (c.path || "/");
+ const d = { url, name: c.name, value: c.value, path: c.path || "/", secure: !!c.secure, httpOnly: !!c.httpOnly, sameSite: mapSameSite(c.sameSite) };
+ if (!c.hostOnly) d.domain = c.domain;
+ if (!c.session && c.expirationDate) d.expirationDate = c.expirationDate;
+ await api.cookies.set(d); imported++;
+ }
+ } catch { skipped++; }
+ if (i % 100 === 0) { onProg(i, total); await tick(); }
+ }
+ onProg(total, total); return { imported, skipped };
+}
+async function importTabs(items, onProg) {
+ const total = items.length; let imported = 0, skipped = 0;
+ for (let i = 0; i < total; i++) {
+ const t = items[i];
+ try { if (!IMPORTABLE.test(t.url)) skipped++; else { await api.tabs.create({ url: t.url, pinned: !!t.pinned, active: false }); imported++; } }
+ catch { skipped++; }
+ onProg(i + 1, total); if (i % 10 === 0) await tick();
+ }
+ return { imported, skipped };
+}
+async function importSessions(list, onProg) {
+ const flat = [];
+ for (const s of list) { if (s.type === "window") for (const t of (s.tabs || [])) flat.push(t); else flat.push(s); }
+ return importTabs(flat, onProg);
+}
+async function importReadingList(items, onProg) {
+ const total = items.length; let imported = 0, skipped = 0;
+ if (HAS_READINGLIST && api.readingList.addEntry) {
+ for (let i = 0; i < total; i++) {
+ const it = items[i];
+ try { if (!IMPORTABLE.test(it.url)) skipped++; else { await api.readingList.addEntry({ url: it.url, title: it.title || it.url, hasBeenRead: !!it.hasBeenRead }); imported++; } }
+ catch { skipped++; }
+ onProg(i + 1, total); if (i % 50 === 0) await tick();
+ }
+ } else {
+ const folder = await api.bookmarks.create({ title: "Reading List (from Portage)" });
+ for (let i = 0; i < total; i++) {
+ const it = items[i];
+ try { if (!IMPORTABLE.test(it.url)) skipped++; else { await api.bookmarks.create({ parentId: folder.id, title: it.title || it.url, url: it.url }); imported++; } }
+ catch { skipped++; }
+ onProg(i + 1, total); if (i % 50 === 0) await tick();
+ }
+ }
+ return { imported, skipped };
+}
+
+const CATS = {
+ bookmarks: { label: "Bookmarks", unit: "bookmarks", icon: SVG.bookmarks, level: "full", desc: "Full bookmark tree, folders and all", importFx: "Created in a 'Portage Import' folder", collect: collectBookmarks, count: countBookmarks, importRun: importBookmarks },
+ history: { label: "History", unit: "pages", icon: SVG.history, level: "full", desc: "Pages you've visited, with timestamps", importFx: IS_FIREFOX ? "Re-added with titles & times" : "Re-added as visits", collect: collectHistory, count: (d) => d.length, importRun: importHistory },
+ cookies: { label: "Cookies", unit: "cookies", icon: SVG.cookies, level: "full", desc: "Site cookies — keeps you signed in", importFx: "Re-set so logins carry over", collect: collectCookies, count: (d) => d.length, importRun: importCookies },
+ tabs: { label: "Open tabs", unit: "tabs", icon: SVG.tabs, level: "full", desc: "Tabs open right now, every window", importFx: "Reopened in this window", collect: collectTabs, count: (d) => d.length, importRun: importTabs },
+ sessions: { label: "Recently closed", unit: "sessions", icon: SVG.sessions, level: "partial", desc: "Tabs & windows you closed recently", importFx: "Reopened as tabs", collect: collectSessions, count: countSessions, importRun: importSessions },
+ readingList: { label: "Reading list", unit: "entries", icon: SVG.reading, level: HAS_READINGLIST ? "full" : "partial", desc: "Pages saved to read later", importFx: HAS_READINGLIST ? "Added to the reading list" : "Saved to a bookmark folder", collect: collectReadingList, count: (d) => d.length, importRun: importReadingList, needsReadingList: true },
+ extensions: { label: "Extensions", unit: "installed", icon: SVG.extensions, level: "checklist", desc: "Installed add-ons → reinstall checklist", importFx: "Can't auto-install — use the .txt checklist", collect: collectExtensions, count: (d) => d.length, importRun: null, special: true },
+};
+const ORDER = ["bookmarks", "history", "cookies", "tabs", "sessions", "readingList", "extensions"];
+
+const exState = {};
+let scanned = false;
+const exGrid = document.getElementById("exportGrid");
+
+function exportCategoryIds() { return ORDER.filter((id) => !(CATS[id].needsReadingList && !HAS_READINGLIST)); }
+
+function makeCard(id, c, opts) {
+ const el = document.createElement("div");
+ el.className = "card";
+ el.dataset.id = id;
+ el.innerHTML = `
+