From f01306d457f2861a5553285bd3956bba9fac817f Mon Sep 17 00:00:00 2001 From: action Date: Wed, 28 Oct 2026 21:59:16 +0800 Subject: [PATCH] update 2026-10-28 21:59:16 --- geoview/Makefile | 4 +- gost/Makefile | 4 +- .../.github/workflows/build-apk.yaml | 2 +- luci-app-amlogic/Makefile | 2 +- .../resources/view/amlogic/backup.js | 44 +- .../share/rpcd/acl.d/luci-app-amlogic.json | 3 +- .../root/usr/share/rpcd/ucode/luci.amlogic | 24 +- luci-app-chatgpt-web | 1 + luci-app-honk/Makefile | 6 +- .../luasrc/controller/passwall.lua | 6 +- .../cbi/passwall/client/type/1_sing-box.lua | 2 +- .../cbi/passwall/server/type/1_sing-box.lua | 2 +- luci-app-passwall/luasrc/passwall/api.lua | 41 +- luci-app-passwall/luasrc/passwall/com.lua | 2 +- .../luasrc/passwall/util_hysteria2.lua | 2 + .../luasrc/passwall/util_xray.lua | 2 + .../view/passwall/node_list/node_list.htm | 11 +- .../view/passwall/socks_auto_switch/btn.htm | 4 +- .../luasrc/controller/passwall2.lua | 6 +- .../cbi/passwall2/client/type/1_sing-box.lua | 2 +- .../cbi/passwall2/server/type/1_sing-box.lua | 2 +- luci-app-passwall2/luasrc/passwall2/api.lua | 41 +- luci-app-passwall2/luasrc/passwall2/com.lua | 2 +- .../luasrc/passwall2/util_xray.lua | 2 + .../view/passwall2/node_list/node_list.htm | 11 +- .../view/passwall2/socks_auto_switch/btn.htm | 4 +- .../luasrc/controller/istore_backend.lua | 12 +- .../luasrc/controller/quickstart.lua | 16 + .../root/usr/libexec/quickstart/auto_setup.sh | 18 +- luci-app-quickstart/tests/auto_setup_test.py | 55 ++ .../tests/security_regression.lua | 88 ++ luci-theme-footstrap/LICENSE | 202 ++++ luci-theme-footstrap/Makefile | 146 +++ luci-theme-footstrap/README.md | 59 ++ luci-theme-footstrap/build-apk.sh | 107 +++ luci-theme-footstrap/build-css.sh | 330 +++++++ luci-theme-footstrap/dev-sync.sh | 124 +++ .../luci-static/footstrap/app-icon-192.png | Bin 0 -> 1341 bytes .../htdocs/luci-static/footstrap/logo.svg | 17 + .../htdocs/luci-static/footstrap/logo_48.png | Bin 0 -> 291 bytes .../luci-static/footstrap/manifest.json | 15 + .../luci-static/resources/fs-appearance.js | 637 +++++++++++++ .../htdocs/luci-static/resources/fs-assets.js | 291 ++++++ .../htdocs/luci-static/resources/fs-axes.js | 368 ++++++++ .../htdocs/luci-static/resources/fs-chrome.js | 660 +++++++++++++ .../htdocs/luci-static/resources/fs-fit.js | 488 ++++++++++ .../luci-static/resources/fs-menutree.js | 133 +++ .../luci-static/resources/fs-overview.js | 320 +++++++ .../htdocs/luci-static/resources/fs-prefs.js | 163 ++++ .../htdocs/luci-static/resources/fs-router.js | 812 ++++++++++++++++ .../htdocs/luci-static/resources/fs-search.js | 297 ++++++ .../htdocs/luci-static/resources/fs-select.js | 599 ++++++++++++ .../htdocs/luci-static/resources/fs-sheets.js | 455 +++++++++ .../luci-static/resources/fs-version.js | 14 + .../luci-static/resources/fs-widgets.js | 35 + .../resources/menu-footstrap-common.js | 159 ++++ .../luci-static/resources/menu-footstrap.js | 10 + .../resources/view/footstrap/appearance.js | 13 + luci-theme-footstrap/luci-upstream.pin | 31 + luci-theme-footstrap/mangle-tokens.sh | 163 ++++ luci-theme-footstrap/po/es/footstrap.po | 870 ++++++++++++++++++ luci-theme-footstrap/po/ru/footstrap.po | 860 +++++++++++++++++ .../po/templates/footstrap.pot | 640 +++++++++++++ .../root/etc/config/footstrap | 2 + .../etc/uci-defaults/30_luci-theme-footstrap | 42 + .../lib/upgrade/keep.d/luci-theme-footstrap | 7 + .../luci/menu.d/luci-theme-footstrap.json | 13 + .../rpcd/acl.d/luci-theme-footstrap.json | 37 + .../www/cgi-bin/luci-theme-footstrap-pattern | 30 + luci-theme-footstrap/strip-build.sh | 303 ++++++ luci-theme-footstrap/styles/00-header.css | 16 + luci-theme-footstrap/styles/02-tokens.css | 240 +++++ luci-theme-footstrap/styles/03-palettes.css | 276 ++++++ luci-theme-footstrap/styles/04-nocolormix.css | 51 + luci-theme-footstrap/styles/base/10-reset.css | 98 ++ .../styles/base/20-typography.css | 144 +++ luci-theme-footstrap/styles/base/30-forms.css | 203 ++++ .../styles/base/40-tables.css | 61 ++ .../styles/base/50-chrome.css | 125 +++ luci-theme-footstrap/styles/base/60-modal.css | 61 ++ .../styles/base/70-buttons.css | 28 + .../styles/base/90-widgets.css | 118 +++ luci-theme-footstrap/styles/base/95-luci.css | 617 +++++++++++++ .../styles/pages/10-login.css | 55 ++ .../styles/pages/20-overview.css | 251 +++++ .../styles/pages/30-software.css | 39 + .../styles/pages/40-sshkeys.css | 14 + .../styles/pages/50-leases.css | 18 + .../styles/pages/70-syslog.css | 40 + .../styles/pages/80-appearance.css | 125 +++ .../styles/theme/10-chrome.css | 107 +++ .../styles/theme/15-wallpaper.css | 43 + .../styles/theme/16-login-bg.css | 11 + .../styles/theme/17-glass.css | 239 +++++ .../styles/theme/20-shell.css | 560 +++++++++++ .../styles/theme/25-progressbar.css | 77 ++ .../styles/theme/30-tables.css | 439 +++++++++ .../styles/theme/35-alerts.css | 128 +++ luci-theme-footstrap/styles/theme/40-tabs.css | 113 +++ luci-theme-footstrap/styles/theme/45-misc.css | 133 +++ .../styles/theme/50-toplayout.css | 32 + .../styles/theme/55-buttons.css | 250 +++++ .../styles/theme/60-inputs.css | 532 +++++++++++ .../styles/theme/65-dropdown.css | 387 ++++++++ .../styles/theme/70-modal.css | 44 + .../styles/theme/75-search.css | 80 ++ .../styles/theme/90-responsive.css | 76 ++ .../styles/theme/95-a11y-media.css | 95 ++ .../styles/theme/97-print.css | 31 + .../ucode/template/themes/footstrap/footer.ut | 33 + .../ucode/template/themes/footstrap/header.ut | 217 +++++ .../themes/footstrap/partials/head.ut | 440 +++++++++ .../themes/footstrap/partials/notice.ut | 16 + .../template/themes/footstrap/sysauth.ut | 185 ++++ luci-theme-footstrap/update-po.sh | 105 +++ lucky/Makefile | 26 +- lucky/files/lucky.config | 5 + lucky/files/lucky.init | 81 ++ lucky/files/luckyarch.bin | 14 + open-app-filter/files/oaf_version | 2 +- v2ray-geodata/Makefile | 4 +- 121 files changed, 16870 insertions(+), 88 deletions(-) create mode 160000 luci-app-chatgpt-web create mode 100644 luci-app-quickstart/tests/auto_setup_test.py create mode 100644 luci-app-quickstart/tests/security_regression.lua create mode 100644 luci-theme-footstrap/LICENSE create mode 100644 luci-theme-footstrap/Makefile create mode 100644 luci-theme-footstrap/README.md create mode 100755 luci-theme-footstrap/build-apk.sh create mode 100755 luci-theme-footstrap/build-css.sh create mode 100755 luci-theme-footstrap/dev-sync.sh create mode 100644 luci-theme-footstrap/htdocs/luci-static/footstrap/app-icon-192.png create mode 100644 luci-theme-footstrap/htdocs/luci-static/footstrap/logo.svg create mode 100644 luci-theme-footstrap/htdocs/luci-static/footstrap/logo_48.png create mode 100644 luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/fs-axes.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/fs-fit.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/fs-menutree.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/fs-prefs.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/fs-search.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/fs-select.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/fs-sheets.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/fs-version.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/fs-widgets.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/menu-footstrap-common.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/menu-footstrap.js create mode 100644 luci-theme-footstrap/htdocs/luci-static/resources/view/footstrap/appearance.js create mode 100644 luci-theme-footstrap/luci-upstream.pin create mode 100755 luci-theme-footstrap/mangle-tokens.sh create mode 100644 luci-theme-footstrap/po/es/footstrap.po create mode 100644 luci-theme-footstrap/po/ru/footstrap.po create mode 100644 luci-theme-footstrap/po/templates/footstrap.pot create mode 100644 luci-theme-footstrap/root/etc/config/footstrap create mode 100755 luci-theme-footstrap/root/etc/uci-defaults/30_luci-theme-footstrap create mode 100644 luci-theme-footstrap/root/lib/upgrade/keep.d/luci-theme-footstrap create mode 100644 luci-theme-footstrap/root/usr/share/luci/menu.d/luci-theme-footstrap.json create mode 100644 luci-theme-footstrap/root/usr/share/rpcd/acl.d/luci-theme-footstrap.json create mode 100755 luci-theme-footstrap/root/www/cgi-bin/luci-theme-footstrap-pattern create mode 100755 luci-theme-footstrap/strip-build.sh create mode 100644 luci-theme-footstrap/styles/00-header.css create mode 100644 luci-theme-footstrap/styles/02-tokens.css create mode 100644 luci-theme-footstrap/styles/03-palettes.css create mode 100644 luci-theme-footstrap/styles/04-nocolormix.css create mode 100644 luci-theme-footstrap/styles/base/10-reset.css create mode 100644 luci-theme-footstrap/styles/base/20-typography.css create mode 100644 luci-theme-footstrap/styles/base/30-forms.css create mode 100644 luci-theme-footstrap/styles/base/40-tables.css create mode 100644 luci-theme-footstrap/styles/base/50-chrome.css create mode 100644 luci-theme-footstrap/styles/base/60-modal.css create mode 100644 luci-theme-footstrap/styles/base/70-buttons.css create mode 100644 luci-theme-footstrap/styles/base/90-widgets.css create mode 100644 luci-theme-footstrap/styles/base/95-luci.css create mode 100644 luci-theme-footstrap/styles/pages/10-login.css create mode 100644 luci-theme-footstrap/styles/pages/20-overview.css create mode 100644 luci-theme-footstrap/styles/pages/30-software.css create mode 100644 luci-theme-footstrap/styles/pages/40-sshkeys.css create mode 100644 luci-theme-footstrap/styles/pages/50-leases.css create mode 100644 luci-theme-footstrap/styles/pages/70-syslog.css create mode 100644 luci-theme-footstrap/styles/pages/80-appearance.css create mode 100644 luci-theme-footstrap/styles/theme/10-chrome.css create mode 100644 luci-theme-footstrap/styles/theme/15-wallpaper.css create mode 100644 luci-theme-footstrap/styles/theme/16-login-bg.css create mode 100644 luci-theme-footstrap/styles/theme/17-glass.css create mode 100644 luci-theme-footstrap/styles/theme/20-shell.css create mode 100644 luci-theme-footstrap/styles/theme/25-progressbar.css create mode 100644 luci-theme-footstrap/styles/theme/30-tables.css create mode 100644 luci-theme-footstrap/styles/theme/35-alerts.css create mode 100644 luci-theme-footstrap/styles/theme/40-tabs.css create mode 100644 luci-theme-footstrap/styles/theme/45-misc.css create mode 100644 luci-theme-footstrap/styles/theme/50-toplayout.css create mode 100644 luci-theme-footstrap/styles/theme/55-buttons.css create mode 100644 luci-theme-footstrap/styles/theme/60-inputs.css create mode 100644 luci-theme-footstrap/styles/theme/65-dropdown.css create mode 100644 luci-theme-footstrap/styles/theme/70-modal.css create mode 100644 luci-theme-footstrap/styles/theme/75-search.css create mode 100644 luci-theme-footstrap/styles/theme/90-responsive.css create mode 100644 luci-theme-footstrap/styles/theme/95-a11y-media.css create mode 100644 luci-theme-footstrap/styles/theme/97-print.css create mode 100644 luci-theme-footstrap/ucode/template/themes/footstrap/footer.ut create mode 100644 luci-theme-footstrap/ucode/template/themes/footstrap/header.ut create mode 100644 luci-theme-footstrap/ucode/template/themes/footstrap/partials/head.ut create mode 100644 luci-theme-footstrap/ucode/template/themes/footstrap/partials/notice.ut create mode 100644 luci-theme-footstrap/ucode/template/themes/footstrap/sysauth.ut create mode 100755 luci-theme-footstrap/update-po.sh create mode 100644 lucky/files/lucky.config create mode 100644 lucky/files/lucky.init create mode 100644 lucky/files/luckyarch.bin diff --git a/geoview/Makefile b/geoview/Makefile index abb05004..254f1450 100644 --- a/geoview/Makefile +++ b/geoview/Makefile @@ -1,12 +1,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=geoview -PKG_VERSION:=0.2.8 +PKG_VERSION:=0.2.9 PKG_RELEASE:=1 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/snowie2000/geoview/tar.gz/$(PKG_VERSION)? -PKG_HASH:=e9e957568250157459b4ccc3398a995202678a0dbf1e932c4b0b6179d8b79e5f +PKG_HASH:=b080a141505a3180c3b81468304864e4373c4868680ff39fb82ee6973e3b5e94 PKG_LICENSE:=Apache-2.0 PKG_LICENSE_FILES:=LICENSE diff --git a/gost/Makefile b/gost/Makefile index fc0c508b..2ea18484 100644 --- a/gost/Makefile +++ b/gost/Makefile @@ -5,12 +5,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=gost -PKG_VERSION:=3.3.1 +PKG_VERSION:=3.3.2 PKG_RELEASE:=1 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/go-gost/gost/tar.gz/v$(PKG_VERSION)? -PKG_HASH:=3b5241edbc69d9578a37b3e950cf02ca7a7dc2aa2f688457affc63d966b93012 +PKG_HASH:=838674347bfd7a7df43cab5ca25a21992a247fe50fe61e9a12497d5a1699a3d5 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE diff --git a/luci-app-adguardhome/.github/workflows/build-apk.yaml b/luci-app-adguardhome/.github/workflows/build-apk.yaml index 128f5855..3eab5d1b 100644 --- a/luci-app-adguardhome/.github/workflows/build-apk.yaml +++ b/luci-app-adguardhome/.github/workflows/build-apk.yaml @@ -25,7 +25,7 @@ jobs: steps: - name: 检出代码 - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/luci-app-amlogic/Makefile b/luci-app-amlogic/Makefile index 7de59558..36717e8e 100644 --- a/luci-app-amlogic/Makefile +++ b/luci-app-amlogic/Makefile @@ -22,7 +22,7 @@ PKG_NAME:=luci-app-amlogic # DD = day (01 = 1st) # NN = daily release sequence (01 = first release of the day, 02 = second, ...) # Example below: 26.10.0101 = October 1, 2026, 1st release of the day -PKG_VERSION:=26.10.2501 +PKG_VERSION:=26.10.2801 # PKG_RELEASE marks the LuCI variant; onekey-install.sh relies on it (do NOT bump) # 1 = Lua version (lua branch) → tag: 26.10.0101 # 2 = JS version (main branch) → tag: 26.10.0101-js diff --git a/luci-app-amlogic/htdocs/luci-static/resources/view/amlogic/backup.js b/luci-app-amlogic/htdocs/luci-static/resources/view/amlogic/backup.js index 62df5ed8..64a6dda3 100644 --- a/luci-app-amlogic/htdocs/luci-static/resources/view/amlogic/backup.js +++ b/luci-app-amlogic/htdocs/luci-static/resources/view/amlogic/backup.js @@ -13,9 +13,10 @@ 'require fs'; 'require view.amlogic.shared as amlogicShared'; -// Generate one openwrt_config.tar.gz on the server and return its path so the -// frontend can stream it back via fs.read_direct. -const callBackup = rpc.declare({ object: 'luci.amlogic', method: 'backup_create' }); +// Generate one openwrt_config.tar.gz on the server (background) so the +// frontend can stream it back via fs.read_direct once backup_status is done. +const callBackup = rpc.declare({ object: 'luci.amlogic', method: 'backup_create' }); +const callBackupStatus = rpc.declare({ object: 'luci.amlogic', method: 'backup_status' }); // List existing snapshot names (e.g. etc-000 / etc-001 / etc-202401...). const callSnapList = rpc.declare({ object: 'luci.amlogic', method: 'snapshot_list', expect: { names: [] } }); @@ -69,17 +70,11 @@ return view.extend({ const btn = ev.currentTarget; btn.disabled = true; btn.value = _('Generating...'); - // Step 1: ask the server to pack /etc into openwrt_config.tar.gz. - return callBackup().then(function (r) { - if (!r || !r.ok) { - downloadStatus.textContent = _("Couldn't open file:") + ' ' + (r ? r.path : ''); - return; - } - // Switch to success color before streaming the blob - downloadStatus.className = 'amlogic-status-ok'; - downloadStatus.textContent = _('The file Will download automatically.') + ' ' + r.path; - // Fetch the file blob via fs.read_direct and trigger browser download. - return fs.read_direct(r.path, 'blob').then(function (blob) { + downloadStatus.className = 'amlogic-status-err'; + downloadStatus.textContent = ''; + + function downloadFile(path) { + return fs.read_direct(path, 'blob').then(function (blob) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -89,6 +84,27 @@ return view.extend({ document.body.removeChild(a); setTimeout(function () { URL.revokeObjectURL(url); }, 10000); }); + } + + function pollStatus(deadline) { + return callBackupStatus().then(function (s) { + if (s && s.status === 'done' && s.size > 0) { + downloadStatus.className = 'amlogic-status-ok'; + downloadStatus.textContent = _('The file Will download automatically.') + ' ' + s.path; + return downloadFile(s.path); + } + if (s && s.status === 'failed') + throw new Error(_("Couldn't open file:") + ' ' + (s.path || '')); + if (Date.now() > deadline) + throw new Error(_('Backup generation timed out.')); + return new Promise(function (resolve) { setTimeout(resolve, 1500); }) + .then(function () { return pollStatus(deadline); }); + }); + } + + // Step 1: kick off the background pack job, then poll until done. + return callBackup().then(function () { + return pollStatus(Date.now() + 300000); }).catch(function (e) { downloadStatus.textContent = _('Create upload file error.') + ' ' + (e && e.message ? e.message : ''); diff --git a/luci-app-amlogic/root/usr/share/rpcd/acl.d/luci-app-amlogic.json b/luci-app-amlogic/root/usr/share/rpcd/acl.d/luci-app-amlogic.json index d89c6995..c0e52cdb 100644 --- a/luci-app-amlogic/root/usr/share/rpcd/acl.d/luci-app-amlogic.json +++ b/luci-app-amlogic/root/usr/share/rpcd/acl.d/luci-app-amlogic.json @@ -14,7 +14,8 @@ "list_uploads", "read_log_tail", "read_log_full", - "cpu_policies" + "cpu_policies", + "backup_status" ] }, "uci": [ "amlogic" ], diff --git a/luci-app-amlogic/root/usr/share/rpcd/ucode/luci.amlogic b/luci-app-amlogic/root/usr/share/rpcd/ucode/luci.amlogic index 6bd303ff..ecae8aaa 100644 --- a/luci-app-amlogic/root/usr/share/rpcd/ucode/luci.amlogic +++ b/luci-app-amlogic/root/usr/share/rpcd/ucode/luci.amlogic @@ -18,6 +18,7 @@ const LOG_PLUGIN = LOG_DIR + '/amlogic_check_plugin.log'; const LOG_KERNEL = LOG_DIR + '/amlogic_check_kernel.log'; const LOG_FIRMWARE = LOG_DIR + '/amlogic_check_firmware.log'; const LOG_RESCUE = LOG_DIR + '/amlogic_check_rescue.log'; +const BACKUP_STATUS = LOG_DIR + '/amlogic_backup_status.log'; const UPLOAD_TMP = '/tmp/upload'; const PKG_NAME = 'luci-app-amlogic'; const BACKUP_LIST = '/etc/amlogic_backup_list.conf'; @@ -681,13 +682,29 @@ function m_install_upload(req) { return { error: 'unsupported file type' }; } +// Kick off openwrt-backup -b in the background; the resulting tarball can be +// fetched via fs.read_direct once backup_status reports done. Packing /etc +// (docker, openclash rules, etc.) often exceeds the ~20s ubus RPC timeout, so +// this must not block the rpcd thread. function m_backup_create() { ensure_dirs(); shcall('chmod +x /usr/sbin/openwrt-backup'); - shcall('/usr/sbin/openwrt-backup -b > ' + LOG_FILE + ' && sync'); + writefile(BACKUP_STATUS, 'running\n'); + shbg('/usr/sbin/openwrt-backup -b > ' + LOG_FILE + ' 2>&1 && sync && ' + + '[ -f /.reserved/openwrt_config.tar.gz ] && ' + + 'echo done > ' + BACKUP_STATUS + ' || echo failed > ' + BACKUP_STATUS); + return { code: 0 }; +} + +function m_backup_status() { + const t = trim(readfile(BACKUP_STATUS) ?? ''); const path = '/.reserved/openwrt_config.tar.gz'; - const ok = access(path); - return { ok: ok ? true : false, path }; + const st = stat(path); + return { + status: t != '' ? t : 'idle', + path, + size: (st && st.type == 'file') ? st.size : 0 + }; } function m_save_backup_list(req) { @@ -778,6 +795,7 @@ const methods = { delete_upload: { args: { name: '' }, call: m_delete_upload }, install_upload: { args: { name: '' }, call: m_install_upload }, backup_create: { call: m_backup_create }, + backup_status: { call: m_backup_status }, save_backup_list:{ args: { content: '' }, call: m_save_backup_list }, prime_backup_list:{ call: m_prime_backup_list }, reload_cpu: { call: m_reload_cpu }, diff --git a/luci-app-chatgpt-web b/luci-app-chatgpt-web new file mode 160000 index 00000000..3ff52da2 --- /dev/null +++ b/luci-app-chatgpt-web @@ -0,0 +1 @@ +Subproject commit 3ff52da23f3c05141ef87f5fd40af48bdd2155a0 diff --git a/luci-app-honk/Makefile b/luci-app-honk/Makefile index 64e19b5b..fcc2d4cf 100644 --- a/luci-app-honk/Makefile +++ b/luci-app-honk/Makefile @@ -5,14 +5,14 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-honk -PKG_VERSION:=2026.10.25 +PKG_VERSION:=2026.10.28 PKG_RELEASE:=1 PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) -PKG_SOURCE:=doona-4354c99b-e60ed58ef40e.tar.gz +PKG_SOURCE:=doona-815d3ac3-2cf143fb4db8.tar.gz PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-honk/releases/download/staging PKG_SOURCE_SUBDIR:=doona -PKG_HASH:=e60ed58ef40e849269219de38dddf6974f1dbd2915675390d4b748eb7f78db2b +PKG_HASH:=2cf143fb4db8b11c9a672c369432adb5d15dd3ff2607121827590ab3db9012ac -include $(CURDIR)/generated-doona-stage.mk diff --git a/luci-app-passwall/luasrc/controller/passwall.lua b/luci-app-passwall/luasrc/controller/passwall.lua index 1453b3ca..87b1df65 100644 --- a/luci-app-passwall/luasrc/controller/passwall.lua +++ b/luci-app-passwall/luasrc/controller/passwall.lua @@ -190,12 +190,12 @@ function socks_autoswitch_add_node() uci_set(id, "enable_autoswitch", "1") local new_list = uci_get(id, "autoswitch_backup_node") or {} for i = #new_list, 1, -1 do - if (uci_get(new_list[i], "remarks") or ""):find(key) then + if (uci_get(new_list[i], "remarks") or ""):find(key, 1, true) then table.remove(new_list, i) end end for k, e in ipairs(api.get_valid_nodes()) do - if e.node_type == "normal" and e["remark"]:find(key) then + if e.node_type == "normal" and e["remark"]:find(key, 1, true) then table.insert(new_list, e.id) end end @@ -212,7 +212,7 @@ function socks_autoswitch_remove_node() uci_set(id, "enable_autoswitch", "1") local new_list = uci_get(id, "autoswitch_backup_node") or {} for i = #new_list, 1, -1 do - if (uci_get(new_list[i], "remarks") or ""):find(key) then + if (uci_get(new_list[i], "remarks") or ""):find(key, 1, true) then table.remove(new_list, i) end end diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/type/1_sing-box.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/type/1_sing-box.lua index a24b734d..d5748302 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/type/1_sing-box.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/type/1_sing-box.lua @@ -33,7 +33,7 @@ local ss_method_old_list = { local security_list = { "none", "auto", "aes-128-gcm", "chacha20-poly1305", "zero" } -local singbox_tags = luci.sys.exec(singbox_bin .. " version | grep 'Tags:' | awk '{print $2}'") +local singbox_tags = api.get_singbox_tags() local singbox_version = api.get_app_version("sing-box"):match("[^v]+") local version_ge_1_14_0 = api.compare_versions(singbox_version, ">=", "1.14.0") diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/server/type/1_sing-box.lua b/luci-app-passwall/luasrc/model/cbi/passwall/server/type/1_sing-box.lua index b72ac78f..e45ef10c 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/server/type/1_sing-box.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/server/type/1_sing-box.lua @@ -27,7 +27,7 @@ s.type_name = type_name s.option_prefix = "singbox_" api.set_type_cbi(s) -local singbox_tags = luci.sys.exec(singbox_bin .. " version | grep 'Tags:' | awk '{print $2}'") +local singbox_tags = api.get_singbox_tags() local local_version = api.get_app_version("sing-box"):match("[^v]+") local version_ge_1_14_0 = api.compare_versions(local_version, ">=", "1.14.0") diff --git a/luci-app-passwall/luasrc/passwall/api.lua b/luci-app-passwall/luasrc/passwall/api.lua index 6c8b0166..c18b2b92 100644 --- a/luci-app-passwall/luasrc/passwall/api.lua +++ b/luci-app-passwall/luasrc/passwall/api.lua @@ -888,7 +888,7 @@ function clone(org) return res end -function get_bin_version_cache(file, cmd) +function get_bin_version_cache(file, cmd, sb_tags) if file and file ~= "" and fs.access(file) then chmod_755(file) local stat = fs.stat(file) @@ -897,16 +897,28 @@ function get_bin_version_cache(file, cmd) -- Use metadata to avoid hashing the entire binary on every lookup. cache_file = CACHE_PATH .. "/binver_" .. table.concat({stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime}, "_") local cached = fs.readfile(cache_file) - if cached and trim(cached) ~= "" then - return trim(cached) + local version = cached and trim(cached:match("^[^\n]*")) + if version and version ~= "" then + if sb_tags then + return version, trim(cached:match("^[^\n]*\n([^\n]*)")) + end + return version end end - local version = sys.exec(string.format("echo -n $(%s %s)", util.shellquote(file), cmd)) + local version, tags + if sb_tags then + local output = sys.exec(string.format("%s %s", util.shellquote(file), cmd)) + version = output:match("^sing%-box%s+version%s+(%S+)") or "" + tags = trim(output:match("\nTags:[ \t]*([^\r\n]*)")) + else + version = sys.exec(string.format("echo -n $(%s %s)", util.shellquote(file), cmd)) + end if version and version ~= "" then if cache_file then fs.mkdirr(CACHE_PATH) - fs.writefile(cache_file, version) + fs.writefile(cache_file, sb_tags and (version .. "\n" .. tags .. "\n") or version) end + if sb_tags then return version, tags end return version end end @@ -924,7 +936,14 @@ end function get_app_version(app_name, file) if file == nil then file = get_app_path(app_name) end - return get_bin_version_cache(file, com[app_name].cmd_version) + local version = get_bin_version_cache(file, com[app_name].cmd_version, app_name == "sing-box") + return version +end + +function get_singbox_tags() + local file = get_app_path("sing-box") + local _, tags = get_bin_version_cache(file, com["sing-box"].cmd_version, true) + return tags or "" end local function is_file(path) @@ -1484,11 +1503,6 @@ function to_move(app_name,file) } end - local flag = sys.call('busybox pgrep -af "passwall/.*'.. app_name ..'" >/dev/null') - if flag == 0 then - sys.call("/etc/init.d/passwall stop") - end - local old_app_size = 0 if fs.access(app_path) then old_app_size = get_file_space(app_path) @@ -1504,6 +1518,11 @@ function to_move(app_name,file) end end + local flag = sys.call('busybox pgrep -af "passwall/.*'.. app_name ..'" >/dev/null') + if flag == 0 then + sys.call("/etc/init.d/passwall stop") + end + result = exec("/bin/mv", { "-f", bin_path, app_path }, nil, command_timeout) == 0 remove(rm_tmp) diff --git a/luci-app-passwall/luasrc/passwall/com.lua b/luci-app-passwall/luasrc/passwall/com.lua index 2a2861b8..ca0cd367 100644 --- a/luci-app-passwall/luasrc/passwall/com.lua +++ b/luci-app-passwall/luasrc/passwall/com.lua @@ -46,7 +46,7 @@ _M["sing-box"] = { name = "Sing-Box", repo = "SagerNet/sing-box", get_url = gh_release_url, - cmd_version = "version | awk '{print $3}' | sed -n 1P", + cmd_version = "version", zipped = true, zipped_suffix = "tar.gz", default_path = "/usr/bin/sing-box", diff --git a/luci-app-passwall/luasrc/passwall/util_hysteria2.lua b/luci-app-passwall/luasrc/passwall/util_hysteria2.lua index 51924690..82b79e79 100644 --- a/luci-app-passwall/luasrc/passwall/util_hysteria2.lua +++ b/luci-app-passwall/luasrc/passwall/util_hysteria2.lua @@ -145,7 +145,9 @@ function gen_config(var) }, quic = { initStreamReceiveWindow = tonumber(node.hysteria2_stream_recv_win) and tonumber(node.hysteria2_stream_recv_win)*1048576 or nil, + maxStreamReceiveWindow = tonumber(node.hysteria2_stream_recv_win) and tonumber(node.hysteria2_stream_recv_win)*1048576 or nil, initConnReceiveWindow = tonumber(node.hysteria2_conn_recv_win) and tonumber(node.hysteria2_conn_recv_win)*1048576 or nil, + maxConnReceiveWindow = tonumber(node.hysteria2_conn_recv_win) and tonumber(node.hysteria2_conn_recv_win)*1048576 or nil, maxIdleTimeout = (function(t) t = tonumber(tostring(t or "30"):match("^%d+")) return (t and t >= 4 and t <= 120) and t .. "s" or "30s" diff --git a/luci-app-passwall/luasrc/passwall/util_xray.lua b/luci-app-passwall/luasrc/passwall/util_xray.lua index f7e9619c..0d8f7d95 100644 --- a/luci-app-passwall/luasrc/passwall/util_xray.lua +++ b/luci-app-passwall/luasrc/passwall/util_xray.lua @@ -393,7 +393,9 @@ function gen_outbound(flag, node, tag, proxy_table) end)(node.hysteria2_keep_alive_period), disablePathMTUDiscovery = node.hysteria2_disable_mtu_discovery == "1", initStreamReceiveWindow = tonumber(node.hysteria2_stream_recv_win) and tonumber(node.hysteria2_stream_recv_win)*1048576 or nil, + maxStreamReceiveWindow = tonumber(node.hysteria2_stream_recv_win) and tonumber(node.hysteria2_stream_recv_win)*1048576 or nil, initConnectionReceiveWindow = tonumber(node.hysteria2_conn_recv_win) and tonumber(node.hysteria2_conn_recv_win)*1048576 or nil, + maxConnectionReceiveWindow = tonumber(node.hysteria2_conn_recv_win) and tonumber(node.hysteria2_conn_recv_win)*1048576 or nil, } end if fragment and fragment_table and ({raw=1, ws=1, httpupgrade=1, grpc=1, xhttp=1})[TP] then diff --git a/luci-app-passwall/luasrc/view/passwall/node_list/node_list.htm b/luci-app-passwall/luasrc/view/passwall/node_list/node_list.htm index 731a35c8..21255332 100644 --- a/luci-app-passwall/luasrc/view/passwall/node_list/node_list.htm +++ b/luci-app-passwall/luasrc/view/passwall/node_list/node_list.htm @@ -451,12 +451,17 @@ table td, .table .td { .pw-filter select, .pw-filter input[type="search"], .pw-filter .cbi-button { + box-sizing: border-box !important; width: auto !important; min-width: 6em; max-width: 22em; - height: auto; - padding: 0.15em 0.3em; - margin: 0; + height: 1.65rem !important; + min-height: 0 !important; + max-height: none !important; + font: inherit !important; + line-height: 1.2 !important; + padding: 0.15em 0.3em !important; + margin: 0 !important; } .pw-filter .pw-filter-count { diff --git a/luci-app-passwall/luasrc/view/passwall/socks_auto_switch/btn.htm b/luci-app-passwall/luasrc/view/passwall/socks_auto_switch/btn.htm index 657cc85a..c4cb010d 100644 --- a/luci-app-passwall/luasrc/view/passwall/socks_auto_switch/btn.htm +++ b/luci-app-passwall/luasrc/view/passwall/socks_auto_switch/btn.htm @@ -9,13 +9,13 @@ local api = map.api function add_node_by_key() { var key = prompt("<%:Please enter the node keyword, pay attention to distinguish between spaces, uppercase and lowercase.%>", ""); if (key) { - window.location.href = '<%=api.url("socks_autoswitch_add_node")%>' + "?id=" + socks_id + "&key=" + key; + window.location.href = '<%=api.url("socks_autoswitch_add_node")%>' + "?id=" + encodeURIComponent(socks_id) + "&key=" + encodeURIComponent(key); } } function remove_node_by_key() { var key = prompt("<%:Please enter the node keyword, pay attention to distinguish between spaces, uppercase and lowercase.%>", ""); if (key) { - window.location.href = '<%=api.url("socks_autoswitch_remove_node")%>' + "?id=" + socks_id + "&key=" + key; + window.location.href = '<%=api.url("socks_autoswitch_remove_node")%>' + "?id=" + encodeURIComponent(socks_id) + "&key=" + encodeURIComponent(key); } } //]]> diff --git a/luci-app-passwall2/luasrc/controller/passwall2.lua b/luci-app-passwall2/luasrc/controller/passwall2.lua index 977e7fdf..73d0eb09 100644 --- a/luci-app-passwall2/luasrc/controller/passwall2.lua +++ b/luci-app-passwall2/luasrc/controller/passwall2.lua @@ -192,12 +192,12 @@ function socks_autoswitch_add_node() uci_set(id, "enable_autoswitch", "1") local new_list = uci_get(id, "autoswitch_backup_node") or {} for i = #new_list, 1, -1 do - if (uci_get(new_list[i], "remarks") or ""):find(key) then + if (uci_get(new_list[i], "remarks") or ""):find(key, 1, true) then table.remove(new_list, i) end end for k, e in ipairs(api.get_valid_nodes()) do - if e.node_type == "normal" and e["remark"]:find(key) then + if e.node_type == "normal" and e["remark"]:find(key, 1, true) then table.insert(new_list, e.id) end end @@ -214,7 +214,7 @@ function socks_autoswitch_remove_node() uci_set(id, "enable_autoswitch", "1") local new_list = uci_get(id, "autoswitch_backup_node") or {} for i = #new_list, 1, -1 do - if (uci_get(new_list[i], "remarks") or ""):find(key) then + if (uci_get(new_list[i], "remarks") or ""):find(key, 1, true) then table.remove(new_list, i) end end diff --git a/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/1_sing-box.lua b/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/1_sing-box.lua index 86c4f086..83887a53 100644 --- a/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/1_sing-box.lua +++ b/luci-app-passwall2/luasrc/model/cbi/passwall2/client/type/1_sing-box.lua @@ -33,7 +33,7 @@ local ss_method_old_list = { local security_list = { "none", "auto", "aes-128-gcm", "chacha20-poly1305", "zero" } -local singbox_tags = luci.sys.exec(singbox_bin .. " version | grep 'Tags:' | awk '{print $2}'") +local singbox_tags = api.get_singbox_tags() local singbox_version = api.get_app_version("sing-box"):match("[^v]+") local version_ge_1_14_0 = api.compare_versions(singbox_version, ">=", "1.14.0") diff --git a/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/1_sing-box.lua b/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/1_sing-box.lua index 7d94532a..8b1dbff5 100644 --- a/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/1_sing-box.lua +++ b/luci-app-passwall2/luasrc/model/cbi/passwall2/server/type/1_sing-box.lua @@ -27,7 +27,7 @@ s.type_name = type_name s.option_prefix = "singbox_" api.set_type_cbi(s) -local singbox_tags = luci.sys.exec(singbox_bin .. " version | grep 'Tags:' | awk '{print $2}'") +local singbox_tags = api.get_singbox_tags() local local_version = api.get_app_version("sing-box"):match("[^v]+") local version_ge_1_14_0 = api.compare_versions(local_version, ">=", "1.14.0") diff --git a/luci-app-passwall2/luasrc/passwall2/api.lua b/luci-app-passwall2/luasrc/passwall2/api.lua index ce424249..39f92dea 100644 --- a/luci-app-passwall2/luasrc/passwall2/api.lua +++ b/luci-app-passwall2/luasrc/passwall2/api.lua @@ -928,7 +928,7 @@ function clone(org) return res end -local function get_bin_version_cache(file, cmd) +local function get_bin_version_cache(file, cmd, sb_tags) if file and file ~= "" and fs.access(file) then chmod_755(file) local stat = fs.stat(file) @@ -937,16 +937,28 @@ local function get_bin_version_cache(file, cmd) -- Use metadata to avoid hashing the entire binary on every lookup. cache_file = CACHE_PATH .. "/binver_" .. table.concat({stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime}, "_") local cached = fs.readfile(cache_file) - if cached and trim(cached) ~= "" then - return trim(cached) + local version = cached and trim(cached:match("^[^\n]*")) + if version and version ~= "" then + if sb_tags then + return version, trim(cached:match("^[^\n]*\n([^\n]*)")) + end + return version end end - local version = sys.exec(string.format("echo -n $(%s %s)", util.shellquote(file), cmd)) + local version, tags + if sb_tags then + local output = sys.exec(string.format("%s %s", util.shellquote(file), cmd)) + version = output:match("^sing%-box%s+version%s+(%S+)") or "" + tags = trim(output:match("\nTags:[ \t]*([^\r\n]*)")) + else + version = sys.exec(string.format("echo -n $(%s %s)", util.shellquote(file), cmd)) + end if version and version ~= "" then if cache_file then fs.mkdirr(CACHE_PATH) - fs.writefile(cache_file, version) + fs.writefile(cache_file, sb_tags and (version .. "\n" .. tags .. "\n") or version) end + if sb_tags then return version, tags end return version end end @@ -964,7 +976,14 @@ end function get_app_version(app_name, file) if file == nil then file = get_app_path(app_name) end - return get_bin_version_cache(file, com[app_name].cmd_version) + local version = get_bin_version_cache(file, com[app_name].cmd_version, app_name == "sing-box") + return version +end + +function get_singbox_tags() + local file = get_app_path("sing-box") + local _, tags = get_bin_version_cache(file, com["sing-box"].cmd_version, true) + return tags or "" end function is_file(path) @@ -1499,11 +1518,6 @@ function to_move(app_name,file) } end - local flag = sys.call('busybox pgrep -af "passwall2/.*'.. app_name ..'" >/dev/null') - if flag == 0 then - sys.call("/etc/init.d/passwall2 stop") - end - local old_app_size = 0 if fs.access(app_path) then old_app_size = get_file_space(app_path) @@ -1519,6 +1533,11 @@ function to_move(app_name,file) end end + local flag = sys.call('busybox pgrep -af "passwall2/.*'.. app_name ..'" >/dev/null') + if flag == 0 then + sys.call("/etc/init.d/passwall2 stop") + end + result = exec("/bin/mv", { "-f", bin_path, app_path }, nil, command_timeout) == 0 sys.call(cmd_rm_tmp) diff --git a/luci-app-passwall2/luasrc/passwall2/com.lua b/luci-app-passwall2/luasrc/passwall2/com.lua index 97142402..532ffc66 100644 --- a/luci-app-passwall2/luasrc/passwall2/com.lua +++ b/luci-app-passwall2/luasrc/passwall2/com.lua @@ -26,7 +26,7 @@ _M["sing-box"] = { name = "Sing-Box", repo = "SagerNet/sing-box", get_url = gh_release_url, - cmd_version = "version | awk '{print $3}' | sed -n 1P", + cmd_version = "version", zipped = true, zipped_suffix = "tar.gz", default_path = "/usr/bin/sing-box", diff --git a/luci-app-passwall2/luasrc/passwall2/util_xray.lua b/luci-app-passwall2/luasrc/passwall2/util_xray.lua index 45fb1e81..f400785b 100644 --- a/luci-app-passwall2/luasrc/passwall2/util_xray.lua +++ b/luci-app-passwall2/luasrc/passwall2/util_xray.lua @@ -402,7 +402,9 @@ function gen_outbound(flag, node, tag, proxy_table) end)(node.hysteria2_keep_alive_period), disablePathMTUDiscovery = node.hysteria2_disable_mtu_discovery == "1", initStreamReceiveWindow = tonumber(node.hysteria2_stream_recv_win) and tonumber(node.hysteria2_stream_recv_win)*1048576 or nil, + maxStreamReceiveWindow = tonumber(node.hysteria2_stream_recv_win) and tonumber(node.hysteria2_stream_recv_win)*1048576 or nil, initConnectionReceiveWindow = tonumber(node.hysteria2_conn_recv_win) and tonumber(node.hysteria2_conn_recv_win)*1048576 or nil, + maxConnectionReceiveWindow = tonumber(node.hysteria2_conn_recv_win) and tonumber(node.hysteria2_conn_recv_win)*1048576 or nil, } end if fragment and fragment_table and ({raw=1, ws=1, httpupgrade=1, grpc=1, xhttp=1})[TP] then diff --git a/luci-app-passwall2/luasrc/view/passwall2/node_list/node_list.htm b/luci-app-passwall2/luasrc/view/passwall2/node_list/node_list.htm index d5fc0235..7426206f 100644 --- a/luci-app-passwall2/luasrc/view/passwall2/node_list/node_list.htm +++ b/luci-app-passwall2/luasrc/view/passwall2/node_list/node_list.htm @@ -449,12 +449,17 @@ table td, .table .td { .pw-filter select, .pw-filter input[type="search"], .pw-filter .cbi-button { + box-sizing: border-box !important; width: auto !important; min-width: 6em; max-width: 22em; - height: auto; - padding: 0.15em 0.3em; - margin: 0; + height: 1.65rem !important; + min-height: 0 !important; + max-height: none !important; + font: inherit !important; + line-height: 1.2 !important; + padding: 0.15em 0.3em !important; + margin: 0 !important; } .pw-filter .pw-filter-count { diff --git a/luci-app-passwall2/luasrc/view/passwall2/socks_auto_switch/btn.htm b/luci-app-passwall2/luasrc/view/passwall2/socks_auto_switch/btn.htm index 657cc85a..c4cb010d 100644 --- a/luci-app-passwall2/luasrc/view/passwall2/socks_auto_switch/btn.htm +++ b/luci-app-passwall2/luasrc/view/passwall2/socks_auto_switch/btn.htm @@ -9,13 +9,13 @@ local api = map.api function add_node_by_key() { var key = prompt("<%:Please enter the node keyword, pay attention to distinguish between spaces, uppercase and lowercase.%>", ""); if (key) { - window.location.href = '<%=api.url("socks_autoswitch_add_node")%>' + "?id=" + socks_id + "&key=" + key; + window.location.href = '<%=api.url("socks_autoswitch_add_node")%>' + "?id=" + encodeURIComponent(socks_id) + "&key=" + encodeURIComponent(key); } } function remove_node_by_key() { var key = prompt("<%:Please enter the node keyword, pay attention to distinguish between spaces, uppercase and lowercase.%>", ""); if (key) { - window.location.href = '<%=api.url("socks_autoswitch_remove_node")%>' + "?id=" + socks_id + "&key=" + key; + window.location.href = '<%=api.url("socks_autoswitch_remove_node")%>' + "?id=" + encodeURIComponent(socks_id) + "&key=" + encodeURIComponent(key); } } //]]> diff --git a/luci-app-quickstart/luasrc/controller/istore_backend.lua b/luci-app-quickstart/luasrc/controller/istore_backend.lua index a5c2c6c6..75bde792 100644 --- a/luci-app-quickstart/luasrc/controller/istore_backend.lua +++ b/luci-app-quickstart/luasrc/controller/istore_backend.lua @@ -22,7 +22,7 @@ local function sink_socket(sock, io_err) if not chunk then return 1 else - return sock:send(chunk) + return sock:sendall(chunk) end end else @@ -64,14 +64,17 @@ local function chunksource(sock, buffer) return function() local output local _, endp, count = buffer:find("^([0-9a-fA-F]+);?.-\r\n") - while not count and #buffer <= 1024 do + while not count and #buffer < 1024 do local newblock, code = sock:recv(1024 - #buffer) - if not newblock then + if not newblock or newblock == "" then return nil, code end buffer = buffer .. newblock _, endp, count = buffer:find("^([0-9a-fA-F]+);?.-\r\n") end + if not count then + return nil, -1, "invalid encoding" + end count = tonumber(count, 16) if not count then return nil, -1, "invalid encoding" @@ -83,6 +86,7 @@ local function chunksource(sock, buffer) return output else output = buffer:sub(endp+1, endp+count) + local buffered_tail = #buffer - endp - #output buffer = "" if count - #output > 0 then local remain, code = sock:recvall(count-#output) @@ -92,7 +96,7 @@ local function chunksource(sock, buffer) output = output .. remain count, code = sock:recvall(2) else - count, code = sock:recvall(count+2-#buffer+endp) + count, code = sock:recvall(2-buffered_tail) end if not count then return nil, code diff --git a/luci-app-quickstart/luasrc/controller/quickstart.lua b/luci-app-quickstart/luasrc/controller/quickstart.lua index cf8a32b1..9bbff133 100644 --- a/luci-app-quickstart/luasrc/controller/quickstart.lua +++ b/luci-app-quickstart/luasrc/controller/quickstart.lua @@ -95,14 +95,30 @@ function auto_setup() -- form style local packages = luci.http.formvalue("packages") local pkgs = "" + local function valid_package(value) + return type(value) == "string" and #value <= 128 and + value:match("^[%w][%w%.%+_%-]*$") ~= nil + end + local function invalid_package() + luci.http.prepare_content("application/json") + luci.http.write_json({success=1, scope="params", error="Invalid package name"}) + end if type(packages) == "table" then if #packages > 0 then for k, v in pairs(packages) do + if not valid_package(v) then + invalid_package() + return + end pkgs = pkgs .. " " .. luci.util.shellquote(v) end end else if packages ~= nil and packages ~= "" then + if not valid_package(packages) then + invalid_package() + return + end pkgs = luci.util.shellquote(packages) end end diff --git a/luci-app-quickstart/root/usr/libexec/quickstart/auto_setup.sh b/luci-app-quickstart/root/usr/libexec/quickstart/auto_setup.sh index a69d2f43..fe086940 100755 --- a/luci-app-quickstart/root/usr/libexec/quickstart/auto_setup.sh +++ b/luci-app-quickstart/root/usr/libexec/quickstart/auto_setup.sh @@ -1,12 +1,22 @@ #!/bin/sh +# Validate all arguments before touching logs, installing packages or sourcing +# metadata scripts. This script is also callable without the LuCI controller. +[ "$#" -gt 0 ] || { echo "No packages specified" >&2; exit 1; } +for pkg in "$@"; do + case "$pkg" in + ''|[!a-zA-Z0-9]*|*[!a-zA-Z0-9_.+-]*) echo "Invalid package name" >&2; exit 1 ;; + esac + [ "${#pkg}" -le 128 ] || { echo "Invalid package name" >&2; exit 1; } +done + > /var/log/auto_setup.success > /var/log/auto_setup.failed > /var/log/auto_setup.input save_input() { local pkg - for pkg in $@; do + for pkg in "$@"; do echo "$pkg" >> /var/log/auto_setup.input done } @@ -37,14 +47,14 @@ load_quickstart_cfg() { auto_setup_app() { local pkg=$1 is-opkg install "app-meta-$pkg" || return 1 - sh -c ". '/usr/libexec/istorea/$pkg.sh'" + sh -c '. "$1"' sh "/usr/libexec/istorea/$pkg.sh" } auto_setup_apps() { local pkg - for pkg in $@; do + for pkg in "$@"; do echo "Setting up $pkg..." - if auto_setup_app $pkg; then + if auto_setup_app "$pkg"; then echo "Set up $pkg success" echo "$pkg" >> /var/log/auto_setup.success else diff --git a/luci-app-quickstart/tests/auto_setup_test.py b/luci-app-quickstart/tests/auto_setup_test.py new file mode 100644 index 00000000..c03920ea --- /dev/null +++ b/luci-app-quickstart/tests/auto_setup_test.py @@ -0,0 +1,55 @@ +"""Exercise the real script with task-owned paths and mocked package installs.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +class AutoSetupTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.logs = self.root / "log" + self.logs.mkdir() + (self.root / "bin").mkdir() + (self.root / "metadata").mkdir() + self.installed = self.root / "installed" + self.configured = self.root / "configured" + helper = self.root / "functions.sh" + helper.write_text('config_load() { return 0; }\nconfig_get() { eval "$1=\\\"$FIXTURE_MAIN\\\""; }\n') + installer = self.root / "bin/is-opkg" + installer.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >> "$FIXTURE_INSTALLED"\n') + installer.chmod(0o700) + source = (Path(__file__).resolve().parents[1] / "root/usr/libexec/quickstart/auto_setup.sh").read_text() + source = source.replace("/var/log", str(self.logs)).replace("/lib/functions.sh", str(helper)).replace("/usr/libexec/istorea", str(self.root / "metadata")) + self.script = self.root / "auto_setup.sh" + self.script.write_text(source) + self.env = dict(os.environ, PATH=str(self.root / "bin") + ":" + os.environ["PATH"], FIXTURE_MAIN=str(self.root / "data"), FIXTURE_INSTALLED=str(self.installed), FIXTURE_CONFIGURED=str(self.configured)) + + def run_script(self, *args): + return subprocess.run(["sh", str(self.script), *args], env=self.env, capture_output=True, text=True, timeout=5) + + def test_rejects_all_invalid_arguments_before_side_effects(self): + for name in ("../other", "--help", "two packages", "pkg'", "", "a" * 129): + result = self.run_script("aria2", name) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.installed.exists()) + self.assertEqual(list(self.logs.iterdir()), []) + + def test_normal_packages_install_configure_and_report(self): + packages = ("aria2", "foo+bar_1.2-3") + for name in packages: + (self.root / "metadata" / (name + ".sh")).write_text('printf "%s\\n" "' + name + '" >> "$FIXTURE_CONFIGURED"\n') + result = self.run_script(*packages) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.installed.read_text().splitlines(), ["install", "app-meta-aria2", "install", "app-meta-foo+bar_1.2-3"]) + self.assertEqual(self.configured.read_text().splitlines(), list(packages)) + self.assertEqual((self.logs / "auto_setup.success").read_text().splitlines(), list(packages)) + self.assertEqual((self.logs / "auto_setup.input").read_text().splitlines(), list(packages)) + self.assertEqual((self.logs / "auto_setup.failed").read_text(), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/luci-app-quickstart/tests/security_regression.lua b/luci-app-quickstart/tests/security_regression.lua new file mode 100644 index 00000000..0d788d58 --- /dev/null +++ b/luci-app-quickstart/tests/security_regression.lua @@ -0,0 +1,88 @@ +-- Run against a staged source copy: lua tests/security_regression.lua ROOT +-- All LuCI, filesystem and command dependencies are mocked. +local root = assert(arg[1], "source root required") +local forms, response, executions = {}, nil, {} +local http = { + formvalue = function(key) return forms[key] end, + prepare_content = function() end, + write_json = function(value) response = value end, +} +local fs = { readfile = function() return "" end, unlink = function() end } +local nixio = { bit = { rshift = function(value, bits) return math.floor(value / 2^bits) end } } +local util = { shellquote = function(value) return "'" .. value:gsub("'", "'\\''") .. "'" end } +package.preload["luci.http"] = function() return http end +package.preload["luci.util"] = function() return util end +package.preload["luci.ltn12"] = function() return {} end +package.preload["nixio"] = function() return nixio end +package.preload["nixio.fs"] = function() return fs end +_G.luci = { http = http, util = util } +_G.nixio = nixio +local original_execute = os.execute +os.execute = function(command) executions[#executions+1] = command; return 0 end +assert(loadfile(root .. "/luasrc/controller/quickstart.lua"))() +local quickstart = package.loaded["luci.controller.quickstart"] +for _, packages in ipairs({"../other", "--help", "two packages", "pkg'", {"aria2", "../other"}, {"aria2", {}}}) do + forms, response, executions = {packages=packages}, nil, {} + quickstart.auto_setup() + assert(response and response.success ~= 0 and response.scope == "params", "invalid package accepted") + assert(#executions == 0, "invalid package reached shell") +end +for _, packages in ipairs({"aria2", {"qbittorrent", "foo+bar_1.2-3"}}) do + forms, response, executions = {packages=packages}, nil, {} + quickstart.auto_setup() + assert(response and response.success == 0 and #executions == 1, "normal package failed") +end +os.execute = original_execute + +assert(loadfile(root .. "/luasrc/controller/istore_backend.lua"))() +local proxy = package.loaded["luci.controller.istore_backend"].istore_backend +local function upvalue(name) + for i=1,100 do + local key, value = debug.getupvalue(proxy, i) + if not key then break end + if key == name then return value end + end + error("missing proxy helper " .. name) +end +local sink = upvalue("sink_socket") +local sent = "" +assert(sink({sendall=function(_, chunk) sent=sent..chunk; return #chunk end})("whole body")) +assert(sent == "whole body", "proxy did not send complete input") + +local chunksource = upvalue("chunksource") +local wire = "5\r\nhello\r\n5\r\nworld\r\n0\r\n\r\n" +for split=0,#wire do + local pos, calls = split+1, 0 + local sock = {} + function sock:recv(n) + assert(n > 0, "zero-length receive") + calls = calls + 1 + assert(calls < 100, "unbounded receive loop") + local data=wire:sub(pos, pos+math.min(n, 3)-1) + pos=pos+#data + return data + end + function sock:recvall(n) + local result="" + while #result +LUCI_URL:=https://github.com/VizzleTF/luci-theme-footstrap + +# CSS ships verbatim: luci.mk's csstidy mangles :has(), color-mix() and nested calc(). build-css.sh +# minifies. +LUCI_MINIFY_CSS:=0 +# +# JS: the released package is built by owfeed (tools/stage.sh), not by this Makefile through the +# SDK, so LUCI_MINIFY_JS keeps luci.mk's jsmin. jsmin's one-character lookback does not know `n` or +# `>`, so `return /re/` makes it eat the rest of the file AND EXIT 0 (openwrt/luci#8299): wrap a +# regex literal there in parentheses. + +# Apache-2.0, not a free choice: styles/base began as a fork of luci-theme-bootstrap's cascade.css, +# the ucode templates derive from LuCI's and a few JS helpers are verbatim copies; the notices +# travel with it. No OFL-1.1: the theme ships no Font Software. +PKG_LICENSE:=Apache-2.0 +PKG_LICENSE_FILES:=LICENSE + +# /etc/config/footstrap is SHIPPED as an empty stub and WRITTEN AT RUNTIME: Appearance -> +# "Save as default" has rpcd uci-set the router-wide axes into that very file. Without this define +# the package manager owns it as an ordinary file and REPLACES it on upgrade, so the admin's saved +# defaults are wiped by the theme's own one-click Update, silently and reported as success. +# OpenWrt honours the define for BOTH formats (include/package-pack.mk), so one covers 24.10's opkg +# and 25.12's apk alike. Any future root/etc/config/* must be listed here too. +define Package/luci-theme-footstrap/conffiles +/etc/config/footstrap +endef + +# No `rpcd reload`: it re-execs rpcd, which drops a plugin that is absent mid-write until the next +# reload, and this package registers no rpcd object (rpcd re-reads acl.d at every login). The postinst +# ends a session only when a fresh login would grant it more. Comments inside a define ship into the +# router's script store (1984 of 3239 B), so the bodies are code only (`set -f`: the unquoted +# `$$(uci -q get …)` lists must split, not glob a `*`). docs/package.md, "Why postinst/postrm never +# call `rpcd reload`". +define Package/luci-theme-footstrap/postinst +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* >/dev/null 2>&1 || true + set -f + ubus list 2>/dev/null | grep -qx session && { + for s in $$(ubus call session list 2>/dev/null | grep -o '"ubus_rpc_session": "[a-f0-9]*"' | cut -d'"' -f4); do + case "$$s" in *[!0]*) ;; *) continue ;; esac + ubus call session access "{\"ubus_rpc_session\":\"$$s\",\"scope\":\"uci\",\"object\":\"footstrap\",\"function\":\"write\"}" 2>/dev/null | grep -q true && continue + u=$$(ubus call session get "{\"ubus_rpc_session\":\"$$s\"}" 2>/dev/null | grep -o '"username": *"[^"]*"' | cut -d'"' -f4) + [ -n "$$u" ] || continue + perm=$$(uci show rpcd 2>/dev/null | grep -F .username= | while read -r kv; do + sect=$${kv%%.username=*} + un=$${kv#*.username=} + un=$${un#\'} + un=$${un%\'} + [ "$$un" = "$$u" ] || continue + for grp in $$(uci -q get "$$sect.write"); do + [ "$$grp" = luci-theme-footstrap ] && { echo write; break 2; } + done + for grp in $$(uci -q get "$$sect.read"); do + [ "$$grp" = luci-theme-footstrap ] && { echo read; break 2; } + done + done) + [ -n "$$perm" ] || continue + [ "$$perm" = read ] && ubus call session access "{\"ubus_rpc_session\":\"$$s\",\"scope\":\"uci\",\"object\":\"footstrap\",\"function\":\"read\"}" 2>/dev/null | grep -q true && continue + ubus call session destroy "{\"ubus_rpc_session\":\"$$s\"}" >/dev/null 2>&1 || true + done + } +} +exit 0 +endef + +# opkg runs the OLD package's postrm with "upgrade" on a version upgrade; it must change nothing +# there, since reverting mediaurlbase flipped upgrading 24.10 users back to bootstrap (apk never +# runs it on upgrade). Removal restores bootstrap only if its media dir and header.ut exist, drops +# /etc/footstrap (the uploaded login background) and, as in the postinst, no rpcd reload. +define Package/luci-theme-footstrap/postrm +#!/bin/sh +case "$$1" in *upgrade*) exit 0 ;; esac +[ -n "$${IPKG_INSTROOT}" ] || { + uci -q delete luci.themes.Footstrap + case "$$(uci -q get luci.main.mediaurlbase)" in + /luci-static/footstrap*) + [ -d /www/luci-static/bootstrap ] && \ + [ -f /usr/share/ucode/luci/template/themes/bootstrap/header.ut ] && \ + uci set luci.main.mediaurlbase=/luci-static/bootstrap ;; + esac + uci commit luci + rm -rf /etc/footstrap >/dev/null 2>&1 || true + rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* >/dev/null 2>&1 || true +} +endef + +# Runs right after luci.mk copies the sources into PKG_BUILD_DIR, so it rewrites the build copy +# only. cascade.css is generated, not committed, and styles/ is not in luci.mk's copy list: +# build-css.sh reads it from $(CURDIR) (cat and awk only, so it runs on a buildbot). The git-derived +# version is stamped into fs-version.js unconditionally; only an un-built checkout keeps +# '0.0.0-dev'. The catalogue is luci.mk's business: `po/` is what LUCI_LANGUAGES globs. +define Build/Prepare/luci-theme-footstrap + # PKG_LICENSE_FILES resolves against $(PKG_BUILD_DIR), which luci.mk fills with only src/ luasrc/ + # htdocs/ root/ ucode/; the copy is metadata only, luci.mk never installs it. + $(CP) $(CURDIR)/LICENSE $(PKG_BUILD_DIR)/ + $(SHELL) $(CURDIR)/build-css.sh $(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css + # Private `--fs-*` names are 13% of the sheet; runs BEFORE the templates strip, which takes the + # comments it reads. + $(SHELL) $(CURDIR)/mangle-tokens.sh \ + $(PKG_BUILD_DIR)/htdocs/luci-static/footstrap/cascade.css \ + $(PKG_BUILD_DIR)/htdocs/luci-static/resources $(PKG_BUILD_DIR)/ucode + # Strips `{# … #}`, whole-line `/* … */` and the indentation from the templates (-27.2 KB of 47.2). + # dev-sync.sh does not run this, so a router being debugged keeps its comments and indentation. + $(SHELL) $(CURDIR)/strip-build.sh templates $(PKG_BUILD_DIR)/ucode + # The same for the shell under root/ (about half of uci-defaults and the CGI are comments); + # whole-line `#` only. + $(SHELL) $(CURDIR)/strip-build.sh shell $(PKG_BUILD_DIR)/root + # The SVG favicon's comment and the manifest's indentation; awk only, so no node on a buildbot. + $(SHELL) $(CURDIR)/strip-build.sh assets $(PKG_BUILD_DIR)/htdocs/luci-static/footstrap + $(SED) "s#const FS_VERSION *= *'[^']*'#const FS_VERSION = '$(if $(PKG_VERSION),$(PKG_VERSION),$(PKG_SRC_VERSION))'#" \ + $(PKG_BUILD_DIR)/htdocs/luci-static/resources/fs-version.js +endef + +# Absolute, not a relative path: the usual ../../luci.mk resolves only inside the luci feed. +include $(TOPDIR)/feeds/luci/luci.mk + +# Load-bearing, not boilerplate: include/scan.mk finds packages by GREPPING Makefiles for +# `call (Build/DefaultTargets|BuildPackage|KernelPackage)`. luci.mk calls BuildPackage, which the +# grep cannot see, so without the literal line below the package gets no CONFIG_PACKAGE_* symbol and +# `make package//compile` fails with "No rule to make target". +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-theme-footstrap/README.md b/luci-theme-footstrap/README.md new file mode 100644 index 00000000..fd99bf69 --- /dev/null +++ b/luci-theme-footstrap/README.md @@ -0,0 +1,59 @@ +# luci-theme-footstrap (the package) + +A LuCI theme for OpenWrt **24.10.3 and newer** (ucode templates). Installing and using it: +[the repository README](../README.md). Developer documentation: [`../docs/`](../docs/README.md). + +Internal name: `footstrap`. Media path: `/luci-static/footstrap`. + +## One theme, one entry + +`luci.themes` carries **exactly one** entry, `Footstrap` → `/luci-static/footstrap`. Everything +else is a **client** axis on the System → Footstrap page: layout (top bar by default, or the side +menu), mode, palette, density, wallpaper, rounding and every colour. The colours are the accent, the +three status colours and the four surfaces. The axes live in `localStorage` plus attributes and +inline properties on `:root`. Nothing is written to the router until "Save to router" is pressed. There are no per-layout or per-mode themes. + +There is one menu renderer too, `renderMainMenu` in `fs-chrome.js`. The top bar is its own markup, +morphed by CSS through `:root[data-layout]`. No second template, no second renderer, no `footstrap-top` symlink. + +**The theme's boundary:** it supplies the chrome and the design language; page content is drawn by +the view JS of `luci-mod-*`. The one exception is `fs-overview.js`, which draws no content of its own +and only re-arranges the stock overview sections. + +## Layout + +The main parts; the full tree is in [`../docs/package.md`](../docs/package.md) "Source tree". + +``` +Makefile luci.mk; Build/Prepare (CSS, mangle, strip, version) +styles/ CSS source: layers tokens / base / theme / pages +build-css.sh styles/ → htdocs/luci-static/footstrap/cascade.css +po/ translation catalogue (Weblate translates this) +ucode/template/themes/footstrap/ header.ut, footer.ut, sysauth.ut, partials/ +htdocs/luci-static/footstrap/ cascade.css (generated), logo.svg; no webfonts, no wallpaper +htdocs/luci-static/resources/ menu-footstrap*.js, fs-*.js, view/footstrap/appearance.js +root/ uci-defaults, the conffile, menu.d, the ACL, keep.d, the pattern CGI +``` + +There is no update checker. The theme is installed from the package feed (`install.sh` adds it), so +`apk upgrade` / `opkg upgrade` carries it forward like everything else on the router. The +System → Footstrap page shows the version it is running and makes no network call to do it. + +**Do not edit `cascade.css`.** `build-css.sh` generates it from `styles/`, and git ignores it. +Colours go in `styles/03-palettes.css`, scales and tokens in `styles/02-tokens.css`. + +## Working on a router + +The normal dev stand is the disposable routers `../owlab.yaml` defines, driven by `owlab`. See +[`../docs/development.md`](../docs/development.md). `dev-sync.sh` targets a **hardware** router over +ssh: + +```sh +./dev-sync.sh [host] # push over ssh, default host router2512 (registers the theme but does NOT activate it) +ssh router2512 'uci set luci.main.mediaurlbase=/luci-static/footstrap; uci commit luci; rm -f /tmp/luci-indexcache*' +ssh router2512 'uci set luci.main.mediaurlbase=/luci-static/bootstrap; uci commit luci' # roll back +``` + +Before pushing, see [`../docs/verifying.md`](../docs/verifying.md). The rules and the traps are in +[`../docs/conventions.md`](../docs/conventions.md); packaging is in +[`../docs/package.md`](../docs/package.md). diff --git a/luci-theme-footstrap/build-apk.sh b/luci-theme-footstrap/build-apk.sh new file mode 100755 index 00000000..fe9fe915 --- /dev/null +++ b/luci-theme-footstrap/build-apk.sh @@ -0,0 +1,107 @@ +#!/bin/sh +# Build luci-theme-footstrap as an OpenWrt .apk via the SDK. The theme is noarch, so the package +# installs on any router of that release whatever its CPU architecture. +# +# ./build-apk.sh # download SDK if needed, then build +# BUILD_DIR=~/x ./build-apk.sh +# +# NOT how the release is built (`./tools/stage.sh && owfeed build` is). This exercises the other +# path: the Makefile, luci.mk, jsmin and the SDK's packaging, for anyone building without owfeed. +set -eu + +# Release and gcc string are baked into the SDK filename and downloads.openwrt.org keeps only the +# current point release, so this goes stale: keep it at owfeed.lock's 25.12 `point:`. Override with +# OPENWRT_RELEASE and OPENWRT_SDK_FILE. +REL="${OPENWRT_RELEASE:-25.12.5}" +SDK_BASE="https://downloads.openwrt.org/releases/${REL}/targets/mediatek/filogic" +SDK_FILE="${OPENWRT_SDK_FILE:-openwrt-sdk-${REL}-mediatek-filogic_gcc-14.3.0_musl.Linux-x86_64.tar.zst}" +SDK_URL="$SDK_BASE/$SDK_FILE" +# MUST be a case-sensitive fs (ext4/…), NOT an NTFS/9p Windows mount. +BUILD_DIR="${BUILD_DIR:-/tmp/ow-footstrap-build}" +# FORCE=1 overrides buildroot's host-prereq bail-outs (see step 4). +export FORCE=1 +THEME_DIR="$(cd "$(dirname "$0")" && pwd)" # this package +REPO="$(cd "$THEME_DIR/.." && pwd)" # repo root (holds tools/, .github/) +SDK_DIR="$BUILD_DIR/sdk" +# the SDK signing channel, derived from REL (25.12.2 -> 25.12) +CHANNEL="${REL%.*}" + +mkdir -p "$BUILD_DIR" +cd "$BUILD_DIR" + +# 1. SDK +if [ ! -d "$SDK_DIR" ]; then + echo ">> downloading SDK $REL ..." + # --https-only: GNU wget follows https -> http redirects + wget -q --https-only -O sdk.tar.zst "$SDK_URL" + + # Signature first, checksum under it: the toolchain lands inside the built package. `sha256sums` + # alone proves nothing, it is served unsigned beside the tarball (docs/conventions.md). The + # ed25519 signature, checked with a key pinned from another host (github.com/openwrt/keyring), + # is what holds. Fails CLOSED. Every pin comes from luci-upstream.pin. + . "$THEME_DIR/luci-upstream.pin" + [ -n "${OPENWRT_KEYRING_PIN:-}" ] || { echo "build-apk: OPENWRT_KEYRING_PIN missing from luci-upstream.pin" >&2; exit 1; } + + # OPENWRT_SDK_KEY_, through eval: /bin/sh has no associative arrays + CH_VAR="$(printf '%s' "$CHANNEL" | tr -d .)" + eval "SDK_KEY=\${OPENWRT_SDK_KEY_$CH_VAR:-}" + eval "SDK_KEY_SHA256=\${OPENWRT_SDK_KEY_${CH_VAR}_SHA256:-}" + [ -n "$SDK_KEY" ] && [ -n "$SDK_KEY_SHA256" ] \ + || { echo "build-apk: no SDK signing key for channel $CHANNEL in luci-upstream.pin" >&2; exit 1; } + + echo ">> verifying SDK signature (key $SDK_KEY, channel $CHANNEL) ..." + rm -rf "$BUILD_DIR/usign" + U="$("$REPO/tools/build-usign.sh" "$BUILD_DIR/usign")" + wget -q --https-only -O sha256sums "$SDK_BASE/sha256sums" + wget -q --https-only -O sha256sums.sig "$SDK_BASE/sha256sums.sig" + wget -q --https-only -O openwrt.pub \ + "https://raw.githubusercontent.com/openwrt/keyring/$OPENWRT_KEYRING_PIN/usign/$SDK_KEY" + echo "$SDK_KEY_SHA256 openwrt.pub" | sha256sum -c - >/dev/null \ + || { echo "build-apk: OpenWrt's $CHANNEL key does not match its pin — refusing to verify with it" >&2; exit 1; } + "$U" -V -m sha256sums -p openwrt.pub -x sha256sums.sig \ + || { echo "build-apk: BAD SIGNATURE on OpenWrt's sha256sums — refusing to build with this SDK." >&2; exit 1; } + echo ">> signature verified; now the checksum means something." + + WANT="$(grep -F " *$SDK_FILE" sha256sums | cut -d' ' -f1)" + [ -n "$WANT" ] || { echo "build-apk: no sha256 published for $SDK_FILE" >&2; exit 1; } + GOT="$(sha256sum sdk.tar.zst | cut -d' ' -f1)" + [ "$WANT" = "$GOT" ] || { echo "build-apk: SDK checksum mismatch: want $WANT, got $GOT" >&2; exit 1; } + rm -f sha256sums sha256sums.sig openwrt.pub + echo ">> SDK verified." + + echo ">> extracting ..." + mkdir -p "$SDK_DIR" + tar --zstd -xf sdk.tar.zst -C "$SDK_DIR" --strip-components=1 + rm -f sdk.tar.zst +fi +cd "$SDK_DIR" + +# 2. feeds (need luci for luci.mk + BuildPackage macros) +if [ ! -f feeds/luci.index ] && [ ! -d feeds/luci ]; then + ./scripts/feeds update base luci +fi +./scripts/feeds install -a -p luci >/dev/null 2>&1 || true + +# 3. drop our theme into the luci themes feed (fresh copy) +DEST="feeds/luci/themes/luci-theme-footstrap" +rm -rf "$DEST" +cp -a "$THEME_DIR" "$DEST" +# a worktree can hold 0600/0700 (git stores 100644); uhttpd answers 403 on a file that is not world-readable +chmod -R u=rwX,go=rX "$DEST" +rm -rf "$DEST/build-apk.sh" "$DEST/dev-sync.sh" "$DEST/.git" 2>/dev/null || true + +./scripts/feeds update -i luci +./scripts/feeds install luci-theme-footstrap + +# 4. build. ncurses is only needed for interactive menuconfig, not for a noarch +# theme, so satisfy the host prereq stamp to skip that check. +mkdir -p staging_dir/host +touch staging_dir/host/.prereq-build +make defconfig FORCE=1 +make package/luci-theme-footstrap/clean FORCE=1 V=s >/dev/null 2>&1 || true +make package/luci-theme-footstrap/compile FORCE=1 V=s + +# 5. locate the artifact +echo +echo ">> built packages:" +find bin -name 'luci-theme-footstrap*' \( -name '*.apk' -o -name '*.ipk' \) -print diff --git a/luci-theme-footstrap/build-css.sh b/luci-theme-footstrap/build-css.sh new file mode 100755 index 00000000..8c84a4c9 --- /dev/null +++ b/luci-theme-footstrap/build-css.sh @@ -0,0 +1,330 @@ +#!/bin/sh +# Concatenate the styles/ tree into a single cascade.css. +# +# ./build-css.sh [outfile] [--dev] +# +# Directory order is layer order (styles/, base/, theme/, pages/); filename order is source order +# within a directory. A later layer beats an earlier one whatever the specificity, so nothing needs +# !important to override base. +# +# Minified unless --dev: uhttpd serves CSS with no gzip, so every byte is a wire byte. Comments and +# whitespace go, and five spellings shrink (docs/css.md, "The build"): [a="b"] -> [a=b], the space +# before !important, ::before -> :before, the spaces beside * and / in a declaration, and a flex +# triple with a keyword form (1 1 auto -> auto). Nothing else in a selector or declaration is +# rewritten, which is why csstidy stays off (it mangles :has() and color-mix()). Needs only cat and +# awk, so an OpenWrt buildbot can run it. +set -eu + +D="$(cd "$(dirname "$0")" && pwd)" +OUT="" +DEV=0 +for a in "$@"; do + case "$a" in + --dev) DEV=1 ;; + # Else a typo like `--devv` becomes the output file name. + -*) echo "build-css: unknown option: $a" >&2; exit 1 ;; + *) OUT="$a" ;; + esac +done +[ -n "$OUT" ] || OUT="$D/htdocs/luci-static/footstrap/cascade.css" + +for d in styles styles/base styles/theme styles/pages; do + [ -d "$D/$d" ] || { echo "build-css: $D/$d missing" >&2; exit 1; } +done + +TMP="$OUT.tmp.$$" +# $TMP.min too: an awk failure leaves it behind. +trap 'rm -f "$TMP" "$TMP.min" "$TMP.layer"' EXIT +trap 'exit 130' INT TERM +mkdir -p "$(dirname "$OUT")" + +# One `@layer X{` per LAYER, not per FILE: 37 file wrappers are 542 bytes. The wrapper comes from +# the DIRECTORY, so each file must open with the layer its directory means or it is silently +# re-layered. A file with no wrapper (00-header.css: banner and layer-order statement) is copied +# through and must come before the wrapped ones, or it lands inside the block. +emit_layer() { + layer="$1"; shift + body="$TMP.layer" + : > "$body" + for f in "$@"; do + if head -1 "$f" | grep -q '^@layer '; then + head -1 "$f" | grep -q "^@layer $layer {\$" || { + echo "build-css: $f is in a $layer directory but does not open with '@layer $layer {'" >&2 + exit 1; } + # the file's own wrapper: its first line, and the bare `}` where brace depth returns to 0. + # Anything but blank or comment after that `}` is refused: the source leaves it unlayered + # and the build would move it into the block, a silent cascade change. + awk -v F="$f" 'NR == FNR { + if (end) { + l = $0 + if (inc) { if (sub(/^.*\*\//, "", l)) inc = 0; else next } + gsub(/\/\*.*\*\//, "", l) + if (match(l, /\/\*/)) { inc = 1; l = substr(l, 1, RSTART - 1) } + if (!bad && l ~ /[^ \t\r]/) bad = NR + next + } + # depth, string- and comment-aware: a `{` in a multi-line comment or in content:"{" + # is not a block (the same walk as brace_count below) + l = $0; n = length(l) + for (i = 1; i <= n; i++) { + ch = substr(l, i, 1) + if (inc) { if (ch == "*" && substr(l, i + 1, 1) == "/") { inc = 0; i++ } continue } + if (q != "") { if (ch == "\\") i++; else if (ch == q) q = ""; continue } + if (ch == "\"" || ch == "'"'"'") { q = ch; continue } + if (ch == "/" && substr(l, i + 1, 1) == "*") { inc = 1; i++; continue } + if (ch == "{") d++; else if (ch == "}") d-- + } + if (NR > 1 && d == 0) end = NR; next } + END { if (bad) { printf "build-css: %s:%d: a rule after the @layer wrapper would be moved into the layer; put it inside\n", F, bad > "/dev/stderr"; exit 1 } } + FNR > 1 && FNR != end' "$f" "$f" >> "$body" + elif [ -s "$body" ]; then + echo "build-css: $f has no @layer wrapper but follows one that does — it would be" >&2 + echo "build-css: swallowed into the $layer block instead of staying above it." >&2 + exit 1 + else + cat "$f" + fi + done + if [ -s "$body" ]; then + printf '@layer %s {\n' "$layer" + cat "$body" + printf '}\n' + fi + # not covered by the EXIT trap: the success path runs `trap - EXIT` first + rm -f "$body" +} + +# glob expands in filename order +{ + emit_layer tokens "$D"/styles/*.css + emit_layer base "$D"/styles/base/*.css + emit_layer theme "$D"/styles/theme/*.css + emit_layer page "$D"/styles/pages/*.css +} > "$TMP" + +# Comments, and — unless told not to — the whitespace CSS ignores anyway, in one string-aware +# pass rather than two chained ones. squeeze=0 (the pre-compression rule count below; a +# `--dev` build skips the pass and keeps the raw sheet) strips comments and trims each line; +# squeeze=1 also does the ~9.5 KB whitespace pass. Why a scan has to be string-aware, what the +# whitespace pass leaves alone and why, and the sed bug this replaced: docs/css.md, "The build". +squeeze() { + awk -v SQUEEZE="$2" ' + # The exact flex triples that have a shorter keyword form (shorthand initial values: grow 1 + # shrink 1 basis auto). Bare 0 is left alone, since `flex:0` means grow 0. + function flexv(v, imp) { + if (v ~ /!important$/) { imp = "!important"; v = substr(v, 1, length(v) - 10) } + if (v == "0 0 auto") v = "none" + else if (v == "1 1 auto") v = "auto" + else if (v == "0 1 auto") v = "0 auto" + else if (v ~ /^1 1 ([0-9]+\.?[0-9]*|\.[0-9]+)(%|[A-Za-z]+)$/) v = substr(v, 5) + return v imp + } + BEGIN { q = ""; inc = 0; banner = 0; lastc = ""; buf = ""; lastreal = ""; d = 0; st = 0; fl = 0 } + { + line = $0 + # a banner already open on an earlier line: nothing on a pure continuation line can + # change q or inc, so the whole line is banner content until its own closing "*/". + if (banner) { + trimmed = line + sub(/^[ \t]+/, "", trimmed); sub(/[ \t]+$/, "", trimmed) + print trimmed + lastc = ""; lastreal = "" + if (index(line, "*/")) banner = 0 + next + } + # The line BREAK a squeeze pass is about to swallow is whitespace, and a declaration + # may be wrapped across it. Feed it to the whitespace-run logic below as a leading + # space: it survives only where a space would and is dropped next to { } ; , : — + # lastc == "" means output is already at the start of a line, with nothing to glue to. + if (SQUEEZE && lastc != "" && q == "" && !inc) line = " " line + out = ""; i = 1; n = length(line); opened = 0 + while (i <= n) { + c = substr(line, i, 1) + if (inc) { # inside a stripped /* ... */ + if (c == "*" && substr(line, i + 1, 1) == "/") { inc = 0; i += 2; continue } + i++; continue + } + if (q != "") { # inside a "..." or '"'"'...'"'"' string + out = out c + if (c == "\\") { out = out substr(line, i + 1, 1); i += 2; continue } + if (c == q) q = "" + lastreal = "" + i++; continue + } + if (c == "\"" || c == "'"'"'") { + # [attr="ident"] -> [attr=ident]: the same selector, 2 bytes less, 394 sites. Only a + # whole ident closed on this line right before `]`; "--" stays quoted, since + # mangle-tokens.sh reads an unquoted --fs- as a name to rename. + if (SQUEEZE) { + j = index(substr(line, i + 1), c) + v = substr(line, i + 1, j - 1) + if (j && substr(line, i + j + 1, 1) == "]" && out ~ /\[[-A-Za-z0-9_]+[~|^$*]?=$/ && + v ~ /^-?[A-Za-z_][-A-Za-z0-9_]*$/ && !index(v, "--")) { + out = out v; lastreal = ""; i += j + 1; continue + } + } + q = c; out = out c; lastreal = ""; i++; continue + } + if (c == "/" && substr(line, i + 1, 1) == "*") { + if (substr(line, i + 2, 1) == "!") { + # a genuine banner open: reached only with q=="" and inc==0 AT THIS + # CHARACTER, the same guard strip_comments used before this pass absorbed + # it — so a "/*!" that is really bytes inside a string or an already-open + # ordinary comment never lands here. Copied raw from the marker on, byte + # for byte: it is an Apache-2.0 attribution, not formatting. + banner = 1 + rest = substr(line, i) + out = out rest + if (index(rest, "*/")) banner = 0 + opened = 1 + break + } + inc = 1; i += 2; continue + } + if (SQUEEZE) { + if (c == " " || c == "\t") { # collapse a run of whitespace to one space + while (i <= n && (substr(line, i, 1) == " " || substr(line, i, 1) == "\t")) i++ + # the last char EMITTED, which on a continuation line lives on the + # previous output line — hence lastc, not just `out`. + prev = (length(out) ? substr(out, length(out), 1) : lastc) + nxt = (i <= n ? substr(line, i, 1) : "") + # Drop the space next to a delimiter; otherwise it may be a combinator. `>` joins: + # outside a string it is the child combinator or a range `>` + # (`@container (width > 560px)`), both valid unspaced. `~` and `+` do not: + # `[attr~=v]` and `calc(100% - 10px)` are ambiguous without bracket depth. + # `!` joins too: `red!important` is the same declaration (35 sites). + if (prev == "" || prev == "{" || prev == "}" || prev == ";" || prev == "," || prev == ":" || prev == ">") + continue + if (nxt == "{" || nxt == "}" || nxt == ";" || nxt == "," || nxt == "" || nxt == ">" || nxt == "!") + continue + # Inside a declaration only + and - need their spaces: calc(1px * 2) -> calc(1px*2), + # 16 / 9 -> 16/9. Not before a comment opener, and `/*` or `*/` is never joined. + if (bk[d] == "D" && (prev == "*" || prev == "/" || nxt == "*" || (nxt == "/" && substr(line, i + 1, 1) != "*")) && + (prev nxt) != "/*" && (prev nxt) != "*/") + continue + out = out " "; lastreal = " " + continue + } + # ::before/::after -> :before/:after, the CSS2 form every engine still parses and + # serialises as `::`; the first colon of 93 pseudo-elements. Not `::before-x`. + if (c == ":" && substr(line, i + 1, 1) == ":") { + w = (substr(line, i + 2, 6) == "before") ? 6 : (substr(line, i + 2, 5) == "after") ? 5 : 0 + if (w && substr(line, i + 2 + w, 1) !~ /[-A-Za-z0-9_]/) { i++; continue } + } + # bk[d] is the kind of the open block: G holds rules (@layer @media @supports @container + # @keyframes), D holds declarations (a style rule, @font-face, a keyframe step). st: a + # statement has started, pk: its kind. The value rewrites cannot tell a nested rule from a + # declaration, so a rule inside a D block fails the build. + if (c == "{") { + if (bk[d] == "D") { printf "build-css: a rule nested in a declaration block (docs/css.md, \"The build\")\n" > "/dev/stderr"; exit 1 } + bk[++d] = (st ? pk : "D"); st = 0 + } else if (c == "}") { d--; st = 0 } + else if (c == ";") st = 0 + else if (!st) { + st = 1 + pk = (substr(line, i, 24) ~ /^@(layer|media|supports|container|scope|starting-style|[-a-z]*keyframes)/) ? "G" : "D" + } + # flex: vs is the offset of the value in buf out; it is rewritten when the declaration ends + if (c == ":" && bk[d] == "D" && out ~ /flex$/ && (buf out) ~ /(^|[{;])flex$/) { fl = 1; vs = length(buf) + length(out) + 1 } + if (fl && (c == ";" || c == "}")) { + fl = 0; ft = buf out; fv = substr(ft, vs + 1); fw = flexv(fv) + if (fw != fv) { buf = ""; out = substr(ft, 1, vs) fw } + } + # The last `;` of a block is dropped here, inside the string-aware scanner: a sed + # `s/;}/}/` cannot see strings and turns `content: ";}"` into `content: "}"`. + # The `;` may already sit in the previous output line, so text is held in `buf` + # until the rule closes. + if (c == "}") { + if (length(out) && substr(out, length(out), 1) == ";") + out = substr(out, 1, length(out) - 1) + else if (!length(out) && length(buf) && substr(buf, length(buf), 1) == ";") + buf = substr(buf, 1, length(buf) - 1) + } + out = out c; lastreal = c; i++ + } else { + out = out c; i++ + } + } + if (opened) { + trimmed = out + sub(/^[ \t]+/, "", trimmed); sub(/[ \t]+$/, "", trimmed) + print trimmed + lastc = ""; lastreal = "" + next + } + if (SQUEEZE) { + buf = buf out + if (length(out)) lastc = substr(out, length(out), 1) + # newline only after a closing brace — one rule per line. lastreal, not lastc: a + # line ending in a QUOTED `}` (content: "}") is not the end of a rule, and + # flushing there would split the rule and lose the space before its next token. + if (lastreal == "}") { print buf; buf = ""; lastc = ""; lastreal = "" } + } else { + sub(/^[ \t]+/, "", out) + sub(/[ \t]+$/, "", out) + if (length(out)) print out + } + } + END { if (SQUEEZE) { if (length(buf)) print buf; else printf "\n" } } + ' "$1" +} + +# Fail loudly rather than let an unbalanced block ship. +# +# String-aware, for the same reason the comment stripper is: a brace inside a CSS STRING is not a +# block, so a counter that gsub()s over the raw line makes a perfectly valid rule fail the build. +# Measured, all three shapes: `content: ";}"`, `content: "{"`, and a data-URI carrying `;}`. It +# fails closed, so nothing is ever corrupted; what it costs is a hunt for an imbalance that is not +# there. +brace_count() { + awk ' + BEGIN { q = "" } + { + line = $0; n = length(line); i = 1 + while (i <= n) { + ch = substr(line, i, 1) + if (q != "") { # inside a string + if (ch == "\\") { i += 2; continue } # escape: skip the pair + if (ch == q) q = "" + i++; continue + } + if (ch == "\"" || ch == "'"'"'") { q = ch; i++; continue } + if (ch == "{") o++ + else if (ch == "}") c++ + i++ + } + } + END { + if (o != c) { printf "build-css: %s: unbalanced braces (%d { vs %d })\n", FILENAME, o, c > "/dev/stderr"; exit 1 } + if (o < 100) { printf "build-css: %s: suspiciously few rules (%d)\n", FILENAME, o > "/dev/stderr"; exit 1 } + print o + }' "$1" +} + +# Always brace-check a COMMENT-STRIPPED, UNSQUEEZED copy, --dev included: counting braces in the +# raw file made a stray "{" in prose fail the build on perfectly valid CSS. +squeeze "$TMP" 0 > "$TMP.min" +RULES_BEFORE=$(brace_count "$TMP.min") || exit 1 +rm -f "$TMP.min" + +if [ "$DEV" -eq 0 ]; then + squeeze "$TMP" 1 > "$TMP.min" + mv "$TMP.min" "$TMP" + + # Recount on what ships: the squeeze is the pass most able to corrupt the sheet. The rule COUNT, + # not brace balance, catches a truncated write: a cut right after the base layer closes is still + # balanced against the true rule count, and RULES_BEFORE comes from the untruncated sheet. + RULES_AFTER=$(brace_count "$TMP") || exit 1 + if [ "$RULES_BEFORE" != "$RULES_AFTER" ]; then + echo "build-css: the squeeze changed the rule count ($RULES_BEFORE -> $RULES_AFTER)." >&2 + exit 1 + fi +fi +# --dev: $TMP keeps comments and formatting (only the @layer wrappers are merged), for reading on a +# router rather than shipping. + +SIZE=$(wc -c < "$TMP" | tr -d ' ') +mv "$TMP" "$OUT" +trap - EXIT + +echo "build-css: $SIZE bytes -> $OUT" diff --git a/luci-theme-footstrap/dev-sync.sh b/luci-theme-footstrap/dev-sync.sh new file mode 100755 index 00000000..1d13b241 --- /dev/null +++ b/luci-theme-footstrap/dev-sync.sh @@ -0,0 +1,124 @@ +#!/bin/sh +# Push the footstrap theme to a HARDWARE router over ssh (containers use owlab). +# ONE theme, one luci.themes entry; layout (top/sidebar) is a client axis on the +# System -> Footstrap page, not a theme entry. This registers the theme; it does NOT activate it. +set -eu + +R="${1:-router2512}" +N=footstrap +D="$(cd "$(dirname "$0")" && pwd)" + +# cascade.css is generated from styles/ and is not in git. --dev keeps the comments so the +# file on the router still reads like the source. +"$D"/build-css.sh "$D/htdocs/luci-static/$N/cascade.css" --dev + +ssh "$R" "mkdir -p /usr/share/ucode/luci/template/themes/$N \ + /www/luci-static/$N \ +" + +# the ONE template (+ partials/). Sidebar and top bar are the same markup, morphed by +# :root[data-layout] — there is no second theme dir to copy. +scp -q "$D"/ucode/template/themes/$N/*.ut "$R":/usr/share/ucode/luci/template/themes/$N/ +ssh "$R" "mkdir -p /usr/share/ucode/luci/template/themes/$N/partials" +scp -q "$D"/ucode/template/themes/$N/partials/*.ut \ + "$R":/usr/share/ucode/luci/template/themes/$N/partials/ + +# shared static: cascade.css, the logo (SVG + PNGs), the app icon and the manifest; no wallpapers. +scp -qr "$D"/htdocs/luci-static/$N/* "$R":/www/luci-static/$N/ +# scp carries a worktree's 0600 across, and uhttpd answers 403 on a file that is not world-readable +ssh "$R" "chmod -R u=rwX,go=rX /www/luci-static/$N /usr/share/ucode/luci/template/themes/$N" + +# Every resource JS goes as a TAR OF THE TREE, never by name or `scp *.js`: luci.mk copies htdocs/ +# wholesale, so a file listed by hand, or one in a subdirectory, ships in the package and never +# reaches the dev router. Every path under resources/ is ours to place. --mode is GNU tar (a +# checkout's umask would otherwise set the router's modes); bsdtar on macOS refuses it, so use gtar. +tar -C "$D/htdocs/luci-static/resources" --mode=u=rwX,go=rX -cf - . | + ssh "$R" "mkdir -p /www/luci-static/resources && tar -C /www/luci-static/resources -xf -" + +# stamp the git-derived version into the deployed fs-version.js (the package does the same in +# Build/Prepare) so the System -> Footstrap page shows a real version instead of "(dev)". The FILE +# NAME is part of the contract: FS_VERSION lives in fs-version.js, so moving it changes every sed. +FS_V="$(git -C "$D" describe --tags --always 2>/dev/null | sed 's/^v//')" +# if-form, not `[ -n ] && ssh`: under `set -e` a bare `&&`-list whose LAST command fails is what +# aborts, so `[ -n "$FS_V" ] && ssh …` would take the sync down whenever git describe yielded +# nothing (a copied tree without .git). An `&&` guarding an ASSIGNMENT is a different shape and is +# safe. expr refuses a tag with sed- or shell-special characters rather than interpolating it. +if [ -n "$FS_V" ] && expr "$FS_V" : '[0-9A-Za-z._-]*$' >/dev/null; then + ssh "$R" "sed -i \"s#const FS_VERSION *= *'[^']*'#const FS_VERSION = '$FS_V'#\" \ + /www/luci-static/resources/fs-version.js" +fi + +# The catalogue: owfeed bundles it in the release, so a dev router gets it only from this sync. +# po2lmo is a luci-base host tool, so this is best-effort: without it strings stay English. Build it +# with make -C modules/luci-base/src po2lmo (plural_formula.c is generated by contrib/lemon). +# The basename is `footstrap`, as luci.mk and owfeed.yml ship it (luci-i18n-footstrap-); the +# old `footstrap-theme.*.lmo` of an earlier dev sync is removed, or a stale catalogue could win. +if command -v po2lmo >/dev/null 2>&1; then + ssh "$R" "mkdir -p /usr/lib/lua/luci/i18n && rm -f /usr/lib/lua/luci/i18n/footstrap-theme.*.lmo" + for po in "$D"/po/*/*.po; do + [ -e "$po" ] || continue + lang="$(basename "$(dirname "$po")")" + po2lmo "$po" "/tmp/footstrap.$lang.lmo" + scp -q "/tmp/footstrap.$lang.lmo" "$R":/usr/lib/lua/luci/i18n/ + rm -f "/tmp/footstrap.$lang.lmo" + done +else + echo " (po2lmo not found — skipping the translation catalogue; strings stay English)" +fi + +# root/ -> / as a TREE with a GLOB of top-level dirs, never a list of names (luci.mk installs root/ +# wholesale; a hand-written `usr` list missed root/etc/config/footstrap). tar, not `scp -r`: its +# merge onto an existing /usr is ambiguous. Two subtrees are excluded by behaviour, handled below: +# - etc/uci-defaults runs from /tmp; in /etc/uci-defaults the next boot would run and DELETE it. +# - etc/config holds conffiles, which the package manager keeps on upgrade and so must we: the +# live file holds the admin's "Save to router". +set -- +for _d in "$D"/root/*/; do set -- "$@" "$(basename "$_d")"; done +tar -C "$D/root" --mode=u=rwX,go=rX --exclude=etc/uci-defaults --exclude=etc/config -cf - "$@" | + ssh "$R" "tar -C / -xf -" + +# conffiles: install only when ABSENT, which is exactly what the package manager does with them. +for _f in "$D"/root/etc/config/*; do + [ -f "$_f" ] || continue + _b=$(basename "$_f") + scp -q "$_f" "$R":"/tmp/.fs-conf-$_b" + ssh "$R" "[ -f /etc/config/$_b ] || { mv /tmp/.fs-conf-$_b /etc/config/$_b; \ + echo ' installed /etc/config/$_b (was absent)'; }; rm -f /tmp/.fs-conf-$_b" +done +# No rpcd reload here — same race as the Makefile's postinst note (Package/luci-theme- +# footstrap/postinst): this copies files onto a router where the theme registers no rpcd object, +# rpcd re-reads acl.d/*.json at LOGIN (not only on reload), and a developer's root session already +# carries read='*' write='*', so the theme's ACL group is irrelevant to it regardless. + +ssh "$R" " +# Sweep every pre-consolidation name, $N-top included: a stale mediaurlbase on its media dir renders +# a theme LuCI no longer lists. rm -rf, not rm -f (it refuses directories and this block has no +# set -e); every path is a literal built from \$N. +cd /www/luci-static +rm -rf $N/$N $N-top $N-dark $N-light $N-top-dark $N-top-light +cd /usr/share/ucode/luci/template/themes +rm -rf $N/$N $N-top $N-dark $N-light $N-top-dark $N-top-light +# The overview layout must not be left in luci-mod-status's global include dir, where LuCI loads +# every *.js under any theme: it is a chrome module now. A package upgrade drops the old file, but +# dev-sync copies rather than installs, so it has to be swept by name — otherwise the dev router +# runs both copies and the grid is arranged twice. +rm -f /www/luci-static/resources/view/status/include/05_footstrap_overview_layout.js +" + +# Register by running the package's own uci-defaults script — the single source of truth +# (ONE entry, luci.themes.Footstrap; layout, mode and palette are client-side toggles). +# PKG_UPGRADE=1 forces its upgrade branch, so the dev router's active theme is left alone. +scp -q "$D"/root/etc/uci-defaults/30_luci-theme-footstrap "$R":/tmp/30_luci-theme-footstrap +ssh "$R" "PKG_UPGRADE=1 sh /tmp/30_luci-theme-footstrap; rm -f /tmp/30_luci-theme-footstrap" + + +# ---- last: drop LuCI's menu cache and bust the browser cache ---- +# +# LuCI's index cache is rebuilt from the files and the registration above. head.ut versions assets +# with `?v={{ pkgs_update_time }}`, the mtime of the PACKAGE DATABASE. An install moves it, scp does +# not, so a dev deploy keeps the URL while the bytes change and a browser may serve a stylesheet +# several deploys old. Touching the file is what an install would do. +ssh "$R" 'rm -f /tmp/luci-indexcache*; + touch /lib/apk/db/installed 2>/dev/null || touch /usr/lib/opkg/status 2>/dev/null || true' + +echo "synced to $R (single Footstrap theme registered, active theme unchanged)" diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/app-icon-192.png b/luci-theme-footstrap/htdocs/luci-static/footstrap/app-icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..fbcbf06873d8b85b564ddc3cbd7cfcd2810bd01c GIT binary patch literal 1341 zcmV-D1;YA?P)-4YW^N-x{Z`18p&FVFM$HBfjMTwc;1M(R7Hf);h%64+oEAJVT83I>lR(9 z>o$S1#VIKW%W*DoL1BlLpvGm2unM%e#RyhF7Pl3G*|5YT1z{$acuXCp;fxm~uep1& zn5ukHwUW`ZAxyxrC#@cR9EmMs55s7#(PoU@Q6r;?>6{qKwOuU>Op(Fs#8#+++?5!0g0g715IYFuZ2{<Tt<*>i?wPK9C&LVK4y8 zfsMAf{Mg3a|D>$GjCo< zqyDSq(=a@{KCxgG%|`*%jh0be(<)i;xCl>fw5U$wU@i(s9=qQ28Is1leDl)&&7Z=kCC z0IT{yI8Z01AMHtux9-G6SGS$&C{I4G9BL{tC~p-6>yfx>=fCb#b*a&ONfxn+PybT( zF`o2~b7TUm{EiD63cJp}iggv;`zMqA=Az%T+tm)c5^>K>@ghrtc_&D`-!5MuD;}%7 z-=jQPar-sfj1~7eVXFIW)Z59}rx$RNEn!CdaNDN@C9+hoNA+7E+rd!89{o7TX6W|{ z$_~$HWQ&vj*ER7cCSw^}`L!WzRmogX1fXt!K=vYpg?^<%h}1#DR?4q`KWNkgCkTZ} zi6;m%Qx^-QIg(18A*4v1R7fc+KObqKZZaT+r@_Kj(5E27w_nC+GeD^N@PS61 z6X zbSSRiSDK@vi3MuK(GkVcKYW7}N~9y>ToNQXL^B=Va(mnjZK1cpvHDg*0)P}s^EEQP zBaUwx_@C{;feZiu0Kg!B>wA0^00000006*M5<}W%YGXc*00000NkvXXu0mjfiz$T= literal 0 HcmV?d00001 diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/logo.svg b/luci-theme-footstrap/htdocs/luci-static/footstrap/logo.svg new file mode 100644 index 00000000..93302ff6 --- /dev/null +++ b/luci-theme-footstrap/htdocs/luci-static/footstrap/logo.svg @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/logo_48.png b/luci-theme-footstrap/htdocs/luci-static/footstrap/logo_48.png new file mode 100644 index 0000000000000000000000000000000000000000..ae7a64611c6328b70386bff89f16ba5439f25596 GIT binary patch literal 291 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sJOMr-u0Wb$>m!D3j~TQ*W&iqz z0J)4ML4Lsu4$p3+0XauKT^vIyZf%{^$acg*gxUK0zyI_73mua>_d9gOy6>K8@604NoXV_QR=@AGs!uHIZl7y`cN@7YI|S@XKi<^( zy+h%rw}fyJztWY}R)R(^vze4+{(SkkCO4wBJ|v;vHmA(ID8wx& kKB+{n^Yb!}dxpPk;-fU!`q%FmdKI;Vst0KQ0a(f|Me literal 0 HcmV?d00001 diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json b/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json new file mode 100644 index 00000000..974777ad --- /dev/null +++ b/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "LuCI", + "start_url": "/cgi-bin/luci/", + "display": "standalone", + "background_color": "#f6f8fa", + "theme_color": "#f6f8fa", + "icons": [ + { + "src": "app-icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js new file mode 100644 index 00000000..637bf893 --- /dev/null +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js @@ -0,0 +1,637 @@ +'use strict'; +'require baseclass'; +'require ui'; +'require fs-prefs as prefs'; +'require fs-axes as axes'; +'require fs-router as router'; +'require fs-assets as assets'; +'require fs-version as ver'; + +/* The Appearance controls: the DOM that presents the axes. It owns no preference: fs-prefs.js and + * fs-axes.js hold the axes and fs-version.js the version string. `view/footstrap/appearance.js` + * calls `renderStandalone()`, the whole surface this file ships. + * + * The version line makes no request and must not grow one: which version is installed is what this + * page answers, and which is available is the package manager's question. */ + +/* Colour readout. Two questions no stored value answers: what colour a role is right now (the + * palette's own while the axis is off) and what contrast it lands at. */ +let _probe = null; +function probeColor(expr) { + if (!_probe) { + /* Off-screen rather than display:none, so the reading does not depend on a display:none element + * computing `color` in every engine. Every declaration is !important: an app's CSS can style a + * plain
. */ + _probe = E('span', { 'id': 'fs-appearance-probe', 'aria-hidden': 'true' }); + _probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;' + + 'width:0!important;height:0!important;overflow:hidden!important;' + + 'pointer-events:none!important;'; + document.body.appendChild(_probe); + } + /* cleared first: an expression the engine rejects leaves the previous colour standing, which + * would report a stale answer as a fresh one */ + _probe.style.setProperty('color', ''); + _probe.style.setProperty('color', expr, 'important'); + return getComputedStyle(_probe).color; +} + +/* A computed colour -> [r,g,b] 0..255, or null. Rasterised, not parsed: a computed `color` keeps the + * space it was authored in, so `oklch(0.54 0.19 300)` parses as three numbers in the wrong units + * (measured: #010078, graded "Too faint to read"). Painting one pixel makes the engine convert. */ +let _cx = null; +function rasterCtx() { + if (!_cx) { + const cv = document.createElement('canvas'); + cv.width = cv.height = 1; + _cx = cv.getContext('2d', { willReadFrequently: true }); + } + return _cx; +} +function parseColor(s) { + const cx = rasterCtx(); + /* fillStyle keeps the last value it could parse, so a colour this engine rejects would + * report the previous one as a fresh reading — the trap probeColor() clears for */ + cx.fillStyle = '#000'; + cx.fillStyle = String(s || ''); + cx.clearRect(0, 0, 1, 1); + cx.fillRect(0, 0, 1, 1); + const d = cx.getImageData(0, 0, 1, 1).data; + /* translucent: composite over nothing is meaningless for a readout */ + return d[3] === 255 ? [ d[0], d[1], d[2] ] : null; +} + +/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states + * what a colour costs and leaves the choice with the user, never correcting it (03-palettes.css + * derives the ink over a fill, which is a different question). */ +function luminance(rgb) { + const c = rgb.map((v) => { + const x = v / 255; + return (x <= .03928) ? (x / 12.92) : Math.pow((x + .055) / 1.055, 2.4); + }); + return (.2126 * c[0]) + (.7152 * c[1]) + (.0722 * c[2]); +} +function contrastRatio(fgExpr, bgExpr) { + const fg = parseColor(probeColor(fgExpr)), bg = parseColor(probeColor(bgExpr)); + if (!fg || !bg) return null; + const a = luminance(fg), b = luminance(bg); + return (Math.max(a, b) + .05) / (Math.min(a, b) + .05); +} + +function toHex(s) { + const rgb = parseColor(s) || [ 0, 0, 0 ]; + return '#' + rgb.map((v) => v.toString(16).padStart(2, '0')).join(''); +} + +/* One colour axis: a native swatch, a hex field and a button back to the palette's own colour. + * Reports through onPick as a hex string, or 0 for "back to the palette". */ +function colorControl(onPick, label, o) { + const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label }); + const field = E('input', { + 'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off', + 'inputmode': 'text', 'maxlength': '7', 'aria-label': label + }); + const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]); + const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null; + + let typed = false; + + /* Repaint everything that mirrors the axis. Called after every edit, and through the returned + * refresh() after a preset, palette switch or dark-mode flip — each changes what the palette's + * own colour is while this axis stays off. */ + function reflect(v) { + const live = probeColor(o.probe); + const hex = (typeof v === 'string') ? v : toHex(live); + swatch.value = hex; + /* do not fight the user mid-edit: `#0` is a legal prefix, and overwriting the field on + * every keystroke made the input impossible to type into */ + if (document.activeElement !== field) { field.value = hex; typed = false; } + /* the button back to the palette doubles as the axis state readout: enabled means the axis + * holds a colour of its own, disabled means the field shows the palette's */ + clear.disabled = !v; + if (!ratio) return; + const r = contrastRatio(o.contrast.fg, o.contrast.bg); + if (r === null) { ratio.hidden = true; ratio.removeAttribute('title'); return; } + /* The readout states what the ratio means; the number itself stays in the title. Thresholds are + * WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI shape. */ + const where = o.contrast.label; + const grade = (o.contrast.kind === 'shape') + ? ((r >= 3) + ? { cls: 'fs-contrast-aa', text: _('Clearly visible %s', 'footstrap').format(where) } + : { cls: 'fs-contrast-aa-large', text: _('Barely visible %s', 'footstrap').format(where) }) + : (r >= 4.5) + ? { cls: 'fs-contrast-aa', text: _('Easy to read %s', 'footstrap').format(where) } + : (r >= 3) + ? { cls: 'fs-contrast-aa-large', text: _('Hard to read %s — large text only', 'footstrap').format(where) } + : { cls: 'fs-contrast-low', text: _('Too faint to read %s', 'footstrap').format(where) }; + ratio.className = 'fs-color-contrast ' + grade.cls; + ratio.textContent = grade.text; + /* a pass says nothing: nine "easy to read" lines buried the one that was not */ + ratio.hidden = (grade.cls === 'fs-contrast-aa'); + ratio.title = _('Contrast %s:1 (WCAG AA wants %s:1 here)', 'footstrap') + .format(r.toFixed(1), (o.contrast.kind === 'shape') ? '3' : '4.5'); + } + + const pick = (v) => { onPick(v); reflect(v); }; + + swatch.addEventListener('input', () => pick(swatch.value.toLowerCase())); + /* commit on blur and Enter, not per keystroke: a half-typed `#0096` would repaint the page + * under the cursor. An unparseable value snaps back to what the axis holds, so the field + * cannot claim a colour the page is not painted in. An untouched field returns first, so a + * bare Tab-through never pins the shown palette colour on any of the 9 axes. */ + const commit = () => { + if (!typed) return; + const v = field.value.trim().toLowerCase(); + if ((/^#[0-9a-f]{6}$/).test(v)) pick(v); + /* re-read rather than a cached copy: the page can change what the axis holds behind this + * control (a preset, Reset to built-in), which a snapshot taken at build time would miss */ + else reflect(o.read()); + }; + field.addEventListener('input', () => { typed = true; }); + field.addEventListener('blur', commit); + field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } }); + clear.addEventListener('click', () => pick(0)); + + const wrap = E('div', { 'class': 'fs-colorctl' }, [ + E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ]) + ].concat(ratio ? [ ratio ] : [])); + /* the caller decides when this runs: probeColor() needs the document, and this control is not + * in it yet */ + wrap.fsRefresh = () => reflect(o.read()); + return wrap; +} + +/* Every stamp that moves a colour lands on :root, including head.ut's OS listener and an app + * rewriting data-darkmode, neither of which calls back here. */ +let _watch = null; +function unwatchColours() { + if (_watch) _watch.disconnect(); + _watch = null; +} +router.onNavigate(unwatchColours); +function watchColours(refresh) { + unwatchColours(); + _watch = new MutationObserver(refresh); + _watch.observe(document.documentElement, { + attributes: true, attributeFilter: [ 'data-darkmode', 'data-palette', 'data-wallpaper' ] + }); +} + +/* Build the page; renderStandalone() hands it over. Four tabs, most-used first: the colour scheme, + * the interface, the background with its glass and the colours nobody touches twice; Saving sits + * under them. Everything applies at once and is this browser's, in localStorage; only "Save to + * router" writes anything, pushing the current look to the router for other browsers. */ +function build() { + /* 24.10.0-24.10.2 have no ui.RangeSlider and a package from the feed still installs there (opkg does + * not version luci-base); one readable line beats the raw "not a constructor" TypeError. */ + if (!ui.RangeSlider) + return E('p', { 'class': 'alert-message warning' }, [ _('Footstrap needs OpenWrt 24.10.3 or newer.', 'footstrap') ]); + + /* every saved axis re-checks the Save button after applying, so it greys the moment this + * browser matches the saved default and un-greys when it diverges. Wrapped around the appliers + * because the controls call them directly and have no other seam back here. */ + const bump = (fn) => (v) => { fn(v); refreshSave(); }; + + /* Every colour control mirrors something it does not own: the palette's colour while its own + * axis is off, and the contrast that colour lands at. A palette switch or dark-mode flip moves + * all of it under controls nobody touched, so they refresh together. */ + const colourCtls = []; + const refreshColours = () => colourCtls.forEach((c) => c.fsRefresh()); + const repaint = (fn) => (v) => { fn(v); refreshColours(); }; + + /* One captioned row in LuCI's own shape: `.cbi-value` > `label.cbi-value-title` + + * `.cbi-value-field`, which base/30-forms.css and theme/60-inputs.css already lay out. `make` is + * handed the caption because each control needs it again as the aria-label of its inner + * input/select (a stock widget's wrapper div cannot carry a name). `o.desc` is a help line under + * the control, `o.extra` the rows with more than a control, `o.cls` marks the rows CSS singles out. */ + const group = (label, make, opts) => { + const o = opts || {}; + const desc = o.desc ? [ E('div', { 'class': 'cbi-value-description' }, [ o.desc ]) ] : []; + return E('div', { 'class': 'cbi-value' + (o.cls ? ' ' + o.cls : '') }, [ + E('label', { 'class': 'cbi-value-title' }, [ label ]), + E('div', { 'class': 'cbi-value-field' }, [ make(label) ].concat(desc, o.extra || [])) + ]); + }; + + /* the three literals every colour row repeats; a string literal survives minification intact */ + const CARD_BG = 'var(--fs-panel)', INK = 'var(--fs-text)', ON_CARD = _('on a card', 'footstrap'); + + /* A list of more than five, the palette, is a `ui.Select`; every number a `ui.RangeSlider`, already + * dressed by base/30-forms.css and theme/60-inputs.css (`ui.RangeSlider` reached openwrt-24.10 in + * 24.10.3). Listen for `widget-change`, the seam UIElement publishes, not an event on the inner + * element. RangeSlider also emits `widget-update` while the handle moves, which resizes the page + * under the drag; the appliers are idempotent, so wiring both costs nothing. */ + const selectCtl = (current, choices, apply, label) => { + /* an array of [value, label] pairs when a key is integer-like: Object.keys hoists it ahead of + * the authored order ('2020' rendered above Footstrap, the default); `sort` pins the order */ + const pairs = Array.isArray(choices) ? choices : Object.entries(choices); + const w = new ui.Select(String(current), Object.fromEntries(pairs), { widget: 'select', sort: pairs.map((p) => p[0]) }); + const node = w.render(); + node.querySelector('select, input').setAttribute('aria-label', label); + node.addEventListener('widget-change', () => apply(w.getValue())); + return node; + }; + + /* Two to three short choices are one row of pills, every choice in sight: native radios under one + * name, so the arrow keys move between them and a screen reader counts them; 80-appearance.css + * draws the pills. */ + let segSeq = 0; + const segCtl = (current, choices, apply, label) => { + const name = 'fs-ap-seg-' + (++segSeq); + const pairs = Array.isArray(choices) ? choices : Object.entries(choices); + return E('div', { 'class': 'fs-seg', 'role': 'radiogroup', 'aria-label': label }, pairs.map(([ v, text ]) => { + const input = E('input', { 'type': 'radio', 'name': name, 'value': v }); + input.checked = (String(current) === v); + input.addEventListener('change', () => { if (input.checked) apply(v); }); + return E('label', {}, [ input, E('span', {}, [ text ]) ]); + })); + }; + + const sliderCtl = (axis, apply, label, opts) => { + const o = opts || {}; + const w = new ui.RangeSlider(String(axis.current()), { + min: axis.min, max: axis.max, step: o.step || 1 + }); + const node = w.render(); + node.querySelector('select, input').setAttribute('aria-label', label); + const push = () => apply(parseInt(w.getValue(), 10)); + /* `live: false` for an axis whose value moves this control: Content width widens the column the + * page is drawn in, so applying mid-drag slides the handle from under the pointer and one small + * drag runs to the maximum. A keyboard user still applies at once (an arrow key fires `change`). */ + if (o.live !== false) node.addEventListener('widget-update', push); + node.addEventListener('widget-change', push); + return node; + }; + + const colourGroup = (label, axis, probe, contrast, opts = {}) => group(label, (lbl) => { + const ctl = colorControl(bump(axis.apply), lbl, { + probe: probe, + read: axis.current, + contrast: contrast + }); + colourCtls.push(ctl); + return ctl; + }, opts); + + /* a card is a stock `.cbi-section` under its own title; a tab pane is one whose title is its tab + * (ui.tabs reads data-tab-title); a subheading splits either in two */ + const card = (title, rows) => E('div', { 'class': 'cbi-section fs-ap-card' }, [ E('h3', {}, [ title ]) ].concat(rows)); + const pane = (name, title, rows) => E('div', { 'class': 'cbi-section fs-ap-card', 'data-tab': name, 'data-tab-title': title }, rows); + const sub = (title, cls) => E('div', { 'class': 'fs-ap-sub' + (cls ? ' ' + cls : '') }, [ E('h4', {}, [ title ]) ]); + + /* Every label here carries the 'footstrap' context: LuCI serves one merged catalogue and the first + * archive holding the hash wins, so a bare msgid is a global name any luci-app can win. */ + + /* ---- the colour scheme: what most visits come to change ---- */ + const scheme = pane('scheme', _('Colour scheme', 'footstrap'), [ + group(_('Mode', 'footstrap'), (label) => segCtl(prefs.currentMode(), [ + [ 'auto', _('Auto', 'footstrap') ], + [ 'light', _('Light', 'footstrap') ], + [ 'dark', _('Dark', 'footstrap') ] + ], bump(prefs.applyMode), label)), + + group(_('Palette', 'footstrap'), (label) => selectCtl(axes.palette.current(), [ + [ 'footstrap', 'Footstrap' ], + [ 'hicontrast', 'Hi-Contrast' ], + [ 'bootstrap', 'Bootstrap' ], + /* names another package, luci-theme-openwrt-2020, whose colourway this is */ + [ '2020', 'OpenWrt 2020' ], + [ 'forum', 'Forum' ] + ], bump(axes.palette.apply), label)), + + /* Accent recolours the accented controls (buttons, toggles, sliders, focus rings), not the + * canvas; any #rrggbb is taken. Read as text on a card, the use that fails first (as a fill it + * carries derived ink). */ + colourGroup(_('Accent', 'footstrap'), axes.accent, 'var(--fs-accent)', + { fg: 'var(--fs-accent)', bg: CARD_BG, label: ON_CARD }) + ]); + + /* ---- the interface ---- */ + const iface = pane('interface', _('Interface', 'footstrap'), [ + group(_('Layout', 'footstrap'), (label) => segCtl(prefs.currentLayout(), [ + [ 'sidebar', _('Sidebar', 'footstrap') ], + [ 'top', _('Top', 'footstrap') ] + ], bump(prefs.applyLayout), label)), + + /* always built and hidden by CSS in the top layout (:root[data-layout="top"] .fs-ap-submenus): + * the page is built once, so a currentLayout() branch would freeze it to the layout the page + * loaded in while CSS morphs the chrome live */ + group(_('Submenus', 'footstrap'), (label) => segCtl(prefs.currentAutoCollapse() ? 'on' : 'off', [ + [ 'off', _('Keep open', 'footstrap') ], + [ 'on', _('Auto-collapse', 'footstrap') ] + ], bump(prefs.applyAutoCollapse), label), { cls: 'fs-ap-submenus' }), + + group(_('Density', 'footstrap'), (label) => segCtl(axes.density.current(), [ + [ 'compact', _('Compact', 'footstrap') ], + [ 'normal', _('Normal', 'footstrap') ], + [ 'large', _('Large', 'footstrap') ] + ], bump(axes.density.apply), label)), + + /* the content column's own cap, separate from Density. The slider starts at the 1280px every + * page was designed against and only widens, which also keeps --fs-content-min (500px, what + * the sidebar-to-bar fold is measured against) out of reach by construction. */ + group(_('Content width', 'footstrap'), + (label) => sliderCtl(axes.contentWidth, bump(axes.contentWidth.apply), label, { step: 40, live: false })), + + group(_('Rounding', 'footstrap'), + (label) => sliderCtl(axes.radius, bump(axes.radius.apply), label)) + ]); + + /* ---- the background: the canvas, the wallpaper and the glass over it ---- + * The rows a value brings are siblings of the Wallpaper row: nested in its `.cbi-value-field` + * they started 216px right of every other control (measured on the router). They are hidden as + * a group, as stock pages do with a dependent field. The pills are the per-browser switch that + * keeps the Save button honest; Choose/Remove only swap the picture and never touch the axis. */ + const REMOVE = _('Remove', 'footstrap'); + const background = (() => { + const err = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); + const preview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' }); + const fileInput = E('input', { 'type': 'file', 'accept': 'image/*', 'style': 'display:none' }); + const chooseLabel = _('Choose image', 'footstrap'); + const chooseBtn = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ chooseLabel ]); + const removeBtn = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ REMOVE ]); + + const patErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); + const patPreview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' }); + const patInput = E('input', { 'type': 'file', 'accept': 'image/svg+xml,.svg', 'style': 'display:none' }); + const patChooseLabel = _('Choose SVG', 'footstrap'); + const patChoose = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ patChooseLabel ]); + const patRemove = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ REMOVE ]); + + /* Colours decides whether the file's own palette is kept or replaced by the theme's: a mask + * uses the alpha only, right for line art and wrong for artwork with its own colours. */ + const patRows = [ + group(_('Pattern', 'footstrap'), + () => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]), + { extra: [ patInput, patPreview, patErr ] }), + group(_('Scale', 'footstrap'), (lbl) => sliderCtl(axes.patternSize, bump(axes.patternSize.apply), lbl, { step: 20 })), + group(_('Strength', 'footstrap'), (lbl) => sliderCtl(axes.patternStrength, bump(axes.patternStrength.apply), lbl, { step: 5 })), + group(_('Colours', 'footstrap'), (lbl) => segCtl(axes.patternInk.current(), [ + [ 'theme', _('Theme', 'footstrap') ], + [ 'original', _('As in file', 'footstrap') ] + ], bump(axes.patternInk.apply), lbl)) + ]; + /* Dim is an ordinary saved axis, so it is bump()-ed or the Save button misreports its status */ + const fileRows = [ + group(_('File', 'footstrap'), + () => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]), + { extra: [ fileInput, preview, err ] }), + group(_('Dim', 'footstrap'), (lbl) => sliderCtl(axes.photoDim, bump(axes.photoDim.apply), lbl, { step: 5 })) + ]; + + function reflect(tok) { + if (tok) { preview.src = axes.loginBg.url(tok); preview.hidden = false; removeBtn.hidden = false; } + else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; } + } + function reflectPattern(tok) { + if (tok) { patPreview.src = axes.pattern.url(tok); patPreview.hidden = false; patRemove.hidden = false; } + else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; } + } + /* `hidden` on the row, which base/10-reset.css restates with !important (the UA's bare + * `[hidden]` loses to `.cbi-value`'s display). Hidden, not removed: each row holds a live + * control that a rebuild would freeze to its construction-time state. */ + function togglePanel(v) { + patRows.forEach((r) => { r.hidden = (v !== 'pattern'); }); + fileRows.forEach((r) => { r.hidden = (v !== 'file'); }); + } + reflect(axes.loginBg.current()); + reflectPattern(axes.pattern.current()); + togglePanel(axes.wallpaper.current()); + + const setWallpaper = (v) => { axes.wallpaper.apply(v); refreshSave(); togglePanel(v); }; + + /* Both uploads share one wiring (pick, upload, report, remove) and never move the Wallpaper + * pills, which the user may change mid-upload. The input is cleared on every change so + * re-picking the same file fires `change`; the label is restored in `finally`, or a failed + * upload leaves "Uploading…" standing. */ + const wireUploader = (u) => { + const fail = (e) => { u.err.textContent = String((e && e.message) || e); u.err.hidden = false; }; + u.choose.addEventListener('click', () => { u.err.hidden = true; u.input.click(); }); + u.input.addEventListener('change', () => { + const f = u.input.files && u.input.files[0]; + u.input.value = ''; + if (!f) return; + u.err.hidden = true; u.choose.disabled = true; + u.choose.textContent = _('Uploading…', 'footstrap'); + u.upload(f) + .then(u.reflect) + .catch(fail) + .finally(() => { u.choose.disabled = false; u.choose.textContent = u.label; }); + }); + u.remove.addEventListener('click', () => { + u.err.hidden = true; u.remove.disabled = true; + u.drop().then(() => u.reflect('')).catch(fail) + .finally(() => { u.remove.disabled = false; }); + }); + }; + + wireUploader({ + choose: patChoose, remove: patRemove, input: patInput, err: patErr, + label: patChooseLabel, reflect: reflectPattern, + upload: assets.uploadPattern, drop: assets.removePattern + }); + + wireUploader({ + choose: chooseBtn, remove: removeBtn, input: fileInput, err: err, + label: chooseLabel, reflect: reflect, + upload: assets.uploadLoginBg, drop: assets.removeLoginBg + }); + + /* The glass rows show only over a wallpaper (CSS: .fs-ap-glass), Material only while a + * surface is glass at all, Blur only for a material that blurs. An OS asking for less + * transparency or more contrast keeps the surfaces solid whatever the sliders say, and the + * page says so. */ + const glassRows = [ + sub(_('Transparency', 'footstrap'), 'fs-ap-glass'), + group(_('Card opacity', 'footstrap'), + (lbl) => sliderCtl(axes.cardOpacity, bump(axes.cardOpacity.apply), lbl, { step: 5 }), { + cls: 'fs-ap-glass', + desc: axes.glassBlocked() ? _('The system asks for less transparency or more contrast, so surfaces stay solid.', 'footstrap') : null + }), + group(_('Menu opacity', 'footstrap'), + (lbl) => sliderCtl(axes.barOpacity, bump(axes.barOpacity.apply), lbl, { step: 5 }), { cls: 'fs-ap-glass' }), + group(_('Material', 'footstrap'), (lbl) => segCtl(axes.material.current(), [ + [ 'flat', _('Flat', 'footstrap') ], + [ 'frosted', _('Frosted', 'footstrap') ], + [ 'acrylic', _('Acrylic', 'footstrap') ] + ], bump(axes.material.apply), lbl), { cls: 'fs-ap-glass fs-ap-material' }), + group(_('Blur', 'footstrap'), + (lbl) => sliderCtl(axes.blur, bump(axes.blur.apply), lbl, { step: 10 }), { cls: 'fs-ap-glass fs-ap-material fs-ap-blur' }) + ]; + + return pane('background', _('Background', 'footstrap'), [ + /* the canvas colour that tells routers apart; CSS hides it under a photo, which covers it */ + colourGroup(_('Tint', 'footstrap'), axes.tint, 'var(--fs-bg)', { + fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap') + }, { cls: 'fs-ap-tint', desc: _('Colours the page so you can tell routers apart.', 'footstrap') }), + + /* the strength half of the Tint, meaningful only in hue mode (a hex canvas has no chroma of + * ours to scale); CSS hides it otherwise. Not named "Density": the string is also the + * aria-label, so a screen reader would announce two rows under one name. */ + group(_('Tint strength', 'footstrap'), + (label) => sliderCtl(axes.tintStrength, bump(repaint(axes.tintStrength.apply)), label, { + step: 5 + }), { cls: 'fs-ap-tint fs-ap-tintstr' }), + + group(_('Wallpaper', 'footstrap'), (label) => segCtl(axes.wallpaper.current(), [ + [ 'off', _('Off', 'footstrap') ], + [ 'pattern', _('Pattern', 'footstrap') ], + [ 'file', _('File', 'footstrap') ] + ], setWallpaper, label)) + ].concat(patRows, fileRows, glassRows)); + })(); + + /* ---- the colours nobody touches twice: the last tab ---- + * The three status colours, then the surfaces: the sheet the UI is drawn on. Body text is read on + * each surface, so each reports --fs-text against itself; no ink is derived, since moving + * --fs-text would recolour what is measured against. The hairline takes the 3:1 UI-component + * threshold (a border is a shape). The rows are data because a string literal repeated eight + * times is not mangled by the minifier. */ + const colourRows = [ + ...[ + [ _('Good', 'footstrap'), axes.good, 'var(--fs-good)' ], + [ _('Warning', 'footstrap'), axes.warn, 'var(--fs-warn)' ], + [ _('Danger', 'footstrap'), axes.danger, 'var(--fs-danger)' ] + ].map(([ label, axis, ink ]) => + colourGroup(label, axis, ink, { fg: ink, bg: CARD_BG, label: ON_CARD })), + sub(_('Surfaces', 'footstrap')), + ...[ + [ _('Cards', 'footstrap'), axes.card, CARD_BG, INK, CARD_BG, ON_CARD ], + [ _('Controls', 'footstrap'), axes.control, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ], + [ _('Sidebar and bar', 'footstrap'), axes.bar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ], + [ _('Borders', 'footstrap'), axes.line, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ] + ].map(([ label, axis, probe, fg, bg, where, kind ]) => + colourGroup(label, axis, probe, { fg, bg, label: where, kind })) + ]; + + /* ---- saving: the router default and the version ---- + * Save writes the current look to /etc/config/footstrap over the scoped uci ACL. It does not + * change this browser (localStorage keeps overriding), so it only shows on a fresh browser. */ + /* each label is said twice in build(); hoisted, a msgid is paid once (24 B x2 -> 30 B) */ + const SAVE_TO_ROUTER = _('Save to router', 'footstrap'); + const RESET_TO_ROUTER = _('Reset to router', 'footstrap'); + const RESET_TO_BUILTIN = _('Reset to built-in', 'footstrap'); + const saveBtn = E('button', { 'class': 'btn cbi-button-action', 'type': 'button' }, [ SAVE_TO_ROUTER ]); + /* Two resets, because two things sit underneath a browser's tweaks: "Reset to router" clears them + * and lets every axis fall back to the router, "Reset to built-in" writes the theme's defaults + * explicitly. Neither touches /etc/config/footstrap. "Default" is not a label: it named the + * router's look in "Save as default" and the theme's in "Reset to default", three names for two + * states (forum topic 251930, post 92). */ + const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ RESET_TO_ROUTER ]); + const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ RESET_TO_BUILTIN ]); + /* Save's only visible failure surface: the rpc rejecting (expired session 403, missing ACL, ubus + * down). A deleted config is not caught: commit no-ops and returns success (measured on the + * router); the package owns that file and the read side falls back to built-in defaults. */ + const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); + + /* The Save button is the status: matching disables it, diverging enables it. A browser that + * refuses storage keeps its choices in fs-prefs's in-memory map, so the comparison stays honest; + * the note waits while a failed Save's error shows, or the .finally below would overwrite it. */ + function refreshSave() { + if (prefs.storageBroken() && saveErr.hidden) { + saveErr.textContent = _('This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving to the router still works and applies to every browser.', 'footstrap'); + saveErr.hidden = false; + } + const saved = axes.matchesSavedDefault(); + saveBtn.disabled = saved; + saveBtn.textContent = saved ? _('Saved to router', 'footstrap') : SAVE_TO_ROUTER; + } + saveBtn.addEventListener('click', () => { + saveBtn.disabled = true; + saveErr.hidden = true; + saveErr.title = ''; /* a stale rpc error would tooltip the storage note (measured: 'ubus code 6' over it) */ + axes.saveAsDefault() + /* on failure refreshSave re-enables the button so the user can retry; the usual cause + * is a stale session, which a reload fixes. The raw rpc error stays in a title + * tooltip. */ + .catch((e) => { + saveErr.textContent = _('Could not save to the router. Reload the page and try again.', 'footstrap'); + saveErr.title = String((e && e.message) || e); + saveErr.hidden = false; + }) + .finally(refreshSave); + }); + /* Two-click confirm on both: discarding local tweaks is destructive and a native confirm() is + * banned in this UI. Arming one disarms the other, so a primed button cannot be fired by a + * click meant for its neighbour. */ + let armed = null; + function disarm() { + if (!armed) return; + armed.btn.textContent = armed.label; + armed.btn.classList.remove('fs-ap-armed'); + armed = null; + } + function twoClick(btn, label, run) { + btn.addEventListener('click', () => { + if (armed && armed.btn === btn) { + disarm(); + run(); + location.reload(); + return; + } + disarm(); + armed = { btn: btn, label: label }; + btn.textContent = _('Confirm reset', 'footstrap'); + btn.classList.add('fs-ap-armed'); + }); + } + twoClick(resetSavedBtn, RESET_TO_ROUTER, axes.resetToSaved); + twoClick(resetBtn, RESET_TO_BUILTIN, axes.resetToBuiltin); + refreshSave(); /* correct label and enabled state before the first paint */ + + const versionLink = E('a', { + 'class': 'fs-ap-version', + 'href': ver.REPO_URL, + 'target': '_blank', + /* `noreferrer` alone: it implies noopener, and the theme's other outward links spell it + * that way */ + 'rel': 'noreferrer' + }, [ ver.label() ]); + + /* The theme's translations are their own packages, and nothing in a package manager can read `uci + * luci.main.lang` to fetch the right one. */ + const lang = (document.documentElement.getAttribute('lang') || '').trim(); + const untranslated = lang && lang !== 'en' && lang !== 'auto' && + _('Layout', 'footstrap') === 'Layout'; + const missing = untranslated ? E('div', { 'class': 'fs-ap-verrow fs-ap-i18n' }, [ + E('span', {}, [ _('This theme is not translated on this router yet.', 'footstrap') + ' ' ]), + E('code', {}, [ 'luci-i18n-footstrap-' + lang ]) + ]) : ''; + + const saving = card(_('Saving', 'footstrap'), [ + E('p', { 'class': 'fs-ap-lead' }, [ _('Footstrap theme settings apply at once and are stored permanently in this browser.', 'footstrap') ]), + E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, E('span', { 'class': 'fs-ap-actgap', 'aria-hidden': 'true' }), resetSavedBtn, resetBtn ]), + saveErr, + E('ul', { 'class': 'fs-ap-help' }, [ + E('li', {}, [ _('Save to router — store this look on the router. Browsers with a look of their own keep it.', 'footstrap') ]), + E('li', {}, [ _('Reset to router — drop this browser\'s changes and follow the router.', 'footstrap') ]), + E('li', {}, [ _('Reset to built-in — go back to the look the theme ships with.', 'footstrap') ]) + ]), + E('div', { 'class': 'fs-ap-footer' }, [ + E('div', { 'class': 'fs-ap-verrow' }, [ versionLink ]), + missing + ]) + ]); + + /* LuCI's own tab group, as on every tabbed form: it puts the strip before the panes and keeps + * the open tab for the session. A pane shown again re-reads its colours, which a palette or + * dark-mode change may have moved while it was hidden. */ + const panes = E('div', { 'class': 'fs-ap-panes' }, [ + scheme, + iface, + background, + pane('colours', _('Colours', 'footstrap'), colourRows) + ]); + const page = E('div', { 'class': 'fs-ap' }, [ panes, saving ]); + ui.tabs.initTabGroup(panes.childNodes); + panes.addEventListener('cbi-tab-active', refreshColours); + + Promise.resolve().then(refreshColours); + + watchColours(refreshColours); + return page; +} + +return baseclass.extend({ + /* the dispatched page's whole render(), called by view/footstrap/appearance.js */ + renderStandalone: () => Promise.resolve().then(build), +}); diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js new file mode 100644 index 00000000..988c07e6 --- /dev/null +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-assets.js @@ -0,0 +1,291 @@ +'use strict'; +'require baseclass'; +'require rpc'; +'require ui'; +'require fs-axes as axes'; + +/* fs-assets — putting a file on the router and taking it off again: the pattern and login-photo uploads, + * the SVG sanitizer, the rollback. A module of its own: only the Appearance page needs these ~4 KB. */ + +/* Hoisted: a string literal is not mangled, so each repeat is paid on flash; msgid stays literal + * for update-po.sh. */ +const MSG_UPLOAD_FAILED = _('Upload failed.', 'footstrap'); +const MSG_NOT_SVG = _('That file is not an SVG image.', 'footstrap'); +const MSG_BAD_IMAGE = _('Could not process the image.', 'footstrap'); +const MSG_PICK_SVG = _('Please choose an SVG file.', 'footstrap'); +/* One template, two numbers: a translator places both %d in one sentence instead of agreeing + * plurals. */ +const MSG_SANITIZED = _('Cleaned before upload: removed %d unsafe element(s) and %d external reference(s).', 'footstrap'); +const MSG_NOTHING_LEFT = _('That SVG had nothing left once the unsafe parts were removed.', 'footstrap'); + +/* ---- the pattern: an SVG the admin uploads, tiled and recoloured ---- + * Fixed path matched by the rpcd ACL, under /etc so an upgrade keeps it. Not the security boundary: a + * `curl` to cgi-upload skips every check below; a direct open is stopped by the CGI's + * `Content-Security-Policy: default-src 'none'; sandbox`. */ +const PAT_PATH = '/etc/footstrap/pattern.svg'; /* cgi-upload target; the ACL grants exactly this */ +const PAT_MAX = 512 * 1024; /* a tile that has to reach a router's flash and then every page load */ +/* What `_sanitizeSvg` removes, judged on the parsed document, not the text. Painted as an image nothing + * loads; these rules cover a direct open of the file: + * - elements: only the SVG namespace stays (`` ran same-origin), minus + * script, foreignObject, a ` + {% endif %} + + + {# + Lets an admin install this page as an app (icon, own window, no address bar), worth more + than a tab on the phone LuCI is opened from when the network is broken. Both files are + static, a theme having no dispatcher node to render them per request: start_url is + /cgi-bin/luci/, uhttpd's default (a moved LuCI opens the standard path), and + background/theme_color is the default palette's page colour, read once at install time. + .json, not .webmanifest: uhttpd's compiled-in MIME map has no entry for that extension, + so it answers application/octet-stream. + #} + + {# + iOS reads this link and never the manifest's icons array, and it scales the 192 square the + manifest also uses down to its own 180. One raster serves both. + #} + + {% if (dispatched?.css): %} + {# + The `css` property of the dispatched menu.d node: a stylesheet the server links for the + page instead of the view injecting one at module eval. `dispatched`, not `node`: the + dispatcher exports the resolved node as `dispatched` and never binds `node`, so no ucode + theme emitted this link until openwrt/luci#8920. That commit also adds `css: 'string'` to + the node schema; without it the property is filtered out and this stays false, as on 24.10 + and 25.12. Versioned, as uhttpd sends no Cache-Control. Not `data-fs-shell`: the sheet + belongs to one page, so the SPA router must judge and contain it like any app sheet. + #} + + {% endif %} + {% if (css): %} + {# + no `title` attribute: on a + {% endif %} + {# + The catalogue is a CGI spawn on the critical path (30 ms on the router) that delivers 13 + bytes at lang=en, where no en catalogue exists because the msgids already are English. + The process is the cost, so emit those bytes inline when the language has no catalogue + and keep the tag when it has. The probe mirrors the server: load_catalog() reads every + `*..lmo` in /usr/lib/lua/luci/i18n, so a router shipping an en catalogue still gets + the tag; deciding by language NAME would drop it. Fails open: a throwing probe keeps the + tag, as a missing catalogue renders English and reports nothing. + #} + {%- let has_catalog = true; + try { has_catalog = length(require('fs').glob(`/usr/lib/lua/luci/i18n/*.${dispatcher.lang}.lmo`)) > 0; } catch (e) {} %} + {% if (has_catalog): %} + + {% else %} + + {% endif %} + {# + the one asset stock LuCI leaves without a cache key. Must stay synchronous and un-deferred: + luci.js captures `window.cbi_init` in its constructor, and `defer` is not an option for the + catalogue above either — footer.ut runs `L.require('menu-footstrap')` inline while the + parser is still running, so a module's `_()` would race a deferred window.TR. + #} + + diff --git a/luci-theme-footstrap/ucode/template/themes/footstrap/partials/notice.ut b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/notice.ut new file mode 100644 index 00000000..c6227eab --- /dev/null +++ b/luci-theme-footstrap/ucode/template/themes/footstrap/partials/notice.ut @@ -0,0 +1,16 @@ +{# + One top-of-content warning notice: heading, sentence and an optional "go fix it" button. The + `dispatcher.lookup` guard must not drift: `path` is looked up before it is linked, so a build + without that page, or a user without rights to it, gets the warning and no dead link. + include(…, { title, text }) for a plain notice; add { path, label } for the button. + title/text are emitted unescaped: they are `_()` results over strings this theme ships, not + request data. + Licensed to the public under the Apache License 2.0. +-#} +
+

{{ title }}

+

{{ text }}

+ {% if (path && dispatcher.lookup(path)): %} + + {% endif %} +
diff --git a/luci-theme-footstrap/ucode/template/themes/footstrap/sysauth.ut b/luci-theme-footstrap/ucode/template/themes/footstrap/sysauth.ut new file mode 100644 index 00000000..429ea5e5 --- /dev/null +++ b/luci-theme-footstrap/ucode/template/themes/footstrap/sysauth.ut @@ -0,0 +1,185 @@ +{# + The LOGIN page. + + Copyright 2008 Steven Barth + Copyright 2008-2012 Jo-Philipp Wich + Licensed to the public under the Apache License 2.0. + + It exists because the generic template includes the header WITHOUT `blank_page: true`: with no + theme-local sysauth the whole chrome renders around the login form with every control dead. + No departure here may drop a field the backend posts. + Do not re-introduce a `