'use strict'; 'require view'; 'require fs'; 'require ui'; 'require rpc'; const css = ' \ .controls { \ display: flex; \ margin: .5em 0 1em 0; \ flex-wrap: wrap; \ justify-content: space-around; \ } \ \ .controls > * { \ padding: .25em; \ white-space: nowrap; \ flex: 1 1 33%; \ box-sizing: border-box; \ display: flex; \ flex-wrap: wrap; \ } \ \ .controls > *:first-child, \ .controls > * > label { \ flex-basis: 100%; \ min-width: 250px; \ } \ \ .controls > *:nth-child(2), \ .controls > *:nth-child(3) { \ flex-basis: 20%; \ } \ \ .controls > * > .btn { \ flex-basis: 20px; \ text-align: center; \ } \ \ .controls > * > * { \ flex-grow: 1; \ align-self: center; \ } \ \ .controls > div > input { \ width: auto; \ } \ \ .td.version, \ .td.size { \ white-space: nowrap; \ } \ \ /* bootstrap sets 15%; shrink */ \ #packages .td.cbi-section-actions { \ width: auto; \ } \ \ ul.deps, ul.deps ul, ul.errors { \ margin-left: 1em; \ } \ \ ul.deps li, ul.errors li { \ list-style: none; \ } \ \ ul.deps li:before { \ content: "↳"; \ display: inline-block; \ width: 1em; \ margin-left: -1em; \ } \ \ ul.deps li > span { \ white-space: nowrap; \ } \ \ ul.errors li { \ color: #c44; \ font-size: 90%; \ font-weight: bold; \ padding-left: 1.5em; \ } \ \ ul.errors li:before { \ content: "⚠"; \ display: inline-block; \ width: 1.5em; \ margin-left: -1.5em; \ } \ '; const isReadonlyView = !L.hasViewPermission() || null; const callMountPoints = rpc.declare({ object: 'luci', method: 'getMountPoints', expect: { result: [] } }); const packages = { available: { providers: {}, pkgs: {} }, installed: { providers: {}, pkgs: {} } }; const languages = ['en']; let currentDisplayMode = 'available' const currentDisplayRows = []; function parseList(s, dest) { const re = /([^\n]*)\n/g; let pkg = null, key = null, val = null, m; let list, stat, mode, installed; while ((m = re.exec(s)) !== null) { if (m[1].match(/^\s(.*)$/)) { if (pkg !== null && key !== null && val !== null) val += '\n' + RegExp.$1.trim(); continue; } if (key !== null && val !== null) { switch (key) { case 'package': pkg = { name: val }; break; case 'depends': case 'provides': list = val.split(/\s*,\s*/); if (list.length !== 1 || list[0].length > 0) pkg[key] = list; break; case 'installed-time': pkg.installtime = new Date(+val * 1000); break; case 'installed-size': pkg.installsize = +val; break; case 'status': stat = val.split(/\s+/); mode = stat[1]; installed = stat[2]; switch (mode) { case 'user': case 'hold': pkg[mode] = true; break; } switch (installed) { case 'installed': pkg.status = ['installed']; break; } break; case 'essential': if (val === 'yes') pkg.essential = true; break; case 'size': pkg.size = +val; break; case 'architecture': case 'auto-installed': case 'filename': case 'sha256sum': case 'section': break; default: pkg[key] = val; break; } key = val = null; } if (m[1].trim().match(/^([\w-]+)\s*:(.+)$/)) { key = RegExp.$1.toLowerCase(); val = RegExp.$2.trim(); } else if (pkg) { dest.pkgs[pkg.name] = pkg; const provides = dest.providers[pkg.name] ? [] : [ pkg.name ]; if (pkg.provides) provides.push.apply(provides, pkg.provides); provides.forEach(function(p) { dest.providers[p] = dest.providers[p] || []; dest.providers[p].push(pkg); }); } } } function parseApkQueryJson(s, dest) { // Parse the raw JSON text string into an array let rawData; try { rawData = JSON.parse(s); } catch (e) { console.error("Failed to parse APK JSON:", e); return; } // Ensure rawData is actually an array before iterating if (!Array.isArray(rawData)) return; // Ensure our storage objects exist on the destination dest.pkgs = dest.pkgs || {}; dest.providers = dest.providers || {}; // Single pass through each item in the new JSON array for (const item of rawData) { // Rename 'file-size' to 'size' while pulling out 'name' const { name, 'file-size': size, ...attributes } = item; // Construct the package object with the expected 'size' key const pkg = { name, size, ...attributes }; // Add/Update the package in the main map dest.pkgs[name] = pkg; // Determine all provided names (a package always provides itself) const provides = [name, ...(Array.isArray(pkg.provides) ? pkg.provides : [])]; for (let p of provides) { p = p.split('=')[0]; // Handle cases where provides are versioned dest.providers[p] = dest.providers[p] || []; if (!dest.providers[p].includes(pkg)) dest.providers[p].push(pkg); } } } function parsePackageData(s, dest) { if (!s) return; // Check if the input is JSON (starts with [) if (s.trim().charAt(0) === '[') { parseApkQueryJson(s, dest); } else { parseList(s, dest); } } function isPkgInstalled(pkg) { return pkg && Array.isArray(pkg.status) && pkg.status.includes('installed'); } function display(pattern) { const src = packages[currentDisplayMode === 'updates' ? 'installed' : currentDisplayMode]; const pagers = document.querySelectorAll('.controls > .pager'); let i18n_filter = null; currentDisplayRows.length = 0; if (typeof(pattern) === 'string' && pattern.length > 0) pattern = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'ig'); switch (document.querySelector('input[name="filter_i18n"]:checked').value) { case 'all': i18n_filter = /^luci-i18n-/; break; case 'lang': i18n_filter = new RegExp('^luci-i18n-(base-.+|.+-(' + languages.join('|') + '))$'); break; } for (let name in src.pkgs) { const pkg = src.pkgs[name]; let desc = pkg.description || ''; let altsize = null; if (!pkg.size && packages.available.pkgs[name]) altsize = packages.available.pkgs[name].size; if (!desc && packages.available.pkgs[name]) desc = packages.available.pkgs[name].description || ''; desc = desc.split(/\n/); desc = desc[0].trim() + (desc.length > 1 ? '…' : ''); if ((pattern instanceof RegExp) && !name.match(pattern) && !desc.match(pattern)) continue; if (name.indexOf('luci-i18n-') === 0 && (!(i18n_filter instanceof RegExp) || !name.match(i18n_filter))) continue; let btn, ver; if (currentDisplayMode === 'updates') { const avail = packages.available.pkgs[name]; const inst = packages.installed.pkgs[name]; if (!isPkgInstalled(inst)) continue; if (!avail || compareVersion(avail.version, pkg.version) <= 0) continue; ver = '%s » %s'.format( truncateVersion(pkg.version || '-'), truncateVersion(avail.version || '-')); btn = E('div', { 'class': 'btn cbi-button-positive', 'data-package': name, 'data-action': 'upgrade', 'click': handleInstall }, _('Upgrade…')); } else if (currentDisplayMode === 'installed') { if (!isPkgInstalled(pkg)) continue; ver = truncateVersion(pkg.version || '-'); btn = E('div', { 'class': 'btn cbi-button-negative', 'data-package': name, 'click': handleRemove }, _('Remove…')); } else { const inst = packages.installed.pkgs[name]; ver = truncateVersion(pkg.version || '-'); if (!isPkgInstalled(inst)) btn = E('div', { 'class': 'btn cbi-button-action', 'data-package': name, 'data-action': 'install', 'click': handleInstall }, _('Install…')); else if (isPkgInstalled(inst) && inst.version !== pkg.version) btn = E('div', { 'class': 'btn cbi-button-positive', 'data-package': name, 'data-action': 'upgrade', 'click': handleInstall }, _('Upgrade…')); else btn = E('div', { 'class': 'btn cbi-button-neutral', 'disabled': 'disabled' }, _('Installed')); } name = '%h'.format(name); desc = '%h'.format(desc || '-'); if (pattern) { name = name.replace(pattern, '$&'); desc = desc.replace(pattern, '$&'); } currentDisplayRows.push([ name, ver, [ pkg.size || altsize || 0, pkg.size ? '%1024mB'.format(pkg.size) : (altsize ? '~%1024mB'.format(altsize) : '-') ], desc, btn ]); } currentDisplayRows.sort(function(a, b) { if (a[0] < b[0]) return -1; else if (a[0] > b[0]) return 1; else return 0; }); for (let pager of pagers) { pager.parentNode.style.display = ''; pager.setAttribute('data-offset', 100); } handlePage({ target: pagers[0].querySelector('.prev') }); } function handlePage(ev) { const filter = document.querySelector('input[name="filter"]'); let offset = +ev.target.parentNode.getAttribute('data-offset'); const next = ev.target.classList.contains('next'); const pagers = document.querySelectorAll('.controls > .pager'); if ((next && (offset + 100) >= currentDisplayRows.length) || (!next && (offset < 100))) return; offset += next ? 100 : -100; for (let pager of pagers) { pager.setAttribute('data-offset', offset); pager.querySelector('.text').firstChild.data = currentDisplayRows.length ? _('Displaying %d-%d of %d').format(1 + offset, Math.min(offset + 100, currentDisplayRows.length), currentDisplayRows.length) : _('No packages'); if (offset < 100) pager.querySelector('.prev').setAttribute('disabled', 'disabled'); else pager.querySelector('.prev').removeAttribute('disabled'); if ((offset + 100) >= currentDisplayRows.length) pager.querySelector('.next').setAttribute('disabled', 'disabled'); else pager.querySelector('.next').removeAttribute('disabled'); } let placeholder = _('No information available'); if (filter.value) placeholder = [ E('span', {}, _('No packages matching "%h".').format(filter.value)), ' (', E('a', { href: '#', click: handleReset }, _('Reset')), ')' ]; cbi_update_table('#packages', currentDisplayRows.slice(offset, offset + 100), placeholder); } function handleMode(ev) { const tab = findParent(ev.target, 'li'); if (tab.getAttribute('data-mode') === currentDisplayMode) return; tab.parentNode.querySelectorAll('li').forEach(function(li) { li.classList.remove('cbi-tab'); li.classList.add('cbi-tab-disabled'); }); tab.classList.remove('cbi-tab-disabled'); tab.classList.add('cbi-tab'); currentDisplayMode = tab.getAttribute('data-mode'); display(document.querySelector('input[name="filter"]').value); ev.target.blur(); ev.preventDefault(); } function handleI18nFilter(ev) { display(document.querySelector('input[name="filter"]').value); } function orderOf(c) { if (c === '~') return -1; else if (c === '' || c >= '0' && c <= '9') return 0; else if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) return c.charCodeAt(0); else return c.charCodeAt(0) + 256; } function compareVersion(val, ref) { // 1. Split into parts by dots or any non-digit sequences const vParts = (val || '').split(/[^0-9]+/); const rParts = (ref || '').split(/[^0-9]+/); // 2. Filter out empty strings caused by leading/trailing non-digits const v = vParts.filter(x => x.length > 0); const r = rParts.filter(x => x.length > 0); const maxLen = Math.max(v.length, r.length); for (let i = 0; i < maxLen; i++) { // Convert to integer (base 10) to automatically ignore leading zeros const vNum = parseInt(v[i] || 0, 10); const rNum = parseInt(r[i] || 0, 10); if (vNum > rNum) return 1; if (vNum < rNum) return -1; } return 0; } function versionSatisfied(ver, ref, vop) { const r = compareVersion(ver, ref); switch (vop) { case '<': case '<=': return r <= 0; case '>': case '>=': return r >= 0; case '<<': return r < 0; case '>>': return r > 0; case '=': return r === 0; } return false; } function pkgStatus(pkg, vop, ver, info, count) { info.errors = info.errors || []; info.install = info.install || []; if (isPkgInstalled(pkg)) { if (vop && !versionSatisfied(pkg.version, ver, vop)) { let repl = null; (packages.available.providers[pkg.name] || []).forEach(function(p) { if (!repl && versionSatisfied(p.version, ver, vop)) repl = p; }); if (repl) { info.install.push(repl); return E('span', { 'class': 'label', 'data-tooltip': _('Requires update to %h %h') .format(repl.name, repl.version) }, _('Needs upgrade')); } info.errors.push(_('The installed version of package %h is not compatible, require %s while %s is installed.').format(pkg.name, truncateVersion(ver, vop), truncateVersion(pkg.version))); return E('span', { 'class': 'label warning', 'data-tooltip': _('Require version %h %h,\ninstalled %h') .format(vop, ver, pkg.version) }, _('Version incompatible')); } return E('span', { 'class': 'label notice' }, _('Installed')); } else if (!pkg.missing) { if (!vop || versionSatisfied(pkg.version, ver, vop)) { if (count !== false) info.install.push(pkg); return E('span', { 'class': 'label' }, _('Not installed')); } info.errors.push(_('The repository version of package %h is not compatible, require %s but only %s is available.') .format(pkg.name, truncateVersion(ver, vop), truncateVersion(pkg.version))); return E('span', { 'class': 'label warning', 'data-tooltip': _('Require version %h %h,\ninstalled %h') .format(vop, ver, pkg.version) }, _('Version incompatible')); } else { info.errors.push(_('Required dependency package %h is not available in any repository.').format(pkg.name)); return E('span', { 'class': 'label warning' }, _('Not available')); } } function renderDependencyItem(dep, info, flat) { const li = E('li'); const vop = dep.version ? dep.version[0] : null; const ver = dep.version ? dep.version[1] : null; const depends = []; // A dependency with several providers (alternatives) is satisfied by any // single one of them, so apk only ever installs one. Mirror the apk // solver's provider selection - an already installed one, otherwise the // available one with the highest provider-priority, otherwise the first // available one - and count only that towards the size estimate and // subdependencies instead of every alternative. let effective = -1; for (let i = 0; dep.pkgs && i < dep.pkgs.length; i++) { if (isPkgInstalled(packages.installed.pkgs[dep.pkgs[i]])) { effective = i; break; } } const satisfied = effective >= 0; if (!satisfied && dep.pkgs && dep.pkgs.length) { let prio = null; for (let i = 0; i < dep.pkgs.length; i++) { const p = packages.available.pkgs[dep.pkgs[i]]; if (!p) continue; const pprio = Number(p['provider-priority']) || 0; if (prio === null || pprio > prio) { effective = i; prio = pprio; } } if (effective < 0) effective = 0; } for (let i = 0; dep.pkgs && i < dep.pkgs.length; i++) { const pkg = packages.installed.pkgs[dep.pkgs[i]] || packages.available.pkgs[dep.pkgs[i]] || { name: dep.name }; if (i > 0) li.appendChild(document.createTextNode(' | ')); let text = pkg.name; if (pkg.installsize) text += ' (%1024mB)'.format(pkg.installsize); else if (pkg.size) text += ' (~%1024mB)'.format(pkg.size); li.appendChild(E('span', { 'data-tooltip': pkg.description }, [ text, ' ', pkgStatus(pkg, vop, ver, info, !satisfied && i === effective) ])); if (i === effective) (pkg.depends || []).forEach(function(d) { if (depends.indexOf(d) === -1) depends.push(d); }); } if (!li.firstChild) li.appendChild(E('span', {}, [ dep.name, ' ', pkgStatus({ name: dep.name, missing: true }, vop, ver, info) ])); if (!flat) { const subdeps = renderDependencies(depends, info); if (subdeps) li.appendChild(subdeps); } return li; } function renderDependencies(depends, info, flat) { const deps = depends || []; const items = []; info.seen = info.seen || []; for (let i = 0; i < deps.length; i++) { let dep, vop, ver; if (deps[i] === 'libc') continue; // This regex handles "name", "name>=ver", and "name (>=ver)" const match = deps[i].match(/^([^><=~\s]+)\s*\(?([><=~]+)?\s*([^)]+)?\)?$/); if (match) { // Destructure the match array: [full_match, name, operator, version] const [, matchedDep, matchedVop, matchedVer] = match; dep = (matchedDep || '').trim(); vop = (matchedVop || '').trim() || null; ver = (matchedVer || '').trim() || null; } else { // Fallback if the string is just a plain name with no operators dep = deps[i].trim(); vop = ver = null; } if (info.seen[dep]) continue; const pkgs = []; (packages.installed.providers[dep] || []).forEach(function(p) { if (pkgs.indexOf(p.name) === -1) pkgs.push(p.name); }); (packages.available.providers[dep] || []).forEach(function(p) { if (pkgs.indexOf(p.name) === -1) pkgs.push(p.name); }); info.seen[dep] = { name: dep, pkgs: pkgs, version: [vop, ver] }; items.push(renderDependencyItem(info.seen[dep], info, flat)); } if (items.length) return E('ul', { 'class': 'deps' }, items); return null; } function truncateVersion(v, op) { v = v.replace(/\b(([a-f0-9]{8})[a-f0-9]{24,32})\b/, '$2…'); if (!op || op === '=') return v; return '%h %h'.format(op, v); } function handleReset(ev) { const filter = document.querySelector('input[name="filter"]'); filter.value = ''; display(); } function handleInstall(ev) { const name = ev.target.getAttribute('data-package'); const installcmd = ev.target.getAttribute('data-action'); const pkg = packages.available.pkgs[name]; const depcache = {}; let size; if (pkg.installsize) size = _('~%1024mB installed').format(pkg.installsize); else if (pkg.size) size = _('~%1024mB compressed').format(pkg.size); else size = _('unknown'); const deps = renderDependencies(pkg.depends, depcache); let tree = null, errs = null, inst = null, desc = null; if (depcache.errors && depcache.errors.length) { errs = E('ul', { 'class': 'errors' }); depcache.errors.forEach(function(err) { errs.appendChild(E('li', {}, err)); }); } let totalsize = pkg.installsize || pkg.size || 0; let totalpkgs = 1; let suggestsize = 0; if (depcache.install && depcache.install.length) depcache.install.forEach(function(ipkg) { totalsize += ipkg.installsize || ipkg.size || 0; totalpkgs++; }); const luci_basename = pkg.name.match(/^luci-([^-]+)-(.+)$/); const i18n_packages = []; let i18n_tree; if (luci_basename && (luci_basename[1] !== 'i18n' || luci_basename[2].indexOf('base-') === 0)) { let i18n_filter; if (luci_basename[1] === 'i18n') { const basenames = []; for (let pkgname in packages.installed.pkgs) { const m = pkgname.match(/^luci-([^-]+)-(.+)$/); if (m && m[1] !== 'i18n') basenames.push(m[2]); } if (basenames.length) i18n_filter = new RegExp('^luci-i18n-(' + basenames.join('|') + ')-' + pkg.name.replace(/^luci-i18n-base-/, '') + '$'); } else { i18n_filter = new RegExp('^luci-i18n-' + luci_basename[2] + '-(' + languages.join('|') + ')$'); } if (i18n_filter) { for (let pkgname in packages.available.pkgs) if (pkgname !== pkg.name && pkgname.match(i18n_filter)) i18n_packages.push(pkgname); const i18ncache = {}; i18n_tree = renderDependencies(i18n_packages, i18ncache, true); if (i18ncache.install && i18ncache.install.length) { i18ncache.install.forEach(function(ipkg) { suggestsize += ipkg.installsize || ipkg.size || 0; }); } } } inst = E('p', [ _('Require approx. %1024mB size for %d package(s) to install.') .format(totalsize, totalpkgs), ' ', suggestsize ? _('Suggested translations require approx. %1024mB additional space.').format(suggestsize) : '' ]); if (deps) { tree = E('li', '%s:'.format(_('Dependencies'))); tree.appendChild(deps); } if (pkg.description) { desc = E('div', {}, [ E('h5', {}, _('Description')), E('p', {}, pkg.description) ]); } ui.showModal(_('Details for package %h').format(pkg.name), [ E('ul', {}, [ E('li', '%s: %h'.format(_('Version'), pkg.version)), E('li', '%s: %h'.format(_('Size'), size)), tree || '', i18n_packages.length ? E('li', [ E('strong', [_('Suggested translations'), ':']), i18n_tree ]) : '' ]), desc || '', errs || inst || '', E('div', [ E('hr'), i18n_packages.length ? E('p', [ E('label', { 'class': 'cbi-checkbox' }, [ E('input', { 'id': 'i18ninstall-cb', 'type': 'checkbox', 'name': 'i18ninstall', 'data-packages': i18n_packages.join(' '), 'disabled': isReadonlyView, 'checked': true }), ' ', E('label', { 'for': 'i18ninstall-cb' }), ' ', _('Install suggested translation packages as well') ]) ]) : '', E('p', [ E('label', { 'class': 'cbi-checkbox' }, [ E('input', { 'id': 'overwrite-cb', 'type': 'checkbox', 'name': 'overwrite', 'disabled': isReadonlyView }), ' ', E('label', { 'for': 'overwrite-cb' }), ' ', _('Allow overwriting conflicting package files') ]) ]) ]), E('div', { 'class': 'right' }, [ E('div', { 'class': 'btn', 'click': ui.hideModal }, _('Cancel')), ' ', E('div', { 'data-command': installcmd, 'data-package': name, 'class': 'btn cbi-button-action', 'click': handlePkg, 'disabled': isReadonlyView }, _('Install')) ]) ]); } function handleManualInstall(ev) { const name_or_url = document.querySelector('input[name="install"]').value; let install = E('div', { 'class': 'btn cbi-button-action', 'data-command': 'install', 'data-package': name_or_url, 'click': function(ev) { document.querySelector('input[name="install"]').value = ''; handlePkg(ev); } }, _('Install')); let warning; if (!name_or_url.length) { return; } else if (name_or_url.indexOf('/') !== -1) { warning = E('p', {}, _('Installing packages from untrusted sources is a potential security risk! Really attempt to install %h?').format(name_or_url)); } else if (!packages.available.providers[name_or_url]) { warning = E('p', {}, _('The package %h is not available in any configured repository.').format(name_or_url)); install = ''; } else { warning = E('p', {}, _('Really attempt to install %h?').format(name_or_url)); } ui.showModal(_('Manually install package'), [ warning, E('div', { 'class': 'right' }, [ E('div', { 'click': ui.hideModal, 'class': 'btn cbi-button-neutral' }, _('Cancel')), ' ', install ]) ]); } function handleConfig(ev) { const conf = {}; const base_dir = L.hasSystemFeature('apk') ? '/etc/apk' : '/etc/opkg'; ui.showModal(_('%s Configuration').format(L.hasSystemFeature('apk') ? 'APK' : 'OPKG'), [ E('p', { 'class': 'spinning' }, _('Loading configuration data…')) ]); fs.list(base_dir).then(function(partials) { const files = []; if (L.hasSystemFeature('apk')) { files.push(base_dir + '/' + 'repositories.d/customfeeds.list', base_dir + '/' + 'repositories.d/distfeeds.list' ) } else { files.push(base_dir + '.conf') } for (let i = 0; i < partials.length; i++) { if (partials[i].type === 'file') { if (L.hasSystemFeature('apk')) { if (partials[i].name === 'repositories') files.push(base_dir + '/' + partials[i].name); } else if (partials[i].name.match(/\.conf$/)) { files.push(base_dir + '/' + partials[i].name); } } } return Promise.all(files.map(function(file) { return fs.read(file) .then(L.bind(function(conf, file, res) { conf[file] = res }, this, conf, file)) .catch(function(err) { ui.addNotification(null, E('p', {}, [ _('Unable to read %s: %s').format(file, err) ])); ui.hideModal(); throw err; }); })); }).then(function() { const opkg_text = _('Below is a listing of the various configuration files used by opkg. Use opkg.conf for global settings and customfeeds.conf for custom repository entries. The configuration in the other files may be changed but is usually not preserved by sysupgrade.') const apk_text = _('Below is a listing of the various configuration files used by apk. The configuration in the other files may be changed but is usually not preserved by sysupgrade.') const body = [ E('p', {}, L.hasSystemFeature('apk') ? apk_text : opkg_text) ]; Object.keys(conf).sort().forEach(function(file) { body.push(E('h5', {}, '%h'.format(file))); body.push(E('textarea', { 'name': file, 'rows': Math.max(Math.min(L.toArray(conf[file].match(/\n/g)).length, 10), 3) + 1 }, '%h'.format(conf[file]))); }); body.push(E('div', { 'class': 'button-row' }, [ E('div', { 'class': 'btn cbi-button-neutral', 'click': ui.hideModal }, _('Cancel')), ' ', E('div', { 'class': 'btn cbi-button-positive', 'click': function(ev) { const data = {}; findParent(ev.target, '.modal').querySelectorAll('textarea[name]') .forEach(function(textarea) { data[textarea.getAttribute('name')] = textarea.value }); ui.showModal(_('%s Configuration').format(L.hasSystemFeature('apk') ? 'APK' : 'OPKG'), [ E('p', { 'class': 'spinning' }, _('Saving configuration data…')) ]); Promise.all(Object.keys(data).map(function(file) { return fs.write(file, data[file]).catch(function(err) { ui.addNotification(null, E('p', {}, [ _('Unable to save %s: %s').format(file, err) ])); }); })).then(ui.hideModal); }, 'disabled': isReadonlyView }, _('Save')), ])); ui.showModal(_('%s Configuration').format(L.hasSystemFeature('apk') ? 'APK' : 'OPKG'), body); }); } function handleRemove(ev) { const name = ev.target.getAttribute('data-package'); const pkg = packages.installed.pkgs[name]; const avail = packages.available.pkgs[name] || {}; let size, desc; if (avail.installsize) size = _('~%1024mB installed').format(avail.installsize); else if (avail.size) size = _('~%1024mB compressed').format(avail.size); else size = _('unknown'); if (avail.description) { desc = E('div', {}, [ E('h5', {}, _('Description')), E('p', {}, avail.description) ]); } ui.showModal(_('Remove package %h').format(pkg.name), [ E('ul', {}, [ E('li', '%s: %h'.format(_('Version'), pkg.version)), E('li', '%s: %h'.format(_('Size'), size)) ]), desc || '', E('div', { 'style': 'display:flex; justify-content:space-between; flex-wrap:wrap' }, [ E('label', { 'class': 'cbi-checkbox', 'style': 'float:left' }, [ E('input', { 'id': 'autoremove-cb', 'type': 'checkbox', 'checked': 'checked', 'name': 'autoremove', 'disabled': isReadonlyView || L.hasSystemFeature('apk') || null }), ' ', E('label', { 'for': 'autoremove-cb' }), ' ', _('Automatically remove unused dependencies') ]), E('div', { 'style': 'flex-grow:1', 'class': 'right' }, [ E('div', { 'class': 'btn', 'click': ui.hideModal }, _('Cancel')), ' ', E('div', { 'data-command': 'remove', 'data-package': name, 'class': 'btn cbi-button-negative', 'click': handlePkg, 'disabled': isReadonlyView }, _('Remove')) ]) ]) ]); } function handlePkg(ev) { return new Promise(function(resolveFn, rejectFn) { const cmd = ev.target.getAttribute('data-command'); const pkg = ev.target.getAttribute('data-package'); const rem = document.querySelector('input[name="autoremove"]'); const owr = document.querySelector('input[name="overwrite"]'); const i18n = document.querySelector('input[name="i18ninstall"]'); const dlg = ui.showModal(_('Executing package manager'), [ E('p', { 'class': 'spinning' }, _('Waiting for the %s %h command to complete…').format(L.hasSystemFeature('apk') ? 'apk' : 'opkg', cmd)) ]); const argv = [ cmd ]; if (cmd === 'remove') argv.push('--force-removal-of-dependent-packages') if (rem && rem.checked) argv.push('--autoremove'); if (owr && owr.checked) argv.push('--force-overwrite'); if (i18n && i18n.checked) argv.push.apply(argv, i18n.getAttribute('data-packages').split(' ')); if (pkg != null) argv.push(pkg); fs.exec_direct('/usr/libexec/package-manager-call', argv, 'json').then(function(res) { dlg.removeChild(dlg.lastChild); if (res.pkmcmd) dlg.appendChild(E('pre', [ res.pkmcmd ])); if (res.stdout) dlg.appendChild(E('pre', [ res.stdout ])); if (res.stderr) { dlg.appendChild(E('h5', _('Errors'))); dlg.appendChild(E('pre', { 'class': 'errors' }, [ res.stderr ])); } if (res.code !== 0) dlg.appendChild(E('p', _('The %s %h command failed with code %d.').format(L.hasSystemFeature('apk') ? 'apk' : 'opkg', cmd, (res.code & 0xff) || -1))); dlg.appendChild(E('div', { 'class': 'button-row' }, E('div', { 'class': 'btn', 'click': L.bind(function(res) { if (ui.menu && ui.menu.flushCache) ui.menu.flushCache(); ui.hideModal(); updateLists(); if (res.code !== 0) rejectFn(new Error(res.stderr || '%s error %d'.format(L.hasSystemFeature('apk') ? 'apk' : 'opkg', res.code))); else resolveFn(res); }, this, res) }, _('Dismiss')))); }).catch(function(err) { ui.addNotification(null, E('p', _('Unable to execute %s %s command: %s').format(L.hasSystemFeature('apk') ? 'apk' : 'opkg', cmd, err))); ui.hideModal(); }); }); } function handleUpload(ev) { const path = '/tmp/upload.%s'.format(L.hasSystemFeature('apk') ? 'apk' : 'ipk'); return ui.uploadFile(path).then(L.bind(function(btn, res) { ui.showModal(_('Manually install package'), [ E('p', {}, _('Installing packages from untrusted sources is a potential security risk! Really attempt to install %h?').format(res.name)), E('ul', {}, [ res.size ? E('li', {}, '%s: %1024.2mB'.format(_('Size'), res.size)) : '', res.checksum ? E('li', {}, '%s: %s'.format(_('MD5'), res.checksum)) : '', res.sha256sum ? E('li', {}, '%s: %s'.format(_('SHA256'), res.sha256sum)) : '' ]), E('div', { 'class': 'right' }, [ E('div', { 'click': function(ev) { ui.hideModal(); fs.remove(path); }, 'class': 'btn cbi-button-neutral' }, _('Cancel')), ' ', E('div', { 'class': 'btn cbi-button-action', 'data-command': 'install', 'data-package': path, 'click': function(ev) { handlePkg(ev).finally(function() { fs.remove(path) }); } }, _('Install')) ]) ]); }, this, ev.target)); } function downloadLists() { return Promise.all([ callMountPoints(), fs.exec_direct('/usr/libexec/package-manager-call', [ 'list-available' ]), fs.exec_direct('/usr/libexec/package-manager-call', [ 'list-installed' ]) ]); } function updateLists(data) { cbi_update_table('#packages', [], E('div', { 'class': 'spinning' }, _('Loading package information…'))); packages.available = { providers: {}, pkgs: {} }; packages.installed = { providers: {}, pkgs: {} }; return (data ? Promise.resolve(data) : downloadLists()).then(function(data) { const pg = document.querySelector('.cbi-progressbar'); const mount = L.toArray(data[0].filter(function(m) { return m.mount === '/' || m.mount === '/overlay' })) .sort(function(a, b) { return a.mount > b.mount })[0] || { size: 0, free: 0 }; pg.firstElementChild.style.width = Math.floor(mount.size ? (100 / mount.size) * (mount.size - mount.free) : 100) + '%'; pg.setAttribute('title', _('%s used (%1024mB used of %1024mB, %1024mB free)').format(pg.firstElementChild.style.width, mount.size - mount.free, mount.size, mount.free)); parsePackageData(data[1], packages.available); parsePackageData(data[2], packages.installed); for (let pkgname in packages.installed.pkgs) if (pkgname.indexOf('luci-i18n-base-') === 0) languages.push(pkgname.substring(15)); display(document.querySelector('input[name="filter"]').value); }); } let inputTimeout = null; function handleInput(ev) { if (inputTimeout !== null) window.clearTimeout(inputTimeout); inputTimeout = window.setTimeout(function() { display(ev.target.value); }, 250); } return view.extend({ load() { return downloadLists(); }, render(listData) { const query = decodeURIComponent(L.toArray(location.search.match(/\bquery=([^=]+)\b/))[1] || ''); const view = E([], [ E('style', { 'type': 'text/css' }, [ css ]), E('h2', {}, _('Software')), E('div', { 'class': 'cbi-map-descr' }, [ E('span', _('Install additional software and upgrade existing packages with %s.').format(L.hasSystemFeature('apk') ? 'apk' : 'opkg')), E('br'), E('span', _('Warning! Package operations can break your system.').format( 'href="https://openwrt.org/meta/infobox/upgrade_packages_warning" target="_blank" rel="noreferrer"' )) ]), E('div', { 'class': 'cbi-section' }, E('div', { 'class': 'controls' }, [ E('div', {}, [ E('label', {'id': 'disk-space-label'}, _('Disk space') + ':'), E('div', { 'class': 'cbi-progressbar', 'title': _('unknown') }, E('div', {}, [ '\u00a0' ])) ]), E('div', {}, [ E('label', {'id': 'filter-label'}, _('Filter') + ':'), E('span', { 'class': 'control-group' }, [ E('input', { 'type': 'text', 'name': 'filter', 'placeholder': _('Type to filter…'), 'value': query, 'input': handleInput }), E('button', { 'class': 'btn cbi-button', 'click': handleReset }, [ _('Clear') ]) ]) ]), E('div', {}, [ E('label', {'id': 'download-label'}, _('Download and install package') + ':'), E('span', { 'class': 'control-group' }, [ E('input', { 'type': 'text', 'name': 'install', 'placeholder': _('Package name or URL…'), 'keydown': function(ev) { if (ev.keyCode === 13) handleManualInstall(ev) }, 'disabled': isReadonlyView }), E('button', { 'class': 'btn cbi-button cbi-button-action', 'click': handleManualInstall, 'disabled': isReadonlyView }, [ _('OK') ]) ]) ]), E('div', {}, [ E('label', {'id': 'action-label'}, _('Actions') + ':'), ' ', E('span', { 'class': 'control-group' }, [ E('button', { 'class': 'btn cbi-button-positive', 'data-command': 'update', 'click': handlePkg, 'disabled': isReadonlyView }, [ _('Update lists…') ]), ' ', E('button', { 'class': 'btn cbi-button-action', 'click': handleUpload, 'disabled': isReadonlyView }, [ _('Upload Package…') ]), ' ', E('button', { 'class': 'btn cbi-button-neutral', 'click': handleConfig }, [ _('Configure %s').format(L.hasSystemFeature('apk') ? 'apk' : 'opkg') ]) ]) ]), E('div', {}, [ E('label', {}, _('Display LuCI translation packages') + ':'), ' ', E('div', [ E('label', { 'data-tooltip': _('Display base translation packages and translation packages for already installed languages only') }, [ E('input', { 'type': 'radio', 'name': 'filter_i18n', 'value': 'lang', 'change': handleI18nFilter, 'checked': true }), ' ', _('filtered', 'Display translation packages') ]), ' \u00a0 ', E('label', { 'data-tooltip': _('Display all available translation packages') }, [ E('input', { 'type': 'radio', 'name': 'filter_i18n', 'value': 'all', 'change': handleI18nFilter }), ' ', _('all', 'Display translation packages') ]), ' \u00a0 ', E('label', { 'data-tooltip': _('Hide all translation packages') }, [ E('input', { 'type': 'radio', 'name': 'filter_i18n', 'value': 'none', 'change': handleI18nFilter }), ' ', _('none', 'Display translation packages') ]) ]) ]) ])), E('ul', { 'class': 'cbi-tabmenu mode' }, [ E('li', { 'data-mode': 'available', 'class': 'available cbi-tab', 'click': handleMode }, E('a', { 'href': '#' }, [ _('Available') ])), E('li', { 'data-mode': 'installed', 'class': 'installed cbi-tab-disabled', 'click': handleMode }, E('a', { 'href': '#' }, [ _('Installed') ])), E('li', { 'data-mode': 'updates', 'class': 'installed cbi-tab-disabled', 'click': handleMode }, E('a', { 'href': '#' }, [ _('Updates') ])) ]), E('div', { 'class': 'cbi-section' }, [ E('div', { 'class': 'controls', 'style': 'display:none' }, [ E('div', { 'class': 'pager center' }, [ E('button', { 'class': 'btn cbi-button-neutral prev', 'aria-label': _('Previous page'), 'click': handlePage }, [ '«' ]), E('div', { 'class': 'text' }, [ 'dummy' ]), E('button', { 'class': 'btn cbi-button-neutral next', 'aria-label': _('Next page'), 'click': handlePage }, [ '»' ]) ]) ]), E('table', { 'id': 'packages', 'class': 'table' }, [ E('tr', { 'class': 'tr cbi-section-table-titles' }, [ E('th', { 'class': 'th col-2 left' }, [ _('Package name') ]), E('th', { 'class': 'th col-2 left version' }, [ _('Version') ]), E('th', { 'class': 'th col-1 center size'}, [ _('Size (%s)').format(L.hasSystemFeature('apk') ? '.apk' : '.ipk') ]), E('th', { 'class': 'th col-10 left' }, [ _('Description') ]), E('th', { 'class': 'th right cbi-section-actions' }, [ '\u00a0' ]) ]) ]), E('div', { 'class': 'controls', 'style': 'display:none' }, [ E('div', { 'class': 'pager center' }, [ E('button', { 'class': 'btn cbi-button-neutral prev', 'aria-label': _('Previous page'), 'click': handlePage }, [ '«' ]), E('div', { 'class': 'text' }, [ 'dummy' ]), E('button', { 'class': 'btn cbi-button-neutral next', 'aria-label': _('Next page'), 'click': handlePage }, [ '»' ]) ]) ]) ]) ]); requestAnimationFrame(function() { updateLists(listData) }); return view; }, handleSave: null, handleSaveApply: null, handleReset: null });