// ==UserScript==
// @name Cookie Manager
// @name:zh-CN Cookie 管理器
// @namespace https://github.com/Minis233/cookie-manager
// @version 0.5.1
// @description A modern cookie manager userscript: dual-engine read/write, batch CRUD, multi-select, paste-to-import, JSON export/import, multiple copy formats, trash bin, dark mode, sort/filter/group, bilingual UI.
// @description:zh-CN 现代化 Cookie 管理油猴脚本:双核引擎读写、批量增删改查、多选、粘贴解析、JSON 导入导出、多种复制格式、回收站、暗色模式、排序/筛选/分组、中英双语 UI
// @author Minis
// @license MIT
// @homepageURL https://github.com/Minis233/cookie-manager
// @supportURL https://github.com/Minis233/cookie-manager/issues
// @match *://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @run-at document-end
// @noframes
// ==/UserScript==
(function () {
'use strict';
const POS_KEY = 'cm_iconPos_v1';
const HAS_CS = typeof window.cookieStore !== 'undefined';
/* ---------- 通用工具 ---------- */
const safeDecode = s => { try { return decodeURIComponent(s == null ? '' : s); } catch { return String(s == null ? '' : s); } };
// 跨脚本管理器兼容:Tampermonkey/Violentmonkey 提供 sync GM_*;Greasemonkey 4 只有 async GM.*;
// 都不支持时退化为 localStorage(功能不丢失,但跨域不共享,对本脚本场景无影响)
const _GM_get = typeof GM_getValue === 'function' ? GM_getValue : null;
const _GM_set = typeof GM_setValue === 'function' ? GM_setValue : null;
const gmGet = (k, d) => {
try {
let v;
if (_GM_get) v = _GM_get(k);
else if (typeof localStorage !== 'undefined') v = localStorage.getItem(k);
if (v == null) return d;
return typeof v === 'string' && (v[0] === '{' || v[0] === '[') ? JSON.parse(v) : v;
} catch { return d; }
};
const gmSet = (k, v) => {
try {
const s = typeof v === 'string' ? v : JSON.stringify(v);
if (_GM_set) _GM_set(k, s);
else if (typeof localStorage !== 'undefined') localStorage.setItem(k, s);
} catch {}
};
const escHtml = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
/* ---------- i18n ---------- */
const LANG_KEY = 'cm_lang_v1';
const I18N = {
zh: {
title: 'Cookie 管理器', titleCompat: ' (兼容模式)',
badd: '批量添加', sadd: '单个添加', copyall: '复制所有', delall: '一键删除',
phKey: '按 key 搜索', phVal: '按 value 搜索',
empty: '没有匹配的 Cookie',
modify: '修改', copyKey: '复制 Key', copyVal: '复制值', copyKv: '复制 KV', del: '删除',
details: '详情', expand: '展开', collapse: '收起',
sel: '多选', selExit: '退出多选', selAll: '全选', selAllPlus: n => `全选(+${n})`, selNone: '取消全选',
cancel: '取消', ok: '确定', save: '保存',
copyN: n => `复制(${n})`, delN: n => `删除(${n})`,
confirmDelOne: k => `删除 "${k}"?`, confirmDelN: n => `删除选中的 ${n} 条 Cookie?`,
confirmDelAllTitle: '一键删除', confirmDelAll: n => `删除当前网站全部 ${n} 条 Cookie?此操作不可逆。`,
tNoCookie: '当前没有 Cookie', tCopiedN: n => `已复制 ${n} 条`, tCopyFail: '复制失败', tCopied: '已复制',
tDeleted: '已删除', tNotSelected: '未选中', tEmptyList: '当前列表为空',
tAdded: '已新增', tModified: '已修改', tNeedKey: 'Key 不能为空', tNeedRow: '请至少填一条',
tBatchOk: (ok, total) => `成功 ${ok} / ${total}`,
tDomainMismatch: h => `设置失败:domain 与当前网站不匹配 (${h})`,
tSetFail: msg => `设置失败:${msg}`,
tInvalidJson: '无效的 JSON 格式',
tImportedN: n => `已导入 ${n} 条`,
tUndo: '撤销', tRestoredN: n => `已撤销 ${n} 条删除`,
addNew: '新增 Cookie', edit: '修改 Cookie', batch: '批量添加 Cookie',
lblKey: 'Key', lblValue: 'Value', lblDomain: 'Domain', lblPath: 'Path', lblDays: '有效期 (天)',
phEmptyDomain: '留空使用当前域名',
lblParse: '粘贴解析(支持 a=b; c=d 或多行 key=value)',
phParse: 'key1=value1; key2=value2\nkey3=value3',
addRow: '+ 添加一行', batchSave: '全部添加',
delConfirm: '删除',
menu: '更多', exp: '导出 JSON', expSel: '导出选中', impJson: '导入 JSON',
trash: '回收站', trashEmpty: '回收站为空', trashCount: n => `回收站 (${n})`,
trashRestore: '恢复', trashPurge: '永久删除', trashClear: '清空回收站',
trashRestoreN: n => `恢复(${n})`, trashPurgeN: n => `永久删除(${n})`,
trashConfirmClear: '永久清空回收站?此操作不可逆。',
trashConfirmPurgeN: n => `永久删除选中的 ${n} 条?此操作不可逆。`,
trashRestoredN: n => `已恢复 ${n} 条`, trashPurgedN: n => `已永久删除 ${n} 条`,
trashRestored: '已恢复', trashPurged: '已永久删除', trashCleared: '回收站已清空',
trashTime: ts => new Date(ts).toLocaleString(),
copyAs: '复制为...', cfHeader: 'Cookie Header', cfCurl: 'cURL --cookie', cfJson: 'JSON', cfKv: 'KV (key=value)',
impTitle: '导入 Cookie',
impHint: '支持以下格式:\n• JSON 数组(本工具或 EditThisCookie 导出)\n• 每行 key=value\n• Cookie header 字符串',
secure: 'Secure', http: 'HttpOnly', sameSite: 'SameSite',
sizeBytes: n => `${n} B`,
sizeWarn: '体积过大(>4KB 可能被截断)',
theme: '主题', themeAuto: '跟随系统', themeLight: '浅色', themeDark: '深色',
sort: '排序', sortKey: 'Key', sortSize: '体积', sortExpires: '过期时间',
sortAsc: '↑ 升序', sortDesc: '↓ 降序',
group: '分组', groupNone: '不分组', groupDomain: '按域名', groupRoot: '按主域',
filterSecure: '仅 Secure', filterHttp: '仅 HttpOnly',
statsN: (n, total) => total > n ? `${n} / ${total}` : `${n}`,
},
en: {
title: 'Cookie Manager', titleCompat: ' (Compat)',
badd: 'Batch Add', sadd: 'Add One', copyall: 'Copy All', delall: 'Delete All',
phKey: 'Search by key', phVal: 'Search by value',
empty: 'No matching cookies',
modify: 'Edit', copyKey: 'Copy Key', copyVal: 'Copy Value', copyKv: 'Copy KV', del: 'Delete',
details: 'Details', expand: 'Expand', collapse: 'Collapse',
sel: 'Select', selExit: 'Exit Select', selAll: 'Select All', selAllPlus: n => `Select All (+${n})`, selNone: 'Deselect All',
cancel: 'Cancel', ok: 'OK', save: 'Save',
copyN: n => `Copy (${n})`, delN: n => `Delete (${n})`,
confirmDelOne: k => `Delete "${k}"?`, confirmDelN: n => `Delete ${n} selected cookies?`,
confirmDelAllTitle: 'Delete All', confirmDelAll: n => `Delete all ${n} cookies for this site? This cannot be undone.`,
tNoCookie: 'No cookies on this site', tCopiedN: n => `Copied ${n} item${n === 1 ? '' : 's'}`, tCopyFail: 'Copy failed', tCopied: 'Copied',
tDeleted: 'Deleted', tNotSelected: 'Nothing selected', tEmptyList: 'List is empty',
tAdded: 'Added', tModified: 'Updated', tNeedKey: 'Key is required', tNeedRow: 'Add at least one row',
tBatchOk: (ok, total) => `Done ${ok} / ${total}`,
tDomainMismatch: h => `Failed: domain mismatch (current: ${h})`,
tSetFail: msg => `Failed: ${msg}`,
tInvalidJson: 'Invalid JSON',
tImportedN: n => `Imported ${n} item${n === 1 ? '' : 's'}`,
tUndo: 'Undo', tRestoredN: n => `Restored ${n} cookie${n === 1 ? '' : 's'}`,
addNew: 'Add Cookie', edit: 'Edit Cookie', batch: 'Batch Add Cookies',
lblKey: 'Key', lblValue: 'Value', lblDomain: 'Domain', lblPath: 'Path', lblDays: 'Max Age (days)',
phEmptyDomain: 'Leave blank to use current host',
lblParse: 'Paste to parse (supports a=b; c=d or multi-line key=value)',
phParse: 'key1=value1; key2=value2\nkey3=value3',
addRow: '+ Add Row', batchSave: 'Add All',
delConfirm: 'Delete',
menu: 'More', exp: 'Export JSON', expSel: 'Export Selected', impJson: 'Import JSON',
trash: 'Trash', trashEmpty: 'Trash is empty', trashCount: n => `Trash (${n})`,
trashRestore: 'Restore', trashPurge: 'Delete forever', trashClear: 'Empty trash',
trashRestoreN: n => `Restore (${n})`, trashPurgeN: n => `Delete forever (${n})`,
trashConfirmClear: 'Permanently empty the trash? This cannot be undone.',
trashConfirmPurgeN: n => `Permanently delete ${n} selected? This cannot be undone.`,
trashRestoredN: n => `Restored ${n} item${n === 1 ? '' : 's'}`, trashPurgedN: n => `Permanently deleted ${n}`,
trashRestored: 'Restored', trashPurged: 'Deleted forever', trashCleared: 'Trash emptied',
trashTime: ts => new Date(ts).toLocaleString(),
copyAs: 'Copy As...', cfHeader: 'Cookie Header', cfCurl: 'cURL --cookie', cfJson: 'JSON', cfKv: 'KV (key=value)',
impTitle: 'Import Cookies',
impHint: 'Supported formats:\n• JSON array (from this tool or EditThisCookie)\n• key=value per line\n• Cookie header string',
secure: 'Secure', http: 'HttpOnly', sameSite: 'SameSite',
sizeBytes: n => `${n} B`,
sizeWarn: 'Too large (>4KB may be truncated)',
theme: 'Theme', themeAuto: 'Auto', themeLight: 'Light', themeDark: 'Dark',
sort: 'Sort', sortKey: 'Key', sortSize: 'Size', sortExpires: 'Expires',
sortAsc: '↑ Asc', sortDesc: '↓ Desc',
group: 'Group', groupNone: 'No group', groupDomain: 'By domain', groupRoot: 'By root domain',
filterSecure: 'Secure only', filterHttp: 'HttpOnly only',
statsN: (n, total) => total > n ? `${n} / ${total}` : `${n}`,
}
};
const detectLang = () => {
const saved = gmGet(LANG_KEY, null);
if (saved && I18N[saved]) return saved;
const nav = (navigator.language || 'en').toLowerCase();
return nav.startsWith('zh') ? 'zh' : 'en';
};
let lang = detectLang();
const t = (key, ...args) => {
const v = I18N[lang][key];
return typeof v === 'function' ? v(...args) : (v == null ? key : v);
};
const setLang = (l) => { lang = l; gmSet(LANG_KEY, l); };
/* ---------- 偏好(主题 / 排序 / 分组 / 筛选) ---------- */
const PREFS_KEY = 'cm_prefs_v1';
const defaultPrefs = { theme: 'auto', sort: 'key', sortDir: 'asc', group: 'none', secureOnly: false, httpOnly: false, collapsed: {} };
let prefs = Object.assign({}, defaultPrefs, gmGet(PREFS_KEY, {}));
const savePrefs = () => gmSet(PREFS_KEY, prefs);
const setPref = (k, v) => { prefs[k] = v; savePrefs(); };
let mql = null;
function applyTheme() {
if (!host) return;
let dark = false;
if (prefs.theme === 'dark') dark = true;
else if (prefs.theme === 'auto') {
try { dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; } catch {}
}
host.classList.toggle('dark', dark);
}
function watchTheme() {
if (mql || !window.matchMedia) return;
try {
mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => { if (prefs.theme === 'auto') applyTheme(); };
if (mql.addEventListener) mql.addEventListener('change', handler);
else if (mql.addListener) mql.addListener(handler);
} catch {}
}
/* ---------- 样式 ---------- */
const STYLE = `
:host{all:initial;
--bg:#fff;--fg:#222;--fg-soft:#444;--fg-mute:#666;--fg-faint:#888;
--border:#ececec;--border-strong:#d4d8de;--surface:#fafbfc;--surface-2:#f3f5f8;--surface-3:#f6f8fb;--surface-pop:#fff;
--primary:#2f6ce6;--primary-fg:#fff;--primary-soft:rgba(47,108,230,.15);
--warn:#dc3545;--warn-soft:#fde0e0;--warn-text:#a02020;
--flag-bg:#dfe9fa;--flag-fg:#1a4baf;
--pop-shadow:0 8px 24px rgba(0,0,0,.18);
--panel-shadow:0 12px 40px rgba(0,0,0,.35);
--overlay:rgba(0,0,0,.5);
--hover:#eef2f7;
--size-bg:#eef0f3;--size-fg:#888;
--kd-fg:#666;
--toast-bg:rgba(20,20,20,.92);--toast-ok:rgba(40,140,80,.95);--toast-err:rgba(190,40,40,.95);
}
:host(.dark){
--bg:#1f2127;--fg:#e6e7ea;--fg-soft:#c0c2c8;--fg-mute:#9aa0aa;--fg-faint:#7a8088;
--border:#34373f;--border-strong:#454853;--surface:#262931;--surface-2:#2c2f37;--surface-3:#2a2d35;--surface-pop:#262931;
--primary:#5b8def;--primary-fg:#fff;--primary-soft:rgba(91,141,239,.22);
--warn:#ef5260;--warn-soft:#3a2225;--warn-text:#ff9aa3;
--flag-bg:#22324a;--flag-fg:#9bbcff;
--pop-shadow:0 8px 24px rgba(0,0,0,.55);
--panel-shadow:0 12px 40px rgba(0,0,0,.6);
--overlay:rgba(0,0,0,.65);
--hover:#2f333d;
--size-bg:#2c2f37;--size-fg:#9aa0aa;
--kd-fg:#a0a4ad;
}
*{box-sizing:border-box}
button{font-family:inherit}
#fab{position:fixed;width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,#4f8cff,#3358c4);color:#fff;display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;box-shadow:0 4px 14px rgba(0,0,0,.28);z-index:2147483646;cursor:grab;user-select:none;touch-action:none}
#fab:active{cursor:grabbing}
.ov{position:fixed;inset:0;background:var(--overlay);z-index:2147483647;display:none;align-items:center;justify-content:center;padding:12px;-webkit-tap-highlight-color:transparent}
.ov.show{display:flex}
.panel{background:var(--bg);color:var(--fg);border-radius:12px;width:100%;max-width:460px;max-height:86vh;display:flex;flex-direction:column;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;box-shadow:var(--panel-shadow);overflow:hidden}
.head{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:var(--primary);color:var(--primary-fg);flex-shrink:0}
.head h3{margin:0;font-size:15px;font-weight:600}
.x{cursor:pointer;font-size:22px;line-height:1;padding:0 4px}
.body{flex:1;overflow-y:auto;padding:12px 14px}
.foot{display:flex;gap:8px;padding:10px 14px;border-top:1px solid var(--border);background:var(--surface);flex-shrink:0;flex-wrap:wrap}
.row{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}
.row label{font-size:12px;color:var(--fg-mute);font-weight:600}
.row input[type=text],.row input[type=number],.row textarea,.row input[type=search]{width:100%;padding:8px 10px;border:1px solid var(--border-strong);border-radius:6px;font-size:14px;background:var(--bg);color:var(--fg);font-family:inherit}
.row textarea{min-height:80px;resize:vertical}
.row input:focus,.row textarea:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 2px var(--primary-soft)}
.btn{padding:7px 12px;border-radius:6px;border:1px solid var(--border-strong);background:var(--bg);color:var(--fg);cursor:pointer;font-size:13px}
.btn:active{transform:scale(.97)}
.btn.pri{background:var(--primary);color:var(--primary-fg);border-color:var(--primary)}
.btn.warn{background:var(--warn);color:#fff;border-color:var(--warn)}
.btn.sm{padding:4px 8px;font-size:12px}
.btn.ghost{background:transparent;border-color:var(--border-strong);color:var(--fg-soft)}
.btn.chip{padding:3px 9px;font-size:12px;border-radius:14px;background:var(--surface-2);border-color:var(--border-strong);color:var(--fg-soft)}
.btn.chip.on{background:var(--primary);color:var(--primary-fg);border-color:var(--primary)}
.search{display:flex;gap:6px;margin-bottom:10px}
.search input{flex:1}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px}
.grid .btn{width:100%}
.toolbar{display:flex;gap:6px;align-items:center;margin-bottom:8px;flex-wrap:wrap}
.toolbar .label{font-size:11px;color:var(--fg-mute);margin-right:2px}
.ck{border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;background:var(--bg)}
.ck-h{display:flex;align-items:center;gap:8px}
.ck-k{font-weight:600;color:var(--fg);word-break:break-all;flex:1}
.ck-v{margin:6px 0;color:var(--fg-soft);word-break:break-all;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.ck-p{font-size:11px;color:var(--fg-faint);background:var(--surface-2);padding:3px 6px;border-radius:4px;display:inline-block}
.ck-a{display:flex;gap:6px;margin-top:8px;flex-wrap:wrap}
.cb{display:none;width:18px;height:18px;border:1.5px solid var(--fg-faint);border-radius:4px;align-items:center;justify-content:center;color:#fff;font-size:12px;cursor:pointer;flex-shrink:0}
.sel .cb{display:inline-flex}
.cb.on{background:var(--primary);border-color:var(--primary)}
.empty{text-align:center;color:var(--fg-faint);padding:24px 0}
.br{display:flex;gap:6px;align-items:center;margin-bottom:6px}
.br input{flex:1;padding:6px 8px;border:1px solid var(--border-strong);border-radius:4px;font-size:13px;background:var(--bg);color:var(--fg)}
.br .rm{width:28px;height:28px;border-radius:50%;border:none;background:var(--warn);color:#fff;font-weight:700;cursor:pointer;flex-shrink:0}
#toast{position:fixed;left:50%;top:24px;transform:translateX(-50%);z-index:2147483647;display:flex;flex-direction:column;gap:6px;pointer-events:none}
.t{background:var(--toast-bg);color:#fff;padding:8px 14px;border-radius:18px;font-size:13px;max-width:80vw;box-shadow:0 4px 14px rgba(0,0,0,.3)}
.t.ok{background:var(--toast-ok)}
.t.err{background:var(--toast-err)}
.dlg{background:var(--bg);border-radius:10px;padding:16px;width:100%;max-width:360px;color:var(--fg)}
.dlg .m{margin-bottom:12px;line-height:1.5;word-break:break-word}
.dlg .a{display:flex;justify-content:flex-end;gap:8px}
.dlg input{width:100%;padding:8px 10px;border:1px solid var(--border-strong);border-radius:6px;font-size:14px;margin-bottom:12px;background:var(--bg);color:var(--fg)}
.dlg textarea{width:100%;min-height:140px;padding:8px 10px;border:1px solid var(--border-strong);border-radius:6px;font-size:13px;font-family:ui-monospace,Menlo,Consolas,monospace;margin-bottom:12px;resize:vertical;background:var(--bg);color:var(--fg)}
.ck-detail{margin-top:8px;padding:8px;background:var(--surface-3);border-radius:6px;font-size:12px;display:none}
.ck.open .ck-detail{display:block}
.ck.open .ck-v{-webkit-line-clamp:initial;display:block}
.ck-detail .row-d{display:flex;gap:8px;margin-bottom:4px;word-break:break-all}
.ck-detail .k-d{color:var(--kd-fg);flex:0 0 70px;font-weight:600}
.ck-detail .v-d{color:var(--fg);flex:1}
.ck-size{font-size:10px;color:var(--size-fg);background:var(--size-bg);padding:1px 5px;border-radius:3px;margin-left:6px}
.ck-size.warn{background:var(--warn-soft);color:var(--warn-text)}
.ck-flag{display:inline-block;padding:1px 5px;border-radius:3px;font-size:10px;margin-left:4px;background:var(--flag-bg);color:var(--flag-fg)}
.group-h{display:flex;align-items:center;gap:6px;padding:6px 10px;margin:8px 0 4px;background:var(--surface);border-radius:6px;cursor:pointer;font-size:12px;color:var(--fg-mute);font-weight:600;user-select:none}
.group-h .arrow{font-size:10px;transition:transform .15s}
.group.collapsed .group-h .arrow{transform:rotate(-90deg)}
.group.collapsed .group-body{display:none}
.group-h .count{margin-left:auto;font-weight:400;color:var(--fg-faint)}
.popm{position:fixed;background:var(--surface-pop);border:1px solid var(--border);border-radius:8px;box-shadow:var(--pop-shadow);padding:4px;z-index:2147483647;min-width:160px}
.popm .it{padding:8px 12px;cursor:pointer;border-radius:4px;font-size:13px;color:var(--fg);white-space:nowrap}
.popm .it.active{background:var(--primary-soft);color:var(--primary)}
.popm .it:hover,.popm .it:active{background:var(--hover)}
.popm .it.dv{height:1px;background:var(--border);padding:0;margin:4px 0}
.t .undo{background:rgba(255,255,255,.2);border:1px solid rgba(255,255,255,.4);color:#fff;padding:3px 10px;border-radius:12px;font-size:12px;cursor:pointer;margin-left:10px;pointer-events:auto}
`;
/* ---------- Shadow DOM 容器 ---------- */
let host, sr, toastBox, fab, mainOv;
function ensureHost() {
if (host && document.documentElement.contains(host)) return;
if (!document.body) return;
host = document.createElement('div');
host.id = 'cm-host';
host.style.cssText = 'all:initial;position:fixed;width:0;height:0;z-index:2147483646';
document.documentElement.appendChild(host);
sr = host.attachShadow({ mode: 'open' });
const st = document.createElement('style'); st.textContent = STYLE; sr.appendChild(st);
toastBox = document.createElement('div'); toastBox.id = 'toast'; sr.appendChild(toastBox);
buildFab();
buildPanel();
applyTheme();
watchTheme();
}
function startGuardian() {
try {
new MutationObserver(() => {
if (document.body && (!host || !document.documentElement.contains(host))) ensureHost();
}).observe(document.documentElement, { childList: true });
} catch {}
}
/* ---------- toast / dialog(替换 alert/confirm,Via 中更稳) ---------- */
function toast(msg, type, action) {
if (!toastBox) return;
const el = document.createElement('div');
el.className = 't' + (type ? ' ' + type : '');
el.textContent = msg;
let timer1 = null, timer2 = null;
if (action) {
const btn = document.createElement('button');
btn.className = 'undo';
btn.textContent = action.label;
btn.addEventListener('click', () => {
clearTimeout(timer1); clearTimeout(timer2);
el.remove();
action.fn();
});
el.appendChild(btn);
}
toastBox.appendChild(el);
const ttl = action ? 5000 : 1800;
timer1 = setTimeout(() => { el.style.transition = 'opacity .25s'; el.style.opacity = '0'; }, ttl);
timer2 = setTimeout(() => el.remove(), ttl + 400);
}
function dialog({ title = '', message = '', input = null, ok, cancel, danger = false } = {}) {
if (ok == null) ok = t('ok');
if (cancel == null) cancel = t('cancel');
return new Promise(resolve => {
const ov = document.createElement('div');
ov.className = 'ov show';
const html = (title ? `
${escHtml(title)}
` : '')
+ `${escHtml(message).replace(/\n/g, '
')}
`
+ (input != null ? `` : '')
+ ``;
const box = document.createElement('div');
box.className = 'dlg';
box.innerHTML = html;
ov.appendChild(box);
sr.appendChild(ov);
const inp = box.querySelector('input');
if (inp) setTimeout(() => inp.focus(), 30);
const close = v => { ov.remove(); resolve(v); };
box.querySelector('.cancel').addEventListener('click', () => close(null));
box.querySelector('.ok').addEventListener('click', () => close(input != null ? (inp.value || '') : true));
});
}
/* ---------- Cookie 双核引擎 ---------- */
const ckMgr = {
async get() {
if (HAS_CS) {
try {
const list = await window.cookieStore.getAll();
return list.map(c => ({
key: c.name, value: c.value || '',
// domain 保留原值(host-only cookie 的 c.domain 在 Chromium 里等于 location.hostname;
// Firefox 实现可能为 null)。我们记一个 _hostOnly 标志,便于 set/del 时判断
domain: c.domain || location.hostname,
_hostOnly: !c.domain,
path: c.path || '/',
secure: !!c.secure,
httpOnly: !!c.httpOnly,
sameSite: c.sameSite || '',
expires: c.expires || null
}));
} catch {}
}
if (!document.cookie) return [];
return document.cookie.split(';').map(s => {
const i = s.indexOf('=');
if (i < 0) return null;
return { key: s.slice(0, i).trim(), value: s.slice(i + 1).trim(), domain: location.hostname, path: '/', _legacy: true, _hostOnly: true };
}).filter(Boolean);
},
async set(key, value, opt = {}) {
const days = typeof opt.days === 'number' && !isNaN(opt.days) ? opt.days : 365;
const path = opt.path || '/';
// 如果 opt.hostOnly 显式为 true,则不发送 domain;否则按 opt.domain 处理
const wantHostOnly = !!opt.hostOnly;
const domain = (!wantHostOnly && opt.domain && opt.domain !== 'N/A') ? opt.domain : '';
try {
if (HAS_CS) {
const p = {
name: key,
value: value || '',
path,
expires: Date.now() + days * 86400000
};
if (domain) p.domain = domain;
if (opt.sameSite) p.sameSite = opt.sameSite;
if (opt.secure) p.secure = true;
await window.cookieStore.set(p);
return true;
}
let c = `${encodeURIComponent(key)}=${encodeURIComponent(value || '')}`;
if (days > 0) c += `; expires=${new Date(Date.now() + days * 86400000).toUTCString()}`;
c += `; path=${path}`;
if (domain) c += `; domain=${domain}`;
if (opt.secure) c += '; secure';
if (opt.sameSite) c += `; samesite=${opt.sameSite}`;
document.cookie = c;
return true;
} catch (e) {
if (e && /domain/i.test(e.message || '')) toast(t('tDomainMismatch', location.hostname), 'err');
else toast(t('tSetFail', e.message || e), 'err');
return false;
}
},
async del(c) {
// 精确删除:用 cookieStore 的话只调一次 delete
// host-only cookie 不传 domain 字段;Domain-attr cookie 传 c.domain
if (HAS_CS && !c._legacy) {
try {
const p = { name: c.key };
if (!c._hostOnly && c.domain && c.domain !== 'N/A') p.domain = c.domain;
if (c.path) p.path = c.path;
await window.cookieStore.delete(p);
return true;
} catch (e) {
// cookieStore 失败再走 document.cookie 兜底
}
}
// document.cookie fallback:只用原 cookie 的 path/domain
try {
const exp = 'expires=Thu, 01 Jan 1970 00:00:00 GMT';
const path = c.path || '/';
let s = `${encodeURIComponent(c.key)}=; ${exp}; path=${path}`;
if (!c._hostOnly && c.domain && c.domain !== 'N/A') s += `; domain=${c.domain}`;
document.cookie = s;
return true;
} catch { return false; }
}
};
async function copy(text) {
try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } } catch {}
try {
const ta = document.createElement('textarea');
ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px;top:0';
document.body.appendChild(ta); ta.focus(); ta.select();
const ok = document.execCommand('copy'); ta.remove();
return ok;
} catch { return false; }
}
/* ---------- Popup menu ---------- */
function popMenu(anchor, items) {
const old = sr.querySelector('.popm');
if (old) {
const same = old.__a === anchor;
old.__close && old.__close();
if (same) return null;
}
const m = document.createElement('div');
m.className = 'popm'; m.__a = anchor;
for (const it of items) {
const el = document.createElement('div');
el.className = it.divider ? 'it dv' : 'it' + (it.active ? ' active' : '');
if (!it.divider) {
el.textContent = (it.active ? '✓ ' : '') + it.label;
el.addEventListener('click', () => { m.__close(); it.fn?.(); });
}
m.appendChild(el);
}
sr.appendChild(m);
const r = anchor.getBoundingClientRect();
m.style.left = Math.max(8, Math.min(window.innerWidth - 180, r.right - 160)) + 'px';
const mh = m.offsetHeight;
m.style.top = (r.bottom + mh + 8 > window.innerHeight ? Math.max(8, r.top - mh - 4) : r.bottom + 4) + 'px';
const onDoc = e => {
const path = e.composedPath?.() || [e.target];
if (path.includes(m) || path.includes(anchor)) return;
m.__close();
};
m.__close = () => { document.removeEventListener('click', onDoc, true); m.remove(); };
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
return m;
}
/* ---------- 复制为多种格式 ---------- */
const FMT = {
header: cs => cs.map(c => `${c.key}=${c.value}`).join('; '),
curl: cs => `--cookie '${FMT.header(cs).replace(/'/g, "'\\''")}'`,
json: cs => JSON.stringify(cs.map(c => ({ name: c.key, value: c.value, domain: c.domain || location.hostname, path: c.path || '/', secure: !!c.secure, sameSite: c.sameSite || '' })), null, 2),
kv: cs => cs.map(c => `${c.key}=${c.value}`).join('\n')
};
async function copyAs(cookies, fmt) {
if (!cookies?.length) return toast(t('tNotSelected'), 'err');
const ok = await copy(FMT[fmt](cookies));
toast(ok ? t('tCopiedN', cookies.length) : t('tCopyFail'), ok ? 'ok' : 'err');
}
function showCopyAsMenu(anchor, cookies) {
popMenu(anchor, [['cfHeader','header'],['cfCurl','curl'],['cfJson','json'],['cfKv','kv']]
.map(([k, f]) => ({ label: t(k), fn: () => copyAs(cookies, f) })));
}
/* ---------- 导出 / 导入 JSON ---------- */
async function exportJson(cookies) {
if (!cookies?.length) return toast(t('tNoCookie'), 'err');
const ok = await copy(FMT.json(cookies));
toast(ok ? t('tCopiedN', cookies.length) : t('tCopyFail'), ok ? 'ok' : 'err');
}
async function restoreCookies(snapshot) {
if (!snapshot?.length) return;
await Promise.all(snapshot.map(c => {
const days = c.expires ? Math.max(1, Math.round((c.expires - Date.now()) / 86400000)) : 365;
return ckMgr.set(c.key, c.value, {
domain: c.domain, path: c.path, days,
hostOnly: !!c._hostOnly,
secure: !!c.secure,
sameSite: c.sameSite || ''
});
}));
toast(t('tRestoredN', snapshot.length), 'ok');
render();
}
/* ---------- 回收站(持久化) ---------- */
const TRASH_KEY = 'cm_trash_v1';
const TRASH_TTL = 30 * 86400000; // 30 天后自动清掉
const TRASH_MAX = 200; // 单个 host 最多 200 条
function trashHostKey() {
// 按 host 分仓,避免不同站点串扰
return location.hostname || 'global';
}
function trashLoadAll() {
const all = gmGet(TRASH_KEY, {}) || {};
return typeof all === 'object' && all ? all : {};
}
function trashSaveAll(all) { gmSet(TRASH_KEY, all); }
function trashList() {
const all = trashLoadAll();
const items = all[trashHostKey()] || [];
// 顺手清掉过期的
const now = Date.now();
const fresh = items.filter(it => now - (it.deletedAt || 0) < TRASH_TTL);
if (fresh.length !== items.length) {
all[trashHostKey()] = fresh;
trashSaveAll(all);
}
return fresh;
}
function trashAdd(cookies) {
if (!cookies?.length) return;
const all = trashLoadAll();
const hk = trashHostKey();
let bucket = all[hk] || [];
const now = Date.now();
for (const c of cookies) {
bucket.unshift({
id: 'tr_' + now.toString(36) + '_' + Math.random().toString(36).slice(2, 8),
deletedAt: now,
cookie: {
key: c.key, value: c.value,
domain: c.domain, path: c.path,
secure: !!c.secure, httpOnly: !!c.httpOnly,
sameSite: c.sameSite || '', expires: c.expires || null,
_hostOnly: !!c._hostOnly, _legacy: !!c._legacy
}
});
}
// 截断
if (bucket.length > TRASH_MAX) bucket = bucket.slice(0, TRASH_MAX);
all[hk] = bucket;
trashSaveAll(all);
}
async function trashRestoreOne(id) {
const all = trashLoadAll();
const hk = trashHostKey();
const bucket = all[hk] || [];
const idx = bucket.findIndex(it => it.id === id);
if (idx < 0) return false;
const item = bucket[idx];
await restoreCookies([item.cookie]);
bucket.splice(idx, 1);
all[hk] = bucket;
trashSaveAll(all);
return true;
}
async function trashRestoreMany(ids) {
if (!ids?.length) return 0;
const all = trashLoadAll();
const hk = trashHostKey();
const bucket = all[hk] || [];
const idSet = new Set(ids);
const toRestore = bucket.filter(it => idSet.has(it.id)).map(it => it.cookie);
if (!toRestore.length) return 0;
// 直接调 ckMgr.set,不通过 restoreCookies(会触发 toast + render,循环性能差)
await Promise.all(toRestore.map(c => {
const days = c.expires ? Math.max(1, Math.round((c.expires - Date.now()) / 86400000)) : 365;
return ckMgr.set(c.key, c.value, {
domain: c.domain, path: c.path, days,
hostOnly: !!c._hostOnly, secure: !!c.secure, sameSite: c.sameSite || ''
});
}));
all[hk] = bucket.filter(it => !idSet.has(it.id));
trashSaveAll(all);
return toRestore.length;
}
function trashRemoveOne(id) {
const all = trashLoadAll();
const hk = trashHostKey();
all[hk] = (all[hk] || []).filter(it => it.id !== id);
trashSaveAll(all);
}
function trashRemoveMany(ids) {
if (!ids?.length) return 0;
const all = trashLoadAll();
const hk = trashHostKey();
const bucket = all[hk] || [];
const idSet = new Set(ids);
all[hk] = bucket.filter(it => !idSet.has(it.id));
trashSaveAll(all);
return bucket.length - all[hk].length;
}
function trashClearHost() {
const all = trashLoadAll();
delete all[trashHostKey()];
trashSaveAll(all);
}
function parseImport(text) {
text = (text || '').trim();
if (!text) return [];
if (text[0] === '[') {
try {
const arr = JSON.parse(text);
if (Array.isArray(arr)) return arr.map(o => ({
key: o.name || o.key, value: o.value == null ? '' : String(o.value),
domain: o.domain || '', path: o.path || '/'
})).filter(x => x.key);
} catch {}
throw new Error('json');
}
const out = [];
for (const line of text.split(/\r?\n/)) {
const segs = (line.indexOf(';') >= 0 && line.split('=').length > 2) ? line.split(';') : [line];
for (const seg of segs) {
const tt = seg.trim(); if (!tt) continue;
const i = tt.indexOf('=');
if (i < 1) continue;
out.push({ key: tt.slice(0, i).trim(), value: tt.slice(i + 1).trim(), domain: '', path: '/' });
}
}
return out;
}
async function showImport() {
const { p, close } = makeOv();
p.innerHTML = `
${escHtml(t('impTitle'))}
×
`;
p.querySelector('.x').addEventListener('click', close);
p.querySelector('.cancel').addEventListener('click', close);
p.querySelector('.save').addEventListener('click', async () => {
let items;
try { items = parseImport(p.querySelector('#imp-text').value); }
catch { return toast(t('tInvalidJson'), 'err'); }
if (!items.length) return toast(t('tNeedRow'), 'err');
const results = await Promise.all(items.map(it => ckMgr.set(it.key, it.value, { domain: it.domain, path: it.path })));
const ok = results.filter(Boolean).length;
toast(t('tImportedN', ok), ok ? 'ok' : 'err');
if (ok > 0) { close(); render(); }
});
}
/* ---------- 回收站面板 ---------- */
function showTrash() {
const { p, ov, close } = makeOv();
// 局部状态:与主面板独立,不互相干扰
const trashState = { sel: false, pool: new Set() };
function rebuild() {
const items = trashList();
// 清掉无效的选中项(被恢复或永久删除的 id)
const validIds = new Set(items.map(it => it.id));
for (const id of [...trashState.pool]) if (!validIds.has(id)) trashState.pool.delete(id);
if (!items.length && trashState.sel) trashState.sel = false;
p.innerHTML = `
×
`;
p.querySelector('.head h3').textContent = items.length ? t('trashCount', items.length) : t('trash');
p.querySelector('.x').addEventListener('click', close);
const list = p.querySelector('#trash-list');
const wrap = el('div', trashState.sel ? 'sel' : '');
list.appendChild(wrap);
if (!items.length) {
wrap.appendChild(el('div', 'empty', t('trashEmpty')));
} else {
for (const it of items) wrap.appendChild(renderTrashItem(it, rebuild, trashState));
}
renderTrashFoot(p, items, trashState, rebuild, close);
}
rebuild();
}
function renderTrashFoot(p, items, ts, rebuild, close) {
const foot = p.querySelector('.foot');
foot.innerHTML = '';
if (!items.length) {
foot.appendChild(mkBtn(t('cancel'), '', close));
return;
}
// 多选切换
foot.appendChild(mkBtn(ts.sel ? t('selExit') : t('sel'), '', () => {
ts.sel = !ts.sel;
if (!ts.sel) ts.pool.clear();
rebuild();
}));
if (ts.sel) {
const ids = items.map(it => it.id);
const inPool = ids.filter(id => ts.pool.has(id)).length;
const allSelected = inPool === ids.length;
const someSelected = inPool > 0 && !allSelected;
const selAllLabel = allSelected ? t('selNone') : (someSelected ? t('selAllPlus', ids.length - inPool) : t('selAll'));
const selAllBtn = mkBtn(selAllLabel, '', () => {
if (allSelected) ts.pool.clear();
else for (const id of ids) ts.pool.add(id);
rebuild();
});
foot.appendChild(selAllBtn);
const n = ts.pool.size;
foot.appendChild(mkBtn(t('trashRestoreN', n), 'pri', async () => {
if (!n) return toast(t('tNotSelected'), 'err');
const restored = await trashRestoreMany([...ts.pool]);
ts.pool.clear();
toast(t('trashRestoredN', restored), 'ok');
rebuild();
render();
}));
foot.appendChild(mkBtn(t('trashPurgeN', n), 'warn', async () => {
if (!n) return toast(t('tNotSelected'), 'err');
const yes = await dialog({ message: t('trashConfirmPurgeN', n), ok: t('trashPurge'), danger: true });
if (!yes) return;
const removed = trashRemoveMany([...ts.pool]);
ts.pool.clear();
toast(t('trashPurgedN', removed), 'ok');
rebuild();
}));
} else {
foot.appendChild(mkBtn(t('cancel'), '', close));
foot.appendChild(mkBtn(t('trashClear'), 'warn', async () => {
const yes = await dialog({ message: t('trashConfirmClear'), ok: t('trashPurge'), danger: true });
if (!yes) return;
trashClearHost();
toast(t('trashCleared'), 'ok');
rebuild();
}));
}
}
function renderTrashItem(it, refresh, ts) {
const c = it.cookie;
const w = el('div', 'ck');
const head = el('div', 'ck-h');
// 复选框(多选模式下显示)
const cb = el('span', 'cb');
if (ts.pool.has(it.id)) { cb.classList.add('on'); cb.textContent = '✓'; }
cb.addEventListener('click', e => {
e.stopPropagation();
if (cb.classList.toggle('on')) { cb.textContent = '✓'; ts.pool.add(it.id); }
else { cb.textContent = ''; ts.pool.delete(it.id); }
// 仅刷新 footer,避免重新渲染整个 list 导致 checkbox 抖动
const foot = w.closest('.panel')?.querySelector('.foot');
if (foot) {
const items = trashList();
renderTrashFoot(w.closest('.panel'), items, ts, refresh, () => w.closest('.ov').remove());
}
});
head.appendChild(cb);
head.append(el('span', 'ck-k', safeDecode(c.key)));
const tsEl = el('span', 'ck-size', t('trashTime', it.deletedAt));
head.appendChild(tsEl);
if (c.secure) head.appendChild(el('span', 'ck-flag', t('secure')));
if (c.httpOnly) head.appendChild(el('span', 'ck-flag', t('http')));
if (c.sameSite) head.appendChild(el('span', 'ck-flag', `${t('sameSite')}=${c.sameSite}`));
w.append(head, el('div', 'ck-v', safeDecode(c.value)));
const meta = el('div', 'ck-p');
meta.textContent = `${c.domain || location.hostname} | ${c.path || '/'}`;
w.appendChild(meta);
const a = el('div', 'ck-a');
a.append(
mkBtn(t('trashRestore'), 'pri sm', async () => {
await trashRestoreOne(it.id);
toast(t('trashRestored'), 'ok');
refresh();
render();
}),
mkBtn(t('trashPurge'), 'warn sm', () => {
trashRemoveOne(it.id);
toast(t('trashPurged'), 'ok');
refresh();
})
);
w.appendChild(a);
return w;
}
/* ---------- 主面板 ---------- */
let state = { all: [], filtered: [], sel: false, pool: new Map() };
function buildPanel() {
mainOv = document.createElement('div');
mainOv.className = 'ov';
const p = document.createElement('div');
p.className = 'panel';
p.innerHTML = `
×
`;
mainOv.appendChild(p);
sr.appendChild(mainOv);
p.querySelector('.x').addEventListener('click', closePanel);
mainOv.addEventListener('click', e => { if (e.target === mainOv) closePanel(); });
p.querySelector('.lang').addEventListener('click', () => {
setLang(lang === 'zh' ? 'en' : 'zh');
applyTitleAndLangBtn();
render();
});
applyTitleAndLangBtn();
}
function applyTitleAndLangBtn() {
if (!mainOv) return;
const titleEl = mainOv.querySelector('.title');
const langEl = mainOv.querySelector('.lang');
if (titleEl) titleEl.textContent = t('title') + (HAS_CS ? '' : t('titleCompat'));
if (langEl) langEl.textContent = lang === 'zh' ? 'EN' : '中';
}
function openPanel() { mainOv.classList.add('show'); render(); }
function closePanel() { mainOv.classList.remove('show'); }
function mkBtn(text, cls, fn) {
const b = document.createElement('button');
b.className = 'btn ' + (cls || '');
b.textContent = text;
b.addEventListener('click', fn);
return b;
}
async function render() {
const body = mainOv.querySelector('.body');
const foot = mainOv.querySelector('.foot');
body.innerHTML = `
`;
body.querySelector('[data-a=badd]').textContent = t('badd');
body.querySelector('[data-a=sadd]').textContent = t('sadd');
body.querySelector('[data-a=copyall]').textContent = t('copyall');
body.querySelector('[data-a=more]').textContent = t('menu') + ' ▾';
body.querySelector('[data-a=delall]').textContent = t('delall');
body.querySelector('#qk').placeholder = t('phKey');
body.querySelector('#qv').placeholder = t('phVal');
foot.innerHTML = '';
state.all = await ckMgr.get();
applyFiltersAndSort();
state.sel = false;
state.pool.clear();
renderList();
renderToolbar();
body.querySelector('[data-a=badd]').addEventListener('click', showBatch);
body.querySelector('[data-a=sadd]').addEventListener('click', () => showEdit({ mode: 'add' }));
body.querySelector('[data-a=copyall]').addEventListener('click', e => {
if (!state.all.length) return toast(t('tNoCookie'), 'err');
showCopyAsMenu(e.currentTarget, state.all);
});
body.querySelector('[data-a=more]').addEventListener('click', e => {
const trashCount = trashList().length;
popMenu(e.currentTarget, [
{ label: t('exp'), fn: () => exportJson(state.all) },
{ label: t('impJson'), fn: () => showImport() },
{ divider: true },
{ label: trashCount > 0 ? t('trashCount', trashCount) : t('trash'), fn: () => showTrash() },
{ divider: true },
{ label: t('theme') + ': ' + t('themeAuto'), active: prefs.theme === 'auto', fn: () => { setPref('theme','auto'); applyTheme(); } },
{ label: t('theme') + ': ' + t('themeLight'), active: prefs.theme === 'light', fn: () => { setPref('theme','light'); applyTheme(); } },
{ label: t('theme') + ': ' + t('themeDark'), active: prefs.theme === 'dark', fn: () => { setPref('theme','dark'); applyTheme(); } },
]);
});
body.querySelector('[data-a=delall]').addEventListener('click', async () => {
if (!state.all.length) return toast(t('tNoCookie'), 'err');
const yes = await dialog({ title: t('confirmDelAllTitle'), message: t('confirmDelAll', state.all.length), ok: t('delConfirm'), danger: true });
if (!yes) return;
const snapshot = state.all.slice();
trashAdd(snapshot);
await Promise.all(snapshot.map(c => ckMgr.del(c)));
await render();
toast(t('tDeleted'), 'ok', { label: t('tUndo'), fn: () => restoreCookies(snapshot) });
});
body.querySelector('[data-a=sort]').addEventListener('click', e => {
const cur = prefs.sort, dir = prefs.sortDir;
popMenu(e.currentTarget, [
{ label: t('sortKey'), active: cur === 'key', fn: () => { setPref('sort','key'); applyFiltersAndSort(); renderList(); renderToolbar(); } },
{ label: t('sortSize'), active: cur === 'size', fn: () => { setPref('sort','size'); applyFiltersAndSort(); renderList(); renderToolbar(); } },
{ label: t('sortExpires'), active: cur === 'expires', fn: () => { setPref('sort','expires'); applyFiltersAndSort(); renderList(); renderToolbar(); } },
{ divider: true },
{ label: t('sortAsc'), active: dir === 'asc', fn: () => { setPref('sortDir','asc'); applyFiltersAndSort(); renderList(); renderToolbar(); } },
{ label: t('sortDesc'), active: dir === 'desc', fn: () => { setPref('sortDir','desc'); applyFiltersAndSort(); renderList(); renderToolbar(); } },
]);
});
body.querySelector('[data-a=group]').addEventListener('click', e => {
popMenu(e.currentTarget, [
{ label: t('groupNone'), active: prefs.group === 'none', fn: () => { setPref('group','none'); renderList(); renderToolbar(); } },
{ label: t('groupDomain'), active: prefs.group === 'domain', fn: () => { setPref('group','domain'); renderList(); renderToolbar(); } },
{ label: t('groupRoot'), active: prefs.group === 'root', fn: () => { setPref('group','root'); renderList(); renderToolbar(); } },
]);
});
body.querySelector('[data-a=fsec]').addEventListener('click', () => {
setPref('secureOnly', !prefs.secureOnly);
applyFiltersAndSort(); renderList(); renderToolbar();
});
body.querySelector('[data-a=fhttp]').addEventListener('click', () => {
setPref('httpOnly', !prefs.httpOnly);
applyFiltersAndSort(); renderList(); renderToolbar();
});
const qk = body.querySelector('#qk'), qv = body.querySelector('#qv');
const onSearch = () => { applyFiltersAndSort(); renderList(); renderToolbar(); };
state.qk = qk; state.qv = qv;
qk.addEventListener('input', onSearch);
qv.addEventListener('input', onSearch);
}
function applyFiltersAndSort() {
const qk = (state.qk?.value || '').trim().toLowerCase();
const qv = (state.qv?.value || '').trim().toLowerCase();
let arr = state.all.filter(c =>
safeDecode(c.key).toLowerCase().includes(qk) &&
safeDecode(c.value).toLowerCase().includes(qv)
);
if (prefs.secureOnly) arr = arr.filter(c => c.secure);
if (prefs.httpOnly) arr = arr.filter(c => c.httpOnly);
const dir = prefs.sortDir === 'desc' ? -1 : 1;
const key = prefs.sort;
arr.sort((a, b) => {
let av, bv;
if (key === 'size') { av = cookieSize(a); bv = cookieSize(b); }
else if (key === 'expires') { av = a.expires || Infinity; bv = b.expires || Infinity; }
else { av = safeDecode(a.key).toLowerCase(); bv = safeDecode(b.key).toLowerCase(); }
return av < bv ? -dir : av > bv ? dir : 0;
});
state.filtered = arr;
}
function renderToolbar() {
const body = mainOv.querySelector('.body');
if (!body) return;
const sortBtn = body.querySelector('[data-a=sort]');
if (sortBtn) {
const sortLabel = { key: t('sortKey'), size: t('sortSize'), expires: t('sortExpires') }[prefs.sort] || t('sortKey');
const arrow = prefs.sortDir === 'desc' ? '↓' : '↑';
sortBtn.textContent = `${t('sort')}: ${sortLabel} ${arrow}`;
}
const groupBtn = body.querySelector('[data-a=group]');
if (groupBtn) {
const groupLabel = { none: t('groupNone'), domain: t('groupDomain'), root: t('groupRoot') }[prefs.group] || t('groupNone');
groupBtn.textContent = `${t('group')}: ${groupLabel}`;
}
const fsec = body.querySelector('[data-a=fsec]');
const fhttp = body.querySelector('[data-a=fhttp]');
if (fsec) { fsec.textContent = t('filterSecure'); fsec.classList.toggle('on', !!prefs.secureOnly); }
if (fhttp) { fhttp.textContent = t('filterHttp'); fhttp.classList.toggle('on', !!prefs.httpOnly); }
const stats = body.querySelector('[data-a=stats]');
if (stats) stats.textContent = t('statsN', state.filtered.length, state.all.length);
}
function ckId(c) { return `${c.key}||${c.domain}||${c.path}`; }
function rootDomain(d) {
if (!d) return location.hostname;
const h = d.replace(/^\./, '');
// crude: take last two labels (works for most public domains; doesn't handle co.uk etc but good enough)
const parts = h.split('.');
if (parts.length <= 2) return h;
return parts.slice(-2).join('.');
}
function groupCookies(arr) {
if (prefs.group === 'none') return [{ key: '', items: arr }];
const map = new Map();
for (const c of arr) {
const k = prefs.group === 'root'
? rootDomain(c.domain || location.hostname)
: (c.domain || location.hostname).replace(/^\./, '');
if (!map.has(k)) map.set(k, []);
map.get(k).push(c);
}
return [...map.entries()].sort((a, b) => a[0] < b[0] ? -1 : 1).map(([key, items]) => ({ key, items }));
}
function renderList() {
const list = mainOv.querySelector('#list');
list.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = state.sel ? 'sel' : '';
list.appendChild(wrap);
if (!state.filtered.length) {
const e = document.createElement('div');
e.className = 'empty';
e.textContent = t('empty');
wrap.appendChild(e);
} else if (prefs.group === 'none') {
for (const c of state.filtered) wrap.appendChild(renderItem(c));
} else {
const groups = groupCookies(state.filtered);
for (const g of groups) {
const groupEl = document.createElement('div');
const collapsed = !!prefs.collapsed[g.key];
groupEl.className = 'group' + (collapsed ? ' collapsed' : '');
const header = document.createElement('div');
header.className = 'group-h';
const arrow = document.createElement('span'); arrow.className = 'arrow'; arrow.textContent = '▼';
const name = document.createElement('span'); name.textContent = g.key;
const count = document.createElement('span'); count.className = 'count'; count.textContent = String(g.items.length);
header.append(arrow, name, count);
header.addEventListener('click', () => {
prefs.collapsed[g.key] = !collapsed;
savePrefs();
renderList();
});
groupEl.appendChild(header);
const body = document.createElement('div');
body.className = 'group-body';
for (const c of g.items) body.appendChild(renderItem(c));
groupEl.appendChild(body);
wrap.appendChild(groupEl);
}
}
renderFoot();
}
function cookieSize(c) {
// 估算 cookie 在 header 中的字节数:name=value + flags
return new Blob([`${c.key}=${c.value}`]).size;
}
// 通用:创建
function el(tag, cls, txt) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (txt != null) e.textContent = txt;
return e;
}
function renderItem(c) {
const w = el('div', 'ck'), id = ckId(c);
const head = el('div', 'ck-h');
const cb = el('span', 'cb');
if (state.pool.has(id)) { cb.classList.add('on'); cb.textContent = '✓'; }
cb.addEventListener('click', e => {
e.stopPropagation();
if (cb.classList.toggle('on')) { cb.textContent = '✓'; state.pool.set(id, c); }
else { cb.textContent = ''; state.pool.delete(id); }
renderFoot();
});
head.append(cb, el('span', 'ck-k', safeDecode(c.key)));
const sz = cookieSize(c);
const sizeEl = el('span', 'ck-size' + (sz > 4096 ? ' warn' : ''), t('sizeBytes', sz));
if (sz > 4096) sizeEl.title = t('sizeWarn');
head.appendChild(sizeEl);
if (c.secure) head.appendChild(el('span', 'ck-flag', t('secure')));
if (c.httpOnly) head.appendChild(el('span', 'ck-flag', t('http')));
if (c.sameSite) head.appendChild(el('span', 'ck-flag', `${t('sameSite')}=${c.sameSite}`));
w.append(head, el('div', 'ck-v', safeDecode(c.value)));
// 详情区
const detail = el('div', 'ck-detail');
const dRow = (k, v) => {
const r = el('div', 'row-d');
r.append(el('span', 'k-d', k), el('span', 'v-d', String(v)));
detail.appendChild(r);
};
dRow('Domain', c.domain || location.hostname);
dRow('Path', c.path || '/');
if (c.expires) dRow('Expires', new Date(c.expires).toLocaleString());
dRow('Secure', !!c.secure);
if (c.httpOnly !== undefined) dRow('HttpOnly', !!c.httpOnly);
if (c.sameSite) dRow('SameSite', c.sameSite);
dRow('Size', t('sizeBytes', sz));
w.appendChild(detail);
// 操作区
const a = el('div', 'ck-a');
const doCopy = async (text) => { const ok = await copy(text); toast(ok ? t('tCopied') : t('tCopyFail'), ok ? 'ok' : 'err'); };
a.append(
mkBtn(t('modify'), 'pri sm', () => showEdit({ mode: 'modify', cookie: c })),
mkBtn(t('copyKey'), 'sm', () => doCopy(safeDecode(c.key))),
mkBtn(t('copyVal'), 'sm', () => doCopy(safeDecode(c.value))),
mkBtn(t('copyAs'), 'sm', e => showCopyAsMenu(e.currentTarget, [c])),
mkBtn(t('del'), 'warn sm', async () => {
if (!await dialog({ message: t('confirmDelOne', safeDecode(c.key)), ok: t('delConfirm'), danger: true })) return;
trashAdd([c]);
await ckMgr.del(c); await render();
toast(t('tDeleted'), 'ok', { label: t('tUndo'), fn: () => restoreCookies([c]) });
})
);
w.appendChild(a);
head.style.cursor = 'pointer';
head.addEventListener('click', e => {
if (cb.contains(e.target)) return;
w.classList.toggle('open');
});
return w;
}
function renderFoot() {
const foot = mainOv.querySelector('.foot');
foot.innerHTML = '';
foot.appendChild(mkBtn(state.sel ? t('selExit') : t('sel'), '', () => {
state.sel = !state.sel;
if (!state.sel) state.pool.clear();
renderList();
}));
if (state.sel) {
const n = state.pool.size;
// 全选状态:基于当前 filtered 列表
const fIds = state.filtered.map(ckId);
const fInPool = fIds.filter(id => state.pool.has(id)).length;
const allFiltered = fIds.length > 0 && fInPool === fIds.length;
const someFiltered = fInPool > 0 && !allFiltered;
const selAllLabel = allFiltered ? t('selNone') : (someFiltered ? t('selAllPlus', fIds.length - fInPool) : t('selAll'));
const selAllBtn = mkBtn(selAllLabel, '', () => {
if (!fIds.length) return toast(t('tEmptyList'), 'err');
if (allFiltered) {
for (const id of fIds) state.pool.delete(id);
} else {
for (const c of state.filtered) state.pool.set(ckId(c), c);
}
renderList();
});
if (!fIds.length) selAllBtn.disabled = true;
foot.appendChild(selAllBtn);
foot.appendChild(mkBtn(t('copyN', n), 'pri', e => {
if (!n) return toast(t('tNotSelected'), 'err');
showCopyAsMenu(e.currentTarget, [...state.pool.values()]);
}));
foot.appendChild(mkBtn(t('expSel'), '', () => {
if (!n) return toast(t('tNotSelected'), 'err');
exportJson([...state.pool.values()]);
}));
foot.appendChild(mkBtn(t('delN', n), 'warn', async () => {
if (!n) return toast(t('tNotSelected'), 'err');
const yes = await dialog({ message: t('confirmDelN', n), ok: t('delConfirm'), danger: true });
if (!yes) return;
const snapshot = [...state.pool.values()];
trashAdd(snapshot);
await Promise.all(snapshot.map(c => ckMgr.del(c)));
state.pool.clear();
await render();
toast(t('tDeleted'), 'ok', { label: t('tUndo'), fn: () => restoreCookies(snapshot) });
}));
}
}
/* ---------- 编辑 / 批量添加 弹窗 ---------- */
function makeOv() {
const ov = document.createElement('div'); ov.className = 'ov show';
const p = document.createElement('div'); p.className = 'panel';
ov.appendChild(p); sr.appendChild(ov);
return { ov, p, close: () => ov.remove() };
}
function showEdit({ mode, cookie }) {
const { p, close } = makeOv();
const isAdd = mode === 'add', c = cookie || {};
p.innerHTML = `
×
`;
const $ = s => p.querySelector(s);
p.querySelector('.head h3').textContent = isAdd ? t('addNew') : t('edit');
const labels = p.querySelectorAll('.row label');
labels[0].textContent = t('lblKey');
labels[1].textContent = t('lblValue');
labels[2].textContent = t('lblDomain');
labels[3].textContent = t('lblPath');
labels[4].textContent = t('lblDays');
$('#ed').placeholder = t('phEmptyDomain');
p.querySelector('.cancel').textContent = t('cancel');
p.querySelector('.save').textContent = t('save');
$('#ek').value = isAdd ? '' : safeDecode(c.key);
$('#ev').value = isAdd ? '' : safeDecode(c.value);
$('#ed').value = (!isAdd && c.domain && c.domain !== 'N/A') ? c.domain : (HAS_CS ? location.hostname : '');
$('#ep').value = (!isAdd && c.path) ? c.path : '/';
p.querySelector('.x').addEventListener('click', close);
p.querySelector('.cancel').addEventListener('click', close);
p.querySelector('.save').addEventListener('click', async () => {
const key = $('#ek').value.trim();
if (!key) return toast(t('tNeedKey'), 'err');
const value = $('#ev').value;
const inputDomain = $('#ed').value.trim();
const path = $('#ep').value.trim() || '/';
const days = parseInt($('#eday').value, 10);
// 关键:如果原 cookie 是 host-only,且用户没改 domain(或改成与 hostname 相同),保持 host-only
// 这样 cookieStore.set 不会创建一个 Domain= 属性的副本,导致同名两条
let hostOnly = false;
let domainToSet = inputDomain;
if (!isAdd && c._hostOnly) {
// 原本就是 host-only;如果用户没动 domain 输入框(仍等于 location.hostname),保持 host-only
if (inputDomain === '' || inputDomain === location.hostname) {
hostOnly = true;
domainToSet = '';
}
} else if (isAdd) {
// 新增时:如果用户填的就是当前 hostname,默认按 host-only 创建(更安全,不影响子域)
if (inputDomain === '' || inputDomain === location.hostname) {
hostOnly = true;
domainToSet = '';
}
}
// 修改模式:精准判断是否需要先删旧
// - 同 (key, domain, path, hostOnly):直接 set 覆盖(cookieStore.set 会原位更新)
// - 改了任意一项:删旧的,写新的
if (!isAdd) {
const sameKey = c.key === key;
const samePath = (c.path || '/') === path;
const sameHostOnly = !!c._hostOnly === hostOnly;
const sameDomain = hostOnly ? sameHostOnly : (c.domain || '') === domainToSet;
if (!(sameKey && samePath && sameHostOnly && sameDomain)) {
await ckMgr.del(c);
}
}
const setOpt = {
domain: domainToSet,
hostOnly,
path,
days: isNaN(days) ? 365 : days,
secure: !isAdd ? !!c.secure : false,
sameSite: !isAdd ? c.sameSite : ''
};
const ok = await ckMgr.set(key, value, setOpt);
if (ok) { toast(isAdd ? t('tAdded') : t('tModified'), 'ok'); close(); render(); }
});
}
function showBatch() {
const { p, close } = makeOv();
p.innerHTML = `
×
`;
const $ = s => p.querySelector(s);
p.querySelector('.head h3').textContent = t('batch');
const lbls = p.querySelectorAll('.row label');
lbls[0].textContent = t('lblDomain');
lbls[1].textContent = t('lblPath');
lbls[2].textContent = t('lblParse');
$('#bd').placeholder = t('phEmptyDomain');
$('#bparse').placeholder = t('phParse');
$('#brow').textContent = t('addRow');
p.querySelector('.cancel').textContent = t('cancel');
p.querySelector('.save').textContent = t('batchSave');
$('#bd').value = HAS_CS ? location.hostname : '';
const rows = $('#brows');
const addRow = (k = '', v = '') => {
const r = document.createElement('div'); r.className = 'br';
const ki = document.createElement('input'); ki.type = 'text'; ki.placeholder = t('lblKey'); ki.className = 'k'; ki.value = k;
const vi = document.createElement('input'); vi.type = 'text'; vi.placeholder = t('lblValue'); vi.className = 'v'; vi.value = v;
const x = document.createElement('button'); x.className = 'rm'; x.textContent = '×';
x.addEventListener('click', () => r.remove());
r.appendChild(ki); r.appendChild(vi); r.appendChild(x);
rows.appendChild(r);
};
addRow();
$('#brow').addEventListener('click', () => addRow());
// 行优先 + 启发式:仅当一行有多个 = 才按 ; 切,避免 value 内含分号被截断
const parsePairs = text => {
const out = [];
for (const line of text.split(/\r?\n/)) {
const segs = (line.indexOf(';') >= 0 && line.split('=').length > 2) ? line.split(';') : [line];
for (const seg of segs) {
const tt = seg.trim(); if (!tt) continue;
let i = tt.indexOf('='); if (i < 0) i = tt.indexOf(':');
if (i < 1) continue;
out.push([tt.slice(0, i).trim(), tt.slice(i + 1).trim()]);
}
}
return out;
};
$('#bparse').addEventListener('input', e => {
const pairs = parsePairs(e.target.value);
if (!pairs.length) return;
rows.innerHTML = '';
for (const [k, v] of pairs) addRow(k, v);
});
p.querySelector('.x').addEventListener('click', close);
p.querySelector('.cancel').addEventListener('click', close);
p.querySelector('.save').addEventListener('click', async () => {
const domain = $('#bd').value.trim();
const path = $('#bp').value.trim() || '/';
const items = [...rows.querySelectorAll('.br')]
.map(r => ({ key: r.querySelector('.k').value.trim(), value: r.querySelector('.v').value }))
.filter(x => x.key);
if (!items.length) return toast(t('tNeedRow'), 'err');
const results = await Promise.all(items.map(it => ckMgr.set(it.key, it.value, { domain, path })));
const ok = results.filter(Boolean).length;
toast(t('tBatchOk', ok, items.length), ok ? 'ok' : 'err');
if (ok > 0) { close(); render(); }
});
}
/* ---------- 浮标 + 拖拽 ---------- */
function buildFab() {
fab = document.createElement('div');
fab.id = 'fab'; fab.title = t('title'); fab.textContent = 'C';
sr.appendChild(fab);
const pos = gmGet(POS_KEY, { bottom: '90px', right: '14px' });
Object.assign(fab.style, { top: pos.top || 'auto', left: pos.left || 'auto', bottom: pos.bottom || 'auto', right: pos.right || 'auto' });
let dragging = false, moved = false, sx = 0, sy = 0, ox = 0, oy = 0;
const TH = 5;
const down = e => {
const t = e.touches ? e.touches[0] : e;
dragging = true; moved = false; sx = t.clientX; sy = t.clientY;
const r = fab.getBoundingClientRect(); ox = r.left; oy = r.top;
};
const move = e => {
if (!dragging) return;
const t = e.touches ? e.touches[0] : e;
const dx = t.clientX - sx, dy = t.clientY - sy;
if (!moved && Math.hypot(dx, dy) < TH) return;
moved = true; e.preventDefault();
const w = fab.offsetWidth, h = fab.offsetHeight;
const nx = Math.max(0, Math.min(window.innerWidth - w, ox + dx));
const ny = Math.max(0, Math.min(window.innerHeight - h, oy + dy));
fab.style.left = nx + 'px'; fab.style.top = ny + 'px';
fab.style.right = 'auto'; fab.style.bottom = 'auto';
};
const up = () => {
if (!dragging) return;
dragging = false;
if (moved) {
const r = fab.getBoundingClientRect();
gmSet(POS_KEY, { top: r.top + 'px', left: r.left + 'px', bottom: 'auto', right: 'auto' });
}
};
fab.addEventListener('touchstart', down, { passive: false });
fab.addEventListener('touchmove', move, { passive: false });
fab.addEventListener('touchend', up);
fab.addEventListener('touchcancel', up);
fab.addEventListener('mousedown', down);
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
fab.addEventListener('click', () => {
if (moved) { moved = false; return; }
mainOv.classList.contains('show') ? closePanel() : openPanel();
});
}
/* ---------- 启动 ---------- */
function boot() {
ensureHost();
startGuardian();
try { if (typeof GM_registerMenuCommand === 'function') GM_registerMenuCommand(t('title'), openPanel); } catch {}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();