🗽 Sync 2026-10-23 00:46:23

This commit is contained in:
github-actions[bot] committed 2026-10-23 00:46:23 +08:00
1 parent 6032a57538
commit 2b29b0c4e1
112 files changed
+1859 -6208

No files matched your search

+6 -3
View File
@@ -7,11 +7,12 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=Argon Theme
LUCI_DEPENDS:=+luci-base +USE_APK:wget-any +!USE_APK:wget +jsonfilter +ucode-mod-math +flock +coreutils-timeout
# Older SDKs require the coreutils parent menu to enable coreutils-timeout.
LUCI_DEPENDS:=+luci-base +USE_APK:wget-any +!USE_APK:wget +jsonfilter +ucode-mod-math +flock +coreutils +coreutils-timeout
# Keep existing translation package names and the argon-config message domain.
LUCI_BASENAME:=argon-config
PKG_VERSION:=3.0.0
PKG_RELEASE:=31
PKG_VERSION:=3.1.0
PKG_RELEASE:=32
PKG_LICENSE:=Apache-2.0 MIT
# LuCI derives translations independently of the theme version. Keep release
@@ -27,6 +28,8 @@ endif
CONFIG_LUCI_CSSTIDY:=
# Ship template source across stable and snapshot runtimes. Precompiled
# bytecode would require the SDK's ucode format (see issue #715).
# Disable this package's build step as well, avoiding LuCI's opt-out warning.
CONFIG_LUCI_UTMIN:=
LUCI_MINIFY_UT:=0
define Package/luci-theme-argon/conffiles
+12 -2
View File
@@ -35,7 +35,15 @@ It also supports automatic and manual switching between light and dark modes.
</div>
## What's new in 3.0
## What's new in 3.1
- **Online theme updates.** Check stable releases and install with the firmware's native APK or OPKG, preserving Argon settings and verifying package SHA256 and size.
- **Layout fixes.** Improve dashboard actions, overview layouts, dynamic lists and custom IP/MAC inputs across narrow screens, light/dark modes and RTL layouts.
- **Compatibility improvements.** Improve compatibility with stable OpenWrt firmware.
Users on v3.0.0 or earlier must install v3.1.0 manually once to get online updates. See the [release notes](RELEASE.md).
## 3.0 highlights
- **Built-in Argon Config.** Manage the theme without a separate plugin.
- **Broad third-party app support.** Refined layouts, forms and tables for most third-party LuCI apps, with desktop/mobile and light/dark adaptations.
@@ -89,10 +97,12 @@ Open **System → Argon Config** for login layouts, colors, glass effects, wallp
Local image and video backgrounds are supported, along with Bing, Unsplash and Wallhaven wallpapers. Unsplash requires an API access key. Browser icons and login logos accept PNG files, 16–1024 pixels per side, up to 1 MiB; browser icons must be square.
Under **Theme updates**, check the latest stable release, read its release notes and download the package matching your firmware's package manager. Checks use the router's connection to GitHub and cache successful results for five minutes.
Under **Theme updates**, check the latest stable release, read its release notes and download the package matching your firmware's package manager.
When a newer version is available, choose **Update now** and confirm to install the theme using the firmware’s native APK or OPKG package manager, preserving your Argon configuration. The page shows download, verification and installation status plus installation logs, and resumes monitoring after a page refresh. Reload the page after installation. Automatic installation requires a matching release package and SHA256 digest and does not perform downgrades. It updates the main theme package; separate translation packages remain managed by the system package manager.
When building firmware, stop including the old standalone `luci-app-argon-config` plugin alongside the theme: its files conflict with the integrated settings. See [Build with OpenWrt](docs/DEVELOPMENT.md#build-with-openwrt) for existing build scripts.
## Screenshots
Light and dark views of the overview and both login layouts, on desktop and mobile.
+12 -2
View File
@@ -35,7 +35,15 @@ Argon 是**一款干净整洁的 OpenWrt LuCI 主题**,
</div>
## 3.0 新特性
## 3.1 新特性
- **在线更新主题**:检查稳定版,通过固件原生 APK 或 OPKG 安装,保留 Argon 设置,校验安装包 SHA256 和大小。
- **布局修复**:优化 Dashboard 操作区、概览布局、动态列表和自定义 IP/MAC 输入框,兼顾窄屏、亮暗色和 RTL 布局。
- **兼容性改进**:改善稳定版 OpenWrt 固件上的兼容性。
v3.0.0 及更早版本用户需先手动安装一次 v3.1.0,之后即可使用在线更新。详见[更新日志](RELEASE_ZH.md)。
## 3.0 主要特性
- **内置 Argon Config**:主题设置随主题安装,无需独立插件。
- **广泛适配第三方应用**:针对大多数第三方 LuCI 应用优化布局、表单和表格,兼顾桌面、手机与亮暗模式。
@@ -89,10 +97,12 @@ apk add --allow-untrusted ./luci-theme-argon-*.apk
背景支持本地图片、视频,以及 Bing、Unsplash、Wallhaven 在线壁纸;Unsplash 需填写 API Access Key。网站图标和登录 Logo 支持 PNG,每边 16–1024 像素、最大 1 MiB,网站图标需为正方形。
在**主题更新**中手动检查最新稳定版、查看更新日志,并下载与固件包管理器匹配的安装包。检查通过路由器连接 GitHub,成功结果缓存五分钟。
在**主题更新**中手动检查最新稳定版、查看更新日志,并下载与固件包管理器匹配的安装包。
检测到更高版本后,可点击**立即更新**并确认,通过固件自带的 APK 或 OPKG 安装主题,保留当前 Argon 配置。页面显示下载、校验、安装状态及安装日志,刷新页面后可继续查看进度;安装完成后重新加载页面。自动安装要求正式发布提供对应安装包及 SHA256 校验值,不执行降级。此功能更新主题主包,独立语言包仍由系统包管理器管理。
自行编译固件时,不要再同时引入旧版独立 `luci-app-argon-config` 插件;它会与主题内置文件冲突。已有自动编译脚本的调整方式见[源码构建说明](docs/DEVELOPMENT_ZH.md#源码构建)。
## 效果展示
亮暗两色,展示桌面与手机端的首页和两套登录界面。
+60
View File
@@ -1,5 +1,65 @@
# Update log for master branch
## v3.1.0 [ 2026.10.22 ]
### Added / 新功能
- Add stable release checks and online theme installation under **System → Argon Config → Theme updates**, using the firmware's native APK or OPKG package manager and preserving Argon settings.
- Verify package SHA256 and size before installation, reject downgrades, and show installation progress and logs with monitoring that resumes after a page refresh.
- 新增稳定版检查与在线安装,可在**系统 → Argon 配置 → 主题更新**中通过固件原生 APK 或 OPKG 包管理器升级,并保留 Argon 设置。
- 新增安装包校验、降级保护、安装进度和日志,刷新页面后可继续跟踪安装状态。
### Changed / 改进
- Improve compatibility with stable OpenWrt firmware.
- 改善稳定版 OpenWrt 固件上的兼容性。
### Fixed / 修复
- Fix dashboard action spacing and overview layouts.
- Fix dynamic list controls and custom IP/MAC input borders across narrow screens, light/dark modes and RTL layouts.
- 修复 Dashboard 操作区间距和概览布局。
- 修复动态列表控件及自定义 IP/MAC 输入框边框,兼顾窄屏、亮暗色和 RTL 布局。
### Upgrade notes / 升级说明
**First upgrade:** users on v3.0.0 or earlier must install v3.1.0 manually once. Later releases can be installed through Theme updates. Online installation updates the theme package; install separate translation packages in the usual way.
**首次升级:** v3.0.0 及更早版本用户需先手动安装一次 v3.1.0,之后可使用主题更新功能在线升级。在线安装仅更新主题主包,独立语言包仍按原方式安装。
### Installation / 安装说明
| Firmware / 固件 | Package / 安装包 |
| --- | --- |
| Native `opkg`, including OpenWrt 23.05 / 24.10 and GL.iNet firmware based on them / 原生使用 `opkg` 的固件 | `.ipk` → `opkg install` |
| Native APK v3 with `apk-tools 3.x` / 原生使用 APK v3 的固件 | `.apk` → `apk add --allow-untrusted` |
APK assets use **APK v3** and cannot be read by `apk-tools 2.x`, including `2.14.0`. Having an `apk` command installed on an opkg-based firmware does not make it APK v3 compatible. `--allow-untrusted` does not change format compatibility, and `apk` cannot install IPK files.
APK 附件采用 **APK v3 格式**,`apk-tools 2.x`(包括 `2.14.0`)无法读取。在原生使用 opkg 的固件上额外安装 `apk`,并不会让固件兼容 APK v3;`--allow-untrusted` 不能解决格式不兼容,`apk` 也不能安装 IPK。
APK builds use the OpenWrt **25.12.5 stable SDK**. Theme templates are shipped as source to avoid snapshot-specific ucode bytecode requirements. If an older APK reports `ucode>=2026.02.27`, use a package rebuilt with the [#715 fix](https://github.com/jerrykuku/luci-theme-argon/issues/715); bypassing dependency checks does not make that bytecode compatible.
APK 使用 OpenWrt **25.12.5 稳定版 SDK** 构建,主题模板保留源码,避免 snapshot 的 ucode 字节码版本限制。若旧 APK 提示需要 `ucode>=2026.02.27`,请使用包含 [#715 修复](https://github.com/jerrykuku/luci-theme-argon/issues/715) 的重新构建包;绕过依赖检查不能解决字节码不兼容。
Download the theme and required language packages in the **same format**. Use the exact asset filenames below. For GL.iNet / OpenWrt 23.05, use IPK files with the firmware's existing `opkg`.
主题和需要的语言包应选择**相同格式**,以本页附件实际文件名为准。GL.iNet / OpenWrt 23.05 请下载 IPK 并使用固件原有的 `opkg` 安装。
#### Argon 3.0 settings integration / 设置集成
Settings are included in `luci-theme-argon`. Fresh installations need only the theme and desired `luci-i18n-argon-config-*` translations.
设置已内置主题,新安装无需配置插件。已有旧插件时,先单独安装本次发行的 `luci-app-argon-config` 空兼容包,再安装主题;该顺序会保留旧配置并完成文件归属交接。
For an existing configuration plugin, install this release's empty `luci-app-argon-config` compatibility package first, then install the theme in a separate command. Do not install both in one transaction or force overwrites.
**Full Changelog**: https://github.com/jerrykuku/luci-theme-argon/compare/v3.0.0...v3.1.0
## v3.0.0 [ 2026.10.16 ]
### Argon 3.0
+22
View File
@@ -1,5 +1,27 @@
# Master 分支的更新日志
## v3.1.0 [ 2026.10.22 ]
### 新功能
- 新增稳定版检查与在线安装,可通过固件原生 APK 或 OPKG 包管理器升级,并保留 Argon 设置。
- 新增安装包校验、降级保护、安装进度和日志,刷新页面后可继续跟踪安装状态。
### 改进
- 改善稳定版 OpenWrt 固件上的兼容性。
### 修复
- 修复 Dashboard 操作区间距和概览布局。
- 修复动态列表控件及自定义 IP/MAC 输入框边框,兼顾窄屏、亮暗色和 RTL 布局。
### 升级说明
v3.0.0 及更早版本用户需先手动安装一次 v3.1.0,之后可使用在线升级。在线安装仅更新主题主包,独立语言包仍按原方式安装。
[完整发布说明](https://github.com/jerrykuku/luci-theme-argon/releases/tag/v3.1.0)
## v3.0.0 [ 2026.10.16 ]
- GitHub Release: https://github.com/jerrykuku/luci-theme-argon/releases/tag/v3.0.0
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-argon-config
PKG_VERSION:=3.0.0
PKG_RELEASE:=20261016
PKG_VERSION:=3.1.0
PKG_RELEASE:=20261022
PKG_LICENSE:=Apache-2.0
LUCI_TITLE:=Argon configuration compatibility package
+44
View File
@@ -17,6 +17,10 @@ make package/luci-theme-argon/compile V=s
Theme settings are included. Select the nested `compat/luci-app-argon-config` package under LuCI applications only when building the migration package. Existing translation names remain `luci-i18n-argon-config-*`.
Firmware and GitHub Actions build scripts should stop cloning the old standalone `luci-app-argon-config` repository and deselect that plugin. The old `1.0-r20261008` plugin duplicates the configuration, RPC services, settings page and icons now owned by the theme, causing APK's `trying to overwrite ... owned by luci-theme-argon` errors. New firmware only needs the theme. If the old package name is required, use this repository's empty migration package and keep exactly one definition of that package in the build tree. After updating an existing build tree, run `make defconfig` and clean the old plugin's build outputs before rebuilding.
The older `does not support ucode template precompilation` log line is LuCI's skip message, separate from the file conflict. This package disables precompilation and ships `.ut` source to avoid SDK/target ucode bytecode incompatibilities; other LuCI packages can retain precompilation.
Release CI builds IPK with the OpenWrt 23.05.5 SDK and APK v3 with the 25.12.5 SDK. Ucode templates ship as source to avoid tying packages to one runtime's bytecode format.
Translations use `PKG_PO_VERSION` to follow the theme release, including `-r<revision>` for APK. IPK translations retain a `v` prefix (for example, `v3.0.1`) so ordinary installation upgrades the broken v3.0.0 `unknown` version. Keep that prefix in future IPK releases. CI reads each translation's internal package metadata and blocks artifact upload and publication if the version is wrong.
@@ -87,6 +91,46 @@ Top-level menu nodes resolve `htdocs/luci-static/argon/icon/<node-name>.svg`, in
Use monochrome SVGs with a `viewBox` and transparent background. CSS masks supply theme and interaction colors. Shared chevrons use the same 24×24 grid and 2px round strokes. See the [icon index](../htdocs/luci-static/argon/icon/README.md) for available assets. This mechanism is separate from uploaded browser icons and login logos.
## Static update feed deployment
The router reads a static `/updates/stable.json` manifest without calling the GitHub API. After publication, CI uses its own `GITHUB_TOKEN` to retrieve the official release, verifies the theme package SHA256 digests and sizes, and generates one artifact. GitHub Pages publishes that artifact directly; Cloudflare Pages deploys the same files from a dedicated `updates` Git branch. Packages continue to download from the pinned GitHub Release, and translation packages remain separate.
Initial maintainer setup:
1. Set **Settings → Pages → Build and deployment → Source** to **GitHub Actions**. Allow the default branch and release tags in the `github-pages` environment. The workflow needs `contents: write` to publish the `updates` branch.
2. Once this code is on the default branch, manually run **Publish update feeds**. This initializes GitHub Pages and creates the `updates` branch from the latest stable release, without publishing a new version or requiring CF setup first.
3. Create a Cloudflare Pages **Git integration** project connected to this GitHub repository. Set production branch to `updates`, framework to **None**, build command to `exit 0`, output directory to `.`, and preview branches to **None**. Use the assigned `pages.dev` hostname or your own domain. Existing Direct Upload projects need to be replaced with a new Git integration project.
4. Set these repository **Variables** under **Settings → Secrets and variables → Actions**:
| Variable | Value |
| --- | --- |
| `ARGON_UPDATE_PRIMARY_URL` | Complete URL, e.g. `https://<assigned-hostname>/updates/stable.json` |
| `ARGON_UPDATES_ENABLED` | `true` to synchronize after stable releases |
| `ARGON_UPDATE_FALLBACK_URL` | Optional; defaults to `https://jerrykuku.github.io/luci-theme-argon/updates/stable.json`. Override for a custom fallback domain. |
5. Choose how CF is triggered. By default, keep automatic production branch deployments enabled: pushing `updates` triggers CF without a CF secret in GitHub. For Deploy Hooks, create a hook bound to `updates` under CF **Settings → Builds → Add deploy hook**. Save the complete URL as the GitHub repository Actions **Secret** `CF_PAGES_DEPLOY_HOOK`, then disable **Enable automatic production branch deployments** to avoid building each publication twice.
6. Compare `/updates/stable.json` on both hosts byte for byte. Verify primary access, fallback access and actual package downloads from the target router's network. New release packages embed the configured source URLs; changing repository variables does not change themes already installed on devices.
`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_PAGES_PROJECT` are no longer used. The Hook URL contains its own trigger credential and belongs in a Secret. A successful Hook response means CF accepted the build request; check Pages for the actual deployment result.
Only successful stable publications update the feed. Previews are excluded, and a delayed old-release job skips publication when its tag is no longer Latest. The `updates` branch contains generated static files, preserves commit history and rejects conflicting pushes. The publisher refuses to overwrite an existing unrelated branch with that name.
Run **Publish update feeds** again to republish GitHub Pages. Identical artifacts do not create another branch commit. With a Hook configured, reruns still trigger CF when the branch is unchanged, allowing failed deployments to be retried. With Git auto-deployment only, retry the build in CF or publish changed feed content. Both publication jobs are independent, so either side can publish if the other fails.
The manifest contains `schema`, `repository`, `tag`, `published_at`, `notes`, and the `apk`/`ipk` filenames with `_sha256`/`_size` fields. Metadata comes from the published package bytes rather than master's Makefile. Both formats must be complete and match the tag. Malformed feeds trigger fallback and never overwrite the previous successful cache. Checks are manual, with a five-minute success cache, a one-minute failure cooldown and a lock that coalesces concurrent requests.
Source builds default to an empty CF primary. Configure one before packaging:
```sh
python3 .github/scripts/generate_update_site.py sources \
--primary 'https://YOUR_DOMAIN/updates/stable.json' \
--output root/usr/share/argon/update-sources.json
```
Sources must be complete HTTPS URLs without credentials, ports, query strings or fragments. Runtime sources live in `/usr/share/argon/update-sources.json`, which is updated by theme packages separately from `/etc/config/argon`. The installer constructs fixed official release download URLs; manifests cannot supply arbitrary download URLs.
References: [Cloudflare GitHub integration](https://developers.cloudflare.com/pages/configuration/git-integration/github-integration/), [Deploy Hooks](https://developers.cloudflare.com/pages/configuration/deploy-hooks/), [branch deployment controls](https://developers.cloudflare.com/pages/configuration/branch-build-controls/), [custom GitHub Pages workflows](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages).
## Checks
Use Node.js 22+, Python 3, LESS 4.1.3 and a Ucode build matching the supported OpenWrt runtime:
+44
View File
@@ -17,6 +17,10 @@ make package/luci-theme-argon/compile V=s
主题设置已内置。仅在需要构建旧插件迁移包时,在 LuCI 应用中额外选择嵌套的 `compat/luci-app-argon-config` 包。语言包继续使用 `luci-i18n-argon-config-*` 名称。
固件或 GitHub Actions 构建脚本应移除旧版独立 `luci-app-argon-config` 仓库的克隆步骤,并取消旧插件选项。旧版 `1.0-r20261008` 插件会重复安装主题已内置的配置、RPC、设置页及图标,导致 APK 报 `trying to overwrite ... owned by luci-theme-argon`。新固件只需选择主题;需要保留旧包名时,使用本仓库的空迁移包,整个构建树中只保留一份该包定义。已有构建缓存时,更新包定义后重新运行 `make defconfig`,并清理旧插件的编译产物再编译。
旧构建日志中的 `does not support ucode template precompilation` 是 LuCI 的跳过提示,不是上述文件冲突的原因。本包关闭模板预编译并保留 `.ut` 源码,避免 SDK 与目标设备的 ucode 字节码格式不一致;无需关闭其他 LuCI 包的预编译。
发行 CI 使用 OpenWrt 23.05.5 SDK 构建 IPK、25.12.5 SDK 构建 APK v3。Ucode 模板保留源码,避免绑定某个运行时的字节码格式。
语言包通过 `PKG_PO_VERSION` 跟随主题发行版本,APK 包含 `-r<修订号>`。IPK 语言包固定使用 `v` 前缀(如 `v3.0.1`),使已安装的 v3.0.0 `unknown` 语言包可以直接正常升级;后续版本须保留该前缀。发布前会逐个读取语言包内部元数据,版本不符时阻止上传和发布。
@@ -87,6 +91,46 @@ lessc less/cascade.less htdocs/luci-static/argon/css/cascade.css
使用透明背景、带 `viewBox` 的单色 SVG,CSS mask 负责主题与交互颜色。共享箭头使用 24×24 网格、2px 圆角描边。资源见 [SVG 图标索引](../htdocs/luci-static/argon/icon/README.md),与用户上传的网站图标 / 登录 Logo 独立。
## 静态更新源部署
更新检查读取 `/updates/stable.json` 静态清单。路由器不调用 GitHub API;发布工作流使用自身的 `GITHUB_TOKEN` 读取正式 Release,核验主题包的 SHA256/大小后生成一份产物。GitHub Pages 直接发布该产物,Cloudflare Pages 从专用 `updates` 分支部署同一份文件。安装包继续从固定的 GitHub Release 版本下载,独立语言包仍单独安装。
首次启用需要仓库维护者完成以下配置:
1. 在仓库 **Settings → Pages → Build and deployment → Source** 选择 **GitHub Actions**。`github-pages` 环境的部署规则需允许默认分支和正式发布标签;工作流需要 `contents: write` 权限来更新 `updates` 分支。
2. 本次代码进入默认分支后,在 Actions 手动运行 **Publish update feeds**。它会用当前最新正式版初始化 GitHub Pages,并创建 `updates` 分支,无需发布新版本,也无需先配置 CF。
3. 在 Cloudflare 创建 **Git 集成**类型的 Pages 项目,连接本 GitHub 仓库。生产分支选 `updates`,框架选 **None**,构建命令填 `exit 0`,输出目录填 `.`,预览分支选 **None**。记录实际分配的 `pages.dev` 地址,或绑定自己控制的域名。如果之前创建了 Direct Upload 项目,需要新建 Git 集成项目。
4. 在仓库 **Settings → Secrets and variables → Actions → Variables** 设置:
| 仓库变量 | 值 |
| --- | --- |
| `ARGON_UPDATE_PRIMARY_URL` | 完整主源地址,例如 `https://<实际项目地址>/updates/stable.json` |
| `ARGON_UPDATES_ENABLED` | `true`,启用正式发布后的自动同步 |
| `ARGON_UPDATE_FALLBACK_URL` | 可选;默认 `https://jerrykuku.github.io/luci-theme-argon/updates/stable.json`。备用源绑定自有域名时设置。 |
5. 选择 CF 的触发方式。默认保留生产分支自动部署,发布工作流推送 `updates` 后 CF 自动构建,GitHub 无需 CF Secret。若要使用 Deploy Hook,在 CF 的 **Settings → Builds → Add deploy hook** 创建绑定 `updates` 分支的 Hook,将完整 URL 保存为 GitHub 仓库 Actions **Secret** `CF_PAGES_DEPLOY_HOOK`,并关闭 CF 的 **Enable automatic production branch deployments**,避免同一次发布构建两次。
6. 读取两端 `/updates/stable.json`,核对内容完全相同;在目标路由器网络验证主源读取、主源失败后的备用读取,以及真实包下载。配置生效后构建的新安装包会带上主源地址;设置仓库变量不会改变已经安装在设备上的主题。
此方案不再使用 `CLOUDFLARE_API_TOKEN`、`CLOUDFLARE_ACCOUNT_ID` 或 `CLOUDFLARE_PAGES_PROJECT`。Hook URL 本身包含触发凭据,只存入 Secret。Hook 返回成功表示 CF 已接受构建请求,实际部署结果需在 CF Pages 查看。
发布工作流仅在正式 Release 成功后自动同步。预发布版本不进入稳定清单;延迟执行的旧版本任务发现自己已不是 Latest 时跳过。`updates` 分支只保存生成的静态文件,保留提交历史并拒绝冲突推送;如果同名分支已用于其他用途,脚本会停止而不覆盖。
重新运行 **Publish update feeds** 可再次发布 GitHub Pages;清单未变化时不重复创建分支提交。若配置了 Hook,即使分支内容未变化也会重新触发 CF,便于重试失败的部署;仅使用 Git 自动部署时,需要在 CF 中重试构建或新增清单提交。两端发布任务互相独立,一端失败不会阻止另一端发布。
清单字段包括 `schema`、`repository`、`tag`、`published_at`、`notes`,以及 `apk`/`ipk` 文件名和对应的 `_sha256`/`_size`。由最终发布包计算元数据,不从 master 的 Makefile 推断最新版。两种格式均须完整且属于同一版本;客户端拒绝损坏清单,并保留上一次成功记录。请求只在手动检查时发起,成功缓存五分钟,双源均失败后冷却一分钟。
源码构建默认不包含 CF 地址。需要自行指定时,在打包前执行:
```sh
python3 .github/scripts/generate_update_site.py sources \
--primary 'https://YOUR_DOMAIN/updates/stable.json' \
--output root/usr/share/argon/update-sources.json
```
主备地址须为完整 HTTPS URL,不含凭据、端口、查询参数或片段。路由器端配置位于 `/usr/share/argon/update-sources.json`;下载地址由安装器固定构造,清单不能指定任意包下载 URL。此目录的配置不属于 `/etc/config/argon` 用户设置,后续主题安装包会更新它。
参考:[Cloudflare GitHub 集成](https://developers.cloudflare.com/pages/configuration/git-integration/github-integration/)、[Deploy Hooks](https://developers.cloudflare.com/pages/configuration/deploy-hooks/)、[分支部署控制](https://developers.cloudflare.com/pages/configuration/branch-build-controls/)、[GitHub Pages 自定义工作流](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages)。
## 检查
准备 Node.js 22+、Python 3、LESS 4.1.3,以及与目标 OpenWrt 运行时匹配的 Ucode:
@@ -2455,6 +2455,7 @@ body:not([data-page="admin-dashboard"]) #cbi-wireless > #wifi_assoclist_table >
.cbi-section-table-row:last-child {
margin-bottom: 0;
}
.cbi-section-table-row > .cbi-value-field .cbi-dynlist,
.cbi-section-table-row > .cbi-value-field .cbi-dropdown,
.cbi-section-table-row > .cbi-value-field .cbi-input-select,
.cbi-section-table-row > .cbi-value-field .cbi-input-text,
@@ -3182,6 +3183,9 @@ header .header-actions {
max-width: 100%;
min-width: min(100%, var(--form-control-min-width));
}
.cbi-value-field .cbi-dynlist {
width: var(--form-control-min-width);
}
@container (max-width: 40rem) {
.cbi-value {
flex-direction: column;
@@ -3435,10 +3439,10 @@ body:not(.Interfaces) .cbi-rowstyle-2:first-child {
align-items: center;
width: 100%;
min-width: 0;
max-width: min(18rem, 100%);
max-width: 100%;
flex-wrap: nowrap;
}
.cbi-dynlist > .add-item input {
.cbi-dynlist > .add-item > input {
flex: 1 1 15.5rem;
display: block;
padding: 0.5rem 0.75rem;
@@ -3458,7 +3462,7 @@ body:not(.Interfaces) .cbi-rowstyle-2:first-child {
background-image: none;
box-shadow: none;
}
[dir="rtl"] .cbi-dynlist > .add-item input:first-child {
[dir="rtl"] .cbi-dynlist > .add-item > input:first-child {
border-radius: 0 var(--radius-control) var(--radius-control) 0;
border-left-width: 0;
border-right-width: 1px;
@@ -3519,11 +3523,11 @@ body:not(.Interfaces) .cbi-rowstyle-2:first-child {
.cbi-dynlist,
.cbi-dropdown {
position: relative;
display: inline-flex;
padding-right: 0.25rem;
min-height: 2.1875rem;
}
.cbi-dropdown {
display: inline-flex;
padding-inline-end: 0.25rem;
align-items: center;
}
.cbi-dropdown.btn > ul:not(.dropdown),
@@ -3770,6 +3774,7 @@ body:not(.Interfaces) .cbi-rowstyle-2:first-child {
text-align: start;
}
.cbi-dropdown[open] > ul.dropdown > li {
display: block;
min-width: 0;
min-height: 2.5rem;
padding: 0.5rem 0.75rem;
@@ -3782,12 +3787,14 @@ body:not(.Interfaces) .cbi-rowstyle-2:first-child {
cursor: pointer;
}
.cbi-dropdown[open] > ul.dropdown > li > label {
display: block;
flex: 1 1 auto;
min-width: 0;
margin-inline-start: 0.5rem;
}
.cbi-dropdown[open] > ul.dropdown > li:has(> img),
.cbi-dropdown[open] > ul.dropdown > li > label:has(> img) {
display: flex;
align-items: flex-start;
}
.cbi-dropdown[open] > ul.dropdown > li:has(> img) > img,
@@ -6403,6 +6410,64 @@ pre.command-output {
[data-page="admin-status-routes"] #view > h3 {
border-radius: var(--radius-card) var(--radius-card) 0 0;
}
#view > .Dashboard {
--dashboard-surface-alt: var(--surface-muted);
--dashboard-text: var(--text-strong);
--dashboard-text-muted: var(--muted-text);
--dashboard-border: var(--border-color);
--dashboard-primary: var(--primary);
--dashboard-success: var(--success);
--dashboard-warn: var(--warning);
}
#view > .Dashboard .dashboard-kpi-row > .cbi-section {
min-width: min(100%, 16rem);
padding: var(--field-gap) var(--section-inset);
gap: var(--field-gap);
}
#view > .Dashboard .dashboard-kpi-text {
overflow-wrap: anywhere;
}
#view > .Dashboard .dashboard-charts > .cbi-section {
min-width: min(100%, 18rem);
padding-block-end: var(--field-gap);
}
#view > .Dashboard .dashboard-charts > .cbi-section > :not(h3:first-child) {
margin-inline: var(--section-inset);
}
#view > .Dashboard .dashboard-charts > .cbi-section > .cbi-section-descr {
padding: 0;
margin-block-end: var(--field-gap);
overflow-wrap: anywhere;
}
#view > .Dashboard .dashboard-charts > .cbi-section > em {
padding: 0;
}
#view > .Dashboard .dashboard-charts > .cbi-section > .dashboard-legend-inline {
margin-block-start: var(--field-gap);
}
#view > .Dashboard .dashboard-bars {
direction: ltr;
}
#view > .Dashboard .dashboard-net-grid {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16.25rem), 1fr));
}
#view > .Dashboard .dashboard-net-fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
#view > .Dashboard .ifacebox {
min-width: 0;
max-width: 100%;
}
#view > .Dashboard .ifacebox-body {
text-align: start;
}
#view > .Dashboard [data-tab="internet"] > div > div > em {
display: block;
padding: var(--field-gap) var(--section-inset);
}
#view > .Dashboard [data-tab="layout"] .cbi-page-actions {
padding: var(--field-gap) var(--section-inset);
}
[data-page="admin-status-processes"] .th:nth-child(3),
[data-page="admin-status-processes"] .td:nth-child(3) {
text-align: start;
@@ -105,6 +105,13 @@ return baseclass.extend({
statusIcon.hidden = kind !== 'current' && kind !== 'complete';
}
function checkError(retryAt) {
var message = _('Unable to check for updates. Check the router\'s network connection and try again.');
if (Number.isSafeInteger(retryAt) && retryAt > Date.now() / 1000)
message += ' ' + _('Try again after %s.').format(new Date(retryAt * 1000).toLocaleTimeString());
return message;
}
function mayInstall() {
var remote = release(state);
var local = version(state && state.current_version);
@@ -154,7 +161,7 @@ return baseclass.extend({
else if (busy)
showStatus(_('Checking for updates...'), 'working');
else if (!state || state.result !== 0)
showStatus(_('Unable to check for updates. Check the router\'s connection to GitHub and try again.'), 'error');
showStatus(checkError(state && state.retry_at), 'error');
else if (!remote)
showStatus(_('Not checked yet.'));
else if (!local || !remote.parsed)
@@ -295,8 +302,13 @@ return baseclass.extend({
busy = true;
refresh();
return callCheck().then(function(result) {
if (result.result !== 0 || !release(result))
throw new Error('update check failed');
if (result.result !== 0 || !release(result)) {
if (state)
state.can_upgrade = false;
var error = new Error('update check failed');
error.retry_at = result.retry_at;
throw error;
}
state = result;
busy = false;
refresh();
@@ -305,10 +317,10 @@ return baseclass.extend({
details.open = !!(remote && remote.parsed && local && compare(local, remote.parsed) < 0 && !needsReload());
if (state.upgrade && state.upgrade.active)
monitor();
}).catch(function() {
}).catch(function(error) {
busy = false;
refresh();
showStatus(_('Unable to check for updates. Check the router\'s connection to GitHub and try again.'), 'error');
showStatus(checkError(error.retry_at), 'error');
});
}
if (state && state.upgrade && state.upgrade.active)
@@ -88,6 +88,9 @@
text-align: start;
> li {
// IP/MAC choices contain text, a strong hostname and punctuation. Keep
// those fragments in one text flow instead of separate anonymous flex items.
display: block;
min-width: 0;
min-height: 2.5rem;
padding: .5rem .75rem;
@@ -100,6 +103,7 @@
cursor: pointer;
> label {
display: block;
flex: 1 1 auto;
min-width: 0;
margin-inline-start: .5rem;
@@ -108,6 +112,7 @@
// Keep wrapped descriptions in one text column, with the icon on its first line.
&:has(> img),
> label:has(> img) {
display: flex;
align-items: flex-start;
> img {
@@ -174,6 +174,12 @@
min-width: ~"min(100%, var(--form-control-min-width))";
}
// A DynamicList's percentage-sized add row needs a definite containing width.
// Match ordinary fields before its combobox measures the placeholder or value.
.cbi-value-field .cbi-dynlist {
width: var(--form-control-min-width);
}
// The map has a definite width, so size containment cannot collapse a flex item.
@container (max-width: 40rem) {
.cbi-value {
+5 -4
View File
@@ -86,10 +86,11 @@
align-items: center;
width: 100%;
min-width: 0;
max-width: min(18rem, 100%);
max-width: 100%;
flex-wrap: nowrap;
input {
// Only the plain add field joins its button; menu custom inputs stand alone.
> input {
flex: 1 1 15.5rem;
display: block;
padding: 0.5rem 0.75rem;
@@ -190,12 +191,12 @@
.cbi-dynlist,
.cbi-dropdown {
position: relative;
display: inline-flex;
padding-right: 0.25rem;
min-height: 2.1875rem;
}
.cbi-dropdown {
display: inline-flex;
padding-inline-end: .25rem;
align-items: center;
}
@@ -231,6 +231,7 @@ body:not([data-page="admin-dashboard"]) {
margin-bottom: 0;
}
.cbi-section-table-row>.cbi-value-field .cbi-dynlist,
.cbi-section-table-row>.cbi-value-field .cbi-dropdown,
.cbi-section-table-row>.cbi-value-field .cbi-input-select,
.cbi-section-table-row>.cbi-value-field .cbi-input-text,
+1
View File
@@ -35,6 +35,7 @@
@import "pages/rule-editors.less";
// Status and monitoring
@import "pages/dashboard.less";
@import "pages/processes.less";
@import "pages/overview.less";
@import "pages/realtime.less";
@@ -0,0 +1,80 @@
// out: false
// luci-mod-dashboard supplies chart layout; the theme owns its card surfaces.
// The module loads CSS after the theme, so keep these overrides view-scoped.
#view > .Dashboard {
--dashboard-surface-alt: var(--surface-muted);
--dashboard-text: var(--text-strong);
--dashboard-text-muted: var(--muted-text);
--dashboard-border: var(--border-color);
--dashboard-primary: var(--primary);
--dashboard-success: var(--success);
--dashboard-warn: var(--warning);
.dashboard-kpi-row > .cbi-section {
min-width: ~"min(100%, 16rem)";
padding: var(--field-gap) var(--section-inset);
gap: var(--field-gap);
}
.dashboard-kpi-text {
overflow-wrap: anywhere;
}
.dashboard-charts > .cbi-section {
min-width: ~"min(100%, 18rem)";
padding-block-end: var(--field-gap);
// Keep the native full-width heading; inset its chart and legend.
> :not(h3:first-child) {
margin-inline: var(--section-inset);
}
> .cbi-section-descr {
padding: 0;
margin-block-end: var(--field-gap);
overflow-wrap: anywhere;
}
> em {
padding: 0;
}
> .dashboard-legend-inline {
margin-block-start: var(--field-gap);
}
}
// Keep numeric chart axes in their native direction within RTL pages.
.dashboard-bars {
direction: ltr;
}
.dashboard-net-grid {
grid-template-columns: repeat(auto-fill, minmax(~"min(100%, 16.25rem)", 1fr));
}
.dashboard-net-fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ifacebox {
min-width: 0;
max-width: 100%;
}
.ifacebox-body {
text-align: start;
}
// Each Internet column wraps its empty hint below the tab's root div.
[data-tab="internet"] > div > div > em {
display: block;
padding: var(--field-gap) var(--section-inset);
}
// The layout editor keeps its actions inside the card, unlike normal pages.
[data-tab="layout"] .cbi-page-actions {
padding: var(--field-gap) var(--section-inset);
}
}
+6 -2
View File
@@ -426,8 +426,8 @@ msgid "No release notes were published."
msgstr "لم تُنشر ملاحظات لهذا الإصدار."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "تعذر التحقق من وجود تحديثات. تحقق من اتصال الموجّه بـ GitHub وحاول مجددًا."
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "تعذر التحقق من وجود تحديثات. تحقق من اتصال الموجّه بالشبكة وحاول مجددًا."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -532,3 +532,7 @@ msgstr "سيُحدَّث المظهر باستخدام مدير حزم النظ
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "إلغاء"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "حاول مجددًا بعد %s."
+6 -2
View File
@@ -426,8 +426,8 @@ msgid "No release notes were published."
msgstr "Für diese Version wurden keine Versionshinweise veröffentlicht."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "Updates konnten nicht geprüft werden. Prüfen Sie die Verbindung des Routers zu GitHub und versuchen Sie es erneut."
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "Suche nach Updates fehlgeschlagen. Prüfen Sie die Netzwerkverbindung des Routers und versuchen Sie es erneut."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -532,3 +532,7 @@ msgstr "Das Theme wird mit der Paketverwaltung des Systems aktualisiert. Ihre Ar
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "Abbrechen"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "Versuchen Sie es nach %s erneut."
+6 -2
View File
@@ -428,8 +428,8 @@ msgid "No release notes were published."
msgstr "No se publicaron notas para esta versión."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "No se pudieron buscar actualizaciones. Compruebe la conexión del router con GitHub e inténtelo de nuevo."
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "No se pudo buscar actualizaciones. Compruebe la conexión de red del router e inténtelo de nuevo."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -534,3 +534,7 @@ msgstr "El tema se actualizará con el gestor de paquetes del sistema. Se conser
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "Cancelar"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "Vuelva a intentarlo después de las %s."
+6 -2
View File
@@ -426,8 +426,8 @@ msgid "No release notes were published."
msgstr "Aucune note n’a été publiée pour cette version."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "Impossible de rechercher les mises à jour. Vérifiez la connexion du routeur à GitHub, puis réessayez."
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "Impossible de rechercher les mises à jour. Vérifiez la connexion réseau du routeur et réessayez."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -532,3 +532,7 @@ msgstr "Le thème sera mis à jour avec le gestionnaire de paquets du système.
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "Annuler"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "Réessayez après %s."
+6 -2
View File
@@ -426,8 +426,8 @@ msgid "No release notes were published."
msgstr "Non sono state pubblicate note per questa versione."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "Impossibile controllare gli aggiornamenti. Verificare la connessione del router a GitHub e riprovare."
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "Impossibile cercare aggiornamenti. Controllare la connessione di rete del router e riprovare."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -532,3 +532,7 @@ msgstr "Il tema verrà aggiornato con il gestore di pacchetti del sistema. Le im
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "Annulla"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "Riprovare dopo le %s."
+6 -2
View File
@@ -426,8 +426,8 @@ msgid "No release notes were published."
msgstr "このバージョンのリリースノートはありません。"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "更新を確認できません。ルーターと GitHub の接続を確認して、もう一度お試しください。"
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "更新を確認できません。ルーターのネットワーク接続を確認して、もう一度お試しください。"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -532,3 +532,7 @@ msgstr "システムのパッケージマネージャーでテーマを更新し
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "キャンセル"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "%s 以降にもう一度お試しください。"
+6 -2
View File
@@ -426,8 +426,8 @@ msgid "No release notes were published."
msgstr "이 버전에는 릴리스 노트가 없습니다."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "업데이트를 확인할 수 없습니다. 라우터의 GitHub 연결을 확인한 후 다시 시도하세요."
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "업데이트를 확인할 수 없습니다. 라우터의 네트워크 연결을 확인하고 다시 시도하세요."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -532,3 +532,7 @@ msgstr "시스템 패키지 관리자로 테마를 업데이트합니다. 현재
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "취소"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "%s 이후에 다시 시도하세요."
+6 -2
View File
@@ -426,8 +426,8 @@ msgid "No release notes were published."
msgstr "Er zijn geen opmerkingen voor deze uitgave gepubliceerd."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "Kan niet op updates controleren. Controleer de verbinding van de router met GitHub en probeer het opnieuw."
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "Kan niet op updates controleren. Controleer de netwerkverbinding van de router en probeer het opnieuw."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -532,3 +532,7 @@ msgstr "Het thema wordt bijgewerkt met de pakketbeheerder van het systeem. Uw Ar
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "Annuleren"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "Probeer het na %s opnieuw."
+6 -2
View File
@@ -426,8 +426,8 @@ msgid "No release notes were published."
msgstr "Nie opublikowano informacji o tym wydaniu."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "Nie można sprawdzić aktualizacji. Sprawdź połączenie routera z GitHub i spróbuj ponownie."
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "Nie można sprawdzić aktualizacji. Sprawdź połączenie sieciowe routera i spróbuj ponownie."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -532,3 +532,7 @@ msgstr "Motyw zostanie zaktualizowany przy użyciu systemowego menedżera pakiet
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "Anuluj"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "Spróbuj ponownie po %s."
+6 -2
View File
@@ -428,8 +428,8 @@ msgid "No release notes were published."
msgstr "Não foram publicadas notas para esta versão."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "Não foi possível verificar atualizações. Verifique a conexão do roteador com o GitHub e tente novamente."
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "Não foi possível verificar atualizações. Verifique a conexão de rede do roteador e tente novamente."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -534,3 +534,7 @@ msgstr "O tema será atualizado com o gerenciador de pacotes do sistema. Suas co
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "Cancelar"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "Tente novamente após %s."
+6 -2
View File
@@ -426,8 +426,8 @@ msgid "No release notes were published."
msgstr "Примечания к этому выпуску не опубликованы."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "Не удалось проверить обновления. Проверьте подключение маршрутизатора к GitHub и повторите попытку."
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "Не удалось проверить обновления. Проверьте сетевое подключение маршрутизатора и повторите попытку."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -532,3 +532,7 @@ msgstr "Тема будет обновлена системным менедже
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "Отмена"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "Повторите попытку после %s."
@@ -433,7 +433,7 @@ msgid "No release notes were published."
msgstr ""
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr ""
#: htdocs/luci-static/resources/argon/updates.js
@@ -539,3 +539,7 @@ msgstr ""
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr ""
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr ""
+6 -2
View File
@@ -426,8 +426,8 @@ msgid "No release notes were published."
msgstr "Bu sürüm için sürüm notları yayımlanmadı."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "Güncellemeler denetlenemedi. Yönlendiricinin GitHub bağlantısını kontrol edip yeniden deneyin."
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "Güncellemeler denetlenemedi. Yönlendiricinin ağ bağlantısını kontrol edip yeniden deneyin."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -532,3 +532,7 @@ msgstr "Tema, sistemin paket yöneticisiyle güncellenecek. Argon ayarlarınız
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "İptal"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "%s sonrasında yeniden deneyin."
+6 -2
View File
@@ -426,8 +426,8 @@ msgid "No release notes were published."
msgstr "Примітки до цього випуску не опубліковані."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "Не вдалося перевірити оновлення. Перевірте з’єднання маршрутизатора з GitHub і повторіть спробу."
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "Не вдалося перевірити оновлення. Перевірте мережеве підключення маршрутизатора та спробуйте ще раз."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -532,3 +532,7 @@ msgstr "Тему буде оновлено системним менеджеро
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "Скасувати"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "Спробуйте ще раз після %s."
+6 -2
View File
@@ -426,8 +426,8 @@ msgid "No release notes were published."
msgstr "Không có ghi chú cho bản phát hành này."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "Không thể kiểm tra cập nhật. Hãy kiểm tra kết nối của bộ định tuyến tới GitHub rồi thử lại."
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "Không thể kiểm tra bản cập nhật. Hãy kiểm tra kết nối mạng của bộ định tuyến rồi thử lại."
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -532,3 +532,7 @@ msgstr "Giao diện sẽ được cập nhật bằng trình quản lý gói h
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "Hủy"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "Thử lại sau %s."
+6 -2
View File
@@ -428,8 +428,8 @@ msgid "No release notes were published."
msgstr "此版本未提供更新日志。"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "检查更新失败。请检查路由器与 GitHub 的连接后重试。"
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "检查更新失败。请检查路由器的网络连接后重试。"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -534,3 +534,7 @@ msgstr "将使用系统包管理器更新主题,并保留当前 Argon 配置
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "取消"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "请在 %s 后重试。"
+6 -2
View File
@@ -428,8 +428,8 @@ msgid "No release notes were published."
msgstr "此版本未提供更新日誌。"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to check for updates. Check the router's connection to GitHub and try again."
msgstr "檢查更新失敗。請檢查路由器與 GitHub 的連線後重試。"
msgid "Unable to check for updates. Check the router's network connection and try again."
msgstr "檢查更新失敗。請檢查路由器的網路連線後重試。"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Unable to compare this version. View the release for details."
@@ -534,3 +534,7 @@ msgstr "將使用系統套件管理器更新佈景主題,並保留目前的 Ar
#: htdocs/luci-static/resources/argon/updates.js
msgid "Cancel"
msgstr "取消"
#: htdocs/luci-static/resources/argon/updates.js
msgid "Try again after %s."
msgstr "請在 %s 後重試。"
@@ -4,7 +4,17 @@
readonly state_dir='/var/run/argon-update'
readonly cache_file='/var/run/argon-update/release.json'
readonly release_api='https://api.github.com/repos/jerrykuku/luci-theme-argon/releases/latest'
readonly sources_file='/usr/share/argon/update-sources.json'
readonly repository='jerrykuku/luci-theme-argon'
opkg_query() (
# OPKG locks its database even for status and comparison commands. Keep our
# RPC reads from racing the worker's version checks against that native lock.
prepare_state || exit 1
exec 6>"$state_dir/package-query.lock"
flock -w 2 6 || exit 1
opkg "$@"
)
installed_version() {
current_version=''
@@ -15,7 +25,7 @@ installed_version() {
[ -z "$current_version" ] || package_manager=apk
fi
if [ -z "$current_version" ] && command -v opkg >/dev/null 2>&1; then
current_version="$(opkg status luci-theme-argon 2>/dev/null | awk '
current_version="$(opkg_query status luci-theme-argon 2>/dev/null | awk '
/^Version: / { version=$2 }
/^Status: .* installed$/ { installed=1 }
END { if (installed) print version }')"
@@ -31,9 +41,110 @@ read_cache() {
[ -f "$cache_file" ] && [ ! -L "$cache_file" ] &&
[ "$(wc -c < "$cache_file")" -le 1048576 ] || return 1
json_load_file "$cache_file" || return 1
json_get_var schema schema
json_get_var cached_repository repository
json_get_var tag tag
json_get_var checked_at checked_at
valid_tag "$tag" && printf '%s\n' "$checked_at" | grep -Eq '^[0-9]{1,11}$'
[ "$schema" = 1 ] && [ "$cached_repository" = "$repository" ] &&
valid_tag "$tag" && printf '%s\n' "$checked_at" | grep -Eq '^[0-9]{1,11}$'
}
valid_source() {
[ "${#1}" -le 512 ] && printf '%s\n' "$1" |
grep -Eq '^https://[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(/[A-Za-z0-9._~-]+)+$'
}
load_sources() {
primary_url=''
fallback_url='https://jerrykuku.github.io/luci-theme-argon/updates/stable.json'
if [ -f "$sources_file" ] && [ ! -L "$sources_file" ] &&
[ "$(wc -c < "$sources_file")" -le 4096 ] && json_load_file "$sources_file"; then
json_get_var configured_primary primary
json_get_var configured_fallback fallback
valid_source "$configured_primary" && primary_url="$configured_primary"
valid_source "$configured_fallback" && fallback_url="$configured_fallback"
fi
}
normalize_manifest() {
json_load_file "$1" || return 1
json_get_var schema schema
json_get_var manifest_repository repository
json_get_var tag tag
[ "$schema" = 1 ] && [ "$manifest_repository" = "$repository" ] && valid_tag "$tag" || return 1
# Both formats must be complete. Never combine fields from different sources.
package_manager=apk
select_package || return 1
package_manager=opkg
select_package || return 1
for field in ipk apk ipk_sha256 apk_sha256 ipk_size apk_size published_at notes; do
json_get_var "$field" "$field"
done
printf '%s\n' "$published_at" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$' || return 1
[ "${#notes}" -le 131072 ] || return 1
# Copy only the contract fields, not remote status, URLs or local timestamps.
json_init
json_add_int schema 1
json_add_string repository "$repository"
json_add_string tag "$tag"
json_add_string published_at "$published_at"
json_add_string notes "$notes"
json_add_string ipk "$ipk"
json_add_string apk "$apk"
json_add_string ipk_sha256 "$ipk_sha256"
json_add_string apk_sha256 "$apk_sha256"
json_add_int ipk_size "$ipk_size"
json_add_int apk_size "$apk_size"
json_add_int checked_at "$(date +%s)"
}
check_failure() {
read_cache || json_init
json_add_int retry_at "$retry_at"
respond 1
}
check_updates() {
prepare_state || { fail 1; return; }
# Separate from the installation lock. Concurrent tabs reuse the first check.
exec 7>"$state_dir/check.lock"
flock -w 15 7 || { retry_at="$(($(date +%s) + 5))"; check_failure; return; }
now="$(date +%s)"
if read_cache && [ "$now" -ge "$checked_at" ] && [ "$((now - checked_at))" -lt 300 ]; then
respond 0
return
fi
retry_at=0
if [ -f "$state_dir/retry_at" ] && [ ! -L "$state_dir/retry_at" ] &&
[ "$(wc -c < "$state_dir/retry_at")" -le 12 ]; then
retry_at="$(cat "$state_dir/retry_at")"
fi
if printf '%s\n' "$retry_at" | grep -Eq '^[0-9]{1,11}$' &&
[ "$retry_at" -gt "$now" ] && [ "$((retry_at - now))" -le 60 ]; then
check_failure
return
fi
load_sources
work="$(mktemp -d "$state_dir/check.XXXXXX")" || { fail 1; return; }
trap 'rm -rf "$work"' EXIT
trap 'exit 1' HUP INT TERM
for source_url in "$primary_url" "$fallback_url"; do
[ -n "$source_url" ] || continue
# The two attempts together stay below rpcd's request timeout.
if (ulimit -f 2048; timeout -k 1 5 wget -T 4 -q -U 'luci-theme-argon' \
-O "$work/manifest.json" "$source_url") 2>/dev/null &&
[ "$(wc -c < "$work/manifest.json")" -le 1048576 ] && normalize_manifest "$work/manifest.json"; then
json_dump > "$work/cache.json"
[ ! -L "$cache_file" ] && mv -f "$work/cache.json" "$cache_file" 2>/dev/null || :
rm -f "$state_dir/retry_at"
respond 0
return
fi
[ "$primary_url" != "$fallback_url" ] || break
done
retry_at="$(($(date +%s) + 60))"
printf '%s\n' "$retry_at" > "$state_dir/retry_at"
check_failure
}
select_package() {
@@ -57,7 +168,7 @@ select_package() {
esac
printf '%s\n' "$revision" | grep -Eq '^[0-9]{1,8}$' &&
printf '%s\n' "$digest" | grep -Eq '^[a-f0-9]{64}$' &&
printf '%s\n' "$size" | grep -Eq '^[0-9]{1,8}$' &&
printf '%s\n' "$size" | grep -Eq '^[1-9][0-9]{0,7}$' &&
[ "$size" -gt 0 ] && [ "$size" -le 8388608 ]
}
@@ -66,13 +177,13 @@ is_upgrade() {
printf '%s\n' "$current_version" | grep -Eq '^[0-9]+(\.[0-9]+){1,3}((~|_)(alpha|beta|rc)[0-9]+|-(alpha|beta|rc)\.[0-9]+)?(-r?[0-9]+)?$' || return 1
case "$package_manager" in
apk) [ "$(apk version -t "$current_version" "$target_version" 2>/dev/null)" = '<' ] ;;
opkg) opkg compare-versions "$current_version" '<<' "$target_version" ;;
opkg) opkg_query compare-versions "$current_version" '<<' "$target_version" ;;
*) return 1 ;;
esac
}
prepare_state() {
[ ! -L "$state_dir" ] && { [ -d "$state_dir" ] || mkdir -m 700 "$state_dir"; } &&
[ ! -L "$state_dir" ] && mkdir -p -m 700 "$state_dir" && [ ! -L "$state_dir" ] &&
command -v flock >/dev/null 2>&1 && command -v sha256sum >/dev/null 2>&1 && command -v timeout >/dev/null 2>&1
}
@@ -129,7 +240,11 @@ respond() {
# Read after the job snapshot so a completed job reports its installed version.
installed_version
can_upgrade=0
if select_package && is_upgrade && command -v flock >/dev/null 2>&1 && command -v sha256sum >/dev/null 2>&1 && command -v timeout >/dev/null 2>&1; then
now="$(date +%s)"
json_get_var checked_at checked_at
if [ "$1" = 0 ] && printf '%s\n' "$checked_at" | grep -Eq '^[0-9]{1,11}$' &&
[ "$now" -ge "$checked_at" ] && [ "$((now - checked_at))" -lt 300 ] &&
select_package && is_upgrade && command -v flock >/dev/null 2>&1 && command -v sha256sum >/dev/null 2>&1 && command -v timeout >/dev/null 2>&1; then
can_upgrade=1
fi
json_add_int result "$1"
@@ -260,92 +375,6 @@ call)
respond 0
exit 0
fi
now="$(date +%s)"
if read_cache && [ "$now" -ge "$checked_at" ] && [ "$((now - checked_at))" -lt 300 ]; then
respond 0
exit 0
fi
[ -L "$state_dir" ] || [ -d "$state_dir" ] || mkdir -m 700 "$state_dir" 2>/dev/null || :
umask 077
work="$(mktemp -d /tmp/argon-update.XXXXXX)" || { fail 1; exit 0; }
trap 'rm -rf "$work"' EXIT
trap 'exit 1' HUP INT TERM
# Bound the request to less than rpcd's timeout and limit the response size.
(ulimit -f 2048; timeout -k 2 12 wget -T 10 -q -U 'luci-theme-argon' \
--header='Accept: application/vnd.github+json' \
--header='Cache-Control: no-cache' \
-O "$work/release.json" "$release_api") 2>/dev/null || { fail 1; exit 0; }
[ "$(wc -c < "$work/release.json")" -le 1048576 ] &&
json_load_file "$work/release.json" || { fail 2; exit 0; }
json_get_var tag tag_name
json_get_var draft draft
json_get_var prerelease prerelease
valid_tag "$tag" && [ "$draft" = 0 ] && [ "$prerelease" = 0 ] || { fail 2; exit 0; }
json_get_var published_at published_at
json_get_var notes body
base="${tag#v}"
ipk=''
apk=''
ipk_sha256=''
apk_sha256=''
ipk_size=0
apk_size=0
ipk_revision=-1
apk_revision=-1
if json_select assets; then
json_get_keys asset_keys
for asset_key in $asset_keys; do
json_select "$asset_key" || continue
json_get_var asset name
json_get_var asset_digest digest
json_get_var asset_size size
# Older releases may not expose a checksum. Keep manual downloads,
# but only checksum-backed assets can be installed through this RPC.
asset_digest="${asset_digest#sha256:}"
printf '%s\n' "$asset_digest" | grep -Eq '^[a-f0-9]{64}$' || asset_digest=''
printf '%s\n' "$asset_size" | grep -Eq '^[0-9]{1,8}$' || asset_size=0
case "$asset" in
"luci-theme-argon_${base}"*_all.ipk)
revision="${asset#luci-theme-argon_${base}}"
revision="${revision%_all.ipk}"
case "$revision" in '') revision=1 ;; -*) revision="${revision#-}" ;; *) revision='' ;; esac
if printf '%s\n' "$revision" | grep -Eq '^[0-9]{1,8}$' && [ "$revision" -gt "$ipk_revision" ]; then
ipk="$asset"
ipk_sha256="$asset_digest"
ipk_size="$asset_size"
ipk_revision="$revision"
fi
;;
"luci-theme-argon-${base}-r"*.apk)
revision="${asset#luci-theme-argon-${base}-r}"
revision="${revision%.apk}"
if printf '%s\n' "$revision" | grep -Eq '^[0-9]{1,8}$' && [ "$revision" -gt "$apk_revision" ]; then
apk="$asset"
apk_sha256="$asset_digest"
apk_size="$asset_size"
apk_revision="$revision"
fi
;;
esac
json_select ..
done
fi
json_init
json_add_string tag "$tag"
json_add_string published_at "$published_at"
json_add_string notes "$notes"
json_add_string ipk "$ipk"
json_add_string apk "$apk"
json_add_string ipk_sha256 "$ipk_sha256"
json_add_string apk_sha256 "$apk_sha256"
json_add_int ipk_size "$ipk_size"
json_add_int apk_size "$apk_size"
json_add_int checked_at "$now"
json_dump > "$work/cache.json"
# Cache only successful checks, atomically; a failed cache write is nonfatal.
if [ ! -L "$cache_file" ] && { [ ! -e "$cache_file" ] || [ -f "$cache_file" ]; }; then
mv -f "$work/cache.json" "$cache_file" 2>/dev/null || :
fi
respond 0
check_updates
;;
esac
@@ -0,0 +1,4 @@
{
"primary": "",
"fallback": "https://jerrykuku.github.io/luci-theme-argon/updates/stable.json"
}
@@ -0,0 +1,171 @@
"""Exercise feed publication against a local Git remote and an isolated hook."""
import importlib.util
import io
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from contextlib import redirect_stdout
from unittest.mock import MagicMock, patch
from urllib.error import HTTPError
SCRIPT = Path(__file__).resolve().parents[1] / '.github/scripts/publish_update_branch.py'
SPEC = importlib.util.spec_from_file_location('update_branch', SCRIPT)
PUBLISH = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(PUBLISH)
HOOK = 'https://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/test-secret-id'
class UpdateBranchTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.directory = Path(self.temp.name)
self.remote = self.directory / 'remote.git'
self.repository = self.directory / 'checkout'
self.site = self.directory / 'site'
self.run_git('init', '--bare', str(self.remote))
self.run_git('init', '-b', 'main', str(self.repository))
self.git('config', 'user.name', 'Test Maintainer')
self.git('config', 'user.email', 'test@example.invalid')
(self.repository / 'README').write_text('Source branch\n')
self.git('add', 'README')
self.git('commit', '-m', 'Initial source')
self.git('remote', 'add', 'origin', str(self.remote))
self.git('push', 'origin', 'main')
self.original = self.git('rev-parse', 'HEAD').strip()
(self.site / 'updates').mkdir(parents=True)
(self.site / '.nojekyll').touch()
(self.site / '404.html').write_text('<title>Not found</title>\n')
(self.site / '_headers').write_text('/updates/stable.json\n Cache-Control: max-age=300\n')
(self.site / 'updates/stable.json').write_text(json.dumps({'schema': 1, 'tag': 'v3.1.0', 'notes': '更新'}))
def run_git(self, *arguments):
return subprocess.check_output(['git', *arguments], stderr=subprocess.PIPE).decode()
def git(self, *arguments):
return self.run_git('-C', str(self.repository), *arguments)
def remote_git(self, *arguments):
return self.run_git('--git-dir', str(self.remote), *arguments)
def publish(self):
return PUBLISH.publish(self.site, self.repository)
def test_first_publication_creates_only_the_feed_branch_and_preserves_dirty_checkout(self):
(self.repository / 'README').write_text('Staged maintainer work\n')
self.git('add', 'README')
(self.repository / 'README').write_text('Unstaged maintainer work\n')
before = self.git('status', '--porcelain')
staged = self.git('diff', '--cached')
commit, changed = self.publish()
self.assertTrue(changed)
self.assertEqual(self.git('status', '--porcelain'), before)
self.assertEqual(self.git('diff', '--cached'), staged)
self.assertEqual(self.git('rev-parse', 'HEAD').strip(), self.original)
self.assertEqual(self.remote_git('rev-parse', 'main').strip(), self.original)
self.assertEqual(self.remote_git('rev-list', '--parents', '-1', commit).split(), [commit])
for path in self.site.rglob('*'):
if path.is_file():
self.assertEqual(self.remote_git('show', f'updates:{path.relative_to(self.site).as_posix()}'), path.read_text())
self.assertEqual(self.remote_git('show', 'updates:.argon-update-feed').encode(), PUBLISH.MARKER)
self.assertNotIn('README', self.remote_git('ls-tree', '-r', '--name-only', 'updates').splitlines())
def test_republication_preserves_history_and_identical_artifacts_are_idempotent(self):
first, changed = self.publish()
repeated, changed = self.publish()
self.assertFalse(changed)
self.assertEqual(repeated, first)
(self.site / 'updates/stable.json').write_text(json.dumps({'schema': 1, 'tag': 'v3.1.1'}))
second, changed = self.publish()
self.assertTrue(changed)
self.assertNotEqual(second, first)
self.assertEqual(self.remote_git('rev-parse', 'updates^').strip(), first)
self.assertEqual(self.remote_git('rev-list', '--count', 'updates').strip(), '2')
def test_unchanged_publication_still_triggers_an_optional_hook_for_retries(self):
first, _ = self.publish()
response = MagicMock()
response.__enter__.return_value.status = 200
with patch('sys.argv', [str(SCRIPT), '--site', str(self.site), '--repository', str(self.repository)]), \
patch.dict(PUBLISH.os.environ, {'CF_PAGES_DEPLOY_HOOK': HOOK}), \
patch.object(PUBLISH, 'urlopen', return_value=response) as request, redirect_stdout(io.StringIO()):
PUBLISH.main()
request.assert_called_once()
self.assertEqual(self.remote_git('rev-parse', 'updates').strip(), first)
def test_unrelated_existing_updates_branch_is_not_replaced(self):
self.git('push', 'origin', 'main:updates')
with self.assertRaisesRegex(ValueError, 'not managed'):
self.publish()
self.assertEqual(self.remote_git('rev-parse', 'updates').strip(), self.original)
def test_concurrent_remote_change_is_rejected_without_force(self):
first, _ = self.publish()
tree = self.git('rev-parse', f'{first}^{{tree}}').strip()
competitor = PUBLISH.git(self.repository, 'commit-tree', tree, '-p', first,
data=b'Concurrent feed publication\n').stdout.decode().strip()
(self.site / 'updates/stable.json').write_text(json.dumps({'schema': 1, 'tag': 'v3.1.1'}))
original_git = PUBLISH.git
def concurrent_git(repository, *arguments, **kwargs):
if arguments[0] == 'push':
original_git(repository, 'push', 'origin', f'{competitor}:refs/heads/updates')
return original_git(repository, *arguments, **kwargs)
with patch.object(PUBLISH, 'git', side_effect=concurrent_git), self.assertRaises(subprocess.CalledProcessError):
self.publish()
self.assertEqual(self.remote_git('rev-parse', 'updates').strip(), competitor)
def test_unexpected_or_linked_artifact_files_are_not_published(self):
extra = self.site / 'unrelated.txt'
extra.write_text('Do not publish')
with self.assertRaises(ValueError):
self.publish()
extra.unlink()
manifest = self.site / 'updates/stable.json'
target = self.directory / 'external.json'
manifest.rename(target)
manifest.symlink_to(target)
with self.assertRaises(ValueError):
self.publish()
self.assertEqual(self.remote_git('branch', '--list', 'updates'), '')
class DeployHookTests(unittest.TestCase):
def test_missing_hook_uses_git_integration_without_a_request(self):
with patch.object(PUBLISH, 'urlopen') as request:
self.assertFalse(PUBLISH.trigger_hook(''))
request.assert_not_called()
def test_hook_sends_a_post_without_api_credentials_or_artifacts(self):
response = MagicMock()
response.__enter__.return_value.status = 200
with patch.object(PUBLISH, 'urlopen', return_value=response) as request:
self.assertTrue(PUBLISH.trigger_hook(HOOK))
sent = request.call_args.args[0]
self.assertEqual(sent.full_url, HOOK)
self.assertEqual(sent.get_method(), 'POST')
self.assertEqual(sent.data, b'')
self.assertIsNone(sent.get_header('Authorization'))
self.assertEqual(request.call_args.kwargs['timeout'], 30)
def test_http_errors_do_not_disclose_the_hook_url(self):
with patch.object(PUBLISH, 'urlopen', side_effect=HTTPError(HOOK, 403, 'Forbidden', {}, None)):
with self.assertRaises(RuntimeError) as failure:
PUBLISH.trigger_hook(HOOK)
self.assertNotIn(HOOK, str(failure.exception))
self.assertNotIn('test-secret-id', str(failure.exception))
def test_other_endpoints_and_urls_containing_credentials_are_rejected(self):
for url in ['http://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/test',
'https://unrelated.invalid/hook', HOOK + '?token=secret', HOOK + '#fragment',
HOOK.replace('api.cloudflare.com', 'user:secret@api.cloudflare.com')]:
with self.subTest(url=url), self.assertRaises(ValueError):
PUBLISH.trigger_hook(url)
if __name__ == '__main__':
unittest.main()
+115 -28
View File
@@ -9,7 +9,8 @@ import time
import unittest
ROOT = Path(__file__).resolve().parents[1]
API = 'https://api.github.com/repos/jerrykuku/luci-theme-argon/releases/latest'
PRIMARY = 'https://primary.argon.test/updates/stable.json'
FALLBACK = 'https://fallback.argon.test/updates/stable.json'
class UpdateRpcTests(unittest.TestCase):
@@ -29,7 +30,12 @@ version) test "$COMPARE_FAIL" != 1 || exit 1; [ "$3" = "$4" ] && printf '=\\n' |
add) printf '%s\\n' "$@" >> "$MUTATIONS"; test "$INSTALL_FAIL" != 1 || { echo 'database locked'; exit 1; }; test "$WRONG_VERSION" = 1 || printf '3.0.0-r2' > "$INSTALLED"; echo 'Theme installed' ;;
esac
''',
'opkg': '''case "$1" in
'opkg': '''if [ "$OPKG_QUERY_GUARD" = 1 ]; then
exec 8>"$MUTATIONS.lock"
flock -n 8 || exit 75
sleep .05
fi
case "$1" in
status) v=$OPKG_VERSION; [ ! -f "$INSTALLED" ] || v=$(cat "$INSTALLED"); test -n "$v" || exit 1; printf 'Package: luci-theme-argon\\nVersion: %s\\nStatus: install ok installed\\n' "$v" ;;
compare-versions) [ "$3" = "<<" ] && [ "$COMPARE_FAIL" != 1 ] && [ "$2" != "$4" ] && [ "$2" != 3.0.0-20261015 ] ;;
install) printf '%s\\n' "$@" >> "$MUTATIONS"; test "$INSTALL_FAIL" != 1 || { echo 'database locked'; exit 1; }; test "$WRONG_VERSION" = 1 || printf '3.0.0-2' > "$INSTALLED"; echo 'Theme installed' ;;
@@ -40,10 +46,16 @@ esac
test "$FETCH_FAIL" != 1 || exit 8
while [ "$#" -gt 0 ]; do
if [ "$1" = -O ]; then shift; destination=$1; fi
url=$1
shift
done
case "$destination" in
*release.json) cp "$PAYLOAD" "$destination" ;;
*manifest.json)
sleep "${MANIFEST_DELAY:-0}"
case "$url" in
*primary.argon.test*) test "$PRIMARY_FAIL" != 1 || exit 8; cp "${PRIMARY_PAYLOAD:-$PAYLOAD}" "$destination" ;;
*) cp "$PAYLOAD" "$destination" ;;
esac ;;
*) sleep "${DOWNLOAD_DELAY:-0}"; test "$DOWNLOAD_FAIL" != 1 || exit 8; cp "$PACKAGE_DATA" "$destination" ;;
esac
'''
@@ -95,8 +107,11 @@ json_set_namespace() {{
json_dump() {{ printf '%s\\n' "$FIXTURE_JSON"; }}
''')
self.script = self.directory / 'rpc.sh'
sources = self.directory / 'sources.json'
sources.write_text(json.dumps({'primary': PRIMARY, 'fallback': FALLBACK}))
self.script.write_text((ROOT / 'root/usr/libexec/rpcd/luci.argon_update').read_text()
.replace('/usr/share/libubox/jshn.sh', str(shim))
.replace('/usr/share/argon/update-sources.json', str(sources))
.replace('/var/run/argon-update/release.json', str(self.cache))
.replace('/var/run/argon-update', str(self.directory / 'state')))
self.env = dict(os.environ, PATH=str(fakebin) + os.pathsep + os.environ['PATH'],
@@ -104,12 +119,12 @@ json_dump() {{ printf '%s\\n' "$FIXTURE_JSON"; }}
REQUESTS=str(self.requests), FETCH_FAIL='0', INSTALLED=str(self.directory / 'installed'),
MUTATIONS=str(self.directory / 'mutations'), PACKAGE_DATA=str(self.directory / 'package'))
(self.directory / 'package').write_bytes(b'argon-package')
self.release = {'tag_name': 'v3.0.0', 'draft': False, 'prerelease': False,
'published_at': '2026-10-16T01:00:00Z', 'body': '## 更新\n- 修复布局。',
'assets': [{'name': name, 'digest': 'sha256:' + hashlib.sha256(b'argon-package').hexdigest(), 'size': 13} for name in [
'luci-theme-argon-3.0.0-r1.apk', 'luci-theme-argon_3.0.0_all.ipk',
'luci-theme-argon-3.0.0-r2.apk', 'luci-theme-argon_3.0.0-2_all.ipk',
'luci-theme-argon-3.0.0-r1.apk', 'luci-theme-argon_3.0.0_all.ipk']]}
self.release = {'schema': 1, 'repository': 'jerrykuku/luci-theme-argon', 'tag': 'v3.0.0',
'published_at': '2026-10-16T01:00:00Z', 'notes': '## 更新\n- 修复布局。',
'ipk': 'luci-theme-argon_3.0.0-2_all.ipk', 'apk': 'luci-theme-argon-3.0.0-r2.apk',
'ipk_sha256': hashlib.sha256(b'argon-package').hexdigest(),
'apk_sha256': hashlib.sha256(b'argon-package').hexdigest(),
'ipk_size': 13, 'apk_size': 13}
self.payload.write_text(json.dumps(self.release))
def call(self, method, **arguments):
@@ -133,16 +148,18 @@ json_dump() {{ printf '%s\\n' "$FIXTURE_JSON"; }}
self.env['OPKG_VERSION'] = ''
self.assertEqual(self.call('get')['current_version'], '')
def test_check_uses_official_url_and_highest_matching_revision(self):
def test_check_uses_configured_static_source_and_ignores_rpc_urls(self):
result = self.call('check', url='http://untrusted.invalid/', version='99.0.0')
self.assertEqual(result['result'], 0)
self.assertEqual(result['apk'], 'luci-theme-argon-3.0.0-r2.apk')
self.assertEqual(result['ipk'], 'luci-theme-argon_3.0.0-2_all.ipk')
self.assertEqual(result['notes'], self.release['body'])
self.assertEqual(result['notes'], self.release['notes'])
self.assertEqual(result['current_version'], '3.0.0-r1')
requests = self.requests.read_text()
self.assertIn(API, requests)
self.assertIn('Cache-Control: no-cache', requests)
self.assertIn(PRIMARY, requests)
self.assertNotIn(FALLBACK, requests)
self.assertNotIn('api.github.com', requests)
self.assertNotIn('Cache-Control: no-cache', requests)
self.assertNotIn('untrusted.invalid', requests)
self.assertEqual(self.cache.stat().st_mode & 0o777, 0o600)
@@ -154,9 +171,77 @@ json_dump() {{ printf '%s\\n' "$FIXTURE_JSON"; }}
self.assertEqual(self.requests.read_text(), before)
self.assertEqual(self.call('get')['tag'], 'v3.0.0')
def test_primary_failure_and_invalid_primary_each_fall_back_to_one_complete_manifest(self):
invalid = self.directory / 'invalid.json'
invalid.write_text(json.dumps(dict(self.release, apk_sha256='invalid', notes='wrong source')))
for patch in [{'PRIMARY_FAIL': '1'}, {'PRIMARY_PAYLOAD': str(invalid)}]:
with self.subTest(patch=patch):
self.cache.unlink(missing_ok=True)
self.requests.unlink(missing_ok=True)
old = self.env.copy()
self.env.update(patch)
checked = self.call('check')
self.assertEqual(checked['result'], 0)
self.assertEqual(checked['notes'], self.release['notes'])
requests = self.requests.read_text()
self.assertEqual(requests.count(PRIMARY), 1)
self.assertEqual(requests.count(FALLBACK), 1)
self.env = old
def test_failed_checks_have_a_cooldown_and_keep_the_previous_success(self):
cached = self.call('check')
cached['checked_at'] -= 400
self.cache.write_text(json.dumps(cached))
self.env['FETCH_FAIL'] = '1'
failed = self.call('check')
requests = self.requests.read_text()
self.assertEqual(failed['result'], 1)
self.assertGreater(failed['retry_at'], time.time())
self.assertEqual(self.call('check')['retry_at'], failed['retry_at'])
self.assertEqual(self.requests.read_text(), requests)
self.assertEqual(self.call('get')['notes'], cached['notes'])
(self.directory / 'state/retry_at').write_text(str(int(time.time()) - 1))
self.env['FETCH_FAIL'] = '0'
self.assertEqual(self.call('check')['result'], 0)
self.assertFalse((self.directory / 'state/retry_at').exists())
def test_concurrent_checks_share_one_network_request(self):
self.env['MANIFEST_DELAY'] = '1'
processes = [subprocess.Popen(['sh', str(self.script), 'call', 'check'], env=self.env,
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True) for _ in range(3)]
outputs = [process.communicate(timeout=20) for process in processes]
for process, (output, errors) in zip(processes, outputs):
self.assertEqual(process.returncode, 0, errors)
self.assertEqual(json.loads(output)['result'], 0)
self.assertEqual(self.requests.read_text().count(PRIMARY), 1)
def test_remote_fields_cannot_override_local_state_or_add_a_download_url(self):
self.payload.write_text(json.dumps(dict(self.release, checked_at=1, result=99,
current_version='99.0.0', url='https://untrusted.invalid/')))
result = self.call('check')
self.assertEqual(result['result'], 0)
self.assertEqual(result['current_version'], '3.0.0-r1')
self.assertGreater(result['checked_at'], 1)
self.assertNotIn('url', result)
def test_parallel_opkg_queries_do_not_lose_versions_to_the_native_database_lock(self):
self.env.update(APK_VERSION='', OPKG_VERSION='3.0.0-1', OPKG_QUERY_GUARD='1')
self.assertTrue(self.call('check')['can_upgrade'])
processes = [subprocess.Popen(['sh', str(self.script), 'call', 'get'], env=self.env,
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True) for _ in range(4)]
outputs = [process.communicate(timeout=20) for process in processes]
for process, (output, errors) in zip(processes, outputs):
self.assertEqual(process.returncode, 0, errors)
result = json.loads(output)
self.assertEqual(result['current_version'], '3.0.0-1')
self.assertTrue(result['can_upgrade'])
def test_expired_or_future_cache_refreshes_and_failed_refresh_keeps_notes(self):
first = self.call('check')
for checked_at in [int(time.time()) - 400, int(time.time()) + 1000]:
(self.directory / 'state/retry_at').unlink(missing_ok=True)
old = dict(first, checked_at=checked_at)
self.cache.write_text(json.dumps(old))
self.env['FETCH_FAIL'] = '1'
@@ -165,23 +250,23 @@ json_dump() {{ printf '%s\\n' "$FIXTURE_JSON"; }}
self.assertEqual(self.call('get')['notes'], first['notes'])
def test_invalid_and_prerelease_responses_never_enter_cache(self):
for value in ['not JSON', '{}', '[]', json.dumps(dict(self.release, draft=True)),
json.dumps(dict(self.release, prerelease=True)),
json.dumps(dict(self.release, tag_name='v3.1.0-beta.1'))]:
for value in ['not JSON', '{}', '[]', json.dumps(dict(self.release, schema=2)),
json.dumps(dict(self.release, repository='other/repo')),
json.dumps(dict(self.release, tag='v3.1.0-beta.1'))]:
with self.subTest(value=value[:60]):
(self.directory / 'state/retry_at').unlink(missing_ok=True)
self.payload.write_text(value)
self.assertNotEqual(self.call('check')['result'], 0)
self.assertFalse(self.cache.exists())
def test_mismatched_and_unsafe_asset_names_are_ignored(self):
self.release['assets'] = [{'name': name} for name in [
def test_mismatched_and_unsafe_asset_names_are_rejected(self):
for name in [
'luci-theme-argon-4.0.0-r2.apk', 'luci-theme-argon_4.0.0_all.ipk',
'luci-theme-argon-3.0.0-r2.apk?evil', 'luci-theme-argon_3.0.0-../../tmp_all.ipk']]
self.payload.write_text(json.dumps(self.release))
result = self.call('check')
self.assertEqual(result['result'], 0)
self.assertEqual(result['apk'], '')
self.assertEqual(result['ipk'], '')
'luci-theme-argon-3.0.0-r2.apk?evil', 'luci-theme-argon_3.0.0-../../tmp_all.ipk']:
(self.directory / 'state/retry_at').unlink(missing_ok=True)
self.payload.write_text(json.dumps(dict(self.release, apk=name)))
self.assertNotEqual(self.call('check')['result'], 0)
self.assertFalse(self.cache.exists())
def test_linked_cache_is_not_read_or_overwritten_and_failed_download_is_retryable(self):
outside = self.directory / 'outside'
@@ -191,6 +276,7 @@ json_dump() {{ printf '%s\\n' "$FIXTURE_JSON"; }}
self.env['FETCH_FAIL'] = '1'
self.assertEqual(self.call('check')['result'], 1)
self.env['FETCH_FAIL'] = '0'
(self.directory / 'state/retry_at').unlink(missing_ok=True)
self.assertEqual(self.call('check')['result'], 0)
self.assertEqual(outside.read_text(), 'keep me')
@@ -245,13 +331,14 @@ json_dump() {{ printf '%s\\n' "$FIXTURE_JSON"; }}
self.assertEqual(self.start()['result'], 3)
self.assertFalse((self.directory / 'mutations').exists())
def test_unverified_assets_remain_manual_downloads(self):
for patch in [{'digest': None}, {'digest': 'bad'}, {'size': 0}, {'size': 9000000}]:
def test_unverified_manifests_cannot_enable_installation(self):
for patch in [{'apk_sha256': None}, {'apk_sha256': 'bad'}, {'apk_size': 0}, {'apk_size': 9000000}, {'apk_size': '08'}]:
self.cache.unlink(missing_ok=True)
release = dict(self.release, assets=[dict(self.release['assets'][2], **patch)])
(self.directory / 'state/retry_at').unlink(missing_ok=True)
release = dict(self.release, **patch)
self.payload.write_text(json.dumps(release))
checked = self.call('check')
self.assertEqual(checked['apk'], 'luci-theme-argon-3.0.0-r2.apk')
self.assertNotEqual(checked['result'], 0)
self.assertFalse(checked['can_upgrade'])
self.assertEqual(self.start()['result'], 3)
self.assertFalse((self.directory / 'mutations').exists())
+123
View File
@@ -0,0 +1,123 @@
"""Verify the published update feed against release metadata and package bytes."""
import copy
import hashlib
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / '.github/scripts/generate_update_site.py'
sys.path.insert(0, str(SCRIPT.parent))
SPEC = importlib.util.spec_from_file_location('update_site', SCRIPT)
SITE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(SITE)
class UpdateSiteTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.directory = Path(self.temp.name)
self.packages = self.directory / 'packages'
self.packages.mkdir()
self.output = self.directory / 'site'
self.release = self.release_for('v3.0.0')
def release_for(self, tag):
metadata = SITE.resolve(tag)
release = {'tag_name': tag, 'draft': False, 'prerelease': False,
'html_url': f'https://github.com/jerrykuku/luci-theme-argon/releases/tag/{tag}',
'published_at': '2026-10-21T01:00:00Z', 'body': '## 更新\n- 修复布局。', 'assets': []}
for name in [f'luci-theme-argon_{metadata["ipk_version"]}_all.ipk',
f'luci-theme-argon-{metadata["apk_version"]}-r{metadata["package_release"]}.apk']:
content = ('package bytes for ' + name).encode()
(self.packages / name).write_bytes(content)
release['assets'].append({'name': name, 'size': len(content),
'digest': 'sha256:' + hashlib.sha256(content).hexdigest()})
return release
def generate(self, release=None):
return SITE.generate(release or self.release, self.packages, 'jerrykuku/luci-theme-argon', self.output)
def test_manifest_contains_exact_revision_names_digests_sizes_and_notes(self):
for tag in ['v3.0.0', 'v3.1.0']:
release = self.release_for(tag)
manifest = self.generate(release)
self.assertEqual(json.loads((self.output / 'updates/stable.json').read_text()), manifest)
self.assertEqual(manifest['schema'], 1)
self.assertEqual(manifest['tag'], tag)
self.assertEqual(manifest['notes'], release['body'])
for kind in ['apk', 'ipk']:
content = (self.packages / manifest[kind]).read_bytes()
self.assertEqual(manifest[kind + '_size'], len(content))
self.assertEqual(manifest[kind + '_sha256'], hashlib.sha256(content).hexdigest())
self.assertNotIn('checked_at', manifest)
self.assertNotIn('url', manifest)
def test_no_site_is_written_for_drafts_previews_or_foreign_releases(self):
for patch in [{'draft': True}, {'prerelease': True}, {'tag_name': 'v3.1.0-beta.1'},
{'tag_name': 'latest'}, {'published_at': ''},
{'html_url': 'https://github.com/other/repo/releases/tag/v3.0.0'}]:
with self.subTest(patch=patch), self.assertRaises(ValueError):
self.generate(dict(self.release, **patch))
self.assertFalse(self.output.exists())
def test_missing_duplicate_or_mismatched_assets_block_publication(self):
for mutate in [lambda release: release['assets'].pop(),
lambda release: release['assets'].append(release['assets'][0]),
lambda release: release['assets'][0].update(size=1),
lambda release: release['assets'][0].update(digest='sha256:' + '0' * 64)]:
release = copy.deepcopy(self.release)
mutate(release)
with self.assertRaises(ValueError):
self.generate(release)
self.assertFalse(self.output.exists())
def test_modified_or_linked_local_package_cannot_be_published(self):
path = self.packages / self.release['assets'][0]['name']
content = path.read_bytes()
path.write_bytes(b'corrupted')
with self.assertRaises(ValueError):
self.generate()
target = self.directory / 'external-package'
target.write_bytes(content)
path.unlink()
path.symlink_to(target)
with self.assertRaises(ValueError):
self.generate()
self.assertFalse(self.output.exists())
def test_large_notes_are_bounded_and_unknown_paths_return_real_404(self):
self.release['body'] = '更新' * 40000
manifest = self.generate()
self.assertEqual(len(manifest['notes']), 32768)
self.assertTrue((self.output / '404.html').is_file())
self.assertIn('max-age=300', (self.output / '_headers').read_text())
self.assertTrue((self.output / '.nojekyll').is_file())
def test_sources_support_an_unconfigured_primary_and_distinct_https_hosts(self):
output = self.directory / 'sources.json'
fallback = 'https://jerrykuku.github.io/luci-theme-argon/updates/stable.json'
for primary in ['', 'https://owned-project.pages.dev/updates/stable.json', fallback]:
SITE.configure_sources(primary, fallback, output)
sources = json.loads(output.read_text())
self.assertEqual(sources['fallback'], fallback)
self.assertEqual(sources['primary'], '' if primary == fallback else primary)
def test_unsafe_or_incomplete_source_urls_are_rejected(self):
for source in ['http://example.com/updates/stable.json', 'https://user:token@example.com/feed',
'https://example.com:443/feed', 'https://example.com/feed?token=secret',
'https://example.com/feed#part', 'https://example.com', 'file:///tmp/feed',
'https://example.com/feed\n--no-check-certificate']:
with self.subTest(source=source), self.assertRaises(ValueError):
SITE.configure_sources(source, 'https://example.com/feed', self.directory / 'sources.json')
with self.assertRaises(ValueError):
SITE.configure_sources('', '', self.directory / 'sources.json')
if __name__ == '__main__':
unittest.main()
+10
View File
@@ -57,6 +57,16 @@ test('loading the settings reads local state without a remote check; checks are
assert.equal(f.nodes.find(n => n.tag === 'details').open, true);
});
test('failed checks show a cooldown time and retain previous version information', async () => {
const f = fixture({ initial: checked('3.0.0-r1'), check: { result: 1, retry_at: Math.floor(Date.now() / 1000) + 60 } });
await f.render();
await f.button().click();
assert.match(f.status(), /network connection/);
assert.match(f.status(), /Try again after/);
assert.equal(f.installButton().hidden, true);
assert.ok(f.nodes.some(n => n.textContent === '3.0.0-r1'));
});
test('package revisions, numeric versions, prereleases and locally newer builds compare correctly', async () => {
for (const [current, latest, manager, expected] of [
['3.0.0-r1', '3.0.0-r2', 'apk', 'Update available.'],