package-manager: migrate to apk-tools JSON API
authorKonstantin Glukhov <redacted>
Wed, 25 Mar 2026 16:32:21 +0000 (01:32 +0900)
committerPaul Donald <redacted>
Thu, 9 Apr 2026 19:44:40 +0000 (21:44 +0200)
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 <redacted>
applications/luci-app-package-manager/htdocs/luci-static/resources/view/package-manager.js
applications/luci-app-package-manager/root/usr/libexec/package-manager-call

index 48476dbe167f8ce4bc8a552728f132810ae7084c..e6b1a45e9a96c6fa6e4d3114cbe40db509773512 100644 (file)
@@ -152,7 +152,7 @@ function parseList(s, dest)
 
                                switch (installed) {
                                case 'installed':
-                                       pkg.installed = true;
+                                       pkg.status = ['installed'];
                                        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)
 {
        const src = packages[currentDisplayMode === 'updates' ? 'installed' : currentDisplayMode];
@@ -249,7 +302,7 @@ function display(pattern)
                        const avail = packages.available.pkgs[name];
                        const inst  = packages.installed.pkgs[name];
 
-                       if (!inst || !inst.installed)
+                       if (!isPkgInstalled(inst))
                                continue;
 
                        if (!avail || compareVersion(avail.version, pkg.version) <= 0)
@@ -267,7 +320,7 @@ function display(pattern)
                        }, _('Upgrade…'));
                }
                else if (currentDisplayMode === 'installed') {
-                       if (!pkg.installed)
+                       if (!isPkgInstalled(pkg))
                                continue;
 
                        ver = truncateVersion(pkg.version || '-');
@@ -282,14 +335,14 @@ function display(pattern)
 
                        ver = truncateVersion(pkg.version || '-');
 
-                       if (!inst || !inst.installed)
+                       if (!isPkgInstalled(inst))
                                btn = E('div', {
                                        'class': 'btn cbi-button-action',
                                        'data-package': name,
                                        'data-action': 'install',
                                        'click': handleInstall
                                }, _('Install…'));
-                       else if (inst.installed && inst.version != pkg.version)
+                       else if (isPkgInstalled(inst) && inst.version !== pkg.version)
                                btn = E('div', {
                                        'class': 'btn cbi-button-positive',
                                        'data-package': name,
@@ -420,46 +473,24 @@ function orderOf(c)
                return c.charCodeAt(0) + 256;
 }
 
-function compareVersion(val, ref)
-{
-       let vi = 0, ri = 0;
-       const isdigit = { 0:1, 1:1, 2:1, 3:1, 4:1, 5:1, 6:1, 7:1, 8:1, 9:1 };
-
-       val = val || '';
-       ref = ref || '';
-
-       if (val === ref)
-               return 0;
-
-       while (vi < val.length || ri < ref.length) {
-               let first_diff = 0;
+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]+/);
 
-               while ((vi < val.length && !isdigit[val.charAt(vi)]) ||
-                      (ri < ref.length && !isdigit[ref.charAt(ri)])) {
-                       const vc = orderOf(val.charAt(vi)), rc = orderOf(ref.charAt(ri));
-                       if (vc !== rc)
-                               return vc - rc;
+       // 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);
 
-                       vi++; ri++;
-               }
-
-               while (val.charAt(vi) === '0')
-                       vi++;
+       const maxLen = Math.max(v.length, r.length);
 
-               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++;
-               }
+       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 (isdigit[val.charAt(vi)])
-                       return 1;
-               else if (isdigit[ref.charAt(ri)])
-                       return -1;
-               else if (first_diff)
-                       return first_diff;
+               if (vNum > rNum) return 1;
+               if (vNum < rNum) return -1;
        }
 
        return 0;
@@ -485,7 +516,7 @@ function versionSatisfied(ver, ref, vop)
                return r > 0;
 
        case '=':
-               return r == 0;
+               return r === 0;
        }
 
        return false;
@@ -496,7 +527,7 @@ function pkgStatus(pkg, vop, ver, info)
        info.errors = info.errors || [];
        info.install = info.install || [];
 
