mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-02 04:43:56 +08:00
🤞 Sync 2026-09-27 22:16:29
This commit is contained in:
1 parent
8ede4a8320
commit
618f51e9dc
459 files changed
+44817
-2191
No files matched your search
+72
@@ -0,0 +1,72 @@
|
||||
#!/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:-}"
|
||||
signing_key="${OPENWRT_APK_SIGNING_KEY:-}"
|
||||
public_key="$root/$OPENWRT_APK_KEY_FILE"
|
||||
output="$root/dist/apk"
|
||||
|
||||
[ -n "$sdk" ] || fail 'OPENWRT_SDK_DIR is required'
|
||||
[ -d "$sdk" ] || fail "SDK directory not found: $sdk"
|
||||
[ -n "$signing_key" ] || fail 'OPENWRT_APK_SIGNING_KEY is required'
|
||||
[ -r "$signing_key" ] || fail "signing key not readable: $signing_key"
|
||||
[ -r "$public_key" ] || fail "public key not found: $public_key"
|
||||
|
||||
case "$(basename "$sdk")" in
|
||||
"${OPENWRT_APK_SDK_ARCHIVE%.tar.zst}") ;;
|
||||
*) fail "unexpected SDK directory: $(basename "$sdk")" ;;
|
||||
esac
|
||||
|
||||
for command in make openssl rsync sha256sum; do
|
||||
command -v "$command" >/dev/null 2>&1 ||
|
||||
fail "required command is missing: $command"
|
||||
done
|
||||
|
||||
"$root/scripts/check-apk-trust.sh"
|
||||
tmp="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
openssl ec -in "$signing_key" -pubout \
|
||||
-out "$tmp/derived-public.pem" >/dev/null 2>&1 ||
|
||||
fail 'invalid EC signing key'
|
||||
cmp -s "$tmp/derived-public.pem" "$public_key" ||
|
||||
fail 'signing key does not match the shared public release key'
|
||||
|
||||
OPENWRT_APK_PUBLIC_KEY="$public_key" \
|
||||
OPENWRT_APK_SIGNING_KEY="$signing_key" \
|
||||
"$root/scripts/build-apk.sh"
|
||||
|
||||
apk_tool="$sdk/staging_dir/host/bin/apk"
|
||||
[ -x "$apk_tool" ] || fail "SDK apk tool not found: $apk_tool"
|
||||
package="$root/dist/${PKG_NAME}-${PKG_VERSION}.apk"
|
||||
[ -r "$package" ] || fail "built APK was not found: $package"
|
||||
|
||||
"$apk_tool" --allow-untrusted adbsign --sign-key "$signing_key" "$package"
|
||||
"$apk_tool" --keys-dir "$root/keys" verify "$package"
|
||||
|
||||
# Only this package is published. Nikitid/openwrt-feed collects the released
|
||||
# APK and builds the shared signed index, so no public key, bootstrap script or
|
||||
# index is shipped from here.
|
||||
rm -rf "$output"
|
||||
mkdir -p "$output"
|
||||
cp "$package" "$output/"
|
||||
(
|
||||
cd "$output"
|
||||
sha256sum "${PKG_NAME}-${PKG_VERSION}.apk" >SHA256SUMS.apk
|
||||
sha256sum -c SHA256SUMS.apk
|
||||
)
|
||||
|
||||
printf 'Signed APK built in %s\n' "$output"
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
fail() {
|
||||
printf 'build-apk: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||
. "$root/release.env"
|
||||
sdk="${OPENWRT_SDK_DIR:-}"
|
||||
[ -n "$sdk" ] || fail 'OPENWRT_SDK_DIR is required'
|
||||
[ -d "$sdk" ] || fail "SDK directory not found: $sdk"
|
||||
command -v rsync >/dev/null 2>&1 || fail 'rsync is required'
|
||||
|
||||
"$root/scripts/check-version-sync.sh"
|
||||
sdk_package="$sdk/package/$PKG_NAME"
|
||||
rm -rf "$sdk_package"
|
||||
mkdir -p "$sdk_package"
|
||||
rsync -a --delete \
|
||||
--exclude .git \
|
||||
--exclude build \
|
||||
--exclude dist \
|
||||
--exclude .DS_Store \
|
||||
"$root/" "$sdk_package/"
|
||||
|
||||
make -C "$sdk" defconfig
|
||||
make -C "$sdk" "package/$PKG_NAME/clean" V=s
|
||||
|
||||
if [ -n "${OPENWRT_APK_SIGNING_KEY:-}" ] && [ -n "${OPENWRT_APK_PUBLIC_KEY:-}" ]; then
|
||||
make -C "$sdk" \
|
||||
BUILD_KEY_APK_SEC="$OPENWRT_APK_SIGNING_KEY" \
|
||||
BUILD_KEY_APK_PUB="$OPENWRT_APK_PUBLIC_KEY" \
|
||||
"package/$PKG_NAME/compile" V=s
|
||||
else
|
||||
make -C "$sdk" "package/$PKG_NAME/compile" V=s
|
||||
fi
|
||||
|
||||
package_path="$(find "$sdk/bin/packages" -type f \
|
||||
-name "${PKG_NAME}-${PKG_VERSION}.apk" -print -quit)"
|
||||
[ -n "$package_path" ] || fail 'built APK was not found'
|
||||
mkdir -p "$root/dist"
|
||||
rm -f "$root/dist"/*.apk
|
||||
cp "$package_path" "$root/dist/"
|
||||
printf '%s\n' "$root/dist/${package_path##*/}"
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#!/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
|
||||
python3 "$root/scripts/pack-ipk.py" "$stage" "$root/dist"
|
||||
(
|
||||
cd "$root/dist"
|
||||
sha256sum ./*.ipk >SHA256SUMS
|
||||
)
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
fail() {
|
||||
printf 'check-apk-trust: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||
. "$root/apk-feed.env"
|
||||
public_key="$root/$OPENWRT_APK_KEY_FILE"
|
||||
|
||||
[ -r "$public_key" ] || fail "public key not found: $OPENWRT_APK_KEY_FILE"
|
||||
actual="$(sha256sum "$public_key" | awk '{ print $1 }')"
|
||||
[ "$actual" = "$OPENWRT_APK_TRUST_SHA256" ] ||
|
||||
fail "public key checksum mismatch: $actual"
|
||||
openssl pkey -pubin -in "$public_key" -noout >/dev/null 2>&1 ||
|
||||
fail 'release public key is not a valid PEM public key'
|
||||
|
||||
tracked_files() {
|
||||
find "$root" -path "$root/.git" -prune -o \
|
||||
-path "$root/build" -prune -o -path "$root/dist" -prune -o \
|
||||
-type f -print
|
||||
}
|
||||
|
||||
# Keep this in sync with the secret patterns in .gitignore. The previous
|
||||
# anchored form missed names such as release-private.pem.
|
||||
if tracked_files |
|
||||
grep -Ei '\.key$|private[^/]*\.(pem|key)$|signing[^/]*\.(pem|key)$|/signing/' \
|
||||
>/dev/null; then
|
||||
fail 'private signing material is present in the source tree'
|
||||
fi
|
||||
|
||||
# A private key under an unexpected name still has recognisable contents. The
|
||||
# scan runs in a subshell, so it only reports and leaves failing to the caller.
|
||||
key_material="$(tracked_files |
|
||||
grep -Ei '\.(pem|key|crt)$' |
|
||||
while IFS= read -r candidate; do
|
||||
if grep -q 'PRIVATE KEY' "$candidate"; then
|
||||
printf '%s\n' "${candidate#"$root"/}"
|
||||
fi
|
||||
done)"
|
||||
[ -z "$key_material" ] ||
|
||||
fail "private key material found in: $(printf '%s' "$key_material" | tr '\n' ' ')"
|
||||
|
||||
# Contract with the shared feed (see openwrt-feed/docs/MEMBER_INTEGRATION.md):
|
||||
# this repository signs
|
||||
# and publishes only its own package. It must not host a feed URL, ship a trust
|
||||
# bootstrap of its own or assemble an index.
|
||||
[ "${OPENWRT_FEED_REPOSITORY:-}" = Nikitid/openwrt-feed ] ||
|
||||
fail 'OPENWRT_FEED_REPOSITORY must name the shared feed'
|
||||
|
||||
[ ! -e "$root/scripts/install-openwrt25.sh" ] ||
|
||||
fail 'the shared installer replaced scripts/install-openwrt25.sh; remove it'
|
||||
|
||||
if grep -rlE 'raw\.githubusercontent\.com/Nikitid/[A-Za-z0-9._-]+/apk-feed' \
|
||||
"$root/scripts" "$root/docs" "$root/.github" "$root"/*.md "$root"/*.env \
|
||||
2>/dev/null | grep -q .; then
|
||||
fail 'a retired per-application feed URL is still referenced'
|
||||
fi
|
||||
|
||||
# Contract rule: every package transaction names the packages it touches, so a
|
||||
# release never upgrades the whole router.
|
||||
blanket="$(tracked_files |
|
||||
grep -E '\.(sh|md|yml|env)$' |
|
||||
xargs awk '
|
||||
/(apk|opkg)[ \t]+upgrade/ {
|
||||
rest = $0
|
||||
sub(/.*(apk|opkg)[ \t]+upgrade/, "", rest)
|
||||
gsub(/[ \t]*--[a-zA-Z-]+/, "", rest)
|
||||
gsub(/[ \t]/, "", rest)
|
||||
if (rest == "" || rest ~ /^([;&|#]|\|\||&&)/)
|
||||
printf "%s:%d\n", FILENAME, FNR
|
||||
}
|
||||
' 2>/dev/null)"
|
||||
[ -z "$blanket" ] ||
|
||||
fail "package upgrade without an explicit package name: $(
|
||||
printf '%s' "$blanket" | tr '\n' ' ')"
|
||||
|
||||
printf 'APK trust configuration OK\n'
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Check the shell that actually lands on the router for constructs BusyBox does
|
||||
# not provide. The test suite runs on GNU coreutils, which masks these: sort -o,
|
||||
# base64, grep -P and find -printf all exist there and none exist on a stock
|
||||
# OpenWrt image.
|
||||
#
|
||||
# The scan runs over a staged package tree so it can never drift from what is
|
||||
# installed. When a busybox binary is available it also syntax-checks every
|
||||
# script with BusyBox ash itself.
|
||||
|
||||
set -eu
|
||||
|
||||
fail() {
|
||||
printf 'check-busybox-compat: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
stage="${1:?usage: check-busybox-compat.sh STAGE_DIR}"
|
||||
[ -d "$stage" ] || fail "stage directory not found: $stage"
|
||||
|
||||
# Every regular file in the stage whose first line is a POSIX sh shebang.
|
||||
shell_files() {
|
||||
find "$stage" -type f -print | sort | while IFS= read -r file; do
|
||||
case "$(head -n 1 "$file" 2>/dev/null)" in
|
||||
'#!/bin/sh' | '#!/bin/sh '* | '#!/bin/ash' | '#!/usr/bin/env sh') ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
printf '%s\n' "$file"
|
||||
done
|
||||
}
|
||||
|
||||
files="$(shell_files)"
|
||||
[ -n "$files" ] || fail 'no shell scripts found in the stage'
|
||||
|
||||
# Each entry is "regex<TAB>explanation".
|
||||
findings=''
|
||||
scan() {
|
||||
pattern="$1"
|
||||
message="$2"
|
||||
hits="$(printf '%s\n' "$files" |
|
||||
xargs grep -nE "$pattern" 2>/dev/null || true)"
|
||||
if [ -n "$hits" ]; then
|
||||
findings="$findings
|
||||
$message
|
||||
$(printf '%s' "$hits" | sed "s|^$stage/||")"
|
||||
fi
|
||||
}
|
||||
|
||||
# Absent from BusyBox, or present with different semantics.
|
||||
scan '\bsort\b[^|;&]*(-o[[:space:]]|--output)' \
|
||||
'sort -o is not supported by BusyBox sort; redirect to a temporary file'
|
||||
scan '\bbase64\b' \
|
||||
'base64 is not present on a stock OpenWrt image'
|
||||
scan '\bgrep\b[^|;&]*(-[a-zA-Z]*P|--perl-regexp)' \
|
||||
'grep -P is not supported by BusyBox grep'
|
||||
scan '\bfind\b[^|;&]*-printf' \
|
||||
'find -printf is not supported by BusyBox find'
|
||||
scan '\bgensub[[:space:]]*\(' \
|
||||
'gensub() is a GNU awk extension'
|
||||
scan '\bstat\b[^|;&]*--format' \
|
||||
'stat --format is GNU-only; BusyBox stat accepts -c'
|
||||
scan '\breadlink\b[^|;&]*--canonicalize' \
|
||||
'readlink --canonicalize is GNU-only; BusyBox accepts -f'
|
||||
|
||||
# Bash-only syntax that ash rejects outright.
|
||||
scan '\[\[' \
|
||||
'[[ ]] is a bash keyword; ash only has [ ]'
|
||||
scan 'set[[:space:]]+-o[[:space:]]+pipefail' \
|
||||
'set -o pipefail is not supported by ash'
|
||||
scan '\$\{[A-Za-z_][A-Za-z0-9_]*//' \
|
||||
'${var//pattern/replacement} is bash-only; use tr or sed'
|
||||
scan '\$\{[A-Za-z_][A-Za-z0-9_]*\^\^' \
|
||||
'${var^^} is bash-only'
|
||||
scan "\\\$'" \
|
||||
"\$'...' quoting is bash-only"
|
||||
scan '\becho[[:space:]]+-e\b' \
|
||||
'echo -e is not portable; use printf'
|
||||
scan '=\([[:space:]]*[^)]' \
|
||||
'arrays are bash-only'
|
||||
|
||||
if [ -n "$findings" ]; then
|
||||
printf 'BusyBox incompatibilities in the packaged shell:%s\n' "$findings" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A real parser is stricter than any pattern list, so use one when present.
|
||||
if command -v busybox >/dev/null 2>&1; then
|
||||
# The loop runs in a subshell, so it reports names and leaves failing to the
|
||||
# caller; parser diagnostics go to stderr on their own.
|
||||
rejected="$(printf '%s\n' "$files" | while IFS= read -r file; do
|
||||
busybox ash -n "$file" || printf '%s\n' "${file#"$stage"/}"
|
||||
done)"
|
||||
[ -z "$rejected" ] ||
|
||||
fail "BusyBox ash rejected: $(printf '%s' "$rejected" | tr '\n' ' ')"
|
||||
printf 'BusyBox compatibility OK (patterns and ash -n)\n'
|
||||
else
|
||||
printf 'BusyBox compatibility OK (patterns only; busybox not installed)\n'
|
||||
fi
|
||||
+115
@@ -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"
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||
. "$root/release.env"
|
||||
tag="${1:?usage: check-release-tag.sh TAG}"
|
||||
expected="v$PKG_VERSION"
|
||||
|
||||
[ "$tag" = "$expected" ] || {
|
||||
printf 'release tag mismatch: expected %s, got %s\n' "$expected" "$tag" >&2
|
||||
exit 1
|
||||
}
|
||||
printf 'release tag OK: %s\n' "$tag"
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
#!/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" ] || {
|
||||
printf 'package name mismatch: %s != %s\n' "$make_name" "$PKG_NAME" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$make_version" = "$PKG_VERSION" ] || {
|
||||
printf 'package version mismatch: %s != %s\n' "$make_version" "$PKG_VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$make_arch" = "$PKG_ARCH" ] || {
|
||||
printf 'package architecture mismatch: %s != %s\n' "$make_arch" "$PKG_ARCH" >&2
|
||||
exit 1
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#!/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 runtime scripts -type f -name '*.sh' -exec sh -n {} +
|
||||
find luci -type f -name '*.js' -exec node --check {} +
|
||||
node ./scripts/test-overview-ui.js
|
||||
PYTHONPYCACHEPREFIX="$root/build/pycache" \
|
||||
python3 -m py_compile scripts/pack-ipk.py scripts/po2lmo.py scripts/test-po2lmo.py
|
||||
PYTHONPYCACHEPREFIX="$root/build/pycache" \
|
||||
python3 ./scripts/test-po2lmo.py
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
for path in Path("luci").glob("*.json"):
|
||||
json.loads(path.read_text())
|
||||
print(f"json OK: {path}")
|
||||
PY
|
||||
./scripts/test-runtime.sh
|
||||
./scripts/build-ipk.sh
|
||||
# The staged tree is exactly what lands on the router, so check it rather than
|
||||
# the sources: the build host has GNU coreutils and the router does not.
|
||||
./scripts/check-busybox-compat.sh "${BUILD_DIR:-$root/build/ipk}/stage"
|
||||
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'
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a deterministic opkg package without requiring an OpenWrt SDK."""
|
||||
|
||||
import gzip
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
|
||||
EPOCH = 0
|
||||
|
||||
|
||||
def normalize(info):
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = "root"
|
||||
info.mtime = EPOCH
|
||||
return info
|
||||
|
||||
|
||||
def archive(members):
|
||||
raw = io.BytesIO()
|
||||
with tarfile.open(fileobj=raw, mode="w", format=tarfile.GNU_FORMAT) as tar:
|
||||
for archive_name, path in members:
|
||||
info = normalize(tar.gettarinfo(path, archive_name))
|
||||
if info.isreg():
|
||||
with open(path, "rb") as source:
|
||||
tar.addfile(info, source)
|
||||
else:
|
||||
tar.addfile(info)
|
||||
compressed = io.BytesIO()
|
||||
with gzip.GzipFile(fileobj=compressed, mode="wb", mtime=EPOCH) as output:
|
||||
output.write(raw.getvalue())
|
||||
return compressed.getvalue()
|
||||
|
||||
|
||||
def walk_payload(stage):
|
||||
result = []
|
||||
for root, directories, files in os.walk(stage):
|
||||
directories.sort()
|
||||
relative = os.path.relpath(root, stage)
|
||||
if relative == "CONTROL" or relative.startswith("CONTROL" + os.sep):
|
||||
continue
|
||||
if relative != ".":
|
||||
result.append(("./" + relative, root))
|
||||
for name in sorted(files):
|
||||
path = os.path.join(root, name)
|
||||
result.append(("./" + os.path.relpath(path, stage), path))
|
||||
return sorted(result)
|
||||
|
||||
|
||||
def field(path, name):
|
||||
with open(path, encoding="utf-8") as source:
|
||||
for line in source:
|
||||
if line.startswith(name + ":"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
raise SystemExit(f"missing {name} in {path}")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit("usage: pack-ipk.py STAGE OUTPUT_DIR")
|
||||
stage, output_dir = sys.argv[1:]
|
||||
control_dir = os.path.join(stage, "CONTROL")
|
||||
control_path = os.path.join(control_dir, "control")
|
||||
control_members = [("./", control_dir)]
|
||||
control_members.extend(
|
||||
("./" + name, os.path.join(control_dir, name))
|
||||
for name in sorted(os.listdir(control_dir))
|
||||
)
|
||||
parts = {
|
||||
"debian-binary": b"2.0\n",
|
||||
"control.tar.gz": archive(control_members),
|
||||
"data.tar.gz": archive(walk_payload(stage)),
|
||||
}
|
||||
package = field(control_path, "Package")
|
||||
version = field(control_path, "Version")
|
||||
architecture = field(control_path, "Architecture")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = os.path.join(
|
||||
output_dir, f"{package}_{version}_{architecture}.ipk"
|
||||
)
|
||||
raw = io.BytesIO()
|
||||
with tarfile.open(fileobj=raw, mode="w", format=tarfile.GNU_FORMAT) as tar:
|
||||
for name in ("debian-binary", "control.tar.gz", "data.tar.gz"):
|
||||
payload = parts[name]
|
||||
info = normalize(tarfile.TarInfo("./" + name))
|
||||
info.mode = 0o644
|
||||
info.size = len(payload)
|
||||
tar.addfile(info, io.BytesIO(payload))
|
||||
with gzip.GzipFile(output_path, "wb", mtime=EPOCH) as output:
|
||||
output.write(raw.getvalue())
|
||||
print(output_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile a gettext PO catalog into LuCI's LMO format.
|
||||
|
||||
This is a byte-for-byte reimplementation of po2lmo from luci-base. Both package
|
||||
build paths use it so the SDK-less IPK and the SDK-built APK ship identical
|
||||
catalogs and neither depends on the LuCI feed host tools being available.
|
||||
|
||||
The reference implementation is modules/luci-base/src/po2lmo.c; the reader that
|
||||
consumes the output is modules/luci-base/src/lib/lmo.c. scripts/test-po2lmo.py
|
||||
pins the behaviour with known-answer tests and can diff against the real
|
||||
po2lmo binary when one is available.
|
||||
"""
|
||||
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
MASK = 0xFFFFFFFF
|
||||
|
||||
|
||||
def sfh_hash(data):
|
||||
"""Paul Hsieh's SuperFastHash, seeded with the length as lmo.c does."""
|
||||
length = len(data)
|
||||
if length <= 0:
|
||||
return 0
|
||||
|
||||
def u16(offset):
|
||||
return data[offset] | (data[offset + 1] << 8)
|
||||
|
||||
def s8(offset):
|
||||
value = data[offset]
|
||||
return value - 256 if value > 127 else value
|
||||
|
||||
hash_value = length
|
||||
rem = length & 3
|
||||
offset = 0
|
||||
|
||||
for _ in range(length >> 2):
|
||||
hash_value = (hash_value + u16(offset)) & MASK
|
||||
tmp = ((u16(offset + 2) << 11) ^ hash_value) & MASK
|
||||
hash_value = ((hash_value << 16) ^ tmp) & MASK
|
||||
hash_value = (hash_value + (hash_value >> 11)) & MASK
|
||||
offset += 4
|
||||
|
||||
if rem == 3:
|
||||
hash_value = (hash_value + u16(offset)) & MASK
|
||||
hash_value = (hash_value ^ (hash_value << 16)) & MASK
|
||||
hash_value = (hash_value ^ ((s8(offset + 2) << 18) & MASK)) & MASK
|
||||
hash_value = (hash_value + (hash_value >> 11)) & MASK
|
||||
elif rem == 2:
|
||||
hash_value = (hash_value + u16(offset)) & MASK
|
||||
hash_value = (hash_value ^ (hash_value << 11)) & MASK
|
||||
hash_value = (hash_value + (hash_value >> 17)) & MASK
|
||||
elif rem == 1:
|
||||
hash_value = (hash_value + s8(offset)) & MASK
|
||||
hash_value = (hash_value ^ (hash_value << 10)) & MASK
|
||||
hash_value = (hash_value + (hash_value >> 1)) & MASK
|
||||
|
||||
hash_value = (hash_value ^ (hash_value << 3)) & MASK
|
||||
hash_value = (hash_value + (hash_value >> 5)) & MASK
|
||||
hash_value = (hash_value ^ (hash_value << 4)) & MASK
|
||||
hash_value = (hash_value + (hash_value >> 17)) & MASK
|
||||
hash_value = (hash_value ^ (hash_value << 25)) & MASK
|
||||
hash_value = (hash_value + (hash_value >> 6)) & MASK
|
||||
return hash_value
|
||||
|
||||
|
||||
def extract_string(line):
|
||||
"""Return the quoted payload of a PO line, or None when there is none.
|
||||
|
||||
Like the C original this only unescapes \\" and \\\\. Every other escape,
|
||||
\\n included, is kept verbatim as backslash plus character, which is what
|
||||
makes the PO header parse below work on literal "\\n" separators.
|
||||
"""
|
||||
if line.startswith("#"):
|
||||
return None
|
||||
|
||||
out = []
|
||||
escaped = False
|
||||
started = False
|
||||
|
||||
for char in line:
|
||||
if not started:
|
||||
if char == '"':
|
||||
started = True
|
||||
continue
|
||||
if escaped:
|
||||
if char not in ('"', "\\"):
|
||||
out.append("\\")
|
||||
out.append(char)
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char != '"':
|
||||
out.append(char)
|
||||
else:
|
||||
break
|
||||
|
||||
return "".join(out) if started else None
|
||||
|
||||
|
||||
class Message:
|
||||
"""One PO record, mirroring struct msg in po2lmo.c."""
|
||||
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.plural_num = -1
|
||||
self.ctxt = None
|
||||
self.msgid = None
|
||||
self.id_plural = None
|
||||
self.values = [None] * 10
|
||||
self.current = None
|
||||
|
||||
def append(self, text):
|
||||
if self.current is None:
|
||||
return
|
||||
field, index = self.current
|
||||
if field == "values":
|
||||
self.values[index] = (self.values[index] or "") + text
|
||||
else:
|
||||
setattr(self, field, (getattr(self, field) or "") + text)
|
||||
|
||||
|
||||
class Catalog:
|
||||
"""Accumulates the string blob and the index of an LMO archive."""
|
||||
|
||||
def __init__(self):
|
||||
self.blob = bytearray()
|
||||
self.entries = []
|
||||
|
||||
def _write(self, payload):
|
||||
offset = len(self.blob)
|
||||
self.blob += payload
|
||||
# Every blob is padded to a 4-byte boundary, as print() does in C.
|
||||
self.blob += b"\0" * ((4 - (len(payload) % 4)) % 4)
|
||||
return offset
|
||||
|
||||
def add(self, key, value, plural_count):
|
||||
key_id = sfh_hash(key)
|
||||
# Identical hashes mean the string is its own translation; the C tool
|
||||
# drops those entries so the reader falls back to the source string.
|
||||
if key_id == sfh_hash(value):
|
||||
return
|
||||
offset = self._write(value)
|
||||
self.entries.append((key_id, plural_count, offset, len(value)))
|
||||
|
||||
def add_plural_forms(self, formula):
|
||||
offset = self._write(formula)
|
||||
self.entries.append((0, 0, offset, len(formula)))
|
||||
|
||||
def serialize(self):
|
||||
if not self.blob:
|
||||
return None
|
||||
out = bytearray(self.blob)
|
||||
# Stable sort by key_id keeps the output deterministic where the C
|
||||
# qsort would leave the order of equal keys unspecified.
|
||||
for entry in sorted(self.entries, key=lambda item: item[0]):
|
||||
out += struct.pack(">IIII", *entry)
|
||||
out += struct.pack(">I", len(self.blob))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def add_message(catalog, msg):
|
||||
"""Emit one PO record, mirroring print_msg() in po2lmo.c."""
|
||||
if msg.msgid is not None and msg.values[0] is not None:
|
||||
for index in range(msg.plural_num + 1):
|
||||
value = msg.values[index]
|
||||
if value is None:
|
||||
continue
|
||||
if msg.ctxt is not None and msg.id_plural is not None:
|
||||
key = f"{msg.ctxt}\1{msg.msgid}\2{index}"
|
||||
elif msg.ctxt is not None:
|
||||
key = f"{msg.ctxt}\1{msg.msgid}"
|
||||
elif msg.id_plural is not None:
|
||||
key = f"{msg.msgid}\2{index}"
|
||||
else:
|
||||
key = msg.msgid
|
||||
catalog.add(
|
||||
key.encode(), value.encode(), msg.plural_num + 1
|
||||
)
|
||||
elif msg.values[0] is not None:
|
||||
# The header record carries the plural formula. Fields are separated by
|
||||
# literal "\n" two-character sequences left in place by extract_string,
|
||||
# and a formula on an unterminated last line is ignored upstream too.
|
||||
header = msg.values[0]
|
||||
for field in header.split("\\n")[:-1]:
|
||||
if field[:14].lower() == "plural-forms: ":
|
||||
catalog.add_plural_forms(field[14:].encode())
|
||||
break
|
||||
msg.reset()
|
||||
|
||||
|
||||
def compile_po(text):
|
||||
catalog = Catalog()
|
||||
msg = Message()
|
||||
|
||||
for line in text.splitlines() + [None]:
|
||||
eof = line is None
|
||||
|
||||
if not eof and line.startswith('msgctxt "'):
|
||||
if msg.msgid is not None or msg.values[0] is not None:
|
||||
add_message(catalog, msg)
|
||||
msg.ctxt = None
|
||||
msg.current = ("ctxt", 0)
|
||||
elif eof or line.startswith('msgid "'):
|
||||
if msg.msgid is not None or msg.values[0] is not None:
|
||||
add_message(catalog, msg)
|
||||
msg.msgid = None
|
||||
msg.current = ("msgid", 0)
|
||||
elif line.startswith('msgid_plural "'):
|
||||
msg.id_plural = None
|
||||
msg.current = ("id_plural", 0)
|
||||
elif line.startswith('msgstr "') or line.startswith("msgstr["):
|
||||
if line.startswith("msgstr["):
|
||||
msg.plural_num = int(line[7:].split("]")[0])
|
||||
else:
|
||||
msg.plural_num = 0
|
||||
if msg.plural_num >= 10:
|
||||
raise SystemExit("po2lmo: too many plural forms")
|
||||
msg.values[msg.plural_num] = None
|
||||
msg.current = ("values", msg.plural_num)
|
||||
|
||||
if eof:
|
||||
break
|
||||
|
||||
text_part = extract_string(line)
|
||||
if text_part:
|
||||
msg.append(text_part)
|
||||
|
||||
return catalog.serialize()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit("usage: po2lmo.py input.po output.lmo")
|
||||
|
||||
source, target = sys.argv[1:]
|
||||
with open(source, encoding="utf-8") as handle:
|
||||
payload = compile_po(handle.read())
|
||||
|
||||
# An empty catalog produces no file at all, matching the C tool.
|
||||
if payload is None:
|
||||
if os.path.exists(target):
|
||||
os.unlink(target)
|
||||
return
|
||||
|
||||
with open(target, "wb") as handle:
|
||||
handle.write(payload)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+99
@@ -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
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
#!/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/etc/config" \
|
||||
"$stage/etc/uci-defaults" \
|
||||
"$stage/usr/libexec" \
|
||||
"$stage/usr/share/luci/menu.d" \
|
||||
"$stage/usr/share/rpcd/acl.d" \
|
||||
"$stage/usr/share/licenses/$PKG_NAME" \
|
||||
"$stage/usr/lib/lua/luci/i18n" \
|
||||
"$stage/www/luci-static/resources/overview-manager" \
|
||||
"$stage/www/luci-static/resources/view/overview-manager" \
|
||||
"$stage/www/luci-static/resources/view/status/include"
|
||||
|
||||
install -m 600 "$root/openwrt/files/etc/config/overview-manager" \
|
||||
"$stage/etc/config/overview-manager"
|
||||
install -m 755 "$root/runtime/overview-manager.sh" \
|
||||
"$stage/usr/libexec/overview-manager"
|
||||
install -m 755 "$root/openwrt/files/etc/uci-defaults/luci-app-overview-manager" \
|
||||
"$stage/etc/uci-defaults/luci-app-overview-manager"
|
||||
install -m 644 "$root/luci/menu.json" \
|
||||
"$stage/usr/share/luci/menu.d/$PKG_NAME.json"
|
||||
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/overview-manager/shared.js"
|
||||
install -m 644 "$root/luci/settings.js" \
|
||||
"$stage/www/luci-static/resources/view/overview-manager/settings.js"
|
||||
install -m 644 "$root/luci/overview.js" \
|
||||
"$stage/www/luci-static/resources/view/status/include/00_overview-manager.js"
|
||||
install -m 644 "$root/LICENSE" \
|
||||
"$stage/usr/share/licenses/$PKG_NAME/LICENSE"
|
||||
|
||||
# The APK path compiles the same catalogs from the Makefile, so both packages
|
||||
# ship identical translations without needing the LuCI feed host tools.
|
||||
for po in "$root"/po/*/*.po; do
|
||||
[ -f "$po" ] || continue
|
||||
language="$(basename "$(dirname "$po")")"
|
||||
[ "$language" != templates ] || continue
|
||||
catalog="$stage/usr/lib/lua/luci/i18n/$(basename "$po" .po).$language.lmo"
|
||||
python3 "$root/scripts/po2lmo.py" "$po" "$catalog"
|
||||
# po2lmo.py honours the caller's umask; fix the mode so the packaged file
|
||||
# stays byte-identical across build hosts.
|
||||
chmod 644 "$catalog"
|
||||
done
|
||||
|
||||
cat >"$stage/CONTROL/control" <<EOF
|
||||
Package: $PKG_NAME
|
||||
Version: $PKG_VERSION
|
||||
Architecture: $PKG_ARCH
|
||||
Maintainer: nikitid
|
||||
Depends: luci-base, rpcd-mod-file
|
||||
Section: luci
|
||||
Priority: optional
|
||||
Description: Configure the order and visibility of Status Overview widgets.
|
||||
EOF
|
||||
|
||||
cat >"$stage/CONTROL/conffiles" <<'EOF'
|
||||
/etc/config/overview-manager
|
||||
EOF
|
||||
|
||||
cat >"$stage/CONTROL/postinst" <<'EOF'
|
||||
#!/bin/sh
|
||||
[ -n "${IPKG_INSTROOT:-}" ] && exit 0
|
||||
# This package defines its own postinst, so the default handler that drains
|
||||
# /etc/uci-defaults does not run. Apply the language registration here so a
|
||||
# runtime install takes effect without waiting for the next boot.
|
||||
if [ -x /etc/uci-defaults/luci-app-overview-manager ]; then
|
||||
/etc/uci-defaults/luci-app-overview-manager &&
|
||||
rm -f /etc/uci-defaults/luci-app-overview-manager
|
||||
fi
|
||||
rm -f /tmp/luci-indexcache
|
||||
rm -rf /tmp/luci-modulecache
|
||||
echo "Overview Manager installed. Open LuCI -> Status -> Overview Manager."
|
||||
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"
|
||||
@@ -0,0 +1,207 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
|
||||
let classMutations = 0;
|
||||
|
||||
function classList() {
|
||||
const values = new Set();
|
||||
return {
|
||||
add: value => { classMutations++; values.add(value); },
|
||||
remove: value => { classMutations++; values.delete(value); },
|
||||
contains: value => values.has(value),
|
||||
/* The real DOM only mutates when the state actually changes, which is
|
||||
* what keeps the toggle in overview.js from repainting on every poll. */
|
||||
toggle: (value, force) => {
|
||||
const wanted = force === undefined ? !values.has(value) : !!force;
|
||||
if (wanted === values.has(value))
|
||||
return wanted;
|
||||
classMutations++;
|
||||
if (wanted)
|
||||
values.add(value);
|
||||
else
|
||||
values.delete(value);
|
||||
return wanted;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function section() {
|
||||
const attributes = {};
|
||||
return {
|
||||
classList: classList(),
|
||||
parentNode: null,
|
||||
getAttribute: name => attributes[name] || null,
|
||||
setAttribute: (name, value) => { attributes[name] = value; }
|
||||
};
|
||||
}
|
||||
|
||||
function load(path, context) {
|
||||
const source = fs.readFileSync(path, 'utf8');
|
||||
return vm.runInNewContext(`(function() {${source}\n})()`, context);
|
||||
}
|
||||
|
||||
const common = load('luci/shared.js', {
|
||||
baseclass: { extend: value => value },
|
||||
E: (tag, attrs, children) => ({ tag, attrs, children })
|
||||
});
|
||||
|
||||
/* luci-mod-status requires its includes as module names, so the .js suffix is
|
||||
* not part of the sort. Sorting raw file names would invert this pair and
|
||||
* shift every widget onto the wrong section. */
|
||||
const sorted = [ '20_x-y.js', '20_x.js' ].sort(common.compareWidgets);
|
||||
if (JSON.stringify(sorted) !== JSON.stringify([ '20_x.js', '20_x-y.js' ]))
|
||||
throw new Error(`unexpected widget sort order: ${sorted.join(',')}`);
|
||||
|
||||
const names = [
|
||||
'00_overview-manager.js',
|
||||
'10_system.js',
|
||||
'20_memory.js',
|
||||
'70_custom.js'
|
||||
];
|
||||
const sections = names.map(() => section());
|
||||
sections.forEach(node => node.classList.add('cbi-section'));
|
||||
|
||||
let appendCalls = 0;
|
||||
const parent = {
|
||||
children: sections,
|
||||
appendChild(node) {
|
||||
appendCalls++;
|
||||
const index = this.children.indexOf(node);
|
||||
if (index >= 0)
|
||||
this.children.splice(index, 1);
|
||||
this.children.push(node);
|
||||
node.parentNode = this;
|
||||
}
|
||||
};
|
||||
sections.forEach(node => { node.parentNode = parent; });
|
||||
|
||||
let execCalls = [];
|
||||
const context = {
|
||||
baseclass: { extend: value => value },
|
||||
common: common,
|
||||
fs: {
|
||||
exec: (path, args) => {
|
||||
execCalls.push(args.join(' '));
|
||||
return Promise.resolve({ stdout: stdoutFor(args[0]) });
|
||||
}
|
||||
},
|
||||
L: { resolveDefault: value => value },
|
||||
Promise,
|
||||
window: {
|
||||
requestAnimationFrame: callback => callback(),
|
||||
setTimeout: callback => callback()
|
||||
},
|
||||
E(tag, attrs) {
|
||||
if (attrs && attrs['data-overview-manager-marker']) {
|
||||
return { closest: () => sections[0] };
|
||||
}
|
||||
return { tag, attrs };
|
||||
}
|
||||
};
|
||||
|
||||
let helperAvailable = true;
|
||||
|
||||
function stdoutFor(command) {
|
||||
if (!helperAvailable)
|
||||
return '';
|
||||
if (command === 'files')
|
||||
return `${names.join('\n')}\n`;
|
||||
return 'order=70_custom.js 10_system.js 20_memory.js\nhidden=20_memory.js\n';
|
||||
}
|
||||
|
||||
const widget = load('luci/overview.js', context);
|
||||
const data = [
|
||||
{ stdout: stdoutFor('files') },
|
||||
{ stdout: stdoutFor('layout') }
|
||||
];
|
||||
|
||||
widget.render(data);
|
||||
|
||||
function currentOrder() {
|
||||
return parent.children.map(node => node.getAttribute('data-overview-widget'));
|
||||
}
|
||||
|
||||
const expected = [
|
||||
'00_overview-manager.js',
|
||||
'70_custom.js',
|
||||
'10_system.js',
|
||||
'20_memory.js'
|
||||
];
|
||||
if (JSON.stringify(currentOrder()) !== JSON.stringify(expected))
|
||||
throw new Error(`unexpected initial order: ${currentOrder().join(',')}`);
|
||||
|
||||
const hidden = parent.children.filter(node =>
|
||||
node.classList.contains('overview-manager-hidden')
|
||||
).map(node => node.getAttribute('data-overview-widget'));
|
||||
if (JSON.stringify(hidden) !== JSON.stringify([
|
||||
'00_overview-manager.js',
|
||||
'20_memory.js'
|
||||
]))
|
||||
throw new Error(`unexpected hidden widgets: ${hidden.join(',')}`);
|
||||
|
||||
/* Regression test for the white flicker: a poll cycle that changes nothing
|
||||
* must not move any section or touch any class. */
|
||||
appendCalls = 0;
|
||||
classMutations = 0;
|
||||
widget.render(data);
|
||||
|
||||
if (JSON.stringify(currentOrder()) !== JSON.stringify(expected))
|
||||
throw new Error(`polling changed the order: ${currentOrder().join(',')}`);
|
||||
if (appendCalls !== 0)
|
||||
throw new Error(`polling re-appended ${appendCalls} sections`);
|
||||
if (classMutations !== 0)
|
||||
throw new Error(`polling caused ${classMutations} class mutations`);
|
||||
|
||||
/* A changed layout must still be applied. */
|
||||
widget.render([
|
||||
{ stdout: `${names.join('\n')}\n` },
|
||||
{ stdout: 'order=10_system.js 20_memory.js 70_custom.js\nhidden=\n' }
|
||||
]);
|
||||
const reordered = [
|
||||
'00_overview-manager.js',
|
||||
'10_system.js',
|
||||
'20_memory.js',
|
||||
'70_custom.js'
|
||||
];
|
||||
if (JSON.stringify(currentOrder()) !== JSON.stringify(reordered))
|
||||
throw new Error(`layout change was not applied: ${currentOrder().join(',')}`);
|
||||
if (parent.children.filter(node =>
|
||||
node.classList.contains('overview-manager-hidden')
|
||||
).length !== 1)
|
||||
throw new Error('unhiding a widget did not take effect');
|
||||
|
||||
/* The layout is read once per page load instead of spawning the helper twice
|
||||
* on every five-second poll cycle. */
|
||||
function testLoadCaching() {
|
||||
helperAvailable = false;
|
||||
execCalls = [];
|
||||
|
||||
return widget.load().then(() => {
|
||||
if (execCalls.length !== 2)
|
||||
throw new Error(`first load ran ${execCalls.length} helper calls`);
|
||||
return widget.load();
|
||||
}).then(() => {
|
||||
/* An unreachable helper must be retried, not cached as an empty list. */
|
||||
if (execCalls.length !== 4)
|
||||
throw new Error('a failed load was cached');
|
||||
helperAvailable = true;
|
||||
execCalls = [];
|
||||
return widget.load();
|
||||
}).then(() => {
|
||||
if (execCalls.length !== 2)
|
||||
throw new Error(`recovery load ran ${execCalls.length} helper calls`);
|
||||
return widget.load();
|
||||
}).then(() => {
|
||||
if (execCalls.length !== 2)
|
||||
throw new Error(`polling re-ran the helper: ${execCalls.join(', ')}`);
|
||||
});
|
||||
}
|
||||
|
||||
testLoadCaching().then(() => {
|
||||
console.log('overview UI tests OK');
|
||||
}).catch(error => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the LMO compiler and the translation catalogs.
|
||||
|
||||
The hash vectors are pinned against the upstream po2lmo binary built from
|
||||
luci-base. Set PO2LMO_REFERENCE to a real po2lmo executable to additionally
|
||||
diff the compiled output byte for byte.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from po2lmo import compile_po, sfh_hash # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
LANGUAGES = ["ru"]
|
||||
|
||||
# Verified against sfh_hash() from luci-base/src/lib/lmo.c. The browser side
|
||||
# computes the same values in sfh() from luci-base/htdocs/.../cbi.js.
|
||||
HASH_VECTORS = {
|
||||
"": 0x00000000,
|
||||
"a": 0x115EA782,
|
||||
"ab": 0x516B8B44,
|
||||
"abc": 0xD2BE198A,
|
||||
"abcd": 0xDAD8B8DB,
|
||||
"Show": 0x49D1A0A4,
|
||||
"Save layout": 0x18762B08,
|
||||
"Saving…": 0x7A938C83,
|
||||
"Переместить вверх": 0x669DABB0,
|
||||
"ctx\1key": 0x999A0ABE,
|
||||
}
|
||||
|
||||
|
||||
def fail(message):
|
||||
print(f"test-po2lmo: {message}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def check(condition, message):
|
||||
if not condition:
|
||||
fail(message)
|
||||
|
||||
|
||||
def read_lmo(payload):
|
||||
"""Decode an LMO archive the way lmo.c reads it: {key_id: value}."""
|
||||
(blob_size,) = struct.unpack(">I", payload[-4:])
|
||||
index = payload[blob_size:-4]
|
||||
check(len(index) % 16 == 0, "index size is not a multiple of the entry size")
|
||||
|
||||
catalog = {}
|
||||
previous = None
|
||||
for offset in range(0, len(index), 16):
|
||||
key_id, val_id, value_offset, length = struct.unpack(
|
||||
">IIII", index[offset:offset + 16]
|
||||
)
|
||||
check(
|
||||
previous is None or key_id >= previous,
|
||||
"index is not sorted by key_id",
|
||||
)
|
||||
previous = key_id
|
||||
check(
|
||||
value_offset + length <= blob_size,
|
||||
f"entry {key_id:08x} points outside the string blob",
|
||||
)
|
||||
catalog[key_id] = payload[value_offset:value_offset + length].decode()
|
||||
return catalog
|
||||
|
||||
|
||||
def trimws(value):
|
||||
"""The normalisation _() applies before hashing, from cbi.js."""
|
||||
return re.sub(r"[ \t\n]+", " ", value.strip())
|
||||
|
||||
|
||||
def parse_po(path):
|
||||
"""Return {msgid: msgstr} for the simple single-form records we ship."""
|
||||
entries = {}
|
||||
key = None
|
||||
field = None
|
||||
values = {"msgid": "", "msgstr": ""}
|
||||
|
||||
def flush():
|
||||
if key:
|
||||
entries[key] = values["msgstr"]
|
||||
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("#") or not line:
|
||||
continue
|
||||
match = re.match(r'^(msgid|msgstr)\s+"(.*)"$', line)
|
||||
if match:
|
||||
if match.group(1) == "msgid":
|
||||
flush()
|
||||
values = {"msgid": match.group(2), "msgstr": ""}
|
||||
field = "msgid"
|
||||
else:
|
||||
field = "msgstr"
|
||||
values["msgstr"] = match.group(2)
|
||||
key = values["msgid"] or None
|
||||
continue
|
||||
match = re.match(r'^"(.*)"$', line)
|
||||
if match and field:
|
||||
values[field] += match.group(1)
|
||||
if field == "msgid":
|
||||
key = values["msgid"] or None
|
||||
flush()
|
||||
return entries
|
||||
|
||||
|
||||
def source_strings():
|
||||
"""Every literal passed to _() in the shipped JavaScript."""
|
||||
found = set()
|
||||
for path in sorted((ROOT / "luci").glob("*.js")):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for match in re.finditer(r"\b_\(\s*'((?:[^'\\]|\\.)*)'", text):
|
||||
found.add(match.group(1).replace("\\'", "'").replace("\\\\", "\\"))
|
||||
return found
|
||||
|
||||
|
||||
def test_hash_vectors():
|
||||
for value, expected in HASH_VECTORS.items():
|
||||
actual = sfh_hash(value.encode())
|
||||
check(
|
||||
actual == expected,
|
||||
f"sfh_hash({value!r}) = {actual:08x}, expected {expected:08x}",
|
||||
)
|
||||
|
||||
|
||||
def test_empty_catalog():
|
||||
header = 'msgid ""\nmsgstr "Content-Type: text/plain; charset=UTF-8\\n"\n'
|
||||
check(
|
||||
compile_po(header) is None,
|
||||
"a catalog without translations must not produce a file",
|
||||
)
|
||||
|
||||
|
||||
def test_identical_translation_omitted():
|
||||
payload = compile_po('msgid "same"\nmsgstr "same"\n')
|
||||
check(
|
||||
payload is None,
|
||||
"a translation identical to its source must be omitted",
|
||||
)
|
||||
|
||||
|
||||
def test_plural_forms_recorded():
|
||||
payload = compile_po(
|
||||
'msgid ""\nmsgstr ""\n'
|
||||
'"Plural-Forms: nplurals=2; plural=(n != 1);\\n"\n'
|
||||
)
|
||||
check(payload is not None, "the plural formula must be compiled")
|
||||
catalog = read_lmo(payload)
|
||||
check(
|
||||
catalog.get(0) == "nplurals=2; plural=(n != 1);",
|
||||
f"unexpected plural formula entry: {catalog.get(0)!r}",
|
||||
)
|
||||
|
||||
|
||||
def test_catalogs_round_trip():
|
||||
"""Every translation must be reachable through the hash _() computes."""
|
||||
for language in LANGUAGES:
|
||||
path = ROOT / "po" / language / "overview-manager.po"
|
||||
entries = parse_po(path)
|
||||
check(bool(entries), f"{path} has no translations")
|
||||
|
||||
payload = compile_po(path.read_text(encoding="utf-8"))
|
||||
check(payload is not None, f"{path} compiled to an empty catalog")
|
||||
catalog = read_lmo(payload)
|
||||
|
||||
for msgid, msgstr in entries.items():
|
||||
check(
|
||||
msgid == trimws(msgid),
|
||||
f"{path}: msgid is not whitespace-normalised: {msgid!r}",
|
||||
)
|
||||
if msgid == msgstr:
|
||||
continue
|
||||
key = sfh_hash(trimws(msgid).encode())
|
||||
check(
|
||||
catalog.get(key) == msgstr,
|
||||
f"{path}: {msgid!r} resolves to {catalog.get(key)!r}, "
|
||||
f"expected {msgstr!r}",
|
||||
)
|
||||
|
||||
|
||||
def test_catalogs_cover_sources():
|
||||
used = source_strings()
|
||||
check(bool(used), "no _() strings were found in the sources")
|
||||
|
||||
template = parse_po(ROOT / "po" / "templates" / "overview-manager.pot")
|
||||
check(
|
||||
set(template) == used,
|
||||
"po/templates/overview-manager.pot is out of sync with the sources; "
|
||||
f"only in template: {sorted(set(template) - used)}; "
|
||||
f"only in sources: {sorted(used - set(template))}",
|
||||
)
|
||||
check(
|
||||
not any(template.values()),
|
||||
"the template must not carry translations",
|
||||
)
|
||||
|
||||
for language in LANGUAGES:
|
||||
path = ROOT / "po" / language / "overview-manager.po"
|
||||
entries = parse_po(path)
|
||||
check(
|
||||
set(entries) == used,
|
||||
f"{path} is out of sync with the sources; "
|
||||
f"only in catalog: {sorted(set(entries) - used)}; "
|
||||
f"only in sources: {sorted(used - set(entries))}",
|
||||
)
|
||||
for msgid, msgstr in entries.items():
|
||||
check(msgstr != "", f"{path}: {msgid!r} is untranslated")
|
||||
|
||||
|
||||
def test_languages_registered():
|
||||
"""Every shipped catalog needs a display name in the uci-defaults script.
|
||||
|
||||
LuCI only offers languages listed in luci.languages, so a catalog with no
|
||||
entry there can never be selected.
|
||||
"""
|
||||
script = (
|
||||
ROOT / "openwrt" / "files" / "etc" / "uci-defaults"
|
||||
/ "luci-app-overview-manager"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
for language in LANGUAGES:
|
||||
check(
|
||||
re.search(rf"^\s*{re.escape(language)}\)\s", script, re.MULTILINE),
|
||||
f"{language} has no display name in the uci-defaults script",
|
||||
)
|
||||
|
||||
|
||||
def test_reference_binary():
|
||||
reference = os.environ.get("PO2LMO_REFERENCE")
|
||||
if not reference:
|
||||
print("test-po2lmo: PO2LMO_REFERENCE not set, skipping oracle diff")
|
||||
return
|
||||
|
||||
for language in LANGUAGES:
|
||||
source = ROOT / "po" / language / "overview-manager.po"
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
expected = Path(directory) / "reference.lmo"
|
||||
subprocess.run(
|
||||
[reference, str(source), str(expected)], check=True
|
||||
)
|
||||
check(
|
||||
expected.read_bytes()
|
||||
== compile_po(source.read_text(encoding="utf-8")),
|
||||
f"{source}: output differs from {reference}",
|
||||
)
|
||||
print("test-po2lmo: output matches the reference po2lmo binary")
|
||||
|
||||
|
||||
def main():
|
||||
test_hash_vectors()
|
||||
test_empty_catalog()
|
||||
test_identical_translation_omitted()
|
||||
test_plural_forms_recorded()
|
||||
test_catalogs_round_trip()
|
||||
test_catalogs_cover_sources()
|
||||
test_languages_registered()
|
||||
test_reference_binary()
|
||||
print("po2lmo tests OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
|
||||
|
||||
mkdir -p "$tmp/includes"
|
||||
touch \
|
||||
"$tmp/includes/00_overview-manager.js" \
|
||||
"$tmp/includes/10_system.js" \
|
||||
"$tmp/includes/20_memory.js" \
|
||||
"$tmp/includes/70_custom.js"
|
||||
|
||||
cat >"$tmp/uci" <<'EOF'
|
||||
#!/bin/sh
|
||||
while [ "${1:-}" = -q ]; do shift; done
|
||||
case "${1:-}:${2:-}" in
|
||||
get:overview-manager.main.order)
|
||||
printf '%s\n' "${FAKE_ORDER:-}"
|
||||
;;
|
||||
get:overview-manager.main.hidden)
|
||||
printf '%s\n' "${FAKE_HIDDEN:-}"
|
||||
;;
|
||||
set:* | commit:*)
|
||||
printf '%s\n' "$*" >>"${FAKE_UCI_LOG:?}"
|
||||
;;
|
||||
*)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
chmod 755 "$tmp/uci"
|
||||
|
||||
run() {
|
||||
OVERVIEW_MANAGER_INCLUDE_DIR="$tmp/includes" \
|
||||
OVERVIEW_MANAGER_UCI="$tmp/uci" \
|
||||
FAKE_UCI_LOG="$tmp/uci.log" \
|
||||
FAKE_ORDER="${FAKE_ORDER:-}" \
|
||||
FAKE_HIDDEN="${FAKE_HIDDEN:-}" \
|
||||
"$root/runtime/overview-manager.sh" "$@"
|
||||
}
|
||||
|
||||
expected_files='00_overview-manager.js
|
||||
10_system.js
|
||||
20_memory.js
|
||||
70_custom.js'
|
||||
[ "$(run files)" = "$expected_files" ]
|
||||
|
||||
FAKE_ORDER='70_custom.js 10_system.js' \
|
||||
FAKE_HIDDEN='20_memory.js' \
|
||||
run layout >"$tmp/layout"
|
||||
grep -qx 'order=70_custom.js 10_system.js' "$tmp/layout"
|
||||
grep -qx 'hidden=20_memory.js' "$tmp/layout"
|
||||
|
||||
: >"$tmp/uci.log"
|
||||
run save \
|
||||
'70_custom.js,10_system.js,20_memory.js,70_custom.js' \
|
||||
'20_memory.js'
|
||||
grep -qx 'set overview-manager.main.order=70_custom.js 10_system.js 20_memory.js' \
|
||||
"$tmp/uci.log"
|
||||
grep -qx 'set overview-manager.main.hidden=20_memory.js' "$tmp/uci.log"
|
||||
grep -qx 'commit overview-manager' "$tmp/uci.log"
|
||||
|
||||
if run save '00_overview-manager.js' '' >/dev/null 2>&1; then
|
||||
printf 'self widget was accepted unexpectedly\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if run save '99_missing.js' '' >/dev/null 2>&1; then
|
||||
printf 'missing widget was accepted unexpectedly\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'runtime tests OK\n'
|
||||
Reference in new issue
Block a user