diff --git a/example/tests/gm_value_test.js b/example/tests/gm_value_test.js
new file mode 100644
index 000000000..d0fee5b33
--- /dev/null
+++ b/example/tests/gm_value_test.js
@@ -0,0 +1,1149 @@
+// ==UserScript==
+// @name GM_addValueChangeListener Test
+// @namespace http://tampermonkey.net/
+// @version 0.1.0
+// @description Test GM_addValueChangeListener with real iframes — dashboard in main, panels in iframes
+// @match https://example.com/*
+// @grant GM_getValue
+// @grant GM_setValue
+// @grant GM_deleteValue
+// @grant GM_addValueChangeListener
+// @grant GM_removeValueChangeListener
+// @run-at document-idle
+// ==/UserScript==
+
+(function () {
+ 'use strict';
+
+ if (!location.search.includes('testGMAddValueChangeListener')) return;
+
+ document.documentElement.appendChild(document.createElement("style")).textContent=`@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700,800&display=swap');`;
+
+ /* ══════════════════════════════════════════════════════════
+ SHARED CONSTANTS
+ ══════════════════════════════════════════════════════════ */
+ const FRAME_IDS = ['main', 'iframe1', 'iframe2', 'iframe3'];
+
+ const WRITE_KEY = {
+ main: 'key_from_main',
+ iframe1: 'key_from_iframe1',
+ iframe2: 'key_from_iframe2',
+ iframe3: 'key_from_iframe3',
+ };
+ const ALL_KEYS = Object.values(WRITE_KEY);
+
+ const ACCENT = {
+ main: '#0369a1',
+ iframe1: '#b91c1c',
+ iframe2: '#15803d',
+ iframe3: '#a16207',
+ };
+
+ const LABEL = {
+ main: '🖥 Main Frame',
+ iframe1: '📦 iFrame #1',
+ iframe2: '📦 iFrame #2',
+ iframe3: '📦 iFrame #3',
+ };
+
+ /* ══════════════════════════════════════════════════════════
+ CONTEXT DETECTION
+ ══════════════════════════════════════════════════════════ */
+ const isMain = window.self === window.top;
+ const frameId = new URLSearchParams(location.search).get('frameId')
+ || (isMain ? 'main' : 'unknown');
+
+ /* ══════════════════════════════════════════════════════════
+ HELPERS
+ ══════════════════════════════════════════════════════════ */
+ function escHtml(s) {
+ return String(s).replace(/[&<>"']/g, ch => ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ }[ch]));
+ }
+
+ function fmtVal(v) {
+ return v === undefined
+ ? 'not set'
+ : escHtml(JSON.stringify(v));
+ }
+
+ function nowTime() {
+ return new Date().toLocaleTimeString('en-GB', { hour12: false });
+ }
+
+ /* ══════════════════════════════════════════════════════════
+ MSG BUS
+ ══════════════════════════════════════════════════════════ */
+ const MSG_NS = 'GMTEST_';
+ const TARGET_ORIGIN = location.origin;
+
+ function reportLog(entry) {
+ top.postMessage({ t: MSG_NS + 'LOG', frameId, entry }, TARGET_ORIGIN);
+ }
+
+ function reportKV(kvMap) {
+ top.postMessage({ t: MSG_NS + 'KV', frameId, kvMap }, TARGET_ORIGIN);
+ }
+
+ function reportReady() {
+ top.postMessage({ t: MSG_NS + 'READY', frameId }, TARGET_ORIGIN);
+ }
+
+ function reportListeners(ids) {
+ top.postMessage({ t: MSG_NS + 'LISTENERS', frameId, ids }, TARGET_ORIGIN);
+ }
+
+ function sendCmd(win, cmd, data = {}) {
+ win.postMessage({ t: MSG_NS + 'CMD', cmd, ...data }, TARGET_ORIGIN);
+ }
+
+ /* ══════════════════════════════════════════════════════════
+ IFRAME FRAME LOGIC
+ ══════════════════════════════════════════════════════════ */
+ if (!isMain) {
+ const myKey = WRITE_KEY[frameId];
+ if (!myKey) return;
+
+ let iframeShadow = null;
+
+ buildIframeBody();
+
+ const listenerIds = {};
+ registerAllListeners();
+
+ pushKV();
+ reportReady();
+
+ window.addEventListener('message', async (e) => {
+ if (e.origin !== location.origin) return;
+ if (!e.data || !e.data.t) return;
+ if (e.data.t !== MSG_NS + 'CMD') return;
+
+ const { cmd, value } = e.data;
+
+ if (cmd === 'SET_STRING') await doSet(`hello_${Date.now()}`);
+ if (cmd === 'SET_NUMBER') await doSet(Math.floor(Math.random() * 99999));
+ if (cmd === 'SET_OBJECT') await doSet({ ts: Date.now(), from: frameId });
+ if (cmd === 'SET_NULL') await doSet(null);
+ if (cmd === 'SET_CUSTOM') await doSet(value);
+ if (cmd === 'DELETE') await doDel();
+
+ if (cmd === 'REMOVE_LISTENERS') {
+ removeAllListeners();
+ }
+
+ if (cmd === 'REREGISTER_LISTENERS') {
+ removeAllListeners();
+ registerAllListeners();
+ }
+ });
+
+ async function doSet(v) {
+ await GM_setValue(myKey, v);
+ iLog(`✏️ Set ${escHtml(myKey)} = ${escHtml(JSON.stringify(v))}`, 'info');
+ await pushKV();
+ }
+
+ async function doDel() {
+ await GM_deleteValue(myKey);
+ iLog(`🗑 Deleted ${escHtml(myKey)}`, 'warn');
+ await pushKV();
+ }
+
+ function registerAllListeners() {
+ for (const key of ALL_KEYS) {
+ if (listenerIds[key] != null) continue;
+
+ const id = GM_addValueChangeListener(key, async (name, oldVal, newVal, remote) => {
+ const tag = remote ? '🌐 remote' : '📍 local';
+
+ iLog(
+ `${tag} ${escHtml(name)}: ${escHtml(JSON.stringify(oldVal))} → ${escHtml(JSON.stringify(newVal))}`,
+ remote ? 'good' : 'warn'
+ );
+
+ await pushKV();
+ });
+
+ listenerIds[key] = id;
+ iLog(`👂 Listener on ${escHtml(key)} (id=${escHtml(id)})`, 'info');
+ }
+
+ reportListeners(Object.entries(listenerIds).map(([k, id]) => ({ key: k, id })));
+ }
+
+ function removeAllListeners() {
+ for (const [key, id] of Object.entries(listenerIds)) {
+ try {
+ GM_removeValueChangeListener(id);
+ } catch (_) { }
+ delete listenerIds[key];
+ }
+
+ iLog('🔇 All listeners removed', 'warn');
+ reportListeners([]);
+ }
+
+ async function pushKV() {
+ const kvMap = {};
+ for (const k of ALL_KEYS) {
+ kvMap[k] = await GM_getValue(k, undefined); // iframe
+ }
+ reportKV(kvMap);
+ }
+
+ function iLog(msg, type = '') {
+ reportLog({ msg, type, t: nowTime() });
+
+ if (!iframeShadow) return;
+
+ const logBox = iframeShadow.getElementById('iframe-log');
+ if (!logBox) return;
+
+ const line = document.createElement('div');
+ line.className = 'log-line';
+ line.innerHTML = `
+ ${escHtml(nowTime())}
+ ${msg}
+ `;
+
+ logBox.appendChild(line);
+ logBox.scrollTop = logBox.scrollHeight;
+ }
+
+ function buildIframeBody() {
+ const accent = ACCENT[frameId] || '#334155';
+
+ document.documentElement.style.cssText = `
+ margin:0;
+ padding:0;
+ width:100%;
+ height:100%;
+ background:#f8fafc;
+ `;
+
+ document.body.style.cssText = `
+ margin:0;
+ padding:0;
+ width:100%;
+ height:100%;
+ background:#f8fafc;
+ overflow:hidden;
+ `;
+
+ document.body.textContent = '';
+
+ const host = document.createElement('div');
+ host.style.cssText = `
+ all:initial;
+ display:block;
+ width:100%;
+ height:100%;
+ `;
+ document.body.appendChild(host);
+
+ iframeShadow = host.attachShadow({ mode: 'open' });
+
+ const sty = new CSSStyleSheet();
+ sty.replaceSync(`
+
+ *, *::before, *::after {
+ all:unset;
+ box-sizing:border-box;
+ }
+
+ #iframe-shell {
+ display:flex;
+ flex-direction:column;
+ gap:6px;
+ width:100%;
+ height:100%;
+ padding:8px;
+ overflow:hidden;
+ background:#f8fafc;
+ color:#0f172a;
+ font-family:'JetBrains Mono','Courier New',monospace;
+ font-size:12px;
+ }
+
+ .iframe-title {
+ display:block;
+ color:${accent};
+ font-family:'JetBrains Mono',monospace;
+ font-size:13px;
+ font-weight:800;
+ letter-spacing:.05em;
+ flex-shrink:0;
+ }
+
+ .iframe-subtitle {
+ display:block;
+ color:#475569;
+ font-size:10px;
+ letter-spacing:.08em;
+ flex-shrink:0;
+ }
+
+ .log-box {
+ display:block;
+ flex:1;
+ min-height:0;
+ overflow-y:auto;
+ background:#ffffff;
+ border:1px solid #cbd5e1;
+ border-radius:7px;
+ padding:6px 8px;
+ font-size:11px;
+ line-height:1.65;
+ scrollbar-width:thin;
+ scrollbar-color:#94a3b8 transparent;
+ box-shadow:0 1px 2px rgba(15,23,42,.08);
+ }
+
+ .log-box::-webkit-scrollbar {
+ width:5px;
+ }
+
+ .log-box::-webkit-scrollbar-thumb {
+ background:#94a3b8;
+ border-radius:999px;
+ }
+
+ .log-line {
+ display:flex;
+ gap:6px;
+ align-items:baseline;
+ }
+
+ .log-time {
+ color:#64748b;
+ flex-shrink:0;
+ }
+
+ .log-msg {
+ color:#334155;
+ }
+
+ .log-msg.good {
+ color:#15803d;
+ font-weight:700;
+ }
+
+ .log-msg.warn {
+ color:#a16207;
+ font-weight:700;
+ }
+
+ .log-msg.info {
+ color:#0369a1;
+ font-weight:700;
+ }
+
+ b {
+ font-weight:700;
+ }
+
+ i {
+ font-style:italic;
+ }
+
+ small {
+ font-size:.85em;
+ opacity:.75;
+ }
+ `);
+
+ // スタイルシートを適用
+ iframeShadow.adoptedStyleSheets = [sty];
+
+
+ const shell = document.createElement('div');
+ shell.id = 'iframe-shell';
+ shell.innerHTML = `
+
${escHtml(LABEL[frameId])}
+ Controlled by main frame dashboard ↑
+
+ `;
+
+ iframeShadow.appendChild(shell);
+ }
+
+ return;
+ }
+
+ /* ══════════════════════════════════════════════════════════
+ MAIN FRAME LOGIC
+ ══════════════════════════════════════════════════════════ */
+ const state = {
+ kv: {},
+ logs: {},
+ listenerSummary: {},
+ };
+
+ FRAME_IDS.forEach(id => {
+ state.kv[id] = {};
+ state.logs[id] = [];
+ state.listenerSummary[id] = [];
+ });
+
+ const iframeWindows = {};
+
+ /* ── Shadow DOM setup ─────────────────────────────────── */
+ const host = document.createElement('div');
+ host.style.cssText = [
+ 'all:initial',
+ 'position:fixed',
+ 'inset:0',
+ 'width:100vw',
+ 'height:100vh',
+ 'z-index:2147483647',
+ 'pointer-events:none',
+ ].join(';');
+
+ document.body.appendChild(host);
+
+ const shadow = host.attachShadow({ mode: 'open' });
+
+ /* ── Styles ─────────────────────────────────────────────── */
+ const sty = new CSSStyleSheet();
+ sty.replaceSync(`
+
+ *, *::before, *::after {
+ all:unset;
+ box-sizing:border-box;
+ }
+
+ #shell {
+ display:grid;
+ position:fixed;
+ inset:0;
+ width:100vw;
+ height:100vh;
+ grid-template-columns:1fr 340px;
+ grid-template-rows:100vh;
+ overflow:hidden;
+ pointer-events:all;
+ background:#f1f5f9;
+ color:#0f172a;
+ font-family:'JetBrains Mono','Courier New',monospace;
+ font-size:12px;
+ }
+
+ #dashboard {
+ display:flex;
+ flex-direction:column;
+ overflow-y:auto;
+ overflow-x:hidden;
+ padding:16px;
+ gap:12px;
+ background:#f8fafc;
+ border-right:1px solid #cbd5e1;
+ scrollbar-width:thin;
+ scrollbar-color:#94a3b8 transparent;
+ }
+
+ #dashboard::-webkit-scrollbar {
+ width:6px;
+ }
+
+ #dashboard::-webkit-scrollbar-thumb {
+ background:#94a3b8;
+ border-radius:999px;
+ }
+
+ #iframe-strip {
+ display:flex;
+ flex-direction:column;
+ overflow:hidden;
+ background:#e2e8f0;
+ }
+
+ .iframe-wrap {
+ display:flex;
+ flex:1;
+ flex-direction:column;
+ border-bottom:1px solid #cbd5e1;
+ overflow:hidden;
+ background:#f8fafc;
+ }
+
+ .iframe-wrap:last-child {
+ border-bottom:none;
+ }
+
+ .iframe-wrap iframe {
+ display:block;
+ flex:1;
+ width:100%;
+ height:100%;
+ border:none;
+ background:#f8fafc;
+ }
+
+ #topbar {
+ display:flex;
+ align-items:center;
+ gap:10px;
+ flex-shrink:0;
+ background:#ffffff;
+ border:1px solid #cbd5e1;
+ border-radius:10px;
+ padding:10px 12px;
+ box-shadow:0 1px 3px rgba(15,23,42,.08);
+ }
+
+ #topbar-title {
+ font-family:'JetBrains Mono',monospace;
+ font-size:15px;
+ font-weight:800;
+ color:#0f172a;
+ letter-spacing:.08em;
+ flex:1;
+ }
+
+ button {
+ display:inline-block;
+ font-family:'JetBrains Mono','Courier New',monospace;
+ font-size:11px;
+ font-weight:700;
+ padding:5px 10px;
+ border-radius:6px;
+ border:1px solid;
+ cursor:pointer;
+ letter-spacing:.03em;
+ transition:background .12s, opacity .12s, transform .08s;
+ white-space:nowrap;
+ pointer-events:all;
+ background:#ffffff;
+ }
+
+ button:hover {
+ opacity:.85;
+ transform:translateY(-1px);
+ }
+
+ button:active {
+ opacity:.7;
+ transform:translateY(0);
+ }
+
+ button.danger {
+ color:#991b1b !important;
+ border-color:#fecaca !important;
+ background:#fff1f2 !important;
+ }
+
+ .p-card {
+ display:block;
+ border:1px solid #cbd5e1;
+ border-radius:12px;
+ padding:12px;
+ background:#ffffff;
+ flex-shrink:0;
+ box-shadow:0 1px 3px rgba(15,23,42,.08);
+ }
+
+ .p-title {
+ display:block;
+ font-family:'JetBrains Mono',monospace;
+ font-size:14px;
+ font-weight:800;
+ letter-spacing:.05em;
+ margin-bottom:4px;
+ }
+
+ .p-subtitle {
+ display:block;
+ font-size:10px;
+ color:#475569;
+ letter-spacing:.08em;
+ margin-bottom:10px;
+ }
+
+ .sec {
+ display:block;
+ margin-top:9px;
+ }
+
+ .sec-label {
+ display:block;
+ font-size:10px;
+ font-weight:700;
+ letter-spacing:.14em;
+ text-transform:uppercase;
+ color:#475569;
+ margin-bottom:5px;
+ }
+
+ .hr {
+ display:block;
+ height:1px;
+ background:#e2e8f0;
+ margin:10px 0;
+ }
+
+ .btn-row {
+ display:flex;
+ flex-wrap:wrap;
+ gap:5px;
+ }
+
+ .kv-table {
+ display:grid;
+ grid-template-columns:1fr 1fr;
+ gap:5px;
+ }
+
+ .kv-card {
+ display:block;
+ background:#f8fafc;
+ border:1px solid #cbd5e1;
+ border-radius:7px;
+ padding:6px 8px;
+ }
+
+ .kv-key {
+ display:block;
+ font-size:10px;
+ font-weight:700;
+ color:#475569;
+ margin-bottom:2px;
+ }
+
+ .kv-val {
+ display:block;
+ font-size:11px;
+ color:#0f172a;
+ word-break:break-all;
+ line-height:1.4;
+ }
+
+ .log-box {
+ display:block;
+ height:118px;
+ overflow-y:auto;
+ background:#ffffff;
+ border:1px solid #cbd5e1;
+ border-radius:7px;
+ padding:6px 8px;
+ font-size:11px;
+ line-height:1.65;
+ scrollbar-width:thin;
+ scrollbar-color:#94a3b8 transparent;
+ }
+
+ .log-box::-webkit-scrollbar {
+ width:5px;
+ }
+
+ .log-box::-webkit-scrollbar-thumb {
+ background:#94a3b8;
+ border-radius:999px;
+ }
+
+ .log-line {
+ display:flex;
+ gap:6px;
+ align-items:baseline;
+ }
+
+ .log-time {
+ color:#64748b;
+ flex-shrink:0;
+ }
+
+ .log-msg {
+ color:#334155;
+ }
+
+ .log-msg.good {
+ color:#15803d;
+ font-weight:700;
+ }
+
+ .log-msg.warn {
+ color:#a16207;
+ font-weight:700;
+ }
+
+ .log-msg.info {
+ color:#0369a1;
+ font-weight:700;
+ }
+
+ .dot {
+ display:inline-block;
+ width:7px;
+ height:7px;
+ border-radius:50%;
+ background:#cbd5e1;
+ vertical-align:middle;
+ border:1px solid #94a3b8;
+ }
+
+ .dot.on {
+ background:#16a34a;
+ border-color:#15803d;
+ box-shadow:0 0 0 3px rgba(22,163,74,.16);
+ }
+
+ b {
+ font-weight:700;
+ }
+
+ i {
+ font-style:italic;
+ }
+
+ small {
+ font-size:.85em;
+ opacity:.75;
+ }
+ `);
+ // スタイルシートを適用
+ shadow.adoptedStyleSheets = [sty];
+
+ /* ── Shell ─────────────────────────────────────────────── */
+ const shell = document.createElement('div');
+ shell.id = 'shell';
+ shadow.appendChild(shell);
+
+ /* ── Left dashboard ─────────────────────────────────────── */
+ const dashboard = document.createElement('div');
+ dashboard.id = 'dashboard';
+ shell.appendChild(dashboard);
+
+ const topbar = document.createElement('div');
+ topbar.id = 'topbar';
+ topbar.innerHTML = `⚙ GM_addValueChangeListener Test`;
+
+ const closeBtn = document.createElement('button');
+ closeBtn.className = 'danger';
+ closeBtn.textContent = '✕ close';
+ closeBtn.onclick = () => host.remove();
+ topbar.appendChild(closeBtn);
+ dashboard.appendChild(topbar);
+
+ /* ── Panel cards ───────────────────────────────────────── */
+ const panelRefs = {};
+
+ FRAME_IDS.forEach(id => {
+ const accent = ACCENT[id];
+
+ const card = document.createElement('div');
+ card.className = 'p-card';
+ card.style.borderColor = accent + '55';
+
+ const title = document.createElement('div');
+ title.className = 'p-title';
+ title.style.color = accent;
+ title.textContent = LABEL[id];
+ card.appendChild(title);
+
+ const subtitle = document.createElement('div');
+ subtitle.className = 'p-subtitle';
+ subtitle.textContent = id === 'main'
+ ? 'runs in this window'
+ : 'runs in real iframe on the right →';
+ card.appendChild(subtitle);
+
+ const wLabel = document.createElement('div');
+ wLabel.className = 'sec-label';
+ wLabel.textContent = 'Write value';
+ card.appendChild(wLabel);
+
+ const writeRow = document.createElement('div');
+ writeRow.className = 'btn-row';
+
+ function makeBtn(text, danger) {
+ const b = document.createElement('button');
+ b.textContent = text;
+
+ if (danger) {
+ b.className = 'danger';
+ } else {
+ b.style.color = accent;
+ b.style.borderColor = accent + '66';
+ b.style.background = '#ffffff';
+ }
+
+ return b;
+ }
+
+ const cmdMap = [
+ ['string', 'SET_STRING'],
+ ['number', 'SET_NUMBER'],
+ ['object', 'SET_OBJECT'],
+ ['null', 'SET_NULL'],
+ ];
+
+ cmdMap.forEach(([label2, cmd]) => {
+ const b = makeBtn(label2);
+ b.onclick = () => dispatchCmd(id, cmd);
+ writeRow.appendChild(b);
+ });
+
+ const delB = makeBtn('delete', true);
+ delB.onclick = () => dispatchCmd(id, 'DELETE');
+ writeRow.appendChild(delB);
+ card.appendChild(writeRow);
+
+ const hr1 = document.createElement('div');
+ hr1.className = 'hr';
+ card.appendChild(hr1);
+
+ const kvLabel = document.createElement('div');
+ kvLabel.className = 'sec-label';
+ kvLabel.textContent = 'GM Values';
+ card.appendChild(kvLabel);
+
+ const kvTable = document.createElement('div');
+ kvTable.className = 'kv-table';
+ card.appendChild(kvTable);
+
+ const hr2 = document.createElement('div');
+ hr2.className = 'hr';
+ card.appendChild(hr2);
+
+ const lcLabel = document.createElement('div');
+ lcLabel.className = 'sec-label';
+ lcLabel.textContent = 'Listener control';
+ card.appendChild(lcLabel);
+
+ const lcRow = document.createElement('div');
+ lcRow.className = 'btn-row';
+
+ const rmB = makeBtn('🔇 remove all', true);
+ rmB.onclick = () => dispatchCmd(id, 'REMOVE_LISTENERS');
+
+ const reB = makeBtn('🔊 re-register');
+ reB.onclick = () => dispatchCmd(id, 'REREGISTER_LISTENERS');
+
+ lcRow.appendChild(rmB);
+ lcRow.appendChild(reB);
+
+ const dotWrap = document.createElement('div');
+ dotWrap.style.cssText = 'display:flex;gap:6px;align-items:center;margin-top:7px;flex-wrap:wrap;';
+
+ const dotMap = {};
+
+ ALL_KEYS.forEach(k => {
+ const dot = document.createElement('span');
+ dot.className = 'dot';
+ dot.title = k;
+
+ const lbl2 = document.createElement('span');
+ lbl2.style.cssText = 'font-size:10px;color:#475569;';
+ lbl2.textContent = k.replace('key_from_', '');
+
+ dotWrap.appendChild(dot);
+ dotWrap.appendChild(lbl2);
+
+ dotMap[k] = dot;
+ });
+
+ card.appendChild(lcRow);
+ card.appendChild(dotWrap);
+
+ const hr3 = document.createElement('div');
+ hr3.className = 'hr';
+ card.appendChild(hr3);
+
+ const logLabel = document.createElement('div');
+ logLabel.className = 'sec-label';
+ logLabel.textContent = 'Event log';
+ card.appendChild(logLabel);
+
+ const logBox = document.createElement('div');
+ logBox.className = 'log-box';
+ card.appendChild(logBox);
+
+ const clrB = makeBtn('✕ clear log', true);
+ clrB.style.marginTop = '5px';
+ clrB.onclick = () => {
+ logBox.innerHTML = '';
+ state.logs[id] = [];
+ };
+ card.appendChild(clrB);
+
+ dashboard.appendChild(card);
+
+ panelRefs[id] = {
+ kvTable,
+ logBox,
+ dotMap,
+ accent,
+ myKey: WRITE_KEY[id],
+ };
+ });
+
+ /* ── Right iframe strip ─────────────────────────────────── */
+ const strip = document.createElement('div');
+ strip.id = 'iframe-strip';
+ shell.appendChild(strip);
+
+ const BASE_URL = location.href.split('?')[0];
+
+ ['iframe1', 'iframe2', 'iframe3'].forEach(fid => {
+ const wrap = document.createElement('div');
+ wrap.className = 'iframe-wrap';
+
+ const iframe = document.createElement('iframe');
+ iframe.src = `${BASE_URL}?testGMAddValueChangeListener&frameId=${encodeURIComponent(fid)}`;
+ iframe.title = fid;
+
+ iframe.onload = () => {
+ iframeWindows[fid] = iframe.contentWindow;
+ };
+
+ wrap.appendChild(iframe);
+ strip.appendChild(wrap);
+
+ iframeWindows[fid] = iframe.contentWindow;
+ });
+
+ /* ── Main frame GM logic ───────────────────────────────── */
+ (() => {
+ const myKey = WRITE_KEY.main;
+ const refs = () => panelRefs.main;
+
+ const listenerIds = {};
+
+ function mLog(msg, type = '') {
+ const { logBox } = refs();
+
+ const line = document.createElement('div');
+ line.className = 'log-line';
+ line.innerHTML = `
+ ${nowTime()}
+ ${msg}
+ `;
+
+ logBox.appendChild(line);
+ logBox.scrollTop = logBox.scrollHeight;
+ }
+
+ async function mRefreshKV() {
+ const { kvTable, myKey: mk, accent } = refs();
+
+ kvTable.innerHTML = '';
+
+ for (const k of ALL_KEYS) {
+ const v = await GM_getValue(k, undefined); // main frame
+ const own = k === mk;
+
+ const card = document.createElement('div');
+ card.className = 'kv-card';
+
+ if (own) card.style.borderColor = accent + '88';
+
+ card.innerHTML = `
+ ${escHtml(k)}${own ? ' (mine)' : ''}
+
+ ${fmtVal(v)}
+
+ `;
+
+ kvTable.appendChild(card);
+ }
+ }
+
+ function registerMainListeners(isReregister = false) {
+ for (const key of ALL_KEYS) {
+ if (listenerIds[key] != null) continue;
+
+ const id = GM_addValueChangeListener(key, async (name, oldVal, newVal, remote) => {
+ const tag = remote ? '🌐 remote' : '📍 local';
+
+ mLog(
+ `${tag} ${escHtml(name)}: ${escHtml(JSON.stringify(oldVal))} → ${escHtml(JSON.stringify(newVal))}`,
+ remote ? 'good' : 'warn'
+ );
+
+ await mRefreshKV();
+ updateDots('main', Object.entries(listenerIds).map(([k, i]) => ({ key: k, id: i })));
+ });
+
+ listenerIds[key] = id;
+
+ mLog(
+ `${isReregister ? '👂 Re-registered' : '👂 Listener on'} ${escHtml(key)} (id=${escHtml(id)})`,
+ 'info'
+ );
+ }
+
+ updateDots('main', Object.entries(listenerIds).map(([k, i]) => ({ key: k, id: i })));
+ }
+
+ function removeMainListeners() {
+ for (const [k, i] of Object.entries(listenerIds)) {
+ try {
+ GM_removeValueChangeListener(i);
+ } catch (_) { }
+ delete listenerIds[k];
+ }
+
+ mLog('🔇 All listeners removed', 'warn');
+ updateDots('main', []);
+ }
+
+ registerMainListeners(false);
+ mRefreshKV();
+
+ window._gmtest_mainDispatch = async (cmd) => {
+ if (cmd === 'SET_STRING') {
+ const v = `hello_${Date.now()}`;
+ await GM_setValue(myKey, v);
+ mLog(`✏️ Set ${escHtml(myKey)} = ${escHtml(JSON.stringify(v))}`, 'info');
+ }
+
+ if (cmd === 'SET_NUMBER') {
+ const v = Math.floor(Math.random() * 99999);
+ await GM_setValue(myKey, v);
+ mLog(`✏️ Set ${escHtml(myKey)} = ${escHtml(JSON.stringify(v))}`, 'info');
+ }
+
+ if (cmd === 'SET_OBJECT') {
+ const v = { ts: Date.now(), from: 'main' };
+ await GM_setValue(myKey, v);
+ mLog(`✏️ Set ${escHtml(myKey)} = ${escHtml(JSON.stringify(v))}`, 'info');
+ }
+
+ if (cmd === 'SET_NULL') {
+ await GM_setValue(myKey, null);
+ mLog(`✏️ Set ${escHtml(myKey)} = null`, 'info');
+ }
+
+ if (cmd === 'DELETE') {
+ await GM_deleteValue(myKey);
+ mLog(`🗑 Deleted ${escHtml(myKey)}`, 'warn');
+ }
+
+ if (cmd === 'REMOVE_LISTENERS') {
+ removeMainListeners();
+ }
+
+ if (cmd === 'REREGISTER_LISTENERS') {
+ removeMainListeners();
+ registerMainListeners(true);
+ }
+
+ await mRefreshKV();
+ };
+ })();
+
+ /* ── postMessage handler ───────────────────────────────── */
+ window.addEventListener('message', (e) => {
+ if (e.origin !== location.origin) return;
+ if (!e.data || !e.data.t) return;
+
+ const { t, frameId: fid, entry, kvMap, ids } = e.data;
+
+ if (!FRAME_IDS.includes(fid)) return;
+
+ if (fid !== 'main' && iframeWindows[fid] && e.source !== iframeWindows[fid]) return;
+
+ if (t === MSG_NS + 'LOG') {
+ appendLog(fid, entry);
+ }
+
+ if (t === MSG_NS + 'KV') {
+ renderKV(fid, kvMap);
+ }
+
+ if (t === MSG_NS + 'READY') {
+ appendLog(fid, {
+ t: nowTime(),
+ msg: '🚀 iframe ready',
+ type: 'info',
+ });
+ }
+
+ if (t === MSG_NS + 'LISTENERS') {
+ updateDots(fid, ids);
+ }
+ });
+
+ /* ── dispatchCmd ───────────────────────────────────────── */
+ function dispatchCmd(targetId, cmd) {
+ if (targetId === 'main') {
+ window._gmtest_mainDispatch(cmd);
+ return;
+ }
+
+ const win = iframeWindows[targetId];
+
+ if (win) {
+ sendCmd(win, cmd, {});
+ } else {
+ appendLog(targetId, {
+ t: nowTime(),
+ msg: '⚠️ iframe not ready yet — try again',
+ type: 'warn',
+ });
+ }
+ }
+
+ /* ── UI helpers ─────────────────────────────────────────── */
+ function appendLog(fid, entry) {
+ const refs = panelRefs[fid];
+ if (!refs || !entry) return;
+
+ const { logBox } = refs;
+
+ const line = document.createElement('div');
+ line.className = 'log-line';
+ line.innerHTML = `
+ ${escHtml(entry.t || nowTime())}
+ ${entry.msg || ''}
+ `;
+
+ logBox.appendChild(line);
+ logBox.scrollTop = logBox.scrollHeight;
+ }
+
+ function renderKV(fid, kvMap) {
+ const refs = panelRefs[fid];
+ if (!refs || !kvMap) return;
+
+ const { kvTable, myKey, accent } = refs;
+
+ kvTable.innerHTML = '';
+
+ for (const k of ALL_KEYS) {
+ const v = kvMap[k];
+ const own = k === myKey;
+
+ const card = document.createElement('div');
+ card.className = 'kv-card';
+
+ if (own) card.style.borderColor = accent + '88';
+
+ card.innerHTML = `
+ ${escHtml(k)}${own ? ' (mine)' : ''}
+
+ ${fmtVal(v)}
+
+ `;
+
+ kvTable.appendChild(card);
+ }
+ }
+
+ function updateDots(fid, ids) {
+ const refs = panelRefs[fid];
+ if (!refs) return;
+
+ const { dotMap } = refs;
+ const activeKeys = new Set((ids || []).map(x => x.key));
+
+ for (const [k, dot] of Object.entries(dotMap)) {
+ dot.className = 'dot' + (activeKeys.has(k) ? ' on' : '');
+ }
+ }
+})();
diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts
index 1d4715b6d..6e9f2441a 100644
--- a/src/app/service/content/script_executor.ts
+++ b/src/app/service/content/script_executor.ts
@@ -46,6 +46,7 @@ export class ScriptExecutor {
}
valueUpdate(data: ValueUpdateDataEncoded) {
+ // runtime/valueUpdate
const { uuid, storageName } = data;
for (const val of this.execScriptMap.values()) {
if (val.scriptRes.uuid === uuid || getStorageName(val.scriptRes) === storageName) {
diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts
index fa802ce52..0502790d4 100644
--- a/src/app/service/content/scripting.ts
+++ b/src/app/service/content/scripting.ts
@@ -3,13 +3,27 @@ import { type CustomEventMessage } from "@Packages/message/custom_event_message"
import { forwardMessage, type Server } from "@Packages/message/server";
import type { MessageSend } from "@Packages/message/types";
import { RuntimeClient } from "../service_worker/client";
-import { makeBlobURL } from "@App/pkg/utils/utils";
+import { getStorageName, makeBlobURL } from "@App/pkg/utils/utils";
import type { Logger } from "@App/app/repo/logger";
import LoggerCore from "@App/app/logger/core";
import type { ValueUpdateDataEncoded } from "./types";
+const PageOrContent = {
+ PAGE: 1,
+ CONTENT: 2,
+ PAGE_AND_CONTENT: 3,
+} as const;
+
+type PageOrContent = ValueOf;
+
+// For Firefox, StorageArea.setAccessLevel is not implemented.
+// See https://bugzilla.mozilla.org/show_bug.cgi?id=1724754
+// const deliveryStorage = isFirefox() ? chrome.storage.local : chrome.storage.session;
+const deliveryStorage = chrome.storage.local; // 日后再处理
+
// scripting页的处理
export default class ScriptingRuntime {
+ private activeStorageNames: Map | null = null;
constructor(
// 监听来自service_worker的消息
private readonly extServer: Server,
@@ -24,10 +38,14 @@ export default class ScriptingRuntime {
) {}
// 广播消息给 content 和 inject
- broadcastToPage(action: string, data?: any): Promise {
+ broadcastToPage(
+ action: string,
+ data?: any,
+ activeOn: PageOrContent = (PageOrContent.PAGE | PageOrContent.CONTENT) as PageOrContent
+ ): Promise {
return Promise.all([
- sendMessage(this.senderToContent, "content/" + action, data),
- sendMessage(this.senderToInject, "inject/" + action, data),
+ activeOn & PageOrContent.CONTENT && sendMessage(this.senderToContent, "content/" + action, data),
+ activeOn & PageOrContent.PAGE && sendMessage(this.senderToInject, "inject/" + action, data),
]).then(() => undefined);
}
@@ -52,13 +70,18 @@ export default class ScriptingRuntime {
// 类似 UDP 原理,service_worker 不会有任何「等待处理」
// 由于 changes 会包括新旧值 (Chrome: JSON serialization, Firefox: Structured Clone)
// 因此需要注意资讯量不要过大导致 onChanged 的触发过慢
- chrome.storage.local.onChanged.addListener((changes) => {
- if (changes["valueUpdateDelivery"]?.newValue) {
- // 转发给 content 和 inject
- this.broadcastToPage(
- "runtime/valueUpdate",
- changes["valueUpdateDelivery"]?.newValue.sendData as ValueUpdateDataEncoded
- );
+ deliveryStorage.onChanged.addListener((changes) => {
+ const record = changes["valueUpdateDelivery"];
+ if (record?.newValue) {
+ const sendData = record.newValue.sendData as ValueUpdateDataEncoded;
+ const activeOn =
+ this.activeStorageNames === null
+ ? PageOrContent.PAGE_AND_CONTENT
+ : this.activeStorageNames.get(sendData.storageName);
+ if (activeOn) {
+ // 转发给 content 和 inject
+ this.broadcastToPage("runtime/valueUpdate", sendData, activeOn);
+ }
}
});
@@ -121,6 +144,14 @@ export default class ScriptingRuntime {
client.pageLoad().then((o) => {
if (!o.ok) return;
const { injectScriptList, contentScriptList, envInfo } = o;
+ const pairs = {} as Record;
+ for (const script of injectScriptList) {
+ pairs[getStorageName(script)] |= PageOrContent.PAGE;
+ }
+ for (const script of contentScriptList) {
+ pairs[getStorageName(script)] |= PageOrContent.CONTENT;
+ }
+ this.activeStorageNames = new Map(Object.entries(pairs));
// 向页面 发送脚本列表及环境信息
if (contentScriptList.length) {
diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts
index a96f2951d..5e9207d63 100644
--- a/src/app/service/sandbox/runtime.ts
+++ b/src/app/service/sandbox/runtime.ts
@@ -321,6 +321,7 @@ export class Runtime {
}
valueUpdate(data: ValueUpdateDataEncoded) {
+ // runtime/valueUpdate
const dataEntries = data.entries;
// 转发给脚本
this.execScriptMap.forEach((val) => {
diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts
index 8ea36659e..83fc7e4d1 100644
--- a/src/app/service/service_worker/runtime.ts
+++ b/src/app/service/service_worker/runtime.ts
@@ -7,7 +7,7 @@ import type { Script, ScriptDAO, ScriptRunResource, ScriptSite, TScriptInfo } fr
import { SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts";
import { type ValueService } from "./value";
import GMApi, { GMExternalDependencies } from "./gm_api/gm_api";
-import type { TDeleteScript, TEnableScript, TInstallScript, TScriptValueUpdate, TSortedScript } from "../queue";
+import type { TDeleteScript, TEnableScript, TInstallScript, TSortedScript } from "../queue";
import { type ScriptService } from "./script";
import { runScript, stopScript } from "../offscreen/client";
import {
@@ -19,6 +19,7 @@ import {
import {
checkUserScriptsAvailable,
getMetadataStr,
+ getStorageName,
getUserConfigStr,
obtainBlackList,
sourceMapTo,
@@ -41,7 +42,7 @@ import { type SystemConfig } from "@App/pkg/config/config";
import { type ResourceService } from "./resource";
import { type LocalStorageDAO } from "@App/app/repo/localStorage";
import Logger from "@App/app/logger/logger";
-import type { GMInfoEnv } from "../content/types";
+import type { GMInfoEnv, ValueUpdateDataEncoded } from "../content/types";
import { initLocalesPromise, localePath } from "@App/locales/locales";
import { DocumentationSite } from "@App/app/const";
import { extractUrlPatterns, RuleType, type URLRuleEntry } from "@App/pkg/utils/url_matcher";
@@ -82,6 +83,13 @@ export type TScriptsForTab = {
scriptmenus: ScriptMenu[];
} | null;
+const bgScriptStorageNames = new Set();
+
+// For Firefox, StorageArea.setAccessLevel is not implemented.
+// See https://bugzilla.mozilla.org/show_bug.cgi?id=1724754
+// const deliveryStorage = isFirefox() ? chrome.storage.local : chrome.storage.session;
+const deliveryStorage = chrome.storage.local; // 日后再处理
+
export class RuntimeService {
scriptMatchEnable: UrlMatch = new UrlMatch();
scriptMatchDisable: UrlMatch = new UrlMatch();
@@ -348,7 +356,48 @@ export class RuntimeService {
await this.loadPageScript(script, apiScript!);
}
+ public async pushValueUpdate(script: Script, sendData: ValueUpdateDataEncoded) {
+ try {
+ // 前台腳本 (推送值到tab)
+ await deliveryStorage!.set({
+ valueUpdateDelivery: {
+ rId: `${Date.now()}.${Math.random()}`, // 用于区分不同的更新,确保 deliveryStorage.onChanged 必能触发
+ sendData,
+ },
+ });
+
+ // 後台腳本
+ if (bgScriptStorageNames.has(sendData.storageName)) {
+ // 推送到offscreen中
+ await sendMessage(this.msgSender, "offscreen/runtime/valueUpdate", sendData);
+ }
+
+ // valueUpdate 消息用于 early script 的处理
+ if (sendData.valueUpdated) {
+ if (script.status === SCRIPT_STATUS_ENABLE && isEarlyStartScript(script.metadata)) {
+ // 如果是预加载脚本,需要更新脚本代码重新注册
+ // scriptMatchInfo 里的 value 改变 => compileInjectionCode -> injectionCode 改变
+ await this.updateResourceOnScriptChange(script);
+ }
+ }
+ } catch (e) {
+ console.error("pushValueUpdate error", e);
+ }
+ }
+
+ async setSessionAccessLevel() {
+ try {
+ // 让 scripting 存取 chrome.storage.session
+ await chrome.storage.session.setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" });
+ } catch (e) {
+ console.error("unable to call chrome.storage.session.setAccessLevel", e);
+ }
+ }
+
init() {
+ if (deliveryStorage === chrome.storage.session) {
+ this.setSessionAccessLevel();
+ }
// 启动gm api
const permission = new PermissionVerify(this.group.group("permission"), this.mq);
const gmApi = new GMApi(
@@ -418,6 +467,8 @@ export class RuntimeService {
// 还是要建立 CompiledResoure, 否则 Popup 看不到 Script
await this.buildAndSaveCompiledResourceFromScript(script, false);
}
+ } else {
+ bgScriptStorageNames.add(getStorageName(script));
}
});
@@ -449,6 +500,7 @@ export class RuntimeService {
if (script.type === SCRIPT_TYPE_NORMAL) {
continue;
}
+ bgScriptStorageNames.add(getStorageName(script));
res.push({
uuid: script.uuid,
enable: script.status === SCRIPT_STATUS_ENABLE,
@@ -466,17 +518,6 @@ export class RuntimeService {
});
});
- // 监听脚本值变更
- this.mq.subscribe("valueUpdate", async ({ script, valueUpdated }: TScriptValueUpdate) => {
- if (valueUpdated) {
- if (script.status === SCRIPT_STATUS_ENABLE && isEarlyStartScript(script.metadata)) {
- // 如果是预加载脚本,需要更新脚本代码重新注册
- // scriptMatchInfo 里的 value 改变 => compileInjectionCode -> injectionCode 改变
- await this.updateResourceOnScriptChange(script);
- }
- }
- });
-
if (chrome.extension.inIncognitoContext) {
this.systemConfig.addListener("enable_script_incognito", async (enable) => {
// 隐身窗口不对注册了的脚本进行实际操作
@@ -973,22 +1014,6 @@ export class RuntimeService {
runtimeGlobal.registerState = failed ? RuntimeRegisterCode.UNSET : RuntimeRegisterCode.REGISTER_DONE;
}
- // 给指定tab发送消息
- sendMessageToTab(to: ExtMessageSender, action: string, data: any) {
- if (to.tabId === -1) {
- // 如果是-1, 代表给offscreen发送消息
- return sendMessage(this.msgSender, "offscreen/runtime/" + action, data);
- }
- return sendMessage(
- new ExtensionContentMessageSend(to.tabId, {
- documentId: to.documentId,
- frameId: to.frameId,
- }),
- "scripting/runtime/" + action,
- data
- );
- }
-
// 给指定脚本触发事件
emitEventToTab(to: ExtMessageSender, req: EmitEventRequest) {
if (to.tabId === -1) {
diff --git a/src/app/service/service_worker/value.test.ts b/src/app/service/service_worker/value.test.ts
index bd28eaaec..d6daae278 100644
--- a/src/app/service/service_worker/value.test.ts
+++ b/src/app/service/service_worker/value.test.ts
@@ -82,8 +82,8 @@ describe("ValueService - setValue 方法测试", () => {
} as any;
valueService.valueDAO = mockValueDAO;
- // Mock pushValueToTab 方法
- valueService.pushValueToTab = vi.fn();
+ // Mock pushValueUpdate 方法
+ valueService.pushValueUpdate = vi.fn();
// Mock mq.emit 方法
mockMessageQueue.emit = vi.fn();
@@ -118,9 +118,10 @@ describe("ValueService - setValue 方法测试", () => {
expect(mockScriptDAO.get).toHaveBeenCalledWith(mockScript.uuid);
expect(mockValueDAO.get).toHaveBeenCalled();
expect(mockValueDAO.save).toHaveBeenCalled();
- expect(valueService.pushValueToTab).toHaveBeenCalledTimes(1);
- expect(valueService.pushValueToTab).toHaveBeenNthCalledWith(
+ expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1);
+ expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith(
1,
+ mockScript,
expect.objectContaining({
entries: expect.any(Object),
id: "testId-4021",
@@ -133,8 +134,6 @@ describe("ValueService - setValue 方法测试", () => {
valueUpdated: true,
})
);
- expect(mockMessageQueue.emit).toHaveBeenCalledTimes(1);
- expect(mockMessageQueue.emit).toHaveBeenCalledWith("valueUpdate", { script: mockScript, valueUpdated: true });
// 验证保存的数据结构
const saveCall = vi.mocked(mockValueDAO.save).mock.calls[0];
@@ -171,9 +170,10 @@ describe("ValueService - setValue 方法测试", () => {
expect(mockScriptDAO.get).toHaveBeenCalledWith(mockScript.uuid);
expect(mockValueDAO.get).toHaveBeenCalled();
expect(mockValueDAO.save).toHaveBeenCalled();
- expect(valueService.pushValueToTab).toHaveBeenCalledTimes(1);
- expect(valueService.pushValueToTab).toHaveBeenNthCalledWith(
+ expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1);
+ expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith(
1,
+ mockScript,
expect.objectContaining({
entries: expect.any(Object),
id: "testId-4022",
@@ -186,8 +186,6 @@ describe("ValueService - setValue 方法测试", () => {
valueUpdated: true,
})
);
- expect(mockMessageQueue.emit).toHaveBeenCalledTimes(1);
- expect(mockMessageQueue.emit).toHaveBeenCalledWith("valueUpdate", { script: mockScript, valueUpdated: true });
// 验证保存的数据结构
const saveCall = vi.mocked(mockValueDAO.save).mock.calls[0];
@@ -233,9 +231,10 @@ describe("ValueService - setValue 方法测试", () => {
expect(mockScriptDAO.get).toHaveBeenCalledWith(mockScript.uuid);
expect(mockValueDAO.get).toHaveBeenCalled();
expect(mockValueDAO.save).toHaveBeenCalled();
- expect(valueService.pushValueToTab).toHaveBeenCalledTimes(1);
- expect(valueService.pushValueToTab).toHaveBeenNthCalledWith(
+ expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1);
+ expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith(
1,
+ mockScript,
expect.objectContaining({
entries: expect.any(Object),
id: "testId-4023",
@@ -248,11 +247,6 @@ describe("ValueService - setValue 方法测试", () => {
valueUpdated: true,
})
);
- expect(mockMessageQueue.emit).toHaveBeenCalledTimes(1);
- expect(mockMessageQueue.emit).toHaveBeenCalledWith("valueUpdate", {
- script: mockScript,
- valueUpdated: true,
- });
// 验证保存的数据被正确更新
const saveCall = vi.mocked(mockValueDAO.save).mock.calls[0];
@@ -294,9 +288,10 @@ describe("ValueService - setValue 方法测试", () => {
expect(mockScriptDAO.get).toHaveBeenCalledWith(mockScript.uuid);
expect(mockValueDAO.get).toHaveBeenCalled();
expect(mockValueDAO.save).not.toHaveBeenCalled(); // 值未改变,不应该保存
- expect(valueService.pushValueToTab).toHaveBeenCalledTimes(1);
- expect(valueService.pushValueToTab).toHaveBeenNthCalledWith(
+ expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(1);
+ expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith(
1,
+ mockScript,
expect.objectContaining({
entries: expect.any(Object),
id: "testId-4024",
@@ -309,8 +304,6 @@ describe("ValueService - setValue 方法测试", () => {
valueUpdated: false,
})
); // 值未改变
- expect(mockMessageQueue.emit).toHaveBeenCalledTimes(1);
- expect(mockMessageQueue.emit).toHaveBeenCalledWith("valueUpdate", { script: mockScript, valueUpdated: false }); // 值未改变
});
it("当设置值为undefined时应该删除该键", async () => {
@@ -375,7 +368,7 @@ describe("ValueService - setValue 方法测试", () => {
// 验证不会执行后续操作
expect(mockValueDAO.get).not.toHaveBeenCalled();
expect(mockValueDAO.save).not.toHaveBeenCalled();
- expect(valueService.pushValueToTab).not.toHaveBeenCalled();
+ expect(valueService.pushValueUpdate).not.toHaveBeenCalled();
expect(mockMessageQueue.emit).toHaveBeenCalledTimes(0);
});
@@ -394,7 +387,7 @@ describe("ValueService - setValue 方法测试", () => {
vi.mocked(mockValueDAO.save).mockResolvedValue({} as any);
expect(mockScriptDAO.get).toHaveBeenCalledTimes(0);
expect(mockValueDAO.save).toHaveBeenCalledTimes(0);
- expect(valueService.pushValueToTab).toHaveBeenCalledTimes(0);
+ expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(0);
// 并发执行两个setValue操作
const keyValuePairs1 = [[key1, encodeRValue(value1)]] satisfies TKeyValuePair[];
@@ -419,9 +412,10 @@ describe("ValueService - setValue 方法测试", () => {
// 验证两个操作都被调用
expect(mockScriptDAO.get).toHaveBeenCalledTimes(2);
expect(mockValueDAO.save).toHaveBeenCalledTimes(2);
- expect(valueService.pushValueToTab).toHaveBeenCalledTimes(2);
- expect(valueService.pushValueToTab).toHaveBeenNthCalledWith(
+ expect(valueService.pushValueUpdate).toHaveBeenCalledTimes(2);
+ expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith(
1,
+ mockScript,
expect.objectContaining({
entries: expect.any(Object),
id: "testId-4041",
@@ -434,8 +428,9 @@ describe("ValueService - setValue 方法测试", () => {
valueUpdated: true,
})
);
- expect(valueService.pushValueToTab).toHaveBeenNthCalledWith(
+ expect(valueService.pushValueUpdate).toHaveBeenNthCalledWith(
2,
+ mockScript,
expect.objectContaining({
entries: expect.any(Object),
id: "testId-4042",
@@ -448,8 +443,5 @@ describe("ValueService - setValue 方法测试", () => {
valueUpdated: true,
})
);
- expect(mockMessageQueue.emit).toHaveBeenCalledTimes(2);
- expect(mockMessageQueue.emit).toHaveBeenNthCalledWith(1, "valueUpdate", { script: mockScript, valueUpdated: true });
- expect(mockMessageQueue.emit).toHaveBeenNthCalledWith(2, "valueUpdate", { script: mockScript, valueUpdated: true });
});
});
diff --git a/src/app/service/service_worker/value.ts b/src/app/service/service_worker/value.ts
index 0ecb34943..39dec9282 100644
--- a/src/app/service/service_worker/value.ts
+++ b/src/app/service/service_worker/value.ts
@@ -7,7 +7,6 @@ import { type RuntimeService } from "./runtime";
import { type PopupService } from "./popup";
import { getStorageName } from "@App/pkg/utils/utils";
import type { ValueUpdateDataEncoded, ValueUpdateDataREntry, ValueUpdateSender } from "../content/types";
-import type { TScriptValueUpdate } from "../queue";
import { type TDeleteScript } from "../queue";
import { type IMessageQueue } from "@Packages/message/message_queue";
import { CACHE_KEY_SET_VALUE } from "@App/app/cache_key";
@@ -75,30 +74,8 @@ export class ValueService {
return this.getScriptValueDetails(script).then((res) => res[0]);
}
- // 推送值到tab
- async pushValueToTab(sendData: T) {
- chrome.storage.local.set(
- {
- valueUpdateDelivery: {
- rId: `${Date.now()}.${Math.random()}`, // 用于区分不同的更新,确保 chrome.storage.local.onChanged 必能触发
- sendData,
- },
- },
- () => {
- const lastError = chrome.runtime.lastError;
- if (lastError) {
- console.error("chrome.runtime.lastError in chrome.storage.local.set", lastError);
- }
- }
- );
- // 推送到offscreen中
- this.runtime!.sendMessageToTab(
- {
- tabId: -1,
- },
- "valueUpdate",
- sendData
- );
+ async pushValueUpdate(script: Script, sendData: T) {
+ return this.runtime!.pushValueUpdate(script, sendData);
}
// 批量设置
@@ -180,16 +157,15 @@ export class ValueService {
});
// 推送到所有加载了本脚本的tab中
const valueUpdated = entries.length > 0;
- this.pushValueToTab({
+ const sendData = {
id,
entries: entries,
uuid,
storageName,
sender: valueSender,
valueUpdated,
- } as ValueUpdateDataEncoded);
- // valueUpdate 消息用于 early script 的处理
- this.mq.emit("valueUpdate", { script, valueUpdated });
+ } as ValueUpdateDataEncoded;
+ this.pushValueUpdate(script, sendData);
}
setScriptValues(params: Pick, _sender: IGetSender) {