mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 16:44:23 +08:00
🤞 Sync 2026-09-27 22:16:29
This commit is contained in:
1 parent
8ede4a8320
commit
618f51e9dc
459 files changed
+44817
-2191
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
@@ -0,0 +1,117 @@
|
||||
# Overview Manager for OpenWrt
|
||||
|
||||
[Русский](README.ru.md)
|
||||
|
||||
[](https://github.com/Nikitid/luci-app-overview-manager/actions/workflows/ci.yml)
|
||||
[](https://github.com/Nikitid/luci-app-overview-manager/releases/latest)
|
||||
[](LICENSE)
|
||||
|
||||
The `luci-app-overview-manager` package is a LuCI application for arranging
|
||||
the cards on the **Status -> Overview** page.
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
@@ -0,0 +1,119 @@
|
||||
# Overview Manager для OpenWrt
|
||||
|
||||
[English](README.md)
|
||||
|
||||
[](https://github.com/Nikitid/luci-app-overview-manager/actions/workflows/ci.yml)
|
||||
[](https://github.com/Nikitid/luci-app-overview-manager/releases/latest)
|
||||
[](LICENSE)
|
||||
|
||||
Пакет `luci-app-overview-manager` - LuCI-приложение для настройки карточек на
|
||||
странице **Status -> Overview**.
|
||||
|
||||

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