🤞 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

+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{js,json,md}]
indent_style = tab
indent_size = 4
[*.{sh,yml,yaml}]
indent_style = space
indent_size = 2
[Makefile]
indent_style = tab
+19
View File
@@ -0,0 +1,19 @@
* text=auto eol=lf
*.sh text eol=lf
*.js text eol=lf
*.json text eol=lf
*.md text eol=lf
Makefile text eol=lf
*.apk binary
*.ipk binary
*.bin binary
*.img binary
*.gz binary
*.zst binary
*.pem text eol=lf
# Shared scripts vendored from repo-templates: kept out of the language stats.
scripts/check-readme.sh linguist-vendored
scripts/release-notes.sh linguist-vendored
+12
View File
@@ -0,0 +1,12 @@
.DS_Store
build/
dist/
# Operational notes tied to a specific machine, router or account. Nothing here
# is published; docs/ holds everything that belongs in the repository.
local/
*.apk
*.ipk
*.key
*-private.pem
private-key.pem
signing/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Nikitid
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+98
View File
@@ -0,0 +1,98 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-overview-manager
PKG_VERSION:=0.1.0
PKG_RELEASE:=1
PKG_LICENSE:=MIT
PKG_MAINTAINER:=nikitid
PKGARCH:=all
include $(INCLUDE_DIR)/package.mk
define Package/luci-app-overview-manager
SECTION:=luci
CATEGORY:=LuCI
SUBMENU:=2. Modules
TITLE:=Status Overview widget manager
DEPENDS:=+luci-base +rpcd-mod-file
endef
define Package/luci-app-overview-manager/description
LuCI application for ordering and hiding Status Overview widgets without
modifying files owned by other packages.
endef
define Package/luci-app-overview-manager/conffiles
/etc/config/overview-manager
endef
LUCI_LANGUAGES:=$(sort $(filter-out templates,$(notdir $(wildcard ${CURDIR}/po/*))))
define Build/Compile
endef
define Package/luci-app-overview-manager/install
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./openwrt/files/etc/config/overview-manager $(1)/etc/config/overview-manager
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_BIN) ./openwrt/files/etc/uci-defaults/luci-app-overview-manager \
$(1)/etc/uci-defaults/luci-app-overview-manager
$(INSTALL_DIR) $(1)/usr/libexec
$(INSTALL_BIN) ./runtime/overview-manager.sh $(1)/usr/libexec/overview-manager
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./luci/menu.json $(1)/usr/share/luci/menu.d/luci-app-overview-manager.json
$(INSTALL_DATA) ./luci/acl.json $(1)/usr/share/rpcd/acl.d/luci-app-overview-manager.json
$(INSTALL_DIR) $(1)/www/luci-static/resources/overview-manager
$(INSTALL_DATA) ./luci/shared.js $(1)/www/luci-static/resources/overview-manager/shared.js
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/overview-manager
$(INSTALL_DATA) ./luci/settings.js $(1)/www/luci-static/resources/view/overview-manager/settings.js
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/status/include
$(INSTALL_DATA) ./luci/overview.js $(1)/www/luci-static/resources/view/status/include/00_overview-manager.js
$(INSTALL_DIR) $(1)/usr/share/licenses/luci-app-overview-manager
$(INSTALL_DATA) ./LICENSE $(1)/usr/share/licenses/luci-app-overview-manager/LICENSE
# scripts/po2lmo.py is a byte-compatible reimplementation of the luci-base
# po2lmo tool. Using it keeps this package independent of the LuCI feed
# host tools and produces the same catalogs as the SDK-less IPK build.
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/i18n
$(foreach language,$(LUCI_LANGUAGES), \
$(foreach po,$(wildcard ${CURDIR}/po/$(language)/*.po), \
python3 $(CURDIR)/scripts/po2lmo.py $(po) \
$(1)/usr/lib/lua/luci/i18n/$(basename $(notdir $(po))).$(language).lmo; \
chmod 0644 $(1)/usr/lib/lua/luci/i18n/$(basename $(notdir $(po))).$(language).lmo;))
endef
define Package/luci-app-overview-manager/postinst
#!/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
endef
define Package/luci-app-overview-manager/prerm
#!/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
endef
$(eval $(call BuildPackage,luci-app-overview-manager))
+117
View File
@@ -0,0 +1,117 @@
# Overview Manager for OpenWrt
[Русский](README.ru.md)
[![CI](https://github.com/Nikitid/luci-app-overview-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/Nikitid/luci-app-overview-manager/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/Nikitid/luci-app-overview-manager)](https://github.com/Nikitid/luci-app-overview-manager/releases/latest)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
The `luci-app-overview-manager` package is a LuCI application for arranging
the cards on the **Status -> Overview** page.
![Overview Manager widget list](docs/images/widgets.png)
## Features
- reorder widgets by dragging or with the up and down buttons;
- hide cards you do not need;
- works with third-party widgets installed as a LuCI status include;
- the layout is stored in UCI and shared by every browser.
## Requirements
- official OpenWrt `24.10.x` with `opkg`;
- official OpenWrt `25.12.x` with `apk`;
- the stock `luci-mod-status` Status Overview page;
- third-party widgets installed as a LuCI status include.
On OpenWrt 25.12 the stock Hide button keeps its state separately, in the
`localStorage` of one browser. It keeps working independently of the shared
Overview Manager layout.
## Installation
### OpenWrt 24.10
Download `luci-app-overview-manager_*_all.ipk` from
[Releases](https://github.com/Nikitid/luci-app-overview-manager/releases) and upload it
through **System -> Software -> Upload Package**.
### OpenWrt 25.12
```sh
wget -O /tmp/nikitid-feed.sh \
https://raw.githubusercontent.com/Nikitid/openwrt-feed/feed/install.sh
sh /tmp/nikitid-feed.sh luci-app-overview-manager
```
The installer verifies the publisher public key against a pinned checksum,
adds the shared signed repository and installs only the named package.
Later upgrades:
```sh
apk update
apk upgrade luci-app-overview-manager
```
Only Overview Manager is upgraded, never every package on the router.
## How it works
Stock LuCI loads the files in
`/www/luci-static/resources/view/status/include` in alphabetical order.
Overview Manager adds an early service include, matches the cards to their
source files and applies the layout stored in UCI to the DOM.
Files belonging to other packages are never modified, renamed or deleted.
Removing Overview Manager restores stock LuCI behaviour on its own.
## Translations
The interface uses the stock LuCI mechanism: `_()` in JavaScript and gettext
catalogues in `po/`. At build time `po/<lang>/overview-manager.po` is compiled
into `/usr/lib/lua/luci/i18n/overview-manager.<lang>.lmo` and ships in the main
package, and `/etc/uci-defaults/luci-app-overview-manager` registers the
language in `luci.languages`. The interface language follows the LuCI setting;
no separate `luci-i18n-*` package is needed.
The package translates only its own strings: a fully Russian LuCI also needs
`luci-i18n-base-ru`. A new language is added as a
`po/<lang>/overview-manager.po` catalogue; `scripts/test-po2lmo.py` checks that
the catalogues match the sources.
## Limitations
A LuCI status include has no common description of its internals: every widget
implements arbitrary `load()` and `render()`. Overview Manager therefore
arranges the order and visibility of cards, but does not edit their contents.
A hidden widget is not displayed, but its own data collection may still run on
the page poll.
## Development
```sh
./scripts/ci-check.sh
```
Building, signing and releasing: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
## Documentation
- [Repository map](docs/MAP.md) - where things live
- [Architecture](docs/ARCHITECTURE.md) - why the page is built this way
- [Development](docs/DEVELOPMENT.md) - building, signing and releasing
## Support
Questions and bug reports go to
[Issues](https://github.com/Nikitid/luci-app-overview-manager/issues/new/choose): pick the form that
fits. Report a vulnerability privately through
[a security advisory](https://github.com/Nikitid/luci-app-overview-manager/security/advisories/new).
English or Russian is fine.
## License
[MIT](LICENSE)
+119
View File
@@ -0,0 +1,119 @@
# Overview Manager для OpenWrt
[English](README.md)
[![CI](https://github.com/Nikitid/luci-app-overview-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/Nikitid/luci-app-overview-manager/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/Nikitid/luci-app-overview-manager)](https://github.com/Nikitid/luci-app-overview-manager/releases/latest)
[![Лицензия: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
Пакет `luci-app-overview-manager` - LuCI-приложение для настройки карточек на
странице **Status -> Overview**.
![Список виджетов в Overview Manager](docs/images/widgets.png)
## Возможности
- порядок виджетов меняется перетаскиванием или кнопками "выше" и "ниже";
- ненужные карточки скрываются полностью;
- работает со сторонними виджетами, установленными как LuCI status include;
- раскладка хранится в UCI и общая для всех браузеров.
## Требования
- официальный OpenWrt `24.10.x` с `opkg`;
- официальный OpenWrt `25.12.x` с `apk`;
- стандартная страница `luci-mod-status` Status Overview;
- сторонние виджеты, установленные как LuCI status include.
В OpenWrt 25.12 штатная кнопка Hide хранит состояние отдельно в
`localStorage` конкретного браузера. Она продолжает работать независимо от
общей раскладки Overview Manager.
## Установка
### OpenWrt 24.10
Скачайте `luci-app-overview-manager_*_all.ipk` из
[Releases](https://github.com/Nikitid/luci-app-overview-manager/releases) и загрузите его
через **System -> Software -> Upload Package**.
### OpenWrt 25.12
```sh
wget -O /tmp/nikitid-feed.sh \
https://raw.githubusercontent.com/Nikitid/openwrt-feed/feed/install.sh
sh /tmp/nikitid-feed.sh luci-app-overview-manager
```
Установщик проверяет публичный ключ издателя по закреплённой контрольной сумме,
подключает общий подписанный репозиторий приложений и ставит только указанный
пакет.
Последующие обновления:
```sh
apk update
apk upgrade luci-app-overview-manager
```
Обновляется только Overview Manager, а не все пакеты роутера.
## Как это работает
Штатный LuCI загружает файлы из
`/www/luci-static/resources/view/status/include` в алфавитном порядке.
Overview Manager добавляет ранний служебный include, сопоставляет карточки с
исходными файлами и применяет сохранённую в UCI раскладку к DOM.
Файлы других пакетов не изменяются, не переименовываются и не удаляются.
После удаления Overview Manager штатное поведение LuCI восстанавливается
автоматически.
## Переводы
Интерфейс использует штатный механизм LuCI: `_()` в JavaScript и каталоги
gettext в `po/`. При сборке `po/<язык>/overview-manager.po` компилируется в
`/usr/lib/lua/luci/i18n/overview-manager.<язык>.lmo` и попадает в основной
пакет, а `/etc/uci-defaults/luci-app-overview-manager` регистрирует язык в
`luci.languages`. Язык интерфейса следует настройке LuCI, отдельный пакет
`luci-i18n-*` не требуется.
Пакет переводит только свои строки: для полностью русского LuCI нужен ещё
`luci-i18n-base-ru`. Новый язык добавляется каталогом
`po/<язык>/overview-manager.po`; соответствие каталогов исходникам проверяет
`scripts/test-po2lmo.py`.
## Ограничения
У LuCI status include нет общего описания внутренних полей: каждый виджет
реализует произвольные `load()` и `render()`. Поэтому Overview Manager
настраивает порядок и видимость карточек, но не редактирует их содержимое.
Скрытый виджет не отображается, однако его штатный сбор данных может
продолжать выполняться при опросе страницы.
## Разработка
```sh
./scripts/ci-check.sh
```
Сборка, подпись и выпуск: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
## Документация
- [Карта репозитория](docs/MAP.md) - где что лежит
- [Архитектура](docs/ARCHITECTURE.md) - почему страница устроена именно так
- [Разработка](docs/DEVELOPMENT.md) - сборка, подпись и выпуск
## Поддержка
Вопросы и сообщения об ошибках - в
[Issues](https://github.com/Nikitid/luci-app-overview-manager/issues/new/choose), выберите
подходящую форму. Об уязвимости сообщайте приватно через
[security advisory](https://github.com/Nikitid/luci-app-overview-manager/security/advisories/new).
Можно писать по-русски или по-английски.
## Лицензия
[MIT](LICENSE)
+23
View File
@@ -0,0 +1,23 @@
# Public OpenWrt 25.12 build and feed identity. This file is sourced as POSIX sh.
# Target this package is built for.
OPENWRT_APK_VERSION=25.12.5
OPENWRT_APK_TARGET=mediatek/filogic
OPENWRT_APK_ARCH=aarch64_cortex-a53
OPENWRT_APK_SDK_ARCHIVE=openwrt-sdk-25.12.5-mediatek-filogic_gcc-14.3.0_musl.Linux-x86_64.tar.zst
OPENWRT_APK_SDK_SHA256=ff4a38a397caa2cfe1c39e18f84ddede14878221b3593c3f2c4cfe24e3ec4c25
# Shared publisher key. The same key signs every Nikitid OpenWrt application and
# the shared index; apk binds a key to neither a package nor a repository, so a
# per-application key would only add another trust anchor to every router.
OPENWRT_APK_KEY_FILE=keys/nikitid-openwrt-release.pem
OPENWRT_APK_TRUST_SHA256=f27474d9261f1084350cf4ba34ecdff29e533769c36483d8dd85566e30a6a703
# This repository builds and signs its own package and publishes it as a GitHub
# Release asset. It neither assembles an index nor hosts a feed URL:
# Nikitid/openwrt-feed collects the current release of every member
# application, verifies the signatures and publishes the signed index. Keeping
# the feed out of an application repository means renaming or retiring this
# project no longer moves a URL recorded in /etc/apk/repositories.d on every
# installed router.
OPENWRT_FEED_REPOSITORY=Nikitid/openwrt-feed
@@ -0,0 +1,212 @@
# Implementation notes
Load-bearing facts about LuCI and OpenWrt that this package depends on, why the
code is shaped the way it is, and how to re-verify each claim. None of it is
derivable from this repository alone: the upstream behaviour lives in
`luci-base`, `luci-mod-status` and the active theme.
Upstream references are paths inside `openwrt/luci`.
## The Overview page rebuilds itself every poll cycle
`luci-mod-status/htdocs/luci-static/resources/view/status/index.js` builds one
`.cbi-section` per include **once**, in `render()`, and then on every poll cycle
calls each include's `load()` and `render()` again and replaces the section
*content* through `dom.content`. The interval is `luci.main.pollinterval`,
default 5 seconds.
Two consequences drive the design of `luci/overview.js`:
1. **Never move a section unless the order actually changed.** Re-appending a
node re-inserts it, and re-insertion restarts its CSS animations. The theme
puts `fade-in` on every revealed section — `.fade-in { animation: fade-in
.4s ease }` with `opacity` `0 → 1` in
`themes/luci-theme-bootstrap/htdocs/luci-static/bootstrap/cascade.css` — so
re-appending all sections on each cycle makes the whole page flash every
five seconds. `applyLayout()` therefore compares the wanted order against
the live DOM order and touches nothing when they match.
2. **Never clear a visibility class and re-add it.** Dropping
`overview-manager-hidden` from every section and then re-adding it exposes
every hidden widget for a frame. Use `classList.toggle(name, force)`, which
only mutates when the state really changes.
`scripts/test-overview-ui.js` pins both: it counts `appendChild` calls and class
mutations across a second identical render and fails if either is non-zero.
Reverting either property makes it fail, which is worth re-checking if the test
is ever refactored.
`dom.content` (in `luci.js`) removes all children and appends the new ones in
one synchronous task, so replacing our own container each cycle cannot paint an
intermediate state. Only node *moves* and class churn are visible.
`load()` caches its result for the lifetime of the page. The saved layout cannot
change while the Overview page stays open, and the alternative is two helper
process spawns every five seconds forever. A failed read is deliberately not
cached, so a transient rpcd error retries on the next cycle instead of pinning
an empty widget list for the session.
## Widget-to-section mapping is positional and order-sensitive
There is no attribute tying a rendered section to the include that produced it,
so the mapping is by index: the *n*-th `.cbi-section` belongs to the *n*-th
include. `data-overview-widget` is stamped on first use and reused afterwards.
`index.js` requires its includes as `view.status.include.<name>` and sorts
**those module names**, so the `.js` suffix takes no part in the ordering. A
file-name sort is not the same sort: `-` is 0x2D and `.` is 0x2E, so plain file
names order `20_x-y.js` before `20_x.js`, while module names order `20_x` before
`20_x-y`. One such pair shifts every later widget onto the wrong section, and
the visible effect is that the wrong card is hidden or moved.
`common.compareWidgets` exists for exactly this and must stay code-unit based.
`localeCompare` would reorder per browser locale and reintroduce the bug in a
locale-dependent way.
Two further constraints follow from the same mechanism:
- The include count must equal the section count, or `applyLayout()` gives up
after eight retries and silently does nothing. `index.js` filters
`fs.list` entries by `type == 'file'`, so an include installed as a symlink
would be skipped there while the shell helper's `[ -f ]` follows it and counts
it. Nothing in the tree creates such a symlink today; if the layout ever stops
applying on a specific device, check this first.
- `title` must be non-empty and stable. `index.js` uses the include title as the
`localStorage` key for its own per-browser Hide button (`includes[i].id =
title`), so an empty title claims the empty key and collides with any other
untitled include. It is deliberately not wrapped in `_()`: a translated title
would change that key whenever the interface language changes.
## Client-side translation
The chain is not obvious and is easy to get subtly wrong.
- The global `_()` is defined in `luci-base/htdocs/luci-static/resources/cbi.js`,
not in `luci.js`. It is `TR[sfh(trimws(s))] || s`.
- `window.TR` is emitted by `admin/translations/<lang>`
(`luci-base/ucode/controller/admin/index.uc`), which the theme header loads on
every page, including the login page. It is a flat map of eight-hex-digit
hashes to strings, merged from **every** `*.<lang>.lmo` in
`/usr/lib/lua/luci/i18n`.
- The lookup key is the hash of the *whitespace-normalised* msgid: `trimws` in
`cbi.js` trims and collapses runs of space, tab and newline. `po2lmo` hashes
the **raw** msgid from the catalog. **Therefore msgids must already be
normalised** — one line, single spaces, no leading or trailing whitespace — or
the runtime lookup misses and the string silently stays English.
- Because all catalogs share one namespace in an unspecified merge order, a
string that `luci-base` also translates must either be left out or given the
identical translation. `Show`, `Move up` and `Move down` are in that category
and use the exact `luci-base` wording so the collision cannot matter.
`scripts/test-po2lmo.py` enforces the normalisation rule, the catalog-to-source
correspondence in both directions, and round-trips every translation through the
real hash and a minimal LMO reader.
### Registering the language is mandatory
Shipping a catalog is not enough. `determine_request_language()` in
`luci-base/ucode/dispatcher.uc` only accepts a language that exists in
`luci.languages`; with `luci.main.lang=auto` and no matching entry it falls
through to `en`. A router with no `luci-i18n-*` package installed has an empty
`luci.languages`, so the catalog would never be selected.
`openwrt/files/etc/uci-defaults/luci-app-overview-manager` adds the entry, the
same way `luci.mk` generates it for stock `luci-i18n-*` packages. Because this
package defines its own `postinst`, the default handler that drains
`/etc/uci-defaults` does not run, so `postinst` invokes the script itself and
removes it on success; otherwise the language would only appear after the next
reboot.
Verify on a device:
```sh
wget -qO- 'http://127.0.0.1/cgi-bin/luci/admin/translations/ru' | head -c 400
uci show luci.languages
```
and that content negotiation resolves as expected:
```sh
curl -s -H 'Accept-Language: ru' http://<router>/cgi-bin/luci/ | grep -o '<html lang="[^"]*"'
```
### `scripts/po2lmo.py` must stay byte-compatible
It reimplements `luci-base/src/po2lmo.c`. Format details that are easy to lose:
values are written first, each padded to a four-byte boundary; the index of
16-byte big-endian records follows, sorted by key hash; the file ends with the
total size of the value blob. The `val_id` field does not hold a hash, it holds
the plural count. Entries whose key and value hash identically are omitted so
the reader falls back to the source string. `extract_string` unescapes only `\"`
and `\\` — `\n` stays two literal characters, which is what makes the header's
`Plural-Forms` parsing work. An empty catalog produces no file at all.
To re-verify after any change, build the reference tool and diff its output. The
reader needs a generated parser header that is not required for writing, so an
empty stub is enough:
```sh
git clone --depth 1 --filter=blob:none --no-checkout https://github.com/openwrt/luci
cd luci && git sparse-checkout set modules/luci-base && git checkout master
cd .. && mkdir -p oracle/lib
cp luci/modules/luci-base/src/po2lmo.c oracle/
sed -n '1,72p' luci/modules/luci-base/src/lib/lmo.c > oracle/lib/sfh.c
cp luci/modules/luci-base/src/lib/lmo.h oracle/lib/
: > oracle/lib/plural_formula.h
cc -O1 -Ioracle/lib -o oracle/po2lmo oracle/po2lmo.c oracle/lib/sfh.c
PO2LMO_REFERENCE=$PWD/oracle/po2lmo python3 scripts/test-po2lmo.py
```
The same binary can be diffed against every catalog in `luci-base/po/*/*.po` as
a broader check; all of them currently match byte for byte.
## Static resources are cached without a cache-buster
`L.require` builds module URLs as
`<base>/<path>.js` plus `?v=<env.resource_version>` **only when that field is
set**, and the OpenWrt 25.12 page template does not set it — it versions
`luci.js` alone. uhttpd serves the files with `ETag` and `Last-Modified` but no
`Cache-Control`, so browsers apply heuristic freshness and can reuse a stale
copy for a long time.
Practical effect: after changing anything under
`/www/luci-static/resources/`, a normal reload may keep running the old code. A
reload that bypasses the cache is required — in Safari that is
`Cmd+Option+R` ("Reload Page From Origin"); `Cmd+Shift+R` does nothing there.
Confirm which code is live by reading the section titles, since a stale include
renders its section heading differently:
```js
[...document.querySelectorAll('.cbi-section')].map(s => s.querySelector('h3')?.textContent.trim())
```
## Shell that ships to the router runs under BusyBox
The test suite runs on GNU coreutils, which silently accepts constructs a stock
OpenWrt image does not have: `sort -o`, `base64`, `grep -P` and `find -printf`
among them. `scripts/check-busybox-compat.sh` scans the **staged package tree**
rather than the sources, so the checked set cannot drift from what is installed;
it also runs `busybox ash -n` on each script when a `busybox` binary is present,
which no pattern list can replace.
Four files reach the router: `CONTROL/postinst`, `CONTROL/prerm`,
`etc/uci-defaults/luci-app-overview-manager` and `usr/libexec/overview-manager`.
## Working against a real device
- OpenWrt has no `sftp-server`, so `scp` fails. Stream instead:
`ssh <host> 'cat > /path' < localfile`.
- `apk-tools` on the device has no `mkpkg`, and OpenWrt 25.12 cannot install an
`.ipk`. Building a package needs the SDK; for a quick check, deploy the payload
files and compare checksums.
- ACL changes need `/etc/init.d/rpcd reload`, which drops active LuCI sessions.
- Name the package in every transaction. A blanket upgrade is never appropriate
on a live router; `scripts/check-apk-trust.sh` fails the build if one appears
in a script or in the documentation.
- Verify a published package with the device's own `apk` and the tracked public
key, and confirm it is rejected without it:
```sh
apk verify --keys-dir <dir-with-public-key> <package>.apk
```
@@ -0,0 +1,38 @@
# Development
## Checks
```sh
./scripts/ci-check.sh
```
## Building
The IPK is written to `dist/`. The APK is built with the official OpenWrt 25.12
SDK:
```sh
OPENWRT_SDK_DIR=/path/to/openwrt-sdk-25.12.x-target \
./scripts/build-apk.sh
```
A release APK is signed with the shared publisher key:
```sh
OPENWRT_SDK_DIR=/path/to/openwrt-sdk-25.12.x-target \
OPENWRT_APK_SIGNING_KEY=/secure/path/release-private.pem \
./scripts/build-apk-release.sh
```
The private half of the publisher key is not stored in any repository. This
repository builds and signs only its own package and publishes it as a release
asset; the index is assembled by
[Nikitid/openwrt-feed](https://github.com/Nikitid/openwrt-feed). See
[Joining the shared feed](https://github.com/Nikitid/openwrt-feed/blob/main/docs/MEMBER_INTEGRATION.md).
## Release
Push a `vX.Y.Z` tag matching the version in `release.env`. The release workflow
checks the tag, runs `scripts/ci-check.sh`, builds and signs the APK with the
pinned OpenWrt SDK, and publishes the release with notes made by
`scripts/release-notes.sh` from the commit subjects since the previous tag.
+34
View File
@@ -0,0 +1,34 @@
# Repository map
Where things live, so a task starts at the right file. Nothing here is large
enough to need a generated function index; the biggest source file is 254
lines, so reading one whole is cheap.
## The shape of it
One OpenWrt package: a LuCI application that customises the Overview page -
which sections appear, in what order, and what each one shows.
| area | files |
| --- | --- |
| pages | `luci/overview.js`, `luci/settings.js` |
| shared design system and dictionary | `luci/shared.js` |
| wiring | `luci/menu.json`, `luci/acl.json` |
| packaged defaults | `openwrt/files/etc/config/overview-manager` |
| translations | `po/` plus `scripts/po2lmo.py` |
| build and release | `Makefile`, `apk-feed.env`, `scripts/` |
## Checks
`scripts/` holds the version-sync, release-tag and translation checks plus
`test-po2lmo.py`. Run them before completion.
## Documentation
| file | for |
| --- | --- |
| `docs/MAP.md` | this file |
| `docs/DEVELOPMENT.md` | building, signing and releasing |
| `docs/ARCHITECTURE.md` | why the page is built the way it is |
| `README.md` | user-facing, English |
| `README.ru.md` | the Russian version |
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+15
View File
@@ -0,0 +1,15 @@
# OpenWrt APK release key
`nikitid-openwrt-release.pem` is the shared public P-256 key for the author's
OpenWrt 25.12 application feed. It is byte-for-byte identical to the existing
IKEv2 Manager release public key.
The matching private key is not stored in this repository. Release builds read
it from the protected `OPENWRT_APK_SIGNING_KEY` CI secret. Compromise of that
private key would affect every package in the shared feed.
Public-key SHA-256:
```text
f27474d9261f1084350cf4ba34ecdff29e533769c36483d8dd85566e30a6a703
```
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE4Kze8kPGZESezGw8NMN4gDMgaevH
+Y9IgZbqWeHpW0gCNXNpctIlS1LSlQraDRwRTJlbg0R+uCGyv0/QnduYkg==
-----END PUBLIC KEY-----
+22
View File
@@ -0,0 +1,22 @@
{
"luci-app-overview-manager": {
"description": "Configure the Status Overview widget layout",
"read": {
"file": {
"/usr/libexec/overview-manager files": [ "exec" ],
"/usr/libexec/overview-manager layout": [ "exec" ]
},
"ubus": {
"file": [ "exec" ]
}
},
"write": {
"file": {
"/usr/libexec/overview-manager save *": [ "exec" ]
},
"ubus": {
"file": [ "exec" ]
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"admin/status/overview-manager": {
"title": "Overview Manager",
"order": 2,
"action": {
"type": "view",
"path": "overview-manager/settings"
},
"depends": {
"acl": [ "luci-app-overview-manager" ]
}
}
}
+138
View File
@@ -0,0 +1,138 @@
'use strict';
'require baseclass';
'require fs';
'require overview-manager.shared as common';
var helper = '/usr/libexec/overview-manager';
var self = '00_overview-manager.js';
/* luci-mod-status builds the widget sections once and only replaces their
* content on later poll cycles, and the saved layout cannot change while the
* Overview page stays loaded. Reading the layout once per page load keeps the
* poll cycle free of two extra helper invocations every five seconds. */
var cached = null;
function installedFiles(text) {
return (text || '').replace(/\r/g, '').split('\n').map(function(name) {
return name.trim();
}).filter(function(name) {
return /\.js$/.test(name);
}).sort(common.compareWidgets);
}
function orderedFiles(files, configured) {
var known = {};
files.forEach(function(name) { known[name] = true; });
var result = [ self ];
(configured || []).forEach(function(name) {
if (name !== self && known[name] && result.indexOf(name) < 0)
result.push(name);
});
files.forEach(function(name) {
if (result.indexOf(name) < 0)
result.push(name);
});
return result;
}
function applyLayout(marker, files, layout, attempts) {
var ownSection = marker.closest('.cbi-section');
var parent = ownSection && ownSection.parentNode;
var sections = parent ? Array.prototype.filter.call(parent.children, function(node) {
return node.classList && node.classList.contains('cbi-section');
}) : [];
if (!parent || sections.length !== files.length) {
if (attempts < 8)
window.setTimeout(function() {
applyLayout(marker, files, layout, attempts + 1);
}, 25);
return;
}
var byName = {};
var mapped = sections.every(function(node) {
var name = node.getAttribute('data-overview-widget');
return name && files.indexOf(name) >= 0 && !byName[name] &&
(byName[name] = node);
});
if (!mapped) {
byName = {};
files.forEach(function(name, index) {
byName[name] = sections[index];
sections[index].setAttribute('data-overview-widget', name);
});
}
var wanted = orderedFiles(files, layout.order);
var moved = wanted.some(function(name, index) {
return sections[index] !== byName[name];
});
/* Re-appending every section on each poll is what makes the page flicker,
* so only touch the DOM when the effective order really changed. */
if (moved)
wanted.forEach(function(name) {
if (byName[name])
parent.appendChild(byName[name]);
});
var hidden = {};
layout.hidden.forEach(function(name) { hidden[name] = true; });
/* Toggle in place. Dropping the class first and re-adding it would reveal
* every hidden widget for one frame on each poll cycle. */
files.forEach(function(name) {
if (byName[name])
byName[name].classList.toggle('overview-manager-hidden',
name === self || hidden[name] === true);
});
}
return baseclass.extend({
/* luci-mod-status uses the title as the localStorage key for its own Hide
* button, so an empty one would claim the empty key and collide with any
* other untitled include. This section is hidden by the stylesheet below
* and never displays the title. It stays untranslated on purpose: a
* translated title would change the key whenever the language changes. */
title: 'Overview Manager',
load: function() {
if (cached)
return Promise.resolve(cached);
return Promise.all([
L.resolveDefault(fs.exec(helper, [ 'files' ]), { stdout: '' }),
L.resolveDefault(fs.exec(helper, [ 'layout' ]), { stdout: '' })
]).then(function(data) {
/* Keep retrying on the next poll when the helper was unreachable
* instead of caching an empty widget list for the whole session. */
if (data[0].stdout)
cached = data;
return data;
});
},
render: function(data) {
var files = installedFiles(data[0].stdout);
var layout = common.parseLayout(data[1].stdout);
var marker = E('span', {
'data-overview-manager-marker': '1',
'style': 'display:none'
});
window.requestAnimationFrame(function() {
window.requestAnimationFrame(function() {
applyLayout(marker, files, layout, 0);
});
});
return E('div', {}, [
E('style', {}, [
'.overview-manager-hidden{display:none!important}'
]),
marker
]);
}
});
+253
View File
@@ -0,0 +1,253 @@
'use strict';
'require view';
'require fs';
'require ui';
'require overview-manager.shared as common';
var helper = '/usr/libexec/overview-manager';
var self = '00_overview-manager.js';
function installedFiles(text) {
return (text || '').replace(/\r/g, '').split('\n').map(function(name) {
return name.trim();
}).filter(function(name) {
return /\.js$/.test(name) && name !== self;
}).sort(common.compareWidgets);
}
function fallbackTitle(name) {
return name.replace(/^\d+_/, '').replace(/\.js$/, '').replace(/[-_]+/g, ' ')
.replace(/(^|\s)(.)/g, function(match, space, letter) {
return space + letter.toUpperCase();
});
}
function widgetTitle(name) {
var moduleName = 'view.status.include.' + name.replace(/\.js$/, '');
return L.resolveDefault(L.require(moduleName), null).then(function(widget) {
return widget && widget.title != null && widget.title !== '' ?
String(widget.title) : fallbackTitle(name);
});
}
function arrange(files, layout) {
var known = {};
files.forEach(function(name) { known[name] = true; });
var order = [];
layout.order.forEach(function(name) {
if (known[name] && order.indexOf(name) < 0)
order.push(name);
});
files.forEach(function(name) {
if (order.indexOf(name) < 0)
order.push(name);
});
return order;
}
return view.extend({
load: function() {
return Promise.all([
L.resolveDefault(fs.exec(helper, [ 'files' ]), { stdout: '' }),
L.resolveDefault(fs.exec(helper, [ 'layout' ]), { stdout: '' })
]).then(function(data) {
var files = installedFiles(data[0].stdout);
var layout = common.parseLayout(data[1].stdout);
return Promise.all(files.map(widgetTitle)).then(function(titles) {
return arrange(files, layout).map(function(name) {
var index = files.indexOf(name);
return {
name: name,
title: titles[index],
visible: layout.hidden.indexOf(name) < 0
};
});
});
});
},
render: function(items) {
var list = E('div', { 'class': 'overview-manager-list' });
var result = E('span', { 'class': 'overview-manager-result' });
var saveButton;
var dragging = null;
function move(name, offset) {
var index = items.findIndex(function(item) { return item.name === name; });
var target = index + offset;
if (index < 0 || target < 0 || target >= items.length)
return;
var item = items.splice(index, 1)[0];
items.splice(target, 0, item);
renderList();
}
function row(item, index) {
var visible = E('input', {
'type': 'checkbox',
'checked': item.visible ? '' : null,
'change': function(ev) {
item.visible = ev.target.checked;
renderList();
}
});
return E('div', {
'class': 'overview-manager-row' + (item.visible ? '' : ' hidden-widget'),
'draggable': 'true',
'data-widget': item.name,
'dragstart': function(ev) {
dragging = item.name;
ev.currentTarget.classList.add('dragging');
ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData('text/plain', item.name);
},
'dragend': function(ev) {
dragging = null;
ev.currentTarget.classList.remove('dragging');
Array.prototype.forEach.call(
list.querySelectorAll('.drag-over'),
function(node) { node.classList.remove('drag-over'); });
},
'dragover': function(ev) {
ev.preventDefault();
ev.currentTarget.classList.add('drag-over');
ev.dataTransfer.dropEffect = 'move';
},
'dragleave': function(ev) {
ev.currentTarget.classList.remove('drag-over');
},
'drop': function(ev) {
ev.preventDefault();
var sourceName = dragging || ev.dataTransfer.getData('text/plain');
var sourceIndex = items.findIndex(function(entry) {
return entry.name === sourceName;
});
if (sourceIndex < 0 || sourceName === item.name)
return;
var after = ev.clientY > ev.currentTarget.getBoundingClientRect().top +
ev.currentTarget.offsetHeight / 2;
var moved = items.splice(sourceIndex, 1)[0];
var targetIndex = items.findIndex(function(entry) {
return entry.name === item.name;
});
items.splice(targetIndex + (after ? 1 : 0), 0, moved);
renderList();
}
}, [
E('span', {
'class': 'overview-manager-handle',
'title': _('Drag to reorder')
}, [ '⠿' ]),
E('span', {}, [
E('span', { 'class': 'overview-manager-widget-title' }, [ item.title ]),
E('span', { 'class': 'overview-manager-widget-file' }, [ item.name ])
]),
E('label', { 'class': 'overview-manager-switch' }, [
visible,
E('span', {}, [ _('Show') ])
]),
E('span', { 'class': 'overview-manager-order-buttons' }, [
E('button', {
'class': 'cbi-button',
'type': 'button',
'disabled': index === 0 ? '' : null,
'title': _('Move up'),
'click': function() { move(item.name, -1); }
}, [ '↑' ]),
E('button', {
'class': 'cbi-button',
'type': 'button',
'disabled': index === items.length - 1 ? '' : null,
'title': _('Move down'),
'click': function() { move(item.name, 1); }
}, [ '↓' ])
])
]);
}
function renderList() {
list.replaceChildren.apply(list, items.map(row));
}
function save() {
var order = items.map(function(item) { return item.name; }).join(',');
var hidden = items.filter(function(item) {
return !item.visible;
}).map(function(item) {
return item.name;
}).join(',');
saveButton.disabled = true;
result.className = 'overview-manager-result';
result.textContent = _('Saving…');
return fs.exec(helper, [ 'save', order, hidden ]).then(function() {
result.className = 'overview-manager-result good';
result.textContent = _('Saved. Reload Overview to see the layout.');
}).catch(function(error) {
result.className = 'overview-manager-result bad';
result.textContent = error.message || _('Unable to save the layout.');
}).finally(function() {
saveButton.disabled = false;
});
}
function resetOrder() {
items.sort(function(left, right) {
return common.compareWidgets(left.name, right.name);
});
items.forEach(function(item) { item.visible = true; });
result.className = 'overview-manager-result';
result.textContent = _('Default order restored locally. Save to apply it.');
renderList();
}
saveButton = E('button', {
'class': 'cbi-button cbi-button-positive',
'type': 'button',
'click': save
}, [ _('Save layout') ]);
renderList();
return E('div', { 'class': 'overview-manager-page' }, [
common.styles(),
E('div', { 'class': 'overview-manager-header' }, [
E('div', {}, [
E('h2', {}, [ 'Overview Manager' ]),
E('p', { 'class': 'overview-manager-subtitle' }, [
_('Change the order of widgets on Status → Overview and hide widgets you do not need.')
])
])
]),
E('section', { 'class': 'overview-manager-section' }, [
E('div', { 'class': 'overview-manager-section-head' }, [
E('div', {}, [
E('h3', {}, [ _('Overview widgets') ]),
E('p', {}, [
_('Drag rows or use the arrow buttons. Newly installed widgets are appended in their normal filename order.')
])
])
]),
items.length ? list : E('p', { 'class': 'overview-manager-muted' }, [
_('No Overview widgets were found.')
]),
E('div', { 'class': 'overview-manager-actions' }, [
saveButton,
E('button', {
'class': 'cbi-button',
'type': 'button',
'click': resetOrder
}, [ _('Restore defaults') ]),
result
]),
E('div', { 'class': 'overview-manager-note' }, [
_('OpenWrt 25.12 also has a browser-local Hide button. A widget hidden there remains hidden only in that browser, independently of this router-wide layout.')
])
])
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
+212
View File
@@ -0,0 +1,212 @@
'use strict';
'require baseclass';
/* luci-mod-status requires its includes as 'view.status.include.<name>' and
* sorts those module names, so the .js suffix takes no part in the ordering.
* Sorting the raw file names instead would place '20_x.js' and '20_x-y.js' in
* the opposite order and shift the whole widget-to-section mapping. The
* comparison stays code-unit based to match the plain Array.sort() upstream
* uses; localeCompare would reorder names per browser locale. */
function compareWidgets(left, right) {
left = left.replace(/\.js$/, '');
right = right.replace(/\.js$/, '');
return left < right ? -1 : (left > right ? 1 : 0);
}
function parseLayout(text) {
var result = { order: [], hidden: [] };
(text || '').replace(/\r/g, '').split('\n').forEach(function(line) {
var equal = line.indexOf('=');
if (equal < 1)
return;
var key = line.slice(0, equal);
if (key !== 'order' && key !== 'hidden')
return;
result[key] = line.slice(equal + 1).trim().split(/\s+/).filter(Boolean);
});
return result;
}
function styles() {
return E('style', {}, [ `
.overview-manager-page {
--ovm-accent: #4f7dff;
--ovm-accent-2: #8b5cf6;
--ovm-grad: linear-gradient(135deg, #4f7dff, #8b5cf6);
--ovm-border: rgba(128, 128, 128, .22);
--ovm-border-strong: rgba(128, 128, 128, .34);
--ovm-surface: rgba(128, 128, 128, .06);
--ovm-surface-2: rgba(128, 128, 128, .11);
--ovm-muted: rgba(128, 128, 128, .85);
--ovm-good: #16a34a;
--ovm-warn: #d97706;
--ovm-bad: #e11d48;
--ovm-radius: 16px;
--ovm-radius-sm: 11px;
--ovm-shadow: 0 1px 2px rgba(0, 0, 0, .05),
0 10px 30px -18px rgba(0, 0, 0, .45);
max-width: 1040px;
}
.overview-manager-page * { box-sizing: border-box; }
.overview-manager-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin: 0 0 1.4rem;
}
.overview-manager-header h2 {
margin: 0 0 .35rem;
font-size: clamp(1.45rem, 2.6vw, 1.85rem);
font-weight: 750;
letter-spacing: -.015em;
}
.overview-manager-subtitle,
.overview-manager-muted {
color: var(--ovm-muted);
line-height: 1.5;
}
.overview-manager-subtitle { margin: 0; max-width: 760px; }
.overview-manager-section {
margin: 1rem 0;
padding: 1.2rem 1.3rem;
border: 1px solid var(--ovm-border);
border-radius: var(--ovm-radius);
background: var(--ovm-surface);
box-shadow: var(--ovm-shadow);
}
.overview-manager-section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.overview-manager-section-head h3 { margin: 0 0 .3rem; }
.overview-manager-section-head p {
margin: 0;
color: var(--ovm-muted);
line-height: 1.5;
}
.overview-manager-list {
display: grid;
gap: .6rem;
}
.overview-manager-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: .75rem;
min-width: 0;
padding: .75rem .85rem;
border: 1px solid var(--ovm-border);
border-radius: var(--ovm-radius-sm);
background: color-mix(in srgb, currentColor 3%, transparent);
transition: border-color .14s ease, background .14s ease,
transform .14s ease, opacity .14s ease;
}
.overview-manager-row[draggable="true"] { cursor: grab; }
.overview-manager-row.dragging { opacity: .45; }
.overview-manager-row.drag-over {
border-color: var(--ovm-accent);
background: color-mix(in srgb, var(--ovm-accent) 9%, transparent);
}
.overview-manager-row.hidden-widget { opacity: .64; }
.overview-manager-handle {
color: var(--ovm-muted);
font-size: 1.25rem;
letter-spacing: -.25rem;
user-select: none;
}
.overview-manager-widget-title {
display: block;
overflow: hidden;
font-weight: 680;
text-overflow: ellipsis;
white-space: nowrap;
}
.overview-manager-widget-file {
display: block;
margin-top: .2rem;
overflow: hidden;
color: var(--ovm-muted);
font-family: monospace;
font-size: .75rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.overview-manager-switch {
display: inline-flex;
align-items: center;
gap: .45rem;
white-space: nowrap;
cursor: pointer;
}
.overview-manager-switch input { margin: 0; }
.overview-manager-order-buttons {
display: inline-flex;
gap: .3rem;
}
.overview-manager-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .6rem;
margin-top: 1rem;
}
.overview-manager-result { color: var(--ovm-muted); }
.overview-manager-result.good { color: var(--ovm-good); }
.overview-manager-result.bad { color: var(--ovm-bad); }
.overview-manager-note {
margin-top: 1rem;
padding: .85rem 1rem;
border: 1px solid color-mix(in srgb, var(--ovm-warn) 30%, var(--ovm-border));
border-left: .26rem solid var(--ovm-warn);
border-radius: var(--ovm-radius-sm);
background: color-mix(in srgb, var(--ovm-warn) 7%, transparent);
line-height: 1.5;
}
.overview-manager-page .cbi-button {
padding: .5rem .8rem;
border: 1px solid var(--ovm-border);
border-radius: var(--ovm-radius-sm);
background: var(--ovm-surface-2);
font-weight: 620;
line-height: 1.2;
cursor: pointer;
transition: transform .12s ease, border-color .14s ease,
filter .14s ease;
}
.overview-manager-page .cbi-button:hover {
border-color: var(--ovm-border-strong);
transform: translateY(-1px);
}
.overview-manager-page .cbi-button-positive {
border-color: transparent;
background-image: var(--ovm-grad);
color: #fff;
}
.overview-manager-page button[disabled] {
opacity: .55;
cursor: wait;
transform: none;
}
@media (max-width: 650px) {
.overview-manager-header,
.overview-manager-section-head { flex-direction: column; }
.overview-manager-row {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.overview-manager-order-buttons {
grid-column: 2 / -1;
justify-self: end;
}
}
` ]);
}
return baseclass.extend({
compareWidgets: compareWidgets,
parseLayout: parseLayout,
styles: styles
});
@@ -0,0 +1,3 @@
config main 'main'
option order ''
option hidden ''
@@ -0,0 +1,35 @@
#!/bin/sh
# Register the bundled catalogs in luci.languages, the same way the stock
# luci-i18n-* packages do. Without this LuCI never selects the language: both
# the language dropdown and the "auto" browser detection only consider codes
# present in this list.
set -e
changed=0
# The section normally exists already, but uci set would fail without it.
uci -q get luci.languages >/dev/null || uci set luci.languages=internal
for catalog in /usr/lib/lua/luci/i18n/overview-manager.*.lmo; do
[ -f "$catalog" ] || continue
language="${catalog##*/overview-manager.}"
language="${language%.lmo}"
case "$language" in
ru) name='Русский' ;;
*) name="$language" ;;
esac
# LuCI stores the option name with underscores, as in luci.languages.pt_br.
option="$(printf '%s' "$language" | tr '-' '_')"
uci set "luci.languages.$option=$name"
changed=1
done
[ "$changed" = 0 ] || uci commit luci
exit 0
@@ -0,0 +1,53 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Language: ru\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
msgid "Change the order of widgets on Status → Overview and hide widgets you do not need."
msgstr "Меняйте порядок карточек на странице «Статус → Обзор» и скрывайте ненужные виджеты."
msgid "Default order restored locally. Save to apply it."
msgstr "Исходный порядок восстановлен. Сохраните, чтобы применить его."
msgid "Drag rows or use the arrow buttons. Newly installed widgets are appended in their normal filename order."
msgstr "Перетаскивайте строки или используйте стрелки. Новые виджеты добавляются в конец в обычном порядке имён файлов."
msgid "Drag to reorder"
msgstr "Перетащите для изменения порядка"
# "Move down", "Move up" and "Show" also exist in the luci-base catalog. All
# catalogs are merged into one lookup table in an unspecified order, so these
# keep the exact luci-base wording to make the collision a no-op.
msgid "Move down"
msgstr "Переместить вниз"
msgid "Move up"
msgstr "Переместить вверх"
msgid "No Overview widgets were found."
msgstr "Виджеты страницы «Обзор» не найдены."
msgid "OpenWrt 25.12 also has a browser-local Hide button. A widget hidden there remains hidden only in that browser, independently of this router-wide layout."
msgstr "В OpenWrt 25.12 также есть локальная для браузера кнопка «Скрыть». Скрытый ею виджет остаётся скрытым только в этом браузере независимо от общей раскладки роутера."
msgid "Overview widgets"
msgstr "Виджеты страницы «Обзор»"
msgid "Restore defaults"
msgstr "Вернуть исходный порядок"
msgid "Save layout"
msgstr "Сохранить раскладку"
msgid "Saved. Reload Overview to see the layout."
msgstr "Сохранено. Обновите страницу «Обзор», чтобы увидеть раскладку."
msgid "Saving…"
msgstr "Сохраняю…"
msgid "Show"
msgstr "Показать"
msgid "Unable to save the layout."
msgstr "Не удалось сохранить раскладку."
@@ -0,0 +1,47 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8\n"
msgid "Change the order of widgets on Status → Overview and hide widgets you do not need."
msgstr ""
msgid "Default order restored locally. Save to apply it."
msgstr ""
msgid "Drag rows or use the arrow buttons. Newly installed widgets are appended in their normal filename order."
msgstr ""
msgid "Drag to reorder"
msgstr ""
msgid "Move down"
msgstr ""
msgid "Move up"
msgstr ""
msgid "No Overview widgets were found."
msgstr ""
msgid "OpenWrt 25.12 also has a browser-local Hide button. A widget hidden there remains hidden only in that browser, independently of this router-wide layout."
msgstr ""
msgid "Overview widgets"
msgstr ""
msgid "Restore defaults"
msgstr ""
msgid "Save layout"
msgstr ""
msgid "Saved. Reload Overview to see the layout."
msgstr ""
msgid "Saving…"
msgstr ""
msgid "Show"
msgstr ""
msgid "Unable to save the layout."
msgstr ""
+4
View File
@@ -0,0 +1,4 @@
PKG_NAME=luci-app-overview-manager
PKG_VERSION=0.1.0
PKG_RELEASE=
PKG_ARCH=all
+78
View File
@@ -0,0 +1,78 @@
#!/bin/sh
set -eu
INCLUDE_DIR="${OVERVIEW_MANAGER_INCLUDE_DIR:-/www/luci-static/resources/view/status/include}"
UCI="${OVERVIEW_MANAGER_UCI:-uci}"
SELF="00_overview-manager.js"
files() {
for path in "$INCLUDE_DIR"/*.js; do
[ -f "$path" ] || continue
basename "$path"
done | sort
}
layout() {
order="$("$UCI" -q get overview-manager.main.order 2>/dev/null || true)"
hidden="$("$UCI" -q get overview-manager.main.hidden 2>/dev/null || true)"
printf 'order=%s\nhidden=%s\n' "$order" "$hidden"
}
normalize_list() {
value="${1:-}"
result=""
seen=" "
old_ifs="$IFS"
IFS=', '
set -- $value
IFS="$old_ifs"
for name in "$@"; do
[ -n "$name" ] || continue
case "$name" in
"$SELF" | *[!A-Za-z0-9_.-]* | *.js.js)
printf 'Invalid widget name: %s\n' "$name" >&2
return 1
;;
*.js) ;;
*)
printf 'Invalid widget name: %s\n' "$name" >&2
return 1
;;
esac
[ -f "$INCLUDE_DIR/$name" ] || {
printf 'Widget is not installed: %s\n' "$name" >&2
return 1
}
case "$seen" in
*" $name "*) continue ;;
esac
result="${result:+$result }$name"
seen="$seen$name "
done
printf '%s\n' "$result"
}
save() {
order="$(normalize_list "${1:-}")"
hidden="$(normalize_list "${2:-}")"
"$UCI" -q set overview-manager.main=main
"$UCI" -q set "overview-manager.main.order=$order"
"$UCI" -q set "overview-manager.main.hidden=$hidden"
"$UCI" -q commit overview-manager
printf 'Layout saved.\n'
}
usage() {
printf 'Usage: %s {files|layout|save ORDER HIDDEN}\n' "$0" >&2
exit 2
}
case "${1:-}" in
files) files ;;
layout) layout ;;
save) save "${2:-}" "${3:-}" ;;
*) usage ;;
esac
+72
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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'
+99
View File
@@ -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
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"
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+254
View File
@@ -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
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
+96
View File
@@ -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
View File
@@ -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'