🤞 Sync 2026-09-27 22:16:29

This commit is contained in:
github-actions[bot] committed 2026-09-27 22:16:29 +08:00
1 parent 8ede4a8320
commit 618f51e9dc
459 files changed
+44817 -2191

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
set -eu
fail() { printf 'build-apk-release: %s\n' "$*" >&2; exit 1; }
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
. "$root/release.env"
. "$root/apk-feed.env"
sdk="${OPENWRT_SDK_DIR:-}"
secret="${OPENWRT_APK_SIGNING_KEY:-}"
public="$root/$OPENWRT_APK_KEY_FILE"
[ -d "$sdk" ] || fail 'OPENWRT_SDK_DIR must name an extracted SDK'
[ -r "$secret" ] || fail 'OPENWRT_APK_SIGNING_KEY is not readable'
[ -r "$public" ] || fail 'shared public key is not readable'
"$root/scripts/check-apk-trust.sh"
verify_dir="$(mktemp -d)"
trap 'rm -rf "$verify_dir"' EXIT HUP INT TERM
openssl ec -in "$secret" -pubout -out "$verify_dir/public.pem" >/dev/null 2>&1 ||
fail 'invalid EC signing key'
cmp -s "$verify_dir/public.pem" "$public" || fail 'signing key does not match shared public key'
OPENWRT_APK_SIGNING_KEY="$secret" OPENWRT_APK_PUBLIC_KEY="$public" \
"$root/scripts/build-apk.sh"
apk_tool="$sdk/staging_dir/host/bin/apk"
package="$root/dist/$PKG_NAME-$PKG_VERSION.apk"
[ -x "$apk_tool" ] || fail 'SDK apk tool is unavailable'
[ -r "$package" ] || fail 'built APK is unavailable'
"$apk_tool" --allow-untrusted adbsign --sign-key "$secret" "$package"
"$apk_tool" --keys-dir "$root/keys" verify "$package"
mkdir -p "$root/dist/apk"
cp "$package" "$root/dist/apk/"
(cd "$root/dist/apk" && sha256sum "$PKG_NAME-$PKG_VERSION.apk" >SHA256SUMS.apk)
+65
View File
@@ -0,0 +1,65 @@
#!/bin/sh
set -eu
fail() { printf 'build-apk: %s\n' "$*" >&2; exit 1; }
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
. "$root/release.env"
. "$root/apk-feed.env"
sdk="${OPENWRT_SDK_DIR:-}"
[ -d "$sdk" ] || fail 'OPENWRT_SDK_DIR must name an extracted SDK'
apk_tool="$sdk/staging_dir/host/bin/apk"
[ -x "$apk_tool" ] || fail 'SDK apk tool is unavailable'
"$root/scripts/check-version-sync.sh"
work="$(mktemp -d)"
cleanup() {
if [ -d "$work" ]; then
chown -R "$(id -u):$(id -g)" "$work" 2>/dev/null ||
sudo -n chown -R "$(id -u):$(id -g)" "$work" 2>/dev/null || true
rm -rf "$work"
fi
}
trap cleanup EXIT HUP INT TERM
"$root/scripts/stage-package.sh" "$work/full"
mkdir -p "$work/payload"
cp -R "$work/full/usr" "$work/full/www" "$work/payload/"
if ! chown -R 0:0 "$work/payload" 2>/dev/null; then
command -v sudo >/dev/null 2>&1 || fail 'root-owned APK staging requires chown or sudo'
sudo -n chown -R 0:0 "$work/payload" || fail 'could not normalize APK ownership'
fi
mkdir -p "$root/dist"
artifact="$root/dist/$PKG_NAME-$PKG_VERSION.apk"
"$apk_tool" mkpkg \
--info "name:$PKG_NAME" \
--info "version:$PKG_VERSION" \
--info 'description:Active Wi-Fi access point QR codes for LuCI Status Overview' \
--info "arch:$OPENWRT_APK_ARCH" \
--info 'license:MIT' \
--info "origin:$PKG_NAME" \
--info 'maintainer:nikitid' \
--info 'depends:libc luci-base luci-lib-uqr' \
--script "post-install:$work/full/CONTROL/postinst" \
--script "post-upgrade:$work/full/CONTROL/postinst" \
--script "pre-deinstall:$work/full/CONTROL/prerm" \
--files "$work/payload" \
--output "$artifact"
"$apk_tool" adbdump --format json "$artifact" >"$work/package.json"
python3 - "$work/package.json" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as source:
package = json.load(source)
if package.get("info", {}).get("depends") != ["libc", "luci-base", "luci-lib-uqr"]:
raise SystemExit("unexpected APK dependency set")
for path in package.get("paths", []):
entries = [path] + path.get("files", [])
for entry in entries:
acl = entry.get("acl", {})
if acl.get("user") != "root" or acl.get("group") != "root":
raise SystemExit("APK contains a non-root-owned path")
if any(path.get("name") == "CONTROL" for path in package.get("paths", [])):
raise SystemExit("IPK control directory leaked into APK payload")
scripts = package.get("scripts", {})
if set(scripts) != {"post-install", "post-upgrade", "pre-deinstall"}:
raise SystemExit("unexpected APK lifecycle scripts")
PY
printf '%s\n' "$artifact"
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
stage="${BUILD_DIR:-$root/build/ipk}/stage"
mkdir -p "$root/dist"
"$root/scripts/check-version-sync.sh"
"$root/scripts/stage-package.sh" "$stage"
rm -f "$root/dist"/*.ipk "$root/dist/SHA256SUMS"
python3 "$root/scripts/pack-ipk.py" "$stage" "$root/dist"
(cd "$root/dist" && sha256sum ./*.ipk >SHA256SUMS)
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
. "$root/apk-feed.env"
actual="$(sha256sum "$root/$OPENWRT_APK_KEY_FILE" | awk '{print $1}')"
[ "$actual" = "$OPENWRT_APK_TRUST_SHA256" ] || {
printf 'shared APK public key checksum mismatch\n' >&2
exit 1
}
if grep -R -E '(apk|opkg)[[:space:]]+upgrade([[:space:]]*$|[[:space:]]*(&&|;))' \
"$root/scripts" "$root/README.md" >/dev/null 2>&1; then
printf 'unscoped package upgrade found\n' >&2
exit 1
fi
+115
View File
@@ -0,0 +1,115 @@
#!/bin/sh
#
# template: check-readme v1 (repo-templates)
# Formatted to pass both `shfmt -i 2` and `shfmt -i 2 -bn -ci`, the styles
# the consuming repositories check with; each gets this file verbatim.
# Do not edit in place: change templates/shared/ in repo-templates
# and run scripts/sync-templates.sh --update.
#
# Hold README.md (English) and README.ru.md (Russian) to the shared layout:
#
# # <Product> for <platform> # <Product> для <platform>
# [Русский](README.ru.md) [English](README.md)
# badges: CI, Release, License
# ## Features ## Возможности
# ## Requirements ## Требования
# ## Installation ## Установка
# ## <project sections> ## <project sections>
# ## Development ## Разработка
# ## Documentation ## Документация
# ## Support ## Поддержка
# ## License ## Лицензия
#
# Both files must have the same number of sections, so one language cannot
# silently drop what the other says. Punctuation is plain ASCII in both: no
# typographic dashes, arrows, quotes or ellipses. The release badge is required
# only where .github/workflows/release.yml exists. Every relative link must
# resolve.
set -eu
root="$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd)"
cd "$root"
status=0
fail() {
printf 'README: %s\n' "$*" >&2
status=1
}
headings() {
sed -n 's/^## //p' "$1"
}
# check_layout <file> <first three sections> <last four sections>, each list
# separated by '|'. Project sections may only sit between the two.
check_layout() {
file="$1"
head_want="$2"
tail_want="$3"
if [ ! -f "$file" ]; then
fail "$file is missing"
return
fi
if ! head -n 1 "$file" | grep -q '^# '; then
fail "$file does not start with a '# ' title"
fi
head_have="$(headings "$file" | head -n 3 | paste -sd '|' -)"
tail_have="$(headings "$file" | tail -n 4 | paste -sd '|' -)"
if [ "$head_have" != "$head_want" ]; then
fail "$file: the first sections are '$head_have', expected '$head_want'"
fi
if [ "$tail_have" != "$tail_want" ]; then
fail "$file: the last sections are '$tail_have', expected '$tail_want'"
fi
}
check_layout README.md 'Features|Requirements|Installation' \
'Development|Documentation|Support|License'
check_layout README.ru.md 'Возможности|Требования|Установка' \
'Разработка|Документация|Поддержка|Лицензия'
if ! grep -qF '[Русский](README.ru.md)' README.md; then
fail 'README.md does not link to README.ru.md'
fi
if ! grep -qF '[English](README.md)' README.ru.md; then
fail 'README.ru.md does not link to README.md'
fi
en="$(headings README.md | wc -l | tr -d ' ')"
ru="$(headings README.ru.md | wc -l | tr -d ' ')"
if [ "$en" != "$ru" ]; then
fail "README.md has $en sections and README.ru.md has $ru"
fi
for file in README.md README.ru.md; do
if ! grep -q 'actions/workflows/.*badge\.svg' "$file"; then
fail "$file has no CI badge"
fi
if ! grep -q 'img\.shields\.io/github/license\|License-\|license-' "$file"; then
fail "$file has no license badge"
fi
if [ -f .github/workflows/release.yml ]; then
if ! grep -q 'img\.shields\.io/github/v/release' "$file"; then
fail "$file has no release badge"
fi
fi
# The curly quotes in the pattern are what it looks for, not a typo.
# shellcheck disable=SC1112
typographic="$(grep -n '—\|–\|→\|←\|«\|»\|“\|”\|‘\|’\|…' "$file" | head -n 3 || true)"
if [ -n "$typographic" ]; then
fail "$file has non-ASCII punctuation: $typographic"
fi
# Relative links: [text](path) and [text](path#anchor), not URLs or anchors.
links="$(grep -o '](\([^)#:]*\)[)#]' "$file" | sed 's/^](//; s/[)#]$//' | sort -u)"
missing=""
for target in $links; do
[ -e "$target" ] || missing="$missing $target"
done
[ -z "$missing" ] || fail "$file links to missing files:$missing"
done
[ "$status" -eq 0 ] && printf 'README layout OK: %s sections each\n' "$en"
exit "$status"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
. "$root/release.env"
[ "${1:?usage: check-release-tag.sh TAG}" = "v$PKG_VERSION" ] || {
printf 'release tag must be v%s\n' "$PKG_VERSION" >&2
exit 1
}
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
. "$root/release.env"
make_name="$(sed -n 's/^PKG_NAME:=//p' "$root/Makefile" | head -n 1)"
make_version="$(sed -n 's/^PKG_VERSION:=//p' "$root/Makefile" | head -n 1)"
make_arch="$(sed -n 's/^PKGARCH:=//p' "$root/Makefile" | head -n 1)"
[ "$make_name" = "$PKG_NAME" ]
[ "$make_version" = "$PKG_VERSION" ]
[ "$make_arch" = "$PKG_ARCH" ]
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
cd "$root"
./scripts/check-version-sync.sh
./scripts/check-readme.sh
./scripts/check-apk-trust.sh
find scripts -type f -name '*.sh' -exec sh -n {} +
find luci -type f -name '*.js' -exec node --check {} +
node ./scripts/test-shared.js
node ./scripts/test-overview-ui.js
python3 -m py_compile scripts/pack-ipk.py
python3 - <<'PY'
import json
from pathlib import Path
for path in Path('luci').glob('*.json'):
json.loads(path.read_text())
print('json OK:', path)
PY
./scripts/build-ipk.sh
if find "${BUILD_DIR:-$root/build/ipk}/stage" -type f -name '._*' | grep -q .; then
printf 'AppleDouble metadata leaked into staged package\n' >&2
exit 1
fi
first="$(sha256sum dist/*.ipk | awk '{print $1}')"
./scripts/build-ipk.sh
second="$(sha256sum dist/*.ipk | awk '{print $1}')"
[ "$first" = "$second" ]
git diff --check 2>/dev/null || true
printf 'ci-check OK\n'
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Build a deterministic opkg package."""
import gzip
import io
import os
import sys
import tarfile
def normalize(info):
info.uid = info.gid = info.mtime = 0
info.uname = info.gname = "root"
return info
def archive(members):
raw = io.BytesIO()
with tarfile.open(fileobj=raw, mode="w", format=tarfile.GNU_FORMAT) as out:
for name, path in members:
info = normalize(out.gettarinfo(path, name))
if info.isreg():
with open(path, "rb") as source:
out.addfile(info, source)
else:
out.addfile(info)
compressed = io.BytesIO()
with gzip.GzipFile(fileobj=compressed, mode="wb", mtime=0) as out:
out.write(raw.getvalue())
return compressed.getvalue()
def walk(stage, control=False):
result = []
base = os.path.join(stage, "CONTROL") if control else stage
for root, dirs, files in os.walk(base):
dirs[:] = sorted(d for d in dirs if control or d != "CONTROL")
rel = os.path.relpath(root, base)
if rel != ".":
result.append(("./" + rel, root))
for name in sorted(files):
result.append(("./" + os.path.join(rel, name).replace("./", "", 1),
os.path.join(root, name)))
return sorted(result)
def control_field(path, field):
with open(path, encoding="utf-8") as source:
for line in source:
if line.startswith(field + ":"):
return line.split(":", 1)[1].strip()
raise SystemExit("missing control field: " + field)
stage, output = sys.argv[1:]
control = os.path.join(stage, "CONTROL", "control")
parts = {
"debian-binary": b"2.0\n",
"control.tar.gz": archive(walk(stage, True)),
"data.tar.gz": archive(walk(stage, False)),
}
os.makedirs(output, exist_ok=True)
name = "%s_%s_%s.ipk" % tuple(control_field(control, field) for field in
("Package", "Version", "Architecture"))
target = os.path.join(output, name)
raw = io.BytesIO()
with tarfile.open(fileobj=raw, mode="w", format=tarfile.GNU_FORMAT) as out:
for part in ("debian-binary", "control.tar.gz", "data.tar.gz"):
data = parts[part]
info = normalize(tarfile.TarInfo("./" + part))
info.mode, info.size = 0o644, len(data)
out.addfile(info, io.BytesIO(data))
with gzip.GzipFile(target, "wb", mtime=0) as out:
out.write(raw.getvalue())
print(target)
+99
View File
@@ -0,0 +1,99 @@
#!/bin/sh
#
# template: release-notes v3 (repo-templates)
# Formatted to pass both `shfmt -i 2` and `shfmt -i 2 -bn -ci`, the styles
# the consuming repositories check with; each gets this file verbatim.
# Do not edit in place: change templates/shared/ in repo-templates
# and run scripts/sync-templates.sh --update.
#
# Print release notes for a tag, built from the subjects of the commits since
# the previous release tag. There is no changelog file: the commit subject is
# the release note, so write it as the line a user should read.
#
# A commit whose subject is only a version number ("Release 1.2.0",
# "release: v0.2.0") says nothing a user can read and is left out.
#
# A commit that touches only documentation, CI, tests or checks is listed
# under "Documentation and tooling" rather than "Changes".
#
# Usage: scripts/release-notes.sh <tag> [previous-tag]
#
# The previous tag defaults to the nearest v* tag before <tag>. For a final
# release, pre-release tags (_rc, _beta, _alpha) are skipped, so its notes
# cover everything since the last final release rather than since its RC. The repository
# for the compare link comes from GITHUB_REPOSITORY, or from the origin remote.
# Needs the full history: check out with fetch-depth 0.
set -eu
[ $# -ge 1 ] || {
printf 'usage: %s <tag> [previous-tag]\n' "$0" >&2
exit 2
}
tag="$1"
if [ $# -ge 2 ]; then
previous="$2"
elif printf '%s\n' "$tag" | grep -Eq '_(rc|beta|alpha)'; then
previous="$(git describe --tags --abbrev=0 --match 'v*' "$tag^" 2>/dev/null || true)"
else
previous="$(git describe --tags --abbrev=0 --match 'v*' --exclude '*_rc*' \
--exclude '*_beta*' --exclude '*_alpha*' "$tag^" 2>/dev/null || true)"
fi
repository="${GITHUB_REPOSITORY:-}"
if [ -z "$repository" ]; then
url="$(git remote get-url origin 2>/dev/null || true)"
url="${url%.git}"
repository="$(printf '%s\n' "$url" | sed -n 's#.*github\.com[:/]\(.*\)$#\1#p')"
fi
if [ -n "$previous" ]; then
range="$previous..$tag"
else
range="$tag"
fi
# Subjects that carry nothing but a version number.
bump='^(release|bump|version)[: ]+(to )?v?[0-9]+(\.[0-9]+)*([-_][a-z0-9]+)?$'
# Paths that never change what a user runs.
tooling='^(docs/|\.github/|tests?/|scripts/(check|test|gen|ci)-|scripts/release-notes\.sh$|LICENSE|NOTICE|THIRD_PARTY|.*\.md$|\.[^/]+$)'
changes=""
tooling_changes=""
for commit in $(git rev-list --no-merges --reverse "$range"); do
subject="$(git log -1 --format=%s "$commit")"
if printf '%s\n' "$subject" | grep -Eiq "$bump"; then
continue
fi
paths="$(git diff-tree --no-commit-id --name-only -r --root "$commit")"
if printf '%s\n' "$paths" | grep -Evq "$tooling"; then
changes="$changes- $subject
"
else
tooling_changes="$tooling_changes- $subject
"
fi
done
if [ -n "$changes" ]; then
printf '## Changes\n\n%s' "$changes"
fi
if [ -n "$tooling_changes" ]; then
[ -z "$changes" ] || printf '\n'
printf '## Documentation and tooling\n\n%s' "$tooling_changes"
fi
if [ -z "$changes$tooling_changes" ]; then
printf 'Version update only; no other changes since %s.\n' "$previous"
fi
if [ -n "$repository" ]; then
if [ -n "$previous" ]; then
printf '\n**Full diff**: https://github.com/%s/compare/%s...%s\n' \
"$repository" "$previous" "$tag"
else
printf '\n**Full history**: https://github.com/%s/commits/%s\n' \
"$repository" "$tag"
fi
fi
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
stage="${1:?usage: stage-package.sh STAGE_DIR}"
. "$root/release.env"
rm -rf "$stage"
mkdir -p "$stage/CONTROL" "$stage/usr/share/rpcd/acl.d" \
"$stage/usr/share/licenses/$PKG_NAME" \
"$stage/www/luci-static/resources/wrqr" \
"$stage/www/luci-static/resources/view/status/include"
install -m 644 "$root/luci/acl.json" "$stage/usr/share/rpcd/acl.d/$PKG_NAME.json"
install -m 644 "$root/luci/shared.js" "$stage/www/luci-static/resources/wrqr/shared.js"
install -m 644 "$root/luci/overview.js" "$stage/www/luci-static/resources/view/status/include/15_wifi-qr.js"
install -m 644 "$root/LICENSE" "$stage/usr/share/licenses/$PKG_NAME/LICENSE"
cat >"$stage/CONTROL/control" <<EOF
Package: $PKG_NAME
Version: $PKG_VERSION
Architecture: $PKG_ARCH
Maintainer: nikitid
Depends: luci-base, luci-lib-uqr
Section: luci
Priority: optional
Description: Display active Wi-Fi access point QR codes on LuCI Status Overview.
EOF
cat >"$stage/CONTROL/postinst" <<'EOF'
#!/bin/sh
[ -n "${IPKG_INSTROOT:-}" ] && exit 0
rm -f /tmp/luci-indexcache
rm -rf /tmp/luci-modulecache
exit 0
EOF
cat >"$stage/CONTROL/prerm" <<'EOF'
#!/bin/sh
[ -n "${IPKG_INSTROOT:-}" ] && exit 0
case "${1:-}" in upgrade) exit 0 ;; esac
[ "${PKG_UPGRADE:-0}" = 1 ] && exit 0
rm -f /tmp/luci-indexcache
rm -rf /tmp/luci-modulecache
exit 0
EOF
chmod 755 "$stage/CONTROL/postinst" "$stage/CONTROL/prerm"
+74
View File
@@ -0,0 +1,74 @@
'use strict';
const fs = require('fs');
const vm = require('vm');
let replacements = 0;
let qrRenders = 0;
function node(tag, attrs, children) {
return {
tag, attrs: attrs || {}, children: children || []
};
}
function evaluate(path, context) {
return vm.runInNewContext(`(function(){${fs.readFileSync(path, 'utf8')}\n})()`, context);
}
const common = evaluate('luci/shared.js', {
baseclass: { extend: value => value }, encodeURIComponent
});
const rpcCalls = [];
const context = {
baseclass: { extend: value => value },
common,
dom: { content: (target, next) => { replacements++; target.children = next; } },
uqr: { renderSVG: value => {
qrRenders++;
return `<svg xmlns="http://www.w3.org/2000/svg" width="148" height="148">${value}</svg>`;
} },
rpc: { declare: spec => (...args) => { rpcCalls.push([spec.object, spec.method, args]); return Promise.resolve({}); } },
L: { resolveDefault: promise => promise },
Promise,
E: (tag, attrs, children) => node(tag, attrs, children)
};
const widget = evaluate('luci/overview.js', context);
const config = { values: {
radio0: { '.type': 'wifi-device', band: '5g' },
ap0: { '.type': 'wifi-iface', device: 'radio0', mode: 'ap', ssid: 'Home', encryption: 'sae-mixed', key: 'secret' }
} };
const running = { radio0: { up: true, interfaces: [
{ section: 'ap0', ifname: 'phy0-ap0', config: { mode: 'ap' } }
] } };
const firstRoot = widget.render([ config, running ]);
if (replacements !== 1 || qrRenders !== 1)
throw new Error('first render did not create exactly one QR snapshot');
const qrMarkup = firstRoot.children[1].children[0].children[2].children[0].tag;
if (!qrMarkup.includes('viewBox="0 0 148 148"') || qrMarkup.includes(' width="148"'))
throw new Error('QR SVG was not converted to a scalable viewBox');
replacements = 0;
qrRenders = 0;
const secondRoot = widget.render([ config, running ]);
if (secondRoot !== firstRoot)
throw new Error('polling replaced the stable widget root');
if (replacements !== 0 || qrRenders !== 0)
throw new Error('unchanged poll mutated or regenerated the widget');
const stopped = { radio0: { up: false, interfaces: [] } };
widget.render([ config, stopped ]);
if (replacements !== 1)
throw new Error('runtime change was not rendered');
widget.load().then(() => {
if (JSON.stringify(rpcCalls.map(call => call.slice(0, 2))) !==
JSON.stringify([ [ 'uci', 'get' ], [ 'luci-rpc', 'getWirelessDevices' ] ]))
throw new Error('load did not refresh configuration and runtime state');
console.log('overview UI tests OK');
}).catch(error => {
console.error(error.message);
process.exit(1);
});
+56
View File
@@ -0,0 +1,56 @@
'use strict';
const fs = require('fs');
const vm = require('vm');
function load(path) {
return vm.runInNewContext(`(function(){${fs.readFileSync(path, 'utf8')}\n})()`, {
baseclass: { extend: value => value },
encodeURIComponent
});
}
const common = load('luci/shared.js');
function equal(actual, expected, label) {
if (JSON.stringify(actual) !== JSON.stringify(expected))
throw new Error(`${label}: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`);
}
equal(common.percentEncode('a;b\nя'), 'a%3Bb%0A%D1%8F', 'percent encoding');
equal(common.payload({ ssid: 'Guest', encryption: 'none' }),
'WIFI:S:Guest;;', 'open network');
equal(common.payload({ ssid: 'Main', encryption: 'sae-mixed', key: 'secret' }),
'WIFI:T:WPA;S:Main;P:secret;;', 'transition network');
equal(common.payload({ ssid: 'Main', encryption: 'sae', key: 'secret', hidden: '1' }),
'WIFI:T:WPA;R:0;S:Main;H:true;P:secret;;', 'WPA3 network');
equal(common.payload({ ssid: 'Corp', encryption: 'wpa2', key: 'secret' }), null,
'enterprise network');
const config = { values: {
radio0: { '.type': 'wifi-device', band: '2g' },
radio1: { '.type': 'wifi-device', band: '5g' },
radio2: { '.type': 'wifi-device', band: '6g', disabled: '1' },
ap0: { '.type': 'wifi-iface', device: 'radio0', mode: 'ap', ssid: 'Home', encryption: 'psk2', key: 'same' },
ap1: { '.type': 'wifi-iface', device: 'radio1', mode: 'ap', ssid: 'Home', encryption: 'sae-mixed', key: 'same' },
ap2: { '.type': 'wifi-iface', device: 'radio2', mode: 'ap', ssid: 'Off', encryption: 'sae', key: 'off' },
sta0: { '.type': 'wifi-iface', device: 'radio1', mode: 'sta', ssid: 'Upstream', encryption: 'psk2', key: 'skip' },
broken: { '.type': 'wifi-iface', device: 'radio1', mode: 'ap', ssid: 'Broken', encryption: 'psk2', key: 'skip' }
} };
const runtime = {
radio0: { up: true, interfaces: [ { section: 'ap0', ifname: 'phy0-ap0', config: { mode: 'ap' } } ] },
radio1: { up: true, interfaces: [
{ section: 'ap1', ifname: 'phy1-ap0', config: { mode: 'ap' } },
{ section: 'sta0', ifname: 'phy1-sta0', config: { mode: 'sta' } }
] },
radio2: { up: false, interfaces: [ { section: 'ap2', ifname: 'phy2-ap0', config: { mode: 'ap' } } ] }
};
equal(common.collect(config, runtime), [ {
ssid: 'Home',
payload: 'WIFI:T:WPA;S:Home;P:same;;',
bands: [ '2.4 GHz', '5 GHz' ]
} ], 'active AP collection and deduplication');
equal(common.collect(config.values, runtime).length, 1, 'RPC-unwrapped UCI response');
console.log('shared tests OK');