update 2026-10-07 18:47:42

This commit is contained in:
action committed 2026-10-07 18:47:42 +08:00
1 parent 617f7ca2a6
commit bd061545ed
4832 files changed
+1693456

No files matched your search

@@ -0,0 +1,794 @@
// SPDX-License-Identifier: Apache-2.0
'use strict';
'require baseclass';
'require form';
'require fs';
'require uci';
'require ui';
'require view.daede.backend as backend';
/* dae 示例里的占位订阅特征 —— example.com、relative/path/to 或中文占位文本 */
const RE_PLACEHOLDER_URL = /(['"])(?:(?:https?|https-file|file):\/\/[^'"]*?(?:example\.com|relative\/path\/to)[^'"]*|你的订阅链接或者机场链接)\1/;
/* Minimal ready-to-run default: gen-dae-config.sh defaults + a placeholder subscription to swap. */
const DEFAULT_TEMPLATE =
'# luci-app-daede 默认配置:把 subscription 里的占位链接换成你的机场订阅,保存即可运行。\n' +
'global {\n' +
' # 端口与网卡:lan_interface 填内网桥(通常 br-lan),wan_interface 保持 auto 即可。\n' +
' tproxy_port: 12345\n' +
' tproxy_port_protect: true\n' +
' so_mark_from_dae: 0\n' +
' log_level: info\n' +
' lan_interface: br-lan\n' +
' wan_interface: auto\n' +
' auto_config_kernel_parameter: true\n' +
' dial_mode: domain\n' +
" tcp_check_url: 'http://cp.cloudflare.com,1.1.1.1,2606:4700:4700::1111'\n" +
" udp_check_dns: 'dns.google:53,8.8.8.8,2001:4860:4860::8888'\n" +
' check_interval: 30s\n' +
' check_tolerance: 50ms\n' +
'}\n' +
'\n' +
'subscription {\n' +
' # ⚠ 把下面这行的 URL 换成你机场的订阅链接,然后保存。\n' +
" my_airport: 'https://example.com/sub'\n" +
'}\n' +
'\n' +
'dns {\n' +
' # 国内域名走 cndns 解析,其余走 fallbackdns,避免 DNS 污染。\n' +
' ipversion_prefer: 4\n' +
' response_ttl: 0\n' +
' upstream {\n' +
" cndns: 'udp://dns.alidns.com:53'\n" +
" fallbackdns: 'tcp+udp://dns.google:53'\n" +
' }\n' +
' routing {\n' +
' request {\n' +
' qname(suffix:googleapis.cn) -> fallbackdns\n' +
' qname(geosite:cn) -> cndns\n' +
' fallback: fallbackdns\n' +
' }\n' +
' }\n' +
'}\n' +
'\n' +
'group {\n' +
' # 节点分组:filter 决定用订阅里的哪些节点,policy 决定怎么选(min_moving_avg = 自动挑最快)。\n' +
' proxy {\n' +
' filter: subtag(my_airport)\n' +
' policy: min_moving_avg\n' +
' }\n' +
'}\n' +
'\n' +
'routing {\n' +
' # 分流规则:从上往下匹配,命中即停,最后由 fallback 兜底。\n' +
' # 想让某个网站直连,在 fallback 之前加一行,例如:domain(example.com) -> direct\n' +
' pname(NetworkManager) -> direct\n' +
' dip(224.0.0.0/3) -> direct\n' +
' dip(255.255.255.255/32) -> direct\n' +
" dip('ff00::/8') -> direct\n" +
' dip(geoip:private) -> direct\n' +
' l4proto(udp) && dport(123) -> direct\n' +
' domain(connectivitycheck.gstatic.com) -> direct\n' +
' domain(msftconnecttest.com) -> direct\n' +
' domain(suffix:googleapis.cn) -> proxy\n' +
' dip(geoip:cn) -> direct\n' +
' domain(geosite:cn) -> direct\n' +
' fallback: proxy\n' +
'}\n';
function detectPlaceholders(text) {
if (!text) return [];
const lines = text.split('\n');
const out = [];
for (let i = 0; i < lines.length; i++) {
const ln = lines[i];
const stripped = ln.replace(/^\s*#.*$/, '');
if (!stripped) continue;
const m = stripped.match(RE_PLACEHOLDER_URL);
if (m) out.push({ line: i + 1, url: m[2], raw: ln.trim() });
}
return out;
}
const GEN = '/usr/share/luci-app-daede/gen-dae-config.sh';
/* Table cell renderer: truncate long links so rows stay compact; full value
stays visible in the edit dialog and on hover. */
function ellipsisCell(section_id) {
const v = this.cfgvalue(section_id) || '';
/* middle-ellipsis keeps the host head + tail visible (the meaningful parts
of a sub link) while staying short enough for one line on a phone */
const short = v.length > 30 ? v.slice(0, 18) + '…' + v.slice(-9) : v;
return E('span', { 'title': v, 'style': 'font-family:ui-monospace,Menlo,monospace;font-size:12px' }, short);
}
/* Tag is auto-generated and rarely relevant to beginners — render it muted. */
function mutedCell(section_id) {
const v = this.cfgvalue(section_id) || '';
return E('span', { 'class': 'dd-meta', 'style': 'opacity:.55;font-size:11.5px' }, v);
}
/* Fill blank tags of a section type with unique <prefix>_<n> values so dae's
config generator always has a stable identifier. Runs after m.save() wrote
the form values into the uci session, before uci.save(). */
function autofillTags(stype, prefix) {
const secs = uci.sections('dae', stype) || [];
const used = {};
secs.forEach(function(s) { if (s.tag) used[s.tag] = 1; });
let n = 1;
secs.forEach(function(s) {
if (s.tag) return;
while (used[prefix + '_' + n]) n++;
const t = prefix + '_' + n;
uci.set('dae', s['.name'], 'tag', t);
used[t] = 1; n++;
});
}
/* Wrap every form section in a collapsible accordion. openTitles lists the
section titles that should start expanded; the rest start collapsed. */
function accordionizeSections(mapNode, openTitles) {
const secs = mapNode.querySelectorAll('.cbi-section');
for (let i = 0; i < secs.length; i++) {
const sec = secs[i];
const h = sec.querySelector('h3');
const title = h ? h.textContent.trim() : _('Section');
const open = (openTitles || []).indexOf(title) >= 0;
const body = E('div', { 'class': 'dd-adv-body' });
while (sec.firstChild) body.appendChild(sec.firstChild);
/* drop the moved <h3> so it doesn't duplicate the accordion bar title */
const dupTitle = body.querySelector('h3');
if (dupTitle) dupTitle.parentNode.removeChild(dupTitle);
const wrap = E('div', { 'class': 'dd-adv' + (open ? '' : ' dd-closed') }, [
E('div', { 'class': 'dd-adv-bar' }, [
E('span', {}, title),
E('span', { 'class': 'dd-adv-chevron' }, '›')
]),
body
]);
wrap.firstChild.addEventListener('click', function() { wrap.classList.toggle('dd-closed'); });
sec.appendChild(wrap);
}
}
function organizeDaeSections(mapNode, openTitles) {
accordionizeSections(mapNode, openTitles);
const advTitles = [ _('Groups'), _('Routing'), _('DNS'), _('Logging') ];
const advWraps = [];
mapNode.querySelectorAll('.dd-adv').forEach(function(w) {
const t = w.querySelector('.dd-adv-bar span');
if (t && advTitles.indexOf(t.textContent.trim()) >= 0) advWraps.push(w);
});
if (!advWraps.length) return;
const outerOpen = (openTitles || []).indexOf(_('Advanced settings')) >= 0;
const outerBody = E('div', { 'class': 'dd-adv-body' });
const outer = E('div', { 'class': 'dd-adv' + (outerOpen ? '' : ' dd-closed') }, [
E('div', { 'class': 'dd-adv-bar' }, [
E('span', {}, _('Advanced settings')),
E('span', { 'class': 'dd-adv-chevron' }, '›')
]),
outerBody
]);
outer.firstChild.addEventListener('click', function() { outer.classList.toggle('dd-closed'); });
advWraps[0].parentNode.insertBefore(outer, advWraps[0]);
advWraps.forEach(function(w) { outerBody.appendChild(w); });
}
function captureAccordionState(mapNode) {
if (!mapNode) return [];
return Array.prototype.map.call(
mapNode.querySelectorAll('.dd-adv:not(.dd-closed) > .dd-adv-bar span:first-child'),
function(node) { return node.textContent.trim(); }
);
}
function restoreAccordionState(mapNode, openTitles) {
if (!mapNode || mapNode.querySelector('.dd-adv')) return;
organizeDaeSections(mapNode, openTitles || []);
}
/* Friendly form UI for the dae backend. Form is the source of truth: on save we
commit the `dae` UCI package, then gen-dae-config.sh renders config.dae,
validates it and hot-reloads. */
function renderDaeForms(ctx) {
let m, s, o;
m = new form.Map('dae', null, null);
/* Subscriptions */
s = m.section(form.GridSection, 'subscription', _('Subscriptions'), null);
s.addremove = true;
s.anonymous = true;
s.sortable = false;
o = s.option(form.Value, 'tag', _('Tag'));
o.placeholder = _('auto-generated');
o.textvalue = mutedCell;
o = s.option(form.Value, 'url', _('Subscription URL'));
o.rmempty = false;
o.placeholder = 'https://example.com/sub';
o.textvalue = ellipsisCell; /* keep long links from blowing up the row */
o.validate = function(sid, v) {
if (!v) return _('URL is required');
if (!/^(https?|https-file|file):\/\//.test(v)) return _('Must start with http(s):// or file://');
return true;
};
o = s.option(form.Flag, 'enabled', _('Enabled'));
o.default = '1';
o.editable = true;
/* Manual nodes (share links) */
s = m.section(form.GridSection, 'node', _('Manual nodes'),
_('One node share link per line — ss, vmess, vless, trojan, tuic, hysteria2, socks5.'));
s.addremove = true;
s.anonymous = true;
s.sortable = false;
o = s.option(form.Value, 'tag', _('Tag'));
o.placeholder = _('auto-generated');
o.textvalue = mutedCell;
o = s.option(form.Value, 'link', _('Share Link'));
o.rmempty = false;
o.placeholder = 'vmess://...';
o.textvalue = ellipsisCell;
o = s.option(form.Flag, 'enabled', _('Enabled'));
o.default = '1';
o.editable = true;
/* Subscription / manual-node tags for the group filter dropdowns */
const subTags = (uci.sections('dae', 'subscription') || [])
.map(function(x) { return x.tag; }).filter(function(x) { return !!x; });
const nodeTags = (uci.sections('dae', 'node') || [])
.map(function(x) { return x.tag; }).filter(function(x) { return !!x; });
/* Groups (outbound) */
s = m.section(form.TypedSection, 'group', _('Groups'),
_('Outbound groups. Leave the source empty to use all nodes. Set the route fallback to one of these.'));
s.addremove = true;
s.anonymous = true;
o = s.option(form.Value, 'name', _('Name'));
o.rmempty = false;
o.default = 'proxy'; /* real default value, editable — a bright placeholder looked filled and tripped the required check */
o.placeholder = 'proxy';
o.validate = function(sid, v) {
if (!v) return _('Name is required');
/* dae fatals on duplicate outbound names — keep group names unique */
const dup = (uci.sections('dae', 'group') || []).some(function(g) {
return g['.name'] !== sid && g.name === v;
});
return dup ? _('Group name must be unique') : true;
};
o = s.option(form.ListValue, 'policy', _('Policy'));
o.value('min_moving_avg', _('Min moving average latency'));
o.value('min', _('Min last latency'));
o.value('min_avg10', _('Min average of last 10'));
o.value('random', _('Random'));
o.value('fixed(0)', _('Fixed (first node)'));
o.default = 'min_moving_avg';
o = s.option(form.DynamicList, 'source', _('Source'),
_('Subscriptions and nodes that feed this group. Pick from the list or type a name. Leave empty to use all nodes.'));
subTags.forEach(function(t) { o.value(t, t + ' (' + _('subscription') + ')'); });
nodeTags.forEach(function(t) { o.value(t, t + ' (' + _('node') + ')'); });
o = s.option(form.DynamicList, 'name_filter', _('Name filter'),
_('Optional. Narrows subscriptions in the source to nodes whose name matches — e.g. 香港 or 新加坡|日本. Nodes you picked by name are always kept.'));
o.placeholder = '新加坡|日本';
/* Routing presets */
const groupNames = (uci.sections('dae', 'group') || [])
.map(function(x) { return x.name; }).filter(function(x) { return !!x; });
s = m.section(form.NamedSection, 'routing', 'routing', _('Routing'));
s.addremove = false;
o = s.option(form.Flag, 'private_direct', _('Private IPs direct'),
_('LAN / private addresses bypass the proxy.'));
o.default = '1';
o = s.option(form.Flag, 'cn_direct', _('China direct'),
_('China mainland IPs and domains bypass the proxy.'));
o.default = '1';
o = s.option(form.Flag, 'block_ads', _('Block ads'),
_('Drop traffic matching the ad domain list.'));
o.default = '0';
o = s.option(form.Value, 'fallback', _('Fallback group'),
_('Default outbound for everything else.'));
o.default = 'proxy'; /* matches the default proxy group so the box works out of the box */
if (groupNames.length)
groupNames.forEach(function(n) { o.value(n, n); });
else
o.value('proxy', 'proxy');
o = s.option(form.DynamicList, 'custom', _('Custom rules'),
_('Raw dae routing lines, evaluated before fallback. Target must be a group name above (or direct/block).'));
o.placeholder = 'dip(geoip:jp) -> proxy';
/* DNS presets */
s = m.section(form.NamedSection, 'dns', 'dns', _('DNS'));
s.addremove = false;
o = s.option(form.Value, 'cn_upstream', _('China upstream'),
_('Resolves China-mainland domains.'));
o.default = 'udp://dns.alidns.com:53';
o.placeholder = 'udp://dns.alidns.com:53';
o = s.option(form.Value, 'fallback_upstream', _('Fallback upstream'),
_('Resolves everything else.'));
o.default = 'tcp+udp://dns.google:53';
o.placeholder = 'tcp+udp://dns.google:53';
o = s.option(form.Value, 'response_ttl', _('DNS response TTL'),
_('TTL returned to LAN DNS clients. 0 keeps dae default behavior.'));
o.datatype = 'uinteger';
o.default = '0';
o.placeholder = '60';
/* Logging — folded into the main form so the single Save button covers it
too (no separate native save bar) */
/* Network interfaces — override dae auto WAN detect, fails on legacy swconfig (#15).
Combobox: dropdown of real devices but still free-text for undetectable WANs */
const netDevs = (ctx && ctx.netDevs) || [];
s = m.section(form.NamedSection, 'config', 'dae', _('Network interfaces'));
s.addremove = false;
o = s.option(form.Value, 'lan_interface', _('LAN interface'),
_('Interface to proxy the LAN from. Usually br-lan.'));
o.default = 'br-lan';
o.placeholder = 'br-lan';
netDevs.forEach(function(d) { o.value(d); });
o = s.option(form.Value, 'wan_interface', _('WAN interface'),
_('Uplink. auto = detect by default route; on legacy swconfig or multi-WAN set it, e.g. eth0.2.'));
o.default = 'auto';
o.placeholder = 'auto';
o.value('auto', 'auto');
netDevs.forEach(function(d) { o.value(d); });
s = m.section(form.NamedSection, 'config', 'dae', _('Logging'));
s.addremove = false;
o = s.option(form.ListValue, 'log_level', _('Log level'),
_('Debug and trace show per-connection routing details, but increase CPU usage and log volume. Enable them only while troubleshooting.'));
o.value('error', 'ERROR');
o.value('warn', 'WARN');
o.value('info', 'INFO');
o.value('debug', 'DEBUG');
o.value('trace', 'TRACE');
o.default = 'info';
o.rmempty = false;
o = s.option(form.Value, 'log_maxsize', _('Max Log Size (MB)'),
_('Rotate the log file once it grows past this many megabytes.'));
o.datatype = 'uinteger';
o.default = '1';
o = s.option(form.Value, 'log_maxbackups', _('Max Log Backups'),
_('Number of rotated log files to keep.'));
o.datatype = 'uinteger';
o.default = '1';
const status = E('span', { 'class': 'dd-editor-status' }, '');
let renderSaveActions = function() {};
let statusTimer = null;
function flash(text, kind, hold) {
status.textContent = text;
status.classList.remove('ok', 'err');
if (kind) status.classList.add(kind);
status.classList.add('show');
if (statusTimer) clearTimeout(statusTimer);
statusTimer = setTimeout(function() { status.classList.remove('show'); }, hold || 3000);
}
/* one save pipeline, parameterised by intent:
- opts.start: after generate, enable + start dae (stopped → "Save and Start")
- otherwise just save + generate; generate hot-reloads itself when dae runs */
function doSave(opts, btns) {
const settingsCard = status.closest('.dd-settings-card');
const openTitles = captureAccordionState(settingsCard && settingsCard.querySelector('.cbi-map'));
const restoreLiveAccordions = function() {
const liveCard = status.closest('.dd-settings-card');
restoreAccordionState(liveCard && liveCard.querySelector('.cbi-map'), openTitles);
};
btns.forEach(function(b) { b.disabled = true; });
flash(_('Saving…'));
/* ensure the singleton sections exist before parsing the form —
on installs where uci-defaults never seeded them, writing an
option to a missing named section fails with ubus NOT_FOUND (4) */
if (!uci.get('dae', 'routing')) uci.add('dae', 'routing', 'routing');
if (!uci.get('dae', 'dns')) uci.add('dae', 'dns', 'dns');
if (!uci.get('dae', 'config')) uci.add('dae', 'dae', 'config');
return m.save(null, true)
.then(function() {
restoreLiveAccordions();
/* assign unique tags to any rows the user left blank */
autofillTags('subscription', 'subscription');
autofillTags('node', 'node');
})
.then(function() { return uci.save(); })
.then(function() {
/* apply flushes the rpc session to /etc/config so the
generator (reads /etc/config/dae) sees the form data;
skip it when nothing changed — apply on an empty
changeset throws ubus NO_DATA (code 5). */
return uci.changes().then(function(ch) {
return (ch && Object.keys(ch).length) ? uci.apply() : null;
});
})
.then(function() { return fs.exec(GEN, ['generate']); })
.then(function(res) {
if (res && res.code !== 0) {
const err = (res.stderr || res.stdout || ('exit ' + res.code)).trim().split('\n')[0];
flash(_('Apply failed: %s').format(err), 'err', 9000);
throw new Error('gen-failed');
}
if (!opts.start) {
/* generate already hot-reloaded if dae was running */
flash(opts.okMsg || _('Saved'), 'ok');
return;
}
flash(_('Starting…'));
return fs.exec('/sbin/uci', ['set', 'dae.config.enabled=1'])
.then(function() { return fs.exec('/sbin/uci', ['commit', 'dae']); })
.then(function() { return fs.exec(backend.BACKENDS.dae.initd, ['enable']); })
.then(function() { return fs.exec(backend.BACKENDS.dae.initd, ['start']); })
.then(function(r2) {
if (r2 && r2.code !== 0)
flash(_('Start failed: %s').format(r2.stderr || r2.stdout || ('exit ' + r2.code)), 'err', 9000);
else {
flash(_('Saved · started'), 'ok');
renderSaveActions(true);
}
});
})
.catch(function(e) {
if (e && e.message === 'gen-failed') return;
if (e && e.name === 'CBIValidationError') {
flash(_('Please fix the highlighted fields.'), 'err', 6000);
return;
}
flash(_('Save failed: %s').format(e.message || e), 'err', 9000);
})
.finally(function() {
restoreLiveAccordions();
btns.forEach(function(b) { b.disabled = false; });
window.setTimeout(function() { ui.changes.init(); }, 1800);
});
}
return m.render().then(function(mapNode) {
/* beginner default: only Subscriptions starts open; nodes/groups/routing/dns/logging collapse */
organizeDaeSections(mapNode, [ _('Subscriptions') ]);
const cardChildren = [ E('h4', { 'class': 'dd-card-title' }, _('dae Configuration')) ];
cardChildren.push(mapNode);
/* state-aware primary action: running → one "Save and Apply"; stopped →
"Save config" (secondary) + "Save and Start" (primary). Starting the
proxy is an explicit choice, never an implicit side effect of saving. */
return backend.detectRunning().then(function(rr) {
const running = rr && rr.dae;
/* "Update all subscriptions" only helps while dae runs (hot reload
re-pulls them). A stopped dae re-fetches every subscription on
start, so there is nothing to update — hide the button then. */
if (running) {
const upBtn = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Update all subscriptions'));
const upMsg = E('span', { 'class': 'dd-meta', 'style': 'margin-left:8px;display:none' }, '');
const setUp = function(text, kind) {
upMsg.textContent = text;
upMsg.style.display = '';
upMsg.style.color = (kind === 'err') ? 'var(--error-color, #d33)' : 'var(--success-color, #2a8)';
if (kind !== 'err')
setTimeout(function() { if (upMsg.textContent === text) upMsg.style.display = 'none'; }, 5000);
};
upBtn.addEventListener('click', function(ev) {
ev.preventDefault();
upBtn.disabled = true;
setUp(_('Updating…'));
fs.exec(backend.BACKENDS.dae.initd, ['hot_reload']).then(function(res) {
if (res && res.code !== 0)
setUp(_('Update failed: %s').format(res.stderr || res.stdout || ('exit ' + res.code)), 'err');
else
setUp(_('Subscriptions updated (dae reloaded)'), 'ok');
}).catch(function(e) {
setUp(_('Update failed: %s').format(e.message || e), 'err');
}).finally(function() { upBtn.disabled = false; });
});
const upWrap = E('div', { 'class': 'dd-actions', 'style': 'margin:0 0 8px' }, [ upBtn, upMsg ]);
const advs = mapNode.querySelectorAll('.dd-adv');
for (let i = 0; i < advs.length; i++) {
const t = advs[i].querySelector('.dd-adv-bar span');
if (t && t.textContent.trim() === _('Subscriptions')) {
const body = advs[i].querySelector('.dd-adv-body');
const tbl = body && body.querySelector('.cbi-section-table');
if (tbl) tbl.parentNode.insertBefore(upWrap, tbl);
else if (body) body.insertBefore(upWrap, body.firstChild);
break;
}
}
}
const actionsWrap = E('div', { 'class': 'dd-editor-actions' });
renderSaveActions = function(isRunning) {
while (actionsWrap.firstChild) actionsWrap.removeChild(actionsWrap.firstChild);
let btns;
if (isRunning) {
const apply = E('button', { 'class': 'cbi-button cbi-button-positive' }, _('Save and Apply'));
btns = [ apply ];
apply.addEventListener('click', function(ev) { ev.preventDefault(); doSave({ okMsg: _('Saved · applied') }, btns); });
actionsWrap.appendChild(apply);
} else {
const saveOnly = E('button', { 'class': 'cbi-button' }, _('Save config'));
const saveStart = E('button', { 'class': 'cbi-button cbi-button-positive' }, _('Save and Start'));
btns = [ saveOnly, saveStart ];
saveOnly.addEventListener('click', function(ev) { ev.preventDefault(); doSave({ okMsg: _('Saved') }, btns); });
saveStart.addEventListener('click', function(ev) { ev.preventDefault(); doSave({ start: true }, btns); });
actionsWrap.appendChild(saveOnly);
actionsWrap.appendChild(saveStart);
}
actionsWrap.appendChild(status);
};
renderSaveActions(running);
cardChildren.push(actionsWrap);
return E('div', { 'class': 'dd-card dd-settings-card' }, cardChildren);
});
});
}
/* Import banner: offer to pull subscription/node from an existing hand-written
config.dae into the form when the form is still empty. */
function renderDaeImportBanner() {
const hasForms = (uci.sections('dae', 'subscription') || []).length
|| (uci.sections('dae', 'node') || []).length;
if (hasForms)
return Promise.resolve(null);
/* stat first (ubus): avoid a 404 when config.dae doesn't exist yet */
return fs.stat(backend.BACKENDS.dae.config).then(function() {
return fs.read_direct(backend.BACKENDS.dae.config, 'text');
}).then(function(content) {
if (!content || !/\b(subscription|node)\s*\{/.test(content))
return null;
const btn = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Import from existing config'));
const note = E('span', { 'class': 'dd-meta', style: 'margin-left:8px' }, '');
btn.addEventListener('click', function(ev) {
ev.preventDefault();
btn.disabled = true;
note.textContent = _('Importing…');
fs.exec(GEN, ['import']).then(function(res) {
if (res && res.code === 0) {
note.textContent = _('Imported, reloading…');
setTimeout(function() { window.location.reload(); }, 600);
} else {
note.textContent = _('Import failed');
btn.disabled = false;
}
}).catch(function() { note.textContent = _('Import failed'); btn.disabled = false; });
});
return E('div', { 'class': 'dd-card', style: 'border-color:rgba(217,158,0,.4)' }, [
E('div', { 'class': 'dd-ph-warn-title' }, _('Existing config detected')),
E('div', { 'class': 'dd-settings-descr', style: 'margin:4px 0 8px' },
_('You have a hand-written config.dae. Import its subscriptions and nodes into the form, or keep editing manually below. Saving the form overwrites config.dae.')),
E('div', { 'class': 'dd-actions' }, [ btn, note ])
]);
}).catch(function() { return null; });
}
/* Lightweight dae-DSL syntax highlighter: tokenises into spans for a <pre>
layer drawn behind a transparent textarea (no editor library needed). */
function highlightDae(src) {
const re = /(#[^\n]*)|('[^'\n]*')|\b(global|subscription|node|group|routing|dns|include|upstream|request|response)\b|\b([A-Za-z_][\w-]*)(?=\s*\()|(->|&&|\|\|)|\b(geoip|geosite)(?=:)/g;
let out = '', last = 0, m;
const esc = function(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); };
while ((m = re.exec(src)) !== null) {
out += esc(src.slice(last, m.index));
const cls = m[1] ? 'c' : m[2] ? 's' : m[3] ? 'k' : m[4] ? 'f' : m[5] ? 'o' : 'g';
out += '<span class="dh-' + cls + '">' + esc(m[0]) + '</span>';
last = m.index + m[0].length;
}
out += esc(src.slice(last));
return out;
}
function renderDaeEditor() {
const textarea = E('textarea', {
'class': 'dd-editor dd-editor-hl',
'spellcheck': 'false',
'wrap': 'off',
'placeholder': _('dae config file is empty. Click "Load default config" to start, or paste your config here.')
}, '');
const hlCode = E('code', {});
const hlPre = E('pre', { 'class': 'dd-hl', 'aria-hidden': 'true' }, hlCode);
const editWrap = E('div', { 'class': 'dd-edit-wrap' }, [ hlPre, textarea ]);
function syncHL() {
hlCode.innerHTML = highlightDae(textarea.value) + '\n';
hlPre.scrollTop = textarea.scrollTop;
hlPre.scrollLeft = textarea.scrollLeft;
}
textarea.addEventListener('scroll', function() {
hlPre.scrollTop = textarea.scrollTop;
hlPre.scrollLeft = textarea.scrollLeft;
});
const save = E('button', { 'class': 'cbi-button cbi-button-positive' }, _('Save manual config'));
const init = E('button', { 'class': 'cbi-button cbi-button-action' }, _('Load default config'));
const status = E('span', { 'class': 'dd-editor-status' }, '');
const phWarn = E('div', { 'class': 'dd-ph-warn' }, [
E('div', { 'class': 'dd-ph-warn-title' }, ''),
E('div', { 'class': 'dd-ph-howto' }, ''),
E('div', { 'class': 'dd-ph-list-label' }, _('Placeholder lines (click line number to jump):')),
E('ul', { 'class': 'dd-ph-list' })
]);
/* Footer:dae version · 行数 · 占位剩余 */
const fbVer = E('span', { 'class': 'dd-fb-item' }, [ E('span', { 'class': 'dd-fb-key' }, 'dae'), E('span', {}, '—') ]);
const fbLine = E('span', { 'class': 'dd-fb-item' }, [ E('span', { 'class': 'dd-fb-key' }, _('lines')), E('span', {}, '0') ]);
const fbStat = E('span', { 'class': 'dd-fb-item dd-fb-ok' }, '✓ ready');
const footer = E('div', { 'class': 'dd-editor-footer' }, [ fbVer, fbLine, fbStat ]);
let statusTimer = null;
function flashStatus(text, kind, holdMs) {
status.textContent = text;
status.classList.remove('ok', 'err');
if (kind) status.classList.add(kind);
status.classList.add('show');
if (statusTimer) clearTimeout(statusTimer);
statusTimer = setTimeout(function() {
status.classList.remove('show');
}, holdMs || 3000);
}
function jumpToLine(lineNo) {
const lines = textarea.value.split('\n');
let offset = 0;
for (let i = 0; i < lineNo - 1 && i < lines.length; i++)
offset += lines[i].length + 1;
textarea.focus();
textarea.setSelectionRange(offset, offset + (lines[lineNo - 1] || '').length);
const lineHeight = 18;
textarea.scrollTop = Math.max(0, (lineNo - 4) * lineHeight);
}
function refreshFooter(phCount) {
const lines = textarea.value ? textarea.value.split('\n').length : 0;
fbLine.lastChild.textContent = String(lines);
while (fbStat.firstChild) fbStat.removeChild(fbStat.firstChild);
fbStat.classList.remove('dd-fb-ok', 'dd-fb-warn');
if (phCount > 0) {
fbStat.classList.add('dd-fb-warn');
fbStat.textContent = _('⚠ %d placeholder').format(phCount);
} else {
fbStat.classList.add('dd-fb-ok');
fbStat.textContent = lines > 0 ? '✓ ready' : '— empty';
}
}
function refreshPlaceholders() {
syncHL();
const hits = detectPlaceholders(textarea.value);
const titleEl = phWarn.querySelector('.dd-ph-warn-title');
const howtoEl = phWarn.querySelector('.dd-ph-howto');
const listEl = phWarn.querySelector('.dd-ph-list');
while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
refreshFooter(hits.length);
if (!hits.length) {
phWarn.classList.remove('show');
return;
}
titleEl.textContent = _('⚠ Still needs a subscription link: %d placeholder URL line(s) detected').format(hits.length);
while (howtoEl.firstChild) howtoEl.removeChild(howtoEl.firstChild);
/* assemble rich text from DOM nodes so the code spans keep their styling */
howtoEl.appendChild(document.createTextNode(_('These ')));
const c1 = document.createElement('code'); c1.textContent = 'example.com'; howtoEl.appendChild(c1);
howtoEl.appendChild(document.createTextNode(' / '));
const c2 = document.createElement('code'); c2.textContent = 'relative/path/to'; howtoEl.appendChild(c2);
howtoEl.appendChild(document.createTextNode(_(' are placeholder addresses from dae\'s template, not real subscriptions. Replace the URL on any one line with your airport subscription link (delete the spare lines) and save.')));
hits.forEach(function(h) {
const lnSpan = E('span', { 'class': 'dd-ph-ln', 'title': _('Jump to line') }, 'L' + h.line);
lnSpan.addEventListener('click', function() { jumpToLine(h.line); });
const txt = E('span', { 'class': 'dd-ph-txt' }, h.url);
listEl.appendChild(E('li', {}, [ lnSpan, txt ]));
});
phWarn.classList.add('show');
}
textarea.addEventListener('input', refreshPlaceholders);
function loadConfig() {
/* stat first (ubus, no HTTP): a fresh install has no config.dae yet, and
reading a missing file via fs.read_direct logs a noisy 404 */
return fs.stat(backend.BACKENDS.dae.config).then(function() {
return fs.read_direct(backend.BACKENDS.dae.config, 'text');
}).then(function(content) {
textarea.value = content || '';
}).catch(function() {
textarea.value = '';
}).finally(refreshPlaceholders);
}
/* 校验流程:先写到 /tmp/dae-validate.dae 干测 → dae validate 通过才覆盖正式 config */
const VALIDATE_PATH = '/tmp/dae-validate.dae';
save.addEventListener('click', function(ev) {
ev.preventDefault();
save.disabled = true;
flashStatus(_('Validating…'));
fs.write(VALIDATE_PATH, textarea.value, 384)
.then(function() { return fs.exec('/usr/bin/env', ['DAE_LOCATION_ASSET=/usr/share/v2ray', '/usr/bin/dae', 'validate', '-c', VALIDATE_PATH]); })
.then(function(res) {
if (res && res.code !== 0) {
const err = (res.stderr || res.stdout || ('exit ' + res.code)).trim().split('\n')[0];
flashStatus(_('Validate failed: %s').format(err), 'err', 8000);
throw new Error('validate-failed');
}
flashStatus(_('Saving…'));
return fs.write(backend.BACKENDS.dae.config, textarea.value, 384);
})
.then(function() { return backend.detectRunning(); })
.then(function(running) {
if (running && running.dae)
return fs.exec(backend.BACKENDS.dae.initd, ['hot_reload']).then(function(res) {
if (res && res.code !== 0)
flashStatus(_('Reload failed: %s').format(res.stderr || res.stdout || ('exit ' + res.code)), 'err', 8000);
else
flashStatus(_('Validated · saved · hot-reloaded'), 'ok');
});
flashStatus(_('Saved OK (start dae to apply)'), 'ok');
})
.catch(function(e) {
if (e && e.message === 'validate-failed') return;
flashStatus(_('Save failed: %s').format(e.message || e), 'err', 8000);
})
.finally(function() {
save.disabled = false;
fs.exec('/bin/rm', ['-f', VALIDATE_PATH]).catch(function() {});
});
});
init.addEventListener('click', function(ev) {
ev.preventDefault();
if (textarea.value.trim() && !confirm(_('This will replace your current config. Continue?')))
return;
textarea.value = DEFAULT_TEMPLATE;
refreshPlaceholders();
flashStatus(_('Default config loaded — replace the subscription link, then save'), 'ok');
});
/* 一次性探测 dae --version;失败就显示 unknown,不影响主流程 */
fs.exec('/usr/bin/dae', ['--version']).then(function(res) {
const out = (res && res.stdout || '').trim();
const m = out.match(/dae\s+version\s+(\S+)/);
fbVer.lastChild.textContent = m ? m[1].split('-')[0] : 'unknown';
}).catch(function() {
fbVer.lastChild.textContent = 'unknown';
});
loadConfig();
return E('div', { 'class': 'dd-card' }, [
E('h4', { 'class': 'dd-card-title' }, _('Advanced / Manual Mode')),
(function() {
var adv = E('div', { 'class': 'dd-adv dd-closed', style: 'margin-top:8px' }, [
E('div', { 'class': 'dd-adv-bar' }, [
E('span', {}, _('Configuration Editor')),
E('span', { 'class': 'dd-adv-chevron' }, '›')
]),
E('div', { 'class': 'dd-adv-body' }, [
E('div', { 'class': 'dd-editor-hint', style: 'margin:0 0 10px' }, [
E('b', {}, _('Text-only mode.')),
' ',
_('Edit the dae DSL directly. "Load default config" gives a ready-to-run starting point. Use daed for a GUI.')
]),
phWarn,
editWrap,
footer,
E('div', { 'class': 'dd-editor-actions' }, [ save, init, status ])
])
]);
adv.firstChild.addEventListener('click', function() { adv.classList.toggle('dd-closed'); });
return adv;
})()
]);
}
return baseclass.extend({
renderDaeForms: renderDaeForms,
renderDaeImportBanner: renderDaeImportBanner,
renderDaeEditor: renderDaeEditor
});