'use strict'; /* ---------------- utilities ---------------- */ const $ = (sel, root = document) => root.querySelector(sel); const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); /* ---------------- wasm engine worker ---------------- */ // The matching engine runs as WebAssembly inside a Web Worker. It is loaded // lazily on the first Analyze (the wasm is several MB) and reused thereafter. const engineWorker = (() => { let worker = null; let readyPromise = null; let seq = 0; const pending = new Map(); function ensure() { if (readyPromise) return readyPromise; worker = new Worker('worker.js'); readyPromise = new Promise((resolve, reject) => { worker.onmessage = (e) => { const d = e.data || {}; if (d.type === 'ready') { resolve(); return; } if (d.type === 'loaderror') { reject(new Error(d.error || 'failed to load engine')); return; } if (d.id != null && pending.has(d.id)) { const { resolve: res, reject: rej } = pending.get(d.id); pending.delete(d.id); if (d.error) rej(new Error(d.error)); else res(d.result); } }; worker.onerror = (e) => reject(new Error(e.message || 'engine worker error')); }); return readyPromise; } return { // Whether the engine is already initialized (used to tailor the spinner text). get ready() { return readyPromise !== null; }, // Kick off loading without running an analysis. preload() { return ensure(); }, async analyze(request) { await ensure(); const id = ++seq; return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); worker.postMessage({ id, request }); }); }, }; })(); const storage = window.singvisStorage; function toast(msg, kind = '') { const root = $('#toast-root'); const t = document.createElement('div'); t.className = 'toast ' + kind; t.textContent = msg; root.appendChild(t); setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .3s'; setTimeout(() => t.remove(), 300); }, 3600); } const store = { get(k, def) { try { const v = localStorage.getItem('singvis.' + k); return v == null ? def : JSON.parse(v); } catch { return def; } }, set(k, v) { try { localStorage.setItem('singvis.' + k, JSON.stringify(v)); } catch {} }, }; /* ---------------- app state ---------------- */ const state = { profiles: [], activeId: null, settings: { dohServer: 'https://1.1.1.1/dns-query' }, draft: null, // { id?, name, config, inputs, ruleSetFiles } lastResult: null, analyzing: false, }; const SAMPLE_CONFIG = `{ "dns": { "servers": [ { "tag": "proxy-dns", "type": "https", "server": "1.1.1.1", "detour": "proxy" }, { "tag": "local-dns", "type": "udp", "server": "223.5.5.5", "detour": "direct" } ], "rules": [ { "rule_set": ["geosite-cn"], "action": "route", "server": "local-dns" }, { "domain_keyword": ["ads"], "action": "reject" } ], "final": "proxy-dns" }, "route": { "rules": [ { "domain_suffix": ["google.com", "openai.com"], "outbound": "proxy" }, { "rule_set": ["geosite-cn"], "outbound": "direct" }, { "rule_set": ["geoip-cn"], "outbound": "direct" } ], "rule_set": [ { "type": "remote", "tag": "geosite-cn", "format": "binary", "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs" }, { "type": "remote", "tag": "geoip-cn", "format": "binary", "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs" } ], "final": "proxy" } }`; /* ---------------- init ---------------- */ async function init() { $('#btn-settings').onclick = openSettings; $('#btn-new').onclick = () => newDraft(); try { state.settings = await storage.getSettings(); } catch (e) { /* keep defaults */ } await loadProfiles(); if (state.profiles.length) selectProfile(state.profiles[0].id); else newDraft(); } async function loadProfiles() { try { state.profiles = (await storage.listProfiles()) || []; } catch (e) { state.profiles = []; } renderSidebar(); } function renderSidebar() { const ul = $('#profile-list'); if (!state.profiles.length) { ul.innerHTML = '
  • No profiles yet. Create one to get started.
  • '; return; } ul.innerHTML = state.profiles.map((p) => `
  • ${esc(p.name || 'Untitled')} updated ${new Date(p.updatedAt).toLocaleString()}
  • `).join(''); ul.querySelectorAll('.profile-item').forEach((li) => { li.onclick = () => selectProfile(li.dataset.id); }); } async function selectProfile(id) { try { const p = await storage.getProfile(id); state.activeId = id; state.draft = { id: p.id, name: p.name || '', config: p.config || '', inputs: p.inputs || '', ruleSetFiles: p.ruleSetFiles || {} }; state.lastResult = null; renderSidebar(); renderEditor(); } catch (e) { toast('Failed to load profile: ' + e.message, 'err'); } } function newDraft() { state.activeId = null; state.draft = { name: '', config: SAMPLE_CONFIG, inputs: 'www.google.com\nbaidu.com\nopenai.com\n1.1.1.1', ruleSetFiles: {} }; state.lastResult = null; renderSidebar(); renderEditor(); } /* ---------------- editor ---------------- */ function renderEditor() { const d = state.draft; const network = store.get('network', ''); const assume = store.get('assumeResolved', true); const files = Object.entries(d.ruleSetFiles || {}); $('#content').innerHTML = `

    ${state.activeId ? 'Edit profile' : 'New profile'}

    ${state.activeId ? '' : ''}
    ${files.map(([k, f]) => `
    ${esc(k)} ${esc(f.format)} · ${f.data ? (f.format === 'binary' ? Math.round(f.data.length * 0.75) : f.data.length) : 0} bytes
    `).join('')}
    DoH: ${esc(state.settings.dohServer || '—')}
    `; $('#btn-save').onclick = saveProfile; const del = $('#btn-delete'); if (del) del.onclick = deleteProfile; $('#btn-analyze').onclick = analyze; $('#f-files').onchange = handleFiles; $('#opt-assume').onchange = (e) => store.set('assumeResolved', e.target.checked); $('#opt-network').onchange = (e) => store.set('network', e.target.value); $('#file-list').querySelectorAll('.rm-file').forEach((b) => { b.onclick = () => { delete state.draft.ruleSetFiles[b.dataset.key]; syncDraftFromForm(); renderEditor(); }; }); if (state.lastResult) renderResults(state.lastResult); } function syncDraftFromForm() { const d = state.draft; const n = $('#f-name'); if (n) d.name = n.value; const c = $('#f-config'); if (c) d.config = c.value; const i = $('#f-inputs'); if (i) d.inputs = i.value; } function handleFiles(e) { const files = Array.from(e.target.files || []); syncDraftFromForm(); let pending = files.length; if (!pending) return; files.forEach((file) => { const isBinary = /\.srs$/i.test(file.name); const reader = new FileReader(); reader.onload = () => { let data, format; if (isBinary) { const bytes = new Uint8Array(reader.result); let bin = ''; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); data = btoa(bin); format = 'binary'; } else { data = reader.result; format = 'source'; } state.draft.ruleSetFiles[file.name] = { format, data }; if (--pending === 0) renderEditor(); }; if (isBinary) reader.readAsArrayBuffer(file); else reader.readAsText(file); }); } async function saveProfile() { syncDraftFromForm(); const d = state.draft; if (!d.name.trim()) { toast('Please enter a profile name', 'err'); return; } const payload = { name: d.name.trim(), config: d.config, inputs: d.inputs, ruleSetFiles: d.ruleSetFiles }; if (state.activeId) payload.id = state.activeId; try { const saved = await storage.saveProfile(payload); state.activeId = saved.id; await loadProfiles(); await selectProfile(saved.id); toast('Profile saved', 'ok'); } catch (e) { toast('Save failed: ' + e.message, 'err'); } } async function deleteProfile() { if (!state.activeId) return; if (!confirm('Delete this profile?')) return; try { await storage.deleteProfile(state.activeId); await loadProfiles(); if (state.profiles.length) selectProfile(state.profiles[0].id); else newDraft(); toast('Profile deleted', 'ok'); } catch (e) { toast('Delete failed: ' + e.message, 'err'); } } /* ---------------- analyze ---------------- */ async function analyze() { syncDraftFromForm(); const d = state.draft; const inputs = d.inputs.split('\n').map((s) => s.trim()).filter(Boolean); if (!inputs.length) { toast('Add at least one domain or IP', 'err'); return; } const btn = $('#btn-analyze'); btn.disabled = true; btn.innerHTML = ' Analyzing…'; const loadingEngine = !engineWorker.ready; $('#results').innerHTML = `
    ${loadingEngine ? 'Loading engine (first run, ~a few MB)…' : 'Resolving & matching…'}
    `; try { const result = await engineWorker.analyze({ config: d.config, inputs, ruleSetFiles: d.ruleSetFiles, dohServer: state.settings.dohServer, network: store.get('network', ''), assumeResolved: store.get('assumeResolved', true), }); state.lastResult = result; renderResults(result); } catch (e) { $('#results').innerHTML = `
    âš  ${esc(e.message)}
    `; } finally { btn.disabled = false; btn.innerHTML = 'â–¶ Analyze'; } } /* ---------------- results rendering ---------------- */ function statusBadge(status) { const label = { match: 'MATCH', no_match: 'no match', unknown: 'UNKNOWN?' }[status] || status; return `${label}`; } function renderResults(result) { const root = $('#results'); if (!result.inputs || !result.inputs.length) { root.innerHTML = '
    No inputs.
    '; return; } const warn = (result.warnings && result.warnings.length) ? `
    âš  ${result.warnings.map(esc).join('
    ')}
    ` : ''; root.innerHTML = warn + result.inputs.map((it, i) => renderInputCard(it, i)).join(''); root.querySelectorAll('.result-head').forEach((h) => { h.onclick = () => h.closest('.result-card').classList.toggle('open'); }); root.querySelectorAll('.step-head').forEach((h) => { h.onclick = (e) => { e.stopPropagation(); h.closest('.step').classList.toggle('expanded'); }; }); } function renderInputCard(it, idx) { const chips = []; if (it.kind === 'invalid') { chips.push(`error${esc(it.error || 'invalid')}`); } else { if (it.dns && it.dns.decision) chips.push(dnsChip(it.dns.decision)); if (it.route && it.route.decision) chips.push(routeChip(it.route.decision)); } const open = idx === 0 ? 'open' : ''; return `
    ${esc(it.input)} ${esc(it.kind)}
    ${chips.join('')}
    â–¶
    ${it.kind === 'invalid' ? `

    ${esc(it.error || 'Could not parse this input.')}

    ` : ''} ${renderResolved(it.resolved)} ${it.dns ? `
    DNS routing (which server / dns action)
    ${renderTrace(it.dns, 'dns')}` : ''} ${it.route ? `
    Route matching (which rule / outbound)
    ${renderTrace(it.route, 'route')}` : ''}
    `; } function dnsChip(dec) { if (dec.actionType === 'reject') return `DNSreject`; const server = dec.server || (dec.actionType); const detour = dec.serverInfo && dec.serverInfo.detour ? ` via ${esc(dec.serverInfo.detour)}` : ''; return `DNS${esc(server)}${detour}`; } function routeChip(dec) { if (dec.actionType === 'reject') return `routereject`; if (dec.actionType === 'hijack-dns') return `routehijack-dns`; return `route${esc(dec.outbound || '(default)')}`; } function renderResolved(r) { if (!r) return ''; if (r.error && !(r.ipv4 && r.ipv4.length) && !(r.ipv6 && r.ipv6.length)) { return `
    Resolved (DoH)

    resolution failed: ${esc(r.error)}

    `; } const v4 = (r.ipv4 || []).map((ip) => `${esc(ip)}`).join(''); const v6 = (r.ipv6 || []).map((ip) => `${esc(ip)}`).join(''); if (!v4 && !v6) return `
    Resolved (DoH)

    no A/AAAA records

    `; return `
    Resolved via ${esc(r.server)}
    ${v4}${v6}
    `; } function renderTrace(trace, kind) { const steps = (trace.steps || []).map((s) => renderStep(s, kind, trace)).join(''); const stepsHtml = steps ? `
    ${steps}
    ` : `

    No ${kind === 'dns' ? 'DNS' : 'route'} rules — the final is used directly.

    `; const note = trace.note ? `

    ${esc(trace.note)}

    ` : ''; return stepsHtml + note + renderDecision(trace.decision, kind); } function renderStep(s, kind, trace) { const selected = (kind === 'route' && trace.selectedIndex === s.index) || (kind === 'dns' && trace.matchedIndex === s.index); const cls = ['step', 's-' + s.status]; if (selected) cls.push('s-selected'); if (s.status === 'no_match') cls.push('dimmed'); const conds = s.type === 'logical' ? renderLogical(s) : (s.conditions || []).map(renderCond).join(''); const effect = s.effect ? `
    ↳ ${esc(s.effect)}
    ` : ''; const detailInner = conds || 'no conditions (matches all)'; return `
    ${s.index} ${statusBadge(s.status)} ${esc(s.summary)} ${esc(s.actionText || '')}
    ${effect}
    ${detailInner}
    `; } function renderLogical(s) { const subs = (s.sub || []).map((sub) => { const inner = sub.type === 'logical' ? renderLogical(sub) : (sub.conditions || []).map(renderCond).join(''); return `
    ${statusBadge(sub.status)} ${esc(sub.summary)}
    ${inner}
    `; }).join(''); return `
    logical ${esc(s.mode || 'and')}${s.invert ? ' (inverted)' : ''}:
    ${subs}`; } function renderCond(c) { const matched = c.matched ? `✓ ${esc(c.matched)}` : ''; const note = c.note ? `
    ${esc(c.note)}
    ` : ''; const rs = c.ruleSet ? renderRuleSet(c.ruleSet) : ''; return `
    ${esc(c.field)} ${statusBadge(c.status)} ${esc(c.group)} ${esc(c.value)} ${matched} ${note}
    ${rs}`; } function renderRuleSet(rs) { const inner = (rs.rules || []).map((r) => { const cs = r.type === 'logical' ? renderLogical(r) : (r.conditions || []).map(renderCond).join(''); return `
    ${statusBadge(r.status)} ${esc(r.summary)}${cs}
    `; }).join(''); const err = rs.error ? `
    âš  ${esc(rs.error)}
    ` : ''; const matched = rs.matchedIdx >= 0 ? ` · matched rule #${rs.matchedIdx}` : ''; return `
    ${statusBadge(rs.status)} rule_set ${esc(rs.tag)} (${esc(rs.type || '?')} · ${rs.count || 0} rules${matched})
    ${err}${inner}
    `; } function renderDecision(dec, kind) { if (!dec) return ''; const assumed = dec.assumed ? `depends on assumptions` : ''; const fromFinal = dec.fromFinal ? `via ${kind === 'dns' ? 'dns.final' : 'route.final'}` : ''; if (kind === 'dns') { const cls = dec.actionType === 'reject' ? 'reject' : 'route'; let value, sub = ''; if (dec.actionType === 'reject') { value = 'reject'; } else if (dec.actionType === 'predefined' || dec.actionType === 'respond') { value = dec.actionType; } else { value = dec.server || '(default)'; if (dec.serverInfo) { const si = dec.serverInfo; sub = `${esc(si.type || '')}${si.address ? ' · ' + esc(si.address) : ''}${si.detour ? ' · via outbound ' + esc(si.detour) + '' : ''}`; } } return `
    DNS action ${esc(dec.actionType)}→ ${esc(value)}${sub} ${fromFinal} ${assumed}
    `; } // route let cls = 'route', value; if (dec.actionType === 'reject') { cls = 'reject'; value = 'reject'; } else if (dec.actionType === 'hijack-dns') { value = 'hijack-dns'; } else { value = dec.outbound || '(default outbound)'; } return `
    Final outbound ${esc(value)} ${dec.detail && dec.actionType === 'reject' ? `${esc(dec.detail)}` : ''} ${fromFinal} ${assumed}
    `; } /* ---------------- settings ---------------- */ function openSettings() { const root = $('#modal-root'); root.innerHTML = ` `; const close = () => { root.innerHTML = ''; }; $('#s-close').onclick = close; $('#settings-backdrop').onclick = (e) => { if (e.target.id === 'settings-backdrop') close(); }; root.querySelectorAll('.preset').forEach((b) => b.onclick = () => { $('#s-doh').value = b.dataset.url; }); $('#s-save').onclick = async () => { const dohServer = $('#s-doh').value.trim() || 'https://1.1.1.1/dns-query'; try { state.settings = await storage.saveSettings({ dohServer }); const ind = $('#doh-indicator'); if (ind) ind.textContent = 'DoH: ' + state.settings.dohServer; close(); toast('Settings saved', 'ok'); } catch (e) { toast('Save failed: ' + e.message, 'err'); } }; } init();