update 2026-05-14 10:41:07

This commit is contained in:
action committed 2026-05-14 10:41:07 +08:00
1 parent 60d7d53781
commit 0b83eba2e1
16 files changed
+202 -311

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-amlogic PKG_NAME:=luci-app-amlogic
PKG_VERSION:=3.1.312 PKG_VERSION:=3.1.313
PKG_RELEASE:=2 PKG_RELEASE:=2
PKG_LICENSE:=GPL-2.0 License PKG_LICENSE:=GPL-2.0 License
@@ -27,7 +27,9 @@ return view.extend({
]); ]);
}, },
render: function (data) { // Render a tabbed form section per CPU policy (cluster), with options to
// set the governor and min/max frequency. The form is bound to the amlogic.armcpu UCI section.
render: function (data) {
const policies = data[0] || []; const policies = data[0] || [];
// Auto-create the armcpu section when missing, so that on a fresh // Auto-create the armcpu section when missing, so that on a fresh
@@ -1,13 +1,10 @@
// SPDX-License-Identifier: GPL-2.0 // SPDX-License-Identifier: GPL-2.0
// Backup Firmware Config + Snapshot management + (optional) KVM switch // Backup Firmware Config + Snapshot management + (optional) KVM switch
// //
// This page exposes three groups of capabilities: // Purpose: pack current /etc config into openwrt_config.tar.gz and download it;
// 1. Pack the current config into openwrt_config.tar.gz and let the user // manage system snapshots (list / create / restore / delete); optionally show
// download it; // KVM dual-partition switch when the platform reports has_kvm.
// 2. List / create / restore / delete etc snapshots (etc-000 / etc-001 are // Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (backup_create, snapshot_*, platform_info, kvm_switch).
// the read-only initial / update snapshots and cannot be deleted);
// 3. If the backend reports has_kvm, render the KVM dual-system switch
// button.
'use strict'; 'use strict';
'require view'; 'require view';
@@ -36,17 +33,19 @@ const callPlatform = rpc.declare({ object: 'luci.amlogic', method: 'platform_i
// Switch active KVM partition. // Switch active KVM partition.
const callKvmSwitch = rpc.declare({ object: 'luci.amlogic', method: 'kvm_switch' }); const callKvmSwitch = rpc.declare({ object: 'luci.amlogic', method: 'kvm_switch' });
// The view object for the Backup & Snapshot page.
return view.extend({ return view.extend({
handleSave: null, handleSave: null,
handleSaveApply: null, handleSaveApply: null,
handleReset: null, handleReset: null,
// Load in parallel: platform info + current snapshot list.
load: function () { load: function () {
// Load in parallel: platform info + current snapshot list.
amlogicShared.ensureCss(); amlogicShared.ensureCss();
return Promise.all([callPlatform(), callSnapList()]); return Promise.all([callPlatform(), callSnapList()]);
}, },
// Render the page: backup config section + snapshot management section + (optional) KVM switch section.
render: function (data) { render: function (data) {
const platform = data[0] || {}; const platform = data[0] || {};
const snapNames = data[1] || []; const snapNames = data[1] || [];
@@ -61,8 +60,7 @@ return view.extend({
} }
}); });
// Download status hint: uses the warning color; replaced by the // Download status hint; updated to success color after generation completes.
// success message after a successful generation.
const downloadStatus = E('span', { class: 'amlogic-status-err' }); const downloadStatus = E('span', { class: 'amlogic-status-err' });
const downloadBtn = E('input', { const downloadBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-save', type: 'button', class: 'cbi-button cbi-button-save',
@@ -80,10 +78,7 @@ return view.extend({
// Switch to success color before streaming the blob // Switch to success color before streaming the blob
downloadStatus.className = 'amlogic-status-ok'; downloadStatus.className = 'amlogic-status-ok';
downloadStatus.textContent = _('The file Will download automatically.') + ' ' + r.path; downloadStatus.textContent = _('The file Will download automatically.') + ' ' + r.path;
// Step 2: fetch the file as a Blob via cgi-download (bypasses // Fetch the file blob via fs.read_direct and trigger browser download.
// ubus message-size limits) and trigger a browser download.
// We use fs.read_direct which internally POST to cgi-download
// with the correct sessionid.
return fs.read_direct(r.path, 'blob').then(function (blob) { return fs.read_direct(r.path, 'blob').then(function (blob) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -104,7 +99,8 @@ return view.extend({
}) })
}); });
const restoreBtn = E('input', { // Navigate to the upload page where the user can upload a backup file to restore.
const restoreBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-save', type: 'button', class: 'cbi-button cbi-button-save',
value: _('Upload Backup'), value: _('Upload Backup'),
click: function () { click: function () {
@@ -112,16 +108,11 @@ return view.extend({
} }
}); });
// Snapshots // Snapshot grid; styles adapt to the current theme via amlogic-snap-* classes.
// Snapshot grid: uses amlogic-snap-list / amlogic-snap-item /
// amlogic-snap-line classes whose styles adapt to the current theme
// (see amlogic.css).
const snapDiv = E('div', { class: 'amlogic-snap-list' }); const snapDiv = E('div', { class: 'amlogic-snap-list' });
// Rebuild the snapshot cards from a list of names: // Render snapshot cards; etc-000/001 are read-only (no Delete button).
// - etc-000 / etc-001 are read-only and do not get a Delete button; // Backend expects the short name without the "etc-" prefix.
// - other snapshots get both Restore and Delete buttons.
// The backend takes the short name (without the "etc-" prefix).
function renderSnapshots(names) { function renderSnapshots(names) {
snapDiv.innerHTML = ''; snapDiv.innerHTML = '';
if (!names || !names.length) { if (!names || !names.length) {
@@ -143,8 +134,7 @@ return view.extend({
if (!confirm(_('You selected a snapshot:') + ' [ ' + n + ' ] , ' + if (!confirm(_('You selected a snapshot:') + ' [ ' + n + ' ] , ' +
_('Confirm recovery and restart OpenWrt?'))) _('Confirm recovery and restart OpenWrt?')))
return; return;
// Capture the button now: ev.currentTarget is null once the // Capture btn before entering async chain; ev.currentTarget becomes null after await.
// promise chain resumes asynchronously.
const btn = ev.currentTarget; const btn = ev.currentTarget;
btn.disabled = true; btn.disabled = true;
btn.value = _('Restoring...'); btn.value = _('Restoring...');
@@ -176,8 +166,7 @@ return view.extend({
if (!confirm(_('You selected a snapshot:') + ' [ ' + n + ' ] , ' + if (!confirm(_('You selected a snapshot:') + ' [ ' + n + ' ] , ' +
_('Confirm delete?'))) _('Confirm delete?')))
return; return;
// Capture the button: ev.currentTarget is null in the // Capture btn before entering async chain; ev.currentTarget becomes null after await.
// async then() that runs after the RPC resolves.
const btn = ev.currentTarget; const btn = ev.currentTarget;
btn.disabled = true; btn.disabled = true;
btn.value = _('Deleting...'); btn.value = _('Deleting...');
@@ -198,7 +187,8 @@ return view.extend({
} }
renderSnapshots(snapNames); renderSnapshots(snapNames);
const createSnapBtn = E('input', { // Create snapshot button above the grid.
const createSnapBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-save', type: 'button', class: 'cbi-button cbi-button-save',
value: _('Create Snapshot'), value: _('Create Snapshot'),
click: ui.createHandlerFn(view, function (ev) { click: ui.createHandlerFn(view, function (ev) {
@@ -212,7 +202,8 @@ return view.extend({
}) })
}); });
const sections = [ // If the platform supports KVM dual partitions, show a button to trigger partition switch.
const sections = [
E('h2', _('Backup Firmware Config')), E('h2', _('Backup Firmware Config')),
E('p', _('Backup OpenWrt config (openwrt_config.tar.gz). Use this file to restore the config in [Manually Upload Update].')), E('p', _('Backup OpenWrt config (openwrt_config.tar.gz). Use this file to restore the config in [Manually Upload Update].')),
E('div', { class: 'cbi-section' }, [ E('div', { class: 'cbi-section' }, [
@@ -239,7 +230,8 @@ return view.extend({
]) ])
]; ];
if (platform.has_kvm) { // If the platform supports KVM dual partitions, show a button to trigger partition switch.
if (platform.has_kvm) {
const kvmBtn = E('input', { const kvmBtn = E('input', {
type: 'button', class: 'cbi-button cbi-button-save', type: 'button', class: 'cbi-button cbi-button-save',
value: _('Switch System'), value: _('Switch System'),
@@ -1,10 +1,10 @@
// SPDX-License-Identifier: GPL-2.0 // SPDX-License-Identifier: GPL-2.0
// Backup file list editor // Backup file list editor
// //
// Edits /etc/amlogic_backup_list.conf, which controls which files / directories // Purpose: edit /etc/amlogic_backup_list.conf which controls which files/dirs
// the openwrt-backup script archives. On load, if the file does not exist or // the openwrt-backup script archives. If the file is missing or empty, the
// is empty, the backend prime_backup_list extracts the default BACKUP_LIST // backend seeds it from the default BACKUP_LIST in /usr/sbin/openwrt-backup.
// from /usr/sbin/openwrt-backup as a starting template. // Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (save_backup_list, prime_backup_list).
'use strict'; 'use strict';
'require view'; 'require view';
@@ -15,24 +15,27 @@
// Persist the user-edited content back to /etc/amlogic_backup_list.conf. // Persist the user-edited content back to /etc/amlogic_backup_list.conf.
const callSave = rpc.declare({ object: 'luci.amlogic', method: 'save_backup_list', const callSave = rpc.declare({ object: 'luci.amlogic', method: 'save_backup_list',
params: ['content'] }); params: ['content'] });
// Pre-fill: if the list file is missing or empty, seed it from the defaults // Seed the list file from defaults embedded in openwrt-backup if missing or empty.
// embedded in the openwrt-backup script (idempotent: existing content stays).
const callPrime = rpc.declare({ object: 'luci.amlogic', method: 'prime_backup_list' }); const callPrime = rpc.declare({ object: 'luci.amlogic', method: 'prime_backup_list' });
// This page uses its own Save button; hide LuCI's default Save/Apply/Reset.
return view.extend({ return view.extend({
// This page uses its own Save button; hide LuCI's default Save/Apply/Reset. // This page uses its own Save button; hide LuCI's default Save/Apply/Reset.
handleSave: null, handleSave: null,
handleSaveApply: null, handleSaveApply: null,
handleReset: null, handleReset: null,
load: function () { // Load the existing backup list content to pre-fill the editor. If the file is
load: function () {
// Run prime first (idempotent), then read the file content. // Run prime first (idempotent), then read the file content.
return callPrime().then(function () { return callPrime().then(function () {
return fs.read('/etc/amlogic_backup_list.conf').catch(function () { return ''; }); return fs.read('/etc/amlogic_backup_list.conf').catch(function () { return ''; });
}); });
}, },
render: function (text) { // Render a full-width textarea pre-filled with the existing content, and a Save button
// that persists the changes via RPC. Also include a Back button to return to the main Backup page.
render: function (text) {
// Full-width multi-line editor pre-filled with the existing list. // Full-width multi-line editor pre-filled with the existing list.
const ta = E('textarea', { const ta = E('textarea', {
rows: '30', rows: '30',
@@ -50,7 +53,7 @@ return view.extend({
return callSave(ta.value).then(function (r) { return callSave(ta.value).then(function (r) {
if (r && (r.ok || r.code == 0)) { if (r && (r.ok || r.code == 0)) {
status.textContent = _('Successfully saved.'); status.textContent = _('Successfully saved.');
// Wait 0.7s before navigating back so the success text is visible. // Navigate back after a short delay so the success message is visible.
setTimeout(function () { setTimeout(function () {
location.href = L.url('admin/system/amlogic/backup'); location.href = L.url('admin/system/amlogic/backup');
}, 700); }, 700);
@@ -1,9 +1,11 @@
// SPDX-License-Identifier: GPL-2.0 // SPDX-License-Identifier: GPL-2.0
// Online Download Update + Rescue Kernel // Online Download Update + Rescue Kernel
// //
// Flow matches the Lua version: check_* RPCs write progress logs; the final // Purpose: check / download / install plugin, kernel, and firmware updates online.
// log line is a raw HTML <input> button string that we parse and render as a // Log tail is polled at 1 Hz; the last log line drives a state machine
// real DOM button so the user can explicitly click Download → then Update. // (idle → checking → button → installing → done). Terminal log lines contain
// raw HTML <input> button strings which are parsed and rendered as DOM buttons.
// Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (state, check_*, start_*, read_log_tail).
'use strict'; 'use strict';
'require view'; 'require view';
@@ -13,8 +15,10 @@
'require poll'; 'require poll';
'require view.amlogic.shared as amlogicShared'; 'require view.amlogic.shared as amlogicShared';
// ── RPCs ──────────────────────────────────────────────────────────────────── // ── RPCs ─────────────────────────────────────────────────────────────────────
// Query system state (firmware / plugin / kernel versions).
const callState = rpc.declare({ object: 'luci.amlogic', method: 'state' }); const callState = rpc.declare({ object: 'luci.amlogic', method: 'state' });
// Run plugin / kernel / firmware version check and write progress to the log.
const callCheckPlugin = rpc.declare({ object: 'luci.amlogic', method: 'check_plugin', params: ['options'] }); const callCheckPlugin = rpc.declare({ object: 'luci.amlogic', method: 'check_plugin', params: ['options'] });
const callCheckKernel = rpc.declare({ object: 'luci.amlogic', method: 'check_kernel', params: ['options'] }); const callCheckKernel = rpc.declare({ object: 'luci.amlogic', method: 'check_kernel', params: ['options'] });
const callCheckFirmware = rpc.declare({ object: 'luci.amlogic', method: 'check_firmware', params: ['options'] }); const callCheckFirmware = rpc.declare({ object: 'luci.amlogic', method: 'check_firmware', params: ['options'] });
@@ -22,14 +26,12 @@ const callStartPlugin = rpc.declare({ object: 'luci.amlogic', method: 'start_p
const callStartKernel = rpc.declare({ object: 'luci.amlogic', method: 'start_kernel' }); const callStartKernel = rpc.declare({ object: 'luci.amlogic', method: 'start_kernel' });
const callStartUpdate = rpc.declare({ object: 'luci.amlogic', method: 'start_update', params: ['amlogic_update_sel'] }); const callStartUpdate = rpc.declare({ object: 'luci.amlogic', method: 'start_update', params: ['amlogic_update_sel'] });
const callStartRescue = rpc.declare({ object: 'luci.amlogic', method: 'start_rescue' }); const callStartRescue = rpc.declare({ object: 'luci.amlogic', method: 'start_rescue' });
// Read the last line (up to 4096 bytes) of the named log file.
const callLogTail = rpc.declare({ object: 'luci.amlogic', method: 'read_log_tail', params: ['name'], expect: { line: '' } }); const callLogTail = rpc.declare({ object: 'luci.amlogic', method: 'read_log_tail', params: ['name'], expect: { line: '' } });
// ── Log-line HTML button parser ────────────────────────────────────────────── // ── Log-line HTML button parser ──────────────────────────────────────────────
// Returns null when the line is plain text, or an object when it is a button: // Parse an <input> button string from the log; return null for plain-text lines.
// { type: 'firmware-download'|'kernel-download'|'firmware-update'| // Returned object: { type, param, label } where type identifies the action.
// 'kernel-update'|'plugin-update',
// param: string|null, // extracted onclick argument (if any)
// label: string } // text after the closing />
function parseButtonLine(line) { function parseButtonLine(line) {
if (!line || line.indexOf('<input') === -1) return null; if (!line || line.indexOf('<input') === -1) return null;
@@ -82,12 +84,10 @@ function parseButtonLine(line) {
return null; return null;
} }
// ── Per-action state machine ───────────────────────────────────────────────── // ── Per-action state machine ──────────────────────────────────────────────────
// States: 'idle' | 'checking' | 'button' | 'installing' | 'done' // States: 'idle' | 'checking' | 'button' | 'installing' | 'done'
//
// statusEl – the span shown in the right column
// Each machine is a plain object; we keep one per action.
// Action object factory: { name, statusEl, state, subBtn }
function makeAction(name, statusEl) { function makeAction(name, statusEl) {
return { name: name, statusEl: statusEl, state: 'idle', subBtn: null }; return { name: name, statusEl: statusEl, state: 'idle', subBtn: null };
} }
@@ -123,16 +123,13 @@ function showSubButton(el, btnLabel, labelText, onClick) {
} }
// ── Poll handler (called at 1 Hz) ──────────────────────────────────────────── // ── Poll handler (called at 1 Hz) ────────────────────────────────────────────
// Inspect the last non-empty log line and advance the action state machine.
function handlePoll(action) { function handlePoll(action) {
if (action.state === 'done') return; if (action.state === 'done') return;
return callLogTail(action.name).then(function (raw) { return callLogTail(action.name).then(function (raw) {
// read_log_tail returns up to 4096 bytes (multi-line). Only inspect // Only inspect the last non-empty line to avoid false-positive
// the last non-empty line so that intermediate opkg/apk output // 'Failed'/'error' matches from intermediate opkg/apk output.
// (e.g. "Collected errors:", dependency warnings) cannot trigger a
// false-positive 'Failed'/'error' match before the final result is
// written. The terminal keywords ("Successful Update", "FAILED",
// etc.) are always written as the very last log line.
var lines = (raw || '').split('\n'); var lines = (raw || '').split('\n');
var line = ''; var line = '';
for (var i = lines.length - 1; i >= 0; i--) { for (var i = lines.length - 1; i >= 0; i--) {
@@ -182,8 +179,7 @@ function handlePoll(action) {
}); });
} }
// Render the sub-button extracted from the log line. The onClick triggers the // Render the sub-button parsed from the log line and wire up the second-phase RPC.
// appropriate second-phase RPC and transitions to 'installing'.
function renderParsedButton(action) { function renderParsedButton(action) {
var parsed = action._parsed; var parsed = action._parsed;
var el = action.statusEl; var el = action.statusEl;
@@ -194,8 +190,7 @@ function renderParsedButton(action) {
showSubButton(el, _('Download'), parsed.label, function (ev) { showSubButton(el, _('Download'), parsed.label, function (ev) {
ev.currentTarget.disabled = true; ev.currentTarget.disabled = true;
ev.currentTarget.value = _('Downloading...'); ev.currentTarget.value = _('Downloading...');
action.state = 'checking'; // re-enter checking loop, wait for Update button action.state = 'checking';
// parsed.param is e.g. "download_3.1.290" — pass directly to the script
callCheckPlugin(parsed.param).then(function () {}); callCheckPlugin(parsed.param).then(function () {});
}); });
break; break;
@@ -204,10 +199,8 @@ function renderParsedButton(action) {
showSubButton(el, _('Download'), parsed.label, function (ev) { showSubButton(el, _('Download'), parsed.label, function (ev) {
ev.currentTarget.disabled = true; ev.currentTarget.disabled = true;
ev.currentTarget.value = _('Downloading...'); ev.currentTarget.value = _('Downloading...');
action.state = 'checking'; // will re-enter checking loop action.state = 'checking';
callCheckFirmware(parsed.param).then(function () { callCheckFirmware(parsed.param).then(function () {});
// After download, keep polling; log will emit Update button
});
}); });
break; break;
@@ -224,10 +217,7 @@ function renderParsedButton(action) {
showSubButton(el, _('Update'), parsed.label, function (ev) { showSubButton(el, _('Update'), parsed.label, function (ev) {
ev.currentTarget.disabled = true; ev.currentTarget.disabled = true;
ev.currentTarget.value = _('Updating...'); ev.currentTarget.value = _('Updating...');
// Transition to 'installing': poll will show log lines and detect // Transition to 'installing'; terminal result comes via log polling.
// the terminal result. callStartUpdate fires the background script
// and returns {code:0} immediately — do NOT use its return value
// to judge success/failure.
action.state = 'installing'; action.state = 'installing';
dom.content(el, E('span', { class: 'amlogic-status-info' }, _('Starting update...'))); dom.content(el, E('span', { class: 'amlogic-status-info' }, _('Starting update...')));
callStartUpdate(parsed.param).catch(function () { callStartUpdate(parsed.param).catch(function () {
@@ -307,7 +297,9 @@ return view.extend({
btn.value = idleLabel; btn.value = idleLabel;
} }
var btnPlugin = E('input', { // Each button triggers the first-phase RPC to start the check/download/install process,
// then the poll handler picks up log lines to advance the state machine and render sub-buttons as needed.
var btnPlugin = E('input', {
type: 'button', class: 'cbi-button cbi-button-reload', type: 'button', class: 'cbi-button cbi-button-reload',
value: _('Only update Amlogic Service'), value: _('Only update Amlogic Service'),
click: ui.createHandlerFn(this, function (ev) { click: ui.createHandlerFn(this, function (ev) {
@@ -332,7 +324,8 @@ return view.extend({
}) })
}); });
var btnKernel = E('input', { // The kernel and firmware buttons have similar logic, just different RPCs and labels.
var btnKernel = E('input', {
type: 'button', class: 'cbi-button cbi-button-reload', type: 'button', class: 'cbi-button cbi-button-reload',
value: _('Update system kernel only'), value: _('Update system kernel only'),
click: ui.createHandlerFn(this, function (ev) { click: ui.createHandlerFn(this, function (ev) {
@@ -356,7 +349,8 @@ return view.extend({
}) })
}); });
var btnFirmware = E('input', { // Firmware update may include both kernel and plugin updates, so it has a more generic label.
var btnFirmware = E('input', {
type: 'button', class: 'cbi-button cbi-button-reload', type: 'button', class: 'cbi-button cbi-button-reload',
value: _('Complete system update'), value: _('Complete system update'),
click: ui.createHandlerFn(this, function (ev) { click: ui.createHandlerFn(this, function (ev) {
@@ -380,7 +374,8 @@ return view.extend({
}) })
}); });
var btnRescue = E('input', { // Rescue button: triggers the rescue RPC which starts mutual recovery; the log will show progress and terminal status.
var btnRescue = E('input', {
type: 'button', class: 'cbi-button cbi-button-reload', type: 'button', class: 'cbi-button cbi-button-reload',
value: _('Rescue the original system kernel'), value: _('Rescue the original system kernel'),
click: ui.createHandlerFn(this, function (ev) { click: ui.createHandlerFn(this, function (ev) {
@@ -390,16 +385,10 @@ return view.extend({
if (actRescue.state !== 'idle') return; if (actRescue.state !== 'idle') return;
btn.disabled = true; btn.disabled = true;
btn.value = _('Rescuing...'); btn.value = _('Rescuing...');
// Set installing state BEFORE the call so the poller picks up // Set installing state before the call so the poller picks up log lines.
// log lines. The script runs in the background (shbg); the RPC
// returns {code:0} immediately and we do NOT use it to judge
// success/failure — the poll loop reads LOG_RESCUE and detects
// the terminal line.
actRescue.state = 'installing'; actRescue.state = 'installing';
showInfo(actRescue.statusEl, _('Starting rescue...')); showInfo(actRescue.statusEl, _('Starting rescue...'));
return callStartRescue().then(function () { return callStartRescue().then(function () {
// RPC triggered OK; result comes via log polling.
// Re-enable the button so the user can retry if needed.
btn.disabled = false; btn.disabled = false;
btn.value = _('Rescue the original system kernel'); btn.value = _('Rescue the original system kernel');
}).catch(function () { }).catch(function () {
@@ -1,14 +1,10 @@
// SPDX-License-Identifier: GPL-2.0 // SPDX-License-Identifier: GPL-2.0
// Plugin Settings // Plugin Settings
// //
// Uses form.Map to maintain the `config` section in /etc/config/amlogic, // Purpose: configure the `config` section in /etc/config/amlogic via form.Map,
// covering: // covering firmware/kernel GitHub repos, tags, version branch, plugin branch,
// - OpenWrt firmware GitHub repo, tag keyword, file suffix // keep-config flag, bootloader write flag, and shared partition fs type.
// - Kernel download repo / tag / version branch // Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (platform_info, state).
// - Keep config, auto-write bootloader, shared partition fs type
// This page uses the default form.Map Save / Apply buttons; saving only
// writes UCI, while other pages (Online Update / Manual Upload) read these
// values to drive the actual operations.
'use strict'; 'use strict';
'require view'; 'require view';
@@ -18,12 +14,13 @@
// Get platform info; only used to display the device PLATFORM tag. // Get platform info; only used to display the device PLATFORM tag.
const callPlatform = rpc.declare({ object: 'luci.amlogic', method: 'platform_info' }); const callPlatform = rpc.declare({ object: 'luci.amlogic', method: 'platform_info' });
// Get runtime state; only kernel_release is used here, to derive default // Query runtime state; kernel_release is used to derive default kernel tag and branch.
// values for kernel tags / version branch.
const callState = rpc.declare({ object: 'luci.amlogic', method: 'state' }); const callState = rpc.declare({ object: 'luci.amlogic', method: 'state' });
// This page uses its own Save button; hide LuCI's default Save/Apply/Reset.
return view.extend({ return view.extend({
load: function () { // Load platform info + state in parallel, along with the UCI config (we will read/write it).
load: function () {
return Promise.all([ return Promise.all([
callPlatform(), callPlatform(),
callState(), callState(),
@@ -31,7 +28,8 @@ return view.extend({
]); ]);
}, },
render: function (data) { // Render form options bound to the amlogic.config NamedSection, and pre-fill some defaults based on platform/state.
render: function (data) {
const platform = data[0] || {}; const platform = data[0] || {};
const state = data[1] || {}; const state = data[1] || {};
@@ -40,7 +38,8 @@ return view.extend({
uci.add('amlogic', 'amlogic', 'config'); uci.add('amlogic', 'amlogic', 'config');
} }
const m = new form.Map('amlogic', _('Plugin Settings'), // Build a form.Map bound to the amlogic.config NamedSection, with options for various plugin settings.
const m = new form.Map('amlogic', _('Plugin Settings'),
_('You can customize the github.com download repository of OpenWrt files and kernels in [Online Download Update].') + _('You can customize the github.com download repository of OpenWrt files and kernels in [Online Download Update].') +
'<br />' + _('Tip: The same files as the current OpenWrt system\'s BOARD (such as rock5b) and kernel (such as 5.10) will be downloaded.')); '<br />' + _('Tip: The same files as the current OpenWrt system\'s BOARD (such as rock5b) and kernel (such as 5.10) will be downloaded.'));
const o = m.section(form.NamedSection, 'config', 'amlogic'); const o = m.section(form.NamedSection, 'config', 'amlogic');
@@ -82,11 +81,8 @@ return view.extend({
kpath.default = 'https://github.com/breakingbadboy/OpenWrt'; kpath.default = 'https://github.com/breakingbadboy/OpenWrt';
kpath.rmempty = false; kpath.rmempty = false;
// 6. Kernel tags - the list depends on currently saved kpath value. // 6. Kernel tags; available tags depend on the selected kernel repo.
// The breakingbadboy/OpenWrt repo only ships rk3588/rk35xx/stable; // Default is auto-derived from kernel_release suffixes (-rk3588/-rk35xx/-h6).
// ophub/kernel additionally ships flippy/h6/beta. The default value is
// auto-derived from -rk3588 / -rk35xx / -h6 keywords in `uname` so the
// user does not have to pick the wrong tag.
const currentKpath = uci.get('amlogic', 'config', 'amlogic_kernel_path') || const currentKpath = uci.get('amlogic', 'config', 'amlogic_kernel_path') ||
'https://github.com/breakingbadboy/OpenWrt'; 'https://github.com/breakingbadboy/OpenWrt';
const knownTags = { const knownTags = {
@@ -99,7 +95,7 @@ return view.extend({
knownTags.kernel_h6 = 'kernel_h6 [Allwinner H6 Kernel]'; knownTags.kernel_h6 = 'kernel_h6 [Allwinner H6 Kernel]';
knownTags.kernel_beta = 'kernel_beta [Beta Kernel]'; knownTags.kernel_beta = 'kernel_beta [Beta Kernel]';
} }
// Determine default tag from existing config or uname // Determine default tag from saved config or kernel_release uname string.
let kernelTagDefault = uci.get('amlogic', 'config', 'amlogic_kernel_tags') || ''; let kernelTagDefault = uci.get('amlogic', 'config', 'amlogic_kernel_tags') || '';
if (!kernelTagDefault) { if (!kernelTagDefault) {
const u = state.kernel_release || ''; const u = state.kernel_release || '';
@@ -1,18 +1,16 @@
// SPDX-License-Identifier: GPL-2.0 // SPDX-License-Identifier: GPL-2.0
// Plugin landing / info page // Plugin landing / info page
// //
// Renders the plugin LOGO, three quick-link badges (packit project, author // Purpose: render the plugin logo, badge links, feature summary, and supported-box list.
// homepage, plugin repository), the feature summary and the list of // Calls sync_menu on load to update sidebar menu flags from runtime platform detection.
// supported boxes. The page does not read or write any UCI config; it only // Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (author, sync_menu, state).
// calls the author RPC to obtain the author's repository URL.
'use strict'; 'use strict';
'require view'; 'require view';
'require rpc'; 'require rpc';
'require view.amlogic.shared as amlogicShared'; 'require view.amlogic.shared as amlogicShared';
// Extract OPENWRT_AUTHOR from /usr/sbin/openwrt-update-amlogic and return the // Extract OPENWRT_AUTHOR from /usr/sbin/openwrt-update-amlogic and return the author's GitHub repo URL.
// author's GitHub repo URL, used when the user clicks author.svg.
const callAuthor = rpc.declare({ const callAuthor = rpc.declare({
object: 'luci.amlogic', object: 'luci.amlogic',
method: 'author', method: 'author',
@@ -20,7 +18,6 @@ const callAuthor = rpc.declare({
}); });
// Sync menu_install / menu_armcpu UCI flags from runtime platform detection. // Sync menu_install / menu_armcpu UCI flags from runtime platform detection.
// Called on every info page load so the sidebar menu is correct from first visit.
const callSyncMenu = rpc.declare({ object: 'luci.amlogic', method: 'sync_menu' }); const callSyncMenu = rpc.declare({ object: 'luci.amlogic', method: 'sync_menu' });
// Get runtime state to determine plugin_branch for the language badge. // Get runtime state to determine plugin_branch for the language badge.
@@ -53,20 +50,18 @@ function openExternal(url) {
if (!w) window.location.href = url; if (!w) window.location.href = url;
} }
// This is a read-only info page, so we disable the top Save / Apply / Reset buttons by setting the handlers to null.
return view.extend({ return view.extend({
// Read-only info page: disable the top Save / Apply / Reset buttons. // Read-only info page: disable the top Save / Apply / Reset buttons.
handleSave: null, handleSave: null,
handleSaveApply: null, handleSaveApply: null,
handleReset: null, handleReset: null,
load: function () { // On load, call sync_menu to update sidebar menu flags from runtime platform detection. If the install menu becomes
// Call sync_menu and wait for it. sync_menu writes menu_install / // visible but is not in the current nav, reload once to pick up the new menu (guarded by #menu-synced hash).
// menu_armcpu into UCI and clears the LuCI index cache on the server. load: function () {
// On first boot from USB the install menu may be missing from the // Call sync_menu; if the install menu is now visible on the server but
// browser-side navigation (built before sync_menu ran). After the RPC // absent in the browser nav, reload once (guarded by #menu-synced hash).
// returns we check if the sidebar already has an install link; if not,
// reload once so the browser re-fetches the updated menu tree.
// A URL hash flag (#menu-synced) prevents an infinite reload loop.
const alreadyReloaded = window.location.hash === '#menu-synced'; const alreadyReloaded = window.location.hash === '#menu-synced';
const syncMenuPromise = callSyncMenu().then(function (res) { const syncMenuPromise = callSyncMenu().then(function (res) {
if (alreadyReloaded) return; if (alreadyReloaded) return;
@@ -74,7 +69,7 @@ return view.extend({
// Check if the sidebar navigation already contains the install link. // Check if the sidebar navigation already contains the install link.
const installLink = document.querySelector('a[href*="amlogic/install"]'); const installLink = document.querySelector('a[href*="amlogic/install"]');
if (!installLink) { if (!installLink) {
// Server has updated the index cache; reload to pick up new nav. // Server-side index cache updated; reload to pick up new nav.
window.location.replace(window.location.pathname + '#menu-synced'); window.location.replace(window.location.pathname + '#menu-synced');
window.location.reload(); window.location.reload();
} }
@@ -83,7 +78,8 @@ return view.extend({
return Promise.all([callAuthor(), syncMenuPromise]).then(function (r) { return r[0]; }); return Promise.all([callAuthor(), syncMenuPromise]).then(function (r) { return r[0]; });
}, },
render: function (authorUrl) { // Render the plugin info: logo, badge links, feature summary, and supported-box list.
render: function (authorUrl) {
// Inject the theme stylesheet on first entry so amlogic-* classes work. // Inject the theme stylesheet on first entry so amlogic-* classes work.
amlogicShared.ensureCss(); amlogicShared.ensureCss();
const res = L.resource('amlogic'); const res = L.resource('amlogic');
@@ -1,12 +1,9 @@
// SPDX-License-Identifier: GPL-2.0 // SPDX-License-Identifier: GPL-2.0
// Install OpenWrt to EMMC // Install OpenWrt to EMMC
// //
// The dropdown lists common devices from the backend model database, plus an // Purpose: let the user select a device model (or enter dtb/soc/uboot manually),
// "Enter the dtb file name" option. When the user picks the manual option and // then invoke start_install and poll the log tail until success or failure.
// fills in dtb / soc / uboot_overload, the frontend assembles the string // Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (model_database, start_install, read_log_tail).
// "id@dtb:soc:uboot" and passes it to the start_install RPC, which invokes
// openwrt-install-amlogic to perform the actual installation. The install log
// tail is polled while the work runs.
'use strict'; 'use strict';
'require view'; 'require view';
@@ -16,18 +13,17 @@
'require poll'; 'require poll';
'require view.amlogic.shared as amlogicShared'; 'require view.amlogic.shared as amlogicShared';
// Read the model database (parsed by the backend from // Read the model database (parsed by the backend from /usr/share/amlogic/model_database.txt).
// /usr/share/amlogic/model_database.txt).
const callDB = rpc.declare({ object: 'luci.amlogic', method: 'model_database', const callDB = rpc.declare({ object: 'luci.amlogic', method: 'model_database',
expect: { entries: [] } }); expect: { entries: [] } });
// Start install; the parameter is the composed string "id@dtb:soc:uboot", // Start install; parameter is the composed string "id@dtb:soc:uboot".
// kept compatible with the original Lua plugin.
const callStartInst = rpc.declare({ object: 'luci.amlogic', method: 'start_install', const callStartInst = rpc.declare({ object: 'luci.amlogic', method: 'start_install',
params: ['amlogic_install_sel'] }); params: ['amlogic_install_sel'] });
// Read the last line of the named log (here we use the 'install' log). // Read the last line of the named log (here we use the 'install' log).
const callLogTail = rpc.declare({ object: 'luci.amlogic', method: 'read_log_tail', const callLogTail = rpc.declare({ object: 'luci.amlogic', method: 'read_log_tail',
params: ['name'], expect: { line: '' } }); params: ['name'], expect: { line: '' } });
// This page uses its own Install button; hide LuCI's default Save/Apply/Reset.
return view.extend({ return view.extend({
handleSave: null, handleSave: null,
handleSaveApply: null, handleSaveApply: null,
@@ -37,10 +33,10 @@ return view.extend({
return callDB(); return callDB();
}, },
render: function (entries) { // Render the plugin info: logo, badge links, feature summary, and supported-box list.
render: function (entries) {
amlogicShared.ensureCss(); amlogicShared.ensureCss();
// Model dropdown: 0 = placeholder, 99 = manual entry, anything in between // Model dropdown: 0 = placeholder, 99 = manual entry, others from database.
// is a model from the database.
const sel = E('select', { style: 'width:auto', name: 'amlogic_soc', id: 'amlogic_soc' }); const sel = E('select', { style: 'width:auto', name: 'amlogic_soc', id: 'amlogic_soc' });
sel.appendChild(E('option', { value: '0' }, _('Select List'))); sel.appendChild(E('option', { value: '0' }, _('Select List')));
(entries || []).forEach(function (e) { (entries || []).forEach(function (e) {
@@ -89,12 +85,10 @@ return view.extend({
click: ui.createHandlerFn(this, function (ev) { click: ui.createHandlerFn(this, function (ev) {
if (installing) return; if (installing) return;
const text = sel.options[sel.selectedIndex].text; const text = sel.options[sel.selectedIndex].text;
// Confirm twice: install only proceeds after the user clicks // Confirm dialog; install proceeds only after user confirms.
// "Start install?".
if (!confirm(_('You have chosen:') + ' ' + text + ', ' + _('Start install?'))) if (!confirm(_('You have chosen:') + ' ' + text + ', ' + _('Start install?')))
return; return;
// When manual entries are blank, dtb falls back to auto_dtb so the // dtb falls back to auto_dtb when the manual field is blank.
// backend can autodetect.
const dtbVal = dtbInput.value || 'auto_dtb'; const dtbVal = dtbInput.value || 'auto_dtb';
const socVal = socInput.value || ''; const socVal = socInput.value || '';
const ubVal = ubootInput.value || ''; const ubVal = ubootInput.value || '';
@@ -114,8 +108,7 @@ return view.extend({
}) })
}); });
// Poll the install log tail once per second to surface backend progress // Poll the install log once per second; detect success/failure keywords.
// and detect terminal success/failure keywords.
poll.add(function () { poll.add(function () {
return callLogTail('install').then(function (line) { return callLogTail('install').then(function (line) {
if (!line || line === '\n') { if (!line || line === '\n') {
@@ -1,10 +1,10 @@
// SPDX-License-Identifier: GPL-2.0 // SPDX-License-Identifier: GPL-2.0
// Service log viewer // Service log viewer
// //
// Reads the full content of the amlogic service log (name=main) into a // Purpose: read the full amlogic service log into a read-only textarea and
// read-only textarea and refreshes it every 2 seconds. Provides controls to // refresh it every 2 seconds; provide controls to pause/resume polling, clear
// stop / restart polling, clear the log, and download the current contents // the log, and download the current contents as a plain-text file.
// as a Blob in the browser. // Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (read_log_full, del_log).
'use strict'; 'use strict';
'require view'; 'require view';
@@ -19,6 +19,8 @@ const callLogFull = rpc.declare({ object: 'luci.amlogic', method: 'read_log_full
// Clear the service main log. // Clear the service main log.
const callDelLog = rpc.declare({ object: 'luci.amlogic', method: 'del_log' }); const callDelLog = rpc.declare({ object: 'luci.amlogic', method: 'del_log' });
// This page uses its own Start/Stop Refresh buttons and does not need the default Save/Apply/Reset buttons,
// so we disable them by setting the handlers to null.
return view.extend({ return view.extend({
handleSave: null, handleSave: null,
handleSaveApply: null, handleSaveApply: null,
@@ -66,8 +68,7 @@ return view.extend({
type: 'button', class: 'cbi-button cbi-button-save', type: 'button', class: 'cbi-button cbi-button-save',
value: _('Download Log'), value: _('Download Log'),
click: function () { click: function () {
// Wrap the textarea content in a Blob and trigger a browser // Wrap textarea content in a Blob and trigger a browser download via a temporary <a>.
// download via a temporary <a>; avoids a second server fetch.
const blob = new Blob([ta.value || ''], { type: 'text/plain' }); const blob = new Blob([ta.value || ''], { type: 'text/plain' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -1,10 +1,9 @@
// SPDX-License-Identifier: GPL-2.0 // SPDX-License-Identifier: GPL-2.0
// Safe Power Off page // Safe Power Off page
// //
// On click, calls the backend poweroff RPC (which finally executes the system // Purpose: call the backend poweroff RPC then run a 5-second on-page countdown;
// `poweroff` command) and runs a 5-second on-page countdown so the user knows // switch status to "Powered off" and show a power-off icon when done.
// the device is shutting down. When the countdown ends, the status switches // Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (poweroff).
// to "Powered off" and a power-off icon is shown.
'use strict'; 'use strict';
'require view'; 'require view';
@@ -15,15 +14,16 @@
// Trigger the backend poweroff RPC. // Trigger the backend poweroff RPC.
const callPoweroff = rpc.declare({ object: 'luci.amlogic', method: 'poweroff' }); const callPoweroff = rpc.declare({ object: 'luci.amlogic', method: 'poweroff' });
// This page uses its own PowerOff button and does not need the default Save/Apply/Reset buttons,
// so we disable them by setting the handlers to null.
return view.extend({ return view.extend({
handleSave: null, handleSave: null,
handleSaveApply: null, handleSaveApply: null,
handleReset: null, handleReset: null,
render: function () { render: function () {
amlogicShared.ensureCss(); amlogicShared.ensureCss();
// Status text: uses the warning color amlogic-status-err which adapts // Status text; amlogic-status-err color adapts to dark mode automatically.
// to dark mode automatically.
const status = E('span', { class: 'amlogic-status-err', style: 'margin-left:1em' }); const status = E('span', { class: 'amlogic-status-err', style: 'margin-left:1em' });
// Power-off icon, hidden by default; shown only after the countdown ends. // Power-off icon, hidden by default; shown only after the countdown ends.
const icon = E('img', { const icon = E('img', {
@@ -32,7 +32,8 @@ return view.extend({
style: 'width:32px; height:32px; max-width:32px; display:none; margin-left:1em; vertical-align:middle' style: 'width:32px; height:32px; max-width:32px; display:none; margin-left:1em; vertical-align:middle'
}); });
const btn = E('input', { // PowerOff button click handler: confirm twice, then call RPC and start countdown on success.
const btn = E('input', {
type: 'button', class: 'cbi-button cbi-button-remove', type: 'button', class: 'cbi-button cbi-button-remove',
value: _('Perform PowerOff'), value: _('Perform PowerOff'),
click: ui.createHandlerFn(this, function (ev) { click: ui.createHandlerFn(this, function (ev) {
@@ -41,7 +42,7 @@ return view.extend({
return; return;
ev.currentTarget.disabled = true; ev.currentTarget.disabled = true;
return callPoweroff().then(function () { return callPoweroff().then(function () {
// Backend has fired poweroff; show a 5-second countdown to the user. // Show a 5-second countdown; replace with "Powered off" and icon when done.
status.textContent = _('Powering off, please wait...'); status.textContent = _('Powering off, please wait...');
let n = 5; let n = 5;
const t = setInterval(function () { const t = setInterval(function () {
@@ -1,20 +1,15 @@
// SPDX-License-Identifier: GPL-2.0 // SPDX-License-Identifier: GPL-2.0
//
// Shared helper module for all luci-app-amlogic JS views. // Shared helper module for all luci-app-amlogic JS views.
// Purpose: on first page load, inject the theme-aware stylesheet amlogic.css //
// into <head> (which contains .amlogic-status-* status colors, .amlogic-snap-* // Purpose: inject the theme-aware stylesheet amlogic.css into <head> on first
// snapshot cards, and prefers-color-scheme dark mode overrides) so every view // page load so every view can use .amlogic-status-* / .amlogic-snap-* classes
// can rely on the same set of classes instead of duplicating inline styles. // and prefers-color-scheme dark mode overrides without duplicating inline styles.
'use strict'; 'use strict';
'require baseclass'; 'require baseclass';
return baseclass.extend({ return baseclass.extend({
// Inject the shared stylesheet (idempotent). // Inject amlogic.css once; guarded by element id to prevent duplicates.
// We tag the <link> element with a fixed id 'amlogic-shared-css' so that
// subsequent calls return immediately and avoid appending it twice. The
// stylesheet path is resolved via L.resource() to
// /luci-static/resources/view/amlogic/amlogic.css.
ensureCss: function () { ensureCss: function () {
if (document.getElementById('amlogic-shared-css')) return; if (document.getElementById('amlogic-shared-css')) return;
const link = document.createElement('link'); const link = document.createElement('link');
@@ -1,17 +1,11 @@
// SPDX-License-Identifier: GPL-2.0 // SPDX-License-Identifier: GPL-2.0
// Manually Upload Update // Manually Upload Update
// //
// Features: // Purpose: upload local files (firmware / kernel / ipk / config backup) to the server,
// 1. Use ui.uploadFile() to open the browser-native file picker and upload // list them, and trigger install / restore / update actions. Chunked upload uses
// to the path returned by the backend (default /tmp/upload/); // a custom ubus RPC instead of cgi-upload to avoid session ACL issues.
// 2. Show a table of files in the upload directory (name / mtime / mode / // Backend RPC: /usr/share/rpcd/ucode/luci.amlogic (upload_path, list_uploads, upload_chunk,
// size) with Remove, Install (ipk) and Restore (config backup) actions; // delete_upload, install_upload, start_update, start_kernel, read_log_tail, state).
// 3. Dynamically reveal "Update firmware" / "Replace kernel" buttons based
// on which file types are currently present in the upload dir; all
// buttons share doButton() to keep disabled / text / status transitions
// consistent;
// 4. Concurrently poll the firmware/kernel log tails and display the
// current firmware version.
'use strict'; 'use strict';
'require view'; 'require view';
@@ -22,43 +16,40 @@
'require fs'; 'require fs';
'require view.amlogic.shared as amlogicShared'; 'require view.amlogic.shared as amlogicShared';
// Query the upload directory the backend allows (default /tmp/upload/, the // Query the upload directory the backend allows (default /tmp/upload/, the backend ensures it exists).
// backend ensures it exists). const callUploadPath = rpc.declare({ object: 'luci.amlogic', method: 'upload_path', expect: { path: '/tmp/upload/' } });
const callUploadPath = rpc.declare({ object: 'luci.amlogic', method: 'upload_path',
expect: { path: '/tmp/upload/' } });
// List files in the upload dir plus flags about installable firmware/kernel/cfg. // List files in the upload dir plus flags about installable firmware/kernel/cfg.
const callList = rpc.declare({ object: 'luci.amlogic', method: 'list_uploads' }); const callList = rpc.declare({ object: 'luci.amlogic', method: 'list_uploads' });
// Delete a named file in the upload dir. // Delete a named file in the upload dir.
const callDelete = rpc.declare({ object: 'luci.amlogic', method: 'delete_upload', const callDelete = rpc.declare({ object: 'luci.amlogic', method: 'delete_upload', params: ['name'] });
params: ['name'] }); // Install one uploaded file (ipk) or restore a config backup; the backend dispatches by suffix.
// Install one uploaded file (ipk) or restore a config backup; the backend const callInstall = rpc.declare({ object: 'luci.amlogic', method: 'install_upload', params: ['name'] });
// dispatches by suffix.
const callInstall = rpc.declare({ object: 'luci.amlogic', method: 'install_upload',
params: ['name'] });
// Chunked upload via our own RPC (sidesteps cgi-upload's session ACL flow). // Chunked upload via our own RPC (sidesteps cgi-upload's session ACL flow).
const callUploadChunk = rpc.declare({ object: 'luci.amlogic', method: 'upload_chunk', const callUploadChunk = rpc.declare({ object: 'luci.amlogic', method: 'upload_chunk', params: ['name', 'data', 'append'] });
params: ['name', 'data', 'append'] });
// Run the full firmware update flow with parameters auto@updated@/tmp. // Run the full firmware update flow with parameters auto@updated@/tmp.
const callStartUpd = rpc.declare({ object: 'luci.amlogic', method: 'start_update', const callStartUpd = rpc.declare({ object: 'luci.amlogic', method: 'start_update', params: ['amlogic_update_sel'] });
params: ['amlogic_update_sel'] });
// Run the kernel-only replace flow. // Run the kernel-only replace flow.
const callStartKnl = rpc.declare({ object: 'luci.amlogic', method: 'start_kernel' }); const callStartKnl = rpc.declare({ object: 'luci.amlogic', method: 'start_kernel' });
// Tail a named log to surface progress during install/update. // Tail a named log to surface progress during install/update.
const callLogTail = rpc.declare({ object: 'luci.amlogic', method: 'read_log_tail', const callLogTail = rpc.declare({ object: 'luci.amlogic', method: 'read_log_tail', params: ['name'], expect: { line: '' } });
params: ['name'], expect: { line: '' } });
// Read system state (this page only uses current_firmware_version). // Read system state (this page only uses current_firmware_version).
const callState = rpc.declare({ object: 'luci.amlogic', method: 'state' }); const callState = rpc.declare({ object: 'luci.amlogic', method: 'state' });
// This page uses its own Upload/Install buttons and does not need the default Save/Apply/Reset buttons,
// so we disable them by setting the handlers to null.
return view.extend({ return view.extend({
handleSave: null, handleSave: null,
handleSaveApply: null, handleSaveApply: null,
handleReset: null, handleReset: null,
// On load, ensure the CSS is injected and load the upload path from the backend.
load: function () { load: function () {
amlogicShared.ensureCss(); amlogicShared.ensureCss();
return callUploadPath(); return callUploadPath();
}, },
// Render the upload page: file input + upload button, table of existing uploads with Remove + Install/Restore buttons,
// and firmware/kernel update buttons if applicable. Also start polling the install/update log tails.
render: function (path) { render: function (path) {
const view = this; const view = this;
@@ -87,11 +78,9 @@ return view.extend({
const fwVer = E('span', _('Collecting data...')); const fwVer = E('span', _('Collecting data...'));
function doButton(btn, busyText, fail, fn) { function doButton(btn, busyText, fail, fn) {
// Shared button state machine: disabled -> RPC -> updating/fail label. // Shared button state machine: disabled → RPC → busyText or fail label.
// On success (code==0) the button stays in busyText state and // On success (code==0) the button stays in busyText state (device rebooting);
// remains disabled — the device is about to reboot, so there is // on failure, re-enable so the user can retry.
// nothing to restore. Only on failure do we surface an error and
// re-enable the button so the user can retry.
btn.disabled = true; btn.value = busyText; btn.disabled = true; btn.value = busyText;
return Promise.resolve(fn()).then(function (r) { return Promise.resolve(fn()).then(function (r) {
if (!r || r.code !== 0) { if (!r || r.code !== 0) {
@@ -106,9 +95,8 @@ return view.extend({
} }
function refreshList() { function refreshList() {
// Refresh the file table and the hint text; the firmware / kernel // Refresh the file table; show/hide firmware/kernel update buttons
// buttons are shown or hidden based on the has_firmware / has_kernel // based on has_firmware / has_kernel flags from the backend.
// flags reported by the backend.
return callList().then(function (info) { return callList().then(function (info) {
dom.content(tableContainer, buildTable(info.items || [], info.path)); dom.content(tableContainer, buildTable(info.items || [], info.path));
let parts = []; let parts = [];
@@ -124,10 +112,9 @@ return view.extend({
}); });
} }
// Helper to build the file table with Remove + Install/Restore buttons per row.
function buildTable(items, dir) { function buildTable(items, dir) {
// We expose three actions per row: remove any file, install ipk, and // File table: expose Remove + Install (ipk) / Restore (config backup) per row.
// restore a config backup. Other types (firmware, kernel archive)
// are listed but have no individual install entry.
const tbl = E('table', { class: 'table cbi-section-table' }, [ const tbl = E('table', { class: 'table cbi-section-table' }, [
E('tr', { class: 'tr cbi-section-table-titles' }, [ E('tr', { class: 'tr cbi-section-table-titles' }, [
E('th', { class: 'th' }, _('File name')), E('th', { class: 'th' }, _('File name')),
@@ -200,27 +187,13 @@ return view.extend({
return tbl; return tbl;
} }
// File upload widget. We deliberately avoid /cgi-bin/cgi-upload here // Chunked file upload via luci.amlogic.upload_chunk RPC (avoids cgi-upload session ACL).
// because it requires the LuCI session to carry a `file` ACL entry // 32 KB raw per chunk → ~44 KB base64, safely under the ubus per-request size limit.
// for the chosen target path, which depends on rpcd ACL bookkeeping
// being live for this session — historically a fragile path.
// Instead we slice the picked file into ~256 KB chunks, base64-encode
// each chunk and POST them through our own ubus method
// `luci.amlogic.upload_chunk`. The ucode handler runs as root and
// owns the upload directory, so no separate file-write ACL is
// involved. Permission is governed solely by the existing
// `upload_chunk` entry in the rpcd ACL `write.ubus` block.
// Chunk size for ubus payload. ubus has a per-request size limit
// (~64 KB) — at 256 KB raw → ~344 KB base64 the request was silently
// dropped and luci returned "No related RPC reply". 32 KB raw →
// ~44 KB base64 leaves comfortable headroom.
const CHUNK_SIZE = 32 * 1024; const CHUNK_SIZE = 32 * 1024;
const uploadStatus = E('span'); const uploadStatus = E('span');
const uploadFileName = E('span', { style: 'margin-right:1em' }); const uploadFileName = E('span', { style: 'margin-right:1em' });
// Encode a Uint8Array as base64. btoa() expects a binary string, so // Encode a Uint8Array as base64 in 32 KB slices to avoid call-stack limits.
// we walk through the bytes in 32k slices to avoid argument-list
// limits in browsers.
function bytesToB64(buf) { function bytesToB64(buf) {
let s = ''; let s = '';
for (let i = 0; i < buf.length; i += 0x8000) for (let i = 0; i < buf.length; i += 0x8000)
@@ -228,6 +201,7 @@ return view.extend({
return btoa(s); return btoa(s);
} }
// Upload a file in chunks; on success, refresh the file list to show the new upload.
function uploadFile(file, target, basename) { function uploadFile(file, target, basename) {
const total = file.size; const total = file.size;
let offset = 0; let offset = 0;
@@ -256,6 +230,7 @@ return view.extend({
return step(); return step();
} }
// Hidden file input to trigger the file picker dialog; on file selection, start the chunked upload.
const hiddenFileInput = E('input', { const hiddenFileInput = E('input', {
type: 'file', style: 'display:none', type: 'file', style: 'display:none',
change: ui.createHandlerFn(this, function (ev) { change: ui.createHandlerFn(this, function (ev) {
@@ -279,8 +254,7 @@ return view.extend({
click: function () { hiddenFileInput.click(); } click: function () { hiddenFileInput.click(); }
}); });
// Polling: log tails + version // Polling: pull firmware and kernel log tails once per second.
// Pull the last line of both firmware and kernel logs once per second.
poll.add(function () { poll.add(function () {
return Promise.all([ return Promise.all([
callLogTail('firmware').then(function (l) { callLogTail('firmware').then(function (l) {
@@ -315,8 +289,7 @@ return view.extend({
E('div', { class: 'cbi-section' }, [ E('div', { class: 'cbi-section' }, [
E('p', { style: 'text-align:center' }, E('p', { style: 'text-align:center' },
_('After uploading firmware (.img/.img.gz/.img.xz/.7z suffix) or kernel files (3 kernel files), the update button will be displayed.')), _('After uploading firmware (.img/.img.gz/.img.xz/.7z suffix) or kernel files (3 kernel files), the update button will be displayed.')),
// Single centered cell: when fwBtn / knBtn are hidden the row // Single centered cell: when fwBtn / knBtn are hidden the row would otherwise show an empty left column.
// would otherwise show an empty left column.
E('div', { style: 'text-align:center' }, E('div', { style: 'text-align:center' },
[fwBtn, knBtn, ' ', fwVer, ' ', fwLog, knLog]) [fwBtn, knBtn, ' ', fwVer, ' ', fwLog, knLog])
]) ])
@@ -1,16 +1,10 @@
#!/bin/bash #!/bin/bash
#================================================================== # SPDX-License-Identifier: GPL-2.0
# This file is licensed under the terms of the GNU General Public # amlogic_check_firmware.sh — check and download OpenWrt firmware updates.
# License version 2. This program is licensed "as is" without any
# warranty of any kind, whether express or implied.
# #
# This file is a part of the luci-app-amlogic plugin # Purpose: query the configured GitHub repository for the latest firmware
# https://github.com/ophub/luci-app-amlogic # release, compare with the local version, and download to /tmp/amlogic
# # if a newer version is available.
# Description: Check and update OpenWrt firmware
# Copyright (C) 2021- https://github.com/unifreq/openwrt_packit
# Copyright (C) 2021- https://github.com/ophub/luci-app-amlogic
#==================================================================
# Set a fixed value # Set a fixed value
check_option="${1}" check_option="${1}"
@@ -1,16 +1,10 @@
#!/bin/bash #!/bin/bash
#================================================================== # SPDX-License-Identifier: GPL-2.0
# This file is licensed under the terms of the GNU General Public # amlogic_check_kernel.sh — check and download OpenWrt kernel updates.
# License version 2. This program is licensed "as is" without any
# warranty of any kind, whether express or implied.
# #
# This file is a part of the luci-app-amlogic plugin # Purpose: query the configured GitHub repository for the latest kernel
# https://github.com/ophub/luci-app-amlogic # packages, compare with the running kernel version, and download to
# # /tmp/amlogic if a newer version is available.
# Description: Check and update OpenWrt Kernel
# Copyright (C) 2021- https://github.com/unifreq/openwrt_packit
# Copyright (C) 2021- https://github.com/ophub/luci-app-amlogic
#==================================================================
# Set a fixed value # Set a fixed value
check_option="${1}" check_option="${1}"
@@ -1,16 +1,10 @@
#!/bin/bash #!/bin/bash
#================================================================== # SPDX-License-Identifier: GPL-2.0
# This file is licensed under the terms of the GNU General Public # amlogic_check_plugin.sh — check and download luci-app-amlogic plugin updates.
# License version 2. This program is licensed "as is" without any
# warranty of any kind, whether express or implied.
# #
# This file is a part of the luci-app-amlogic plugin # Purpose: query the configured GitHub repository for the latest plugin
# https://github.com/ophub/luci-app-amlogic # release (JS or Lua branch), compare with the installed version, and
# # download the IPK/APK package to /tmp/amlogic if a newer version is available.
# Description: Check and update luci-app-amlogic plugin
# Copyright (C) 2021- https://github.com/unifreq/openwrt_packit
# Copyright (C) 2021- https://github.com/ophub/luci-app-amlogic
#==================================================================
# Set a fixed value # Set a fixed value
check_option="${1}" check_option="${1}"
@@ -1,8 +1,9 @@
// SPDX-License-Identifier: GPL-2.0 // SPDX-License-Identifier: GPL-2.0
// luci.amlogic — ucode RPC bridge for luci-app-amlogic (JS branch).
// //
// luci.amlogic - ucode RPC bridge for the JS-based luci-app-amlogic. // Purpose: expose all plugin operations (install, update, backup, snapshot,
// // CPU policy, log read, upload) as ubus methods consumed by the JS views.
// Helpers // Each method is registered in the `methods` table at the bottom of this file.
'use strict'; 'use strict';
import { popen, readfile, writefile, mkstemp, stat, glob, error as fserror, import { popen, readfile, writefile, mkstemp, stat, glob, error as fserror,
@@ -44,19 +45,14 @@ function sh(cmd) {
// Run a shell command, return exit code (0 on success) // Run a shell command, return exit code (0 on success)
function shcall(cmd) { function shcall(cmd) {
// `sh -c "...; echo $?"` — last line is exit code.
const out = sh('(' + cmd + ') >/dev/null 2>&1; echo $?'); const out = sh('(' + cmd + ') >/dev/null 2>&1; echo $?');
const lines = split(out, '\n'); const lines = split(out, '\n');
const last = lines[length(lines) - 1] ?? '1'; const last = lines[length(lines) - 1] ?? '1';
return int(last); return int(last);
} }
// Run a shell command in the background (fire-and-forget). // Run cmd in the background; close stdin so shbg does not block rpcd.
// Returns immediately so rpcd is not blocked, allowing concurrent
// read_log_tail / read_log_full polls to succeed.
function shbg(cmd) { function shbg(cmd) {
// Detach from rpcd: close stdin so interactive prompts don't block,
// but let the command manage its own stdout/stderr (e.g. redirect to log).
const wrapper = '(' + cmd + ') </dev/null &'; const wrapper = '(' + cmd + ') </dev/null &';
const p = popen(wrapper, 'r'); const p = popen(wrapper, 'r');
if (p) p.close(); if (p) p.close();
@@ -67,8 +63,7 @@ function shq(s) {
return "'" + replace(s ?? '', "'", "'\\''") + "'"; return "'" + replace(s ?? '', "'", "'\\''") + "'";
} }
// Ensure the runtime tmp dirs exist (mirrors the luci.sys.exec calls in the // Ensure runtime tmp dirs exist.
// original controller's index() function).
function ensure_dirs() { function ensure_dirs() {
mkdir(LOG_DIR); mkdir(LOG_DIR);
mkdir(UPLOAD_TMP); mkdir(UPLOAD_TMP);
@@ -92,13 +87,8 @@ function show_install_menu() {
return m ? m[1] : 'Unknown'; return m ? m[1] : 'Unknown';
} }
// Detect whether OpenWrt's root fs is on internal storage (eMMC/NVMe/fixed disk). // Detect whether the root fs is on internal storage (eMMC/NVMe/fixed disk).
// Returns true → already installed, hide the Install menu. // Returns true if already installed; sd* removable flag distinguishes USB vs SATA.
// Returns false → running from USB/SD, show the Install menu.
//
// sd* devices can be either USB (removable=1) or internal SATA (removable=0);
// we read /sys/block/<base_dev>/removable to distinguish them.
// mmcblk* and nvme* are always internal storage on these platforms.
function root_on_internal_storage() { function root_on_internal_storage() {
const root_pt = trim(sh("df / | tail -n1 | awk '{print $1}' | awk -F '/' '{print $3}'")); const root_pt = trim(sh("df / | tail -n1 | awk '{print $1}' | awk -F '/' '{print $3}'"));
if (!root_pt) return false; if (!root_pt) return false;
@@ -108,17 +98,15 @@ function root_on_internal_storage() {
return true; return true;
} }
if (match(root_pt, /^[hsv]d[a-z][0-9]+$/)) { if (match(root_pt, /^[hsv]d[a-z][0-9]+$/)) {
// Strip trailing partition digit(s) to get base device (e.g. sda2 → sda) // Strip partition digit(s) to get base device (e.g. sda2 → sda); check removable flag.
const base_dev = replace(root_pt, /[0-9]+$/, ''); const base_dev = replace(root_pt, /[0-9]+$/, '');
const removable = trim(sh('cat /sys/block/' + shq(base_dev) + '/removable 2>/dev/null')); const removable = trim(sh('cat /sys/block/' + shq(base_dev) + '/removable 2>/dev/null'));
// removable=0 → internal SATA/fixed disk; removable=1 → USB/removable return (removable == '0'); // 0 = internal SATA; 1 = USB/removable
return (removable == '0');
} }
return false; return false;
} }
// Pick the helper script names for the current platform (as the original // Return platform-specific helper script names for install/update/kernel operations.
// controller did).
function platform_scripts() { function platform_scripts() {
const p = platform(); const p = platform();
if (index(p, 'rockchip') >= 0) if (index(p, 'rockchip') >= 0)
@@ -192,8 +180,7 @@ function read_log_tail(name) {
function read_log_full(name) { function read_log_full(name) {
const p = _safe_log_path(name ?? 'main'); const p = _safe_log_path(name ?? 'main');
if (!p) return ''; if (!p) return '';
// Use popen+cat for consistency with read_log_tail; readfile() may be // Use popen+cat to bypass rpcd process-level file cache.
// stale inside the long-lived rpcd ucode process.
const pipe = popen('cat ' + p + ' 2>/dev/null', 'r'); const pipe = popen('cat ' + p + ' 2>/dev/null', 'r');
if (!pipe) return ''; if (!pipe) return '';
const t = pipe.read('all') ?? ''; const t = pipe.read('all') ?? '';
@@ -268,9 +255,7 @@ function m_platform_info() {
const can_install = (index(p, 'amlogic') >= 0 || index(p, 'allwinner') >= 0 || index(m, 'yes') >= 0); const can_install = (index(p, 'amlogic') >= 0 || index(p, 'allwinner') >= 0 || index(m, 'yes') >= 0);
// can_armcpu: all platforms except qemu support CPU frequency settings // can_armcpu: all platforms except qemu support CPU frequency settings
const can_armcpu = (index(p, 'qemu') < 0); const can_armcpu = (index(p, 'qemu') < 0);
// is_installed: root fs is on an internal storage device (eMMC/NVMe/fixed disk). // is_installed: root fs on internal storage means already installed; hide Install menu.
// sd* devices may be USB (removable=1) or internal SATA (removable=0); check
// /sys/block/<dev>/removable to distinguish. When true the Install menu is hidden.
const is_installed = root_on_internal_storage(); const is_installed = root_on_internal_storage();
return { return {
platform: p, platform: p,
@@ -312,16 +297,13 @@ function m_snapshot_list() {
return { names }; return { names };
} }
// Mirrors the Lua "Edit List" button's side-effect: if the backup-list file is // Seed /etc/amlogic_backup_list.conf from the default BACKUP_LIST in openwrt-backup if absent.
// missing or empty, pre-populate it from the BACKUP_LIST shell variable inside
// /usr/sbin/openwrt-backup.
function m_prime_backup_list() { function m_prime_backup_list() {
shcall("[ -s " + BACKUP_LIST + " ] || sed -n \"/BACKUP_LIST='/,/.*'$/p\" /usr/sbin/openwrt-backup | sed -e \"s/BACKUP_LIST=\\(.*\\)/\\1/; s/'//g; s/\\\\\\\\//g; s/ //g\" > " + BACKUP_LIST + " 2>/dev/null"); shcall("[ -s " + BACKUP_LIST + " ] || sed -n \"/BACKUP_LIST='/,/.*'$/p\" /usr/sbin/openwrt-backup | sed -e \"s/BACKUP_LIST=\\(.*\\)/\\1/; s/'//g; s/\\\\\\\\//g; s/ //g\" > " + BACKUP_LIST + " 2>/dev/null");
return { code: 0 }; return { code: 0 };
} }
// Create a read-only snapshot of /etc into /.snapshots/etc-<MM.DD.HHMMSS>. // Create a read-only btrfs snapshot of /etc into /.snapshots/.
// Mirrors the `Create Snapshot` button in amlogic_backup.lua.
function m_snapshot_create() { function m_snapshot_create() {
shcall("btrfs subvolume snapshot -r /etc /.snapshots/etc-$(date +%m.%d.%H%M%S) && sync"); shcall("btrfs subvolume snapshot -r /etc /.snapshots/etc-$(date +%m.%d.%H%M%S) && sync");
return { code: 0 }; return { code: 0 };
@@ -368,9 +350,7 @@ function m_check_kernel(req) {
shbg('/usr/share/amlogic/amlogic_check_kernel.sh -check'); shbg('/usr/share/amlogic/amlogic_check_kernel.sh -check');
return { code: 0 }; return { code: 0 };
} }
// download option: pass the full param string directly to the script // download option: pass the full param string verbatim to the script.
// (it may contain underscores, dots, and digits — safe to pass verbatim
// since shbg wraps the whole command in single-quoted sh -c)
shbg('/usr/share/amlogic/amlogic_check_kernel.sh -download ' + shq(opt)); shbg('/usr/share/amlogic/amlogic_check_kernel.sh -download ' + shq(opt));
return { code: 0 }; return { code: 0 };
} }
@@ -396,9 +376,7 @@ function m_start_plugin() {
const script_path = LOG_DIR + '/amlogic_install_plugin.sh'; const script_path = LOG_DIR + '/amlogic_install_plugin.sh';
writefile(LOG_RUNNING, '1@Plugin update in progress, try again later!\n'); writefile(LOG_RUNNING, '1@Plugin update in progress, try again later!\n');
writefile(LOG_PLUGIN, ''); writefile(LOG_PLUGIN, '');
// Write install script to a file so shbg (which redirects stdin from // Write install script to a temp file; shbg closes stdin so heredoc cannot be used.
// /dev/null) can execute it — heredoc via shbg does not work because
// shbg closes stdin before sh can read the here-document.
const script = '#!/bin/sh\n' const script = '#!/bin/sh\n'
+ 'LOG=' + shq(LOG_PLUGIN) + '\n' + 'LOG=' + shq(LOG_PLUGIN) + '\n'
+ 'LOG_RUNNING=' + shq(LOG_RUNNING) + '\n' + 'LOG_RUNNING=' + shq(LOG_RUNNING) + '\n'
@@ -643,22 +621,16 @@ function m_list_uploads() {
has_config: cfg }; has_config: cfg };
} }
// Strip directory prefix; reject empty, dot/dotdot, or paths still containing separators.
function _safe_basename(s) { function _safe_basename(s) {
const b = replace(s ?? '', /^.*[\\\/]/, ''); const b = replace(s ?? '', /^.*[\\\/]/, '');
// Permissive basename check: only reject empty, dot/dotdot and anything
// still containing path separators after the strip. Strict allowlists
// rejected legitimate filenames (e.g. some i18n packages) on certain
// runtimes where the regex character class behaved unexpectedly.
if (b == '' || b == '.' || b == '..') return null; if (b == '' || b == '.' || b == '..') return null;
if (index(b, '/') >= 0 || index(b, '\\') >= 0) return null; if (index(b, '/') >= 0 || index(b, '\\') >= 0) return null;
return b; return b;
} }
// Chunked upload helper. The browser-side view splits the file into base64 // Receive one base64-encoded chunk and write (or append) it to the upload file.
// chunks and calls this method N times. The first call (append=false) // First call (append=false) truncates/creates; subsequent calls append.
// truncates / creates the file; later calls append. Implemented through our
// own ubus method so it bypasses the cgi-upload ACL flow entirely (the
// ucode runs as root and owns the upload dir).
function m_upload_chunk(req) { function m_upload_chunk(req) {
const dir = compute_upload_path(); const dir = compute_upload_path();
mkdir(dir); mkdir(dir);
@@ -739,9 +711,7 @@ function m_reload_cpu() {
return { code: 0 }; return { code: 0 };
} }
// Sync menu visibility UCI flags from runtime platform detection. // Sync menu_install and menu_armcpu UCI flags based on runtime platform detection.
// Called from info.js on every page load so the sidebar reflects the
// current hardware state without requiring the user to visit Plugin Settings.
function m_sync_menu() { function m_sync_menu() {
const p = platform(); const p = platform();
const m = show_install_menu(); const m = show_install_menu();
@@ -757,9 +727,7 @@ function m_sync_menu() {
return { code: 0, show_install, show_armcpu }; return { code: 0, show_install, show_armcpu };
} }
// Enumerate cpufreq policies + their available freqs/governors so the armcpu // Enumerate cpufreq policies and their available frequencies and governors.
// view can render a tab per CPU policy (mirrors the Lua dynamic taboption
// loop in amlogic_armcpu.lua).
function m_cpu_policies() { function m_cpu_policies() {
const policies_str = sh("ls /sys/devices/system/cpu/cpufreq 2>/dev/null | grep -E 'policy[0-9]{1,3}' | xargs"); const policies_str = sh("ls /sys/devices/system/cpu/cpufreq 2>/dev/null | grep -E 'policy[0-9]{1,3}' | xargs");
let names = split(policies_str, ' '); let names = split(policies_str, ' ');