🎨 Sync 2026-09-15 11:19:33

This commit is contained in:
github-actions[bot] committed 2026-09-15 11:19:33 +08:00
1 parent a9e0557b71
commit ac76f4cbc9
106 files changed
+17125 -19121

No files matched your search

@@ -12,6 +12,158 @@
'require uci';
'require ui';
function pad2(value) {
value = (value == null) ? '' : String(value).trim();
return value.length === 1 ? '0' + value : value;
}
function normalizeTimePart(value, min, max) {
value = parseInt(String(value || '').trim(), 10);
if (isNaN(value))
value = min;
value = Math.max(min, Math.min(max, value));
return pad2(value);
}
function parseCron(value) {
const matched = String(value || '').trim().match(/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+([0-7*])$/);
if (!matched)
return null;
const minute = +matched[1],
hour = +matched[2];
if (minute < 0 || minute > 59 || hour < 0 || hour > 23)
return null;
return {
minute: normalizeTimePart(matched[1], 0, 59),
hour: normalizeTimePart(matched[2], 0, 23),
day: matched[3] === '*' ? '*' : (matched[3] === '7' ? '0' : matched[3])
};
}
function buildCron(day, hour, minute) {
return [
normalizeTimePart(minute, 0, 59),
normalizeTimePart(hour, 0, 23),
'*',
'*',
String(day || '*')
].join(' ');
}
function alignCronEditorRow(wrap) {
let field = wrap?.parentNode;
while (field && !field.classList?.contains('cbi-value-field'))
field = field.parentNode;
let row = field?.parentNode;
while (row && !row.classList?.contains('cbi-value'))
row = row.parentNode;
if (!row || !field)
return false;
if (wrap._dailyRow) {
wrap._dailyRow.style.display = row.style.display;
return true;
}
let dailyRow = E('div', { 'class': 'cbi-value homeproxy-cron-daily-row' }, [
E('div', { 'class': 'cbi-value-title' }, _('Update time (daily)')),
E('div', { 'class': 'cbi-value-field' }, [ wrap._dailyField ])
]);
wrap._dailyRow = dailyRow;
row.parentNode.insertBefore(dailyRow, row.nextSibling);
return true;
}
function watchCronEditorRow(wrap) {
const align = () => alignCronEditorRow(wrap);
[ 0, 50, 200, 500, 1000, 2000 ].forEach(delay => window.setTimeout(align, delay));
if (typeof MutationObserver !== 'undefined' && document?.body) {
const observer = new MutationObserver(() => {
if (align())
observer.disconnect();
});
observer.observe(document.body, { childList: true, subtree: true });
window.setTimeout(() => observer.disconnect(), 3000);
}
}
function renderCronEditor(input) {
if (!input)
return null;
const parsed = parseCron(input.value);
input.type = 'text';
input.readOnly = true;
input.tabIndex = -1;
input.style.position = 'absolute';
input.style.opacity = '0';
input.style.pointerEvents = 'none';
input.style.width = '1px';
input.style.height = '1px';
input.style.minWidth = '1px';
input.style.maxWidth = '1px';
input.style.padding = '0';
input.style.border = '0';
let day = E('select', { 'class': 'cbi-input-select', 'style': 'width: 12em !important; min-width: 12em !important; max-width: 12em !important; box-sizing: border-box;' }),
hour = E('select', { 'class': 'cbi-input-select', 'style': 'width: 4.25em !important; min-width: 4.25em !important; max-width: 4.25em !important; box-sizing: border-box;' }),
minute = E('select', { 'class': 'cbi-input-select', 'style': 'width: 4.25em !important; min-width: 4.25em !important; max-width: 4.25em !important; box-sizing: border-box;' }),
dailyField = E('div', { 'style': 'display: flex; align-items: center; gap: .25em; width: 9.5em;' }, [
hour,
E('span', ':'),
minute
]),
wrap = E('div', { 'class': 'homeproxy-cron-editor' }, [ day ]);
wrap._dailyField = dailyField;
for (let i = 0; i < 24; i++)
hour.appendChild(E('option', { value: pad2(i) }, pad2(i)));
for (let i = 0; i < 60; i++)
minute.appendChild(E('option', { value: pad2(i) }, pad2(i)));
[
['*', _('Every day')],
['1', _('Every Monday')],
['2', _('Every Tuesday')],
['3', _('Every Wednesday')],
['4', _('Every Thursday')],
['5', _('Every Friday')],
['6', _('Every Saturday')],
['0', _('Every Sunday')]
].forEach(([value, label]) => day.appendChild(E('option', { value }, label)));
day.value = parsed ? parsed.day : '*';
hour.value = parsed ? parsed.hour : '00';
minute.value = parsed ? parsed.minute : '00';
input.value = buildCron(day.value, hour.value, minute.value);
input.setAttribute('value', input.value);
const sync = () => {
input.value = buildCron(day.value, hour.value, minute.value);
input.setAttribute('value', input.value);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
};
day.addEventListener('change', sync);
hour.addEventListener('change', sync);
minute.addEventListener('change', sync);
wrap.appendChild(input);
window.setTimeout(() => watchCronEditorRow(wrap), 0);
return wrap;
}
return baseclass.extend({
dns_strategy: {
'': _('Default'),
@@ -86,6 +238,29 @@ return baseclass.extend({
}
}),
CBIMultiValue: form.MultiValue.extend({
__name__: 'CBI.HomeProxyMultiValue',
renderWidget(section_id, _option_index, cfgvalue) {
let value = (cfgvalue != null) ? cfgvalue : this.default,
choices = this.transformChoices() || {},
widget = new ui.Dropdown(L.toArray(value), choices, {
id: this.cbid(section_id),
sort: this.keylist,
multiple: true,
optional: this.optional || this.rmempty,
select_placeholder: this.placeholder,
create: this.create,
display_items: this.display_size ?? this.size ?? 3,
dropdown_items: this.dropdown_size ?? this.size ?? -1,
validate: this.getValidator(section_id),
disabled: (this.readonly != null) ? this.readonly : this.map.readonly
});
return widget.render();
}
}),
calcStringMD5(e) {
/* Thanks to https://stackoverflow.com/a/41602636 */
let h = (a, b) => {
@@ -185,6 +360,49 @@ return baseclass.extend({
return L.resolveDefault(callGetSingBoxFeatures(), {});
},
renderCronSelector(/* ... */) {
if (!this._homeproxyCronOriginalFormvalue) {
this._homeproxyCronOriginalFormvalue = this.formvalue;
this.formvalue = function(section_id) {
let cbid = this.cbid(section_id),
ids = [ cbid, 'widget.' + cbid ],
roots = [ document.getElementById('modal_overlay'), document ],
input = null;
for (let root of roots) {
if (!root)
continue;
for (let el of root.querySelectorAll('input')) {
if (ids.includes(el.id) || ids.includes(el.name)) {
input = el;
break;
}
}
if (input)
break;
}
let value = input?.value ||
this._homeproxyCronOriginalFormvalue?.call(this, section_id) ||
this.default ||
'0 0 * * *';
return parseCron(value) ? value : (this.default || '0 0 * * *');
};
}
let node = form.Value.prototype.renderWidget.apply(this, arguments),
editor = renderCronEditor(node.querySelector('input'));
return editor || node;
},
renderCronSelectorRow(/* ... */) {
return form.Value.prototype.render.apply(this, arguments);
},
generateRand(type, length) {
let byteArr;
if (['base64', 'hex'].includes(type))
@@ -208,6 +426,9 @@ return baseclass.extend({
},
loadDefaultLabel(uciconfig, ucisection) {
if (!ucisection)
return '';
let label = uci.get(uciconfig, ucisection, 'label');
if (label) {
return label;
@@ -218,73 +439,127 @@ return baseclass.extend({
},
loadModalTitle(title, addtitle, uciconfig, ucisection) {
if (!ucisection)
return addtitle;
let label = uci.get(uciconfig, ucisection, 'label');
return label ? title + ' » ' + label : addtitle;
},
toArray(value) {
if (Array.isArray(value))
return value;
else if (value == null || value === '')
return [];
return [ value ];
},
showConfigDiagnostics(diagnostics) {
let items = diagnostics?.items || [],
generatedAt = null,
noticeColor = '#1f232b',
noticeAccentColor = '#8b1e1e';
document.querySelectorAll('.homeproxy-config-diagnostics').forEach((node) => node.remove());
if (!items.length)
return;
if (diagnostics?.time) {
let date = new Date(Number(diagnostics.time) * 1000);
if (!isNaN(date.getTime()))
generatedAt = date.toLocaleString();
}
ui.addNotification(null, E('div', { 'style': 'color:%s;'.format(noticeColor) }, [
E('p', { 'style': 'color:%s;font-weight:600;'.format(noticeColor) }, _('HomeProxy found the following issues. Check and fix them manually:')),
E('p', { 'style': 'color:%s;font-weight:600;'.format(noticeAccentColor) },
generatedAt
? _('These diagnostics are from the last configuration generation or subscription update (%s). Re-apply, restart the service, or update subscriptions again after fixing them to refresh diagnostics.').format(generatedAt)
: _('These diagnostics are from the last configuration generation or subscription update. Re-apply, restart the service, or update subscriptions again after fixing them to refresh diagnostics.')),
E('ul', { 'style': 'color:%s;'.format(noticeColor) }, items.map((item) => E('li', { 'style': 'color:%s;'.format(noticeColor) }, [
E('strong', { 'style': 'color:%s;'.format(noticeColor) }, '[' + (item.type || 'warning') + '] '),
item.message || '',
item.suggestion ? E('div', { 'style': 'margin-top:.2em;color:%s;font-weight:600;'.format(noticeAccentColor) },
_('Suggestion:') + item.suggestion) : ''
])))
]), 'warning', 'homeproxy-config-diagnostics');
normalizeSectionId(label, prefix) {
let section_id = (label || '').trim().replace(/[^A-Za-z0-9_]/g, '_').replace(/^_+|_+$/g, '');
return (prefix || 'cfg') + '_' + (section_id || 'section');
},
renderSectionAdd(section, extra_class) {
let el = form.GridSection.prototype.renderSectionAdd.apply(section, [ extra_class ]),
nameEl = el.querySelector('.cbi-section-create-name');
ui.addValidator(nameEl, 'uciname', true, (v) => {
let button = el.querySelector('.cbi-section-create > .cbi-button-add');
let uciconfig = section.uciconfig || section.map.config;
nameEl = el.querySelector('.cbi-section-create-name'),
button = el.querySelector('.cbi-section-create > .cbi-button-add'),
uciconfig = section.uciconfig || section.map.config,
sectiontype = section.sectiontype,
labelEl = E('input', {
'type': 'text',
'class': nameEl.className
}),
hintEl = E('div', { 'class': 'cbi-value-description' });
if (!v) {
nameEl.style.display = 'none';
nameEl.parentNode.insertBefore(labelEl, nameEl);
nameEl.parentNode.appendChild(hintEl);
button.disabled = true;
const syncSectionName = () => {
let label = (labelEl.value || '').trim();
hintEl.textContent = '';
if (!label) {
nameEl.value = '';
button.disabled = true;
return true;
} else if (uci.get(uciconfig, v)) {
button.disabled = true;
return _('Expecting: %s').format(_('unique UCI identifier'));
} else {
button.disabled = null;
return true;
return;
}
}, 'blur', 'keyup');
let duplicate = false;
uci.sections(uciconfig, sectiontype, (res) => {
if ((res.label || res['.name']) === label)
duplicate = true;
});
if (duplicate) {
nameEl.value = '';
button.disabled = true;
hintEl.textContent = _('Expecting: %s').format(_('unique value'));
return;
}
let normalized = (label || '').trim().replace(/[^A-Za-z0-9_]/g, '_').replace(/^_+|_+$/g, '') || 'section',
section_id = (sectiontype || 'cfg') + '_' + normalized,
suffix = 1;
while (uci.get(uciconfig, section_id))
section_id = (sectiontype || 'cfg') + '_' + normalized + '_' + suffix++;
nameEl.value = section_id;
nameEl.dataset.sectionId = section_id;
nameEl.dataset.sectionLabel = label;
button.disabled = null;
};
labelEl.addEventListener('input', syncSectionName);
labelEl.addEventListener('blur', syncSectionName);
button.addEventListener('click', () => {
syncSectionName();
let label = nameEl.dataset.sectionLabel,
section_id = nameEl.dataset.sectionId;
window.setTimeout(() => {
if (label && section_id && uci.get(uciconfig, section_id))
uci.set(uciconfig, section_id, 'label', label);
}, 0);
});
return el;
},
installCloseButtonText() {
if (window.__homeproxyCloseButtonText)
return;
window.__homeproxyCloseButtonText = true;
let fix = (root) => {
for (let el of (root || document).querySelectorAll('button, .btn, input[type="button"], input[type="submit"]')) {
let text = (el.textContent || el.value || '').trim();
if (text !== 'Dismiss' && text !== '\u5ffd\u7565')
continue;
if ('value' in el && !el.textContent.trim())
el.value = _('Close');
else
el.textContent = _('Close');
}
};
let observe = () => {
if (!document.body)
return;
fix(document);
new MutationObserver((mutations) => {
for (let mutation of mutations)
for (let node of mutation.addedNodes)
if (node.nodeType === 1)
fix(node);
fix(document);
}).observe(document.body, { childList: true, subtree: true });
};
if (document.body)
observe();
else
document.addEventListener('DOMContentLoaded', observe, { once: true });
},
uploadCertificate(_option, type, filename, ev) {
const callWriteCertificate = rpc.declare({
object: 'luci.homeproxy',
@@ -305,6 +580,44 @@ return baseclass.extend({
.catch((e) => { ui.addNotification(null, E('p', e.message)) });
},
uploadPanel(_option, ev) {
const callInstallPanel = rpc.declare({
object: 'luci.homeproxy',
method: 'clash_api_install_panel',
expect: { '': {} }
});
const normalizeMessage = (message) => {
switch (message) {
case 'panel_backup_failed':
return _('Backup panel failed.');
case 'panel_restore_failed':
return _('Restore panel failed.');
case 'empty_panel_zip':
return _('Panel ZIP file is empty.');
case 'unzip_unavailable':
return _('The unzip command is unavailable.');
case 'invalid_panel_zip':
return _('Invalid panel ZIP file.');
case 'panel_install_failed':
return _('Install panel failed.');
default:
return message || _('unknown error');
}
};
return ui.uploadFile('/tmp/homeproxy_panel.tmp', ev.target)
.then(L.bind((_btn, res) => {
return L.resolveDefault(callInstallPanel(), {}).then((ret) => {
if (ret.result === true)
ui.addNotification(null, E('p', _('Your %s was successfully uploaded. Size: %sB.').format(_('panel ZIP package'), res.size)), 'info');
else
ui.addNotification(null, E('p', _('Failed to upload %s, error: %s.').format(_('panel ZIP package'), normalizeMessage(ret.error))), 'danger');
});
}, this, ev.target))
.catch((e) => { ui.addNotification(null, E('p', e.message || e), 'danger') });
},
validateBase64Key(length, section_id, value) {
/* Thanks to luci-proto-wireguard */
if (section_id && value)
@@ -345,7 +658,7 @@ return baseclass.extend({
if (section_id) {
if (!value)
return _('Expecting: %s').format(_('non-empty value'));
if (ucioption === 'node' && ['urltest', 'selector'].includes(value))
if (ucioption === 'node' && (value === 'urltest' || value === 'selector'))
return true;
let duplicate = false;
@@ -1,105 +0,0 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy 面板入口 helper。
*/
'use strict';
'require baseclass';
const DEFAULT_DASHBOARD_URL = 'https://metacubexd.pages.dev/#/overview';
const DEFAULT_CONTROLLER = '127.0.0.1:9090';
const DEFAULT_PROXY_LISTEN = '127.0.0.1:9091';
return baseclass.extend({
parseController(value) {
let result = {
hostname: '',
port: '',
https: false
};
if (!value)
return result;
try {
let url = new URL(value.match(/^https?:\/\//) ? value : 'http://' + value);
result.hostname = url.hostname;
result.port = url.port;
result.https = url.protocol === 'https:';
} catch(e) { }
return result;
},
deriveProxyController(value) {
let controller = this.parseController(value || DEFAULT_CONTROLLER);
if (!controller.hostname)
return DEFAULT_PROXY_LISTEN;
let hostname = controller.hostname.replace(/^\[(.*)\]$/, '$1');
return String.format('%s:9091', hostname.includes(':') ? '[' + hostname + ']' : hostname);
},
configuredUrl(uci, config) {
return uci.get(config, 'config', 'metacubexd_url') || DEFAULT_DASHBOARD_URL;
},
baseUrl(uci, config) {
let dashboard = this.configuredUrl(uci, config),
hash = dashboard.indexOf('#');
if (hash >= 0)
dashboard = dashboard.slice(0, hash);
return dashboard.replace(/\/+$/, '');
},
setupUrl(uci, config) {
let enabled = uci.get(config, 'config', 'clash_api_enabled') === '1',
target = uci.get(config, 'config', 'clash_api_external_controller') || DEFAULT_CONTROLLER,
proxy = uci.get(config, 'config', 'clash_api_proxy_external_controller') || this.deriveProxyController(target),
controller = this.parseController(proxy),
secret = uci.get(config, 'config', 'clash_api_secret') || '';
if (!enabled || !controller.hostname)
return null;
let dashboard = this.configuredUrl(uci, config),
base = this.baseUrl(uci, config);
if (dashboard.includes('yacd.metacubex.one'))
return dashboard;
let params = new URLSearchParams();
params.set('hostname', controller.hostname);
if (controller.port)
params.set('port', controller.port);
params.set(controller.https ? 'https' : 'http', '1');
if (secret)
params.set('secret', secret);
return base + '/#/setup?' + params.toString();
},
renderButton(uci, config) {
let url = this.setupUrl(uci, config);
if (!url)
return null;
return E('span', { 'class': 'homeproxy-dashboard-action' }, [
E('a', {
'class': 'btn cbi-button cbi-button-action',
'href': url,
'target': '_blank',
'rel': 'noreferrer noopener'
}, [ _('MetaCubeXD Dashboard') ]),
E('span', {
'style': 'display:block;margin-top:.35em;color:var(--text-color-medium,#666);font-size:90%;'
}, _('When using the remote MetaCubeXD dashboard, the Clash API secret is provided to that web page. Use a self-hosted or local dashboard when stronger security is required.'))
]);
}
});
@@ -1,27 +0,0 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy 配置诊断 helper。
*/
'use strict';
'require baseclass';
'require rpc';
'require homeproxy as hp';
const callConfigDiagnostics = rpc.declare({
object: 'luci.homeproxy_node_tools',
method: 'config_diagnostics',
expect: { '': {} }
});
return baseclass.extend({
load() {
return L.resolveDefault(callConfigDiagnostics(), { result: true, items: [] });
},
show(diagnostics) {
return hp.showConfigDiagnostics(diagnostics);
}
});
@@ -1,90 +0,0 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy 节点正则预览 helper。
*/
'use strict';
'require baseclass';
'require dom';
'require homeproxy as hp';
'require rpc';
const callValidateRegex = rpc.declare({
object: 'luci.homeproxy_node_tools',
method: 'validate_regex',
params: ['pattern'],
expect: { '': {} }
});
const callPreviewNodeFilter = rpc.declare({
object: 'luci.homeproxy_node_tools',
method: 'preview_node_filter',
params: ['manual_nodes', 'node_filter', 'node_filter_exclude', 'node'],
expect: { '': {} }
});
return baseclass.extend({
normalizeNodeList(value) {
let nodes = [];
for (let node_id of hp.toArray(value))
if (node_id && !nodes.includes(node_id))
nodes.push(node_id);
return nodes;
},
validate(pattern) {
return L.resolveDefault(callValidateRegex(pattern), { result: false, error: _('Unknown error.') });
},
preview(manual_nodes, node_filter, node_filter_exclude, node) {
return L.resolveDefault(callPreviewNodeFilter(
manual_nodes,
node_filter,
node_filter_exclude,
node
), { result: false, error: _('Unknown error.') });
},
setPreviewVisible(container, visible) {
let row = container.closest('.cbi-value');
if (row)
row.style.display = visible ? '' : 'none';
},
renderPreview(container, res, nodeName) {
if (!res.result) {
let message = _('Expecting: %s').format(_('valid regular expression'));
if (res.error)
message += ': ' + res.error;
dom.content(container, E('em', { 'style': 'color: #a33' }, message));
return;
}
let nodes = this.normalizeNodeList(res.nodes);
if (!nodes.length) {
dom.content(container, E('em', {}, _('No effective nodes.')));
return;
}
let content = [];
if (res.truncated)
content.push(E('div', { 'style': 'color: #a66; margin-bottom: .35em;' },
_('Only the first %d effective nodes are shown. Narrow the regex to avoid generating an oversized node group.').format(res.max_result_nodes || nodes.length)));
if (res.scan_truncated)
content.push(E('div', { 'style': 'color: #a66; margin-bottom: .35em;' },
_('Regex matching stopped after scanning %d nodes. Narrow the regex or reduce subscription size.').format(res.max_scan_nodes || 0)));
content.push(E('ul', {
'style': 'margin: 0; padding-left: 1.4em; max-height: 28em; overflow: auto;'
}, nodes.map((node_id) => E('li', {
'title': node_id
}, nodeName(node_id)))));
dom.content(container, content);
}
});
@@ -1,104 +0,0 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy 路由节点前端 helper。
*/
'use strict';
'require baseclass';
'require uci';
return baseclass.extend({
routingNodeName(res) {
let type = _('Routing node');
if (res.node === 'urltest')
type = _('URLTest');
else if (res.node === 'selector')
type = _('Selector');
return String.format('[%s] %s', type, res.label || res['.name']);
},
buildRoutingNodeNames(uci_config) {
let names = {};
uci.sections(uci_config, 'routing_node', (res) => {
names[res['.name']] = this.routingNodeName(res);
});
return names;
},
addSelectableOutbounds(option, uci_config, proxy_nodes, section_id, include_routing_nodes) {
for (let i in proxy_nodes)
option.value(i, proxy_nodes[i]);
if (!include_routing_nodes)
return;
uci.sections(uci_config, 'routing_node', (res) => {
if (res.enabled === '1' && res['.name'] !== section_id)
option.value(res['.name'], this.routingNodeName(res));
});
},
nodeDisplayName(node_id, proxy_nodes, routing_node_names) {
return proxy_nodes[node_id] || routing_node_names[node_id] || node_id;
},
toArray(value) {
if (!value)
return [];
return Array.isArray(value) ? value : [ value ];
},
routingNodeEdges(res) {
let edges = [];
if (res.node === 'selector') {
edges = edges.concat(this.toArray(res.selector_nodes));
edges = edges.concat(this.toArray(res.selector_default));
} else if (res.node === 'urltest') {
edges = edges.concat(this.toArray(res.urltest_nodes));
} else {
edges = edges.concat(this.toArray(res.node));
}
return edges.concat(this.toArray(res.outbound));
},
selectorHasPath(uci_config, start, target, path_cache, seen) {
if (!start || !target)
return false;
if (start === target)
return true;
let key = start + '->' + target;
if (path_cache[key] !== undefined)
return path_cache[key];
if (seen[start])
return false;
seen[start] = true;
let found = false;
uci.sections(uci_config, 'routing_node', (res) => {
if (found || res['.name'] !== start)
return;
if (res.enabled !== '1' || !res.node)
return;
for (let node_id of this.routingNodeEdges(res)) {
if (node_id === target || this.selectorHasPath(uci_config, node_id, target, path_cache, seen)) {
found = true;
return;
}
}
});
path_cache[key] = found;
return found;
}
});
@@ -1,339 +0,0 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* HomeProxy 节点测速前端 helper。
*/
'use strict';
'require baseclass';
'require rpc';
'require ui';
const MAX_NODES = 300;
const HELP_TEXT = _('Speed tests use the current runtime configuration. To test all nodes, add them to a running node group with a node regex such as .*');
const running = {};
const callNodesTcping = rpc.declare({
object: 'luci.homeproxy_tcping',
method: 'nodes_tcping',
params: [ 'sections' ],
expect: { '': {} }
});
const callNodeTcping = rpc.declare({
object: 'luci.homeproxy_tcping',
method: 'node_tcping',
params: [ 'section' ],
expect: { '': {} }
});
function replaceElementContent(el, content) {
if (!el)
return;
while (el.firstChild)
el.removeChild(el.firstChild);
if (Array.isArray(content)) {
for (let node of content)
el.appendChild((typeof node === 'string') ? document.createTextNode(node) : node);
} else {
el.appendChild((typeof content === 'string') ? document.createTextNode(content) : content);
}
}
function svgElement(tag, attrs, children) {
let el = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (let key in (attrs || {}))
el.setAttribute(key, attrs[key]);
for (let child of (children || []))
el.appendChild(child);
return el;
}
function renderSpeedtestIcon(extra_class) {
return svgElement('svg', {
'xmlns': 'http://www.w3.org/2000/svg',
'width': '18',
'height': '18',
'viewBox': '0 0 24 24',
'fill': 'none',
'stroke': 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'class': extra_class || ''
}, [
svgElement('path', { 'd': 'M5.636 19.364a9 9 0 1 1 12.728 0' }),
svgElement('path', { 'd': 'M16 9l-4 4' })
]);
}
function renderSpinner() {
return E('span', { 'class': 'homeproxy-tcping-spinner' });
}
function setButtonIcon(btn, busy) {
if (!btn)
return;
replaceElementContent(btn, renderSpeedtestIcon(busy ? 'homeproxy-tcping-icon homeproxy-tcping-icon-busy' : 'homeproxy-tcping-icon'));
}
function nodeId(section_id) {
return 'homeproxy-node-tcping-' + String(section_id).replace(/[^A-Za-z0-9_-]/g, (c) =>
'_' + c.charCodeAt(0).toString(16) + '_');
}
function ensureStyle() {
if (document.getElementById('homeproxy-tcping-style'))
return;
document.head.appendChild(E('style', { 'id': 'homeproxy-tcping-style' }, `
@keyframes homeproxy-tcping-spin { to { transform: rotate(360deg); } }
@keyframes homeproxy-tcping-pulse { 50% { opacity: .45; } }
.homeproxy-tcping-button {
width: 36px;
min-width: 36px;
height: 36px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.homeproxy-tcping-icon {
width: 18px;
height: 18px;
}
.homeproxy-tcping-icon-busy {
animation: homeproxy-tcping-pulse 1s ease-in-out infinite;
}
.homeproxy-latency-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 4.8em;
min-height: 1.7em;
padding: .16em .55em;
border-radius: 6px;
cursor: pointer;
font-variant-numeric: tabular-nums;
line-height: 1.3;
user-select: none;
transition: background-color .15s ease, transform .15s ease;
}
.homeproxy-latency-pill:hover {
background-color: rgba(128, 128, 128, .14);
transform: scale(1.04);
}
.homeproxy-latency-pill:active {
transform: scale(.96);
}
.homeproxy-latency-pill.homeproxy-tcping-busy {
color: gray !important;
}
.homeproxy-tcping-spinner {
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 999px;
animation: homeproxy-tcping-spin .8s linear infinite;
}
`));
}
function formatTestedAt(result) {
let tested_at = result?.tested_at;
if (!tested_at)
return '';
try {
return _('Tested at: %s').format(new Date(tested_at * 1000).toLocaleString());
} catch (e) {
return '';
}
}
function setResult(section_id, text, color, title) {
let el = document.getElementById(nodeId(section_id));
if (!el)
return false;
replaceElementContent(el, text);
el.title = title || '';
el.style.setProperty('color', color || '');
el.classList.remove('homeproxy-tcping-busy');
el.setAttribute('aria-busy', 'false');
return true;
}
function setBusy(section_id) {
let el = document.getElementById(nodeId(section_id));
if (!el)
return false;
replaceElementContent(el, renderSpinner());
el.title = _('Testing...');
el.classList.add('homeproxy-tcping-busy');
el.setAttribute('aria-busy', 'true');
return true;
}
function renderResult(section_id, result) {
let target = result?.target || '',
error = result?.error || '',
tested_at = formatTestedAt(result),
title = [ target, error, tested_at ].filter((v) => v).join('\n');
switch (result?.status) {
case 'ok':
setResult(section_id, '%d ms'.format(result.delay), 'green', title);
break;
case 'timeout':
setResult(section_id, _('Timeout'), 'red', title);
break;
case 'unsupported':
setResult(section_id, _('Unsupported'), 'gray', title);
break;
case 'invalid':
setResult(section_id, _('No address'), 'red', title);
break;
case 'missing':
setResult(section_id, _('Missing'), 'red', title);
break;
case 'skipped':
setResult(section_id, _('Skipped'), 'gray', title);
break;
case 'unloaded':
setResult(section_id, _('Not loaded'), 'gray', title);
break;
default:
setResult(section_id, _('Failed'), 'red', title);
break;
}
return true;
}
function runSingle(section_id, notices) {
return L.resolveDefault(callNodeTcping(section_id), {}).then((res) => {
if (!res.result) {
let message = res.error || _('Connectivity test failed.');
renderResult(section_id, { status: 'failed', error: message });
notices[message] = true;
return;
}
if (res.warning)
notices[res.warning] = true;
renderResult(section_id, res.node);
}).catch((err) => {
let message = err.message || String(err);
renderResult(section_id, { status: 'failed', error: message });
notices[message] = true;
});
}
return baseclass.extend({
helpText: HELP_TEXT,
nodeId: nodeId,
ensureStyle: ensureStyle,
renderIcon: renderSpeedtestIcon,
runNode(section_id, ev) {
let notices = {};
if (ev) {
ev.preventDefault();
ev.stopPropagation();
}
if (running[section_id])
return Promise.resolve();
running[section_id] = true;
setBusy(section_id);
return runSingle(section_id, notices).then(() => {
for (let message in notices)
ui.addNotification(null, E('p', message));
}).finally(() => {
running[section_id] = false;
});
},
runNodes(section_ids, ev) {
let btn = ev?.currentTarget || ev?.target,
test_sections = section_ids.slice(0, MAX_NODES),
skipped_sections = section_ids.slice(MAX_NODES),
notices = {};
if (!section_ids.length) {
ui.addNotification(null, E('p', _('No testable nodes.')));
return Promise.resolve();
}
let visible = 0;
for (let section_id of test_sections) {
if (setBusy(section_id))
visible++;
}
for (let section_id of skipped_sections)
renderResult(section_id, {
status: 'skipped',
error: _('At most %d nodes can be tested at once.').format(MAX_NODES)
});
if (!visible)
ui.addNotification(null, E('p', _('No latency column was found on this page. Force-refresh the page and try again.')));
if (btn) {
btn.disabled = true;
setButtonIcon(btn, true);
}
return L.resolveDefault(callNodesTcping(test_sections), {}).then((res) => {
if (!res.result)
throw new Error(res.error || _('Connectivity test failed.'));
if (res.warning)
notices[res.warning] = true;
let nodes = res.nodes || {};
for (let section_id of test_sections)
renderResult(section_id, nodes[section_id]);
}).catch((err) => {
let message = err.message || String(err);
for (let section_id of test_sections)
renderResult(section_id, { status: 'failed', error: message });
notices[message] = true;
}).then(() => {
for (let message in notices)
ui.addNotification(null, E('p', message));
}).then(() => {
if (skipped_sections.length) {
let message = _('Skipped %d nodes. At most %d nodes can be tested at once.').format(skipped_sections.length, MAX_NODES);
ui.addNotification(null, E('p', message));
}
if (btn) {
btn.disabled = false;
setButtonIcon(btn, false);
}
});
}
});
@@ -1,221 +0,0 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2026 HomeProxy Custom
*/
'use strict';
'require fs';
'require rpc';
'require ui';
'require view';
// 当前页面会话里的动态路径状态
let currentBackupPath = null;
let currentRestorePath = null;
const callBackupCreate = rpc.declare({
object: 'luci.homeproxy_backup',
method: 'backup_create',
expect: { '': {} }
});
const callBackupGetUploadPath = rpc.declare({
object: 'luci.homeproxy_backup',
method: 'backup_get_upload_path',
expect: { '': {} }
});
const callBackupCleanup = rpc.declare({
object: 'luci.homeproxy_backup',
method: 'backup_cleanup',
params: ['path'],
expect: { '': {} }
});
const callBackupValidate = rpc.declare({
object: 'luci.homeproxy_backup',
method: 'backup_validate',
params: ['path'],
expect: { '': {} }
});
const callBackupRestore = rpc.declare({
object: 'luci.homeproxy_backup',
method: 'backup_restore',
params: ['path'],
expect: { '': {} }
});
function downloadFile(path, filename) {
return fs.read_direct(path, 'blob').then((blob) => {
const url = window.URL.createObjectURL(blob);
let a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.setTimeout(() => {
a.remove();
window.URL.revokeObjectURL(url);
}, 100);
});
}
function renderFileList(files) {
if (!files || !files.length)
return E('em', _('No files.'));
return E('ul', {}, files.map((file) => E('li', {}, file)));
}
function setButtonText(btn, text) {
if (btn && btn.firstChild)
btn.firstChild.data = text;
}
return view.extend({
handleCreateBackup(ev) {
const btn = ev.currentTarget || ev.target;
setButtonText(btn, _('Generating backup file...'));
return L.resolveDefault(callBackupCreate(), {}).then((res) => {
if (!res.result)
throw new Error(res.error || _('Failed to generate backup file.'));
// 记录后端生成的备份路径,供浏览器下载
currentBackupPath = res.download_path || res.path;
if (!currentBackupPath)
throw new Error(_('Backup file path was not returned.'));
const filename = 'homeproxy-backup-%s.tar.gz'.format((new Date()).toISOString().replace(/[:.]/g, '-'));
return downloadFile(currentBackupPath, filename).then(() => {
ui.addNotification(null, E('p', _('Backup file has been generated and download has started.')), 'info');
// 下载后清理后端临时备份文件,避免证书和私钥留在 /tmp
return L.resolveDefault(callBackupCleanup(currentBackupPath), {});
});
}).catch((err) => {
ui.addNotification(null, E('p', err.message || err));
}).finally(() => {
setButtonText(btn, _('Generate backup file'));
});
},
handleUploadRestore(ev) {
const btn = ev.currentTarget || ev.target;
setButtonText(btn, _('Uploading backup file...'));
// 先向后端申请本次上传路径
return L.resolveDefault(callBackupGetUploadPath(), {}).then((res) => {
if (!res.upload_path)
throw new Error(_('Failed to get upload path'));
currentRestorePath = res.upload_path;
return ui.uploadFile(currentRestorePath);
}).then(() => {
setButtonText(btn, _('Checking backup file...'));
return L.resolveDefault(callBackupValidate(currentRestorePath), {});
}).then((res) => {
if (!res.valid)
throw new Error(res.error || _('The uploaded backup file is invalid.'));
ui.showModal(_('Restore HomeProxy backup?'), [
E('p', _('The uploaded backup file contains the following HomeProxy source configuration. Restoring it will overwrite the current HomeProxy settings, custom direct/proxy lists, and uploaded certificates and private keys.')),
renderFileList(res.files),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn',
'click': ui.createHandlerFn(this, () => {
return L.resolveDefault(callBackupCleanup(currentRestorePath), {}).finally(ui.hideModal);
})
}, [ _('Cancel') ]),
' ',
E('button', {
'class': 'btn cbi-button-action important',
'click': ui.createHandlerFn(this, 'handleRestoreConfirm')
}, [ _('Continue') ])
])
]);
}).catch((err) => {
ui.addNotification(null, E('p', err.message || err));
return L.resolveDefault(callBackupCleanup(currentRestorePath), {});
}).finally(() => {
setButtonText(btn, _('Upload backup file...'));
});
},
handleRestoreConfirm() {
ui.showModal(_('Restoring HomeProxy backup...'), [
E('p', { 'class': 'spinning' }, _('Applying backup file and restarting HomeProxy.'))
]);
return L.resolveDefault(callBackupRestore(currentRestorePath), {}).then((res) => {
if (!res.result)
throw new Error(res.error || _('Restore failed.'));
ui.showModal(_('Restore complete'), [
E('p', _('HomeProxy configuration has been restored and the service has restarted.')),
E('p', _('The rollback archive for the previous HomeProxy configuration has been saved to %s.').format(res.rollback || '/tmp/homeproxy-rollback.tar.gz')),
renderFileList(res.files),
E('div', { 'class': 'right' },
E('button', { 'class': 'btn', 'click': ui.hideModal }, [ _('Close') ])
)
]);
}).catch((err) => {
ui.addNotification(null, E('p', err.message || err));
ui.hideModal();
});
},
render() {
const readonly = !L.hasViewPermission();
return E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('HomeProxy configuration backup / restore')),
E('div', { 'class': 'cbi-map-descr' },
_('Export and import HomeProxy source configuration to fully restore WebUI settings after reinstallation.')),
E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('Backup')),
E('div', { 'class': 'cbi-section-descr' },
_('Download a tar.gz backup containing /etc/config/homeproxy, custom direct/proxy lists, and uploaded certificates and private keys. The backup contains plaintext private keys; keep it safe and avoid transmitting or storing it through untrusted channels. Restore only verifies file integrity (SHA-256 manifest), not source authenticity.')),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Download backup')),
E('div', { 'class': 'cbi-value-field' }, [
E('button', {
'class': 'btn cbi-button cbi-button-action important',
'disabled': readonly || null,
'click': ui.createHandlerFn(this, 'handleCreateBackup')
}, [ _('Generate backup file') ])
])
])
]),
E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('Restore')),
E('div', { 'class': 'cbi-section-descr' },
_('Upload a HomeProxy backup file and overwrite the current HomeProxy source configuration. A rollback archive is saved automatically before restore.')),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Restore backup')),
E('div', { 'class': 'cbi-value-field' }, [
E('button', {
'class': 'btn cbi-button cbi-button-action important',
'disabled': readonly || null,
'click': ui.createHandlerFn(this, 'handleUploadRestore')
}, [ _('Upload backup file...') ])
])
])
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -126,6 +126,8 @@ return view.extend({
let m, s, o;
let features = data[1];
hp.installCloseButtonText();
m = new form.Map('homeproxy', _('HomeProxy Server'),
_('The modern ImmortalWrt proxy platform for ARM64/AMD64.'));
@@ -188,6 +190,7 @@ return view.extend({
o.value('tuic', _('Tuic'));
o.value('vless', _('VLESS'));
o.value('vmess', _('VMess'));
o.default = 'mixed';
o.rmempty = false;
o = s.option(form.Value, 'address', _('Listen address'));
@@ -637,10 +640,10 @@ return view.extend({
o.modalonly = true;
o = s.option(form.ListValue, 'tls_dns01_provider', _('DNS provider'));
o.value('alidns', _('Alibaba Cloud DNS'));
o.value('cloudflare', _('Cloudflare'));
o.value('alidns', _('AliYun'));
o.value('cfdns', _('CloudFlare'));
o.depends('tls_dns01_challenge', '1');
o.default = 'cloudflare';
o.default = 'cfdns';
o.rmempty = false;
o.modalonly = true;
@@ -663,7 +666,7 @@ return view.extend({
o = s.option(form.Value, 'tls_dns01_cf_api_token', _('API token'));
o.password = true;
o.depends('tls_dns01_provider', 'cloudflare');
o.depends('tls_dns01_provider', 'cfdns');
o.rmempty = false;
o.modalonly = true;
@@ -14,6 +14,8 @@
'require ui';
'require view';
'require homeproxy as hp';
/* Thanks to luci-app-aria2 */
const css = ' \
#log_textarea { \
@@ -27,6 +29,53 @@ const css = ' \
} \
.description { \
background-color: #33ccff; \
} \
.homeproxy-connect-grid { \
display: grid; \
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));\
gap: 12px; \
} \
.homeproxy-connect-card { \
display: grid; \
grid-template-columns: 44px 1fr; \
gap: 10px; \
align-items: center; \
min-height: 64px; \
padding: 8px 10px; \
border-radius: 6px; \
background: #fff; \
box-shadow: 0 1px 4px rgba(0, 0, 0, .08);\
} \
.homeproxy-connect-icon { \
display: flex; \
align-items: center; \
justify-content: center; \
width: 38px; \
height: 38px; \
border-radius: 50%; \
color: #fff; \
font-weight: 700; \
font-size: 15px; \
} \
.homeproxy-connect-info { \
min-height: 42px; \
padding: 7px 12px; \
border-radius: 8px; \
background: #e9edf2; \
} \
.homeproxy-connect-title { \
color: #7d8aa0; \
font-weight: 700; \
font-size: 13px; \
line-height: 1.2; \
} \
.homeproxy-connect-result { \
margin-top: 2px; \
color: #ff4d35; \
font-weight: 700; \
font-size: 13px; \
cursor: pointer; \
line-height: 1.2; \
}';
const hp_dir = '/var/run/homeproxy';
@@ -44,13 +93,13 @@ function getConnStat(o, site) {
'class': 'btn cbi-button cbi-button-action',
'click': ui.createHandlerFn(this, () => {
return L.resolveDefault(callConnStat(site), {}).then((ret) => {
let ele = o.default.firstElementChild.nextElementSibling;
let ele = o.default.firstElementChild.nextElementSibling;
if (ret.result) {
ele.style.setProperty('color', 'green');
ele.innerHTML = _('passed');
ele.innerHTML = _('passed');
} else {
ele.style.setProperty('color', 'red');
ele.innerHTML = _('failed');
ele.innerHTML = _('failed');
}
});
})
@@ -60,6 +109,53 @@ function getConnStat(o, site) {
]);
}
function renderConnCards() {
const callConnStat = rpc.declare({
object: 'luci.homeproxy',
method: 'connection_check',
params: ['site'],
expect: { '': {} }
});
const items = [
{ site: 'baidu', title: _('Baidu Connection'), icon: 'B', color: '#ff7a00' },
{ site: 'google', title: _('Google Connection'), icon: 'G', color: '#4285f4' },
{ site: 'github', title: _('GitHub Connection'), icon: 'GH', color: '#7b3bb3' },
{ site: 'youtube', title: _('YouTube Connection'), icon: 'YT', color: '#ff0033' }
];
return E('div', { 'class': 'homeproxy-connect-grid' }, items.map((item) => {
let result = E('div', {
'class': 'homeproxy-connect-result',
'click': ui.createHandlerFn(this, () => {
result.textContent = _('Checking...');
result.style.setProperty('color', '#f0ad4e');
return L.resolveDefault(callConnStat(item.site), {}).then((ret) => {
if (ret.result && ret.latency != null) {
result.style.setProperty('color', 'green');
result.textContent = _('%s ms').format(ret.latency);
} else {
result.style.setProperty('color', '#ff4d35');
result.textContent = _('failed');
}
});
})
}, [ _('Click to check') ]);
return E('div', { 'class': 'homeproxy-connect-card' }, [
E('div', {
'class': 'homeproxy-connect-icon',
'style': 'background:%s'.format(item.color)
}, [ item.icon ]),
E('div', { 'class': 'homeproxy-connect-info' }, [
E('div', { 'class': 'homeproxy-connect-title' }, [ item.title ]),
result
])
]);
}));
}
function getResVersion(o, type) {
const callResVersion = rpc.declare({
object: 'luci.homeproxy',
@@ -227,16 +323,16 @@ return view.extend({
render() {
let m, s, o;
hp.installCloseButtonText();
m = new form.Map('homeproxy');
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Connection check'));
s.anonymous = true;
o = s.option(form.DummyValue, '_check_baidu', _('BaiDu'));
o.cfgvalue = L.bind(getConnStat, this, o, 'baidu');
o = s.option(form.DummyValue, '_check_google', _('Google'));
o.cfgvalue = L.bind(getConnStat, this, o, 'google');
o = s.option(form.DummyValue, '_connection_check');
o.rawhtml = true;
o.render = renderConnCards;
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Resources management'));
s.anonymous = true;