-       if (pkg.installed) {
+       if (isPkgInstalled(pkg)) {
                if (vop && !versionSatisfied(pkg.version, ver, vop)) {
                        let repl = null;
 
@@ -605,12 +636,17 @@ function renderDependencies(depends, info, flat)
                if (deps[i] === 'libc')
                        continue;
 
-               if (deps[i].match(/^(.+?)\s+\((<=|>=|<<|>>|<|>|=)(.+?)\)/)) {
-                       dep = RegExp.$1.trim();
-                       vop = RegExp.$2.trim();
-                       ver = RegExp.$3.trim();
-               }
-               else {
+               // 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;
                }
@@ -701,16 +737,16 @@ function handleInstall(ev)
        const i18n_packages = [];
        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;
 
-               if (luci_basename[1] == 'i18n') {
+               if (luci_basename[1] === 'i18n') {
                        const basenames = [];
 
                        for (let pkgname in packages.installed.pkgs) {
                                const m = pkgname.match(/^luci-([^-]+)-(.+)$/);
 
-                               if (m && m[1] != 'i18n')
+                               if (m && m[1] !== 'i18n')
                                        basenames.push(m[2]);
                        }
 
@@ -723,7 +759,7 @@ function handleInstall(ev)
 
                if (i18n_filter) {
                        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);
 
                        const i18ncache = {};
@@ -876,9 +912,9 @@ function handleConfig(ev)
                 }
 
                for (let i = 0; i < partials.length; i++) {
-                       if (partials[i].type == 'file') {
+                       if (partials[i].type === 'file') {
                                if (L.hasSystemFeature('apk')) {
-                                       if (partials[i].name == 'repositories')
+                                       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);
@@ -1010,7 +1046,7 @@ function handlePkg(ev)
 
                const argv = [ cmd ];
 
-               if (cmd == 'remove')
+               if (cmd === 'remove')
                        argv.push('--force-removal-of-dependent-packages')
 
                if (rem && rem.checked)
@@ -1118,14 +1154,14 @@ function updateLists(data)
 
        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' }))
+               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));
 
-               parseList(data[1], packages.available);
-               parseList(data[2], packages.installed);
+               parsePackageData(data[1], packages.available);
+               parsePackageData(data[2], packages.installed);
 
                for (let pkgname in packages.installed.pkgs)
                        if (pkgname.indexOf('luci-i18n-base-') === 0)
@@ -1169,12 +1205,12 @@ return view.extend({
 
                        E('div', { 'class': 'controls' }, [
                                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', {}, [
-                                       E('label', {}, _('Filter') + ':'),
+                                       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') ])
@@ -1182,7 +1218,7 @@ return view.extend({
                                ]),
 
                                E('div', {}, [
-                                       E('label', {}, _('Download and install package') + ':'),
+                                       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') ])
@@ -1190,7 +1226,7 @@ return view.extend({
                                ]),
 
                                E('div', {}, [
-                                       E('label', {}, _('Actions') + ':'), ' ',
+                                       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…') ]), ' ',
index 3a4175783a0ba5389902885f5b370d161661c40b..b641483e50ee86afc4ef4a7a959d01e8e93536f1 100755 (executable)
@@ -14,14 +14,14 @@ fi
 case "$action" in
        list-installed)
                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
                        cat /usr/lib/opkg/status
                fi
        ;;
        list-available)
                if [ $ipkg_bin = "apk" ]; then
-                       $ipkg_bin list --full 2>/dev/null
+      $ipkg_bin query --fields all --format json --available \* 2>/dev/null
                else
                        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
@@ -31,18 +31,12 @@ case "$action" in
                (
                        cmd="$ipkg_bin"
 
-                       # APK have command renamed
+                       # The apk package manager uses distinct commands for installing and removing packages.
                        if [ $ipkg_bin = "apk" ]; then
                                case "$action" in
                                        install)
                                                action="add"
                                        ;;
-                                       update)
-                                               action="update"
-                                       ;;
-                                       upgrade)
-                                               action="upgrade"
-                                       ;;
                                        remove)
                                                action="del"
                                        ;;
@@ -54,6 +48,11 @@ case "$action" in
                        if [ $ipkg_bin = "apk" ]; then
                                while [ -n "$1" ]; do
                                        case "$1" in
+            --autoremove)
+              # Just shift and do nothing.
+              # 'apk del' already cleans up orphaned dependencies by default.
+              shift
+              ;;
                                                --force-removal-of-dependent-packages)
                                                        cmd="$cmd -r"
                                                        shift
git clone https://git.99rst.org/PROJECT