'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('')}
`;
$('#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 = ``;
} 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 = `
Settings
${['https://1.1.1.1/dns-query', 'https://dns.google/dns-query', 'https://dns.quad9.net/dns-query', 'https://dns.alidns.com/dns-query']
.map((u) => ``).join('')}
`;
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();