package-manager: migrate to apk-tools JSON API

Replace legacy opkg status parsing with native apk-tools JSON queries.
This modernizes the backend calls and improves dependency resolution.

- Implement 'apk query --format json' for package data retrieval.
- Add robust regex for versioned dependencies (e.g., name>=version).
- Update version comparison to handle APK date-based revisions.
- Fix label-input associations to resolve accessibility warnings.
- Retain deprecated opkg fallback logic for LuCI master.

Signed-off-by: Konstantin Glukhov <KGlukhov@Hotmail.com>
This commit is contained in:
Konstantin Glukhov
2026-03-26 01:32:21 +09:00
committed by Paul Donald
parent 8420a723f6
commit 1624418f64
2 changed files with 107 additions and 72 deletions

View File

@@ -152,7 +152,7 @@ function parseList(s, dest)
switch (installed) { switch (installed) {
case 'installed': case 'installed':
pkg.installed = true; pkg.status = ['installed'];
break; break;
} }
break; break;
@@ -201,6 +201,59 @@ function parseList(s, dest)
} }
} }
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 (const p of provides) {
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) function display(pattern)
{ {
const src = packages[currentDisplayMode === 'updates' ? 'installed' : currentDisplayMode]; const src = packages[currentDisplayMode === 'updates' ? 'installed' : currentDisplayMode];
@@ -249,7 +302,7 @@ function display(pattern)
const avail = packages.available.pkgs[name]; const avail = packages.available.pkgs[name];
const inst = packages.installed.pkgs[name]; const inst = packages.installed.pkgs[name];
if (!inst || !inst.installed) if (!isPkgInstalled(inst))
continue; continue;
if (!avail || compareVersion(avail.version, pkg.version) <= 0) if (!avail || compareVersion(avail.version, pkg.version) <= 0)
@@ -267,7 +320,7 @@ function display(pattern)
}, _('Upgrade…')); }, _('Upgrade…'));
} }
else if (currentDisplayMode === 'installed') { else if (currentDisplayMode === 'installed') {
if (!pkg.installed) if (!isPkgInstalled(pkg))
continue; continue;
ver = truncateVersion(pkg.version || '-'); ver = truncateVersion(pkg.version || '-');
@@ -282,14 +335,14 @@ function display(pattern)
ver = truncateVersion(pkg.version || '-'); ver = truncateVersion(pkg.version || '-');
if (!inst || !inst.installed) if (!isPkgInstalled(inst))
btn = E('div', { btn = E('div', {
'class': 'btn cbi-button-action', 'class': 'btn cbi-button-action',
'data-package': name, 'data-package': name,
'data-action': 'install', 'data-action': 'install',
'click': handleInstall 'click': handleInstall
}, _('Install…')); }, _('Install…'));
else if (inst.installed && inst.version != pkg.version) else if (isPkgInstalled(inst) && inst.version !== pkg.version)
btn = E('div', { btn = E('div', {
'class': 'btn cbi-button-positive', 'class': 'btn cbi-button-positive',
'data-package': name, 'data-package': name,
@@ -420,46 +473,24 @@ function orderOf(c)
return c.charCodeAt(0) + 256; return c.charCodeAt(0) + 256;
} }
function compareVersion(val, ref) function compareVersion(val, ref) {
{ // 1. Split into parts by dots or any non-digit sequences
let vi = 0, ri = 0; const vParts = (val || '').split(/[^0-9]+/);
const isdigit = { 0:1, 1:1, 2:1, 3:1, 4:1, 5:1, 6:1, 7:1, 8:1, 9:1 }; const rParts = (ref || '').split(/[^0-9]+/);
val = val || ''; // 2. Filter out empty strings caused by leading/trailing non-digits
ref = ref || ''; const v = vParts.filter(x => x.length > 0);
const r = rParts.filter(x => x.length > 0);
if (val === ref) const maxLen = Math.max(v.length, r.length);
return 0;
while (vi < val.length || ri < ref.length) { for (let i = 0; i < maxLen; i++) {
let first_diff = 0; // Convert to integer (base 10) to automatically ignore leading zeros
const vNum = parseInt(v[i] || 0, 10);
const rNum = parseInt(r[i] || 0, 10);
while ((vi < val.length && !isdigit[val.charAt(vi)]) || if (vNum > rNum) return 1;
(ri < ref.length && !isdigit[ref.charAt(ri)])) { if (vNum < rNum) return -1;
const vc = orderOf(val.charAt(vi)), rc = orderOf(ref.charAt(ri));
if (vc !== rc)
return vc - rc;
vi++; ri++;
}
while (val.charAt(vi) === '0')
vi++;
while (ref.charAt(ri) === '0')
ri++;
while (isdigit[val.charAt(vi)] && isdigit[ref.charAt(ri)]) {
first_diff = first_diff || (val.charCodeAt(vi) - ref.charCodeAt(ri));
vi++; ri++;
}
if (isdigit[val.charAt(vi)])
return 1;
else if (isdigit[ref.charAt(ri)])
return -1;
else if (first_diff)
return first_diff;
} }
return 0; return 0;
@@ -485,7 +516,7 @@ function versionSatisfied(ver, ref, vop)
return r > 0; return r > 0;
case '=': case '=':
return r == 0; return r === 0;
} }
return false; return false;
@@ -496,7 +527,7 @@ function pkgStatus(pkg, vop, ver, info)
info.errors = info.errors || []; info.errors = info.errors || [];
info.install = info.install || []; info.install = info.install || [];
if (pkg.installed) { if (isPkgInstalled(pkg)) {
if (vop && !versionSatisfied(pkg.version, ver, vop)) { if (vop && !versionSatisfied(pkg.version, ver, vop)) {
let repl = null; let repl = null;
@@ -605,12 +636,17 @@ function renderDependencies(depends, info, flat)
if (deps[i] === 'libc') if (deps[i] === 'libc')
continue; continue;
if (deps[i].match(/^(.+?)\s+\((<=|>=|<<|>>|<|>|=)(.+?)\)/)) { // This regex handles "name", "name>=ver", and "name (>=ver)"
dep = RegExp.$1.trim(); const match = deps[i].match(/^([^><=~\s]+)\s*\(?([><=~]+)?\s*([^)]+)?\)?$/);
vop = RegExp.$2.trim();
ver = RegExp.$3.trim(); if (match) {
} // Destructure the match array: [full_match, name, operator, version]
else { 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(); dep = deps[i].trim();
vop = ver = null; vop = ver = null;
} }
@@ -701,16 +737,16 @@ function handleInstall(ev)
const i18n_packages = []; const i18n_packages = [];
let i18n_tree; let i18n_tree;
if (luci_basename && (luci_basename[1] != 'i18n' || luci_basename[2].indexOf('base-') === 0)) { if (luci_basename && (luci_basename[1] !== 'i18n' || luci_basename[2].indexOf('base-') === 0)) {
let i18n_filter; let i18n_filter;
if (luci_basename[1] == 'i18n') { if (luci_basename[1] === 'i18n') {
const basenames = []; const basenames = [];
for (let pkgname in packages.installed.pkgs) { for (let pkgname in packages.installed.pkgs) {
const m = pkgname.match(/^luci-([^-]+)-(.+)$/); const m = pkgname.match(/^luci-([^-]+)-(.+)$/);
if (m && m[1] != 'i18n') if (m && m[1] !== 'i18n')
basenames.push(m[2]); basenames.push(m[2]);
} }
@@ -723,7 +759,7 @@ function handleInstall(ev)
if (i18n_filter) { if (i18n_filter) {
for (let pkgname in packages.available.pkgs) for (let pkgname in packages.available.pkgs)
if (pkgname != pkg.name && pkgname.match(i18n_filter)) if (pkgname !== pkg.name && pkgname.match(i18n_filter))
i18n_packages.push(pkgname); i18n_packages.push(pkgname);
const i18ncache = {}; const i18ncache = {};
@@ -876,9 +912,9 @@ function handleConfig(ev)
} }
for (let i = 0; i < partials.length; i++) { for (let i = 0; i < partials.length; i++) {
if (partials[i].type == 'file') { if (partials[i].type === 'file') {
if (L.hasSystemFeature('apk')) { if (L.hasSystemFeature('apk')) {
if (partials[i].name == 'repositories') if (partials[i].name === 'repositories')
files.push(base_dir + '/' + partials[i].name); files.push(base_dir + '/' + partials[i].name);
} else if (partials[i].name.match(/\.conf$/)) { } else if (partials[i].name.match(/\.conf$/)) {
files.push(base_dir + '/' + partials[i].name); files.push(base_dir + '/' + partials[i].name);
@@ -1010,7 +1046,7 @@ function handlePkg(ev)
const argv = [ cmd ]; const argv = [ cmd ];
if (cmd == 'remove') if (cmd === 'remove')
argv.push('--force-removal-of-dependent-packages') argv.push('--force-removal-of-dependent-packages')
if (rem && rem.checked) if (rem && rem.checked)
@@ -1118,14 +1154,14 @@ function updateLists(data)
return (data ? Promise.resolve(data) : downloadLists()).then(function(data) { return (data ? Promise.resolve(data) : downloadLists()).then(function(data) {
const pg = document.querySelector('.cbi-progressbar'); const pg = document.querySelector('.cbi-progressbar');
const mount = L.toArray(data[0].filter(function(m) { return m.mount == '/' || m.mount == '/overlay' })) 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 }; .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.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)); pg.setAttribute('title', _('%s used (%1024mB used of %1024mB, %1024mB free)').format(pg.firstElementChild.style.width, mount.size - mount.free, mount.size, mount.free));
parseList(data[1], packages.available); parsePackageData(data[1], packages.available);
parseList(data[2], packages.installed); parsePackageData(data[2], packages.installed);
for (let pkgname in packages.installed.pkgs) for (let pkgname in packages.installed.pkgs)
if (pkgname.indexOf('luci-i18n-base-') === 0) if (pkgname.indexOf('luci-i18n-base-') === 0)
@@ -1169,12 +1205,12 @@ return view.extend({
E('div', { 'class': 'controls' }, [ E('div', { 'class': 'controls' }, [
E('div', {}, [ E('div', {}, [
E('label', {}, _('Disk space') + ':'), E('label', {'id': 'disk-space-label'}, _('Disk space') + ':'),
E('div', { 'class': 'cbi-progressbar', 'title': _('unknown') }, E('div', {}, [ '\u00a0' ])) E('div', { 'class': 'cbi-progressbar', 'title': _('unknown') }, E('div', {}, [ '\u00a0' ]))
]), ]),
E('div', {}, [ E('div', {}, [
E('label', {}, _('Filter') + ':'), E('label', {'id': 'filter-label'}, _('Filter') + ':'),
E('span', { 'class': 'control-group' }, [ E('span', { 'class': 'control-group' }, [
E('input', { 'type': 'text', 'name': 'filter', 'placeholder': _('Type to filter…'), 'value': query, 'input': handleInput }), E('input', { 'type': 'text', 'name': 'filter', 'placeholder': _('Type to filter…'), 'value': query, 'input': handleInput }),
E('button', { 'class': 'btn cbi-button', 'click': handleReset }, [ _('Clear') ]) E('button', { 'class': 'btn cbi-button', 'click': handleReset }, [ _('Clear') ])
@@ -1182,7 +1218,7 @@ return view.extend({
]), ]),
E('div', {}, [ E('div', {}, [
E('label', {}, _('Download and install package') + ':'), E('label', {'id': 'download-label'}, _('Download and install package') + ':'),
E('span', { 'class': 'control-group' }, [ 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('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('button', { 'class': 'btn cbi-button cbi-button-action', 'click': handleManualInstall, 'disabled': isReadonlyView }, [ _('OK') ])
@@ -1190,7 +1226,7 @@ return view.extend({
]), ]),
E('div', {}, [ E('div', {}, [
E('label', {}, _('Actions') + ':'), ' ', E('label', {'id': 'action-label'}, _('Actions') + ':'), ' ',
E('span', { 'class': 'control-group' }, [ 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-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-action', 'click': handleUpload, 'disabled': isReadonlyView }, [ _('Upload Package…') ]), ' ',

View File

@@ -14,14 +14,14 @@ fi
case "$action" in case "$action" in
list-installed) list-installed)
if [ $ipkg_bin = "apk" ]; then if [ $ipkg_bin = "apk" ]; then
$ipkg_bin list -I --full 2>/dev/null $ipkg_bin query --fields all --format json --installed --from system \* 2>/dev/null
else else
cat /usr/lib/opkg/status cat /usr/lib/opkg/status
fi fi
;; ;;
list-available) list-available)
if [ $ipkg_bin = "apk" ]; then if [ $ipkg_bin = "apk" ]; then
$ipkg_bin list --full 2>/dev/null $ipkg_bin query --fields all --format json --available \* 2>/dev/null
else else
lists_dir=$(sed -rne 's#^lists_dir \S+ (\S+)#\1#p' /etc/opkg.conf /etc/opkg/*.conf 2>/dev/null | tail -n 1) lists_dir=$(sed -rne 's#^lists_dir \S+ (\S+)#\1#p' /etc/opkg.conf /etc/opkg/*.conf 2>/dev/null | tail -n 1)
find "${lists_dir:-/usr/lib/opkg/lists}" -type f '!' -name '*.sig' | xargs -r gzip -cd find "${lists_dir:-/usr/lib/opkg/lists}" -type f '!' -name '*.sig' | xargs -r gzip -cd
@@ -31,18 +31,12 @@ case "$action" in
( (
cmd="$ipkg_bin" cmd="$ipkg_bin"
# APK have command renamed # The apk package manager uses distinct commands for installing and removing packages.
if [ $ipkg_bin = "apk" ]; then if [ $ipkg_bin = "apk" ]; then
case "$action" in case "$action" in
install) install)
action="add" action="add"
;; ;;
update)
action="update"
;;
upgrade)
action="upgrade"
;;
remove) remove)
action="del" action="del"
;; ;;
@@ -54,6 +48,11 @@ case "$action" in
if [ $ipkg_bin = "apk" ]; then if [ $ipkg_bin = "apk" ]; then
while [ -n "$1" ]; do while [ -n "$1" ]; do
case "$1" in case "$1" in
--autoremove)
# Just shift and do nothing.
# 'apk del' already cleans up orphaned dependencies by default.
shift
;;
--force-removal-of-dependent-packages) --force-removal-of-dependent-packages)
cmd="$cmd -r" cmd="$cmd -r"
shift shift