🐤 Sync 2026-10-30 12:53:07

This commit is contained in:
github-actions[bot] committed 2026-10-30 12:53:07 +08:00
1 parent 57d68d2cec
commit 8da5d996a7
124 files changed
+8650 -562

No files matched your search

+3 -3
View File
@@ -1,11 +1,11 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=7z
PKG_VERSION:=26.04
PKG_RELEASE:=5
PKG_VERSION:=26.05
PKG_RELEASE:=6
PKG_SOURCE:=$(PKG_NAME)$(subst .,,$(PKG_VERSION))-src.tar.xz
PKG_SOURCE_URL:=https://7-zip.org/a/
PKG_SOURCE_URL:=https://github.com/ip7z/7zip/releases/download/$(PKG_VERSION)/
PKG_HASH:=skip
PKG_MAINTAINER:=Tianling Shen <cnsztl@immortalwrt.org>
+2 -2
View File
@@ -24,7 +24,7 @@
$(CFLAGS_DEBUG) -D_REENTRANT -D_FILE_OFFSET_BITS=64 -D_LARGEFILE_SOURCE \
-fPIC
@@ -169,7 +169,7 @@ endif
@@ -168,7 +168,7 @@ endif
@@ -33,7 +33,7 @@
ifdef IS_MINGW
@@ -1361,7 +1361,7 @@ predef_cc:
@@ -1367,7 +1367,7 @@ predef_cc:
$(CC) $(CFLAGS) -E $(SHOW_PREDEF) ../../../../C/CpuArch.c > predef_cc_log
# $(CC) $(CFLAGS) -E -dM - < /dev/null
predef_cxx:
+2 -2
View File
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=gately
PKG_VERSION:=0.6.0.4
PKG_RELEASE:=17
PKG_VERSION:=0.6.1
PKG_RELEASE:=18
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/wangwanjie/gately/tar.gz/v$(PKG_VERSION)?
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-open-box
PKG_VERSION:=0.1.371
PKG_RELEASE:=51
PKG_VERSION:=0.1.374
PKG_RELEASE:=52
PKG_MAINTAINER:=liandu2024
PKG_LICENSE:=GPL-3.0-or-later
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.10.29
PKG_RELEASE:=357
PKG_RELEASE:=358
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
+10 -12
View File
@@ -303,9 +303,13 @@ function gen_outbound(flag, node, tag, proxy_table)
local finalmask = {}
local TP = node.transport
if TP == "mkcp" then
local s = { type = "mkcp-legacy" }
if node.mkcp_seed and node.mkcp_seed ~= "" then
s.settings = { value = node.mkcp_seed }
end
local udp = { s }
local map = {none = "none", srtp = "srtp", utp = "utp", ["wechat-video"] = "wechat",
dtls = "dtls", wireguard = "wireguard", dns = "dns"}
local udp = {}
if node.mkcp_guise and node.mkcp_guise ~= "none" then
local g = { type = "mkcp-legacy" }
g.settings = { header = map[node.mkcp_guise] }
@@ -314,11 +318,6 @@ function gen_outbound(flag, node, tag, proxy_table)
end
udp[#udp+1] = g
end
local s = { type = "mkcp-legacy" }
if node.mkcp_seed and node.mkcp_seed ~= "" then
s.settings = { value = node.mkcp_seed }
end
udp[#udp+1] = s
finalmask.udp = udp
elseif TP == "hysteria" then
local udp = {}
@@ -807,9 +806,13 @@ function gen_config_server(node)
finalmask = (function()
local finalmask = {}
if node.transport == "mkcp" then
local s = { type = "mkcp-legacy" }
if node.mkcp_seed and node.mkcp_seed ~= "" then
s.settings = { value = node.mkcp_seed }
end
local udp = { s }
local map = {none = "none", srtp = "srtp", utp = "utp", ["wechat-video"] = "wechat",
dtls = "dtls", wireguard = "wireguard", dns = "dns"}
local udp = {}
if node.mkcp_guise and node.mkcp_guise ~= "none" then
local g = { type = "mkcp-legacy" }
g.settings = { header = map[node.mkcp_guise] }
@@ -818,11 +821,6 @@ function gen_config_server(node)
end
udp[#udp+1] = g
end
local s = { type = "mkcp-legacy" }
if node.mkcp_seed and node.mkcp_seed ~= "" then
s.settings = { value = node.mkcp_seed }
end
udp[#udp+1] = s
finalmask.udp = udp
elseif node.transport == "hysteria" then
local udp = {}
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.10.29
PKG_RELEASE:=158
PKG_RELEASE:=159
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -312,9 +312,13 @@ function gen_outbound(flag, node, tag, proxy_table)
local finalmask = {}
local TP = node.transport
if TP == "mkcp" then
local s = { type = "mkcp-legacy" }
if node.mkcp_seed and node.mkcp_seed ~= "" then
s.settings = { value = node.mkcp_seed }
end
local udp = { s }
local map = {none = "none", srtp = "srtp", utp = "utp", ["wechat-video"] = "wechat",
dtls = "dtls", wireguard = "wireguard", dns = "dns"}
local udp = {}
if node.mkcp_guise and node.mkcp_guise ~= "none" then
local g = { type = "mkcp-legacy" }
g.settings = { header = map[node.mkcp_guise] }
@@ -323,11 +327,6 @@ function gen_outbound(flag, node, tag, proxy_table)
end
udp[#udp+1] = g
end
local s = { type = "mkcp-legacy" }
if node.mkcp_seed and node.mkcp_seed ~= "" then
s.settings = { value = node.mkcp_seed }
end
udp[#udp+1] = s
finalmask.udp = udp
elseif TP == "hysteria" then
local udp = {}
@@ -812,9 +811,13 @@ function gen_config_server(node)
finalmask = (function()
local finalmask = {}
if node.transport == "mkcp" then
local s = { type = "mkcp-legacy" }
if node.mkcp_seed and node.mkcp_seed ~= "" then
s.settings = { value = node.mkcp_seed }
end
local udp = { s }
local map = {none = "none", srtp = "srtp", utp = "utp", ["wechat-video"] = "wechat",
dtls = "dtls", wireguard = "wireguard", dns = "dns"}
local udp = {}
if node.mkcp_guise and node.mkcp_guise ~= "none" then
local g = { type = "mkcp-legacy" }
g.settings = { header = map[node.mkcp_guise] }
@@ -823,11 +826,6 @@ function gen_config_server(node)
end
udp[#udp+1] = g
end
local s = { type = "mkcp-legacy" }
if node.mkcp_seed and node.mkcp_seed ~= "" then
s.settings = { value = node.mkcp_seed }
end
udp[#udp+1] = s
finalmask.udp = udp
elseif node.transport == "hysteria" then
local udp = {}
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-smartsafehub
PKG_VERSION:=0.2.30
PKG_RELEASE:=10
PKG_VERSION:=0.2.31
PKG_RELEASE:=11
PKG_MAINTAINER:=Beomjun Kang <kals323@gmail.com>
PKG_LICENSE:=GPL-3.0-or-later
@@ -46,6 +46,8 @@ define Package/luci-app-smartsafehub/postinst
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
mkdir -p /tmp/smartsafehub
# Drop the temporary diagnostic created by older Apps size queries.
rm -f /tmp/smartsafehub/apps-size-diagnostics.tsv
uci -q delete smartsafehub.firmware.check_enabled >/dev/null 2>&1 || true
uci -q commit smartsafehub >/dev/null 2>&1 || true
if [ -x /etc/init.d/smartsafehub-events ]; then
+2
View File
@@ -7,6 +7,8 @@
SmartSafeHub는 OpenWrt 공유기를 위한 통합 홈 게이트웨이 관리 UI입니다.
네트워크, Wi-Fi, 연결 기기, SafeShield 보안, 업데이트와 시스템 상태를 하나의 사용자 친화적인 인터페이스에서 관리할 수 있습니다.
**[SmartSafeHub 공유기 체험하기](https://www.smartsafehub.com/router-demo/)**
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/dashboard-dark.png">
<source media="(prefers-color-scheme: light)" srcset="docs/assets/dashboard-light.png">
+105
View File
@@ -0,0 +1,105 @@
# SmartSafeHub Apps — 공유기 웹사이트
## 메뉴 및 아키텍처
- `System` 영역: `SmartSafeHub 계정 → Apps → 업데이트 → 설정` / 해시 URL `#apps`.
- 앱 목록: `smartsafehub.apps_catalog` RPC → `/usr/libexec/smartsafehub-apps` → Hub 승인 카탈로그.
- 설치 상태: `smartsafehub.apps_status` RPC → 독립된 `smartsafehub-apps-manager`의 로컬 APK 상태와 설치 기록.
- 앱 설치 저장 공간과 설치된 APK의 용량은 별도 `smartsafehub.apps_metrics` 읽기 RPC로 조회합니다. LuCI ACL의 `read.ubus.smartsafehub`에도 `apps_metrics`가 있어야 합니다. 실패하더라도 설치·제거 상태에는 영향을 주지 않습니다.
- 설치된 버전은 `apk list -I`에서 조회한 대표 승인 APK의 실제 설치 버전입니다. Wake-on-LAN은 백엔드 `etherwake`가 아닌 `luci-app-wol` 버전을 표시합니다. 카탈로그의 `version`은 현재 **제공/승인된 앱 버전**이므로 둘을 구분해 표시합니다. 복수 APK 앱은 대표 패키지 버전이 앱 릴리스 버전과 다를 수 있습니다.
- 설치·업데이트·제거: `smartsafehub.apps_install`, `smartsafehub.apps_update`, `smartsafehub.apps_remove` RPC. 앱 ID만 받으며, 실제 작업은 root 전용 백그라운드 매니저에서 진행합니다. 다른 앱 작업이 진행 중이면 거절합니다.
- Phase 2의 IoT/로컬 AI 설치기는 추후 `runtime`에 따라 별도 어댑터로 추가합니다. 현재는 `router + apk`만 가능하며 Hub의 패키지명·URL·명령을 브라우저가 보낼 수 없습니다.
## 앱 카테고리 아이콘
- **네트워크**: 연결된 노드 아이콘 / **도구**(`utilities`): 렌치 아이콘 / **기타**(`other`): 원 안의 점 3개 아이콘.
- 분류되지 않은 앱은 기타로 묶고, 같은 아이콘을 실제 공유기와 읽기 전용 체험 화면에서 사용합니다. 앱 설치·승인 정보와 정렬 순서는 변경하지 않습니다.
## 설치를 활성화하기 위한 **필수 운영 조건**
현재 버전은 APK 엔진까지 구현했지만 **아무 OpenWrt 미러에서나 바로 설치하지 않습니다.**
1. Hub에 `store.patch`의 앱 카탈로그가 배포되고 펌웨어가 `device_code`와 `build_id`로 등록되어 있어야 합니다.
2. 관리자 승인이 특정 펌웨어 빌드, 채널, 패키지 아키텍처 및 **정확한 APK 버전과 SHA-256**에 대해 완료되어야 합니다.
3. 공유기에는 OpenWrt 공식 패키지 관리자(`apk`)와 필요한 시스템 의존성이 설치되어 있어야 합니다. SmartSafeHub가 사용하는 APK 서명 공개 키도 신뢰 저장소에 등록되어 있어야 합니다.
4. 설치기는 고정된 릴리스 ID, 승인된 채널(`beta`/`stable`)과 공유기 아키텍처로 `https://repo.smartsafehub.com/<채널>/packages/<아키텍처>/smartsafehub/<릴리스 ID>/packages.adb`를 조회합니다. 이 인덱스를 새로 다운로드한 뒤 Hub 승인 SHA-256과 비교합니다. 펌웨어에 별도 인덱스 파일을 복사할 필요가 없습니다.
5. 실제 APK는 위 고정 도메인/경로에서 이름과 버전으로만 내려받아 SHA-256을 검증합니다. 임의 URL 입력이나 `--allow-untrusted`는 허용되지 않습니다.
6. `apk`, `sha256sum`, `start-stop-daemon`, `jsonfilter`, `uclient-fetch`가 공유기에 있어야 하며 RAM/overlay 용량이 충분해야 합니다.
**주의:** 모든 승인은 고정된 SmartSafeHub 릴리스에서 설치합니다. 릴리스 파일 또는 인덱스의 SHA-256이 승인값과 다르면 설치를 차단합니다.
**의존성 주의:** `nlbwmon`, `luci-app-nlbwmon`, `luci-lib-chartjs`는 동일한 SmartSafeHub 서명 저장소에서만 설치합니다. 이외의 의존성을 자동으로 가져오지 않으며, 새로운 커널 모듈·라이브러리는 별도 검증이 필요합니다.
OpenWrt 25.12의 패키지 인덱스는 `packages.adb` 형식일 수 있으며, 이전의 `APKINDEX.tar.gz` 형식이라고 가정하면 안 됩니다.
## Wake-on-LAN 앱 (0.2.30-r37)
- Hub에서 `wake-on-lan` 앱을 현재 펌웨어와 아키텍처에 승인하면 Apps에 표시됩니다. `utilities` 카테고리는 **도구** 탭에 표시합니다.
- 서명된 고정 릴리스에서 `etherwake`, `luci-app-wol` **두 APK만** 함께 설치합니다. 다른 경로로 설치된 패키지는 Apps가 자동으로 소유하지 않습니다.
- `luci-app-wol`까지 설치된 경우에만 **열기**를 표시하고, 새 탭에서 공유기의 `/cgi-bin/luci/admin/services/wol`로 이동합니다. LuCI 로그인을 추가로 요구할 수 있습니다.
- 지원되는 유선 컴퓨터의 BIOS/UEFI 및 운영체제에서 Wake-on-LAN 기능을 켜야 합니다. 원격 인터넷에서 임의로 전원을 켜는 기능이나 별도 포트 개방을 제공하지 않습니다.
- 패키지를 설치·제거하면 LuCI 메뉴 캐시를 갱신합니다. 공유기 체험 화면의 카드는 예시이며 실제 설치·신호 전송을 하지 않습니다.
## 설치된 앱 열기 (0.2.30-r9)
- 웹 화면을 제공하는 앱은 설치 후 `열기`로 **새 탭**에서 공식 LuCI UI를 표시합니다. SmartSafeHub 탭은 그대로 유지합니다.
- 웹 UI는 `web_ui: {"type":"luci","path":"/cgi-bin/luci/admin/…","package":"…"}`로 승인된 카탈로그에서 선언할 수 있습니다. 경로는 **같은 공유기의 LuCI 관리자 화면**으로 제한됩니다. 외부 사이트, 임의 포트, 실행 명령은 허용되지 않습니다.
- `network-usage-monitor`는 기존 카탈로그에도 `admin/services/nlbw/display` 경로를 사용할 수 있지만, **`luci-app-nlbwmon`이 설치되어 있어야만** 열기 버튼이 표시됩니다. `nlbwmon`만 설치된 공유기에는 `웹 UI 미설치`가 표시됩니다.
- 세 APK와 인덱스의 해시를 모두 확인하며, 설치 관리자는 승인된 릴리스 ID를 필수로 요구합니다.
- `luci-app-nlbwmon`은 OpenWrt LuCI 인증을 다시 요구할 수 있습니다.
## 동작과 제한
- 설치 시작 시 **Hub에서 승인 목록을 다시 조회**합니다. 인터넷이 끊겼거나 승인이 취소된 앱은 신규 설치가 불가능합니다.
- SmartSafeHub 저장소에서 승인된 인덱스와 APK를 내려받아 SHA-256을 확인합니다. 확인된 파일만 임시 로컬 저장소로 구성하며, APK의 서명 검증은 그대로 적용합니다.
- 모든 앱 APK는 SmartSafeHub가 빌드하고 서명한 하나의 불변 릴리스에서 설치합니다. `apk --simulate`는 저장소 인덱스를 자동 갱신하지 않으므로, 검증된 임시 로컬 저장소에 대해 `apk update`를 수행한 후 설치 계획을 검사하고 설치합니다. 승인되지 않은 패키지는 자동 설치하지 않습니다.
- `--allow-untrusted`, 전체 `apk upgrade`, 사용자 입력 셸 명령 및 공유기의 영구 저장소 설정 변경은 사용하지 않습니다. 임시 저장소는 검증된 파일로만 구성하며 작업 종료 시 삭제합니다.
- 핵심 시스템 패키지 및 SafeShield 관련 패키지는 앱 패키지 대상에서 제외합니다. 의존성 해석과 설치 스크립트 실행은 APK가 담당하므로, 앱마다 실제 환경 테스트가 필요합니다.
- `/etc/smartsafehub/apps-installed/<앱 ID>`에 **Apps가 직접 설치한 패키지 소유권**을 기록합니다. 기존 Software/SSH로 설치된 동일 패키지는 Apps에서 제거하지 않습니다.
- 테스트 기기의 이전 설치·승인 정보는 승계하지 않습니다. 새 승인으로 설치한 앱만 이 버전에서 관리합니다.
- 펌웨어 변경으로 앱 바이너리가 사라진 경우 소유권 기록과 새 펌웨어 승인에 따라 자동 복원하며, 조건이 일치하지 않으면 안전하게 중단합니다.
- 설치 작업 중 공유기를 재부팅하거나 전원을 끊지 말아야 합니다. APK 실패 후 트랜잭션의 수동 복구가 필요할 수 있으며 **완전한 자동 롤백은 제공하지 않습니다**.
- Apps에서 직접 설치한 앱은 새 펌웨어·기기 모델·아키텍처에 맞게 승인된 **더 높은 버전**이 나타날 때 `업데이트` 버튼을 표시합니다. 외부에서 설치한 앱은 업데이트하거나 자동 인수하지 않습니다.
- 사용자가 명시적으로 업데이트를 선택해야 하며, 업데이트 전 카탈로그와 서명된 불변 저장소·파일 해시를 다시 검증합니다.
- 앱별 APK의 **같은 패키지 집합**에서 버전만 올릴 수 있습니다. 패키지 추가·제거, 다운그레이드, 같은 버전의 해시 변경, 기존 설치 버전 불일치, 다른 패키지 변경을 요구하는 계획은 거부합니다.
- OpenWrt 전체 `apk upgrade`를 실행하지 않습니다. 기존 패키지를 대상으로 `apk add --upgrade`를 정확한 버전으로 한정하고 사전 계획을 검증합니다. OpenWrt는 펌웨어의 시스템 패키지를 무분별하게 업그레이드하는 것을 권장하지 않습니다.
- 업데이트가 성공해야 소유권 파일의 버전·해시를 교체합니다. 실패 시 원본 소유권 기록은 유지되며, 부분적으로 변경된 APK를 자동으로 롤백하지 않습니다. 이 경우 관리자 확인이 필요할 수 있습니다.
- 업데이트는 새로운 설치 성공 다운로드 통계에 포함하지 않습니다. 펌웨어 갱신 후 자동 복원은 기존대로 소유권 기록을 활용하며 업데이트를 자동 수행하지 않습니다.
## 장애 확인
- `E21`: 서버에서 새 카탈로그를 조회하지 못함 / `E22`: 승인 앱 없음 / `E23`, `E24`: 런타임 또는 기기 아키텍처 불일치.
- `E25`: SmartSafeHub 배포 인덱스 다운로드 실패 또는 SHA 불일치 / `E26`: 리소스 부족 / `E27`: 승인 패키지 정보 검증 실패.
- `E51`~`E55`: 소유권 없는 앱 업데이트, 승인 버전 불일치, 변경 범위 초과, APK 업데이트 또는 기록 저장 실패.
- `E29`: 기존 외부 설치 패키지 충돌 / `E31`~`E34`: 다운로드·해시·설치 시뮬레이션 실패 / `E35`: 설치 실패.
- `E41`: Apps가 설치한 앱이 아니라서 제거 불가 / `E44`, `E45`: 제거 시뮬레이션·실행 실패.
- `/tmp/smartsafehub/apps-operation.state`: 최근 작업 상태 및 코드 (재부팅 시 초기화).
- `/etc/smartsafehub/apps-installed/`: Apps가 설치한 패키지 소유권 기록 (설치 바이너리는 포함하지 않음).
- `apk` 작업 로그는 작업 후 임시 디렉터리에서 정리되며, 실패한 경우 최신 패키지 관리자 오류만 최대 4KB까지 `/tmp/smartsafehub/apps-last-error.log`에 보존합니다 (재부팅 시 초기화).
## 테스트
```sh
sh tests/test-apps-contract.sh
sh tests/test-apps-manager.sh
sh tests/test-apps-restore.sh
sh tests/test-apps-ui.sh
sh tests/test-ucode-imports.sh
# 개발 환경에서 (프론트엔드 빌드 결과는 패치 미포함)
cd frontend && npm ci && npm run typecheck && npm run build
```
## 펌웨어 업데이트 후 Apps 복원
- `sysupgrade`는 `/etc/smartsafehub/apps-installed/`의 **소유권 기록**을 보관합니다. APK 바이너리 자체는 새 펌웨어에 포함되지 않으면 사라질 수 있습니다.
- 기존 `smartsafehub-firmware` daemon이 부팅 시 **일회성 복원 스크립트**를 실행하여 누락된, SmartSafeHub가 관리한 앱만 복원합니다. 별도의 procd/init 서비스나 상시 실행 daemon은 추가하지 않습니다. 기록이 없거나 이미 설치되어 있으면 아무것도 설치하지 않습니다.
- 복원은 현재 펌웨어의 `device_code`·`build_id`에 일치하는 Hub 카탈로그를 새로 받아 기존 Apps manager의 **서명, SHA-256, 의존성 및 소유권 검사**를 통과해야 합니다. 지난 펌웨어의 승인이나 캐시된 카탈로그로 설치하지 않습니다.
- 네트워크 오류는 제한적으로 다시 시도합니다. 앱 승인이 철회되거나, APK 해시·SmartSafeHub 서명 인덱스·아키텍처·소유권 기록이 맞지 않으면 중단합니다. 다른 방법으로 설치된 패키지를 인수하지 않습니다.
- 공유기에서 `/tmp/smartsafehub/apps-restore.state`와 `logread -e smartsafehub-apps-restore`로 결과를 확인할 수 있습니다. 실패 시 Apps 화면의 **설치 마무리**로 다시 검증할 수 있습니다. 수동 재시도는 `/usr/libexec/smartsafehub-apps-restore run`을 사용합니다.
- 인터넷이 오래 끊겨 재시도 횟수를 넘기면 해당 실행은 종료됩니다. 이후 WAN이 다시 연결되면 hotplug이 네트워크 오류에 한하여 일회성 복원을 재시도하며, 수동 실행이나 다음 부팅으로도 다시 시도할 수 있습니다.
- 주의: 새로운 펌웨어에서 승인된 패키지 pin이 변경되었으면 이전 manifest를 무단으로 덮어쓰지 않고 중단합니다. 이 경우 호환성 확인과 승인 후 별도 마이그레이션이 필요합니다.
## 설치 성공 건수 수집 (2026-10-28)
Apps에서 사용자가 새로 설치에 성공하면, 앱 ID·릴리스 버전·작업 ID만 로컬 outbox에 보관했다가 기존 Device 자격증명으로 Hub에 전송합니다. 실패한 설치, 제거, 펌웨어 업데이트에 따른 자동 복원은 집계하지 않습니다. 최대 500건을 보관하고 네트워크 장애 시 5분 간격으로 최대 3건씩 재시도하며, 해당 전송은 앱 설치의 성공 여부에 영향을 주지 않습니다. 수치는 공개 Apps 화면에 표시하지 않습니다.
+22
View File
@@ -239,8 +239,30 @@ Health 계약 테스트의 mock 환경은 각 시나리오를 subshell에서 실
sh tests/test-lan-settings.sh
SMARTSAFEHUB_REQUIRE_UCODE=1 sh tests/test-ucode-syntax.sh
shellspec spec/contracts_spec.sh
# 새 테스트 파일이 ShellSpec 실행 목록에 누락되지 않았는지 확인
sh tests/test-suite-integrity.sh
# 브라우저 동작 테스트 전체 (Node.js 24+)
node --experimental-strip-types --test tests/runtime/*.test.mjs
```
### OpenWrt 25.12 셸 명령 성능 검사
`sh tests/test-openwrt-commands.sh`는 BusyBox 기본 명령의 존재 여부와 함께 공유기에 배포하는 셸 스크립트의 고비용 패턴을 검사합니다. `root/`의 새 셸 진입점도 자동 포함되고 Backend CI 및 ShellSpec에서 동일한 검사기가 실행됩니다. 다음 패턴은 실패합니다.
- `find /`, `du /` 또는 `du -a`로 전체 파일시스템/파일 목록을 순회
- 재귀적 `grep -r/-R/--recursive`, `ls -R`로 넓은 범위를 반복 검색
- `dd if=/dev/zero` 또는 `dd if=/dev/urandom`에 `count=`가 없는 무제한 읽기
- `xargs -P0` 또는 `xargs -P 0`의 무제한 병렬 작업
- `sleep 0`, 명시적 대기·종료 경로가 없는 `while true`/`while :` 무한 루프
`grep`, `awk`, `find`, `sort` 자체를 금지하지 않습니다. 가능한 한 처리 디렉터리와 파일 크기, 반복 횟수, 병렬도에 상한을 두세요. 위험 동작이 실제로 필요한 경우 무조건 허용 목록에 넣기보다는 호출 범위를 제한하도록 코드를 수정하고 회귀 테스트를 추가합니다. 이 검사는 *정적 안티패턴 게이트*이며 변수로 전달된 경로, 런타임 실행 빈도, BusyBox 명령 옵션, 실제 CPU/메모리 부하까지 보증하지 않으므로 실기기 프로파일링을 대체하지 않습니다.
셸 테스트(`tests/**/*.sh`)는 `spec/*_spec.sh`에 각각 한 번씩 등록해야 합니다. GitHub Actions의 Backend 작업은 ShellSpec과 ucode를 설치하기 **전에** `test-suite-integrity.sh`를 독립적으로 실행하므로, 등록되지 않은 테스트가 있으면 즉시 실패합니다. 동일한 파일을 중복 등록하거나 이미 삭제한 테스트를 참조해도 실패합니다. CI가 `shellspec` 전체 실행을 제거하거나 조건부 실행/실패 무시로 변경해도 검사합니다.
브라우저 동작 테스트는 `tests/runtime/*.test.mjs` 이름으로 추가하면 Frontend CI에서 자동 실행됩니다. CI는 고정된 테스트 파일 목록을 사용하지 않으며, 자동 검색되지 않는 다른 확장자(`*.test.js`, `*.test.ts` 등)나 중첩 폴더에 테스트를 추가하면 누락 검사에서 실패합니다. 새로운 테스트 실행 형식이 필요하면 CI 실행 규칙과 검사기를 함께 수정해야 합니다. 변경하지 않은 배포 파일을 새로 생성하지 않고도 런타임 테스트를 로컬에서 실행할 수 있습니다.
누락 검사 자체에도 **미등록/중복/삭제된 테스트 참조, 잘못된 파일 확장자, CI 실행 단계 제거·조건부 실행·실패 무시**를 실제로 재현하는 회귀 검증을 포함했습니다. 화면 소스의 특정 공백이나 Tailwind 클래스 이름만 고정하는 일회성 테스트보다는 **보안 경계, 설정 보존, 버전 비교, RPC 계약, 사용자 동작**을 확인하는 테스트를 우선합니다.
```bash
cd frontend
npm ci
+17
View File
@@ -176,3 +176,20 @@ rm -f /tmp/luci-indexcache
```
브라우저에서는 강력 새로고침을 수행하거나 기존 SmartSafeHub 탭을 닫고 다시 접속합니다. 통합 진입 템플릿의 `app.js?v=...`와 Shadow DOM용 `app.css?v=...`에는 현재 패키지의 `PKG_VERSION-rPKG_RELEASE` 값이 사용되므로 패키지 릴리스 변경 시 브라우저 캐시가 함께 무효화됩니다.
### 공유기 웹사이트 RPC 로딩 지연 확인
사용자 규칙 등 화면이 간헐적으로 느릴 때 공유기 웹사이트에 로그인한 브라우저의
개발자 도구(F12 → Console)에서 아래 명령어를 실행합니다. 느려지는 상황을 재현한
후 실행하면 가장 오래 걸린 RPC와 실패 요청을 구분할 수 있습니다.
```js
window.__SMARTHUB_RPC_DIAGNOSTICS__?.snapshot()
// 처음부터 다시 측정할 때
window.__SMARTHUB_RPC_DIAGNOSTICS__?.reset()
```
- `summary`: 메서드별 호출 수(`calls`), 느린 호출(`slow`), 오류(`errors`), 마지막/최대/평균 처리 시간(ms).
- `recent`: **1.5초 이상 걸린 요청** 또는 실패한 요청 최근 최대 60건. `responseMs`는 응답 헤더까지 걸린 시간, `decodeMs`는 본문 JSON 처리 시간입니다.
- 기록은 현재 브라우저 탭의 메모리에만 유지되며 새로고침 시 사라집니다. 서버 업로드나 영구 저장은 하지 않으며, 세션 ID·도메인 규칙·호출 인자·응답 내용·오류 메시지는 기록하지 않습니다.
- `responseMs`가 긴 경우 네트워크/uhttpd/rpcd 경로를, `decodeMs`가 긴 경우 큰 응답 또는 브라우저 메인 스레드 부하를 우선 확인합니다. 수치만으로 원인을 단정할 수는 없습니다.
+2 -1
View File
@@ -18,7 +18,8 @@ git add frontend/demo-dist
## 공개 데모 화면
- 대시보드, 최근 활동, 네트워크(WAN/LAN), Wi-Fi/게스트 Wi-Fi, 연결된 기기, SafeShield, 사용자 규칙, 업데이트를 체험할 수 있습니다.
- 대시보드, 최근 활동, 네트워크(WAN/LAN), Wi-Fi/게스트 Wi-Fi, 연결된 기기, SafeShield, 사용자 규칙, **SmartSafeHub 계정 및 Apps**, 업데이트를 체험할 수 있습니다.
- 계정 연결 상태/플랜, 승인 앱 목록 및 설치 상태는 **가상 데이터**로만 표시합니다. 계정 관리 페이지 열기와 앱 설치·제거·업데이트·실행은 안내만 표시하고 실제 서버나 공유기에 요청하지 않습니다.
- 저장, 차단 규칙 수정, WAN 재연결, 업데이트 확인/설치 등은 **실제 요청을 보내지 않고** 안내 메시지만 보여줍니다.
- QR 코드도 실제 자격 정보 요청 대신 **데모 전용 예시 Wi-Fi 정보**로 생성합니다. 실제 공유기에서는 기존 인증된 RPC를 계속 사용합니다.
- `frontend/src/app/routes.ts`의 `DEMO_ROUTES`는 데모 메뉴 및 허용 경로의 단일 기준입니다.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 580 KiB

@@ -1,3 +1,3 @@
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties{:host,*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-outline-style:solid;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:Line truncated
@layer properties{:host,*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:10Line truncated
/*$vite$:1*/
@@ -1,4 +1,3 @@
var e,t,n,r,i,a,o,s,c,l,u={},d=[],f=/^m(i|n|o|s|text|space)$/,p=Array.isArray,m=d.slice,h=Object.assign;function g(e){e&&e.parentNode&&e.remove()}function _(n,r,i,a,o){var s={type:n,props:r,key:i,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:o||++t,__i:-1,__u:0};return!o&&e.vnode&&e.vnode(s),s}function v(e){return e.children}function y(e,t){this.props=e,this.context=t,this.__g=0}function b(e,t){if(t==null)return e.__?b(e.__,e.__i+1):null;for(var n;t<e.__k.length;t++)if((n=e.__k[t])&&n.__e)return n.__e;return typeof e.type!=`function`||e.props.__P?null:b(e)}function x(e){if((e=e.__)&&e.__c&&!e.props.__P)return e.__e=null,e.__k.some(function(t){return t&&(e.__e=t.__e)}),x(e)}function S(t){(8&t.__g||!(t.__g|=8)||!n.push(t)||i++)&&r==e.debounceRendering||((r=e.debounceRendering)||queueMicrotask)(C)}function C(){var t,r,o,s,c,l,u,d,f;try{for(r=1;n.length;)n.length>r&&n.sort(a),t=n.shift(),r=n.length,8&t.__g&&(s=void 0,c=void 0,l=(c=(o=t).__v).__e,u=[],d=[],(f=o.__P)&&((s=h({constructor:void 0},c)).__v=c.__v+1,e.vnode&&e.vnode(s),M(f,s,c,o.__n,f.namespaceURI,32&c.__u?[l]:null,u,l||b(c),32&c.__u,d),s.__v=c.__v,s.__.__k[s.__i]=s,te(u,s,d),c.__=c.__e=null,s.__e!=l&&x(s)))}finally{n.length=i=0}}function w(e,t,n,r,i,a,o,s,c,l,f){var p,m,h,g,_,v,y=r.__k||d,b=t.length;for(c=T(n,t,y,c,b),p=0;p<b;p++)(h=n.__k[p])!=null&&(m=~h.__i&&y[h.__i]||u,h.__i=p,v=M(e,h,m,i,a,o,s,c,l,f),g=h.__e,(m.ref!=h.ref||8&m.__u)&&(m.ref&&re(m.ref,null,h,m),h.ref&&f.push(h.ref,h.__c||g,h)),_=_||g,4&h.__u?(c=E(h,c,e,!m.__v),m.__e&&(m.__e=null)):typeof h.type==`function`&&v!==void 0?c=v:g&&(c=g.nextSibling),h.__u&=-7);return n.__e=_,c}function T(e,t,n,r,i){var a,o,s,c,l,u,d,f,m,h,g=n.length,y=g,x=0,S=!1,C=e.__k=Array(i);for(a=0;a<i;a++)(o=t[a])!=null&&typeof o!=`boolean`&&typeof o!=`function`?(typeof o!=`object`||o.constructor==String?o=C[a]=_(null,o):p(o)?o=C[a]=_(v,{children:o}):o.constructor===void 0&&o.__b?o=C[a]=_(o.type,o.props,o.key,o.ref,o.__v):C[a]=o,c=a+x,o.__=e,o.__b=e.__b+1,s=null,~(l=o.__i=O(o,n,c,y))&&(y--,(s=n[l])&&(s.__u|=2)),s&&s.__v?(o.__u|=2,l==c-1?x--:l==c+1?x++:l!=c&&(l>c?x--:x++,S=!0)):(~l||(i>g?x--:i<g&&x++),typeof o.type!=`function`&&(o.__u|=4))):C[a]=null;if(S){for(u=[],d=[],a=0;a<i;a++)if((o=C[a])&&2&o.__u){for(f=0,m=u.length;f<m;)u[h=f+m>>1]<o.__i?f=h+1:m=h;u[f]=o.__i,d[a]=f+1}for(x=u.length;a--;)d[a]&&(d[a]==x?x--:C[a].__u|=4)}if(y)for(a=0;a<g;a++)!(s=n[a])||2&s.__u||(s.__e==r&&(r=b(s)),ie(s,s));return r}function E(e,t,n,r){var i,a,o;if(typeof e.type==`function`){if(e.props.__P)return t;if(i=e.__k)for(a=0;a<i.length;a++)i[a]&&(i[a].__=e,t=E(i[a],t,n,!1));return t}for(t&&!t.parentNode&&(t=b(e))&&!t.parentNode&&(t=null),o=t;o&&o.nodeType==8;)o=o.nextSibling;for(e.__e!=o&&(!r&&n.moveBefore&&e.__e.parentNode?n.moveBefore(e.__e,t):n.insertBefore(e.__e,t||null)),t=e.__e;(t=t&&t.nextSibling)&&t.nodeType==8;);return t}function D(e,t){return t=t||[],e!=null&&typeof e!=`boolean`&&(p(e)?e.some(function(e){D(e,t)}):t.push(e)),t}function O(e,t,n,r){var i,a,o,s=e.key,c=e.type,l=t[n],u=l&&!(2&l.__u);if(l===null&&s==null||u&&s==l.key&&c==l.type)return n;if(r>+!!u){for(i=n-1,a=n+1;i>=0||a<t.length;)if((l=t[o=i>=0?i--:a++])&&!(2&l.__u)&&s==l.key&&c==l.type)return o}return-1}function k(e,t,n){n??(n=``),t[0]==`-`?e.setProperty(t,n):e[t]=n}function A(e,t,n,r,i){var a,o;n:if(t==`style`){if(typeof n==`string`)e.style.cssText=n;else{if(typeof r==`string`&&(e.style.cssText=r=``),r)for(t in r)n&&t in n||k(e.style,t,``);if(n)for(t in n)r&&n[t]==r[t]||k(e.style,t,n[t])}}else if(t[0]==`o`&&t[1]==`n`)(e.__e||(e.__e={}))[t]=n,n&&r||(o=l[t]||(l[t]=j(t)),(e.__a||(e.__a={}))[t]=c,a=t!=(t=t.replace(s,`$1`)),(t=t.slice(2))[0]<`a`&&(t=t.toLowerCase()),n?e.addEventListener(t,o,a):e.removeEventListener(t,o,a));else{if(i==`http://www.w3.org/2000/svg`)t=t.replace(/xlink(H|:h)/,`h`).replace(/sName$/,`s`);else if(t!=`width`&&t!=`height`&&t!=`href`&&t!=`list`&&t!=`form`&&t!=`tabIndex`&&t!=`download`&&t!=`rowSpan`&&t!=`colSpan`&&t!=`role`&&t!=`popover`&&t in e)try{e[t]=n??``;break n}catch{}typeof n==`function`||(n==null||!1===n&&t[4]!=`-`?e.removeAttribute(t):e.setAttribute(t,t==`popover`&&n==1?``:n))}}function j(t){return function(n){if(this.__e){var r=this.__e[t];if(n[o]==null)n[o]=c++;else if(n[o]<this.__a[t])return;return r(e.event?e.event(n):n)}}}function M(t,n,r,i,a,o,s,c,l,u){var f,m,_,x,S,C,T,E,D,O,k,A,j,M,te,re,oe,se,ce,le,N=n.type;if(n.constructor!==void 0)return null;if(128&r.__u&&(l=32&r.__u,f=r.__c.__z)){if(n.__u|=l,m=o=[],f.nodeType==8)for(_=1,x=f.nextSibling;x;x=x.nextSibling){if(x.nodeType==8){if(x.data.startsWith(`$s`))_++;else if(x.data.startsWith(`/$s`)&&!--_)break}o.push(x)}else o.push(f);c=o[0]}(f=e.__b)&&f(n);n:if(typeof N==`function`){S=s.length;try{if(O=n.props,k=(f=N.prototype)&&f.render,A=(f=N.contextType)&&i[f.__c],j=f?A?A.props.value:f.__:i,r.__c?2&(C=n.__c=r.__c).__g&&(C.__g|=1):(k?n.__c=C=new N(O,j):(n.__c=C=new y(O,j),C.constructor=N,C.render=ae),A&&A.sub(C),C.state||(C.state={}),C.__n=i,C.__g|=8,C.__h=[],C._Line truncated
`),l,g,x,re]),I(()=>{if(!C)return;let e=window.setTimeout(()=>E(!0),6e4);return()=>window.clearTimeout(e)},[C]);async function ie(){if(!(!u||g||x)){_(!0),ee(null),j(null);try{let e=await u();re(e),j(e.syncStatus===`confirmed`?`서버에서 규칙을 가져와 SafeShield 허용 목록을 확인했습니다.`:`추천 규칙 상태를 확인 중입니다. 진행 결과는 자동으로 갱신됩니다.`),c()}catch{ee(`추천 규칙을 동기화하지 못했습니다. 서버 연결 및 기기 등록 상태를 확인해 주세요.`)}finally{_(!1)}}}async function ae(){if(!y||x||!d)return;let e=y.id;D.current=e,S(!0),w(!0),E(!1),ee(null),j(null);try{let t=await d(e);re(t),b(null),D.current!==null&&j(`적용 해제 요청을 처리했습니다. 공유기의 허용 목록을 확인하고 있습니다.`),k.current()}catch{b(null),ee(`적용 해제 결과를 아직 확인하지 못했습니다. 공유기의 상태를 자동으로 다시 확인합니다.`);try{let e=await l?.();e&&re(e)}catch{}}finally{S(!1)}}let oe=f?.syncStage,se=f?.syncStatus===`pending`,ce=f?.syncStatus===`delayed`,le=f?.syncStatus===`failed`,N=oe===`server_sync`?`서버 동기화 중`:oe===`safeshield_refreshing`?`SafeShield 갱신 중`:oe===`applying`?`추천 규칙 적용 중`:oe===`verifying`?`허용 목록 확인 중`:ce?`재확인 지연`:le?`동기화 실패`:se?`동기화 대기`:`허용 목록 확인`,ue=oe===`safeshield_refreshing`&&se?`SafeShield가 보호 목록을 갱신하고 있습니다. 갱신이 끝나면 추천 규칙을 자동으로 다시 확인합니다.`:oe===`applying`&&se?`추천 규칙을 SafeShield 허용 목록에 반영하고 있습니다.`:oe===`verifying`&&se?`SafeShield 허용 목록에서 추천 규칙 반영 결과를 확인하고 있습니다.`:oe===`server_sync`&&se?`SmartSafeHub 서버에서 최신 추천 규칙을 확인하고 있습니다.`:ce?`서버 응답이 지연되고 있습니다. 이미 확인한 허용 규칙은 유지되며 자동으로 다시 시도합니다.`:le?f?.syncReason===`auth_rejected`?`SmartSafeHub 기기 인증이 거부되었습니다. 공유기 계정 연결 상태를 확인해 주세요.`:f?.syncReason===`api_rejected`?`서버가 추천 규칙 요청을 거부했습니다. Hub API 배포 상태를 확인해 주세요.`:f?.syncReason===`invalid_response`?`서버 응답을 확인할 수 없습니다. 규칙은 유지되며 다음 동기화에서 다시 확인합니다.`:`추천 규칙 반영을 완료하지 못했습니다. 다음 자동 동기화에서 다시 시도합니다.`:se?`추천 규칙의 적용 결과를 기다리고 있습니다.`:null,de=C&&D.current!==null?{tone:`amber`,label:T?`적용 해제 처리 지연`:`적용 해제 확인 중`,description:T?`적용 해제 확인이 지연되고 있습니다. SafeShield 갱신 중이라면 완료 후 자동으로 재확인합니다.`:ue??`적용 해제 상태를 확인하고 있습니다. 확인되면 목록과 상태가 자동 갱신됩니다.`}:se||ce||le?{tone:le?`red`:`amber`,label:N,description:ue??``}:M?{tone:`red`,label:`상태 확인 필요`,description:M}:f?.syncWarning?{tone:`amber`,label:`서버 재확인 지연`,description:`이미 확인한 허용 규칙은 유지되며 자동으로 다시 시도합니다.`}:A?{tone:`green`,label:`허용 목록 확인`,description:A}:null;return i&&!t?L(zn,{}):n&&!t?L(Bn,{message:n,onRetry:c}):t?L(v,{children:[y?L(`div`,{class:`fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-4`,role:`presentation`,onClick:()=>{x||b(null)},children:L(`section`,{"aria-labelledby":`recommended-revoke-title`,"aria-modal":`true`,class:`w-full max-w-md rounded-2xl border border-slate-200 bg-white p-5 shadow-xl`,onClick:e=>e.stopPropagation(),role:`dialog`,children:[L(`h2`,{class:`m-0 text-lg font-extrabold text-slate-950`,id:`recommended-revoke-title`,children:`추천 규칙 적용 해제`}),L(`p`,{class:`mt-3 text-sm text-slate-700`,children:[L(`strong`,{children:y.title}),` 규칙을 이 공유기에서 해제할까요?`]}),L(`p`,{class:`mt-2 text-xs leading-5 text-slate-600`,children:[`포함된 도메인 `,y.domains.length,`개를 다시 확인합니다. 직접 추가한 허용 규칙과 다른 추천 규칙에 필요한 도메인은 유지됩니다.`]}),L(`div`,{class:`mt-5 flex justify-end gap-2`,children:[L(`button`,{class:`rounded-lg border border-slate-200 px-4 py-2 text-sm font-bold text-slate-700 disabled:cursor-wait disabled:opacity-50`,disabled:x,onClick:()=>b(null),type:`button`,children:`취소`}),L(`button`,{class:`inline-flex min-w-24 items-center justify-center gap-2 rounded-lg bg-red-700 px-4 py-2 text-sm font-bold text-white transition hover:bg-red-800 disabled:cursor-progress disabled:bg-red-700 disabled:opacity-80`,"aria-busy":x,disabled:x,onClick:()=>void ae(),type:`button`,children:[x?L(`svg`,{class:`size-4 animate-spin`,"aria-hidden":`true`,viewBox:`0 0 24 24`,fill:`none`,children:[L(`circle`,{cx:`12`,cy:`12`,r:`9`,stroke:`currentColor`,"stroke-width":`3`,opacity: Line truncated
var e,t,n,r,i,a,o,s,c,l,u=Symbol.for(`pv`),d={},f=[],p=/^m(i|n|o|s|text|space)$/,m=Array.isArray,h=f.slice,g=Object.assign;function _(e){e&&e.parentNode&&e.remove()}function v(e,t,n){var r,i,a,o={},s=arguments.length;for(a in t)a==`key`?r=t[a]:a==`ref`&&typeof e!=`function`?i=t[a]:o[a]=t[a];return s>2&&(o.children=s>3?h.call(arguments,2):n),y(e,o,r,i,null)}function y(n,r,i,a,o){var s={type:n,props:r,key:i,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:u,__v:o||++t,__i:-1,__u:0};return!o&&e.vnode&&e.vnode(s),s}function b(e){return e.children}function x(e,t){this.props=e,this.context=t,this.__g=0}function S(e,t){if(t==null)return e.__&&S(e.__,e.__i+1);for(var n;t<e.__k.length;t++)if((n=e.__k[t])&&n.__e)return n.__e;return typeof e.type!=`function`||e.props.__P?null:S(e)}function C(e){if((e=e.__)&&e.__c&&!e.props.__P)return e.__e=null,e.__k.some(function(t){return t&&(e.__e=t.__e)}),C(e)}function w(t){(8&t.__g||(t.__g|=8,n.push(t),i++))&&r==e.debounceRendering||((r=e.debounceRendering)||queueMicrotask)(T)}function T(){var t,o,s,c,l,d,f,p,m;try{for(o=1;n.length;)n.length>o&&n.sort(a),t=n.shift(),o=n.length,8&t.__g&&(c=void 0,l=void 0,d=(l=(s=t).__v).__e,f=[],p=[],(m=s.__P)&&((c=g({constructor:u},l)).__v=l.__v+1,e.vnode&&e.vnode(c),P(m,c,l,s.__n,m.namespaceURI,32&l.__u?[d]:null,f,d||S(l),32&l.__u,p),c.__v=l.__v,c.__.__k[c.__i]=c,te(f,c,p),l.__=l.__e=null,c.__e!=d&&C(c)))}finally{(i=n.length)&&(r||queueMicrotask)(T)}}function E(e,t,n,r,i,a,o,s,c,l,u){var p,m,h,g,_,v,y=r.__k||f,b=t.length;for(c=D(n,t,y,c,b),p=0;p<b;p++)(h=n.__k[p])!=null&&(m=~h.__i&&y[h.__i]||d,h.__i=p,v=P(e,h,m,i,a,o,s,c,l,u),g=h.__e,(m.ref!=h.ref||8&m.__u)&&(m.ref&&re(m.ref,null,h,m),h.ref&&u.push(h.ref,h.__c||g,h)),_=_||g,4&h.__u?(c=O(h,c,e,!m.__v),m.__e&&(m.__e=null)):typeof h.type==`function`&&v!==void 0?c=v:g&&(c=g.nextSibling),h.__u&=-21);return n.__e=_,c}function D(e,t,n,r,i){var a,o,s,c,l,d,f,p,h,g=n.length,_=g,v=0,x=0,C=e.__k=Array(i);for(a=0;a<i;a++)(o=t[a])!=null&&typeof o!=`boolean`&&typeof o!=`function`?(typeof o!=`object`||o.constructor===String?o=C[a]=y(null,o):m(o)?o=C[a]=y(b,{children:o}):o.constructor===u&&o.__b?o=C[a]=y(o.type,o.props,o.key,o.ref,o.__v):C[a]=o,c=a+v,o.__=e,o.__b=e.__b+1,s=null,~(l=o.__i=A(o,n,c,_))&&(_--,(s=n[l])&&(s.__u|=2)),s&&s.__v?(o.__u|=16,l==c-1?v--:l==c+1?v++:l!=c&&(l>c?v--:v++,x=1)):(~l||(i>g?v--:i<g&&v++),typeof o.type!=`function`&&(o.__u|=4))):C[a]=null;if(x){for(d=[],f=[],a=0;a<i;a++)if((o=C[a])&&16&o.__u){for(v=0,p=d.length;v<p;)d[h=v+p>>1]<o.__i?v=h+1:p=h;d[v]=o.__i,f[a]=v+1}for(v=d.length;a--;)f[a]&&(f[a]==v?v--:C[a].__u|=4)}if(_)for(a=0;a<g;a++)!(s=n[a])||2&s.__u||(s.__e==r&&(r=S(s)),ie(s,s));return r}function O(e,t,n,r){var i,a,o,s;if(typeof e.type==`function`){if(e.props.__P)return t;if(i=e.__k)for(a=0;a<i.length;a++)i[a]&&(i[a].__=e,t=O(i[a],t,n,!1));return t}for(t&&!t.parentNode&&(t=S(e))&&!t.parentNode&&(t=null),o=e.__e,s=t;s&&s.nodeType==8;)s=s.nextSibling;for(o!=s&&(!r&&n.moveBefore&&o.parentNode?n.moveBefore(o,t):n.insertBefore(o,t||null)),t=o;(t=t&&t.nextSibling)&&t.nodeType==8&&!t.data.startsWith(`$s`););return t}function k(e,t){return t=t||[],e!=null&&typeof e!=`boolean`&&(m(e)?e.some(function(e){k(e,t)}):t.push(e)),t}function A(e,t,n,r){var i,a=e.key,o=e.type,s=t[n],c=s&&!(2&s.__u);if(s===null&&a==null||c&&a==s.key&&o==s.type)return n;if(r>+!!c){for(i=n-1;i>=0;i--)if((s=t[i])&&!(2&s.__u)&&a==s.key&&o==s.type)return i;for(i=n+1;i<t.length;i++)if((s=t[i])&&!(2&s.__u)&&a==s.key&&o==s.type)return i}return-1}function j(e,t,n){n??(n=``),t[0]==`-`?e.setProperty(t,n):e[t]=n}function M(e,t,n,r,i){var a,o;if(t==`style`){if(typeof n==`string`)e.style.cssText=n;else{if(typeof r==`string`&&(e.style.cssText=r=``),r)for(t in r)n&&t in n||j(e.style,t);if(n)for(t in n)r&&n[t]==r[t]||j(e.style,t,n[t])}}else if(t[0]==`o`&&t[1]==`n`)(e.__e||(e.__e={}))[t]=n,n&&r||(o=l[t]||(l[t]=N(t)),(e.__a||(e.__a={}))[t]=c,a=t!=(t=t.replace(s,`$1`)),(t=t.slice(2))[0]<`a`&&(t=t.toLowerCase()),n?e.addEventListener(t,o,a):e.removeEventListener(t,o,a));else{if(i==`http://www.w3.org/2000/svg`)t=t.replace(/xlink(H|:h)/,`h`).replace(/sName$/,`s`);else if(t!=`width`&&t!=`height`&&t!=`href`&&t!=`list`&&t!=`form`&&t!=`tabIndex`&&t!=`download`&&t!=`rowSpan`&&t!=`colSpan`&&t!=`role`&&t!=`popover`&&t in e)try{e[t]=n??``;return}catch{}if(typeof n==`function`)return;n==null||!1===n&&t[4]!=`-`?e.removeAttribute(t):e.setAttribute(t,t==`popover`&&n==1?``:n)}}function N(t){return function(n){var r=this.__e;if(r){if(r=r[t],n[o]==null)n[o]=c++;else if(n[o]<this.__a[t])return;return r(e.event?e.event(n):n)}}}function P(t,n,r,i,a,o,s,c,l,d){var p,h,v,y,C,w,T,D,O,k,A,j,M,N,P,te,re,oe,se=n.type;if(n.constructor!==u)return null;128&r.__u&&(l=32&r.__u,p=r.__c.__z)&&(n.__u|=l,h=o=[],p.nodeType==8?c=F(p,o):o.push(p),c=o[0]||c),(p=e.__b)&&p(n);n:if(typeof se==`function`){v=s.length;try{if(D=n.props,O=(p=se.prototype)&&p.render,k=(p=se.contextType)&&i[p.__c],A=p?k?k.props.value:p.__:i,(y=r.__c)?(n.__c=y,2&y.__g&&(y.__g|=1)):(O?n.__c=y=Line truncated
`)>-1?e.split(`
`):e}function Df(e,t){let{element:n,datasetIndex:r,index:i}=t,a=e.getDatasetMeta(r).controller,{label:o,value:s}=a.getLabelAndValue(i);return{chart:e,label:o,parsed:a.getParsed(i),raw:e.data.datasets[r].data[i],formattedValue:s,dataset:a.getDataset(),dataIndex:i,datasetIndex:r,element:n}}function Of(e,t){let n=e.chart.ctx,{body:r,footer:i,title:a}=e,{boxWidth:o,boxHeight:s}=t,c=_s(t.bodyFont),l=_s(t.titleFont),u=_s(t.footerFont),d=a.length,f=i.length,p=r.length,m=gs(t.padding),h=m.height,g=0,_=r.reduce((e,t)=>e+t.before.length+t.lines.length+t.after.length,0);if(_+=e.beforeBody.length+e.afterBody.length,d&&(h+=d*l.lineHeight+(d-1)*t.titleSpacing+t.titleMarginBottom),_){let e=t.displayColors?Math.max(s,c.lineHeight):c.lineHeight;h+=p*e+(_-p)*c.lineHeight+(_-1)*t.bodySpacing}f&&(h+=t.footerMarginTop+f*u.lineHeight+(f-1)*t.footerSpacing);let v=0,y=function(e){g=Math.max(g,n.measureText(e).width+v)};return n.save(),n.font=l.string,J(e.title,y),n.font=c.string,J(e.beforeBody.concat(e.afterBody),y),v=t.displayColors?o+2+t.boxPadding:0,J(r,e=>{J(e.before,y),J(e.lines,y),J(e.after,y)}),v=0,n.font=u.string,J(e.footer,y),n.restore(),g+=m.width,{width:g,height:h}}function kf(e,t){let{y:n,height:r}=t;return n<r/2?`top`:n>e.height-r/2?`bottom`:`center`}function Af(e,t,n,r){let{x:i,width:a}=r,o=n.caretSize+n.caretPadding;if(e===`left`&&i+a+o>t.width||e===`right`&&i-a-o<0)return!0}function jf(e,t,n,r){let{x:i,width:a}=n,{width:o,chartArea:{left:s,right:c}}=e,l=`center`;return r===`center`?l=i<=(s+c)/2?`left`:`right`:i<=a/2?l=`left`:i>=o-a/2&&(l=`right`),Af(l,e,t,n)&&(l=`center`),l}function Mf(e,t,n){let r=n.yAlign||t.yAlign||kf(e,n);return{xAlign:n.xAlign||t.xAlign||jf(e,t,n,r),yAlign:r}}function Nf(e,t){let{x:n,width:r}=e;return t===`right`?n-=r:t===`center`&&(n-=r/2),n}function Pf(e,t,n){let{y:r,height:i}=e;return t===`top`?r+=n:r-=t===`bottom`?i+n:i/2,r}function Ff(e,t,n,r){let{caretSize:i,caretPadding:a,cornerRadius:o}=e,{xAlign:s,yAlign:c}=n,l=i+a,{topLeft:u,topRight:d,bottomLeft:f,bottomRight:p}=hs(o),m=Nf(t,s),h=Pf(t,c,l);return c===`center`?s===`left`?m+=l:s===`right`&&(m-=l):s===`left`?m-=Math.max(u,f)+i:s===`right`&&(m+=Math.max(d,p)+i),{x:ao(m,0,r.width-t.width),y:ao(h,0,r.height-t.height)}}function If(e,t,n){let r=gs(n.padding);return t===`center`?e.x+e.width/2:t===`right`?e.x+e.width-r.right:e.x+r.left}function Lf(e){return Tf([],Ef(e))}function Rf(e,t,n){return bs(e,{tooltip:t,tooltipItems:n,type:`tooltip`})}function zf(e,t){let n=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return n?e.override(n):e}var Bf={beforeTitle:ha,title(e){if(e.length>0){let t=e[0],n=t.chart.data.labels,r=n?n.length:0;if(this&&this.options&&this.options.mode===`dataset`)return t.dataset.label||``;if(t.label)return t.label;if(r>0&&t.dataIndex<r)return n[t.dataIndex]}return``},afterTitle:ha,beforeBody:ha,beforeLabel:ha,label(e){if(this&&this.options&&this.options.mode===`dataset`)return e.label+`: `+e.formattedValue||e.formattedValue;let t=e.dataset.label||``;t&&(t+=`: `);let n=e.formattedValue;return U(n)||(t+=n),t},labelColor(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{borderColor:t.borderColor,backgroundColor:t.backgroundColor,borderWidth:t.borderWidth,borderDash:t.borderDash,borderDashOffset:t.borderDashOffset,borderRadius:0}},labelTextColor(){return this.options.bodyColor},labelPointStyle(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{pointStyle:t.pointStyle,rotation:t.rotation}},afterLabel:ha,afterBody:ha,beforeFooter:ha,footer:ha,afterFooter:ha};function Vf(e,t,n,r){let i=e[t].call(n,r);return i===void 0?Bf[t].call(n,r):i}var Hf=class extends Nu{constructor(e){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=e.chart,this.options=e.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(e){this.options=e,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){let e=this._cachedAnimations;if(e)return e;let t=this.chart,n=this.options.setContext(this.getContext()),r=n.enabled&&t.options.animation&&n.animations,i=new Uc(this.chart,r);return r._cacheable&&(this._cachedAnimations=Object.freeze(i)),i}getContext(){return this.$context||(this.$context=Rf(this.chart.getContext(),this,this._tooltipItems))}getTitle(e,t){let{callbacks:n}=t,r=Vf(n,`beforeTitle`,this,e),i=Vf(n,`title`,this,e),a=Vf(n,`afterTitle`,this,e),o=[];return o=Tf(o,Ef(r)),o=Tf(o,Ef(i)),o=Tf(o,Ef(a)),o}getBeforeBody(e,t){return Lf(Vf(t.callbacks,`beforeBody`,this,e))}getBody(e,t){let{callbacks:n}=t,r=[];return J(e Line truncated
`):e}function _p(e,t){let{element:n,datasetIndex:r,index:i}=t,a=e.getDatasetMeta(r).controller,{label:o,value:s}=a.getLabelAndValue(i);return{chart:e,label:o,parsed:a.getParsed(i),raw:e.data.datasets[r].data[i],formattedValue:s,dataset:a.getDataset(),dataIndex:i,datasetIndex:r,element:n}}function vp(e,t){let n=e.chart.ctx,{body:r,footer:i,title:a}=e,{boxWidth:o,boxHeight:s}=t,c=oc(t.bodyFont),l=oc(t.titleFont),u=oc(t.footerFont),d=a.length,f=i.length,p=r.length,m=ac(t.padding),h=m.height,g=0,_=r.reduce((e,t)=>e+t.before.length+t.lines.length+t.after.length,0);if(_+=e.beforeBody.length+e.afterBody.length,d&&(h+=d*l.lineHeight+(d-1)*t.titleSpacing+t.titleMarginBottom),_){let e=t.displayColors?Math.max(s,c.lineHeight):c.lineHeight;h+=p*e+(_-p)*c.lineHeight+(_-1)*t.bodySpacing}f&&(h+=t.footerMarginTop+f*u.lineHeight+(f-1)*t.footerSpacing);let v=0,y=function(e){g=Math.max(g,n.measureText(e).width+v)};return n.save(),n.font=l.string,X(e.title,y),n.font=c.string,X(e.beforeBody.concat(e.afterBody),y),v=t.displayColors?o+2+t.boxPadding:0,X(r,e=>{X(e.before,y),X(e.lines,y),X(e.after,y)}),v=0,n.font=u.string,X(e.footer,y),n.restore(),g+=m.width,{width:g,height:h}}function yp(e,t){let{y:n,height:r}=t;return n<r/2?`top`:n>e.height-r/2?`bottom`:`center`}function bp(e,t,n,r){let{x:i,width:a}=r,o=n.caretSize+n.caretPadding;if(e===`left`&&i+a+o>t.width||e===`right`&&i-a-o<0)return!0}function xp(e,t,n,r){let{x:i,width:a}=n,{width:o,chartArea:{left:s,right:c}}=e,l=`center`;return r===`center`?l=i<=(s+c)/2?`left`:`right`:i<=a/2?l=`left`:i>=o-a/2&&(l=`right`),bp(l,e,t,n)&&(l=`center`),l}function Sp(e,t,n){let r=n.yAlign||t.yAlign||yp(e,n);return{xAlign:n.xAlign||t.xAlign||xp(e,t,n,r),yAlign:r}}function Cp(e,t){let{x:n,width:r}=e;return t===`right`?n-=r:t===`center`&&(n-=r/2),n}function wp(e,t,n){let{y:r,height:i}=e;return t===`top`?r+=n:r-=t===`bottom`?i+n:i/2,r}function Tp(e,t,n,r){let{caretSize:i,caretPadding:a,cornerRadius:o}=e,{xAlign:s,yAlign:c}=n,l=i+a,{topLeft:u,topRight:d,bottomLeft:f,bottomRight:p}=ic(o),m=Cp(t,s),h=wp(t,c,l);return c===`center`?s===`left`?m+=l:s===`right`&&(m-=l):s===`left`?m-=Math.max(u,f)+i:s===`right`&&(m+=Math.max(d,p)+i),{x:Jo(m,0,r.width-t.width),y:Jo(h,0,r.height-t.height)}}function Ep(e,t,n){let r=ac(n.padding);return t===`center`?e.x+e.width/2:t===`right`?e.x+e.width-r.right:e.x+r.left}function Dp(e){return hp([],gp(e))}function Op(e,t,n){return lc(e,{tooltip:t,tooltipItems:n,type:`tooltip`})}function kp(e,t){let n=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return n?e.override(n):e}var Ap={beforeTitle:to,title(e){if(e.length>0){let t=e[0],n=t.chart.data.labels,r=n?n.length:0;if(this&&this.options&&this.options.mode===`dataset`)return t.dataset.label||``;if(t.label)return t.label;if(r>0&&t.dataIndex<r)return n[t.dataIndex]}return``},afterTitle:to,beforeBody:to,beforeLabel:to,label(e){if(this&&this.options&&this.options.mode===`dataset`)return e.label+`: `+e.formattedValue||e.formattedValue;let t=e.dataset.label||``;t&&(t+=`: `);let n=e.formattedValue;return K(n)||(t+=n),t},labelColor(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{borderColor:t.borderColor,backgroundColor:t.backgroundColor,borderWidth:t.borderWidth,borderDash:t.borderDash,borderDashOffset:t.borderDashOffset,borderRadius:0}},labelTextColor(){return this.options.bodyColor},labelPointStyle(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{pointStyle:t.pointStyle,rotation:t.rotation}},afterLabel:to,afterBody:to,beforeFooter:to,footer:to,afterFooter:to};function jp(e,t,n,r){let i=e[t].call(n,r);return i===void 0?Ap[t].call(n,r):i}var Mp=class extends Sd{constructor(e){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=e.chart,this.options=e.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(e){this.options=e,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){let e=this._cachedAnimations;if(e)return e;let t=this.chart,n=this.options.setContext(this.getContext()),r=n.enabled&&t.options.animation&&n.animations,i=new Ml(this.chart,r);return r._cacheable&&(this._cachedAnimations=Object.freeze(i)),i}getContext(){return this.$context||(this.$context=Op(this.chart.getContext(),this,this._tooltipItems))}getTitle(e,t){let{callbacks:n}=t,r=jp(n,`beforeTitle`,this,e),i=jp(n,`title`,this,e),a=jp(n,`afterTitle`,this,e),o=[];return o=hp(o,gp(r)),o=hp(o,gp(i)),o=hp(o,gp(a)),o}getBeforeBody(e,t){return Dp(jp(t.callbacks,`beforeBody`,this,e))}getBody(e,t){let{callbacks:n}=t,r=[];return X(e Line truncated
+93 -92
View File
@@ -1,23 +1,23 @@
{
"name": "luci-app-smartsafehub-frontend",
"version": "0.2.30",
"version": "0.2.31",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "luci-app-smartsafehub-frontend",
"version": "0.2.30",
"version": "0.2.31",
"dependencies": {
"chart.js": "4.5.1",
"preact": "11.0.1"
"chart.js": "4.6.1",
"preact": "11.1.0"
},
"devDependencies": {
"@preact/preset-vite": "2.10.6",
"@tailwindcss/vite": "4.3.3",
"@types/node": "26.6.4",
"@types/node": "26.6.5",
"tailwindcss": "4.3.3",
"typescript": "7.0.2",
"vite": "8.3.4"
"vite": "8.3.5"
},
"engines": {
"node": ">=24.0"
@@ -448,9 +448,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.153.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
"integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
"version": "0.155.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
"dev": true,
"license": "MIT",
"funding": {
@@ -550,9 +550,9 @@
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
"integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
"cpu": [
"arm"
],
@@ -567,9 +567,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz",
"integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
"cpu": [
"arm64"
],
@@ -584,9 +584,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz",
"integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
"cpu": [
"arm64"
],
@@ -601,9 +601,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz",
"integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
"cpu": [
"x64"
],
@@ -618,9 +618,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz",
"integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
"cpu": [
"x64"
],
@@ -635,9 +635,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz",
"integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
"cpu": [
"arm"
],
@@ -652,9 +652,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz",
"integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
"cpu": [
"arm64"
],
@@ -669,9 +669,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz",
"integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
"cpu": [
"arm64"
],
@@ -686,9 +686,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz",
"integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
"cpu": [
"ppc64"
],
@@ -703,9 +703,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz",
"integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
"cpu": [
"s390x"
],
@@ -720,9 +720,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz",
"integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
"cpu": [
"x64"
],
@@ -737,9 +737,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz",
"integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
"cpu": [
"x64"
],
@@ -754,9 +754,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz",
"integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
"cpu": [
"arm64"
],
@@ -771,9 +771,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz",
"integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
"cpu": [
"arm64"
],
@@ -788,9 +788,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz",
"integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
"cpu": [
"x64"
],
@@ -1125,9 +1125,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.6.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
"integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
"version": "26.6.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1560,9 +1560,9 @@
"license": "CC-BY-4.0"
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.6.1.tgz",
"integrity": "sha512-hJyniucsFmq//EvXYvJ6c6mahPoI3kaxdjVH8awTaJ/fDNWeSqqqArixMJWkWNN6fcYvPU3HneSANFNYtRi61g==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
@@ -2218,9 +2218,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.29",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
"version": "8.5.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
"dev": true,
"funding": [
{
@@ -2238,7 +2238,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.19",
"nanoid": "^3.3.20",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.2"
},
@@ -2247,15 +2247,16 @@
}
},
"node_modules/preact": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-11.0.1.tgz",
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-11.1.0.tgz",
"integrity": "sha512-hz1VsjWx5pSYPDVEzb9w+XyOmQpP1qW/ZEaMiYm+C8ykJr8KP+TKGZIbCW0QmZ/Yr/jg3XkZoziEWn9iWHZ9Mw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact-render-to-string": ">=5"
"preact-render-to-string": ">=6.7.0"
},
"peerDependenciesMeta": {
"preact-render-to-string": {
@@ -2264,13 +2265,13 @@
}
},
"node_modules/rolldown": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
"integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.153.0",
"@oxc-project/types": "=0.155.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -2280,21 +2281,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.13",
"@rolldown/binding-android-arm64": "1.2.13",
"@rolldown/binding-darwin-arm64": "1.2.13",
"@rolldown/binding-darwin-x64": "1.2.13",
"@rolldown/binding-freebsd-x64": "1.2.13",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.13",
"@rolldown/binding-linux-arm64-gnu": "1.2.13",
"@rolldown/binding-linux-arm64-musl": "1.2.13",
"@rolldown/binding-linux-ppc64-gnu": "1.2.13",
"@rolldown/binding-linux-s390x-gnu": "1.2.13",
"@rolldown/binding-linux-x64-gnu": "1.2.13",
"@rolldown/binding-linux-x64-musl": "1.2.13",
"@rolldown/binding-openharmony-arm64": "1.2.13",
"@rolldown/binding-win32-arm64-msvc": "1.2.13",
"@rolldown/binding-win32-x64-msvc": "1.2.13"
"@rolldown/binding-android-arm-eabi": "1.2.15",
"@rolldown/binding-android-arm64": "1.2.15",
"@rolldown/binding-darwin-arm64": "1.2.15",
"@rolldown/binding-darwin-x64": "1.2.15",
"@rolldown/binding-freebsd-x64": "1.2.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
"@rolldown/binding-linux-arm64-musl": "1.2.15",
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
"@rolldown/binding-linux-x64-gnu": "1.2.15",
"@rolldown/binding-linux-x64-musl": "1.2.15",
"@rolldown/binding-openharmony-arm64": "1.2.15",
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
"@rolldown/binding-win32-x64-msvc": "1.2.15"
}
},
"node_modules/semver": {
@@ -2467,16 +2468,16 @@
}
},
"node_modules/vite": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.29",
"rolldown": "~1.2.12",
"postcss": "^8.5.30",
"rolldown": "~1.2.14",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -2493,7 +2494,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.7.1",
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "luci-app-smartsafehub-frontend",
"private": true,
"version": "0.2.30",
"version": "0.2.31",
"type": "module",
"engines": {
"node": ">=24.0"
@@ -14,15 +14,15 @@
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"chart.js": "4.5.1",
"preact": "11.0.1"
"chart.js": "4.6.1",
"preact": "11.1.0"
},
"devDependencies": {
"@preact/preset-vite": "2.10.6",
"@tailwindcss/vite": "4.3.3",
"@types/node": "26.6.4",
"@types/node": "26.6.5",
"tailwindcss": "4.3.3",
"typescript": "7.0.2",
"vite": "8.3.4"
"vite": "8.3.5"
}
}
+43 -9
View File
@@ -4,6 +4,8 @@ import {
} from '../auth/sessionEvents';
import type { SmartSafeHubBootstrap } from '../types/bootstrap';
import type { ApiResponse } from '../types/status';
import { ensureRpcDiagnostics, recordRpcTiming } from './rpcDiagnostics';
import { acquireRpcSlot, type RpcPriority } from './rpcScheduler';
const UBUS_STATUS_TEXT: Readonly<Record<number, string>> = {
1: '잘못된 명령입니다.',
@@ -27,6 +29,8 @@ let requestId = 1;
export interface RpcCallOptions {
timeoutMs?: number;
signal?: AbortSignal;
priority?: RpcPriority;
}
export class RpcError extends Error {
@@ -258,15 +262,25 @@ export async function callRpc<T>(
options: RpcCallOptions = {},
): Promise<T> {
const bootstrap = getBootstrap();
ensureRpcDiagnostics();
const startedAt = performance.now();
let responseMs: number | null = null;
let decodeMs: number | null = null;
let resultCode = 'ok';
const id = requestId++;
// Cancellation while queued never occupies a network slot. Start the
// response timeout only after a slot is acquired, not while waiting.
let release: (() => void) | null = null;
let timeout: number | null = null;
const controller = new AbortController();
const timeout = window.setTimeout(
() => controller.abort(),
normalizedTimeout(options.timeoutMs),
);
let cancelled = false;
const cancel = () => { cancelled = true; controller.abort(); };
options.signal?.addEventListener('abort', cancel, { once: true });
try {
release = await acquireRpcSlot(options.priority ?? 'normal', options.signal);
if (cancelled || options.signal?.aborted) throw new RpcError('RPC_CANCELLED', '요청이 취소되었습니다.');
timeout = window.setTimeout(() => controller.abort(), normalizedTimeout(options.timeoutMs));
const response = await fetch(bootstrap.rpcUrl, {
method: 'POST',
credentials: 'same-origin',
@@ -281,6 +295,7 @@ export async function callRpc<T>(
}),
signal: controller.signal,
});
responseMs = performance.now() - startedAt;
if (!response.ok) {
throw new RpcError(
@@ -293,33 +308,52 @@ export async function callRpc<T>(
try {
payload = await response.json();
decodeMs = performance.now() - startedAt - (responseMs ?? 0);
} catch {
throw invalidResponse();
}
return parseJsonRpcResponse<T>(payload, id);
} catch (error) {
if (cancelled || options.signal?.aborted) {
resultCode = 'RPC_CANCELLED';
throw new RpcError('RPC_CANCELLED', '요청이 취소되었습니다.');
}
if (controller.signal.aborted) {
resultCode = 'RPC_TIMEOUT';
throw new RpcError(
'RPC_TIMEOUT',
'장치 응답 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요.',
);
}
if (error instanceof DOMException && error.name === 'AbortError') {
resultCode = 'RPC_CANCELLED';
throw new RpcError('RPC_CANCELLED', '요청이 취소되었습니다.');
}
if (error instanceof RpcError) {
throw await sessionExpiryError(error, bootstrap);
const resolved = await sessionExpiryError(error, bootstrap);
resultCode = resolved.code;
throw resolved;
}
resultCode = 'NETWORK_ERROR';
throw new RpcError(
'NETWORK_ERROR',
'공유기와 통신할 수 없습니다. 네트워크 연결을 확인해 주세요.',
);
} finally {
window.clearTimeout(timeout);
if (timeout !== null) window.clearTimeout(timeout);
options.signal?.removeEventListener('abort', cancel);
release?.();
const elapsedMs = Math.round(performance.now() - startedAt);
// Development diagnostics only: never record parameters, tokens, or responses.
if (import.meta.env.DEV && elapsedMs >= 1000) {
console.warn(`[RPC slow] ${object}.${method}: ${elapsedMs}ms`);
const slow = recordRpcTiming({
object, method, elapsedMs, responseMs, decodeMs, result: resultCode,
});
// One concise warning only for a slow call or a timeout; full bounded
// timings are available in DevTools even on a production router.
if (slow || resultCode === 'RPC_TIMEOUT') {
console.warn(`[SmartSafeHub RPC] ${object}.${method}: ${elapsedMs}ms (${resultCode})`);
}
}
}
@@ -0,0 +1,112 @@
// Browser-local, bounded RPC diagnostics. Never store URL, session, request
// arguments, headers, response data, or error messages.
export const RPC_SLOW_THRESHOLD_MS = 1_500;
const MAX_RECENT = 60;
const MAX_METHODS = 64;
export interface RpcTiming {
object: string;
method: string;
elapsedMs: number;
responseMs: number | null;
decodeMs: number | null;
result: string;
}
interface RpcEvent extends RpcTiming {
at: string;
}
interface RpcSummary {
method: string;
calls: number;
slow: number;
errors: number;
lastMs: number;
maxMs: number;
averageMs: number;
}
interface MutableSummary extends RpcSummary { totalMs: number }
export interface RpcDiagnosticsSnapshot {
slowThresholdMs: number;
summary: RpcSummary[];
recent: RpcEvent[];
}
export interface RpcDiagnosticsApi {
snapshot: () => RpcDiagnosticsSnapshot;
reset: () => void;
}
declare global {
interface Window {
__SMARTHUB_RPC_DIAGNOSTICS__?: RpcDiagnosticsApi;
}
}
const summaries = new Map<string, MutableSummary>();
const recent: RpcEvent[] = [];
function rounded(milliseconds: number | null): number | null {
return milliseconds === null ? null : Math.max(0, Math.round(milliseconds));
}
export function recordRpcTiming(timing: RpcTiming): boolean {
const method = `${timing.object}.${timing.method}`;
const elapsedMs = rounded(timing.elapsedMs) ?? 0;
const slow = elapsedMs >= RPC_SLOW_THRESHOLD_MS;
const failed = timing.result !== 'ok';
let summary = summaries.get(method);
if (!summary && summaries.size < MAX_METHODS) {
summary = { method, calls: 0, slow: 0, errors: 0, lastMs: 0, maxMs: 0, averageMs: 0, totalMs: 0 };
summaries.set(method, summary);
}
if (summary) {
summary.calls++;
summary.slow += Number(slow);
summary.errors += Number(failed);
summary.totalMs += elapsedMs;
summary.lastMs = elapsedMs;
summary.maxMs = Math.max(summary.maxMs, elapsedMs);
summary.averageMs = Math.round(summary.totalMs / summary.calls);
}
if (slow || failed) {
recent.push({
at: new Date().toISOString(),
object: timing.object,
method: timing.method,
elapsedMs,
responseMs: rounded(timing.responseMs),
decodeMs: rounded(timing.decodeMs),
result: timing.result,
});
if (recent.length > MAX_RECENT) recent.shift();
}
return slow;
}
export function snapshotRpcDiagnostics(): RpcDiagnosticsSnapshot {
return {
slowThresholdMs: RPC_SLOW_THRESHOLD_MS,
summary: [...summaries.values()].map(({ totalMs: _totalMs, ...summary }) => ({ ...summary })),
recent: recent.map((entry) => ({ ...entry })),
};
}
export function resetRpcDiagnostics(): void {
summaries.clear();
recent.length = 0;
}
const diagnosticsApi: RpcDiagnosticsApi = {
snapshot: snapshotRpcDiagnostics,
reset: resetRpcDiagnostics,
};
export function ensureRpcDiagnostics(): void {
// Enable in production as well, without persistent storage or remote transfer.
window.__SMARTHUB_RPC_DIAGNOSTICS__ ??= diagnosticsApi;
}
@@ -0,0 +1,52 @@
/** Shared bounded queue. Reserve one connection for user-triggered requests. */
export type RpcPriority = 'high' | 'normal' | 'low';
interface Waiter {
priority: RpcPriority;
start: (release: () => void) => void;
abort: () => void;
}
const MAX_RUNNING = 4;
const MAX_BACKGROUND = 3;
let running = 0;
let background = 0;
const queue: Waiter[] = [];
function drain() {
while (running < MAX_RUNNING) {
const index = queue.findIndex((waiter) => waiter.priority !== 'low' || background < MAX_BACKGROUND);
if (index === -1) return;
const waiter = queue.splice(index, 1)[0];
if (!waiter) continue;
running += 1;
if (waiter.priority === 'low') background += 1;
waiter.start(() => {
running -= 1;
if (waiter.priority === 'low') background -= 1;
drain();
});
}
}
export function acquireRpcSlot(priority: RpcPriority, signal?: AbortSignal): Promise<() => void> {
if (signal?.aborted) return Promise.reject(new DOMException('Cancelled', 'AbortError'));
return new Promise((resolve, reject) => {
const waiter: Waiter = {
priority,
start: (release) => { signal?.removeEventListener('abort', waiter.abort); resolve(release); },
abort: () => {
const index = queue.indexOf(waiter);
if (index !== -1) queue.splice(index, 1);
signal?.removeEventListener('abort', waiter.abort);
reject(new DOMException('Cancelled', 'AbortError'));
},
};
signal?.addEventListener('abort', waiter.abort, { once: true });
// Preserve FIFO within priority while permitting interactive RPC ahead of polls.
const last = queue.findIndex((item) =>
(priority === 'high' && item.priority !== 'high') ||
(priority === 'normal' && item.priority === 'low'));
if (last === -1) queue.push(waiter);
else queue.splice(last, 0, waiter);
drain();
});
}
@@ -125,8 +125,9 @@ function apiError(response: Record<string, unknown>): RpcError {
async function callSafeShield<T>(
method: string,
params: Record<string, unknown> = {},
signal?: AbortSignal,
): Promise<T> {
const response = await callRpc<unknown>(API_OBJECT, method, params);
const response = await callRpc<unknown>(API_OBJECT, method, params, { ...(signal ? { signal } : {}), priority: signal ? 'low' : 'normal' });
const object = objectValue(response);
if (object.ok === false) {
@@ -455,8 +456,8 @@ export async function fetchSafeShieldStatus(): Promise<SafeShieldStatus> {
}
}
async function fetchSafeShieldRuleConfig(): Promise<SafeShieldRuleConfig> {
const response = await callSafeShield<RawSafeShieldConfig>('config');
async function fetchSafeShieldRuleConfig(signal?: AbortSignal): Promise<SafeShieldRuleConfig> {
const response = await callSafeShield<RawSafeShieldConfig>('config', {}, signal);
return normalizeRuleConfig(response);
}
@@ -491,10 +492,10 @@ export async function requestSafeShieldRefresh(): Promise<SafeShieldRefreshResul
};
}
export async function fetchSafeShieldRules(): Promise<SafeShieldRules> {
export async function fetchSafeShieldRules(signal?: AbortSignal): Promise<SafeShieldRules> {
const [rulesResponse, config] = await Promise.all([
callSafeShield<RawSafeShieldRules>('rules_list'),
fetchSafeShieldRuleConfig(),
callSafeShield<RawSafeShieldRules>('rules_list', {}, signal),
fetchSafeShieldRuleConfig(signal),
]);
const allow = stringArray(rulesResponse.allow);
const block = stringArray(rulesResponse.block);
@@ -1,4 +1,5 @@
import type { ActivityHistory } from '../types/activity';
import type { AppsCatalog, AppsInstallStatus, AppsMetrics, AppsOperationAccepted } from '../types/apps';
import type {
ConfigurationBackupDiscardResult,
ConfigurationBackupRestoreResult,
@@ -87,30 +88,39 @@ export interface DeviceRegistrationStatus {
pairingExpiresAt: string | null;
nextSyncAt: number;
lastSuccessAt: number;
operation?: BackgroundOperation;
pairingOperation?: BackgroundOperation;
}
export function fetchRecommendedRulesStatus(): Promise<RecommendedRulesStatus> {
return callApi(API_OBJECT, 'device_recommended_rules_status', {}, { timeoutMs: 5000 });
export interface BackgroundOperation {
phase: 'idle' | 'starting' | 'running' | 'success' | 'failed';
code: string;
argument: string;
updatedAt: number;
}
export function fetchRecommendedRulesStatus(signal?: AbortSignal): Promise<RecommendedRulesStatus> {
return callApi(API_OBJECT, 'device_recommended_rules_status', {}, { ...(signal ? { signal } : {}), priority: 'low', timeoutMs: 5000 });
}
export function refreshRecommendedRulesStatus(): Promise<RecommendedRulesStatus> {
return callApi(API_OBJECT, 'device_recommended_rules_refresh', {}, { timeoutMs: 30000 });
return callApi(API_OBJECT, 'device_recommended_rules_refresh', {}, { priority: 'high', timeoutMs: 6000 });
}
export function revokeRecommendedRule(ruleId: number): Promise<RecommendedRulesStatus> {
return callApi(API_OBJECT, 'device_recommended_rules_revoke', { rule_id: ruleId }, { timeoutMs: 30000 });
return callApi(API_OBJECT, 'device_recommended_rules_revoke', { rule_id: ruleId }, { priority: 'high', timeoutMs: 6000 });
}
export function fetchDeviceRegistrationStatus(): Promise<DeviceRegistrationStatus> {
return callApi(API_OBJECT, 'device_registration_status', {}, { timeoutMs: 5000 });
export function fetchDeviceRegistrationStatus(signal?: AbortSignal): Promise<DeviceRegistrationStatus> {
return callApi(API_OBJECT, 'device_registration_status', {}, { ...(signal ? { signal } : {}), priority: 'low', timeoutMs: 5000 });
}
export function refreshDeviceRegistrationStatus(): Promise<DeviceRegistrationStatus> {
return callApi(API_OBJECT, 'device_registration_refresh', {}, { timeoutMs: 30000 });
return callApi(API_OBJECT, 'device_registration_refresh', {}, { priority: 'high', timeoutMs: 6000 });
}
export function requestDevicePairingCode(): Promise<DeviceRegistrationStatus> {
return callApi(API_OBJECT, 'device_pairing_refresh', {}, { timeoutMs: 15000 });
return callApi(API_OBJECT, 'device_pairing_refresh', {}, { priority: 'high', timeoutMs: 6000 });
}
export function fetchHealthStatus(): Promise<HealthStatus> {
@@ -125,6 +135,42 @@ export function updateHealthReporter(enabled: boolean): Promise<HealthStatus> {
return callApi(API_OBJECT, 'health_reporter_update', { enabled });
}
export function fetchAppsCatalog(signal?: AbortSignal): Promise<AppsCatalog> {
return callApi(API_OBJECT, 'apps_catalog', {}, { ...(signal ? { signal } : {}), priority: 'low', timeoutMs: 6000 });
}
export function refreshAppsCatalog(): Promise<AppsCatalog> {
return callApi(API_OBJECT, 'apps_catalog', { refresh: true }, { priority: 'high', timeoutMs: 6000 });
}
export function fetchAppsStatus(signal?: AbortSignal): Promise<AppsInstallStatus> {
return callApi(API_OBJECT, 'apps_status', {}, { ...(signal ? { signal } : {}), priority: 'low', timeoutMs: 6000 });
}
export function refreshAppsStatus(): Promise<AppsInstallStatus> {
return callApi(API_OBJECT, 'apps_status', { refresh: true }, { priority: 'high', timeoutMs: 6000 });
}
export function fetchAppsMetrics(signal?: AbortSignal): Promise<AppsMetrics> {
return callApi(API_OBJECT, 'apps_metrics', {}, { ...(signal ? { signal } : {}), priority: 'low', timeoutMs: 6000 });
}
export function refreshAppsMetrics(): Promise<AppsMetrics> {
return callApi(API_OBJECT, 'apps_metrics', { refresh: true }, { priority: 'high', timeoutMs: 6000 });
}
export function installVerifiedApp(appId: string): Promise<AppsOperationAccepted> {
return callApi(API_OBJECT, 'apps_install', { app_id: appId }, { timeoutMs: 8000 });
}
export function updateVerifiedApp(appId: string): Promise<AppsOperationAccepted> {
return callApi(API_OBJECT, 'apps_update', { app_id: appId }, { timeoutMs: 8000 });
}
export function removeVerifiedApp(appId: string): Promise<AppsOperationAccepted> {
return callApi(API_OBJECT, 'apps_remove', { app_id: appId }, { timeoutMs: 8000 });
}
export function fetchSoftwareUpdates(): Promise<SoftwareUpdateStatus> {
return callApi(API_OBJECT, 'updates_status');
}
+78 -5
View File
@@ -1,12 +1,14 @@
import type { ComponentChildren } from 'preact';
import { useEffect } from 'preact/hooks';
import { useEffect, useRef } from 'preact/hooks';
import { fetchGuestWifiQr, fetchRecommendedRulesStatus, refreshRecommendedRulesStatus,
import { fetchAppsCatalog, fetchAppsStatus, fetchAppsMetrics, refreshAppsCatalog, refreshAppsStatus, refreshAppsMetrics, installVerifiedApp, updateVerifiedApp, removeVerifiedApp, fetchGuestWifiQr, fetchRecommendedRulesStatus, refreshRecommendedRulesStatus,
revokeRecommendedRule, fetchWifiQr, initializeSystemTimezone } from '../api/smartsafehub';
import { AppShell } from '../components/AppShell';
import { PageTabs } from '../components/PageTabs';
import { useActivityHistory } from '../hooks/useActivityHistory';
import { useAsyncResource } from '../hooks/useAsyncResource';
import type { AppsCatalog, AppsInstallStatus, AppsMetrics } from '../types/apps';
import { useConfigurationBackup } from '../hooks/useConfigurationBackup';
import { useConnectedDevices } from '../hooks/useConnectedDevices';
import { useFirmwareUpdates } from '../hooks/useFirmwareUpdates';
@@ -29,6 +31,7 @@ import { useDeviceRegistration } from '../hooks/useDeviceRegistration';
import { useDashboardAccountStatus } from '../hooks/useDashboardAccountStatus';
import { useWifi } from '../hooks/useWifi';
import { ActivityPage } from '../pages/ActivityPage';
import { AppsPage } from '../pages/AppsPage';
import { ConnectedDevicesPage } from '../pages/ConnectedDevicesPage';
import { HomePage } from '../pages/HomePage';
import { IptvPage } from '../pages/IptvPage';
@@ -51,6 +54,28 @@ export function App({ onAdministratorPasswordChanged }: AppProps) {
const tabs = usePageTabs();
const configurationBackup = useConfigurationBackup();
const activity = useActivityHistory(route === 'activity');
const apps = useAsyncResource<AppsCatalog>({
active: route === 'apps',
fallbackError: '검증된 앱 목록을 불러오지 못했습니다.',
loader: fetchAppsCatalog,
pollInterval: (data) => data?.refreshing ? 2_000 : !data?.ready ? (data?.refreshFailed ? 15_000 : 2_000) : 60_000,
staleTimeMs: 2_000,
});
const appsStatus = useAsyncResource<AppsInstallStatus>({
active: route === 'apps',
fallbackError: '앱 설치 상태를 불러오지 못했습니다.',
loader: fetchAppsStatus,
pollInterval: (data) => !data?.ready || data?.refreshing ? 2_000 : data?.job.phase === 'running' || data?.job.phase === 'starting' || data?.restore?.phase === 'running' || data?.restore?.phase === 'starting' ? 3000 : 12000,
staleTimeMs: 3000,
});
// Optional metrics never participate in the status/error guard for buttons.
const appsMetrics = useAsyncResource<AppsMetrics>({
active: route === 'apps',
fallbackError: '저장 공간 정보를 확인하지 못했습니다.',
loader: fetchAppsMetrics,
pollInterval: (data) => data?.refreshing || !data?.ready ? 3_000 : 60000,
staleTimeMs: 5000,
});
const status = useStatus(route === 'home' || route === 'settings');
const updates = useSoftwareUpdates(true);
const firmware = useFirmwareUpdates(true);
@@ -95,6 +120,23 @@ export function App({ onAdministratorPasswordChanged }: AppProps) {
});
}, []);
// A completed install/remove invalidates both APK snapshots even if their
// background TTL has not elapsed. Never force them on every render/poll.
const appsJobId = appsStatus.data?.job.operationId;
const appsJobPhase = appsStatus.data?.job.phase;
const appsRestorePhase = appsStatus.data?.restore?.phase;
const previousAppsRefreshKey = useRef('');
useEffect(() => {
if (route !== 'apps' || (appsJobPhase !== 'success' && appsRestorePhase !== 'success')) return;
const key = `${appsJobId ?? ''}:${appsJobPhase}:${appsRestorePhase ?? ''}`;
if (key === previousAppsRefreshKey.current) return;
previousAppsRefreshKey.current = key;
void Promise.allSettled([refreshAppsStatus(), refreshAppsMetrics()]).then(() => {
void appsStatus.refresh();
void appsMetrics.refresh();
});
}, [route, appsJobId, appsJobPhase, appsRestorePhase]);
const safeshieldActions = useSafeShieldActions(
safeshield.refresh,
safeshieldStatistics.refresh,
@@ -117,13 +159,44 @@ export function App({ onAdministratorPasswordChanged }: AppProps) {
? rules
: route === 'account'
? deviceRegistration
: route === 'system'
? updates
: status;
: route === 'apps'
? apps
: route === 'system'
? updates
: status;
let content: ComponentChildren;
switch (route) {
case 'apps':
content = (
<AppsPage
data={apps.data?.ready === false ? null : apps.data}
error={apps.error ?? (apps.data?.refreshFailed && !apps.data?.ready ? '앱 목록 갱신에 실패했습니다. 서버 연결을 확인해 주세요.' : null)}
loading={apps.loading || (!!apps.data && !apps.data.ready && !apps.data.refreshFailed)}
refreshing={apps.refreshing || !!apps.data?.refreshing}
onRetry={() => void refreshAppsCatalog().then(() => apps.refresh()).then(() =>
Promise.all([appsStatus.refresh(), appsMetrics.refresh()])).catch(() => apps.refresh())}
installStatus={appsStatus.data?.ready === false ? null : appsStatus.data}
metrics={appsMetrics.data}
statusError={appsStatus.error ?? (appsStatus.data?.ready === false ? '설치 상태를 확인하고 있습니다.' : null)}
onRefreshStatus={() => void appsStatus.refresh()}
onInstall={async (id) => {
await installVerifiedApp(id);
await appsStatus.refresh();
}}
onUpdate={async (id) => {
await updateVerifiedApp(id);
await appsStatus.refresh();
}}
onRemove={async (id) => {
await removeVerifiedApp(id);
await appsStatus.refresh();
}}
/>
);
break;
case 'network':
content = (
<div class="min-w-0">
@@ -8,6 +8,7 @@ export type AppRoute =
| 'safeshield'
| 'rules'
| 'account'
| 'apps'
| 'system'
| 'settings';
@@ -83,6 +84,13 @@ export const ROUTES: readonly RouteDefinition[] = [
title: 'SmartSafeHub 계정',
description: '이 기기의 SmartSafeHub 계정 연결 상태와 구독 플랜을 확인합니다.',
},
{
route: 'apps',
hash: '#apps',
label: 'Apps',
title: 'SmartSafeHub Apps',
description: 'SmartSafeHub에서 검증한 앱과 확장 기능을 살펴봅니다.',
},
{
route: 'system',
hash: '#system',
@@ -105,5 +113,5 @@ export const ROUTE_BY_NAME = Object.fromEntries(
// Public demo exposes only these read-only, fixture-backed screens.
export const DEMO_ROUTES: readonly AppRoute[] = [
'home', 'activity', 'network', 'wifi', 'devices', 'safeshield', 'rules', 'system',
'home', 'activity', 'network', 'wifi', 'devices', 'safeshield', 'rules', 'account', 'apps', 'system',
];
@@ -0,0 +1,32 @@
import { AppsIcon, CircleEllipsisIcon, GamepadIcon, HomeIcon, NetworkNodesIcon, ShieldIcon, WrenchIcon } from './Icons';
export type AppsCategory = 'all' | 'network' | 'security' | 'smart_home' | 'entertainment' | 'utilities' | 'other';
/** Normalize catalog categories once for both the filter and icon fallback. */
export function appsCategoryOf(category: string): AppsCategory {
switch (category) {
case 'network': return 'network';
case 'security': return 'security';
case 'entertainment': return 'entertainment';
case 'utilities': return 'utilities';
case 'other': return 'other';
case 'smart_home':
case 'iot': return 'smart_home';
default: return 'other';
}
}
/** Local SVG icons: never fetch or execute artwork from a catalog entry.
* When verified per-app icons are supported, use these as the fallback. */
export function AppsCategoryIcon({ category, class: className }: { category: AppsCategory; class?: string }) {
switch (category) {
case 'all': return <AppsIcon class={className} />;
case 'network': return <NetworkNodesIcon class={className} />;
case 'security': return <ShieldIcon class={className} />;
case 'smart_home': return <HomeIcon class={className} />;
case 'entertainment': return <GamepadIcon class={className} />;
case 'utilities': return <WrenchIcon class={className} />;
case 'other': return <CircleEllipsisIcon class={className} />;
default: return <CircleEllipsisIcon class={className} />;
}
}
@@ -0,0 +1,39 @@
import type { AppsStorage } from '../types/apps';
import { summarizeAppsStorage } from '../utils/appsStorage';
const RING_CIRCUMFERENCE = 2 * Math.PI * 39;
export function AppsStorageCard({ storage }: { storage: AppsStorage | null | undefined }) {
const summary = summarizeAppsStorage(storage);
const availableArc = summary ? (summary.availableRatio * RING_CIRCUMFERENCE).toFixed(2) : '0';
return (
<aside aria-label="앱 설치 저장 공간" data-level={summary?.level ?? 'unavailable'} class="ssh-apps-storage-card flex min-w-0 items-center gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-slate-900/5">
<div class="ssh-apps-storage-ring relative grid size-24 shrink-0 place-items-center" data-level={summary?.level ?? 'unavailable'}>
<svg aria-hidden="true" class="size-24" viewBox="0 0 96 96">
<circle class="ssh-apps-storage-track" cx="48" cy="48" r="39" />
{summary && summary.availableRatio > 0 ? (
<circle class="ssh-apps-storage-free" cx="48" cy="48" r="39"
stroke-dasharray={`${availableArc} ${RING_CIRCUMFERENCE.toFixed(2)}`} />
) : null}
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center text-center">
<strong class="text-lg leading-6 font-black text-slate-950">{summary ? `${summary.availablePercent}%` : '—'}</strong>
<span class="text-[10px] font-bold text-slate-500">여유</span>
</div>
</div>
<div aria-live="polite" class="min-w-0 flex-1">
<h3 class="m-0 text-sm font-black text-slate-950">저장 공간</h3>
{summary ? (
<>
<p class="ssh-apps-storage-available mb-0 mt-1 text-base font-extrabold text-teal-700">{summary.availableText} 남음</p>
<p class="mb-0 mt-1 text-[11px] leading-4 text-slate-500" title="앱 설치 가능 용량을 기준으로 계산하며, 파일시스템 예약 공간이 사용량에 포함될 수 있습니다.">
사용 {summary.usedText} / 전체 {summary.totalText}
</p>
</>
) : (
<p class="mb-0 mt-1 text-xs leading-5 text-slate-500">용량 정보를 확인할 수 없습니다.</p>
)}
</div>
</aside>
);
}
@@ -69,6 +69,59 @@ export function UserIcon(props: IconProps) {
);
}
/** App modules icon; distinct from firmware update and system settings. */
export function AppsIcon(props: IconProps) {
return (
<IconBase {...props}>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<path d="M17.5 14v7M14 17.5h7" />
</IconBase>
);
}
/** Connected nodes for network-monitoring Apps. */
export function NetworkNodesIcon(props: IconProps) {
return (
<IconBase {...props}>
<rect x="2.5" y="3.5" width="7" height="6" rx="1.5" />
<rect x="14.5" y="3.5" width="7" height="6" rx="1.5" />
<rect x="8.5" y="15" width="7" height="6" rx="1.5" />
<path d="M6 9.5v2.5h12V9.5M12 12v3" />
</IconBase>
);
}
/** Controller outline for games and entertainment Apps. */
export function GamepadIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M7.5 7h9a5 5 0 0 1 4.9 4l.8 5a2.5 2.5 0 0 1-4 2.3L15.5 16h-7l-2.7 2.3a2.5 2.5 0 0 1-4-2.3l.8-5A5 5 0 0 1 7.5 7Z" />
<path d="M6.5 11v4M4.5 13h4M16 11.5h.01M18.5 13.5h.01" />
</IconBase>
);
}
/** Tools and utilities: a wrench is distinct from the power control. */
export function WrenchIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.287l-7.91 7.91a2.121 2.121 0 0 1-3-3l7.91-7.91a6 6 0 0 1 7.287-8.259c.438.12.54.662.218.984z" />
</IconBase>
);
}
/** Other/unknown Apps: a simple ellipsis, distinct from network nodes. */
export function CircleEllipsisIcon(props: IconProps) {
return (
<IconBase {...props}>
<circle cx="12" cy="12" r="10" />
<path d="M8 12h.01M12 12h.01M16 12h.01" />
</IconBase>
);
}
export function UpdateIcon(props: IconProps) {
return (
<IconBase {...props}>
@@ -4,6 +4,7 @@ import { DEMO_ROUTES, ROUTE_BY_NAME } from '../app/routes';
import type { AppRoute } from '../app/routes';
import { luciAdminUrl } from '../utils/luci';
import {
AppsIcon,
CloseIcon,
ClockIcon,
DevicesIcon,
@@ -47,6 +48,7 @@ const NAVIGATION_GROUPS: readonly {
{ label: 'Overview', routes: ['home', 'activity'] },
{ label: 'Network', routes: ['network', 'wifi', 'iptv', 'devices'] },
{ label: 'Security', routes: ['safeshield', 'rules'] },
{ label: 'Explore', routes: ['apps'] },
{ label: 'System', routes: ['account', 'system', 'settings'] },
];
@@ -70,6 +72,8 @@ function NavigationIcon({ route }: { route: AppRoute }) {
return <UserIcon class="size-5" />;
case 'account':
return <KeyIcon class="size-5" />;
case 'apps':
return <AppsIcon class="size-5" />;
case 'system':
return <UpdateIcon class="size-5" />;
case 'settings':
@@ -85,14 +89,25 @@ function themeActionLabel(theme: 'dark' | 'light'): string {
return theme === 'dark' ? '라이트 모드' : '다크 모드';
}
function navigationClass(active: boolean, collapsed = false): string {
return `relative flex min-h-11 min-w-0 items-center rounded-xl py-2.5 text-sm font-extrabold no-underline transition ${
collapsed ? 'justify-center px-2' : 'gap-3 px-3'
} ${
active
? 'bg-teal-50 text-teal-800 ring-1 ring-inset ring-teal-200'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-950'
}`;
function navigationClass(active: boolean, collapsed = false, routeName?: AppRoute): string {
const baseSpacing = collapsed ? 'justify-center px-2' : 'gap-3 px-3';
if (active) {
return `relative flex min-h-11 min-w-0 items-center rounded-xl py-2.5 text-sm font-extrabold no-underline transition ${baseSpacing} bg-teal-50 text-teal-800 ring-1 ring-inset ring-teal-200`;
}
if (routeName === 'apps') {
return `ssh-apps-nav-link relative flex min-h-11 min-w-0 items-center rounded-xl py-2.5 text-sm font-extrabold no-underline transition ${baseSpacing}`;
}
return `relative flex min-h-11 min-w-0 items-center rounded-xl py-2.5 text-sm font-extrabold text-slate-600 no-underline transition ${baseSpacing} hover:bg-slate-50 hover:text-slate-950`;
}
function navigationDescription(routeName: AppRoute): string | null {
if (routeName === 'apps') {
return '앱으로 확장하기';
}
return null;
}
function NavigationItems({
@@ -133,7 +148,7 @@ function NavigationItems({
aria-label={routeName === 'account' && accountRegistered === false
? `${item.label} (계정 연결 필요)`
: collapsed ? item.label : undefined}
class={navigationClass(active, collapsed)}
class={navigationClass(active, collapsed, routeName)}
href={item.hash}
key={routeName}
title={routeName === 'account' && accountRegistered === false
@@ -149,7 +164,14 @@ function NavigationItems({
</span>
{collapsed ? null : (
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="truncate">{item.label}</span>
<span class="min-w-0 flex-1">
<span class="block truncate">{item.label}</span>
{navigationDescription(routeName) ? (
<span class="ssh-apps-nav-description mt-0.5 block truncate text-[11px] font-semibold tracking-normal">
{navigationDescription(routeName)}
</span>
) : null}
</span>
{routeName === 'iptv' ? (
<span class="rounded-full border border-amber-300 bg-amber-100 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-[0.08em] text-amber-800">
Beta
@@ -10,10 +10,100 @@ import type { LanSettings } from '../types/lan';
import type { SafeShieldStatistics, SafeShieldStatus } from '../types/safeshield';
import type { SmartSafeHubStatus } from '../types/status';
import type { SoftwareUpdateStatus } from '../types/updates';
import type { DeviceRegistrationStatus } from '../api/smartsafehub';
import type { AppsCatalog, AppsInstallStatus, AppsMetrics } from '../types/apps';
const now = Math.floor(Date.now() / 1000);
const hour = 3600;
// Demo-only account status: no user, device credential, pairing token or live Hub session.
export const demoAccount: DeviceRegistrationStatus = {
schema: 1,
component: 'device',
phase: 'registered',
lastResult: 'success',
lastErrorCode: null,
accountRegistered: true,
plan: 'free',
pairingCode: null,
pairingExpiresAt: null,
nextSyncAt: 0,
lastSuccessAt: now - 300,
};
// Demo-only catalog. No APK URLs or authenticated API responses are used.
export const demoApps: AppsCatalog = {
schema: 1,
deviceCode: 'iptime-ax3000sm',
firmwareBuildId: 'demo-build',
channel: 'stable',
apps: [
{
id: 'retro-arcade',
name: '레트로 아케이드',
summary: '브라우저에서 레트로 게임을 즐기는 엔터테인먼트 앱입니다.',
description: '사용자가 직접 준비한 게임 파일로 즐기는 레트로 아케이드입니다.',
category: 'entertainment',
version: '1.1.0-r5',
webUi: { type: 'static', path: '/retro-arcade/' },
},
{
id: 'network-usage-monitor',
name: '네트워크 사용량 모니터',
summary: '기기별 네트워크 사용량과 통계를 확인하는 앱입니다.',
description: '네트워크 트래픽을 모니터링합니다.',
category: 'network',
version: '2025.06.02~29236be6-r1',
webUi: null,
},
{
id: 'wake-on-lan',
name: 'Wake-on-LAN',
summary: '공유기에서 PC와 홈서버의 전원을 켤 수 있는 도구입니다.',
description: 'Wake-on-LAN 지원 컴퓨터를 네트워크에서 깨우는 앱입니다.',
category: 'utilities',
version: '26.288.56989~75a306d',
webUi: null, // Public demo must never link to an actual LuCI control page.
},
],
};
export const demoAppsStatus: AppsInstallStatus = {
job: { phase: 'idle', action: '', appId: '', code: 'NONE' },
installed: {
'retro-arcade': {
installed: true,
partial: false,
managed: true,
updateAvailable: true,
installedVersion: '1.0.0-r1',
webUiReady: true,
},
'network-usage-monitor': {
installed: true,
partial: false,
managed: true,
updateAvailable: false,
installedVersion: '2025.06.02~29236be6-r1',
webUiReady: false,
},
'wake-on-lan': {
installed: false,
partial: false,
managed: false,
updateAvailable: false,
installedVersion: null,
webUiReady: false,
},
},
};
export const demoAppsMetrics: AppsMetrics = {
storage: { mount: '/overlay', totalBytes: 256 * 1024 * 1024, availableBytes: 185 * 1024 * 1024 },
// Installed APK metadata; neither app cache nor runtime data is included.
installedSizes: { 'retro-arcade': 1394688, 'network-usage-monitor': 117760 },
};
export const demoDevices: ConnectedDevicesSummary = {
generatedAt: now,
devices: [
@@ -37,7 +127,7 @@ export const demoActivity: ActivityHistory = {
export const demoStatus: SmartSafeHubStatus = {
device: { hostname: 'SmartRouter', model: 'ipTIME AX3000SM', boardName: 'iptime,ax3000sm' },
software: { distribution: 'OpenWrt', version: '25.12', revision: 'demo', kernel: '6.12' },
runtime: { uptime: 86400, localtime: now, load: [0.08, 0.12, 0.05], memory: { total: 268435456, free: 83886080, shared: 4194304, buffered: 41943040, available: 171966464, cached: 41943040 } },
runtime: { uptime: 86400, localtime: now, load: [0.08, 0.12, 0.05], memory: { total: 268435456, free: 83886080, shared: 4194304, buffered: 41943040, available: 171966464, cached: 41943040 }, storage: demoAppsMetrics.storage },
network: { available: true, up: true, protocol: 'dhcp', ipv4Address: '198.51.100.20' },
activityHistory: demoActivity,
};
@@ -4,6 +4,8 @@ import { useEffect, useState } from 'preact/hooks';
import { AppShell } from '../components/AppShell';
import { DEMO_ROUTES } from '../app/routes';
import { ActivityPage } from '../pages/ActivityPage';
import { AppsPage } from '../pages/AppsPage';
import { SmartSafeHubAccountPage } from '../pages/SmartSafeHubAccountPage';
import { WanPage } from '../pages/WanPage';
import { LanPage } from '../pages/LanPage';
import { WifiPage } from '../pages/WifiPage';
@@ -19,6 +21,7 @@ import {
demoActivity, demoDevices, demoFirmware, demoHealth, demoLan,
demoSafeShield, demoStatistics, demoStatus, demoUpdates,
demoWan, demoWifi, demoRules, demoWifiQr, demoGuestWifiQr,
demoAccount, demoApps, demoAppsStatus, demoAppsMetrics,
} from './data';
import '../styles/app.css';
@@ -36,7 +39,7 @@ function DemoApp() {
setNotice('해당 기능은 현재 데모에서 제공하지 않습니다.');
}
}, [currentRoute]);
const readonlyNotice = () => setNotice('데모에서는 실제 공유기 설정을 변경할 수 없습니다.');
const readonlyNotice = () => setNotice('체험 모드에서는 실제 계정 연결, 앱 설치·제거·실행 및 공유기 설정 변경을 수행하지 않습니다.');
const dismiss = () => setNotice('');
const reset = () => setNotice('예시 데이터가 표시되고 있습니다. 실제 공유기에는 연결되지 않습니다.');
@@ -54,6 +57,20 @@ function DemoApp() {
let content;
switch (route) {
case 'account':
content = <SmartSafeHubAccountPage data={demoAccount} error={null} loading={false}
pairingBusy={false} refreshing={false} syncError={null} demo
onDemoAction={readonlyNotice} onRequestPairing={async () => { readonlyNotice(); return null; }}
onRetry={reset} />;
break;
case 'apps':
content = <AppsPage data={demoApps} error={null} loading={false} refreshing={false}
installStatus={demoAppsStatus} metrics={demoAppsMetrics} statusError={null}
demo onDemoAction={readonlyNotice} onRefreshStatus={reset} onRetry={reset}
onInstall={async () => { readonlyNotice(); }}
onRemove={async () => { readonlyNotice(); }}
onUpdate={async () => { readonlyNotice(); }} />;
break;
case 'activity':
content = <ActivityPage actionError={null} actionMessage={null} data={demoActivity}
error={null} loading={false} onDismissActionFeedback={dismiss} onRetry={reset}
@@ -109,7 +126,7 @@ function DemoApp() {
default:
content = (
<HomePage
accountRegistered={false} activity={demoActivity} activityError={null} activityLoading={false}
accountRegistered={demoAccount.accountRegistered} activity={demoActivity} activityError={null} activityLoading={false}
data={demoStatus} devices={demoDevices} devicesError={null} devicesLoading={false}
error={null} firmware={demoFirmware} firmwareError={null} firmwareLoading={false}
health={demoHealth} healthError={null} healthLoading={false}
@@ -122,7 +139,7 @@ function DemoApp() {
}
return (
<AppShell accountRegistered={false} demo loading={false} onRefresh={reset} refreshing={false} route={route} updateCount={0}>
<AppShell accountRegistered={demoAccount.accountRegistered} demo loading={false} onRefresh={reset} refreshing={false} route={route} updateCount={0}>
<div role="status" class="mb-5 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-teal-200 bg-teal-50 px-4 py-3 text-sm font-semibold text-teal-800">
<span>체험 모드 · 표시되는 기기와 보호 통계는 모두 예시입니다.</span>
<a class="font-extrabold underline" href="/devices/">지원 기기 살펴보기 →</a>
@@ -12,7 +12,7 @@ export interface AsyncResourceState<T> {
interface AsyncResourceOptions<T> {
active?: boolean;
fallbackError: string;
loader: () => Promise<T>;
loader: (signal?: AbortSignal) => Promise<T>;
pollInterval?: number | ((data: T | null) => number | null);
refreshOnFocus?: boolean;
refreshOnVisible?: boolean;
@@ -37,6 +37,8 @@ export function useAsyncResource<T>({
const requested = useRef(false);
const mounted = useRef(true);
const inFlight = useRef<Promise<void> | null>(null);
const controller = useRef<AbortController | null>(null);
const generation = useRef(0);
const lastAttemptAt = useRef<number | null>(null);
const lastSuccessAt = useRef<number | null>(null);
const failureCount = useRef(0);
@@ -48,6 +50,8 @@ export function useAsyncResource<T>({
return () => {
mounted.current = false;
generation.current += 1;
controller.current?.abort();
};
}, []);
@@ -67,10 +71,14 @@ export function useAsyncResource<T>({
}
let request: Promise<void>;
const sequence = ++generation.current;
const activeController = new AbortController();
controller.current = activeController;
lastAttemptAt.current = Date.now();
request = loader()
request = loader(activeController.signal)
.then((data) => {
if (sequence !== generation.current || activeController.signal.aborted) return;
lastSuccessAt.current = Date.now();
failureCount.current = 0;
if (mounted.current) {
@@ -78,6 +86,7 @@ export function useAsyncResource<T>({
}
})
.catch((error: unknown) => {
if (sequence !== generation.current || activeController.signal.aborted) return;
failureCount.current += 1;
if (mounted.current) {
setState((current) => ({
@@ -89,9 +98,8 @@ export function useAsyncResource<T>({
}
})
.finally(() => {
if (inFlight.current === request) {
inFlight.current = null;
}
if (inFlight.current === request) inFlight.current = null;
if (controller.current === activeController) controller.current = null;
});
inFlight.current = request;
@@ -103,6 +111,10 @@ export function useAsyncResource<T>({
useEffect(() => {
if (!active) {
requested.current = false;
generation.current += 1;
controller.current?.abort();
controller.current = null;
inFlight.current = null;
return;
}
@@ -157,7 +157,8 @@ export function useDeviceRegistration(enabled: boolean) {
useEffect(() => () => { mounted.current = false; }, []);
const shouldPoll = useMemo(() => pairingStillValid(data), [data]);
const shouldPoll = useMemo(() => pairingStillValid(data) ||
data?.pairingOperation?.phase === 'starting' || data?.pairingOperation?.phase === 'running', [data]);
useEffect(() => {
if (!enabled || !shouldPoll) {
@@ -168,10 +169,10 @@ export function useDeviceRegistration(enabled: boolean) {
let timer = 0;
const poll = async () => {
await refresh();
if (active) {
timer = window.setTimeout(() => void poll(), PAIRING_POLL_INTERVAL_MS);
}
const generation = ++requestGeneration.current;
const status = await fetchDeviceRegistrationStatus().catch(() => null);
if (active && status) commitStatus(status, generation);
if (active) timer = window.setTimeout(() => void poll(), PAIRING_POLL_INTERVAL_MS);
};
timer = window.setTimeout(() => void poll(), PAIRING_POLL_INTERVAL_MS);
@@ -179,7 +180,7 @@ export function useDeviceRegistration(enabled: boolean) {
active = false;
window.clearTimeout(timer);
};
}, [enabled, refresh, shouldPoll]);
}, [enabled, commitStatus, shouldPoll]);
return {
data,
@@ -16,6 +16,7 @@ const HASH_ROUTES: Readonly<Record<string, AppRoute>> = {
'#safeshield': 'safeshield',
'#rules': 'rules',
'#account': 'account',
'#apps': 'apps',
'#system': 'system',
'#settings': 'settings',
'#settings/diagnostics': 'settings',
+1 -1
View File
@@ -44,7 +44,7 @@ function installBootstrap(sessionId: string, host: HTMLElement): void {
sessionId,
rpcUrl: luciUrl('/admin/ubus'),
assetBase: host.dataset.assetBase ?? '/luci-static/smartsafehub/',
assetVersion: host.dataset.assetVersion ?? '0.2.30-r1',
assetVersion: host.dataset.assetVersion ?? '0.2.31-r1',
locale: document.documentElement.lang || 'ko',
});
}
@@ -0,0 +1,436 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { AppsIcon, ReloadIcon } from '../components/Icons';
import { AppsStorageCard } from '../components/AppsStorageCard';
import { AppsCategoryIcon, appsCategoryOf } from '../components/AppsCategoryIcon';
import type { AppsCategory } from '../components/AppsCategoryIcon';
import { SectionHeading } from '../components/SectionHeading';
import type { AppsCatalog, AppsInstallStatus, AppsMetrics, VerifiedApp } from '../types/apps';
import { errorMessage } from '../utils/errors';
import { approvedAppPath } from '../utils/appsWebUi';
import { formatStorageBytes } from '../utils/appsStorage';
import { appsJobNoticeKey, readDismissedAppsJob, saveDismissedAppsJob } from '../utils/appsJobNotice';
interface AppsPageProps {
data: AppsCatalog | null;
error: string | null;
loading: boolean;
refreshing: boolean;
onRetry: () => void;
installStatus: AppsInstallStatus | null;
metrics: AppsMetrics | null;
statusError: string | null;
onRefreshStatus: () => void;
onInstall: (id: string) => Promise<void>;
onRemove: (id: string) => Promise<void>;
onUpdate: (id: string) => Promise<void>;
/** All public demo actions are local-only; no installs or real app launch. */
demo?: boolean;
onDemoAction?: () => void;
}
const OPERATION_MESSAGES: Readonly<Record<string, string>> = {
E21: '서버에서 승인 정보를 확인하지 못했습니다. 인터넷 연결을 확인해 주세요.',
E22: '이 펌웨어에서 승인되지 않은 앱입니다.',
E24: '공유기의 패키지 아키텍처와 앱이 일치하지 않습니다.',
E25: '배포 저장소가 승인 당시와 달라졌거나 접근할 수 없습니다. 관리자 확인 후 다시 시도해 주세요.',
E26: '앱 설치에 필요한 메모리 또는 저장 공간이 부족합니다.',
E27: '승인된 패키지 정보가 올바르지 않습니다.',
E28: '이전에 설치한 앱의 구성이 현재 승인 정보와 다릅니다.',
E29: '동일한 패키지가 이미 다른 방법으로 설치되어 있습니다.',
E31: '앱 파일을 다운로드하지 못했습니다.',
E32: '다운로드된 앱 파일이 올바르지 않습니다.',
E33: '앱 파일의 검증 값이 일치하지 않습니다.',
E34: '패키지 설치 사전 확인에 실패했습니다.',
E35: '패키지를 설치하지 못했습니다.',
E37: '공식 OpenWrt LuCI 저장소가 승인 당시와 다릅니다. 관리자에게 확인해 주세요.',
E38: '기존 공유 라이브러리의 버전이 승인된 버전과 달라 설치할 수 없습니다.',
E51: 'SmartSafeHub Apps에서 관리하는 앱만 업데이트할 수 있습니다.',
E52: '현재 설치 버전과 승인된 업데이트가 일치하지 않습니다. 앱 구성과 업데이트 버전을 확인해 주세요.',
E53: '다른 시스템 패키지 변경이 필요하여 앱 업데이트를 중단했습니다.',
E54: '앱 업데이트에 실패했습니다. 일부 패키지가 변경되었을 수 있으니 관리자에게 확인해 주세요.',
E55: '업데이트 후 설치 기록을 저장하지 못했습니다. 관리자에게 확인해 주세요.',
E41: 'SmartSafeHub Apps에서 설치한 앱만 여기서 제거할 수 있습니다.',
E44: '기존 시스템에 영향을 줄 수 있어 앱 제거가 차단됐습니다.',
E45: '앱 제거에 실패했습니다.',
};
const TABS: readonly { id: AppsCategory; label: string }[] = [
{ id: 'all', label: '전체' },
{ id: 'network', label: '네트워크' },
{ id: 'security', label: '보안' },
{ id: 'smart_home', label: '스마트홈' },
{ id: 'entertainment', label: '엔터테인먼트' },
{ id: 'utilities', label: '도구' },
{ id: 'other', label: '기타' },
];
type AppAction = 'install' | 'update' | 'remove';
function OperationIndicator({ action }: { action: AppAction }) {
const removing = action === 'remove';
return (
<span aria-live="polite" class={`inline-flex items-center gap-1.5 text-xs font-bold ${removing ? 'text-red-700' : 'text-teal-700'}`} role="status">
<svg aria-hidden="true" class="size-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" opacity="0.25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
</svg>
{removing ? '제거 중…' : action === 'update' ? '업데이트 중…' : '설치 중…'}
</span>
);
}
export function AppsPage({ data, error, loading, refreshing, onRetry, installStatus, metrics, statusError,
onRefreshStatus, onInstall, onUpdate, onRemove, demo = false, onDemoAction }: AppsPageProps) {
const [category, setCategory] = useState<AppsCategory>('all');
const [query, setQuery] = useState('');
const [pending, setPending] = useState<{ appId: string; action: AppAction } | null>(null);
const [removeTarget, setRemoveTarget] = useState<VerifiedApp | null>(null);
const cancelRemoveRef = useRef<HTMLButtonElement>(null);
const removeTriggerRef = useRef<HTMLButtonElement | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [dismissedJob, setDismissedJob] = useState<string | null>(readDismissedAppsJob);
// Keep the last successful catalog while a refresh fails. A separate status
// request may describe a different catalog generation, so a missing ID
// alone is NOT proof that an app has been discontinued.
const currentData = data;
const apps = currentData?.apps ?? [];
const orphans = currentData && !error && !loading && !refreshing && !statusError
? Object.entries(installStatus?.installed ?? {}).filter(([id, state]) =>
state.retired === true && state.managed && !apps.some((app) => app.id === id))
: [];
const restore = installStatus?.restore;
const job = installStatus?.job;
const running = job?.phase === 'starting' || job?.phase === 'running';
const restoring = restore?.phase === 'starting' || restore?.phase === 'running';
const jobKey = appsJobNoticeKey(job);
const dismissJob = (key: string) => {
setDismissedJob(key);
saveDismissedAppsJob(key);
};
const showJob = job && job.phase !== 'idle' && jobKey !== dismissedJob;
useEffect(() => {
if (job?.phase !== 'success' || !showJob) return;
const timeout = window.setTimeout(() => dismissJob(jobKey), 7000);
return () => window.clearTimeout(timeout);
}, [jobKey, job?.phase, showJob]);
const jobAction: AppAction | null = job?.action === 'install' || job?.action === 'update' || job?.action === 'remove' ? job.action : null;
const filtered = useMemo(() => apps.filter((app) =>
(category === 'all' || appsCategoryOf(app.category) === category) &&
`${app.name} ${app.summary}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()),
), [apps, category, query]);
useEffect(() => {
if (!removeTarget) return;
cancelRemoveRef.current?.focus();
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
setRemoveTarget(null);
removeTriggerRef.current?.focus();
}
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [removeTarget]);
const closeRemoveDialog = () => {
setRemoveTarget(null);
removeTriggerRef.current?.focus();
};
const openRemoveDialog = (app: VerifiedApp, trigger: HTMLButtonElement) => {
if (!installStatus || statusError || pending !== null || running || restoring) return;
removeTriggerRef.current = trigger;
setRemoveTarget(app);
};
const requestOperation = async (app: VerifiedApp, action: AppAction) => {
if (!installStatus || statusError || pending !== null || running || restoring) return;
if (demo) {
if (action === 'remove') closeRemoveDialog();
onDemoAction?.();
return;
}
if (action === 'update' && !window.confirm(`${app.name} 앱을 최신 승인 버전으로 업데이트하시겠습니까?\n\n업데이트 중 공유기를 재부팅하지 마세요.`)) return;
if (action === 'install' && !window.confirm(`${app.name} 앱을 설치하시겠습니까?\n\n앱을 변경하는 동안 공유기를 재부팅하지 마세요.`)) return;
if (action === 'remove') closeRemoveDialog();
setActionError(null);
setPending({ appId: app.id, action });
try {
if (action === 'install') await onInstall(app.id);
else if (action === 'update') await onUpdate(app.id);
else await onRemove(app.id);
onRefreshStatus();
} catch (cause: unknown) {
setActionError(errorMessage(cause, `${app.name} 앱 작업을 시작하지 못했습니다.`));
} finally {
setPending(null);
}
};
return (
<section class="min-w-0 space-y-6">
{removeTarget ? (
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-4"
onClick={(event) => { if (event.target === event.currentTarget) closeRemoveDialog(); }}
role="presentation"
>
<div
aria-describedby="apps-remove-description"
aria-labelledby="apps-remove-title"
aria-modal="true"
class="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-5 shadow-xl"
onKeyDown={(event) => {
if (event.key !== 'Tab') return;
// Keep keyboard focus inside the confirmation dialog.
if (event.shiftKey && event.target === cancelRemoveRef.current) {
event.preventDefault();
const confirmButton = event.currentTarget.querySelector<HTMLButtonElement>('[data-confirm-remove]');
confirmButton?.focus();
} else if (!event.shiftKey && event.target instanceof HTMLButtonElement && event.target.hasAttribute('data-confirm-remove')) {
event.preventDefault();
cancelRemoveRef.current?.focus();
}
}}
role="dialog"
>
<h2 class="m-0 text-lg font-extrabold text-slate-950" id="apps-remove-title">앱 제거 확인</h2>
<p class="mt-3 break-words text-sm text-slate-700" id="apps-remove-description">
<strong>{removeTarget.name}</strong> 앱을 이 공유기에서 제거하시겠습니까?
</p>
<p class="mt-2 text-xs leading-5 text-slate-600">
앱에서 제공하던 기능을 더 이상 사용할 수 없습니다. 제거하는 동안 공유기를 재부팅하지 마세요.
</p>
<div class="mt-5 flex justify-end gap-2">
<button
class="min-h-10 rounded-lg border border-slate-200 bg-white px-4 text-sm font-bold text-slate-700 transition hover:bg-slate-50"
onClick={closeRemoveDialog}
ref={cancelRemoveRef}
type="button"
>취소</button>
<button
class="min-h-10 rounded-lg bg-red-700 px-4 text-sm font-bold text-white transition hover:bg-red-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-100 disabled:opacity-50"
data-confirm-remove
disabled={!installStatus || Boolean(statusError) || pending !== null || running || restoring}
onClick={() => void requestOperation(removeTarget, 'remove')}
type="button"
>앱 제거</button>
</div>
</div>
</div>
) : null}
<div class="min-w-0">
<SectionHeading
eyebrow="SmartSafeHub Apps"
title="Apps"
description="SmartSafeHub에서 직접 검증한 앱으로 공유기의 기능을 확장하세요."
/>
<div class="grid min-w-0 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,18.5rem)]">
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-slate-900/5 sm:p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-3">
<span class="grid size-12 shrink-0 place-items-center rounded-2xl bg-teal-50 text-teal-700">
<AppsIcon class="size-6" />
</span>
<div class="min-w-0">
<h3 class="m-0 text-base font-black text-slate-950">검증된 앱</h3>
<p class="mt-1 mb-0 text-xs leading-5 text-slate-500">현재 펌웨어에서 확인된 앱만 표시합니다.</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full border border-teal-200 bg-teal-50 px-3 py-1 text-xs font-bold text-teal-800">
{currentData ? `${apps.length}개` : '확인 전'}
</span>
{error && currentData ? (
<span class="rounded-full border border-amber-200 bg-amber-50 px-2 py-1 text-[11px] font-semibold text-amber-800" title={error} role="status">이전 목록</span>
) : null}
<button
class="inline-flex min-h-10 items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 text-xs font-bold text-slate-700 transition hover:border-teal-300 hover:text-teal-700 disabled:opacity-50"
disabled={loading || refreshing}
onClick={onRetry}
type="button"
>
<ReloadIcon class={`size-4 ${refreshing ? 'animate-spin' : ''}`} />
{refreshing ? '확인 중' : '목록 새로고침'}
</button>
</div>
</div>
<p class="mt-3 mb-0 text-xs leading-5 text-slate-500">
검증된 APK를 설치·업데이트·제거할 수 있습니다. 작업 중에는 다른 앱 작업을 시작할 수 없습니다.
</p>
</div>
<AppsStorageCard storage={metrics?.storage} />
</div>
</div>
{restore && (restore.phase === 'starting' || restore.phase === 'running' || restore.phase === 'failed') ? (
<div aria-live="polite" class={`rounded-2xl border p-4 text-sm ${restore.phase === 'failed' ? 'border-amber-300 bg-amber-50 text-amber-900' : 'border-teal-200 bg-teal-50 text-teal-900'}`} role="status">
<strong>{restore.phase === 'failed' ? 'Apps 자동 복원을 완료하지 못했습니다.' : '펌웨어 업데이트 후 Apps를 복원하는 중입니다.'}</strong>
{restore.appId ? <span> · {restore.appId}</span> : null}
{restore.phase === 'failed' ? <p class="mb-0 mt-1 text-xs">{OPERATION_MESSAGES[restore.code] ?? '승인·연결·설치 상태를 확인해 주세요.'} 필요한 경우 앱 카드에서 설치 마무리를 진행할 수 있습니다.</p> : <p class="mb-0 mt-1 text-xs">검증된 패키지만 다시 설치합니다. 완료될 때까지 앱 설치·제거를 시작하지 마세요.</p>}
</div>
) : null}
{showJob && job ? (
<div aria-live="polite" class={`rounded-2xl border p-4 text-sm ${job.phase === 'failed' ? 'border-rose-200 bg-rose-50 text-rose-800' : running && job.action === 'remove' ? 'border-red-200 bg-red-50 text-red-800' : 'border-teal-200 bg-teal-50 text-teal-900'}`} role="status">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
{running ? (job.action === 'remove' ? '앱 제거 중' : job.action === 'update' ? '앱 업데이트 중' : '앱 설치 중') : job.phase === 'success' ? '마지막 앱 작업 완료' : '마지막 앱 작업 실패'}
{job.appId ? ` · ${job.appId}` : ''}
{job.code && job.code !== 'NONE' ? ` · 오류 코드 ${job.code}` : ''}
{job.phase === 'failed' && job.code && OPERATION_MESSAGES[job.code]
? <p class="mb-0 mt-1 text-xs">{OPERATION_MESSAGES[job.code]}</p> : null}
{running ? <p class="mb-0 mt-1 text-xs">{job.action === 'remove' ? '앱 제거와 패키지 정리가 진행 중입니다.' : job.action === 'update' ? '승인된 버전으로 앱을 업데이트하고 있습니다.' : '설치 확인과 패키지 처리가 진행 중입니다.'} 화면을 닫아도 작업은 계속됩니다.</p> : null}
</div>
{!running ? <button aria-label="앱 작업 알림 닫기" class="shrink-0 rounded-lg px-2 py-1 text-xs font-bold hover:bg-black/5 focus-visible:outline-none focus-visible:ring-2" onClick={() => dismissJob(jobKey)} type="button">닫기 ×</button> : null}
</div>
</div>
) : null}
{actionError ? <p class="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800" role="alert">{actionError}</p> : null}
{statusError ? <div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900" role="alert">설치 상태를 확인하지 못했습니다. 설치 및 제거 버튼이 비활성화됩니다. <button class="underline" onClick={onRefreshStatus} type="button">다시 확인</button></div> : null}
{error && !currentData ? (
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-5" role="alert">
<p class="m-0 text-sm font-bold text-rose-800">앱 목록을 불러오지 못했습니다.</p>
<p class="mt-2 mb-0 text-sm text-rose-700">{error}</p>
<button class="mt-3 rounded-lg border border-rose-300 px-3 py-2 text-xs font-bold text-rose-800" onClick={onRetry} type="button">다시 시도</button>
</div>
) : null}
{loading && !currentData ? (
<div aria-live="polite" class="rounded-2xl border border-slate-200 bg-white p-8 text-center text-sm text-slate-500" role="status">검증된 앱 목록을 확인하는 중입니다.</div>
) : null}
{currentData ? (
<>
{apps.length > 0 ? (
<div class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2" role="group" aria-label="앱 카테고리">
{TABS.map((tab) => (
<button
class={`inline-flex min-h-10 items-center gap-1.5 rounded-xl border px-3 text-xs font-bold ${category === tab.id ? 'border-teal-400 bg-teal-50 text-teal-800' : 'border-slate-200 bg-white text-slate-600'}`}
key={tab.id}
onClick={() => setCategory(tab.id)}
type="button"
><AppsCategoryIcon category={tab.id} class="size-4 shrink-0" />{tab.label}</button>
))}
</div>
<input
aria-label="앱 검색"
class="min-h-10 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-900 outline-none focus:border-teal-500 sm:w-52"
onInput={(event) => setQuery(event.currentTarget.value)}
placeholder="앱 검색"
type="search"
value={query}
/>
</div>
<div class="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((app) => {
const state = installStatus?.installed[app.id];
const installed = state?.installed ?? false;
const bytes = metrics?.installedSizes?.[app.id];
const sizeLabel = installed && typeof bytes === 'number' && Number.isSafeInteger(bytes) && bytes >= 0
? formatStorageBytes(bytes) : null;
const managed = state?.managed ?? false;
const updateAvailable = Boolean(installed && managed && state?.updateAvailable);
const installedVersion = installed ? state?.installedVersion : null;
const showOfferedVersion = Boolean(installed && installedVersion && (updateAvailable || installedVersion !== app.version));
const activeAction = pending?.appId === app.id ? pending.action : running && job?.appId === app.id ? jobAction : null;
const disabled = !installStatus || Boolean(statusError) || pending !== null || running || restoring;
const removable = managed && (installed || Boolean(state?.partial));
const launchPath = installed && state?.webUiReady ? approvedAppPath(app.webUi) : null;
return (
<article class="flex min-w-0 flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm" key={app.id}>
<div class="flex min-w-0 items-start gap-3">
<span class="grid size-14 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-600"><AppsCategoryIcon category={appsCategoryOf(app.category)} class="size-7" /></span>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 flex-wrap items-start justify-between gap-x-3 gap-y-2">
<h3 class="m-0 min-w-0 flex-1 break-words text-sm font-black text-slate-950">{app.name}</h3>
{installed && managed ? <span class="ssh-apps-installed-badge shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-bold">✓ 설치됨</span> : null}
</div>
<div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="text-xs text-slate-500" title={installed ? '공유기에 설치된 대표 APK 패키지 버전입니다.' : '현재 제공 중인 승인 버전입니다.'}>
{installed ? installedVersion ? `설치 v${installedVersion}` : '설치 버전 확인 불가' : `v${app.version}`}
</span>
{updateAvailable ? <span class="ssh-apps-update-badge rounded-full border px-2 py-0.5 text-[11px] font-bold">업데이트 가능</span> : null}
</div>
{showOfferedVersion || sizeLabel !== null ? (
<p class="mb-0 mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-slate-500">
{showOfferedVersion ? (
<span title="현재 서버가 승인한 앱 릴리스 버전입니다. 설치된 APK 패키지의 버전과 다를 수 있습니다.">제공 v{app.version}</span>
) : null}
{showOfferedVersion && sizeLabel !== null ? <span aria-hidden="true">·</span> : null}
{sizeLabel !== null ? (
<span title="설치된 APK 패키지의 용량 합계입니다. 앱 데이터 및 캐시는 포함되지 않습니다.">설치 용량 약 {sizeLabel}</span>
) : null}
</p>
) : null}
</div>
</div>
<p class="mt-4 mb-4 min-h-10 break-words text-sm leading-5 text-slate-600">{app.summary}</p>
<div class="mt-auto flex flex-wrap items-center justify-between gap-2 border-t border-slate-100 pt-3">
<span class="text-xs font-bold text-teal-700">✓ 검증된 앱</span>
{activeAction ? <OperationIndicator action={activeAction} /> : (
<span class="flex flex-wrap items-center gap-2">
{managed && state?.partial && !installed ? (
<button class="min-h-9 rounded-lg border border-teal-300 bg-white px-3 text-xs font-bold text-teal-800 hover:bg-teal-50 disabled:opacity-50" disabled={disabled} onClick={() => void requestOperation(app, 'install')} type="button">설치 마무리</button>
) : null}
{updateAvailable ? <button class="ssh-apps-update-button min-h-9 rounded-lg border px-3 text-xs font-bold transition disabled:opacity-50" disabled={disabled} onClick={() => void requestOperation(app, 'update')} type="button">업데이트</button> : null}
{launchPath ? (demo ? (
<button aria-label={`${app.name} 데모 실행 안내`} class="inline-flex min-h-9 items-center rounded-lg border border-teal-700 bg-teal-700 px-3 text-xs font-bold text-white transition hover:bg-teal-800" onClick={onDemoAction} type="button">열기 ↗</button>
) : (
<a aria-label={`${app.name} 새 탭에서 열기`} class="inline-flex min-h-9 items-center rounded-lg border border-teal-700 bg-teal-700 px-3 text-xs font-bold text-white transition hover:bg-teal-800" href={launchPath} rel="noopener noreferrer" target="_blank">열기 ↗</a>
)) : installed && app.webUi ? (
<span class="text-xs text-amber-700" title="이 앱의 웹 UI 패키지를 설치해야 열 수 있습니다.">웹 UI 미설치</span>
) : null}
{installed && !managed ? <span class="text-xs text-slate-500" title="공유기 소프트웨어 메뉴 또는 SSH로 설치한 앱은 Apps에서 제거하지 않습니다.">기존에 설치됨</span>
: state?.partial && !managed ? <span class="text-xs text-amber-700">일부 패키지 설치됨</span>
: <button
class={`min-h-9 rounded-lg border bg-white px-3 text-xs font-bold transition focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 ${removable ? 'border-red-300 text-red-700 hover:bg-red-50 focus-visible:ring-red-100' : 'border-teal-300 text-teal-800 hover:bg-teal-50 focus-visible:ring-teal-200'}`}
disabled={disabled || (Boolean(state?.partial) && !managed)}
onClick={(event) => removable ? openRemoveDialog(app, event.currentTarget) : void requestOperation(app, 'install')}
type="button"
>{removable ? '제거' : managed ? '재설치' : '설치'}</button>}
</span>
)}
</div>
</article>
);
})}
</div>
{filtered.length === 0 ? <p class="rounded-xl bg-slate-100 p-5 text-center text-sm text-slate-600">검색 조건에 맞는 앱이 없습니다.</p> : null}
</div>
) : (
<div class="rounded-2xl border border-dashed border-slate-300 bg-white px-5 py-12 text-center">
<AppsIcon class="mx-auto size-10 text-slate-400" />
<h3 class="mt-4 mb-2 text-base font-black text-slate-900">아직 등록된 앱이 없습니다.</h3>
<p class="m-0 text-sm leading-6 text-slate-500">현재 기기와 펌웨어에 대해 승인된 앱이 준비되면 이곳에서 확인할 수 있습니다.</p>
</div>
)}
<p class="text-xs text-slate-400">기기 {currentData.deviceCode} · 펌웨어 {currentData.firmwareBuildId} · {currentData.channel} 채널</p>
</>
) : null}
{orphans.length > 0 ? (
<div class="rounded-2xl border border-amber-200 bg-amber-50 p-5">
<h3 class="m-0 text-sm font-black text-amber-900">제공이 중단된 앱</h3>
<p class="mb-3 mt-1 text-xs text-amber-800">현재 승인 목록에서 제외되었지만 기기에 남아 있는 앱입니다. 설치는 불가능하며 제거할 수 있습니다.</p>
<div class="space-y-2">
{orphans.map(([id]) => {
const activeAction = pending?.appId === id ? pending.action : running && job?.appId === id ? jobAction : null;
return (
<div class="flex flex-wrap items-center justify-between gap-3" key={id}>
<span class="break-all text-sm text-amber-900">{id}</span>
{activeAction ? <OperationIndicator action={activeAction} /> : <button
class="min-h-9 rounded-lg border border-red-300 bg-white px-3 text-xs font-bold text-red-700 transition hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-100 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!installStatus || running || restoring || pending !== null || Boolean(statusError)}
onClick={(event) => openRemoveDialog({ id, name: id, summary: '', description: '', category: '', version: '' }, event.currentTarget)}
type="button"
>제거</button>}
</div>
);
})}
</div>
</div>
) : null}
</section>
);
}
@@ -7,6 +7,7 @@ import {
CheckCircleIcon,
ClockIcon,
DevicesIcon,
DatabaseIcon,
GlobeIcon,
MemoryIcon,
RouterIcon,
@@ -26,6 +27,7 @@ import type { SafeShieldStatistics, SafeShieldStatus } from '../types/safeshield
import type { SmartSafeHubStatus } from '../types/status';
import type { SoftwareUpdateStatus } from '../types/updates';
import { isSoftwareUpdateCheckStale } from '../utils/softwareUpdates';
import { summarizeAppsStorage } from '../utils/appsStorage';
import {
formatBootTime,
formatBytes,
@@ -37,6 +39,9 @@ import {
getMemoryUsage,
} from '../app/format';
// Keep the dashboard ring geometry identical to AppsStorageCard (96px SVG, r=39).
const DISK_RING_CIRCUMFERENCE = 2 * Math.PI * 39;
interface HomePageProps {
accountRegistered: boolean | null;
activity: ActivityHistory | null;
@@ -514,6 +519,8 @@ export function HomePage({
}
const memoryPercent = memory ? Math.round(memory.percent) : 0;
const disk = summarizeAppsStorage(data.runtime.storage);
const diskAvailableArc = disk ? (disk.availableRatio * DISK_RING_CIRCUMFERENCE).toFixed(2) : '0';
const safeShieldSummary = safeShieldOverview(
safeshield,
safeshieldLoading,
@@ -857,7 +864,7 @@ export function HomePage({
<section aria-labelledby="dashboard-system-title">
<SectionHeading
description="메모리, 시스템 부하와 장치 정보를 빠르게 점검합니다."
description="메모리, 디스크, 시스템 부하와 장치 정보를 빠르게 점검합니다."
eyebrow="System"
id="dashboard-system-title"
title="시스템 상태"
@@ -885,57 +892,97 @@ export function HomePage({
</span>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-3">
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="mt-5 grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="flex min-w-0 flex-col rounded-xl border border-slate-200 bg-slate-50 p-3 sm:p-4">
<div class="flex items-center gap-2 text-slate-500">
<MemoryIcon class="size-4" />
<span class="text-xs font-extrabold">메모리</span>
</div>
<p class="mt-2 mb-0 text-xl font-black text-slate-950">{memoryPercent}%</p>
<p class="mt-1 mb-0 text-xs font-semibold text-slate-500">
{memory ? formatBytes(memory.used) : '-'} /{' '}
{formatBytes(data.runtime.memory.total)}
</p>
<div
aria-label={`메모리 사용률 ${memoryPercent}%`}
class="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-200"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={memoryPercent}
>
<div class="flex min-h-0 flex-1 flex-col justify-center py-1">
<p class="mt-2 mb-0 text-xl font-black text-slate-950">{memoryPercent}%</p>
<p class="mt-1 mb-0 text-xs font-semibold text-slate-500">
{memory ? formatBytes(memory.used) : '-'} /{' '}
{formatBytes(data.runtime.memory.total)}
</p>
<div
class={`h-full rounded-full transition-[width] duration-300 ${
memoryPercent >= 90 ? 'bg-amber-500' : 'bg-teal-600'
}`}
style={{ width: `${Math.min(100, memoryPercent)}%` }}
/>
aria-label={`메모리 사용률 ${memoryPercent}%`}
class="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-200"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={memoryPercent}
>
<div
class={`h-full rounded-full transition-[width] duration-300 ${
memoryPercent >= 90 ? 'bg-amber-500' : 'bg-teal-600'
}`}
style={{ width: `${Math.min(100, memoryPercent)}%` }}
/>
</div>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="ssh-dashboard-storage-card flex min-w-0 flex-col rounded-xl border border-slate-200 bg-slate-50 p-3 sm:p-4" data-level={disk?.level ?? 'unavailable'}>
<div class="flex items-center gap-2 text-slate-500">
<DatabaseIcon class="size-4" />
<span class="text-xs font-extrabold">디스크</span>
</div>
<div
class="mt-1 flex justify-center"
role="img"
aria-label={disk
? `디스크 여유 공간 ${disk.availablePercent}%, ${disk.availableText} 남음, 사용 ${disk.usedText} / 전체 ${disk.totalText}`
: '디스크 저장 공간 확인 불가'}
>
<div class="ssh-apps-storage-ring relative grid size-[76px] shrink-0 place-items-center" data-level={disk?.level ?? 'unavailable'}>
<svg aria-hidden="true" class="size-[76px]" viewBox="0 0 96 96">
<circle class="ssh-apps-storage-track" cx="48" cy="48" r="39" />
{disk && disk.availableRatio > 0 ? (
<circle class="ssh-apps-storage-free" cx="48" cy="48" r="39"
stroke-dasharray={`${diskAvailableArc} ${DISK_RING_CIRCUMFERENCE.toFixed(2)}`} />
) : null}
</svg>
<div class="absolute inset-0 flex items-center justify-center text-center">
<strong class="text-base leading-none font-black text-slate-950">{disk ? `${disk.availablePercent}%` : '—'}</strong>
</div>
</div>
</div>
<p class="ssh-apps-storage-available mt-1 mb-0 text-center text-xs font-extrabold text-teal-700">
{disk ? `${disk.availableText} 남음` : '저장 공간 확인 불가'}
</p>
{disk ? (
<p class="mt-1 mb-0 text-center text-[11px] leading-4 font-semibold text-slate-500" title="앱 설치 가능 공간을 기준으로 계산하며 파일시스템 예약 공간이 사용량에 포함될 수 있습니다.">
사용 {disk.usedText} / 전체 {disk.totalText}
</p>
) : null}
</div>
<div class="flex min-w-0 flex-col rounded-xl border border-slate-200 bg-slate-50 p-3 sm:p-4">
<div class="flex items-center gap-2 text-slate-500">
<RouterIcon class="size-4" />
<span class="text-xs font-extrabold">시스템 부하</span>
</div>
<p class="mt-2 mb-0 text-xl font-black text-slate-950">
{formatLoadAverage(data.runtime.load[0])}
</p>
<p class="mt-1 mb-0 text-xs font-semibold text-slate-500">
5분 {formatLoadAverage(data.runtime.load[1])} · 15분{' '}
{formatLoadAverage(data.runtime.load[2])}
</p>
<div class="flex min-h-0 flex-1 flex-col justify-center py-1">
<p class="mt-2 mb-0 text-xl font-black text-slate-950">
{formatLoadAverage(data.runtime.load[0])}
</p>
<p class="mt-1 mb-0 text-xs font-semibold text-slate-500">
5분 {formatLoadAverage(data.runtime.load[1])} · 15분{' '}
{formatLoadAverage(data.runtime.load[2])}
</p>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex min-w-0 flex-col rounded-xl border border-slate-200 bg-slate-50 p-3 sm:p-4">
<div class="flex items-center gap-2 text-slate-500">
<ClockIcon class="size-4" />
<span class="text-xs font-extrabold">실행 시간</span>
</div>
<p class="mt-2 mb-0 text-xl font-black text-slate-950">
{formatUptime(data.runtime.uptime)}
</p>
<p class="mt-1 mb-0 text-xs font-semibold text-slate-500">
부팅 {formatBootTime(data.runtime.localtime, data.runtime.uptime)}
</p>
<div class="flex min-h-0 flex-1 flex-col justify-center py-1">
<p class="mt-2 mb-0 text-xl font-black text-slate-950">
{formatUptime(data.runtime.uptime)}
</p>
<p class="mt-1 mb-0 text-xs font-semibold text-slate-500">
부팅 {formatBootTime(data.runtime.localtime, data.runtime.uptime)}
</p>
</div>
</div>
</div>
@@ -1,5 +1,6 @@
import { SectionHeading } from '../components/SectionHeading';
import type { TargetedEvent, TargetedSubmitEvent } from 'preact';
import { memo } from 'preact/compat';
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { RelativeTimestamp } from '../components/RelativeTimestamp';
@@ -16,6 +17,12 @@ import {
import { ErrorPanel, LoadingPanel } from '../components/StatePanels';
import type { SafeShieldRulesAction } from '../hooks/useSafeShieldRules';
import type { RecommendedRuleAssignment, RecommendedRulesStatus, SafeShieldRuleAction, SafeShieldRules } from '../types/rules';
import { filterRecommendedAssignments, filterRuleGroups, sameRecommendationContent } from '../utils/ruleLists';
// Stable fallbacks keep memoized rule cards from rebuilding while the first
// recommendation status request is still pending.
const EMPTY_RECOMMENDED_DOMAINS: string[] = [];
const EMPTY_RECOMMENDED_RULES: RecommendedRuleAssignment[] = [];
interface SafeShieldRulesPageProps {
action: SafeShieldRulesAction | null;
@@ -53,7 +60,7 @@ interface RuleListCardProps {
domain: string,
) => Promise<boolean>;
rules: string[];
recommendedDomains: string[];
recommendedDomainSet: ReadonlySet<string>;
recommendedKnown: boolean;
recommendedRules: RecommendedRuleAssignment[];
onRequestRevoke?: (rule: RecommendedRuleAssignment) => void;
@@ -81,13 +88,13 @@ function isValidDomain(value: string): boolean {
);
}
function RuleListCard({
const RuleListCard = memo(function RuleListCard({
action,
kind,
onAddRule,
onDeleteRule,
rules,
recommendedDomains,
recommendedDomainSet,
recommendedKnown,
recommendedRules,
onRequestRevoke,
@@ -102,22 +109,18 @@ function RuleListCard({
const [recommendedExpanded, setRecommendedExpanded] = useState<boolean | null>(null);
const isAllow = kind === 'allow';
const busy = action !== null;
const filteredRules = useMemo(() => {
const query = search.trim().toLowerCase();
return query ? rules.filter((domain) => domain.includes(query)) : rules;
}, [rules, search]);
const searching = search.trim().length > 0;
// Reconciler-owned domains remain separate from user-managed manual entries.
const managedRules = isAllow && recommendedKnown
? filteredRules.filter((domain) => recommendedDomains.includes(domain))
: [];
const manualRules = isAllow && recommendedKnown
? filteredRules.filter((domain) => !recommendedDomains.includes(domain))
: filteredRules;
const visibleRecommendedRules = isAllow ? recommendedRules.filter((rule) =>
!searching || rule.title.toLowerCase().includes(search.trim().toLowerCase()) ||
rule.domains.some((domain) => domain.includes(search.trim().toLowerCase()))
) : [];
const query = useMemo(() => search.trim().toLowerCase(), [search]);
const searching = query.length > 0;
// Filter and partition once per actual list/search/ownership change. Set
// membership avoids a quadratic scan across the recommended domains.
const { filteredRules, manualRules, managedRules } = useMemo(
() => filterRuleGroups(rules, query, isAllow && recommendedKnown, recommendedDomainSet),
[rules, query, isAllow, recommendedKnown, recommendedDomainSet],
);
const visibleRecommendedRules = useMemo(
() => isAllow ? filterRecommendedAssignments(recommendedRules, query) : [],
[isAllow, recommendedRules, query],
);
const showRecommended = searching || (recommendedExpanded ?? (recommendedRules.length || managedRules.length) <= 3);
const showManual = searching || manualExpanded;
@@ -156,7 +159,7 @@ function RuleListCard({
function renderRule(domain: string) {
// A manual entry that happens to match a cloud recommendation must
// remain deletable. Only entries actually owned by the reconciler lock.
const cloudManaged = isAllow && recommendedKnown && recommendedDomains.includes(domain);
const cloudManaged = isAllow && recommendedKnown && recommendedDomainSet.has(domain);
const ruleOrigin = !isAllow || recommendedKnown
? (cloudManaged ? '추천 규칙' : '직접 추가')
: null;
@@ -319,7 +322,7 @@ function RuleListCard({
</div>
</article>
);
}
});
export function SafeShieldRulesPage({
action,
@@ -344,30 +347,54 @@ export function SafeShieldRulesPage({
const [revokeConfirmationDelayed, setRevokeConfirmationDelayed] = useState(false);
const pendingRevokeId = useRef<number | null>(null);
const previousStatusSignature = useRef<string | null>(null);
const lastConfirmedSignature = useRef<string | null>(null);
const onRetryRef = useRef(onRetry);
onRetryRef.current = onRetry;
const [syncMessage, setSyncMessage] = useState<string | null>(null);
const [syncError, setSyncError] = useState<string | null>(null);
const recommendedDomains = recommendedStatus?.domains ?? [];
const recommendedRules = recommendedStatus?.rules ?? [];
const recommendedDomains = recommendedStatus?.domains ?? EMPTY_RECOMMENDED_DOMAINS;
const recommendedRules = recommendedStatus?.rules ?? EMPTY_RECOMMENDED_RULES;
const recommendedDomainSet = useMemo(() => new Set(recommendedDomains), [recommendedDomains]);
// A revoke can reach Hub even if rpcd times out while SafeShield is applying
// the new rules. Only a confirmed local read can settle its final outcome.
// The local SafeShield list also needs a refresh when a background poll
// observes a new recommendation revision, without reloading the whole page.
const acceptRecommendedStatus = useCallback((status: RecommendedRulesStatus): void => {
setRecommendedStatus(status);
const acceptRecommendedStatus = useCallback((status: RecommendedRulesStatus): boolean => {
setRecommendedStatus((previous) => {
if (!previous) return status;
// Poll responses are newly deserialized objects. Reuse unchanged list
// references so both memoized cards skip work on progress-only updates.
const { domains, rules } = sameRecommendationContent(previous, status);
return { ...status, domains, rules };
});
setRecommendedKnown(true);
const signature = JSON.stringify([
status.revision, status.domains, status.rules.map((rule) => rule.id),
status.syncStatus,
]);
if (previousStatusSignature.current !== null && previousStatusSignature.current !== signature) {
onRetryRef.current();
// Progress/status/metadata changes must not re-read the whole SafeShield
// rules list. After a completed reconcile, reload only once per actual
// recommendation revision/ownership change. The first pending -> confirmed
// transition also refreshes a list fetched while SafeShield was applying.
const signature = JSON.stringify([status.revision, status.domains]);
let reloaded = false;
if (status.syncStatus === 'confirmed' && lastConfirmedSignature.current !== signature) {
if (previousStatusSignature.current !== null) {
onRetryRef.current();
reloaded = true;
}
lastConfirmedSignature.current = signature;
}
previousStatusSignature.current = signature;
if (status.syncStatus === 'confirmed' && pendingRevokeId.current === null) {
// A worker failure is not a successful revoke. Keep the previous rule
// visible and let the user retry instead of waiting indefinitely.
if (pendingRevokeId.current !== null && status.revokeOperation?.phase === 'failed' &&
status.revokeOperation.argument === String(pendingRevokeId.current)) {
pendingRevokeId.current = null;
setRevokeConfirming(false);
setRevokeConfirmationDelayed(false);
setSyncError('추천 규칙 적용 해제를 완료하지 못했습니다. 연결 상태를 확인하고 다시 시도해 주세요.');
}
if (status.syncStatus === 'confirmed' && pendingRevokeId.current === null &&
status.revokeOperation?.phase !== 'failed') {
setSyncError(null);
}
if (
@@ -381,6 +408,7 @@ export function SafeShieldRulesPage({
setSyncError(null);
setSyncMessage('추천 규칙 적용 해제를 확인했습니다.');
}
return reloaded;
}, []);
useEffect(() => {
@@ -415,7 +443,7 @@ export function SafeShieldRulesPage({
window.removeEventListener('focus', onFocus);
document.removeEventListener('visibilitychange', onVisible);
};
}, [data?.allow.join('\n'), onLoadRecommendedRulesStatus, syncing, revokeBusy, acceptRecommendedStatus]);
}, [onLoadRecommendedRulesStatus, syncing, revokeBusy, acceptRecommendedStatus]);
// An RPC timeout cannot prove whether Hub accepted the removal. After a
// minute, explain the delay but continue watching for eventual confirmation.
@@ -432,11 +460,13 @@ export function SafeShieldRulesPage({
setSyncMessage(null);
try {
const status = await onSyncRecommendedRules();
acceptRecommendedStatus(status);
const reloaded = acceptRecommendedStatus(status);
setSyncMessage(status.syncStatus === 'confirmed'
? '서버에서 규칙을 가져와 SafeShield 허용 목록을 확인했습니다.'
: '추천 규칙 상태를 확인 중입니다. 진행 결과는 자동으로 갱신됩니다.');
onRetry(); // Reload the actual SafeShield allow list after reconciliation.
// The user explicitly requested synchronization, so allow one manual
// refresh after confirmation but never queue a duplicate full RPC.
if (status.syncStatus === 'confirmed' && !reloaded) onRetry();
} catch {
setSyncError('추천 규칙을 동기화하지 못했습니다. 서버 연결 및 기기 등록 상태를 확인해 주세요.');
} finally {
@@ -460,7 +490,8 @@ export function SafeShieldRulesPage({
if (pendingRevokeId.current !== null) {
setSyncMessage('적용 해제 요청을 처리했습니다. 공유기의 허용 목록을 확인하고 있습니다.');
}
onRetryRef.current();
// A pending revoke is not yet a changed SafeShield rule list. The
// confirmed status poll refreshes once the local apply is complete.
} catch {
// Hub may have removed the assignment before the local SafeShield apply
// timed out. Keep checking instead of permanently displaying a failure.
@@ -677,7 +708,7 @@ export function SafeShieldRulesPage({
onAddRule={onAddRule}
onDeleteRule={onDeleteRule}
rules={data.allow}
recommendedDomains={recommendedDomains}
recommendedDomainSet={recommendedDomainSet}
recommendedKnown={recommendedKnown}
recommendedRules={recommendedRules}
{...(onRevokeRecommendedRule ? { onRequestRevoke: setRevokeTarget } : {})}
@@ -689,7 +720,7 @@ export function SafeShieldRulesPage({
onAddRule={onAddRule}
onDeleteRule={onDeleteRule}
rules={data.block}
recommendedDomains={recommendedDomains}
recommendedDomainSet={recommendedDomainSet}
recommendedKnown={recommendedKnown}
recommendedRules={recommendedRules}
revokeBusy={revokeBusy}
@@ -80,6 +80,9 @@ interface SmartSafeHubAccountPageProps {
syncError: string | null;
onRequestPairing: () => Promise<DeviceRegistrationStatus | null>;
onRetry: () => void;
/** Public router demo never navigates to a real device account console. */
demo?: boolean;
onDemoAction?: () => void;
}
export function SmartSafeHubAccountPage({
@@ -91,6 +94,8 @@ export function SmartSafeHubAccountPage({
syncError,
onRequestPairing,
onRetry,
demo = false,
onDemoAction,
}: SmartSafeHubAccountPageProps) {
const [pairingError, setPairingError] = useState<string | null>(null);
const [pairingCopied, setPairingCopied] = useState(false);
@@ -109,6 +114,8 @@ export function SmartSafeHubAccountPage({
const currentPlan = planLabel(status?.plan ?? null);
const expired = status ? pairingExpired(status) : false;
const pairingCode = !connected && !expired ? status?.pairingCode : null;
const issuingCode = pairingBusy || status?.pairingOperation?.phase === 'starting' ||
status?.pairingOperation?.phase === 'running';
async function requestPairing(): Promise<void> {
setPairingError(null);
@@ -154,14 +161,20 @@ export function SmartSafeHubAccountPage({
<p class="mt-1 mb-0 text-sm leading-6 text-slate-600">이 기기가 계정과 정상적으로 연결되어 있습니다. 연결 코드는 더 이상 필요하지 않습니다.</p>
</div>
</div>
<a
class="mt-5 inline-flex min-h-11 items-center justify-center rounded-xl border border-emerald-700 bg-emerald-700 px-4 py-2 text-sm font-extrabold text-white no-underline transition hover:bg-emerald-800 focus:outline-none focus-visible:ring-4 focus-visible:ring-emerald-100"
href={DEVICE_CONSOLE_URL}
rel="noopener noreferrer"
target="_blank"
>
웹사이트에서 기기 관리
</a>
{demo ? (
<button
class="mt-5 inline-flex min-h-11 items-center justify-center rounded-xl border border-emerald-700 bg-emerald-700 px-4 py-2 text-sm font-extrabold text-white transition hover:bg-emerald-800"
onClick={onDemoAction}
type="button"
>웹사이트에서 기기 관리</button>
) : (
<a
class="mt-5 inline-flex min-h-11 items-center justify-center rounded-xl border border-emerald-700 bg-emerald-700 px-4 py-2 text-sm font-extrabold text-white no-underline transition hover:bg-emerald-800 focus:outline-none focus-visible:ring-4 focus-visible:ring-emerald-100"
href={DEVICE_CONSOLE_URL}
rel="noopener noreferrer"
target="_blank"
>웹사이트에서 기기 관리</a>
)}
<p class="mt-4 mb-0 border-t border-slate-100 pt-4 text-xs font-semibold leading-5 text-slate-500">
웹사이트에서 기기 등록을 해제하면 공유기도 다음 계정 상태 동기화에서 자동으로 연결 해제를 반영합니다.
</p>
@@ -205,13 +218,16 @@ export function SmartSafeHubAccountPage({
<button
class="mt-4 inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-xl border border-teal-700 bg-teal-700 px-4 py-2 text-sm font-black text-white transition hover:bg-teal-800 focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-100 disabled:cursor-not-allowed disabled:opacity-50"
disabled={pairingBusy}
disabled={issuingCode}
onClick={() => void requestPairing()}
type="button"
>
{pairingBusy ? <ReloadIcon class="size-4 animate-spin" /> : null}
{pairingBusy ? '발급 중…' : pairingCode ? '새 코드 발급' : '연결 코드 발급'}
{issuingCode ? <ReloadIcon class="size-4 animate-spin" /> : null}
{issuingCode ? '발급 중…' : pairingCode ? '새 코드 발급' : '연결 코드 발급'}
</button>
{status?.pairingOperation?.phase === 'failed' ? (
<p class="mt-3 mb-0 text-sm font-semibold text-red-700">연결 코드 발급에 실패했습니다. 다시 시도해 주세요.</p>
) : null}
{pairingError ? <p class="mt-3 mb-0 text-sm font-semibold text-red-700">{pairingError}</p> : null}
</div>
)}
@@ -259,6 +259,45 @@
}
/* Apps is an optional expansion area, not the current page by default.
* Avoid translucent *light* teal utilities on dark backgrounds: the 40% white
* mix makes an unselected Apps link look like a grey selected overlay. */
.ssh-apps-nav-link:not([aria-current='page']) {
border: 1px solid #99f6e4;
background-color: #f0fdfa;
color: #115e59;
}
.ssh-apps-nav-link:not([aria-current='page']):hover {
border-color: #5eead4;
background-color: #ccfbf1;
}
.ssh-apps-nav-description {
color: #0f766e;
}
.ssh-app[data-theme='dark'] .ssh-apps-nav-link:not([aria-current='page']) {
border-color: #134e4a;
background-color: rgb(20 184 166 / 0.09);
color: #e2e8f0;
}
.ssh-app[data-theme='dark'] .ssh-apps-nav-link:not([aria-current='page']):hover {
border-color: #0f766e;
background-color: rgb(20 184 166 / 0.16);
color: #5eead4;
}
.ssh-app[data-theme='dark'] .ssh-apps-nav-description {
color: #5eead4;
}
.ssh-apps-nav-link:focus-visible {
outline: 2px solid #0d9488;
outline-offset: 2px;
}
.ssh-sidebar-toggle {
border: 1px solid #e2e8f0;
background: #ffffff;
@@ -606,6 +645,7 @@
.ssh-app[data-theme='dark'] [class~='text-teal-700'],
.ssh-app[data-theme='dark'] [class~='text-teal-800'],
.ssh-app[data-theme='dark'] [class~='text-teal-900'],
.ssh-app[data-theme='dark'] [class~='text-teal-950'] {
color: #5eead4;
}
@@ -757,6 +797,11 @@
background-color: #1e293b;
}
/* Apps removal stays understated at rest; dark mode uses a muted red hover. */
.ssh-app[data-theme='dark'] .hover\:bg-red-50:hover {
background-color: rgb(239 68 68 / 0.16);
}
.ssh-app[data-theme='dark'] .hover\:bg-rose-50:hover {
background-color: rgb(244 63 94 / 0.18);
}
@@ -2275,3 +2320,71 @@
background-color: #fbbf24;
--ssh-account-dot-ring: #0f172a;
}
/* Apps storage: a small SVG donut, without Chart.js or expensive redraws. */
.ssh-apps-storage-track,
.ssh-apps-storage-free {
fill: none;
stroke-width: 9;
}
.ssh-apps-storage-track { stroke: #e2e8f0; }
.ssh-apps-storage-free {
stroke: #0d9488;
transform: rotate(-90deg);
transform-origin: 50% 50%;
}
.ssh-apps-storage-ring[data-level='warning'] .ssh-apps-storage-free { stroke: #d97706; }
.ssh-apps-storage-ring[data-level='critical'] .ssh-apps-storage-free { stroke: #e11d48; }
.ssh-app[data-theme='dark'] .ssh-apps-storage-track { stroke: #334155; }
.ssh-app[data-theme='dark'] .ssh-apps-storage-free { stroke: #2dd4bf; }
.ssh-app[data-theme='dark'] .ssh-apps-storage-ring[data-level='warning'] .ssh-apps-storage-free { stroke: #fbbf24; }
.ssh-app[data-theme='dark'] .ssh-apps-storage-ring[data-level='critical'] .ssh-apps-storage-free { stroke: #fb7185; }
/* Identical low-space warnings in Apps and Dashboard, including the capacity label. */
.ssh-apps-storage-card[data-level='warning'] .ssh-apps-storage-available,
.ssh-dashboard-storage-card[data-level='warning'] .ssh-apps-storage-available { color: #b45309; }
.ssh-apps-storage-card[data-level='critical'] .ssh-apps-storage-available,
.ssh-dashboard-storage-card[data-level='critical'] .ssh-apps-storage-available { color: #be123c; }
.ssh-app[data-theme='dark'] .ssh-apps-storage-card[data-level='warning'] .ssh-apps-storage-available,
.ssh-app[data-theme='dark'] .ssh-dashboard-storage-card[data-level='warning'] .ssh-apps-storage-available { color: #fbbf24; }
.ssh-app[data-theme='dark'] .ssh-apps-storage-card[data-level='critical'] .ssh-apps-storage-available,
.ssh-app[data-theme='dark'] .ssh-dashboard-storage-card[data-level='critical'] .ssh-apps-storage-available { color: #fb7185; }
/* Apps cards: installed is a quiet state, updates are an amber action. */
.ssh-apps-installed-badge {
border-color: #cbd5e1;
background: #f1f5f9;
color: #475569;
}
.ssh-apps-update-badge {
border-color: #fcd34d;
background: #fffbeb;
color: #92400e;
}
.ssh-apps-update-button {
border-color: #d97706;
background: #d97706;
color: #fff;
}
.ssh-apps-update-button:hover:not(:disabled) { background: #b45309; border-color: #b45309; }
.ssh-apps-update-button:focus-visible { outline: 2px solid #d97706; outline-offset: 2px; }
.ssh-app[data-theme='dark'] .ssh-apps-installed-badge {
border-color: #475569;
background: #1e293b;
color: #cbd5e1;
}
.ssh-app[data-theme='dark'] .ssh-apps-update-badge {
border-color: #92400e;
background: #451a03;
color: #fcd34d;
}
.ssh-app[data-theme='dark'] .ssh-apps-update-button {
border-color: #b45309;
background: #b45309;
color: #fff;
}
.ssh-app[data-theme='dark'] .ssh-apps-update-button:hover:not(:disabled) {
background: #d97706;
border-color: #d97706;
}
@@ -0,0 +1,74 @@
/** Read-only SmartSafeHub Apps catalog schema 1 from the local router. */
export interface VerifiedApp {
id: string;
name: string;
summary: string;
description: string;
category: string;
version: string;
/** Approved same-origin LuCI page or first-party static app; never a remote URL. */
webUi?: { type: 'luci' | 'static'; path: string } | null;
}
export interface AppsCatalog {
schema: 1;
deviceCode: string;
firmwareBuildId: string;
channel: string;
apps: VerifiedApp[];
/** False until the first immutable, firmware-matched snapshot is available. */
ready?: boolean;
refreshing?: boolean;
refreshFailed?: boolean;
}
/** Writable filesystem used for installing APKs (not RAM or squashfs). */
export interface AppsStorage {
mount: '/overlay' | '/';
totalBytes: number;
availableBytes: number;
}
/** Optional metrics, independent from installation and removal state. */
export interface AppsMetrics {
storage: AppsStorage | null;
installedSizes: Record<string, number>;
ready?: boolean;
refreshing?: boolean;
}
/** Local APK adapter state, independent from the future IoT / AI runtimes. */
export interface AppsInstallStatus {
job: {
phase: 'idle' | 'starting' | 'running' | 'success' | 'failed';
action: 'install' | 'update' | 'remove' | '';
appId: string;
code: string;
operationId?: string;
};
restore?: {
phase: 'idle' | 'starting' | 'running' | 'success' | 'failed';
appId: string;
code: string;
};
ready?: boolean;
refreshing?: boolean;
refreshFailed?: boolean;
installed: Record<string, {
installed: boolean;
partial: boolean;
managed: boolean;
/** Verified by a local Apps-owned manifest, not inferred by comparing asynchronous RPC replies. */
retired?: boolean;
updateAvailable?: boolean;
/** Actual installed version of the first approved APK; not the catalog version. */
installedVersion?: string | null;
webUiReady?: boolean;
}>;
}
export interface AppsOperationAccepted {
accepted: boolean;
appId: string;
action: 'install' | 'update' | 'remove';
}
@@ -57,4 +57,6 @@ export interface RecommendedRulesStatus {
lastDeviceSyncAt: number;
nextDeviceSyncAt: number;
accountRegistered: boolean;
operation?: { phase: string; code: string; updatedAt: number };
revokeOperation?: { phase: string; code: string; argument: string; updatedAt: number };
}
@@ -1,4 +1,5 @@
import type { ActivityHistory } from './activity';
import type { AppsStorage } from './apps';
export interface DeviceStatus {
hostname: string;
model: string;
@@ -26,6 +27,8 @@ export interface RuntimeStatus {
localtime: number;
load: [number, number, number];
memory: RuntimeMemory;
/** Writable root, using the same units and definition as Apps. Missing on older firmware. */
storage?: AppsStorage | null;
}
export interface NetworkStatus {
@@ -0,0 +1,32 @@
import type { AppsInstallStatus } from '../types/apps';
const STORAGE_KEY = 'smartsafehub:apps:dismissed-operation:v1';
type AppsJob = AppsInstallStatus['job'];
/** A job ID is preserved through running/success/failure on the router. */
export function appsJobNoticeKey(job: AppsJob | null | undefined): string {
if (!job || job.phase === 'idle') return '';
// Backwards compatibility while router frontend and RPC versions differ.
return job.operationId
? `operation:${job.operationId}`
: `legacy:${job.phase}:${job.action}:${job.appId}:${job.code}`;
}
/** Browser storage is optional (private browsing or hardened browsers). */
export function readDismissedAppsJob(): string | null {
try {
return typeof window === 'undefined' ? null : window.localStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
}
export function saveDismissedAppsJob(key: string): void {
if (!key) return;
try {
if (typeof window !== 'undefined') window.localStorage.setItem(STORAGE_KEY, key);
} catch {
// In-memory dismissal remains usable if storage is blocked.
}
}
@@ -0,0 +1,37 @@
import type { AppsStorage } from '../types/apps';
export interface StorageSummary {
availablePercent: number;
availableRatio: number;
availableText: string;
usedText: string;
totalText: string;
level: 'normal' | 'warning' | 'critical';
}
/** Base-2 units match the BusyBox df -h figures used on OpenWrt. */
export function formatStorageBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '—';
if (bytes < 1024) return `${Math.round(bytes)} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / (1024 ** 2)).toFixed(1)} MB`;
return `${(bytes / (1024 ** 3)).toFixed(1)} GB`;
}
/** Reserved blocks count as unavailable, not as space for APK installation. */
export function summarizeAppsStorage(storage: AppsStorage | null | undefined): StorageSummary | null {
if (!storage || !Number.isSafeInteger(storage.totalBytes) ||
!Number.isSafeInteger(storage.availableBytes) || storage.totalBytes <= 0 ||
storage.availableBytes < 0 || storage.availableBytes > storage.totalBytes ||
(storage.mount !== '/overlay' && storage.mount !== '/')) return null;
const availableRatio = storage.availableBytes / storage.totalBytes;
return {
availablePercent: Math.round(availableRatio * 100),
availableRatio,
availableText: formatStorageBytes(storage.availableBytes),
// Includes filesystem-reserved space, not only files written by apps.
usedText: formatStorageBytes(storage.totalBytes - storage.availableBytes),
totalText: formatStorageBytes(storage.totalBytes),
level: availableRatio <= 0.1 ? 'critical' : availableRatio <= 0.2 ? 'warning' : 'normal',
};
}
@@ -0,0 +1,7 @@
/** Only approved same-origin LuCI pages or the first-party Retro Arcade entry point. */
export function approvedAppPath(webUi: { type: string; path: string } | null | undefined): string | null {
if (!webUi || typeof webUi.path !== 'string' || webUi.path.length > 160) return null;
if (webUi.type === 'static') return webUi.path === '/retro-arcade/' ? webUi.path : null;
if (webUi.type !== 'luci') return null;
return /^\/cgi-bin\/luci\/admin\/[a-z0-9_-]+(?:\/[a-z0-9_-]+)*$/.test(webUi.path) ? webUi.path : null;
}
@@ -1,7 +1,5 @@
import { RpcError } from '../api/rpc';
export function errorMessage(error: unknown, fallback: string): string {
return error instanceof RpcError || error instanceof Error
return error instanceof Error
? error.message
: fallback;
}
@@ -0,0 +1,56 @@
import type { RecommendedRuleAssignment, RecommendedRulesStatus } from '../types/rules';
export function filterRuleGroups(
rules: readonly string[],
query: string,
splitOwnership: boolean,
recommendedDomainSet: ReadonlySet<string>,
): { filteredRules: readonly string[]; manualRules: readonly string[]; managedRules: readonly string[] } {
const filteredRules = query ? rules.filter((domain) => domain.includes(query)) : rules;
if (!splitOwnership) return { filteredRules, manualRules: filteredRules, managedRules: [] };
const manualRules: string[] = [];
const managedRules: string[] = [];
for (const domain of filteredRules) {
(recommendedDomainSet.has(domain) ? managedRules : manualRules).push(domain);
}
return { filteredRules, manualRules, managedRules };
}
export function filterRecommendedAssignments(
rules: readonly RecommendedRuleAssignment[],
query: string,
): readonly RecommendedRuleAssignment[] {
if (!query) return rules;
return rules.filter((rule) =>
rule.title.toLowerCase().includes(query) ||
rule.domains.some((domain) => domain.includes(query)),
);
}
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
return left === right || (left.length === right.length && left.every((value, index) => value === right[index]));
}
function sameAssignments(
left: readonly RecommendedRuleAssignment[],
right: readonly RecommendedRuleAssignment[],
): boolean {
return left === right || (left.length === right.length && left.every((rule, index) => {
const other = right[index];
return other !== undefined && rule.id === other.id && rule.title === other.title &&
sameStrings(rule.domains, other.domains);
}));
}
// Preserve only the arrays that feed the expensive rule cards. Other status
// fields (including stage and timestamps) must always be allowed to update.
export function sameRecommendationContent(
previous: RecommendedRulesStatus,
next: RecommendedRulesStatus,
): Pick<RecommendedRulesStatus, 'domains' | 'rules'> {
return {
domains: sameStrings(previous.domains, next.domains) ? previous.domains : next.domains,
rules: sameAssignments(previous.rules, next.rules) ? previous.rules : next.rules,
};
}
@@ -0,0 +1,16 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
# Resume a bounded restore after a late WAN reconnection, not on LAN churn.
[ "${ACTION:-}" = ifup ] || exit 0
case "${INTERFACE:-}" in wan|wan6) ;; *) exit 0;; esac
[ -d /etc/smartsafehub/apps-installed ] || exit 0
# A live restore already retries transient connectivity failures.
[ ! -d /tmp/smartsafehub/apps-restore.lock ] || exit 0
[ -r /tmp/smartsafehub/apps-restore.state ] || exit 0
# Never auto-repeat compatibility, integrity, or signing failures.
grep -Eq '^code[[:space:]]+(E21|E25|E31|E35|BUSY)$' \
/tmp/smartsafehub/apps-restore.state || exit 0
# Network retries run a finite worker, never launch another service.
RUNNER=/usr/libexec/smartsafehub-apps-restore
[ -x "$RUNNER" ] || exit 0
"$RUNNER" run </dev/null >/dev/null 2>&1 &
@@ -0,0 +1,2 @@
/etc/smartsafehub/apps-installed/
/etc/smartsafehub/apps-metrics-pending/
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
# Read-only, firmware-pinned SmartSafeHub Apps catalog fetch.
set -eu
RUNTIME_DIR="${SMARTSAFEHUB_APPS_RUNTIME_DIR:-/tmp/smartsafehub}"
ROM_METADATA="${SMARTSAFEHUB_APPS_ROM_METADATA:-/rom/usr/share/smartsafehub/firmware.json}"
FIRMWARE_METADATA="${SMARTSAFEHUB_APPS_METADATA:-/usr/share/smartsafehub/firmware.json}"
JSONFILTER="${SMARTSAFEHUB_APPS_JSONFILTER_BIN:-jsonfilter}"
UCLIENT="${SMARTSAFEHUB_APPS_UCLIENT_BIN:-uclient-fetch}"
CATALOG_URL='https://www.smartsafehub.com/api/v1/app-store/catalog'
json_field() { "$JSONFILTER" -i "$1" -e "$2" 2>/dev/null | head -n 1; }
# The ROM image is authoritative when available. Do not accept a device code,
# build ID or catalog URL from RPC/browser input (or from an untrusted feed).
metadata="$ROM_METADATA"
[ -s "$metadata" ] || metadata="$FIRMWARE_METADATA"
[ -s "$metadata" ] || exit 2
device_code="$(json_field "$metadata" '@.device_code')"
build_id="$(json_field "$metadata" '@.build_id')"
channel="$(json_field "$metadata" '@.channel')"
case "$device_code" in
iptime-ax3000sm|iptime-ax3000se|iptime-a3004t|gl-mt300n-v2|xiaomi-ax3000t) ;;
*) exit 2 ;;
esac
case "$build_id" in
''|[!a-zA-Z0-9]*|*[!a-zA-Z0-9._-]*) exit 2 ;;
esac
[ "${#build_id}" -le 64 ] || exit 2
case "$channel" in stable|beta) ;; *) channel=stable ;; esac
mkdir -p "$RUNTIME_DIR" || exit 1
umask 077
tmp="$(mktemp "$RUNTIME_DIR/apps-catalog.XXXXXXXX")" || exit 1
trap 'rm -f "$tmp"' EXIT HUP INT TERM
url="$CATALOG_URL?schema=1&device_code=$device_code&firmware_build_id=$build_id&channel=$channel"
"$UCLIENT" -q -T 8 -O "$tmp" "$url" >/dev/null 2>&1 || exit 1
bytes="$(wc -c < "$tmp" | tr -d ' ')"
[ "$bytes" -gt 1 ] && [ "$bytes" -le 131072 ] || exit 1
[ "$(json_field "$tmp" '@.schema')" = 1 ] || exit 1
[ "$(json_field "$tmp" '@.device_code')" = "$device_code" ] || exit 1
[ "$(json_field "$tmp" '@.firmware_build_id')" = "$build_id" ] || exit 1
[ "$(json_field "$tmp" '@.channel')" = "$channel" ] || exit 1
# Atomically replace the previous (display-only) snapshot. If the network is
# unavailable, rpcd rejects this invocation; no stale approval is returned.
mv -f "$tmp" "$RUNTIME_DIR/apps-catalog.json"
exit 0
@@ -0,0 +1,776 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
# Privileged, opt-in APK adapter for SmartSafeHub Apps fixed-release protocol.
# No caller-supplied package, URL, shell command or repository is accepted.
set -eu
SELF="${SMARTSAFEHUB_APPS_MANAGER_BIN:-/usr/libexec/smartsafehub-apps-manager}"
CATALOG_HELPER="${SMARTSAFEHUB_APPS_HELPER_BIN:-/usr/libexec/smartsafehub-apps}"
RUNTIME="${SMARTSAFEHUB_APPS_RUNTIME_DIR:-/tmp/smartsafehub}"
OWNED="${SMARTSAFEHUB_APPS_OWNED_DIR:-/etc/smartsafehub/apps-installed}"
# Fixed first-party host. Only the Hub-approved channel/architecture determine
# the repository path; callers cannot submit a repository URL.
REPO_BASE="${SMARTSAFEHUB_APPS_REPO_BASE:-https://repo.smartsafehub.com}"
UCLIENT="${SMARTSAFEHUB_APPS_UCLIENT_BIN:-uclient-fetch}"
OVERLAY="${SMARTSAFEHUB_APPS_OVERLAY_PATH:-/overlay}"
# OpenWrt uses /proc/meminfo; tests on non-Linux hosts supply a fixture.
MEMINFO="${SMARTSAFEHUB_APPS_MEMINFO_PATH:-/proc/meminfo}"
JSONFILTER="${SMARTSAFEHUB_APPS_JSONFILTER_BIN:-jsonfilter}"
APK="${SMARTSAFEHUB_APPS_APK_BIN:-apk}"
ARCH_FILE="${SMARTSAFEHUB_APPS_ARCH_FILE:-/etc/apk/arch}"
SHA256="${SMARTSAFEHUB_APPS_SHA256_BIN:-sha256sum}"
DAEMON="${SMARTSAFEHUB_APPS_DAEMON_BIN:-start-stop-daemon}"
METRICS="${SMARTSAFEHUB_APPS_METRICS_BIN:-/usr/libexec/smartsafehub-apps-metrics}"
LUCI_INDEX_CACHE="${SMARTSAFEHUB_APPS_LUCI_INDEX_CACHE:-/tmp/luci-indexcache}"
LOCK="$RUNTIME/apps-operation.lock"
STATE="$RUNTIME/apps-operation.state"
CATALOG="$RUNTIME/apps-catalog.json"
field() { "$JSONFILTER" -i "$1" -e "$2" 2>/dev/null | head -n 1; }
# A wildcard expression returns multiple app IDs. Scalar fields above must
# remain single-valued, but catalog iteration must not truncate the results.
catalog_ids() { "$JSONFILTER" -i "$CATALOG" -e '@.apps[*].id' 2>/dev/null; }
valid_id() { case "$1" in ''|*[!a-z0-9-]*|-*|*-) return 1;; esac; [ "${#1}" -le 80 ]; }
valid_package() {
case "$1" in ''|*[!a-zA-Z0-9_.+-]*|[-.+]*|*[-.+]) return 1;; esac
[ "${#1}" -le 100 ]
}
valid_version() {
case "$1" in ''|*[!a-zA-Z0-9._+~-]*|[-.+~]*|*[-.+~]) return 1;; esac
[ "${#1}" -le 100 ]
}
valid_sha() {
case "$1" in *[!a-f0-9]*|'') return 1;; esac
[ "${#1}" -eq 64 ]
}
protected_package() {
case "$1" in
base-files|busybox|apk|procd|netifd|dnsmasq*|firewall*|odhcp*|dropbear|ubus*|rpcd*|uhttpd*|safeshield*|luci-app-smartsafehub|kernel|kmod-*) return 0;;
esac
return 1
}
write_state() {
phase="$1"; action="$2"; app="$3"; code="$4"; operation_id="$5"; source="${6:-user}"
tmp="$(mktemp "$RUNTIME/apps-state.XXXXXXXX")" || return 1
printf 'phase\t%s\naction\t%s\napp_id\t%s\ncode\t%s\noperation_id\t%s\nsource\t%s\n' "$phase" "$action" "$app" "$code" "$operation_id" "$source" > "$tmp"
mv -f "$tmp" "$STATE"
}
# Persist *ownership*, not merely an installed flag, so removal never deletes
# unrelated packages installed through LuCI, ssh or another app.
owned_manifest() { printf '%s/%s' "$OWNED" "$1"; }
# Dump only simple validated package pins. Indexed JSON filter expressions are
# created exclusively from a numeric index resolved from the approved catalog.
app_index() {
id="$1"; n=0
for candidate in $(catalog_ids); do
if [ "$candidate" = "$id" ]; then printf '%s\n' "$n"; return 0; fi
n=$((n + 1))
[ "$n" -le 100 ] || break
done
return 1
}
extract_pins() {
index="$1"; out="$2"
: > "$out"; n=0
while :; do
name="$(field "$CATALOG" "@.apps[$index].packages[$n].name")"
[ -n "$name" ] || break
version="$(field "$CATALOG" "@.apps[$index].packages[$n].version")"
hash="$(field "$CATALOG" "@.apps[$index].packages[$n].sha256")"
valid_package "$name" && valid_version "$version" && valid_sha "$hash" || return 1
protected_package "$name" && return 1
grep -Fq "$(printf '%s\t' "$name")" "$out" && return 1
printf '%s\t%s\t%s\n' "$name" "$version" "$hash" >> "$out"
n=$((n + 1))
[ "$n" -le 32 ] || return 1
done
[ "$n" -gt 0 ]
}
# A dry-run may install only explicitly reviewed packages. Never allow any
# upgrades, downgrades, removals or automatic dependency additions. Missing
# prerequisites cause a safe failure.
review_install_plan() {
plan="$1"; allow="$2"
awk -v allowed="$allow" '
BEGIN { split(allowed, names, " "); for (i in names) if (names[i] != "") ok[names[i]]=1 }
{
line=$0
sub(/^\([0-9]+\/[0-9]+\) /, "", line)
if (line ~ /^(Installing|Upgrading|Downgrading|Purging|Removing) /) {
split(line, fields, " ")
if (fields[1] != "Installing" || !(fields[2] in ok) || ++seen[fields[2]] != 1) bad=1
}
}
END {
for (name in ok) if (seen[name] != 1) bad=1
exit bad ? 1 : 0
}
' "$plan"
}
# Only explicitly approved, already-owned APKs may be upgraded. Never let
# apk upgrade the firmware, pull new dependencies, remove packages or downgrade.
review_update_plan() {
plan="$1"; allow="$2"
awk -v allowed="$allow" '
BEGIN { split(allowed, names, " "); for (i in names) if (names[i] != "") ok[names[i]]=1 }
{
line=$0
sub(/^\([0-9]+\/[0-9]+\) /, "", line)
if (line ~ /^(Installing|Upgrading|Downgrading|Purging|Removing|Replacing) /) {
split(line, fields, " ")
if (fields[1] != "Upgrading" || !(fields[2] in ok) || ++seen[fields[2]] != 1) bad=1
}
}
END {
for (name in ok) if (seen[name] != 1) bad=1
exit bad ? 1 : 0
}
' "$plan"
}
# Likewise, removal may touch only package names in the Apps-owned manifest.
# Shared dependencies, manually installed packages and system packages must
# remain untouched even if apk considers them orphaned.
review_remove_plan() {
plan="$1"; allow="$2"
awk -v allowed="$allow" '
BEGIN { split(allowed, names, " "); for (i in names) if (names[i] != "") ok[names[i]]=1 }
{
line=$0
sub(/^\([0-9]+\/[0-9]+\) /, "", line)
if (line ~ /^(Purging|Removing|Upgrading|Downgrading) /) {
split(line, fields, " ")
if ((fields[1] != "Purging" && fields[1] != "Removing") || !(fields[2] in ok)) bad=1
}
}
END { exit bad ? 1 : 0 }
' "$plan"
}
# Preserve recovery of partial operations: an owned manifest may contain a
# not-yet-installed package, but must never adopt a package that was present
# before this transaction. A later retry can finish it safely.
append_owned_pin() {
name="$1"; version="$2"; hash="$3"
manifest="$(owned_manifest "$app")"
mkdir -p "$OWNED" || return 1
if [ -e "$manifest" ] && grep -Fqx "$(printf '%s\t%s\t%s' "$name" "$version" "$hash")" "$manifest"; then
return 0
fi
tmp_manifest="$stage/owned-next"
if [ -e "$manifest" ]; then cp "$manifest" "$tmp_manifest" || return 1; else : > "$tmp_manifest"; fi
printf '%s\t%s\t%s\n' "$name" "$version" "$hash" >> "$tmp_manifest"
cp "$tmp_manifest" "$manifest.new" && mv -f "$manifest.new" "$manifest"
}
installed() { "$APK" info -e "$1" >/dev/null 2>&1; }
# apk info -e verifies only the name. Version checks must use the installed
# database or an old, manually changed APK could be mistaken for our release.
installed_version() {
"$APK" list -I "$1" 2>/dev/null | awk -v prefix="$1-" '
index($1, prefix) == 1 { print substr($1, length(prefix) + 1); exit }
'
}
# Use APK's installed-size metadata, not archive/download size or a slow
# recursive scan of /overlay. apk versions may put the number on the same
# line as the "installed size" heading or on the next line. Do not guess
# missing metadata, and do not accept a size from a different package.
parse_installed_apk_size() {
awk -v name="$1" -v expected="$2" '
function number_of_bytes(value, parts, count, factor, bytes) {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
# Accept the standard "123 KiB" and compact "123KiB" forms.
sub(/(B|KiB|MiB|GiB)$/, " &", value)
count=split(value, parts, /[[:space:]]+/)
if (count != 2 || parts[1] !~ /^[0-9]+([.][0-9]+)?$/) return -1
if (parts[2] == "B") factor=1
else if (parts[2] == "KiB") factor=1024
else if (parts[2] == "MiB") factor=1048576
else if (parts[2] == "GiB") factor=1073741824
else return -1
bytes=parts[1]*factor
if (bytes < 0 || bytes > 1099511627776) return -1
return bytes
}
# apk v3 may print the exact same installed-size record more than once
# (for instance, when package names are returned from multiple indexes).
# Accept only consistent repetitions; never sum duplicate records.
function record_size(value, version, parsed_bytes) {
parsed_bytes=number_of_bytes(value)
# An APK upgrade can leave multiple versions in index-derived info.
# Prefer the exact locally installed release over older/newer entries.
if (expected != "" && version == expected) {
matched=1
if (parsed_bytes < 0 || (selected && parsed_bytes != selected_bytes)) {
selected_invalid=1
return
}
selected_bytes=parsed_bytes
selected=1
return
}
if (parsed_bytes < 0 || (parsed && parsed_bytes != bytes)) {
invalid=1
return
}
bytes=parsed_bytes
parsed=1
}
{
line=$0
sub(/\r$/, "", line)
heading=" installed size:"
marker=index(line, heading)
if (marker > 0 && (substr(line, 1, length(name) + 1) == name "-" ||
substr(line, 1, length(name) + length(heading)) == name heading)) {
if (waiting) {
if (expected != "" && pending_version == expected) selected_invalid=1
else invalid=1
}
seen++
prefix=substr(line, 1, marker-1)
version=prefix == name ? "" : substr(prefix, length(name)+2)
value=substr(line, marker+length(heading))
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
waiting=0
if (value == "") {
waiting=1
pending_version=version
} else record_size(value, version)
next
}
if (waiting) {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", line)
if (line == "") next
record_size(line, pending_version)
waiting=0
}
}
END {
if (waiting) {
if (expected != "" && pending_version == expected) selected_invalid=1
else invalid=1
}
if (matched) {
if (!selected_invalid && selected)
printf "%.0f\n", selected_bytes
else exit 1
} else if (!invalid && seen > 0 && parsed == 1) {
# Keep supporting APK variants without exact version records.
printf "%.0f\n", bytes
} else exit 1
}
'
}
installed_size_bytes() {
[ "$#" -eq 1 ] || return 1
name="$1"
# Match the package release actually installed, not a stale APK index hit.
current_version="$(installed_version "$name" || :)"
valid_version "$current_version" || current_version=''
# Try APK's size-only view first; fall back to its full information view.
# Do not attempt a potentially expensive recursive disk scan.
bytes="$("$APK" info -s "$name" 2>/dev/null | parse_installed_apk_size "$name" "$current_version")" || {
bytes="$("$APK" info "$name" 2>/dev/null | parse_installed_apk_size "$name" "$current_version")" || return 1
}
case "$bytes" in ''|*[!0-9]*) return 1;; esac
printf '%s\n' "$bytes"
}
# Refuse a downgrade, a same-version rebuild with a different digest, any
# package-set change, or an APK that no longer matches our ownership record.
# The approved replacement must be strictly newer according to apk itself.
select_upgrades() {
old_manifest="$1"; approved_pins="$2"; changed_pins="$3"
[ -s "$old_manifest" ] || return 1
: > "$changed_pins"
old_count=0
while IFS="$(printf '\t')" read -r name old_version old_hash; do
valid_package "$name" && valid_version "$old_version" && valid_sha "$old_hash" || return 1
protected_package "$name" && return 1
new_version="$(awk -F '\t' -v name="$name" '$1 == name { print $2; exit }' "$approved_pins")"
new_hash="$(awk -F '\t' -v name="$name" '$1 == name { print $3; exit }' "$approved_pins")"
[ -n "$new_version" ] && valid_sha "$new_hash" || return 1
[ "$(installed_version "$name")" = "$old_version" ] || return 1
if [ "$old_version" = "$new_version" ]; then
[ "$old_hash" = "$new_hash" ] || return 1
else
[ "$("$APK" version -t "$old_version" "$new_version" 2>/dev/null)" = '<' ] || return 1
printf '%s\t%s\t%s\n' "$name" "$new_version" "$new_hash" >> "$changed_pins"
fi
old_count=$((old_count + 1))
[ "$old_count" -le 32 ] || return 1
done < "$old_manifest"
[ "$old_count" -gt 0 ] || return 1
[ "$old_count" -eq "$(wc -l < "$approved_pins" | tr -d ' ')" ] || return 1
[ -s "$changed_pins" ]
}
# Prefer OpenWrt's configured apk architecture. Some package-manager builds
# do not support --print-arch consistently. Never guess based on the CPU type,
# device name, or the channel: the Hub approval must match an exact native arch.
local_package_arch() {
native=''
if [ -r "$ARCH_FILE" ]; then
native="$(awk '$1 ~ /^(aarch64_cortex-a53|mipsel_24kc|x86_64)$/ {print $1; exit}' "$ARCH_FILE" 2>/dev/null)"
fi
if [ -z "$native" ]; then
native="$("$APK" --print-arch 2>/dev/null || :)"
fi
case "$native" in aarch64_cortex-a53|mipsel_24kc|x86_64) printf '%s\n' "$native";; esac
}
# Web UI is never considered ready based only on the daemon package.
# Network usage is explicitly known to require a separate LuCI package.
# Other apps opt in via an approved web_ui.package that is also in their
# approved package list, preventing an unrelated local app from being opened.
web_ui_package() {
id="$1"; idx="$2"
if [ "$id" = network-usage-monitor ]; then
printf '%s\n' luci-app-nlbwmon
return 0
fi
if [ "$id" = wake-on-lan ]; then
# The approved app has exactly one LuCI entry point. Do not mistake the
# backend etherwake package (or a different LuCI page) for that UI.
[ "$(field "$CATALOG" "@.apps[$idx].web_ui.type")" = luci ] || return 1
[ "$(field "$CATALOG" "@.apps[$idx].web_ui.path")" = /cgi-bin/luci/admin/services/wol ] || return 1
[ "$(field "$CATALOG" "@.apps[$idx].web_ui.package")" = luci-app-wol ] || return 1
fi
if [ "$id" = retro-arcade ]; then
[ "$(field "$CATALOG" "@.apps[$idx].web_ui.type")" = static ] || return 1
[ "$(field "$CATALOG" "@.apps[$idx].web_ui.path")" = /retro-arcade/ ] || return 1
[ "$(field "$CATALOG" "@.apps[$idx].web_ui.package")" = smartsafehub-app-retro-arcade ] || return 1
else
[ "$(field "$CATALOG" "@.apps[$idx].web_ui.type")" = luci ] || return 1
fi
name="$(field "$CATALOG" "@.apps[$idx].web_ui.package")"
valid_package "$name" || return 1
p=0
while [ "$p" -le 32 ]; do
candidate="$(field "$CATALOG" "@.apps[$idx].packages[$p].name")"
[ -n "$candidate" ] || break
if [ "$candidate" = "$name" ]; then printf '%s\n' "$name"; return 0; fi
p=$((p + 1))
done
return 1
}
status() {
tmp="$(mktemp "$RUNTIME/apps-status.XXXXXXXX")" || return 1
{
# A disconnected device can still report its local installation state.
if [ ! -r "$CATALOG" ]; then : > "$tmp"; mv -f "$tmp" "$RUNTIME/apps-status.tsv"; return 0; fi
# Snapshot approved IDs once. Both catalog membership and local ownership
# checks below refer to the same on-disk approved generation.
approved_ids="$(catalog_ids)"
n=0
for id in $approved_ids; do
valid_id "$id" || continue
idx="$n"; n=$((n + 1))
[ "$n" -le 100 ] || break
total=0; present=0; p=0; primary_package=''
while :; do
name="$(field "$CATALOG" "@.apps[$idx].packages[$p].name")"
[ -n "$name" ] || break
valid_package "$name" || break
[ -n "$primary_package" ] || primary_package="$name"
total=$((total + 1)); installed "$name" && present=$((present + 1))
p=$((p + 1)); [ "$p" -le 32 ] || break
done
# WOL's public release version belongs to its LuCI APK, not the
# etherwake backend. Avoid showing a false installed/offered mismatch.
if [ "$id" = wake-on-lan ] && [ "$total" -eq 2 ]; then
primary_package=luci-app-wol
fi
managed=0
[ -f "$(owned_manifest "$id")" ] && managed=1
ui_ready=0
ui_package="$(web_ui_package "$id" "$idx" || :)"
if [ -n "$ui_package" ] && installed "$ui_package"; then
if [ "$id" != retro-arcade ] || [ -r "${SMARTSAFEHUB_APPS_WEB_ROOT:-/www}/retro-arcade/index.html" ]; then
ui_ready=1
fi
fi
# Update availability requires a complete Apps-owned install and a newer
# Hub-approved exact package set; it never uses an untrusted URL or version.
update_ready=0
if [ "$managed" -eq 1 ] && [ "$total" -gt 0 ] && [ "$total" -eq "$present" ]; then
status_pins="$(mktemp "$RUNTIME/apps-status-pins.XXXXXXXX")" || return 1
status_changes="$(mktemp "$RUNTIME/apps-status-changes.XXXXXXXX")" || { rm -f "$status_pins"; return 1; }
if extract_pins "$idx" "$status_pins" && select_upgrades "$(owned_manifest "$id")" "$status_pins" "$status_changes"; then
update_ready=1
fi
rm -f "$status_pins" "$status_changes"
fi
# The catalog version is the latest approved release, not the APK
# currently installed. Read the actual version of the first approved
# package as a representative version; never infer it from the catalog.
# Use a fixed APK command and validate the field before putting it in TSV.
installed_release=''
if [ -n "$primary_package" ] && installed "$primary_package"; then
installed_release="$(installed_version "$primary_package" || :)"
valid_version "$installed_release" || installed_release=''
fi
if [ "$total" -gt 0 ] && [ "$total" -eq "$present" ]; then full=1; else full=0; fi
if [ "$present" -gt 0 ]; then partial=1; else partial=0; fi
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$id" "$full" "$partial" "$managed" "$ui_ready" "$update_ready" "$installed_release"
done
# Removed approvals must be proven by a valid Apps-owned manifest, not
# inferred from racing UI/catalog RPC responses. Manual apk add does not
# write an Apps manifest and therefore must never appear as retired.
retired_count=0
for manifest in "$OWNED"/*; do
[ -f "$manifest" ] || continue
id="${manifest##*/}"
valid_id "$id" || continue
printf '%s\n' "$approved_ids" | grep -Fxq "$id" && continue
retired_count=$((retired_count + 1))
[ "$retired_count" -le 100 ] || break
count=0; present=0; valid=1
while IFS="$(printf '\t')" read -r name version hash; do
valid_package "$name" && valid_version "$version" && valid_sha "$hash" || { valid=0; break; }
protected_package "$name" && { valid=0; break; }
count=$((count + 1))
[ "$count" -le 32 ] || { valid=0; break; }
installed "$name" && present=$((present + 1))
done < "$manifest"
[ "$valid" -eq 1 ] && [ "$count" -gt 0 ] && [ "$present" -gt 0 ] || continue
# Eighth column explicitly marks the verified retired ownership record.
printf '%s\t0\t1\t1\t0\t0\t\t1\n' "$id"
done
} > "$tmp"
mv -f "$tmp" "$RUNTIME/apps-status.tsv"
}
# Optional read-only sizes. Consult the *last known installed* status and
# approved catalog. For incomplete metadata, omit the total rather than show
# a misleading partial sum.
sizes() {
tmp="$(mktemp "$RUNTIME/apps-size.XXXXXXXX")" || return 1
{
if [ -r "$CATALOG" ] && [ -r "$RUNTIME/apps-status.tsv" ]; then
for id in $(awk -F '\t' '$2 == "1" {print $1}' "$RUNTIME/apps-status.tsv"); do
valid_id "$id" || continue
idx="$(app_index "$id")" || continue
set --
p=0
while :; do
name="$(field "$CATALOG" "@.apps[$idx].packages[$p].name")"
[ -n "$name" ] || break
valid_package "$name" || break
set -- "$@" "$name"
p=$((p + 1)); [ "$p" -le 32 ] || break
done
[ "$#" -gt 0 ] || continue
complete=1; total_bytes=0
for name in "$@"; do
bytes="$(installed_size_bytes "$name" || :)"
case "$bytes" in
''|*[!0-9]*)
complete=0;;
*)
# Never overflow signed shell arithmetic or report an impossible total.
if [ "${#bytes}" -gt 13 ] || [ "$bytes" -gt 1099511627776 ] ||
[ "$total_bytes" -gt $((1099511627776 - bytes)) ]; then
complete=0
else
total_bytes=$((total_bytes + bytes))
fi;;
esac
done
[ "$complete" -eq 1 ] || continue
printf '%s\t%s\n' "$id" "$total_bytes"
done
fi
} > "$tmp"
mv -f "$tmp" "$RUNTIME/apps-size.tsv"
}
# Release names from the Hub have zero command authority. We cross-check the
# fresh exact-build approval, locally trusted immutable index, architecture and
# resource requirements before invoking the fixed APK binary.
preflight_install() {
"$CATALOG_HELPER" || return 21
index="$(app_index "$app")" || return 22
[ "$(field "$CATALOG" "@.apps[$index].runtime")" = router ] || return 23
[ "$(field "$CATALOG" "@.apps[$index].installer")" = apk ] || return 23
arch="$(field "$CATALOG" "@.apps[$index].package_arch")"
native_arch="$(local_package_arch)"
if [ -z "$arch" ] || [ "$arch" != "$native_arch" ]; then
# E24 is an approval mismatch, not a generic download failure. Keep a
# non-sensitive diagnostic for devices with different apk CLI behaviors.
command -v logger >/dev/null 2>&1 && logger -t smartsafehub-apps "E24: approved=${arch:-missing} local=${native_arch:-unknown}" || :
return 24
fi
approved_hash="$(field "$CATALOG" "@.apps[$index].repository_index_sha256")"
valid_sha "$approved_hash" || return 25
# Build an allowlisted repository URL from immutable local firmware identity
# and the approved catalog response. The Hub pins the exact index digest.
channel="$(field "$CATALOG" '@.channel')"
case "$channel" in stable|beta) ;; *) return 25;; esac
case "$arch" in aarch64_cortex-a53|mipsel_24kc|x86_64) ;; *) return 24;; esac
publication="$(field "$CATALOG" "@.apps[$index].repository_release_id")"
printf '%s\n' "$publication" | grep -Eq '^[0-9]{8}T[0-9]{6}([0-9]{1,6})?$' || return 25
repo_url="$REPO_BASE/$channel/packages/$arch/smartsafehub/$publication"
index_url="$repo_url/packages.adb"
# Always fetch the current published index. If a channel is republished after
# review, fail closed until Hub has reviewed the new index hash.
"$UCLIENT" -q -T 15 -O "$stage/packages.adb" "$index_url" > "$stage/index-fetch.log" 2>&1 || return 25
[ -s "$stage/packages.adb" ] || return 25
index_bytes="$(wc -c < "$stage/packages.adb" | tr -d ' ')"
[ "$index_bytes" -le 8388608 ] || return 25
actual_hash="$("$SHA256" "$stage/packages.adb" | cut -d ' ' -f 1)"
[ "$approved_hash" = "$actual_hash" ] || return 25
memory="$(field "$CATALOG" "@.apps[$index].requirements.min_memory_mb")"
storage="$(field "$CATALOG" "@.apps[$index].requirements.min_storage_mb")"
case "$memory" in ''|*[!0-9]*) return 26;; esac
case "$storage" in ''|*[!0-9]*) return 26;; esac
[ "$memory" -le 131072 ] && [ "$storage" -le 131072 ] || return 26
available_memory="$(awk '/^MemTotal:/ {print int($2/1024); exit}' "$MEMINFO" 2>/dev/null)"
available_storage="$(df -Pk "$OVERLAY" 2>/dev/null | awk 'END {print int($4/1024)}')"
[ -n "$available_memory" ] && [ "$available_memory" -ge "$memory" ] || return 26
[ -n "$available_storage" ] && [ "$available_storage" -ge "$storage" ] || return 26
extract_pins "$index" "$pins" || return 27
if [ "$app" = network-usage-monitor ]; then
[ "$(wc -l < "$pins" | tr -d ' ')" -eq 3 ] || return 27
for required in nlbwmon luci-app-nlbwmon luci-lib-chartjs; do
grep -q "^${required}$(printf '\t')" "$pins" || return 27
done
elif [ "$app" = wake-on-lan ]; then
# WOL always requires both the signal sender and its LuCI user interface.
# Never install extra packages just because a catalog lists them.
[ "$(wc -l < "$pins" | tr -d ' ')" -eq 2 ] || return 27
for required in etherwake luci-app-wol; do
grep -q "^${required}$(printf '\t')" "$pins" || return 27
done
elif [ "$app" = retro-arcade ]; then
# The approved first-party game app owns exactly one APK. Keep the
# package name fixed without freezing its future upgrade versions.
[ "$(wc -l < "$pins" | tr -d ' ')" -eq 1 ] || return 27
grep -q "^smartsafehub-app-retro-arcade$(printf '\t')" "$pins" || return 27
fi
return 0
}
# Download and hash-check all approved archives prior to any APK mutation.
# Both initial installation and a selected-app update use this same check.
fetch_approved_archives() {
mkdir -p "$stage/packages" || return 30
while IFS="$(printf '\t')" read -r name version hash; do
archive="$stage/packages/$name-$version.apk"
"$UCLIENT" -q -T 30 -O "$archive" "$repo_url/$name-$version.apk" > "$stage/fetch.log" 2>&1 || return 31
[ -s "$archive" ] && [ ! -L "$archive" ] || return 32
archive_bytes="$(wc -c < "$archive" | tr -d ' ')"
[ "$archive_bytes" -le 33554432 ] || return 32
[ "$("$SHA256" "$archive" | cut -d ' ' -f 1)" = "$hash" ] || return 33
done < "$pins"
}
prepare_signed_index() {
cp "$stage/packages.adb" "$stage/packages/packages.adb" || return 34
[ "$("$SHA256" "$stage/packages/packages.adb" | cut -d ' ' -f 1)" = "$approved_hash" ] || return 34
local_index="file://$stage/packages/packages.adb"
"$APK" --repositories-file /dev/null -X "$local_index" update > "$stage/repo-update.log" 2>&1 || return 34
}
# All packages come from one SmartSafeHub-signed immutable release.
install_app() {
preflight_install || return $?
manifest="$(owned_manifest "$app")"
# Existing app ownership must match the reviewed pins.
if [ -e "$manifest" ]; then
[ -s "$manifest" ] || return 28
while IFS="$(printf '\t')" read -r name version hash; do
valid_package "$name" && valid_version "$version" && valid_sha "$hash" || return 28
pin="$(printf '%s\t%s\t%s' "$name" "$version" "$hash")"
grep -Fqx "$pin" "$pins" || return 28
done < "$manifest"
fi
need_install=0
# Never adopt a package previously installed outside SmartSafeHub Apps.
while IFS="$(printf '\t')" read -r name version hash; do
pin="$(printf '%s\t%s\t%s' "$name" "$version" "$hash")"
if [ -e "$manifest" ] && grep -Fqx "$pin" "$manifest"; then
installed "$name" || need_install=1
elif installed "$name"; then
return 29
else
need_install=1
fi
done < "$pins"
[ "$need_install" -eq 1 ] || return 28
fetch_approved_archives || return $?
# Only missing packages enter the single signed repository transaction.
set --; base_names=''
while IFS="$(printf '\t')" read -r name version hash; do
if ! installed "$name"; then set -- "$@" "$name=$version"; base_names="${base_names:+$base_names }$name"; fi
done < "$pins"
if [ "$#" -gt 0 ]; then
# Only the verified immutable index can supply packages.
prepare_signed_index || return $?
"$APK" --repositories-file /dev/null -X "$local_index" add --simulate "$@" > "$stage/plan.log" 2>&1 || return 34
review_install_plan "$stage/plan.log" "$base_names" || return 34
# Write ownership immediately before modifying packages. If APK partially
# succeeds, the UI offers repair/removal without adopting unrelated packages.
while IFS="$(printf '\t')" read -r name version hash; do
if ! installed "$name"; then append_owned_pin "$name" "$version" "$hash" || return 36; fi
done < "$pins"
"$APK" --repositories-file /dev/null -X "$local_index" add "$@" > "$stage/install.log" 2>&1 || return 35
fi
# A failed or incomplete add never counts as a successful application.
while IFS="$(printf '\t')" read -r name version hash; do
installed "$name" || return 35
done < "$pins"
return 0
}
# Upgrade only packages already owned by this app, and only when the newly
# approved version is strictly newer. Re-fetch approval at click time, use a
# signed immutable repository, and reject any unrelated solver action.
update_app() {
preflight_install || return $?
manifest="$(owned_manifest "$app")"
[ -s "$manifest" ] || return 51
changes="$stage/changes"
select_upgrades "$manifest" "$pins" "$changes" || return 52
# No package installation has been changed at this point. Keep the previous
# manifest intact until every targeted APK has been upgraded and verified.
fetch_approved_archives || return $?
prepare_signed_index || return $?
set --; update_names=''
while IFS="$(printf '\t')" read -r name version hash; do
set -- "$@" "$name=$version"
update_names="${update_names:+$update_names }$name"
done < "$changes"
[ "$#" -gt 0 ] || return 52
"$APK" --repositories-file /dev/null -X "$local_index" add --upgrade --simulate "$@" > "$stage/plan.log" 2>&1 || return 53
review_update_plan "$stage/plan.log" "$update_names" || return 53
"$APK" --repositories-file /dev/null -X "$local_index" add --upgrade "$@" > "$stage/install.log" 2>&1 || return 54
while IFS="$(printf '\t')" read -r name version hash; do
[ "$(installed_version "$name")" = "$version" ] || return 54
done < "$pins"
# Preserve ownership atomically; future removal and firmware restore now use
# the new pins. Never record an update as a first-time download receipt.
cp "$pins" "$manifest.new" || return 55
mv -f "$manifest.new" "$manifest" || return 55
return 0
}
remove_app() {
manifest="$(owned_manifest "$app")"
[ -f "$manifest" ] || return 41
set --; count=0
while IFS="$(printf '\t')" read -r name version hash; do
valid_package "$name" && valid_version "$version" && valid_sha "$hash" || return 42
protected_package "$name" && return 42
# Reject packages owned by any other Apps installation.
for other in "$OWNED"/*; do
[ -f "$other" ] || continue
[ "$other" = "$manifest" ] && continue
grep -Fq "${name}$(printf '\t')" "$other" && return 43
done
# A failed transaction may have recorded ownership before apk completed.
# Only pass actually installed packages to apk del; still clear the
# manifest once all remaining owned packages are safely removed.
installed "$name" && set -- "$@" "$name"
count=$((count + 1)); [ "$count" -le 32 ] || return 42
done < "$manifest"
[ "$count" -gt 0 ] || return 42
any_installed=0
for name in "$@"; do installed "$name" && any_installed=1; done
if [ "$any_installed" -eq 1 ]; then
"$APK" del --simulate "$@" > "$stage/plan.log" 2>&1 || return 44
# Removing an optional app must never implicitly remove system/network,
# SafeShield or firmware kernel-module packages.
if grep -Ei '(Purging|Removing|Upgrading|Downgrading) (base-files|busybox|apk|procd|netifd|dnsmasq[^ ]*|firewall[^ ]*|odhcp[^ ]*|dropbear|ubus[^ ]*|rpcd[^ ]*|uhttpd[^ ]*|safeshield[^ ]*|luci-app-smartsafehub|kernel|kmod-[^ ]*) ' "$stage/plan.log" >/dev/null; then
return 44
fi
review_remove_plan "$stage/plan.log" "$*" || return 44
"$APK" del "$@" > "$stage/remove.log" 2>&1 || return 45
fi
rm -f "$manifest"
return 0
}
worker() {
action="$1"; app="$2"
# Keep the ID from the queued job across running/success/failed states.
# The browser uses it to distinguish a new failure from a dismissed one.
operation_id="$(awk -F '\t' '$1 == "operation_id" { print $2; exit }' "$STATE" 2>/dev/null || :)"
source="$(awk -F '\t' '$1 == "source" { print $2; exit }' "$STATE" 2>/dev/null || :)"
case "$source" in user|restore) ;; *) source=user ;; esac
trap 'rm -rf "$stage"; rmdir "$LOCK" 2>/dev/null || true' EXIT HUP INT TERM
stage="$(mktemp -d "$RUNTIME/apps-work.XXXXXXXX")" || exit 1
pins="$stage/pins"
# Never expose error text from a previous install as the current result.
rm -f "$RUNTIME/apps-last-error.log"
write_state running "$action" "$app" NONE "$operation_id" "$source"
rc=0
case "$action" in
install) install_app || rc=$?;;
update) update_app || rc=$?;;
remove) remove_app || rc=$?;;
esac
if [ "$rc" -eq 0 ]; then
# The LuCI menu tree may be cached before an optional UI is installed.
# Invalidate the cache only after a successful package transaction.
if [ "$app" = wake-on-lan ]; then
rm -f "$LUCI_INDEX_CACHE"
fi
write_state success "$action" "$app" NONE "$operation_id" "$source"
# Count successful user installs and updates separately. Restores,
# removals and failed/no-op operations never become receipts. Telemetry
# must not affect the success of an APK transaction.
if [ "$source" = user ] && { [ "$action" = install ] || [ "$action" = update ]; }; then
version="$(field "$CATALOG" "@.apps[$index].version")"
"$METRICS" enqueue "$app" "$version" "$operation_id" "$action" >/dev/null 2>&1 || :
fi
else
# Keep only the latest package-manager diagnostic after the workspace is
# removed. Never record an auth token, catalog JSON, or package contents.
for log in "$stage/repo-update.log" "$stage/plan.log" "$stage/install.log" "$stage/remove.log"; do
if [ -s "$log" ]; then
tail -c 4096 "$log" > "$RUNTIME/apps-last-error.log" 2>/dev/null || :
fi
done
write_state failed "$action" "$app" "E$rc" "$operation_id" "$source"
fi
}
start() {
action="$1"; app="$2"; source="${3:-user}"
case "$source" in user|restore) ;; *) exit 2;; esac
[ "$action" = install ] || [ "$source" = user ] || exit 2
case "$action" in install|update|remove) ;; *) exit 2;; esac
valid_id "$app" || exit 2
mkdir -p "$RUNTIME" || exit 1
mkdir "$LOCK" 2>/dev/null || exit 3
# The ID is unique to each invocation, even if the same error repeats.
# OpenWrt's default BusyBox image does not include the od applet.
# dd and sha256sum are available; hash 8 random bytes into a 16-hex nonce.
# Fail closed if the entropy read fails rather than reusing a fixed ID.
random_file="$(mktemp "$RUNTIME/apps-random.XXXXXXXX")" || { rmdir "$LOCK" 2>/dev/null || :; exit 1; }
if ! dd if=/dev/urandom of="$random_file" bs=8 count=1 2>/dev/null ||
[ "$(wc -c < "$random_file" | tr -d ' ')" -ne 8 ]; then
rm -f "$random_file"
rmdir "$LOCK" 2>/dev/null || :
exit 1
fi
random="$("$SHA256" "$random_file" | cut -c 1-16)"
rm -f "$random_file"
[ "${#random}" -eq 16 ] || { rmdir "$LOCK" 2>/dev/null || :; exit 1; }
operation_id="$(date +%s)-$$-$random"
write_state starting "$action" "$app" NONE "$operation_id" "$source"
if ! "$DAEMON" -S -b -x "$SELF" -- worker "$action" "$app" >/dev/null 2>&1; then
write_state failed "$action" "$app" LAUNCH "$operation_id" "$source"
rmdir "$LOCK" 2>/dev/null || true
exit 1
fi
}
case "${1:-}" in
status) mkdir -p "$RUNTIME"; status;;
sizes) mkdir -p "$RUNTIME"; sizes;;
start) [ "$#" -ge 3 ] && [ "$#" -le 4 ] || exit 2; start "$2" "$3" "${4:-user}";;
worker) [ "$#" -eq 3 ] || exit 2; worker "$2" "$3";;
*) exit 2;;
esac
@@ -0,0 +1,74 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
# Durable outbox for authenticated successful user installs and updates.
# Never affects APK transactions; send only app, release, kind and receipt ID.
set -eu
umask 077
QUEUE="${SMARTSAFEHUB_APPS_METRICS_DIR:-/etc/smartsafehub/apps-metrics-pending}"
CREDENTIAL="${SMARTSAFEHUB_APPS_METRICS_CREDENTIAL:-/etc/smartsafehub/device-credential.json}"
JSONFILTER="${SMARTSAFEHUB_APPS_JSONFILTER_BIN:-jsonfilter}"
FETCH="${SMARTSAFEHUB_APPS_METRICS_FETCH_BIN:-uclient-fetch}"
URL="${SMARTSAFEHUB_APPS_METRICS_URL:-https://www.smartsafehub.com/api/v1/app-store/installs}"
MAX_PENDING=500
MAX_FLUSH=3
json_field() { "$JSONFILTER" -i "$1" -e "$2" 2>/dev/null | head -n 1; }
valid_id() { case "$1" in ''|*[!a-z0-9-]*|-*|*-) return 1;; esac; [ "${#1}" -le 80 ]; }
valid_version() { case "$1" in ''|*[!a-zA-Z0-9._+~:-]*|[-.+~:]*) return 1;; esac; [ "${#1}" -le 64 ]; }
valid_event() {
# Timestamp, process ID and 64-bit random component (no user data).
printf '%s\n' "$1" | grep -Eq '^[0-9]{10}-[0-9]+-[0-9a-f]{16}$' && [ "${#1}" -le 64 ]
}
enqueue() {
[ "$#" -eq 3 ] || [ "$#" -eq 4 ] || exit 2
valid_id "$1" && valid_version "$2" && valid_event "$3" || exit 2
kind="${4:-install}"
case "$kind" in install|update) ;; *) exit 2;; esac
mkdir -p "$QUEUE" || exit 1
[ -d "$QUEUE" ] && [ ! -L "$QUEUE" ] || exit 1
file="$QUEUE/$3.json"
[ ! -e "$file" ] || return 0
# Bounded flash use. A full outbox never blocks installations.
count="$(find "$QUEUE" -maxdepth 1 -type f -name '*.json' | wc -l | tr -d ' ')"
[ "$count" -lt "$MAX_PENDING" ] || exit 1
tmp="$(mktemp "$QUEUE/.pending.XXXXXXXX")" || exit 1
if ! printf '{"app_id":"%s","version":"%s","event_id":"%s","kind":"%s"}\n' "$1" "$2" "$3" "$kind" > "$tmp"; then
rm -f "$tmp"; exit 1
fi
mv -f "$tmp" "$file"
}
flush() {
[ -d "$QUEUE" ] || return 0
[ -f "$CREDENTIAL" ] && [ ! -L "$CREDENTIAL" ] || return 0
token="$(json_field "$CREDENTIAL" '@.token')"
case "$token" in ssh_dev_*) ;; *) return 0;; esac
[ "${#token}" -eq 72 ] || return 0
# Only lower-case hex credentials can enter HTTP headers.
case "${token#ssh_dev_}" in *[!0-9a-f]*|'') return 0;; esac
mkdir "$QUEUE/.flush.lock" 2>/dev/null || return 0
trap 'rmdir "$QUEUE/.flush.lock" 2>/dev/null || :' EXIT HUP INT TERM
n=0; failed=0
for file in "$QUEUE"/*.json; do
[ -f "$file" ] && [ ! -L "$file" ] || continue
event="${file##*/}"; event="${event%.json}"
valid_event "$event" || continue
[ "$(json_field "$file" '@.event_id')" = "$event" ] || continue
response="$(mktemp "$QUEUE/.response.XXXXXXXX")" || return 1
if "$FETCH" -q -T 8 -O "$response" \
--header='Content-Type: application/json' \
--header="Authorization: Device $token" --post-file="$file" "$URL" >/dev/null 2>&1; then
accepted="$(json_field "$response" '@.status')"
case "$accepted" in recorded|duplicate) rm -f "$file";; *) failed=1;; esac
else
failed=1
fi
rm -f "$response"
n=$((n + 1)); [ "$n" -lt "$MAX_FLUSH" ] || break
done
[ "$failed" -eq 0 ]
}
case "${1:-}" in
enqueue) shift; enqueue "$@";;
flush) [ "$#" -eq 1 ] || exit 2; flush;;
*) exit 2;;
esac
@@ -0,0 +1,165 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
# Reconcile sysupgrade-preserved Apps ownership with the new firmware's APK DB.
# The privileged Apps manager alone decides which signed packages may be added.
set -eu
MANAGER="${SMARTSAFEHUB_APPS_MANAGER_BIN:-/usr/libexec/smartsafehub-apps-manager}"
OWNED="${SMARTSAFEHUB_APPS_OWNED_DIR:-/etc/smartsafehub/apps-installed}"
RUNTIME="${SMARTSAFEHUB_APPS_RUNTIME_DIR:-/tmp/smartsafehub}"
APK="${SMARTSAFEHUB_APPS_APK_BIN:-apk}"
SLEEP="${SMARTSAFEHUB_APPS_RESTORE_SLEEP_BIN:-sleep}"
START_DELAY="${SMARTSAFEHUB_APPS_RESTORE_START_DELAY:-30}"
RETRY_DELAY="${SMARTSAFEHUB_APPS_RESTORE_RETRY_DELAY:-120}"
RETRIES="${SMARTSAFEHUB_APPS_RESTORE_RETRIES:-12}"
POLL_INTERVAL="${SMARTSAFEHUB_APPS_RESTORE_POLL_INTERVAL:-2}"
POLL_LIMIT="${SMARTSAFEHUB_APPS_RESTORE_POLL_LIMIT:-240}"
STATE="$RUNTIME/apps-restore.state"
JOB="$RUNTIME/apps-operation.state"
LOCK="$RUNTIME/apps-restore.lock"
valid_id() {
case "$1" in ''|*[!a-z0-9-]*|-*|*-) return 1;; esac
[ "${#1}" -le 80 ]
}
valid_package() {
case "$1" in ''|*[!a-zA-Z0-9_.+-]*|[-.+]*|*[-.+]) return 1;; esac
[ "${#1}" -le 100 ]
}
valid_sha() {
case "$1" in ''|*[!a-f0-9]*) return 1;; esac
[ "${#1}" -eq 64 ]
}
write_state() {
phase="$1"; id="$2"; code="$3"
state_tmp="$(mktemp "$RUNTIME/apps-restore-state.XXXXXXXX")" || return 1
printf 'phase\t%s\napp_id\t%s\ncode\t%s\n' "$phase" "$id" "$code" > "$state_tmp"
mv -f "$state_tmp" "$STATE"
}
log() {
command -v logger >/dev/null 2>&1 && logger -t smartsafehub-apps-restore "$*" || :
}
# A corrupt manifest must NOT grant an arbitrary app installation right.
# Existing packages are never replaced merely because an old manifest exists.
needs_restore() {
manifest="$1"; count=0; missing=0
[ -f "$manifest" ] && [ ! -L "$manifest" ] && [ -s "$manifest" ] || return 2
while IFS="$(printf '\t')" read -r name version hash; do
valid_package "$name" && [ -n "$version" ] && valid_sha "$hash" || return 2
count=$((count + 1))
[ "$count" -le 32 ] || return 2
"$APK" info -e "$name" >/dev/null 2>&1 || missing=1
done < "$manifest"
[ "$count" -gt 0 ] || return 2
[ "$missing" -eq 1 ]
}
job_field() {
[ -r "$JOB" ] || return 0
awk -F '\t' -v key="$1" '$1 == key {print $2; exit}' "$JOB"
}
# Starts exactly the same preflight/ownership/signature/plan checks as a user
# clicking Install. Never calls apk add directly, never reuses cached catalogs.
run_job() {
id="$1"; n=0
if ! "$MANAGER" start install "$id" restore >/dev/null 2>&1; then
RESULT=BUSY
return 1
fi
while [ "$n" -lt "$POLL_LIMIT" ]; do
phase="$(job_field phase)"; current="$(job_field app_id)"
if [ "$current" = "$id" ]; then
case "$phase" in
success) RESULT=NONE; return 0;;
failed) RESULT="$(job_field code)"; [ -n "$RESULT" ] || RESULT=UNKNOWN; return 1;;
esac
fi
"$SLEEP" "$POLL_INTERVAL"
n=$((n + 1))
done
RESULT=TIMEOUT
return 1
}
restore_one() {
id="$1"; attempt=1
while [ "$attempt" -le "$RETRIES" ]; do
# User removal while waiting takes precedence; never reinstall a removed app.
[ -f "$OWNED/$id" ] || return 0
if needs_restore "$OWNED/$id"; then :; else
result=$?
[ "$result" -eq 1 ] && return 0
RESULT=INVALID_MANIFEST
return 1
fi
write_state running "$id" NONE
if run_job "$id"; then
# Successful APK transaction may still have omitted an owned package.
if needs_restore "$OWNED/$id"; then
RESULT=INCOMPLETE
else
result=$?
[ "$result" -eq 1 ] && return 0
RESULT=INVALID_MANIFEST
fi
fi
# Only networking failures or a concurrent manual operation are retried.
# Changed approval pins, revoked apps, bad signatures and unsafe dependency
# plans are never bypassed or retried indefinitely.
case "$RESULT" in BUSY|E21|E25|E31|E35) ;; *) return 1;; esac
[ "$attempt" -lt "$RETRIES" ] || return 1
"$SLEEP" "$RETRY_DELAY"
attempt=$((attempt + 1))
done
return 1
}
restore() {
mkdir -p "$RUNTIME" || exit 1
# procd only starts one instance, but an operator may invoke run manually.
mkdir "$LOCK" 2>/dev/null || exit 0
trap 'rmdir "$LOCK" 2>/dev/null || :' EXIT
write_state idle '' NONE
# No persistent ownership means never install anything on a fresh router.
[ -d "$OWNED" ] || return 0
pending=0
for manifest in "$OWNED"/*; do
[ -f "$manifest" ] || continue
id="${manifest##*/}"
valid_id "$id" || continue
if needs_restore "$manifest"; then pending=1; break; else
result=$?; [ "$result" -eq 2 ] && { pending=1; break; }
fi
done
[ "$pending" -eq 1 ] || return 0
write_state starting '' NONE
"$SLEEP" "$START_DELAY"
failed=0; last_app=''; last_code=NONE
for manifest in "$OWNED"/*; do
[ -f "$manifest" ] || continue
id="${manifest##*/}"
valid_id "$id" || continue
if needs_restore "$manifest"; then
RESULT=NONE
if restore_one "$id"; then
log "restored $id"
else
failed=1; last_app="$id"; last_code="$RESULT"
log "restore blocked for $id ($RESULT)"
fi
else
result=$?
if [ "$result" -eq 2 ]; then
failed=1; last_app="$id"; last_code=INVALID_MANIFEST
log "invalid owned manifest for $id"
fi
fi
done
if [ "$failed" -eq 1 ]; then
write_state failed "$last_app" "$last_code"
else
write_state success '' NONE
fi
}
case "${1:-}" in
run) [ "$#" -eq 1 ] || exit 2; restore;;
*) exit 2;;
esac
@@ -29,6 +29,7 @@ LOGGER_BIN="${SMARTSAFEHUB_DEVICE_LOGGER_BIN:-logger}"
HEXDUMP_BIN="${SMARTSAFEHUB_DEVICE_HEXDUMP_BIN:-hexdump}"
UBUS_BIN="${SMARTSAFEHUB_DEVICE_UBUS_BIN:-ubus}"
REQUEST_TIMEOUT_S=10
APPS_METRICS="${SMARTSAFEHUB_APPS_METRICS_BIN:-/usr/libexec/smartsafehub-apps-metrics}"
CHECK_INTERVAL_S=300
RECOMMENDED_POLL_INTERVAL_S=30
RECOMMENDED_POLL_MAX_RETRY_S=300
@@ -498,6 +499,8 @@ daemon() {
delay="$(retry_delay "$failures")"
[ "$failures" -lt 4 ] && failures=$((failures + 1))
fi
# Backlogged Apps receipts are retried independently of account sync.
[ ! -x "$APPS_METRICS" ] || "$APPS_METRICS" flush >/dev/null 2>&1 || :
now="$(now_epoch)"
next_full=$((now + delay))
# Full sync already handled the recommended payload.
@@ -21,6 +21,7 @@ SHA256SUM_BIN="${SMARTSAFEHUB_FIRMWARE_SHA256SUM_BIN:-sha256sum}"
UBUS_BIN="${SMARTSAFEHUB_FIRMWARE_UBUS_BIN:-ubus}"
SYSUPGRADE_BIN="${SMARTSAFEHUB_FIRMWARE_SYSUPGRADE_BIN:-/sbin/sysupgrade}"
EVENTS_BIN="${SMARTSAFEHUB_FIRMWARE_EVENTS_BIN:-/usr/libexec/smartsafehub-events}"
APPS_RESTORE_BIN="${SMARTSAFEHUB_APPS_RESTORE_BIN:-/usr/libexec/smartsafehub-apps-restore}"
DEFAULT_API_BASE_URL="https://www.smartsafehub.com/api/v1"
CHECK_TIMEOUT_S=20
DOWNLOAD_TIMEOUT_S=180
@@ -702,8 +703,18 @@ periodic_check_if_due() {
fi
}
# The existing firmware daemon schedules an independent, finite restore job
# once at boot. There is no new procd service, respawn loop or idle process.
# Restore worker itself checks the preserved ownership manifest before I/O.
start_apps_restore_once() {
[ -x "$APPS_RESTORE_BIN" ] || return 0
[ -d "${SMARTSAFEHUB_APPS_OWNED_DIR:-/etc/smartsafehub/apps-installed}" ] || return 0
"$APPS_RESTORE_BIN" run </dev/null >/dev/null 2>&1 &
}
run_daemon() {
sync_firmware_identity
start_apps_restore_once
load_daemon_settings
sleep "$DAEMON_INITIAL_DELAY_S"
boot_check_with_retry || true
@@ -30,7 +30,9 @@ SNAPSHOT="$RUNTIME/recommended-snapshot.$$"
REPLY="$RUNTIME/recommended-reply.$$"
ALLOW="$RUNTIME/recommended-allow.$$"
VERIFY="$RUNTIME/recommended-verify.$$"
cleanup() { rm -f "$DESIRED" "$OWNED" "$SNAPSHOT" "$REPLY" "$ALLOW" "$VERIFY" "$LOCK/pid"; rmdir "$LOCK" 2>/dev/null || true; }
ADD="$RUNTIME/recommended-add.$$"
REMOVE="$RUNTIME/recommended-remove.$$"
cleanup() { rm -f "$DESIRED" "$OWNED" "$SNAPSHOT" "$REPLY" "$ALLOW" "$VERIFY" "$ADD" "$REMOVE" "$LOCK/pid"; rmdir "$LOCK" 2>/dev/null || true; }
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
@@ -48,16 +50,18 @@ extract_array() {
[ "$status" -eq 1 ] && [ ! -s "$output" ]
fi
}
valid_domain() {
[ -n "$1" ] && [ "${#1}" -le 253 ] || return 1
printf '%s\n' "$1" | LC_ALL=C grep -Eq '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$' || return 1
! printf '%s\n' "$1" | LC_ALL=C grep -Eq '\.[0-9]+$' || return 1
rest="$1"
while [ -n "$rest" ]; do
label="${rest%%.*}"
[ "${#label}" -le 63 ] || return 1
case "$rest" in *.*) rest="${rest#*.}" ;; *) break ;; esac
done
# Validate whole sorted domain lists in one awk process. Per-domain grep and
# subshell creation previously scaled with every saved SafeShield allow entry.
valid_domains_file() {
LC_ALL=C awk '
length($0) == 0 || length($0) > 253 { exit 1 }
$0 !~ /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/ { exit 1 }
$0 ~ /\.[0-9]+$/ { exit 1 }
{
count = split($0, labels, /\./)
for (i = 1; i <= count; i++) if (length(labels[i]) > 63) exit 1
}
' "$1"
}
revision="$(field "$RESPONSE" '@.recommended_rules.revision')"
@@ -68,7 +72,7 @@ revision="$(field "$RESPONSE" '@.recommended_rules.revision')"
valid_hash "$revision" || exit 1
extract_array "$RESPONSE" '@.recommended_rules.domains' "$DESIRED" || exit 1
[ "$(wc -l < "$DESIRED")" -le 100 ] || exit 1
while IFS= read -r domain || [ -n "$domain" ]; do valid_domain "$domain" || exit 1; done < "$DESIRED"
valid_domains_file "$DESIRED" || exit 1
sort -u "$DESIRED" -o "$DESIRED"
previous=''
@@ -78,14 +82,19 @@ if [ -e "$STATE" ]; then
previous="$(sed -n '1p' "$STATE")"
[ "$previous" = '-' ] || valid_hash "$previous" || exit 1
sed '1d' "$STATE" > "$OWNED"
while IFS= read -r domain || [ -n "$domain" ]; do valid_domain "$domain" || exit 1; done < "$OWNED"
valid_domains_file "$OWNED" || exit 1
sort -u "$OWNED" -o "$OWNED"
fi
persist() {
temp="${STATE}.tmp.$$"
{ printf '%s\n' "$1"; sort -u "$OWNED"; } > "$temp"
chmod 600 "$temp"
mv -f "$temp" "$STATE"
# Avoid extra flash writes on a successful metadata-only reconciliation.
if [ -f "$STATE" ] && cmp -s "$STATE" "$temp"; then
rm -f "$temp"
else
mv -f "$temp" "$STATE"
fi
}
# Report progress from the worker itself. The parent daemon only commits a
@@ -111,9 +120,11 @@ safeshield_refreshing() {
[ "$status_value" = running ]
}
# Defer a different desired revision before calling potentially busy SafeShield
# rules_list, which can itself be delayed during a long refresh.
if [ "$previous" != "$revision" ] && safeshield_refreshing; then
# Always defer a reconciliation during SafeShield's full-list refresh. Even
# a matching revision may need recovery if a prior apply lost its receipt.
# Check before dropping the old receipt so interrupted refreshes are not
# mistaken for failed or unverified rules.
if safeshield_refreshing; then
phase safeshield_refreshing
exit 75
fi
@@ -129,26 +140,41 @@ fi
# A broken or truncated rules_list must never be mistaken for an empty list.
LC_ALL=C grep -Eq '"allow"[[:space:]]*:[[:space:]]*\[' "$REPLY" || exit 1
extract_array "$REPLY" '@.allow' "$ALLOW" || exit 1
while IFS= read -r domain || [ -n "$domain" ]; do valid_domain "$domain" || exit 1; done < "$ALLOW"
valid_domains_file "$ALLOW" || exit 1
sort -u "$ALLOW" -o "$ALLOW"
# Do not mutate overrides while a full protection refresh is already running.
# Retain ownership and let the 30-second poll retry when SafeShield settles.
# Build the two mutation sets once. Owned entries absent from SafeShield do
# not need a delete, but their claims remain until final verification.
: > "$ADD"
: > "$REMOVE"
LC_ALL=C awk -v add_file="$ADD" -v remove_file="$REMOVE" '
FILENAME == ARGV[1] { if ($0 != "") desired[$0] = 1; next }
FILENAME == ARGV[2] { if ($0 != "") allowed[$0] = 1; next }
FILENAME == ARGV[3] {
if ($0 != "" && !($0 in desired) && ($0 in allowed)) print $0 > remove_file
next
}
END {
for (domain in desired) if (!(domain in allowed)) print domain > add_file
}
' "$DESIRED" "$ALLOW" "$OWNED" || exit 1
sort -u "$ADD" -o "$ADD"
sort -u "$REMOVE" -o "$REMOVE"
changes_needed=0
[ "$previous" = '-' ] && changes_needed=1
while IFS= read -r domain; do
[ -n "$domain" ] || continue
if ! grep -Fxq "$domain" "$ALLOW"; then changes_needed=1; fi
done < "$DESIRED"
while IFS= read -r domain; do
[ -n "$domain" ] || continue
if ! grep -Fxq "$domain" "$DESIRED" && grep -Fxq "$domain" "$ALLOW"; then changes_needed=1; fi
done < "$OWNED"
if [ "$previous" = '-' ] || [ -s "$ADD" ] || [ -s "$REMOVE" ]; then changes_needed=1; fi
if [ "$changes_needed" -eq 1 ] && safeshield_refreshing; then
phase safeshield_refreshing
exit 75
fi
if [ "$changes_needed" -eq 1 ]; then phase applying; fi
if [ "$changes_needed" -eq 1 ]; then
phase applying
# Mark the entire transaction unconfirmed once, before touching any rule.
# Keeping all ownership claims prevents a partial deletion from becoming a
# future manual-allow exemption after interruption.
persist '-'
fi
mutate() {
method="$1"; domain="$2"
@@ -159,36 +185,24 @@ mutate() {
# Refresh is retried after a partial or unsuccessful reconciliation.
need_refresh=0
[ "$previous" = '-' ] && need_refresh=1
cp "$OWNED" "$SNAPSHOT"
while IFS= read -r domain; do
[ -n "$domain" ] || continue
if ! grep -Fxq "$domain" "$DESIRED"; then
if grep -Fxq "$domain" "$ALLOW"; then
mutate rule_delete "$domain" || exit 1
need_refresh=1
fi
# Keep the ownership claim until the final SafeShield read confirms the
# domain is gone. A refresh failure must not turn a leftover cloud entry
# into an apparently user-created allow rule on the next assignment.
persist '-'
fi
done < "$SNAPSHOT"
mutate rule_delete "$domain" || exit 1
need_refresh=1
# Ownership remains in the pending state until final verification.
done < "$REMOVE"
while IFS= read -r domain; do
[ -n "$domain" ] || continue
if ! grep -Fxq "$domain" "$ALLOW"; then
# Write the ownership intent before creating the allow entry. If the
# process is interrupted after rule_add, the retry must still recognize
# the entry as cloud-managed rather than silently treating it as manual.
if ! grep -Fxq "$domain" "$OWNED"; then
printf '%s\n' "$domain" >> "$OWNED"
persist '-'
fi
mutate rule_add "$domain" || exit 1
need_refresh=1
# Record intent before rule_add so a crash cannot reclassify a managed rule
# as a manual allow entry. Preserve ownership on partial refresh failures.
if ! grep -Fxq "$domain" "$OWNED"; then
printf '%s\n' "$domain" >> "$OWNED"
persist '-'
fi
done < "$DESIRED"
mutate rule_add "$domain" || exit 1
need_refresh=1
done < "$ADD"
if [ "$need_refresh" -eq 1 ]; then
phase safeshield_refreshing
@@ -201,54 +215,55 @@ if [ "$need_refresh" -eq 1 ]; then
exit 1
fi
fi
# Read the actual SafeShield rule list again rather than trusting rule_add results.
# Verify with a second RPC only when a mutation/refresh was requested.
# Otherwise the first rules_list response is already an authoritative read.
if [ "$changes_needed" -eq 1 ] || [ "$previous" != "$revision" ]; then phase verifying; fi
if ! "$UBUS" call safeshield rules_list '{}' > "$REPLY"; then
if safeshield_refreshing; then phase safeshield_refreshing; exit 75; fi
exit 1
fi
[ "$(field "$REPLY" '@.ok')" != false ] || exit 1
LC_ALL=C grep -Eq '"allow"[[:space:]]*:[[:space:]]*\[' "$REPLY" || exit 1
extract_array "$REPLY" '@.allow' "$VERIFY" || exit 1
while IFS= read -r domain || [ -n "$domain" ]; do valid_domain "$domain" || exit 1; done < "$VERIFY"
sort -u "$VERIFY" -o "$VERIFY"
while IFS= read -r domain; do
[ -n "$domain" ] || continue
grep -Fxq "$domain" "$VERIFY" || exit 1
done < "$DESIRED"
# Do not retire ownership if a previously managed, now-unassigned domain is
# still present in SafeShield. It may reappear during a failed refresh or an
# overlapping request, and a subsequent recommendation must not adopt it as a
# manual rule. Retry the deletion while the ownership marker remains intact.
while IFS= read -r domain; do
[ -n "$domain" ] || continue
if ! grep -Fxq "$domain" "$DESIRED" && grep -Fxq "$domain" "$VERIFY"; then
if [ "$need_refresh" -eq 1 ]; then
if ! "$UBUS" call safeshield rules_list '{}' > "$REPLY"; then
if safeshield_refreshing; then phase safeshield_refreshing; exit 75; fi
exit 1
fi
done < "$OWNED"
# Only after successful reconciliation may we forget ownership of removed
# domains. The preserved marker survives a failed refresh and a reassignment.
cp "$OWNED" "$SNAPSHOT"
: > "$OWNED"
while IFS= read -r domain; do
[ -n "$domain" ] || continue
if grep -Fxq "$domain" "$DESIRED"; then
printf '%s\n' "$domain" >> "$OWNED"
fi
done < "$SNAPSHOT"
[ "$(field "$REPLY" '@.ok')" != false ] || exit 1
LC_ALL=C grep -Eq '"allow"[[:space:]]*:[[:space:]]*\[' "$REPLY" || exit 1
extract_array "$REPLY" '@.allow' "$VERIFY" || exit 1
valid_domains_file "$VERIFY" || exit 1
sort -u "$VERIFY" -o "$VERIFY"
else
cp "$ALLOW" "$VERIFY"
fi
# Check desired presence and pending managed deletions with one process.
LC_ALL=C awk '
FILENAME == ARGV[1] { if ($0 != "") desired[$0] = 1; next }
FILENAME == ARGV[2] { if ($0 != "") verified[$0] = 1; next }
FILENAME == ARGV[3] {
if ($0 != "" && !($0 in desired) && ($0 in verified)) invalid = 1
next
}
END {
for (domain in desired) if (!(domain in verified)) invalid = 1
exit invalid
}
' "$DESIRED" "$VERIFY" "$OWNED" || exit 1
# Retire ownership only after final verification, never on a failed refresh.
LC_ALL=C awk '
FILENAME == ARGV[1] { if ($0 != "") desired[$0] = 1; next }
($0 in desired) { print }
' "$DESIRED" "$OWNED" > "$SNAPSHOT" || exit 1
sort -u "$SNAPSHOT" -o "$SNAPSHOT"
cp "$SNAPSHOT" "$OWNED"
# The receipt describes *allow-list presence*, not proven DNS query results.
# Domains not in OWNED existed before this recommendation (manual configuration).
receipt_tmp="${RECEIPT}.tmp.$$"
{
printf '{"revision":"%s","status":"confirmed","domains":[' "$revision"
first=1
while IFS= read -r domain; do
[ -n "$domain" ] || continue
if grep -Fxq "$domain" "$OWNED"; then source=managed; else source=manual; fi
[ "$first" -eq 1 ] || printf ','
printf '{"domain":"%s","source":"%s"}' "$domain" "$source"
first=0
done < "$DESIRED"
LC_ALL=C awk '
FILENAME == ARGV[1] { if ($0 != "") owned[$0] = 1; next }
FILENAME == ARGV[2] {
if ($0 == "") next
if (count++) printf ","
printf "{\"domain\":\"%s\",\"source\":\"%s\"}", $0, (($0 in owned) ? "managed" : "manual")
}
' "$OWNED" "$DESIRED"
printf ']}\n'
} > "$receipt_tmp"
chmod 600 "$receipt_tmp"
@@ -0,0 +1,121 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
# Bounded, single-flight subprocesses for slow read-only scans / Hub calls.
# RPC callers accept a job and read snapshots. No user-provided command/URL.
set -eu
umask 077
RUNTIME="${SMARTSAFEHUB_RPC_JOBS_RUNTIME:-/tmp/smartsafehub/rpc-jobs}"
DAEMON="${SMARTSAFEHUB_RPC_JOBS_DAEMON:-start-stop-daemon}"
SELF="${SMARTSAFEHUB_RPC_JOBS_SELF:-/usr/libexec/smartsafehub-rpc-jobs}"
CATALOG="${SMARTSAFEHUB_RPC_JOBS_CATALOG:-/usr/libexec/smartsafehub-apps}"
MANAGER="${SMARTSAFEHUB_RPC_JOBS_MANAGER:-/usr/libexec/smartsafehub-apps-manager}"
DEVICE="${SMARTSAFEHUB_RPC_JOBS_DEVICE:-/usr/libexec/smartsafehub-device}"
valid_job() {
case "$1" in catalog|status|sizes|device-sync|pairing|recommended-sync|recommended-revoke) return 0;; esac
return 1
}
valid_arg() {
case "$1" in ''|0|*[!0-9]*) return 1;; esac
[ "${#1}" -le 10 ]
}
state_path() { printf '%s/%s.state' "$RUNTIME" "$1"; }
lock_path() { printf '%s/%s.lock' "$RUNTIME" "$1"; }
# All fields come from fixed enums, epoch seconds and validated digits.
write_state() {
job="$1"; phase="$2"; code="$3"; argument="$4"; started="$5"
tmp="$(mktemp "$RUNTIME/$job.XXXXXXXX")" || return 1
printf 'phase\t%s\ncode\t%s\nargument\t%s\nstarted\t%s\nupdated\t%s\n' \
"$phase" "$code" "$argument" "$started" "$(date +%s)" > "$tmp"
mv -f "$tmp" "$(state_path "$job")"
}
read_field() { [ -r "$1" ] && sed -n "s/^$2$(printf '\t')//p" "$1" | head -n 1 || :; }
ensure() {
job="$1"; mode="$2"; argument="$3"
now="$(date +%s)"
state="$(state_path "$job")"; lock="$(lock_path "$job")"
ttl=0
case "$job" in
catalog) ttl=600;; status) ttl=15;; sizes) ttl=120;; device-sync) ttl=30;;
*) ttl=0;;
esac
if [ "$mode" = auto ]; then
# Suppress redundant requests and short retry storms after errors.
stamp="$(read_field "$state" updated)"
case "$stamp" in ''|*[!0-9]*) stamp=0;; esac
if [ $((now - stamp)) -ge 0 ] && [ $((now - stamp)) -lt "$ttl" ]; then
exit 0
fi
if [ "$ttl" -eq 0 ]; then exit 2; fi
fi
if ! mkdir "$lock" 2>/dev/null; then
# Keep a live worker. Recover only an abandoned lock, not a slow worker.
pid="$(read_field "$lock/pid" pid)"
stamp="$(read_field "$state" started)"
case "$stamp" in ''|*[!0-9]*) stamp="$now";; esac
if [ "$((now - stamp))" -gt 180 ] && { [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; }; then
rm -f "$lock/pid"
rmdir "$lock" 2>/dev/null || exit 0
mkdir "$lock" 2>/dev/null || exit 0
else
# Explicit duplicate actions are not silently counted as completed.
[ "$mode" = auto ] && exit 0
existing="$(read_field "$state" argument)"
[ "$argument" = "$existing" ] && exit 0
exit 3
fi
fi
write_state "$job" starting NONE "$argument" "$now"
if ! "$DAEMON" -S -b -x "$SELF" -- worker "$job" "$argument" >/dev/null 2>&1; then
write_state "$job" failed LAUNCH "$argument" "$now"
rmdir "$lock" 2>/dev/null || :
exit 1
fi
}
worker_cleanup() {
if [ "${finished:-0}" -ne 1 ]; then
write_state "$job" failed E70 "$argument" "$started" || :
fi
rm -f "$lock/pid"
rmdir "$lock" 2>/dev/null || :
}
worker() {
job="$1"; argument="$2"
lock="$(lock_path "$job")"
started="$(read_field "$(state_path "$job")" started)"
case "$started" in ''|*[!0-9]*) started="$(date +%s)";; esac
finished=0
trap 'worker_cleanup' EXIT
trap 'exit 70' HUP INT TERM
printf 'pid\t%s\n' "$$" > "$lock/pid"
write_state "$job" running NONE "$argument" "$started"
rc=0
case "$job" in
catalog) "$CATALOG" || rc=$?;;
status) "$MANAGER" status || rc=$?;;
sizes) "$MANAGER" sizes || rc=$?;;
device-sync) "$DEVICE" status-sync || rc=$?;;
pairing) "$DEVICE" pairing-session || rc=$?;;
recommended-sync) "$DEVICE" recommended-sync || rc=$?;;
recommended-revoke) "$DEVICE" recommended-revoke "$argument" || rc=$?;;
esac
if [ "$rc" -eq 0 ]; then
write_state "$job" success NONE "$argument" "$started"
else
write_state "$job" failed "E$rc" "$argument" "$started"
fi
finished=1
}
[ "$#" -ge 2 ] && [ "$#" -le 3 ] || exit 2
mode="$1"; job="$2"; argument="${3:-}"
valid_job "$job" || exit 2
if [ "$job" = recommended-revoke ]; then valid_arg "$argument" || exit 2
else [ -z "$argument" ] || exit 2; fi
mkdir -p "$RUNTIME" || exit 1
case "$mode" in
ensure) ensure "$job" auto "$argument";;
request) ensure "$job" explicit "$argument";;
worker) worker "$job" "$argument";;
*) exit 2;;
esac
@@ -10,6 +10,9 @@
"wifi_summary",
"updates_status",
"firmware_status",
"apps_catalog",
"apps_status",
"apps_metrics",
"system_time_settings",
"system_scheduled_reboot_settings",
"health_status",
@@ -50,6 +53,9 @@
"system_reboot",
"updates_check",
"updates_install",
"apps_install",
"apps_update",
"apps_remove",
"updates_settings_update",
"firmware_check",
"firmware_prepare",
@@ -2,6 +2,8 @@
'use strict';
import { failure } from './smartsafehub/core.uc';
import { read_apps_catalog } from './smartsafehub/apps.uc';
import { read_apps_status, read_apps_metrics, install_app, update_app, remove_app } from './smartsafehub/apps-manager.uc';
import { read_connected_devices } from './smartsafehub/devices.uc';
import {
discard_uploaded_backup,
@@ -173,6 +175,42 @@ const methods = {
return update_update_settings(request);
}),
},
apps_catalog: {
args: { refresh: false },
call: require_root_password(function(request) {
return read_apps_catalog(request);
}),
},
apps_status: {
args: { refresh: false },
call: require_root_password(function(request) {
return read_apps_status(request);
}),
},
apps_metrics: {
args: { refresh: false },
call: require_root_password(function(request) {
return read_apps_metrics(request);
}),
},
apps_install: {
args: { app_id: '' },
call: require_root_password(function(request) {
return install_app(request);
}),
},
apps_update: {
args: { app_id: '' },
call: require_root_password(function(request) {
return update_app(request);
}),
},
apps_remove: {
args: { app_id: '' },
call: require_root_password(function(request) {
return remove_app(request);
}),
},
firmware_status: {
call: require_root_password(function(request) {
return read_firmware_status(request);
@@ -0,0 +1,162 @@
// SPDX-License-Identifier: GPL-3.0-or-later
'use strict';
import * as fs from 'fs';
import { failure, run_command, safe_call, success } from './core.uc';
import { from_system_root, from_statvfs } from './apps-storage.uc';
import { ensure_job, job_state, request_job } from './rpc-jobs.uc';
const MANAGER = '/usr/libexec/smartsafehub-apps-manager';
const STATE = '/tmp/smartsafehub/apps-operation.state';
const STATUS = '/tmp/smartsafehub/apps-status.tsv';
const SIZES = '/tmp/smartsafehub/apps-size.tsv';
const RESTORE_STATE = '/tmp/smartsafehub/apps-restore.state';
// OpenWrt procd already reports the writable root filesystem in system.info.
// Those counters are KiB, unlike ucode fs.statvfs() which returns blocks.
// Prefer the existing ubus connection, then fall back to statvfs without
// spawning df or allowing optional metrics to affect core Apps status.
function writable_storage() {
const info = safe_call('system', 'info', {});
const root_storage = from_system_root(info?.root);
if (root_storage != null)
return root_storage;
// Do not assume /overlay is present (e.g. ext4 or extroot installs).
// Both paths are fixed trusted paths. Each call is inexpensive.
for (let mount in [ '/overlay', '/' ]) {
let stats = null;
try {
stats = fs.statvfs(mount);
}
catch (e) {
continue;
}
const storage = from_statvfs(stats, mount);
if (storage != null)
return storage;
}
return null;
}
function job() {
const state = { phase: 'idle', action: '', appId: '', code: '', operationId: '' };
const raw = fs.readfile(STATE);
if (type(raw) != 'string' || length(raw) > 4096) return state;
for (let line in split(type(raw) == 'string' ? raw : '', /\r?\n/)) {
const fields = split(line, /\t/);
if (length(fields) != 2) continue;
const key = fields[0];
const value = fields[1];
if (key == 'phase' && match(value, /^(idle|starting|running|success|failed)$/) != null)
state.phase = value;
else if (key == 'action' && (value == 'install' || value == 'update' || value == 'remove'))
state.action = value;
else if (key == 'app_id' && match(value, /^[a-z0-9][a-z0-9-]{0,79}$/) != null)
state.appId = value;
else if (key == 'code' && match(value, /^(NONE|LAUNCH|E[0-9]{1,3})$/) != null)
state.code = value;
else if (key == 'operation_id' && match(value, /^[0-9]{1,12}-[0-9]{1,12}(-[0-9a-f]{16})?$/) != null)
state.operationId = value;
}
return state;
}
function restore_status() {
const result = { phase: 'idle', appId: '', code: '' };
const raw = fs.readfile(RESTORE_STATE);
if (type(raw) != 'string' || length(raw) > 4096) return result;
for (let line in split(raw, /\r?\n/)) {
const fields = split(line, /\t/);
if (length(fields) != 2) continue;
if (fields[0] == 'phase' && match(fields[1], /^(idle|starting|running|success|failed)$/) != null)
result.phase = fields[1];
else if (fields[0] == 'app_id' && (fields[1] == '' || match(fields[1], /^[a-z0-9][a-z0-9-]{0,79}$/) != null))
result.appId = fields[1];
else if (fields[0] == 'code' && match(fields[1], /^(NONE|BUSY|TIMEOUT|INCOMPLETE|INVALID_MANIFEST|UNKNOWN|E[0-9]{1,3})$/) != null)
result.code = fields[1];
}
return result;
}
export function read_apps_status(request) {
if (request?.args?.refresh == true) request_job('status');
else ensure_job('status');
const scan = job_state('status');
const raw = fs.readfile(STATUS);
if (type(raw) != 'string' || length(raw) > 16384)
return failure('APPS_STATUS_INVALID', '설치 상태 데이터가 올바르지 않습니다.');
const installed = {};
for (let line in split(raw, /\r?\n/)) {
const fields = split(line, /\t/);
if ((length(fields) != 4 && length(fields) != 5 && length(fields) != 6 && length(fields) != 7 && length(fields) != 8) ||
match(fields[0], /^[a-z0-9][a-z0-9-]{0,79}$/) == null)
continue;
installed[fields[0]] = {
installed: fields[1] == '1',
partial: fields[2] == '1',
managed: fields[3] == '1',
// Only the local manifest scanner can mark a removed approval as retired.
retired: length(fields) == 8 && fields[7] == '1',
webUiReady: length(fields) >= 5 && fields[4] == '1',
updateAvailable: length(fields) >= 6 && fields[5] == '1',
// This is a representative installed APK version, not the Hub's
// approved app/catalog version. Never guess when missing.
installedVersion: length(fields) >= 7 && fields[1] == '1' &&
match(fields[6], /^[a-zA-Z0-9_][a-zA-Z0-9._+~-]{0,99}$/) != null
? fields[6] : null,
};
}
return success({ job: job(), restore: restore_status(), installed: installed,
ready: type(raw) == 'string',
refreshing: scan.phase == 'starting' || scan.phase == 'running',
refreshFailed: scan.phase == 'failed' });
};
// Optional data lives behind an independent RPC. A slow APK database lookup
// or filesystem error must never prevent installation/removal status checks.
export function read_apps_metrics(request) {
let storage = null;
try {
storage = writable_storage();
}
catch (e) {
// Older/broken filesystem modules cannot disable core Apps operations.
warn('smartsafehub: Apps storage statistics unavailable\n');
}
const installedSizes = {};
// Disk accounting is optional and runs outside rpcd. Parse only the
// last atomically committed snapshot; it may be empty on first load.
if (request?.args?.refresh == true) request_job('sizes');
else ensure_job('sizes');
const sizesScan = job_state('sizes');
const raw = fs.readfile(SIZES);
if (type(raw) == 'string' && length(raw) <= 8192) {
for (let line in split(raw, /\r?\n/)) {
const fields = split(line, /\t/);
if (length(fields) != 2 ||
match(fields[0], /^[a-z0-9][a-z0-9-]{0,79}$/) == null ||
match(fields[1], /^(0|[1-9][0-9]{0,12})$/) == null)
continue;
installedSizes[fields[0]] = int(fields[1]);
}
}
return success({ storage: storage, installedSizes: installedSizes,
ready: type(raw) == 'string',
refreshing: sizesScan.phase == 'starting' || sizesScan.phase == 'running' });
};
function start_apps_operation(request, action) {
// rpcd passes declared method parameters in request.args, not request itself.
const app_id = request?.args?.app_id;
if (type(app_id) != 'string' || match(app_id, /^[a-z0-9][a-z0-9-]{0,79}$/) == null)
return failure('APPS_INVALID_ID', '올바르지 않은 앱입니다.');
if (!run_command([ MANAGER, 'start', action, app_id ], 5000))
return failure('APPS_OPERATION_REJECTED', '다른 작업이 진행 중이거나 요청을 시작하지 못했습니다.');
return success({ accepted: true, appId: app_id, action: action });
}
export function install_app(request) { return start_apps_operation(request, 'install'); };
export function update_app(request) { return start_apps_operation(request, 'update'); };
export function remove_app(request) { return start_apps_operation(request, 'remove'); };
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: GPL-3.0-or-later
'use strict';
const MAX_BYTES = 9007199254740991; // JavaScript Number.MAX_SAFE_INTEGER
function valid_counter(value) {
return (type(value) == 'int' || type(value) == 'double') &&
value >= 0 && value <= MAX_BYTES;
}
function storage_bytes(total, available, mount) {
if (!valid_counter(total) || !valid_counter(available) ||
total <= 0 || available > total)
return null;
return { mount: mount, totalBytes: total, availableBytes: available };
}
// procd system.info.root.total/avail are KiB, NOT bytes. In OpenWrt
// with overlayfs, / is the writable union and its free blocks come
// from the underlying writable overlay filesystem.
export function from_system_root(root) {
if (!root || !valid_counter(root.total) || !valid_counter(root.avail) ||
root.total > MAX_BYTES / 1024 || root.avail > root.total)
return null;
return storage_bytes(root.total * 1024, root.avail * 1024, '/');
};
// Filesystem fallback for firmware without procd root statistics.
// The ucode type() builtin calls integers "int", not "integer".
export function from_statvfs(stats, mount) {
if (!stats || (mount != '/overlay' && mount != '/'))
return null;
const frsize = valid_counter(stats.frsize) && stats.frsize > 0
? stats.frsize : stats.bsize;
if (!valid_counter(frsize) || frsize <= 0 ||
!valid_counter(stats.blocks) || !valid_counter(stats.bavail) ||
stats.blocks <= 0 || stats.bavail > stats.blocks ||
stats.blocks > MAX_BYTES / frsize)
return null;
return storage_bytes(stats.blocks * frsize, stats.bavail * frsize, mount);
};
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: GPL-3.0-or-later
'use strict';
import * as fs from 'fs';
import { failure, success } from './core.uc';
import { ensure_job, job_state, request_job } from './rpc-jobs.uc';
const CATALOG_FILE = '/tmp/smartsafehub/apps-catalog.json';
function text(value, max_length) {
return type(value) == 'string' && length(value) <= max_length ? value : '';
}
// Only open LuCI pages served by this router. Reject query strings, fragments,
// percent escapes, scheme-relative URLs and path traversal from catalog data.
function luci_web_ui_path(value) {
if (!text(value, 160) ||
match(value, /^\/cgi-bin\/luci\/admin\/[a-z0-9_-]+(\/[a-z0-9_-]+)*$/) == null)
return '';
return value;
}
// This endpoint is *read-only*. The catalog never supplies executable
// commands, repository URLs or permissions to a package installer. The
// separate privileged installer must revalidate every approval in the future.
export function read_apps_catalog(request) {
// Never perform an HTTP request on rpcd's event loop. Serve a last-known
// display-only snapshot while the detached worker updates it atomically.
if (request?.args?.refresh == true) request_job('catalog');
else ensure_job('catalog');
const operation = job_state('catalog');
const raw = fs.readfile(CATALOG_FILE);
const pending = operation.phase == 'starting' || operation.phase == 'running';
if (type(raw) != 'string' || length(raw) < 2) {
return success({ schema: 1, deviceCode: '', firmwareBuildId: '', channel: '', apps: [],
ready: false, refreshing: pending, refreshFailed: operation.phase == 'failed' });
}
if (length(raw) > 131072) {
return failure('APPS_CATALOG_INVALID', '앱 목록 데이터가 올바르지 않습니다.');
}
let data;
try {
data = json(raw);
}
catch (e) {
return failure('APPS_CATALOG_INVALID', '앱 목록을 읽지 못했습니다.');
}
if (type(data) != 'object' || data?.schema != 1 || type(data?.apps) != 'array' ||
length(data.apps) > 100 || !text(data?.device_code, 80) ||
!text(data?.firmware_build_id, 80)) {
return failure('APPS_CATALOG_INVALID', '앱 목록 형식이 올바르지 않습니다.');
}
// A firmware change invalidates the old catalog before any cards are shown.
// The ROM metadata takes precedence over writable overlay metadata.
let metadataRaw = fs.readfile('/rom/usr/share/smartsafehub/firmware.json');
if (type(metadataRaw) != 'string' || !length(metadataRaw))
metadataRaw = fs.readfile('/usr/share/smartsafehub/firmware.json');
if (type(metadataRaw) == 'string' && length(metadataRaw)) {
let metadata = null;
try { metadata = json(metadataRaw); } catch (e) { /* reject stale catalog below */ }
if (metadata == null || metadata?.device_code != data.device_code ||
metadata?.build_id != data.firmware_build_id ||
(metadata?.channel ?? 'stable') != (data?.channel ?? 'stable'))
return success({ schema: 1, deviceCode: '', firmwareBuildId: '', channel: '', apps: [],
ready: false, refreshing: pending, refreshFailed: operation.phase == 'failed' });
}
const apps = [];
for (let item in data.apps) {
if (type(item) != 'object' || item.runtime != 'router' || item.installer != 'apk') {
continue;
}
const id = text(item?.id, 80);
const name = text(item?.name, 120);
if (!length(id) || !length(name) || match(id, /^[a-z0-9][a-z0-9-]{0,79}$/) == null) {
continue;
}
// Backward-compatible route for the already approved nlbwmon catalog.
// The page is only launchable if its UI package is actually installed.
let path = '';
if (id == 'wake-on-lan') {
if (item?.web_ui?.type == 'luci' &&
item?.web_ui?.path == '/cgi-bin/luci/admin/services/wol' &&
item?.web_ui?.package == 'luci-app-wol')
path = '/cgi-bin/luci/admin/services/wol';
}
else if (item?.web_ui?.type == 'luci')
path = luci_web_ui_path(item.web_ui.path);
else if (id == 'retro-arcade' && item?.web_ui?.type == 'static' &&
item?.web_ui?.path == '/retro-arcade/' &&
item?.web_ui?.package == 'smartsafehub-app-retro-arcade')
path = '/retro-arcade/';
else if (id == 'network-usage-monitor')
path = '/cgi-bin/luci/admin/services/nlbw/display';
// Only UI copy crosses into the unprivileged browser. All package hashes,
// permissions and install descriptors remain server-side until an installer
// with independent verification is implemented.
push(apps, {
id: id,
name: name,
summary: text(item?.summary, 320),
description: text(item?.description, 2000),
category: text(item?.category, 64),
version: text(item?.version, 64),
webUi: length(path) ? { type: id == 'retro-arcade' && path == '/retro-arcade/' ? 'static' : 'luci', path: path } : null,
});
}
return success({
schema: 1,
deviceCode: data.device_code,
firmwareBuildId: data.firmware_build_id,
channel: text(data?.channel, 16),
apps: apps,
ready: true,
refreshing: pending,
refreshFailed: operation.phase == 'failed',
});
};
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: GPL-3.0-or-later
'use strict';
import * as fs from 'fs';
import { failure, run_command, success } from './core.uc';
import { failure, success } from './core.uc';
import { ensure_job, job_state, request_job } from './rpc-jobs.uc';
const STATE_FILE = '/tmp/smartsafehub/device.json';
const HELPER = '/usr/libexec/smartsafehub-device';
function read_state() {
@@ -48,7 +49,11 @@ export function read_recommended_rules_status(request) {
// A busy SafeShield refresh is pending work, not an application failure.
// Keep an older verified receipt for its revision without treating a newer
// in-progress operation as already complete.
const syncStatus = sync?.status == 'pending' ? 'pending' :
const syncOperation = job_state('recommended-sync');
const revokeOperation = job_state('recommended-revoke');
const operationRunning = syncOperation.phase == 'starting' || syncOperation.phase == 'running' ||
revokeOperation.phase == 'starting' || revokeOperation.phase == 'running';
const syncStatus = operationRunning || sync?.status == 'pending' ? 'pending' :
confirmed ? 'confirmed' : sync?.status == 'delayed' ? 'delayed' :
sync?.status == 'failed' ? 'failed' : 'pending';
const syncWarning = confirmed && (sync?.status == 'failed' || sync?.status == 'delayed');
@@ -60,32 +65,45 @@ export function read_recommended_rules_status(request) {
confirmedDomains: confirmedDomains,
syncStatus: syncStatus,
syncWarning: syncWarning,
syncStage: sync?.stage ?? null,
syncStage: operationRunning ? 'server_sync' : (sync?.stage ?? null),
syncReason: sync?.reason ?? null,
lastCheckedAt: sync?.checkedAt ?? 0,
lastDeviceSyncAt: device?.lastSuccessAt ?? 0,
nextDeviceSyncAt: device?.nextSyncAt ?? 0,
accountRegistered: device?.accountRegistered ?? false
accountRegistered: device?.accountRegistered ?? false,
operation: syncOperation,
revokeOperation: revokeOperation
});
};
// A request means "accepted", never "the remote rule has been applied".
// The local receipt and operation phase remain separate authoritative fields.
export function refresh_recommended_rules_status(request) {
// The blocklist download and the Hub device sync are different operations.
// Reuse the authenticated device sync, never the SafeShield blocklist refresh.
if (!run_command([ HELPER, 'recommended-sync' ], 25000)) {
const latest = read_json_file(RECOMMENDED_SYNC_STATUS);
if (latest?.status != 'pending' && latest?.status != 'delayed')
return failure('RECOMMENDED_SYNC_UNAVAILABLE', 'SmartSafeHub 서버와 동기화하지 못했습니다. 잠시 후 다시 시도해 주세요.');
}
if (!request_job('recommended-sync'))
return failure('RECOMMENDED_SYNC_BUSY', '이미 다른 추천 규칙 동기화 작업이 진행 중입니다.');
return read_recommended_rules_status(request);
};
export function revoke_recommended_rule(request) {
const rule_id = request?.args?.rule_id;
if (rule_id == null || match(sprintf('%s', rule_id), /^[1-9][0-9]{0,9}$/) == null)
return failure('INVALID_RULE_ID', '적용 해제할 추천 규칙을 선택해 주세요.');
if (!run_command([ HELPER, 'recommended-revoke', sprintf('%s', rule_id) ], 25000))
return failure('RECOMMENDED_REVOKE_FAILED', '규칙 해제 또는 SafeShield 반영에 실패했습니다. 서버 변경은 다음 동기화에서 반영될 수 있습니다.');
if (!request_job('recommended-revoke', rule_id))
return failure('RECOMMENDED_REVOKE_BUSY', '이미 추천 규칙 해제 작업이 진행 중입니다.');
return read_recommended_rules_status(request);
};
export function read_device_registration_status(request) { const state = read_state(); return state != null ? success(state) : failure('DEVICE_STATE_INVALID', '기기 등록 상태를 읽지 못했습니다.'); };
export function refresh_device_registration_status(request) { if (!run_command([ HELPER, 'status-sync' ], 25000)) return failure('DEVICE_SYNC_FAILED', 'SmartSafeHub 계정 연결 상태를 확인하지 못했습니다.'); const state = read_state(); return state != null ? success(state) : failure('DEVICE_STATE_INVALID', '기기 등록 상태를 읽지 못했습니다.'); };
export function refresh_device_pairing(request) { if (!run_command([ HELPER, 'pairing-session' ], 15000)) return failure('DEVICE_PAIRING_FAILED', 'SmartSafeHub 계정 연결 코드를 발급하지 못했습니다.'); const state = read_state(); return state != null ? success(state) : failure('DEVICE_STATE_INVALID', '기기 등록 상태를 읽지 못했습니다.'); };
export function read_device_registration_status(request) {
const state = read_state();
if (state == null) return failure('DEVICE_STATE_INVALID', '기기 등록 상태를 읽지 못했습니다.');
state.operation = job_state('device-sync');
state.pairingOperation = job_state('pairing');
return success(state);
};
export function refresh_device_registration_status(request) {
if (!ensure_job('device-sync'))
return failure('DEVICE_SYNC_LAUNCH_FAILED', '서버 상태 확인을 시작하지 못했습니다.');
return read_device_registration_status(request);
};
export function refresh_device_pairing(request) {
if (!request_job('pairing'))
return failure('DEVICE_PAIRING_BUSY', '연결 코드 발급을 시작하지 못했습니다. 잠시 후 다시 시도해 주세요.');
return read_device_registration_status(request);
};
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-3.0-or-later
'use strict';
import * as fs from 'fs';
import { run_command } from './core.uc';
const HELPER = '/usr/libexec/smartsafehub-rpc-jobs';
const RUNTIME = '/tmp/smartsafehub/rpc-jobs/';
// Only fixed job names reach the privileged launcher. Never accept arbitrary
// executable paths, shell fragments or network locations from the browser.
export function job_state(name) {
const state = { phase: 'idle', code: '', argument: '', updatedAt: 0 };
const raw = fs.readfile(RUNTIME + name + '.state');
if (type(raw) != 'string' || length(raw) > 1024) return state;
for (let line in split(raw, /\r?\n/)) {
const fields = split(line, /\t/);
if (length(fields) != 2) continue;
if (fields[0] == 'phase' && match(fields[1], /^(starting|running|success|failed)$/) != null)
state.phase = fields[1];
else if (fields[0] == 'code' && match(fields[1], /^(NONE|LAUNCH|E[0-9]{1,3})$/) != null)
state.code = fields[1];
else if (fields[0] == 'argument' && match(fields[1], /^[0-9]{0,10}$/) != null)
state.argument = fields[1];
else if (fields[0] == 'updated' && match(fields[1], /^[0-9]{1,12}$/) != null)
state.updatedAt = int(fields[1]);
}
return state;
};
export function ensure_job(name) {
return run_command([ HELPER, 'ensure', name ], 1500);
};
export function request_job(name, argument) {
const command = [ HELPER, 'request', name ];
if (argument != null) push(command, sprintf('%s', argument));
return run_command(command, 1500);
};
@@ -4,6 +4,7 @@
import * as fs from 'fs';
import { read_activity_history } from './activity.uc';
import { from_system_root, from_statvfs } from './apps-storage.uc';
import {
defer_call,
@@ -563,6 +564,27 @@ export function reboot_system(request) {
});
};
// The dashboard already has system.info; some firmware images omit its root
// counters. Reuse Apps' statvfs fallback instead of displaying an empty donut.
// No extra ubus request or spawned process is required.
function dashboard_writable_storage(root) {
const from_root = from_system_root(root);
if (from_root != null)
return from_root;
for (let mount in [ '/overlay', '/' ]) {
try {
const storage = from_statvfs(fs.statvfs(mount), mount);
if (storage != null)
return storage;
}
catch (e) {
// Try the next known writable root; failure is optional.
}
}
return null;
}
function system_status_payload(board, info, wan) {
const release = board?.release ?? {};
const memory = info?.memory ?? {};
@@ -585,6 +607,8 @@ function system_status_payload(board, info, wan) {
runtime: {
uptime: number_value(info?.uptime),
localtime: number_value(info?.localtime),
// Reuse the same writable root counters as Apps; no extra RPC or APK scan.
storage: dashboard_writable_storage(info?.root),
load: [
number_value(load?.[0]),
number_value(load?.[1]),
@@ -10,16 +10,16 @@
<meta name="robots" content="noindex,nofollow,noarchive">
<meta name="referrer" content="same-origin">
<title>SmartSafeHub</title>
<link rel="icon" type="image/svg+xml" href="/luci-static/smartsafehub/favicon.svg?v=0.2.30-r1">
<link rel="icon" type="image/svg+xml" href="/luci-static/smartsafehub/favicon.svg?v=0.2.31-r1">
</head>
<body style="margin:0;background:#f8fafc;min-height:100vh;">
<div
id="smartsafehub-entry-root"
data-asset-base="/luci-static/smartsafehub/"
data-asset-version="0.2.30-r1"
data-asset-version="0.2.31-r1"
style="display:block;width:100%;min-height:100vh;"
></div>
<noscript>SmartSafeHub를 사용하려면 JavaScript를 활성화해 주세요.</noscript>
<script type="module" src="/luci-static/smartsafehub/app.js?v=0.2.30-r1"></script>
<script type="module" src="/luci-static/smartsafehub/app.js?v=0.2.31-r1"></script>
</body>
</html>
@@ -1,3 +1,3 @@
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties{:host,*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-outline-style:solid;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:Line truncated
@layer properties{:host,*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:10Line truncated
/*$vite$:1*/
@@ -1,4 +1,3 @@
var e,t,n,r,i,a,o,s,c,l,u={},d=[],f=/^m(i|n|o|s|text|space)$/,p=Array.isArray,m=d.slice,h=Object.assign;function g(e){e&&e.parentNode&&e.remove()}function _(n,r,i,a,o){var s={type:n,props:r,key:i,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:o||++t,__i:-1,__u:0};return!o&&e.vnode&&e.vnode(s),s}function v(e){return e.children}function y(e,t){this.props=e,this.context=t,this.__g=0}function b(e,t){if(t==null)return e.__?b(e.__,e.__i+1):null;for(var n;t<e.__k.length;t++)if((n=e.__k[t])&&n.__e)return n.__e;return typeof e.type!=`function`||e.props.__P?null:b(e)}function x(e){if((e=e.__)&&e.__c&&!e.props.__P)return e.__e=null,e.__k.some(function(t){return t&&(e.__e=t.__e)}),x(e)}function S(t){(8&t.__g||!(t.__g|=8)||!n.push(t)||i++)&&r==e.debounceRendering||((r=e.debounceRendering)||queueMicrotask)(C)}function C(){var t,r,o,s,c,l,u,d,f;try{for(r=1;n.length;)n.length>r&&n.sort(a),t=n.shift(),r=n.length,8&t.__g&&(s=void 0,c=void 0,l=(c=(o=t).__v).__e,u=[],d=[],(f=o.__P)&&((s=h({constructor:void 0},c)).__v=c.__v+1,e.vnode&&e.vnode(s),ee(f,s,c,o.__n,f.namespaceURI,32&c.__u?[l]:null,u,l||b(c),32&c.__u,d),s.__v=c.__v,s.__.__k[s.__i]=s,ne(u,s,d),c.__=c.__e=null,s.__e!=l&&x(s)))}finally{n.length=i=0}}function w(e,t,n,r,i,a,o,s,c,l,f){var p,m,h,g,_,v,y=r.__k||d,b=t.length;for(c=T(n,t,y,c,b),p=0;p<b;p++)(h=n.__k[p])!=null&&(m=~h.__i&&y[h.__i]||u,h.__i=p,v=ee(e,h,m,i,a,o,s,c,l,f),g=h.__e,(m.ref!=h.ref||8&m.__u)&&(m.ref&&ie(m.ref,null,h,m),h.ref&&f.push(h.ref,h.__c||g,h)),_=_||g,4&h.__u?(c=E(h,c,e,!m.__v),m.__e&&(m.__e=null)):typeof h.type==`function`&&v!==void 0?c=v:g&&(c=g.nextSibling),h.__u&=-7);return n.__e=_,c}function T(e,t,n,r,i){var a,o,s,c,l,u,d,f,m,h,g=n.length,y=g,x=0,S=!1,C=e.__k=Array(i);for(a=0;a<i;a++)(o=t[a])!=null&&typeof o!=`boolean`&&typeof o!=`function`?(typeof o!=`object`||o.constructor==String?o=C[a]=_(null,o):p(o)?o=C[a]=_(v,{children:o}):o.constructor===void 0&&o.__b?o=C[a]=_(o.type,o.props,o.key,o.ref,o.__v):C[a]=o,c=a+x,o.__=e,o.__b=e.__b+1,s=null,~(l=o.__i=O(o,n,c,y))&&(y--,(s=n[l])&&(s.__u|=2)),s&&s.__v?(o.__u|=2,l==c-1?x--:l==c+1?x++:l!=c&&(l>c?x--:x++,S=!0)):(~l||(i>g?x--:i<g&&x++),typeof o.type!=`function`&&(o.__u|=4))):C[a]=null;if(S){for(u=[],d=[],a=0;a<i;a++)if((o=C[a])&&2&o.__u){for(f=0,m=u.length;f<m;)u[h=f+m>>1]<o.__i?f=h+1:m=h;u[f]=o.__i,d[a]=f+1}for(x=u.length;a--;)d[a]&&(d[a]==x?x--:C[a].__u|=4)}if(y)for(a=0;a<g;a++)!(s=n[a])||2&s.__u||(s.__e==r&&(r=b(s)),ae(s,s));return r}function E(e,t,n,r){var i,a,o;if(typeof e.type==`function`){if(e.props.__P)return t;if(i=e.__k)for(a=0;a<i.length;a++)i[a]&&(i[a].__=e,t=E(i[a],t,n,!1));return t}for(t&&!t.parentNode&&(t=b(e))&&!t.parentNode&&(t=null),o=t;o&&o.nodeType==8;)o=o.nextSibling;for(e.__e!=o&&(!r&&n.moveBefore&&e.__e.parentNode?n.moveBefore(e.__e,t):n.insertBefore(e.__e,t||null)),t=e.__e;(t=t&&t.nextSibling)&&t.nodeType==8;);return t}function D(e,t){return t=t||[],e!=null&&typeof e!=`boolean`&&(p(e)?e.some(function(e){D(e,t)}):t.push(e)),t}function O(e,t,n,r){var i,a,o,s=e.key,c=e.type,l=t[n],u=l&&!(2&l.__u);if(l===null&&s==null||u&&s==l.key&&c==l.type)return n;if(r>+!!u){for(i=n-1,a=n+1;i>=0||a<t.length;)if((l=t[o=i>=0?i--:a++])&&!(2&l.__u)&&s==l.key&&c==l.type)return o}return-1}function k(e,t,n){n??(n=``),t[0]==`-`?e.setProperty(t,n):e[t]=n}function A(e,t,n,r,i){var a,o;n:if(t==`style`){if(typeof n==`string`)e.style.cssText=n;else{if(typeof r==`string`&&(e.style.cssText=r=``),r)for(t in r)n&&t in n||k(e.style,t,``);if(n)for(t in n)r&&n[t]==r[t]||k(e.style,t,n[t])}}else if(t[0]==`o`&&t[1]==`n`)(e.__e||(e.__e={}))[t]=n,n&&r||(o=l[t]||(l[t]=j(t)),(e.__a||(e.__a={}))[t]=c,a=t!=(t=t.replace(s,`$1`)),(t=t.slice(2))[0]<`a`&&(t=t.toLowerCase()),n?e.addEventListener(t,o,a):e.removeEventListener(t,o,a));else{if(i==`http://www.w3.org/2000/svg`)t=t.replace(/xlink(H|:h)/,`h`).replace(/sName$/,`s`);else if(t!=`width`&&t!=`height`&&t!=`href`&&t!=`list`&&t!=`form`&&t!=`tabIndex`&&t!=`download`&&t!=`rowSpan`&&t!=`colSpan`&&t!=`role`&&t!=`popover`&&t in e)try{e[t]=n??``;break n}catch{}typeof n==`function`||(n==null||!1===n&&t[4]!=`-`?e.removeAttribute(t):e.setAttribute(t,t==`popover`&&n==1?``:n))}}function j(t){return function(n){if(this.__e){var r=this.__e[t];if(n[o]==null)n[o]=c++;else if(n[o]<this.__a[t])return;return r(e.event?e.event(n):n)}}}function ee(t,n,r,i,a,o,s,c,l,u){var f,m,_,x,S,C,T,E,D,O,k,A,j,ee,ne,ie,se,ce,le,ue,de=n.type;if(n.constructor!==void 0)return null;if(128&r.__u&&(l=32&r.__u,f=r.__c.__z)){if(n.__u|=l,m=o=[],f.nodeType==8)for(_=1,x=f.nextSibling;x;x=x.nextSibling){if(x.nodeType==8){if(x.data.startsWith(`$s`))_++;else if(x.data.startsWith(`/$s`)&&!--_)break}o.push(x)}else o.push(f);c=o[0]}(f=e.__b)&&f(n);n:if(typeof de==`function`){S=s.length;try{if(O=n.props,k=(f=de.prototype)&&f.render,A=(f=de.contextType)&&i[f.__c],j=f?A?A.props.value:f.__:i,r.__c?2&(C=n.__c=r.__c).__g&&(C.__g|=1):(k?n.__c=C=new de(O,j):(n.__c=C=new y(O,j),C.constructor=de,C.render=oe),A&&A.sub(C),C.state||(C.state={}),C.__n=i,C.__g|=8,C.Line truncated
var e,t,n,r,i,a,o,s,c,l,u=Symbol.for(`pv`),d={},f=[],p=/^m(i|n|o|s|text|space)$/,m=Array.isArray,h=f.slice,g=Object.assign;function _(e){e&&e.parentNode&&e.remove()}function v(e,t,n){var r,i,a,o={},s=arguments.length;for(a in t)a==`key`?r=t[a]:a==`ref`&&typeof e!=`function`?i=t[a]:o[a]=t[a];return s>2&&(o.children=s>3?h.call(arguments,2):n),y(e,o,r,i,null)}function y(n,r,i,a,o){var s={type:n,props:r,key:i,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:u,__v:o||++t,__i:-1,__u:0};return!o&&e.vnode&&e.vnode(s),s}function b(e){return e.children}function x(e,t){this.props=e,this.context=t,this.__g=0}function S(e,t){if(t==null)return e.__&&S(e.__,e.__i+1);for(var n;t<e.__k.length;t++)if((n=e.__k[t])&&n.__e)return n.__e;return typeof e.type!=`function`||e.props.__P?null:S(e)}function C(e){if((e=e.__)&&e.__c&&!e.props.__P)return e.__e=null,e.__k.some(function(t){return t&&(e.__e=t.__e)}),C(e)}function w(t){(8&t.__g||(t.__g|=8,n.push(t),i++))&&r==e.debounceRendering||((r=e.debounceRendering)||queueMicrotask)(T)}function T(){var t,o,s,c,l,d,f,p,m;try{for(o=1;n.length;)n.length>o&&n.sort(a),t=n.shift(),o=n.length,8&t.__g&&(c=void 0,l=void 0,d=(l=(s=t).__v).__e,f=[],p=[],(m=s.__P)&&((c=g({constructor:u},l)).__v=l.__v+1,e.vnode&&e.vnode(c),P(m,c,l,s.__n,m.namespaceURI,32&l.__u?[d]:null,f,d||S(l),32&l.__u,p),c.__v=l.__v,c.__.__k[c.__i]=c,ne(f,c,p),l.__=l.__e=null,c.__e!=d&&C(c)))}finally{(i=n.length)&&(r||queueMicrotask)(T)}}function E(e,t,n,r,i,a,o,s,c,l,u){var p,m,h,g,_,v,y=r.__k||f,b=t.length;for(c=D(n,t,y,c,b),p=0;p<b;p++)(h=n.__k[p])!=null&&(m=~h.__i&&y[h.__i]||d,h.__i=p,v=P(e,h,m,i,a,o,s,c,l,u),g=h.__e,(m.ref!=h.ref||8&m.__u)&&(m.ref&&ie(m.ref,null,h,m),h.ref&&u.push(h.ref,h.__c||g,h)),_=_||g,4&h.__u?(c=O(h,c,e,!m.__v),m.__e&&(m.__e=null)):typeof h.type==`function`&&v!==void 0?c=v:g&&(c=g.nextSibling),h.__u&=-21);return n.__e=_,c}function D(e,t,n,r,i){var a,o,s,c,l,d,f,p,h,g=n.length,_=g,v=0,x=0,C=e.__k=Array(i);for(a=0;a<i;a++)(o=t[a])!=null&&typeof o!=`boolean`&&typeof o!=`function`?(typeof o!=`object`||o.constructor===String?o=C[a]=y(null,o):m(o)?o=C[a]=y(b,{children:o}):o.constructor===u&&o.__b?o=C[a]=y(o.type,o.props,o.key,o.ref,o.__v):C[a]=o,c=a+v,o.__=e,o.__b=e.__b+1,s=null,~(l=o.__i=A(o,n,c,_))&&(_--,(s=n[l])&&(s.__u|=2)),s&&s.__v?(o.__u|=16,l==c-1?v--:l==c+1?v++:l!=c&&(l>c?v--:v++,x=1)):(~l||(i>g?v--:i<g&&v++),typeof o.type!=`function`&&(o.__u|=4))):C[a]=null;if(x){for(d=[],f=[],a=0;a<i;a++)if((o=C[a])&&16&o.__u){for(v=0,p=d.length;v<p;)d[h=v+p>>1]<o.__i?v=h+1:p=h;d[v]=o.__i,f[a]=v+1}for(v=d.length;a--;)f[a]&&(f[a]==v?v--:C[a].__u|=4)}if(_)for(a=0;a<g;a++)!(s=n[a])||2&s.__u||(s.__e==r&&(r=S(s)),ae(s,s));return r}function O(e,t,n,r){var i,a,o,s;if(typeof e.type==`function`){if(e.props.__P)return t;if(i=e.__k)for(a=0;a<i.length;a++)i[a]&&(i[a].__=e,t=O(i[a],t,n,!1));return t}for(t&&!t.parentNode&&(t=S(e))&&!t.parentNode&&(t=null),o=e.__e,s=t;s&&s.nodeType==8;)s=s.nextSibling;for(o!=s&&(!r&&n.moveBefore&&o.parentNode?n.moveBefore(o,t):n.insertBefore(o,t||null)),t=o;(t=t&&t.nextSibling)&&t.nodeType==8&&!t.data.startsWith(`$s`););return t}function k(e,t){return t=t||[],e!=null&&typeof e!=`boolean`&&(m(e)?e.some(function(e){k(e,t)}):t.push(e)),t}function A(e,t,n,r){var i,a=e.key,o=e.type,s=t[n],c=s&&!(2&s.__u);if(s===null&&a==null||c&&a==s.key&&o==s.type)return n;if(r>+!!c){for(i=n-1;i>=0;i--)if((s=t[i])&&!(2&s.__u)&&a==s.key&&o==s.type)return i;for(i=n+1;i<t.length;i++)if((s=t[i])&&!(2&s.__u)&&a==s.key&&o==s.type)return i}return-1}function j(e,t,n){n??(n=``),t[0]==`-`?e.setProperty(t,n):e[t]=n}function M(e,t,n,r,i){var a,o;if(t==`style`){if(typeof n==`string`)e.style.cssText=n;else{if(typeof r==`string`&&(e.style.cssText=r=``),r)for(t in r)n&&t in n||j(e.style,t);if(n)for(t in n)r&&n[t]==r[t]||j(e.style,t,n[t])}}else if(t[0]==`o`&&t[1]==`n`)(e.__e||(e.__e={}))[t]=n,n&&r||(o=l[t]||(l[t]=N(t)),(e.__a||(e.__a={}))[t]=c,a=t!=(t=t.replace(s,`$1`)),(t=t.slice(2))[0]<`a`&&(t=t.toLowerCase()),n?e.addEventListener(t,o,a):e.removeEventListener(t,o,a));else{if(i==`http://www.w3.org/2000/svg`)t=t.replace(/xlink(H|:h)/,`h`).replace(/sName$/,`s`);else if(t!=`width`&&t!=`height`&&t!=`href`&&t!=`list`&&t!=`form`&&t!=`tabIndex`&&t!=`download`&&t!=`rowSpan`&&t!=`colSpan`&&t!=`role`&&t!=`popover`&&t in e)try{e[t]=n??``;return}catch{}if(typeof n==`function`)return;n==null||!1===n&&t[4]!=`-`?e.removeAttribute(t):e.setAttribute(t,t==`popover`&&n==1?``:n)}}function N(t){return function(n){var r=this.__e;if(r){if(r=r[t],n[o]==null)n[o]=c++;else if(n[o]<this.__a[t])return;return r(e.event?e.event(n):n)}}}function P(t,n,r,i,a,o,s,c,l,d){var p,h,v,y,C,w,T,D,O,k,A,j,M,N,P,ne,ie,se,ce=n.type;if(n.constructor!==u)return null;128&r.__u&&(l=32&r.__u,p=r.__c.__z)&&(n.__u|=l,h=o=[],p.nodeType==8?c=ee(p,o):o.push(p),c=o[0]||c),(p=e.__b)&&p(n);n:if(typeof ce==`function`){v=s.length;try{if(D=n.props,O=(p=ce.prototype)&&p.render,k=(p=ce.contextType)&&i[p.__c],A=p?k?k.props.value:p.__:i,(y=r.__c)?(n.__c=y,2&y.__g&&(y.__g|=1)):(O?n.__c=yLine truncated
`)>-1?e.split(`
`):e}function zm(e,t){let{element:n,datasetIndex:r,index:i}=t,a=e.getDatasetMeta(r).controller,{label:o,value:s}=a.getLabelAndValue(i);return{chart:e,label:o,parsed:a.getParsed(i),raw:e.data.datasets[r].data[i],formattedValue:s,dataset:a.getDataset(),dataIndex:i,datasetIndex:r,element:n}}function Bm(e,t){let n=e.chart.ctx,{body:r,footer:i,title:a}=e,{boxWidth:o,boxHeight:s}=t,c=Ml(t.bodyFont),l=Ml(t.titleFont),u=Ml(t.footerFont),d=a.length,f=i.length,p=r.length,m=jl(t.padding),h=m.height,g=0,_=r.reduce((e,t)=>e+t.before.length+t.lines.length+t.after.length,0);if(_+=e.beforeBody.length+e.afterBody.length,d&&(h+=d*l.lineHeight+(d-1)*t.titleSpacing+t.titleMarginBottom),_){let e=t.displayColors?Math.max(s,c.lineHeight):c.lineHeight;h+=p*e+(_-p)*c.lineHeight+(_-1)*t.bodySpacing}f&&(h+=t.footerMarginTop+f*u.lineHeight+(f-1)*t.footerSpacing);let v=0,y=function(e){g=Math.max(g,n.measureText(e).width+v)};return n.save(),n.font=l.string,Q(e.title,y),n.font=c.string,Q(e.beforeBody.concat(e.afterBody),y),v=t.displayColors?o+2+t.boxPadding:0,Q(r,e=>{Q(e.before,y),Q(e.lines,y),Q(e.after,y)}),v=0,n.font=u.string,Q(e.footer,y),n.restore(),g+=m.width,{width:g,height:h}}function Vm(e,t){let{y:n,height:r}=t;return n<r/2?`top`:n>e.height-r/2?`bottom`:`center`}function Hm(e,t,n,r){let{x:i,width:a}=r,o=n.caretSize+n.caretPadding;if(e===`left`&&i+a+o>t.width||e===`right`&&i-a-o<0)return!0}function Um(e,t,n,r){let{x:i,width:a}=n,{width:o,chartArea:{left:s,right:c}}=e,l=`center`;return r===`center`?l=i<=(s+c)/2?`left`:`right`:i<=a/2?l=`left`:i>=o-a/2&&(l=`right`),Hm(l,e,t,n)&&(l=`center`),l}function Wm(e,t,n){let r=n.yAlign||t.yAlign||Vm(e,n);return{xAlign:n.xAlign||t.xAlign||Um(e,t,n,r),yAlign:r}}function Gm(e,t){let{x:n,width:r}=e;return t===`right`?n-=r:t===`center`&&(n-=r/2),n}function Km(e,t,n){let{y:r,height:i}=e;return t===`top`?r+=n:r-=t===`bottom`?i+n:i/2,r}function qm(e,t,n,r){let{caretSize:i,caretPadding:a,cornerRadius:o}=e,{xAlign:s,yAlign:c}=n,l=i+a,{topLeft:u,topRight:d,bottomLeft:f,bottomRight:p}=Al(o),m=Gm(t,s),h=Km(t,c,l);return c===`center`?s===`left`?m+=l:s===`right`&&(m-=l):s===`left`?m-=Math.max(u,f)+i:s===`right`&&(m+=Math.max(d,p)+i),{x:bc(m,0,r.width-t.width),y:bc(h,0,r.height-t.height)}}function Jm(e,t,n){let r=jl(n.padding);return t===`center`?e.x+e.width/2:t===`right`?e.x+e.width-r.right:e.x+r.left}function Ym(e){return Lm([],Rm(e))}function Xm(e,t,n){return Fl(e,{tooltip:t,tooltipItems:n,type:`tooltip`})}function Zm(e,t){let n=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return n?e.override(n):e}var Qm={beforeTitle:Es,title(e){if(e.length>0){let t=e[0],n=t.chart.data.labels,r=n?n.length:0;if(this&&this.options&&this.options.mode===`dataset`)return t.dataset.label||``;if(t.label)return t.label;if(r>0&&t.dataIndex<r)return n[t.dataIndex]}return``},afterTitle:Es,beforeBody:Es,beforeLabel:Es,label(e){if(this&&this.options&&this.options.mode===`dataset`)return e.label+`: `+e.formattedValue||e.formattedValue;let t=e.dataset.label||``;t&&(t+=`: `);let n=e.formattedValue;return Y(n)||(t+=n),t},labelColor(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{borderColor:t.borderColor,backgroundColor:t.backgroundColor,borderWidth:t.borderWidth,borderDash:t.borderDash,borderDashOffset:t.borderDashOffset,borderRadius:0}},labelTextColor(){return this.options.bodyColor},labelPointStyle(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{pointStyle:t.pointStyle,rotation:t.rotation}},afterLabel:Es,afterBody:Es,beforeFooter:Es,footer:Es,afterFooter:Es};function $m(e,t,n,r){let i=e[t].call(n,r);return i===void 0?Qm[t].call(n,r):i}var eh=class extends Kf{constructor(e){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=e.chart,this.options=e.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(e){this.options=e,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){let e=this._cachedAnimations;if(e)return e;let t=this.chart,n=this.options.setContext(this.getContext()),r=n.enabled&&t.options.animation&&n.animations,i=new td(this.chart,r);return r._cacheable&&(this._cachedAnimations=Object.freeze(i)),i}getContext(){return this.$context||(this.$context=Xm(this.chart.getContext(),this,this._tooltipItems))}getTitle(e,t){let{callbacks:n}=t,r=$m(n,`beforeTitle`,this,e),i=$m(n,`title`,this,e),a=$m(n,`afterTitle`,this,e),o=[];return o=Lm(o,Rm(r)),o=Lm(o,Rm(i)),o=Lm(o,Rm(a)),o}getBeforeBody(e,t){return Ym($m(t.callbacks,`beforeBody`,this,e))}getBody(e,t){let{callbacks:n}=t,r=[];return Q(e Line truncated
`),l,g,x,ie]),N(()=>{if(!C)return;let e=window.setTimeout(()=>E(!0),6e4);return()=>window.clearTimeout(e)},[C]);async function ae(){if(!(!u||g||x)){_(!0),te(null),j(null);try{let e=await u();ie(e),j(e.syncStatus===`confirmed`?`서버에서 규칙을 가져와 SafeShield 허용 목록을 확인했습니다.`:`추천 규칙 상태를 확인 중입니다. 진행 결과는 자동으로 갱신됩니다.`),c()}catch{te(`추천 규칙을 동기화하지 못했습니다. 서버 연결 및 기기 등록 상태를 확인해 주세요.`)}finally{_(!1)}}}async function oe(){if(!y||x||!d)return;let e=y.id;D.current=e,S(!0),w(!0),E(!1),te(null),j(null);try{let t=await d(e);ie(t),b(null),D.current!==null&&j(`적용 해제 요청을 처리했습니다. 공유기의 허용 목록을 확인하고 있습니다.`),k.current()}catch{b(null),te(`적용 해제 결과를 아직 확인하지 못했습니다. 공유기의 상태를 자동으로 다시 확인합니다.`);try{let e=await l?.();e&&ie(e)}catch{}}finally{S(!1)}}let se=f?.syncStage,ce=f?.syncStatus===`pending`,le=f?.syncStatus===`delayed`,ue=f?.syncStatus===`failed`,de=se===`server_sync`?`서버 동기화 중`:se===`safeshield_refreshing`?`SafeShield 갱신 중`:se===`applying`?`추천 규칙 적용 중`:se===`verifying`?`허용 목록 확인 중`:le?`재확인 지연`:ue?`동기화 실패`:ce?`동기화 대기`:`허용 목록 확인`,fe=se===`safeshield_refreshing`&&ce?`SafeShield가 보호 목록을 갱신하고 있습니다. 갱신이 끝나면 추천 규칙을 자동으로 다시 확인합니다.`:se===`applying`&&ce?`추천 규칙을 SafeShield 허용 목록에 반영하고 있습니다.`:se===`verifying`&&ce?`SafeShield 허용 목록에서 추천 규칙 반영 결과를 확인하고 있습니다.`:se===`server_sync`&&ce?`SmartSafeHub 서버에서 최신 추천 규칙을 확인하고 있습니다.`:le?`서버 응답이 지연되고 있습니다. 이미 확인한 허용 규칙은 유지되며 자동으로 다시 시도합니다.`:ue?f?.syncReason===`auth_rejected`?`SmartSafeHub 기기 인증이 거부되었습니다. 공유기 계정 연결 상태를 확인해 주세요.`:f?.syncReason===`api_rejected`?`서버가 추천 규칙 요청을 거부했습니다. Hub API 배포 상태를 확인해 주세요.`:f?.syncReason===`invalid_response`?`서버 응답을 확인할 수 없습니다. 규칙은 유지되며 다음 동기화에서 다시 확인합니다.`:`추천 규칙 반영을 완료하지 못했습니다. 다음 자동 동기화에서 다시 시도합니다.`:ce?`추천 규칙의 적용 결과를 기다리고 있습니다.`:null,pe=C&&D.current!==null?{tone:`amber`,label:T?`적용 해제 처리 지연`:`적용 해제 확인 중`,description:T?`적용 해제 확인이 지연되고 있습니다. SafeShield 갱신 중이라면 완료 후 자동으로 재확인합니다.`:fe??`적용 해제 상태를 확인하고 있습니다. 확인되면 목록과 상태가 자동 갱신됩니다.`}:ce||le||ue?{tone:ue?`red`:`amber`,label:de,description:fe??``}:ee?{tone:`red`,label:`상태 확인 필요`,description:ee}:f?.syncWarning?{tone:`amber`,label:`서버 재확인 지연`,description:`이미 확인한 허용 규칙은 유지되며 자동으로 다시 시도합니다.`}:A?{tone:`green`,label:`허용 목록 확인`,description:A}:null;return i&&!t?z(bo,{}):n&&!t?z(xo,{message:n,onRetry:c}):t?z(v,{children:[y?z(`div`,{class:`fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-4`,role:`presentation`,onClick:()=>{x||b(null)},children:z(`section`,{"aria-labelledby":`recommended-revoke-title`,"aria-modal":`true`,class:`w-full max-w-md rounded-2xl border border-slate-200 bg-white p-5 shadow-xl`,onClick:e=>e.stopPropagation(),role:`dialog`,children:[z(`h2`,{class:`m-0 text-lg font-extrabold text-slate-950`,id:`recommended-revoke-title`,children:`추천 규칙 적용 해제`}),z(`p`,{class:`mt-3 text-sm text-slate-700`,children:[z(`strong`,{children:y.title}),` 규칙을 이 공유기에서 해제할까요?`]}),z(`p`,{class:`mt-2 text-xs leading-5 text-slate-600`,children:[`포함된 도메인 `,y.domains.length,`개를 다시 확인합니다. 직접 추가한 허용 규칙과 다른 추천 규칙에 필요한 도메인은 유지됩니다.`]}),z(`div`,{class:`mt-5 flex justify-end gap-2`,children:[z(`button`,{class:`rounded-lg border border-slate-200 px-4 py-2 text-sm font-bold text-slate-700 disabled:cursor-wait disabled:opacity-50`,disabled:x,onClick:()=>b(null),type:`button`,children:`취소`}),z(`button`,{class:`inline-flex min-w-24 items-center justify-center gap-2 rounded-lg bg-red-700 px-4 py-2 text-sm font-bold text-white transition hover:bg-red-800 disabled:cursor-progress disabled:bg-red-700 disabled:opacity-80`,"aria-busy":x,disabled:x,onClick:()=>void oe(),type:`button`,children:[x?z(`svg`,{class:`size-4 animate-spin`,"aria-hidden":`true`,viewBox:`0 0 24 24`,fill:`none`,children:[z(`circle`,{cx:`12`,cy:`12`,r:`9`,stroke:`currentColor`,"stroke-width":`3`,opac Line truncated
`):e}function Rh(e,t){let{element:n,datasetIndex:r,index:i}=t,a=e.getDatasetMeta(r).controller,{label:o,value:s}=a.getLabelAndValue(i);return{chart:e,label:o,parsed:a.getParsed(i),raw:e.data.datasets[r].data[i],formattedValue:s,dataset:a.getDataset(),dataIndex:i,datasetIndex:r,element:n}}function zh(e,t){let n=e.chart.ctx,{body:r,footer:i,title:a}=e,{boxWidth:o,boxHeight:s}=t,c=Au(t.bodyFont),l=Au(t.titleFont),u=Au(t.footerFont),d=a.length,f=i.length,p=r.length,m=ku(t.padding),h=m.height,g=0,_=r.reduce((e,t)=>e+t.before.length+t.lines.length+t.after.length,0);if(_+=e.beforeBody.length+e.afterBody.length,d&&(h+=d*l.lineHeight+(d-1)*t.titleSpacing+t.titleMarginBottom),_){let e=t.displayColors?Math.max(s,c.lineHeight):c.lineHeight;h+=p*e+(_-p)*c.lineHeight+(_-1)*t.bodySpacing}f&&(h+=t.footerMarginTop+f*u.lineHeight+(f-1)*t.footerSpacing);let v=0,y=function(e){g=Math.max(g,n.measureText(e).width+v)};return n.save(),n.font=l.string,Q(e.title,y),n.font=c.string,Q(e.beforeBody.concat(e.afterBody),y),v=t.displayColors?o+2+t.boxPadding:0,Q(r,e=>{Q(e.before,y),Q(e.lines,y),Q(e.after,y)}),v=0,n.font=u.string,Q(e.footer,y),n.restore(),g+=m.width,{width:g,height:h}}function Bh(e,t){let{y:n,height:r}=t;return n<r/2?`top`:n>e.height-r/2?`bottom`:`center`}function Vh(e,t,n,r){let{x:i,width:a}=r,o=n.caretSize+n.caretPadding;if(e===`left`&&i+a+o>t.width||e===`right`&&i-a-o<0)return!0}function Hh(e,t,n,r){let{x:i,width:a}=n,{width:o,chartArea:{left:s,right:c}}=e,l=`center`;return r===`center`?l=i<=(s+c)/2?`left`:`right`:i<=a/2?l=`left`:i>=o-a/2&&(l=`right`),Vh(l,e,t,n)&&(l=`center`),l}function Uh(e,t,n){let r=n.yAlign||t.yAlign||Bh(e,n);return{xAlign:n.xAlign||t.xAlign||Hh(e,t,n,r),yAlign:r}}function Wh(e,t){let{x:n,width:r}=e;return t===`right`?n-=r:t===`center`&&(n-=r/2),n}function Gh(e,t,n){let{y:r,height:i}=e;return t===`top`?r+=n:r-=t===`bottom`?i+n:i/2,r}function Kh(e,t,n,r){let{caretSize:i,caretPadding:a,cornerRadius:o}=e,{xAlign:s,yAlign:c}=n,l=i+a,{topLeft:u,topRight:d,bottomLeft:f,bottomRight:p}=Ou(o),m=Wh(t,s),h=Gh(t,c,l);return c===`center`?s===`left`?m+=l:s===`right`&&(m-=l):s===`left`?m-=Math.max(u,f)+i:s===`right`&&(m+=Math.max(d,p)+i),{x:vl(m,0,r.width-t.width),y:vl(h,0,r.height-t.height)}}function qh(e,t,n){let r=ku(n.padding);return t===`center`?e.x+e.width/2:t===`right`?e.x+e.width-r.right:e.x+r.left}function Jh(e){return Ih([],Lh(e))}function Yh(e,t,n){return Nu(e,{tooltip:t,tooltipItems:n,type:`tooltip`})}function Xh(e,t){let n=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return n?e.override(n):e}var Zh={beforeTitle:wc,title(e){if(e.length>0){let t=e[0],n=t.chart.data.labels,r=n?n.length:0;if(this&&this.options&&this.options.mode===`dataset`)return t.dataset.label||``;if(t.label)return t.label;if(r>0&&t.dataIndex<r)return n[t.dataIndex]}return``},afterTitle:wc,beforeBody:wc,beforeLabel:wc,label(e){if(this&&this.options&&this.options.mode===`dataset`)return e.label+`: `+e.formattedValue||e.formattedValue;let t=e.dataset.label||``;t&&(t+=`: `);let n=e.formattedValue;return Y(n)||(t+=n),t},labelColor(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{borderColor:t.borderColor,backgroundColor:t.backgroundColor,borderWidth:t.borderWidth,borderDash:t.borderDash,borderDashOffset:t.borderDashOffset,borderRadius:0}},labelTextColor(){return this.options.bodyColor},labelPointStyle(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{pointStyle:t.pointStyle,rotation:t.rotation}},afterLabel:wc,afterBody:wc,beforeFooter:wc,footer:wc,afterFooter:wc};function Qh(e,t,n,r){let i=e[t].call(n,r);return i===void 0?Zh[t].call(n,r):i}var $h=class extends Wp{constructor(e){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=e.chart,this.options=e.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(e){this.options=e,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){let e=this._cachedAnimations;if(e)return e;let t=this.chart,n=this.options.setContext(this.getContext()),r=n.enabled&&t.options.animation&&n.animations,i=new $d(this.chart,r);return r._cacheable&&(this._cachedAnimations=Object.freeze(i)),i}getContext(){return this.$context||(this.$context=Yh(this.chart.getContext(),this,this._tooltipItems))}getTitle(e,t){let{callbacks:n}=t,r=Qh(n,`beforeTitle`,this,e),i=Qh(n,`title`,this,e),a=Qh(n,`afterTitle`,this,e),o=[];return o=Ih(o,Lh(r)),o=Ih(o,Lh(i)),o=Ih(o,Lh(a)),o}getBeforeBody(e,t){return Jh(Qh(t.callbacks,`beforeBody`,this,e))}getBody(e,t){let{callbacks:n}=t,r=[];return Q(e Line truncated
@@ -22,6 +22,13 @@ Describe 'SmartSafeHub 셸 계약 테스트'
The error should be blank
End
It 'OpenWrt 25.12 BusyBox 기본 명령과 의존성 호환성을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-openwrt-commands.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '셸 파이프라인의 Broken pipe 회귀를 방지한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-shell-pipeline-safety.sh"
The status should be success
@@ -70,6 +77,13 @@ Describe 'SmartSafeHub 셸 계약 테스트'
The error should be blank
End
It '사용자 규칙 렌더링 최적화와 RPC 진단 개인정보 보호 계약을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-rules-performance.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '추천 사용자 규칙의 적용과 해제 및 수동 규칙 보존을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-recommended-rules.sh"
The status should be success
@@ -112,6 +126,20 @@ Describe 'SmartSafeHub 셸 계약 테스트'
The error should be blank
End
It 'Apps 설치 성공 이벤트 전송과 재시도·복원 제외 계약을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-apps-metrics.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '펌웨어 업데이트 후 검증된 Apps 자동 복원 계약을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-apps-restore.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '내비게이션 계약을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-navigation-contract.sh"
The status should be success
@@ -133,6 +161,13 @@ Describe 'SmartSafeHub 셸 계약 테스트'
The error should be blank
End
It 'CHANGELOG 릴리즈 사이 빈 줄 두 개를 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-changelog-spacing.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It 'README와 상세 문서 분리 계약을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-documentation-contract.sh"
The status should be success
@@ -273,6 +308,41 @@ Describe 'SmartSafeHub 셸 계약 테스트'
The error should be blank
End
It 'RPC 외부 작업 분리와 캐시/작업 중복 방지를 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-rpc-jobs.sh"
The status should be success
The output should start with 'PASS: detached RPC worker'
The error should be blank
End
It 'SmartSafeHub Apps 설치와 제거 안전성 계약을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-apps-manager.sh"
The status should be success
The output should start with 'PASS: Apps install/remove'
The error should be blank
End
It 'Apps 설치/제거 색상과 제거 확인 팝업 및 진행 상태를 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-apps-ui.sh"
The status should be success
The output should start with 'PASS: Apps removal confirmation'
The error should be blank
End
It 'SmartSafeHub Apps 공식 UI 새 탭 실행과 알림 닫기 계약을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-apps-launch.sh"
The status should be success
The output should start with 'PASS: Apps secure new-tab launch'
The error should be blank
End
It 'SmartSafeHub Apps 메뉴와 승인된 앱 카탈로그를 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-apps-contract.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '모든 rpcd ucode 진입점과 모듈의 실제 컴파일을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-ucode-syntax.sh"
The status should be success
@@ -335,4 +405,111 @@ Describe 'SmartSafeHub 셸 계약 테스트'
The output should start with 'PASS:'
The error should be blank
End
# 지속적으로 보호해야 하는 기능 계약(과거 CI에서 누락된 테스트 포함)
It '최근 활동 동기화 데몬의 유휴 폴링 제한을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-activity-idle-polling.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '최근 활동의 Cloud 설정과 기록 표시 및 접근성을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-activity-layout.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '관리자 비밀번호 변경 권한과 입력 검증을 확인한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-admin-password-change.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '물리 버튼 비밀번호 복구의 안전성 및 초기화를 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-admin-password-recovery.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '데몬 실행 및 상태 관리의 런타임 계약을 확인한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-daemon-runtime.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '대시보드의 데이터 중복 호출과 병렬 RPC를 방지한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-dashboard-performance-contract.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '기기 등록 코드와 자격증명 처리의 안전성을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-device-registration.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '펌웨어 내장 소프트웨어 버전 비교를 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-firmware-bundling.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '펌웨어 식별 정보와 업데이트 연결을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-firmware-identity.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It 'WAN/LAN 통합 메뉴 및 과거 링크 호환성을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-network-settings.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '설정 탭 이동 및 입력값 유지와 키보드 접근을 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-page-tabs.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '임시 상태 파일과 런타임 경로를 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-runtime-path-contract.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '테스트 파일이 CI 실행 목록에서 누락되지 않는지 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-suite-integrity.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It '기본 UCI 설정값과 초기화를 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-system-defaults.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
It 'Wi-Fi QR 인증과 비밀번호 보호를 검증한다'
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-wifi-qr-contract.sh"
The status should be success
The output should start with 'PASS:'
The error should be blank
End
End
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: GPL-3.0-or-later
'use strict';
import { from_system_root, from_statvfs } from '../root/usr/share/rpcd/ucode/smartsafehub/apps-storage.uc';
function check(ok, message) {
if (!ok) {
warn(`FAIL: ${message}\n`);
exit(1);
}
};
let s = from_system_root({ total: 65536, avail: 40960 });
check(s?.mount == '/' && s?.totalBytes == 67108864 && s?.availableBytes == 41943040,
'procd root stats must convert KiB to bytes');
check(from_system_root({ total: 500, avail: 501 }) == null, 'invalid root counters');
check(from_system_root({ total: '65536', avail: '40960' }) == null, 'reject string counters');
check(from_system_root({ total: 9007199254740991, avail: 1 }) == null, 'reject integer overflow');
check(from_system_root(null) == null, 'missing procd statistics must be optional');
check(from_system_root({ total: 42, avail: 0 })?.availableBytes == 0, 'allow a full filesystem');
let t = from_statvfs({ frsize: 4096, blocks: 20000, bavail: 13000 }, '/overlay');
check(t?.totalBytes == 81920000 && t?.availableBytes == 53248000, 'statvfs basic units');
check(from_statvfs({ frsize: 0, bsize: 4096, blocks: 20000, bavail: 0 }, '/')?.availableBytes == 0,
'fall back to bsize if frsize is zero');
check(from_statvfs({ frsize: 4096, blocks: 20000, bavail: 20001 }, '/') == null,
'reject invalid available blocks');
check(from_statvfs({ frsize: 4096, blocks: 20000, bavail: 1 }, '/tmp') == null,
'must never use tmpfs instead of APK storage');
check(from_statvfs(null, '/overlay') == null, 'missing statvfs stats must be optional');
print('PASS: Apps storage procd and ucode statvfs conversions\n');
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
appsJobNoticeKey,
readDismissedAppsJob,
saveDismissedAppsJob,
} from '../../frontend/src/utils/appsJobNotice.ts';
test('Apps 알림: 같은 작업은 새로고침해도 닫힘 상태를 유지한다', () => {
const entries = new Map();
const oldWindow = globalThis.window;
globalThis.window = {
localStorage: {
getItem: (key) => entries.get(key) ?? null,
setItem: (key, value) => entries.set(key, value),
},
};
try {
const failed = { phase: 'failed', action: 'install', appId: 'network-usage-monitor', code: 'E22', operationId: '1800000000-1234' };
const key = appsJobNoticeKey(failed);
assert.equal(readDismissedAppsJob(), null);
saveDismissedAppsJob(key);
assert.equal(readDismissedAppsJob(), key);
assert.equal(appsJobNoticeKey({ ...failed }), key);
assert.notEqual(appsJobNoticeKey({ ...failed, operationId: '1800000000-1235' }), key,
'a new operation with the same error must show a fresh notice');
assert.equal(appsJobNoticeKey({ ...failed, phase: 'idle' }), '');
} finally {
if (oldWindow === undefined) delete globalThis.window;
else globalThis.window = oldWindow;
}
});
test('Apps 알림: 저장소 사용을 막아도 UI가 동작하고 구형 작업 상태도 지원한다', () => {
const oldWindow = globalThis.window;
globalThis.window = {
get localStorage() { throw new Error('SecurityError'); },
};
try {
assert.equal(readDismissedAppsJob(), null);
assert.doesNotThrow(() => saveDismissedAppsJob('operation:1800000000-1234'));
assert.equal(appsJobNoticeKey({ phase: 'failed', action: 'install', appId: 'retro-arcade', code: 'E29' }),
'legacy:failed:install:retro-arcade:E29');
assert.equal(appsJobNoticeKey(null), '');
} finally {
if (oldWindow === undefined) delete globalThis.window;
else globalThis.window = oldWindow;
}
});
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { formatStorageBytes, summarizeAppsStorage } from '../../frontend/src/utils/appsStorage.ts';
const MB = 1024 ** 2;
test('앱 공간: /overlay 기준 실제 설치 가능 공간과 백분율을 표시한다', () => {
const summary = summarizeAppsStorage({ mount: '/overlay', totalBytes: 80 * MB, availableBytes: 68 * MB });
assert.equal(summary.availablePercent, 85);
assert.equal(summary.availableText, '68.0 MB');
assert.equal(summary.usedText, '12.0 MB');
assert.equal(summary.totalText, '80.0 MB');
assert.ok(Math.abs(summary.availableRatio - 0.85) < 1e-10);
assert.equal(summary.level, 'normal');
});
test('앱 공간: 극단값, 경계값과 ext4 루트 파일시스템을 처리한다', () => {
const empty = summarizeAppsStorage({ mount: '/', totalBytes: 100 * MB, availableBytes: 0 });
assert.equal(empty.availablePercent, 0);
assert.equal(empty.usedText, '100.0 MB');
const full = summarizeAppsStorage({ mount: '/', totalBytes: 100 * MB, availableBytes: 100 * MB });
assert.equal(full.availableRatio, 1);
assert.equal(full.usedText, '0 B');
assert.equal(summarizeAppsStorage({ mount: '/overlay', totalBytes: 100 * MB, availableBytes: 20 * MB }).level, 'warning');
assert.equal(summarizeAppsStorage({ mount: '/overlay', totalBytes: 100 * MB, availableBytes: 10 * MB }).level, 'critical');
});
test('앱 공간: 실제 시스템 용량은 MiB(표시 MB)로 계산하고 반올림한다', () => {
const summary = summarizeAppsStorage({ mount: '/', totalBytes: 81031168, availableBytes: 75153408 });
assert.equal(summary.availableText, '71.7 MB');
assert.equal(summary.usedText, '5.6 MB');
assert.equal(summary.totalText, '77.3 MB');
});
test('앱 공간: 누락·유효하지 않은 RPC 응답은 임의의 용량으로 채우지 않는다', () => {
for (const storage of [null, undefined, {}, { mount: '/tmp', totalBytes: 100, availableBytes: 20 },
{ mount: '/overlay', totalBytes: 0, availableBytes: 0 },
{ mount: '/overlay', totalBytes: 100, availableBytes: -1 },
{ mount: '/overlay', totalBytes: 100, availableBytes: 101 },
{ mount: '/overlay', totalBytes: Infinity, availableBytes: 20 },
{ mount: '/overlay', totalBytes: NaN, availableBytes: 20 }]) {
assert.equal(summarizeAppsStorage(storage), null);
}
});
test('앱 공간: 작은 용량과 큰 용량을 올바른 단위로 표시한다', () => {
assert.equal(formatStorageBytes(512), '512 B');
assert.equal(formatStorageBytes(512 * 1024), '512.0 KB');
assert.equal(formatStorageBytes(77.3 * MB), '77.3 MB');
assert.equal(formatStorageBytes(3 * 1024 ** 3), '3.0 GB');
assert.equal(formatStorageBytes(-3), '—');
});
test('대시보드 디스크 사용률은 Apps 설치 가능 여유 공간과 정확히 대응한다', () => {
const summary = summarizeAppsStorage({ mount: '/', totalBytes: 100 * MB, availableBytes: 89 * MB });
assert.equal(100 - summary.availablePercent, 11);
assert.equal(summary.usedText, '11.0 MB');
assert.equal(summary.totalText, '100.0 MB');
assert.equal(summarizeAppsStorage(null), null); // older RPC: never show a false 0% bar
});
@@ -23,7 +23,7 @@ test('추천 규칙 상태를 단계별로 보여주고 UI의 오류를 중복
const rpc = source('root/usr/share/rpcd/ucode/smartsafehub/device-registration.uc');
const page = source('frontend/src/pages/SafeShieldRulesPage.tsx');
const types = source('frontend/src/types/rules.ts');
assert.match(rpc, /syncStage: sync\?\.stage/);
assert.match(rpc, /syncStage: operationRunning \? 'server_sync' : \(sync\?\.stage/);
assert.match(rpc, /syncReason: sync\?\.reason/);
assert.match(types, /syncStage: string \| null/);
for (const stage of ['server_sync', 'safeshield_refreshing', 'applying', 'verifying']) {
@@ -79,3 +79,100 @@ test('추천 규칙 API 조회는 실제 공유기 앱에서만 주입된다', (
assert.match(app, /onLoadRecommendedRulesStatus=\{fetchRecommendedRulesStatus\}/);
assert.doesNotMatch(demo, /onLoadRecommendedRulesStatus=/);
});
test('SmartSafeHub Account and Apps use fictional fixtures, not live APIs', () => {
const routes = readFileSync(resolve(root, 'app/routes.ts'), 'utf8');
const app = readFileSync(resolve(root, 'demo/main.tsx'), 'utf8');
const fixtures = readFileSync(resolve(root, 'demo/data.ts'), 'utf8');
assert.match(routes, /'rules', 'account', 'apps', 'system'/);
for (const [route, component] of [['account', 'SmartSafeHubAccountPage'], ['apps', 'AppsPage']]) {
assert.match(app, new RegExp(`case '${route}':`));
assert.ok(app.includes(`<${component}`), `${component} not reused by demo`);
assert.match(app, new RegExp(`<${component}[^;]*?demo`, 's'));
}
for (const fixture of ['demoAccount', 'demoApps', 'demoAppsStatus', 'demoAppsMetrics']) {
assert.ok(fixtures.includes(`export const ${fixture}:`), `missing ${fixture}`);
assert.ok(app.includes(fixture), `unused ${fixture}`);
}
assert.match(fixtures, /accountRegistered: true/);
assert.match(fixtures, /pairingCode: null/);
assert.match(fixtures, /id: 'network-usage-monitor'/);
assert.match(fixtures, /id: 'retro-arcade'/);
assert.match(fixtures, /id: 'wake-on-lan'/);
assert.match(fixtures, /category: 'entertainment'/);
const newFixtures = fixtures.slice(fixtures.indexOf('export const demoAccount:'), fixtures.indexOf('export const demoDevices:'));
assert.doesNotMatch(newFixtures, /https?:\/\/|\/cgi-bin\/|password|token|api_key/i);
assert.match(app, /accountRegistered=\{demoAccount\.accountRegistered\}/);
});
test('demo showcases verified Apps versions, upgrade badge, and installed sizes without changing router state', () => {
const fixtures = readFileSync(resolve(root, 'demo/data.ts'), 'utf8');
const catalog = fixtures.split('export const demoApps: AppsCatalog = {')[1].split('export const demoAppsStatus:')[0];
const status = fixtures.split('export const demoAppsStatus: AppsInstallStatus = {')[1].split('export const demoAppsMetrics:')[0];
const metrics = fixtures.split('export const demoAppsMetrics: AppsMetrics = {')[1].split('export const demoDevices:')[0];
assert.ok(catalog.indexOf("id: 'retro-arcade'") < catalog.indexOf("id: 'network-usage-monitor'"),
'demo card ordering should match the actual Apps page');
assert.match(catalog, /id: 'retro-arcade',[\s\S]*?version: '1\.1\.0-r5'/);
assert.match(catalog, /id: 'network-usage-monitor',[\s\S]*?version: '2025\.06\.02~29236be6-r1'/);
assert.match(catalog, /id: 'wake-on-lan',[\s\S]*?category: 'utilities',[\s\S]*?version: '26\.288\.56989~75a306d'/);
assert.ok(catalog.indexOf("id: 'network-usage-monitor'") < catalog.indexOf("id: 'wake-on-lan'"));
assert.match(status, /'wake-on-lan': \{\s*installed: false,\s*partial: false,\s*managed: false/);
assert.match(status, /'retro-arcade': \{\s*installed: true,\s*partial: false,\s*managed: true,\s*updateAvailable: true,\s*installedVersion: '1\.0\.0-r1'/);
assert.match(status, /'network-usage-monitor': \{\s*installed: true,\s*partial: false,\s*managed: true,\s*updateAvailable: false,\s*installedVersion: '2025\.06\.02~29236be6-r1'/);
assert.match(metrics, /'retro-arcade': 1394688/);
assert.match(metrics, /'network-usage-monitor': 117760/);
assert.match(fixtures, /storage: demoAppsMetrics\.storage/,
'dashboard disk and Apps donut must share the same fictional storage');
const demo = readFileSync(resolve(root, 'demo/main.tsx'), 'utf8');
assert.match(demo, /installStatus=\{demoAppsStatus\} metrics=\{demoAppsMetrics\}/);
assert.match(demo, /onUpdate=\{async \(\) => \{ readonlyNotice\(\); \}\}/);
});
test('Apps 카테고리 아이콘은 공유기와 데모에서 동일하고 도구와 기타가 구분된다', () => {
const page = readFileSync(resolve(root, 'pages/AppsPage.tsx'), 'utf8');
const category = readFileSync(resolve(root, 'components/AppsCategoryIcon.tsx'), 'utf8');
const icons = readFileSync(resolve(root, 'components/Icons.tsx'), 'utf8');
const demo = readFileSync(resolve(root, 'demo/main.tsx'), 'utf8');
assert.match(demo, /<AppsPage data=\{demoApps\}/);
assert.match(page, /<AppsCategoryIcon category=\{tab\.id\}/);
assert.match(page, /<AppsCategoryIcon category=\{appsCategoryOf\(app\.category\)\}/);
assert.match(page, /\{ id: 'utilities', label: '도구' \}/);
assert.match(page, /\{ id: 'other', label: '기타' \}/);
assert.match(category, /case 'utilities': return <WrenchIcon/);
assert.match(category, /case 'other': return <CircleEllipsisIcon/);
assert.match(category, /default: return 'other'/);
assert.match(category, /default: return <CircleEllipsisIcon/);
assert.match(icons, /export function WrenchIcon/);
assert.match(icons, /export function CircleEllipsisIcon/);
assert.match(icons, /export function NetworkNodesIcon/);
assert.doesNotMatch(category, /PowerIcon|PuzzleIcon/);
});
test('public demo actions never install APKs, pair an account, or open a live app', () => {
const demo = readFileSync(resolve(root, 'demo/main.tsx'), 'utf8');
const apps = readFileSync(resolve(root, 'pages/AppsPage.tsx'), 'utf8');
const account = readFileSync(resolve(root, 'pages/SmartSafeHubAccountPage.tsx'), 'utf8');
assert.match(demo, /onDemoAction=\{readonlyNotice\}/);
assert.match(demo, /onRequestPairing=\{async \(\) => \{ readonlyNotice\(\); return null; \}\}/);
for (const action of ['onInstall', 'onRemove', 'onUpdate']) {
assert.ok(demo.includes(`${action}={async () => { readonlyNotice(); }}`), `${action} must be a no-op`);
}
assert.match(apps, /if \(demo\) \{/);
assert.match(apps, /if \(action === 'remove'\) closeRemoveDialog\(\)/);
assert.match(apps, /onDemoAction\?\.\(\);/);
assert.match(apps, /launchPath \? \(demo \?/);
assert.match(apps, /onClick=\{onDemoAction\} type="button">열기/);
assert.match(account, /demo \? \(/);
assert.match(account, /onClick=\{onDemoAction\}/);
assert.match(account, /href=\{DEVICE_CONSOLE_URL\}/);
assert.doesNotMatch(demo, /import .*from ['"]\.\.\/api\//);
});
test('README links to the public router demo', () => {
const readme = readFileSync(resolve(root, '../../README.md'), 'utf8');
assert.match(readme, /https:\/\/www\.smartsafehub\.com\/router-demo\//);
});
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import assert from 'node:assert/strict';
import test from 'node:test';
import { acquireRpcSlot } from '../../frontend/src/api/rpcScheduler.ts';
test('RPC queue reserves a slot for interactive actions and releases cancelled waiters', async () => {
const background = await Promise.all(Array.from({ length: 3 }, () => acquireRpcSlot('low')));
const pending = acquireRpcSlot('low');
const interactive = await acquireRpcSlot('high');
// With four slots taken, a queued request must be cancellable without a leak.
const controller = new AbortController();
const cancelled = acquireRpcSlot('normal', controller.signal);
controller.abort();
await assert.rejects(cancelled, (error) => error.name === 'AbortError');
interactive();
let settled = false;
void pending.then(() => { settled = true; });
await Promise.resolve();
assert.equal(settled, false, 'fourth low-priority RPC must not consume the reserved slot');
background[0]();
const fourth = await pending;
fourth();
background[1]();
background[2]();
});
test('pre-aborted signal never starts work', async () => {
const controller = new AbortController();
controller.abort();
await assert.rejects(acquireRpcSlot('high', controller.signal), (error) => error.name === 'AbortError');
});
test('queued RPC starts after an earlier queued request is aborted', async () => {
const active = await Promise.all(Array.from({ length: 4 }, () => acquireRpcSlot('normal')));
const controller = new AbortController();
const cancelled = acquireRpcSlot('normal', controller.signal);
const successor = acquireRpcSlot('high');
controller.abort();
await assert.rejects(cancelled, (error) => error.name === 'AbortError');
active[0]();
const releaseSuccessor = await successor;
releaseSuccessor();
active.slice(1).forEach((release) => release());
// All accounting must be restored after cancelling, draining and releasing.
const again = await Promise.all(Array.from({ length: 4 }, () => acquireRpcSlot('normal')));
again.forEach((release) => release());
});
@@ -19,7 +19,7 @@ test('허용 목록의 수동 규칙과 추천 규칙을 로컬 소유 정보로
assert.match(rpc, /recommended-rules\.state/);
assert.match(rpc, /domains: domains/);
assert.match(helper, /grep -Fxq "\$domain" "\$OWNED"/);
assert.match(page, /const cloudManaged = isAllow && recommendedKnown && recommendedDomains\.includes\(domain\);/);
assert.match(page, /const cloudManaged = isAllow && recommendedKnown && recommendedDomainSet\.has\(domain\);/);
assert.match(page, /cloudManaged \? '추천 규칙' : '직접 추가'/);
assert.match(page, /disabled=\{busy \|\| cloudManaged\}/);
});
@@ -50,7 +50,7 @@ test('추천 규칙 수동 동기화와 적용 확인 상태는 데모 API를
assert.match(acl, /"device_recommended_rules_refresh"/);
assert.match(device, /write_recommended_sync_status failed/);
assert.match(device, /write_recommended_sync_status confirmed/);
assert.match(rpc, /const syncStatus = sync\?\.status == 'pending'/);
assert.match(rpc, /const syncStatus = operationRunning \|\| sync\?\.status == 'pending'/);
assert.match(rpc, /const syncWarning = confirmed &&/);
assert.match(rpc, /const confirmed = revision != null && receipt\?\.status == 'confirmed'/);
assert.doesNotMatch(page, /from ['"]\.\.\/api\//);
@@ -78,9 +78,9 @@ test('추천 규칙 상태는 화면을 새로고침하지 않아도 주기적
test('갱신 실패 후에도 추천 규칙의 소유권을 유지하고 확인서가 없으면 재조회한다', () => {
const device = readFileSync(resolve(root, 'root/usr/libexec/smartsafehub-device'), 'utf8');
assert.match(helper, /Keep the ownership claim until the final SafeShield read confirms/);
assert.match(helper, /Only after successful reconciliation may we forget ownership/);
assert.match(helper, /ownership intent before creating the allow entry/);
assert.match(helper, /Retire ownership only after final verification/);
assert.match(helper, /persist "\$revision"/);
assert.match(helper, /Record intent before rule_add/);
assert.match(device, /A state revision alone is not proof that SafeShield applied the rules/);
assert.match(device, /json_value "\$RECOMMENDED_RECEIPT" '@\.revision'/);
});
@@ -102,7 +102,7 @@ test('해제 요청 시간 초과 후에도 로컬 적용 확인을 반복하고
});
test('백그라운드 규칙 변경을 감지하면 SafeShield 목록을 다시 읽는다', () => {
assert.match(page, /previousStatusSignature\.current !== signature/);
assert.match(page, /previousStatusSignature\.current !== null/);
assert.match(page, /onRetryRef\.current\(\)/);
assert.match(page, /window\.setInterval\(updateStatus, 15_000\)/);
assert.match(page, /window\.addEventListener\('focus', onFocus\)/);
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
filterRuleGroups,
filterRecommendedAssignments,
sameRecommendationContent,
} from '../../frontend/src/utils/ruleLists.ts';
import {
recordRpcTiming,
resetRpcDiagnostics,
snapshotRpcDiagnostics,
} from '../../frontend/src/api/rpcDiagnostics.ts';
test('규칙 검색은 한 번만 분류하고 웹사이트 관리 규칙을 직접 삭제 가능한 그룹에서 분리한다', () => {
const rules = ['ads.example.com', 'manual.example.org', 'tv.example.net'];
const ownership = new Set(['ads.example.com', 'tv.example.net']);
const result = filterRuleGroups(rules, '', true, ownership);
assert.equal(result.filteredRules, rules, 'no search preserves source array');
assert.deepEqual(result.managedRules, ['ads.example.com', 'tv.example.net']);
assert.deepEqual(result.manualRules, ['manual.example.org']);
assert.deepEqual(filterRuleGroups(rules, 'example.org', true, ownership).manualRules, ['manual.example.org']);
assert.deepEqual(filterRuleGroups(rules, 'no-match', true, ownership).managedRules, []);
assert.equal(filterRuleGroups(rules, '', false, ownership).manualRules, rules);
});
test('추천 규칙 검색은 제목 또는 도메인과 일치하고 빈 검색어에서 원래 목록을 유지한다', () => {
const rules = [
{ id: 1, title: 'Samsung TV', domains: ['ads.its-newid.net'] },
{ id: 2, title: 'Other', domains: ['example.org'] },
];
assert.equal(filterRecommendedAssignments(rules, ''), rules);
assert.deepEqual(filterRecommendedAssignments(rules, 'samsung'), [rules[0]]);
assert.deepEqual(filterRecommendedAssignments(rules, 'its-newid'), [rules[0]]);
});
test('백그라운드 상태 갱신에서는 동일한 목록 참조를 재사용하되 실제 변경은 반영한다', () => {
const previous = {
revision: 'r1', domains: ['ads.its-newid.net'],
rules: [{ id: 2, title: 'TV', domains: ['ads.its-newid.net'] }],
lastCheckedAt: 1, syncStatus: 'pending',
};
const progress = {
...previous,
domains: ['ads.its-newid.net'],
rules: [{ id: 2, title: 'TV', domains: ['ads.its-newid.net'] }],
lastCheckedAt: 2,
syncStatus: 'confirmed',
};
const stable = sameRecommendationContent(previous, progress);
assert.equal(stable.domains, previous.domains);
assert.equal(stable.rules, previous.rules);
assert.equal(progress.syncStatus, 'confirmed', 'status transitions still flow to the parent');
const changed = sameRecommendationContent(previous, {
...progress, domains: ['new.example.org'],
rules: [{ id: 2, title: 'TV updated', domains: ['new.example.org'] }],
});
assert.notEqual(changed.domains, previous.domains);
assert.notEqual(changed.rules, previous.rules);
});
test('운영 환경 RPC 진단은 느린/실패 요청을 제한된 메모리에만 기록한다', () => {
resetRpcDiagnostics();
recordRpcTiming({ object: 'safeshield', method: 'rules_list', elapsedMs: 12, responseMs: 10, decodeMs: 2, result: 'ok' });
assert.equal(snapshotRpcDiagnostics().recent.length, 0);
assert.equal(recordRpcTiming({ object: 'safeshield', method: 'rules_list', elapsedMs: 1501, responseMs: 1490, decodeMs: 11, result: 'ok' }), true);
recordRpcTiming({ object: 'smartsafehub', method: 'device_recommended_rules_status', elapsedMs: 28, responseMs: 24, decodeMs: 4, result: 'RPC_TIMEOUT' });
const snapshot = snapshotRpcDiagnostics();
assert.deepEqual(snapshot.summary[0], { method: 'safeshield.rules_list', calls: 2, slow: 1, errors: 0, lastMs: 1501, maxMs: 1501, averageMs: 757 });
assert.equal(snapshot.recent.length, 2);
assert.equal(snapshot.recent[1].result, 'RPC_TIMEOUT');
assert.equal('params' in snapshot.recent[0], false);
assert.equal('sessionId' in snapshot.recent[0], false);
for (let i = 0; i < 70; i++) {
recordRpcTiming({ object: 'safeshield', method: 'rules_list', elapsedMs: 2000, responseMs: null, decodeMs: null, result: 'RPC_TIMEOUT' });
}
assert.equal(snapshotRpcDiagnostics().recent.length, 60);
resetRpcDiagnostics();
assert.deepEqual(snapshotRpcDiagnostics().summary, []);
});
@@ -32,8 +32,8 @@ grep -Fq 'void refresh();' "$HOOK" || fail 'page entry must refresh Hub state in
grep -Fq 'if (loading && !data) return <LoadingPanel />;' "$PAGE" || fail 'account page must render cached local state without waiting for Hub'
grep -Fq '최신 상태 확인이 지연되고 있습니다. 마지막으로 확인된 연결 상태를 표시합니다.' "$PAGE" || fail 'connected devices must present cloud delay as freshness information, not a connection warning'
grep -Fq '>마지막 확인</dt>' "$PAGE" || fail 'account status must label the timestamp as the last confirmed state'
grep -Fq "timeoutMs: 30000" "$API" || fail 'background account synchronization must allow the router helper enough time to complete'
grep -Fq "status-sync' ], 25000" "$RPC" || fail 'rpcd must allow status-sync to finish before the frontend timeout'
grep -Fq "timeoutMs: 6000" "$API" || fail 'RPC account sync must promptly return an accepted job'
grep -Fq "ensure_job('device-sync')" "$RPC" || fail 'RPC account sync must use detached worker'
grep -Fq 'const connected = status?.accountRegistered === true;' "$PAGE" || fail 'page must derive explicit account connection state'
grep -Fq 'const pairingCode = !connected && !expired ? status?.pairingCode : null;' "$PAGE" || fail 'connected devices must never display a stale pairing code'
@@ -17,5 +17,5 @@ assert loop.index('cloud_sync_enabled') < loop.index("previous_state_number '@.n
assert 'retry_backoff_active' in loop
assert 'consume_wake_marker || true' in loop
assert 'ssh_runtime_execute sync-once sync_once' in loop
print('activity idle polling contract: OK')
print('PASS: activity idle polling contract')
PY
+152
View File
@@ -0,0 +1,152 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
ROUTES="$ROOT/frontend/src/app/routes.ts"
NAV="$ROOT/frontend/src/components/ProductNavigation.tsx"
UCODE="$ROOT/root/usr/share/rpcd/ucode/smartsafehub/apps.uc"
RPC="$ROOT/root/usr/share/rpcd/ucode/smartsafehub.uc"
HELPER="$ROOT/root/usr/libexec/smartsafehub-apps"
ACL="$ROOT/root/usr/share/rpcd/acl.d/luci-app-smartsafehub.json"
fail() { echo "FAIL: $*" >&2; exit 1; }
for file in "$ROUTES" "$NAV" "$UCODE" "$RPC" "$HELPER" "$ACL" \
"$ROOT/frontend/src/pages/AppsPage.tsx" "$ROOT/frontend/src/types/apps.ts"; do
[ -f "$file" ] || fail "missing ${file##*/}"
done
[ -x "$HELPER" ] || fail 'helper not executable'
sh -n "$HELPER" || fail 'invalid helper shell syntax'
python3 - "$ROUTES" "$NAV" "$ROOT/frontend/src/hooks/useHashRoute.ts" <<'PY' || exit 1
from pathlib import Path
import sys
routes, nav, hash_route = map(lambda p: Path(p).read_text(), sys.argv[1:])
assert "route: 'apps'" in routes and "hash: '#apps'" in routes
assert routes.index("route: 'account'") < routes.index("route: 'apps'") < routes.index("route: 'system'")
assert "{ label: 'Explore', routes: ['apps'] }" in nav
assert "{ label: 'System', routes: ['account', 'system', 'settings'] }" in nav
assert "case 'apps':" in nav and '<AppsIcon' in nav
assert "return '앱으로 확장하기';" in nav
assert "'#apps': 'apps'" in hash_route
PY
grep -Fq 'apps_catalog: {' "$RPC" || fail 'RPC method missing'
grep -Fq 'require_root_password(function(request)' "$RPC" || fail 'RPC password guard missing'
grep -Fq '"apps_catalog"' "$ACL" || fail 'read permission missing'
grep -Fq "'apps_catalog'" "$ROOT/frontend/src/api/smartsafehub.ts" || fail 'frontend RPC missing'
grep -Fq "active: route === 'apps'" "$ROOT/frontend/src/app/App.tsx" || fail 'catalog must be loaded only on Apps route'
# Apps must follow the existing Recent Activity section spacing contract.
# A tall refresh action next to the title pushes the entire heading and card
# down on desktop; keep that control inside the card instead.
python3 - "$ROOT/frontend/src/pages/AppsPage.tsx" "$ROOT/frontend/src/pages/ActivityPage.tsx" <<'PY' || fail 'Apps layout differs from shared section design'
from pathlib import Path
import sys
apps, activity = (Path(path).read_text() for path in sys.argv[1:])
assert '<div class="min-w-0 space-y-6">' in activity
assert '<section class="min-w-0 space-y-6">' in apps
heading = apps.split('<SectionHeading', 1)[1].split('/>', 1)[0]
assert 'action=' not in heading, 'refresh action changes heading height'
card = apps.split('/>', 1)[1].split('{error ?', 1)[0]
assert 'p-4 shadow-sm shadow-slate-900/5 sm:p-5' in card, 'card padding differs from Activity'
assert '목록 새로고침' in card, 'refresh action must be within card'
assert 'disabled={loading || refreshing}' in card, 'refresh loading guard missing'
PY
grep -Fq "item.runtime != 'router' || item.installer != 'apk'" "$UCODE" || fail 'unknown runtimes must stay hidden'
# Separate the privileged APK adapter from display-only Hub catalog parsing.
MANAGER="$ROOT/root/usr/libexec/smartsafehub-apps-manager"
MODULE="$ROOT/root/usr/share/rpcd/ucode/smartsafehub/apps-manager.uc"
[ -x "$MANAGER" ] || fail 'APK manager missing or not executable'
[ -f "$MODULE" ] || fail 'APK RPC adapter missing'
sh -n "$MANAGER" || fail 'APK manager shell syntax invalid'
grep -Fq 'apps_install: {' "$RPC" || fail 'Apps install RPC missing'
grep -Fq 'apps_update: {' "$RPC" || fail 'Apps update RPC missing'
grep -Fq 'apps_remove: {' "$RPC" || fail 'Apps remove RPC missing'
grep -Fq 'apps_status: {' "$RPC" || fail 'Apps status RPC missing'
grep -Fq '"apps_install"' "$ACL" || fail 'Apps install ACL missing'
grep -Fq '"apps_update"' "$ACL" || fail 'Apps update ACL missing'
grep -Fq '"apps_remove"' "$ACL" || fail 'Apps remove ACL missing'
grep -Fq '"apps_status"' "$ACL" || fail 'Apps status ACL missing'
grep -Fq '"apps_metrics"' "$ACL" || fail 'Apps metrics read ACL missing'
grep -Fq 'updateVerifiedApp' "$ROOT/frontend/src/api/smartsafehub.ts" || fail 'Apps frontend update call missing'
grep -Fq 'installVerifiedApp' "$ROOT/frontend/src/api/smartsafehub.ts" || fail 'Apps frontend installation call missing'
# 다크 모드에서 앱 작업 상태 텍스트가 어둡게 남지 않도록 색상 매핑을 확인한다.
python3 - "$ROOT/frontend/src/pages/AppsPage.tsx" "$ROOT/frontend/src/styles/app.css" <<'PY_APPS_THEME' || fail 'Apps 작업 상태 다크 모드 계약이 올바르지 않습니다'
from pathlib import Path
import sys
page, styles = (Path(file).read_text() for file in sys.argv[1:])
assert "'border-teal-200 bg-teal-50 text-teal-900'" in page
assert "'border-rose-200 bg-rose-50 text-rose-800'" in page
assert "[class~='text-teal-900']" in styles
assert "[class~='text-rose-800']" in styles
PY_APPS_THEME
# Apps RPC 인자가 rpcd의 request.args 아래로 전달되는지 검사한다.
# request.app_id는 항상 빈 값이어서 패키지 작업 시작 전에 APPS_INVALID_ID가 발생한다.
python3 - "$RPC" "$MODULE" "$ROOT/frontend/src/api/smartsafehub.ts" <<'PY_RPC' || fail 'Apps RPC 인자 전달 계약이 올바르지 않습니다'
from pathlib import Path
import re
import sys
rpc, adapter, frontend = (Path(path).read_text() for path in sys.argv[1:])
for action in ('install', 'remove'):
method = f'apps_{action}'
assert re.search(rf'{method}:\s*\{{\s*args:\s*\{{\s*app_id:\s*', rpc), method
assert f"'{method}', {{ app_id: appId }}" in frontend, method
assert 'const app_id = request?.args?.app_id;' in adapter
assert 'const app_id = request?.app_id;' not in adapter
assert 'match(app_id, /^[a-z0-9][a-z0-9-]{0,79}$/)' in adapter
assert "run_command([ MANAGER, 'start', action, app_id ], 5000)" in adapter
PY_RPC
# Fake only the HTTP/JSON filter utilities: no external calls or OpenWrt runtime
# are needed to verify identity pinning and mismatch/failure handling.
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
mkdir -p "$TMP/bin" "$TMP/runtime"
cat > "$TMP/firmware.json" <<'EOF'
{"device_code":"iptime-ax3000sm","build_id":"2026.10.20-123","channel":"stable"}
EOF
cat > "$TMP/response.json" <<'EOF'
{"schema":1,"device_code":"iptime-ax3000sm","firmware_build_id":"2026.10.20-123","channel":"stable","apps":[]}
EOF
cat > "$TMP/bin/jsonfilter" <<'PY'
#!/usr/bin/env python3
import json,sys
args=sys.argv
obj=json.load(open(args[args.index('-i')+1],encoding='utf-8'))
for key in args[args.index('-e')+1].removeprefix('@.').split('.'):
obj=obj[key]
print(obj if not isinstance(obj,bool) else str(obj).lower())
PY
cat > "$TMP/bin/uclient-fetch" <<'SH2'
#!/bin/sh
[ "${APPS_TEST_FAIL:-0}" = 0 ] || exit 1
while [ "$#" -gt 0 ]; do
case "$1" in -O) dest="$2"; shift 2;; *) url="$1"; shift;; esac
done
printf '%s\n' "$url" > "$APPS_TEST_URL_FILE"
cp "$APPS_TEST_RESPONSE" "$dest"
SH2
chmod +x "$TMP/bin/jsonfilter" "$TMP/bin/uclient-fetch"
export SMARTSAFEHUB_APPS_ROM_METADATA="$TMP/firmware.json"
export SMARTSAFEHUB_APPS_METADATA="$TMP/firmware.json"
export SMARTSAFEHUB_APPS_RUNTIME_DIR="$TMP/runtime"
export SMARTSAFEHUB_APPS_JSONFILTER_BIN="$TMP/bin/jsonfilter"
export SMARTSAFEHUB_APPS_UCLIENT_BIN="$TMP/bin/uclient-fetch"
export APPS_TEST_RESPONSE="$TMP/response.json"
export APPS_TEST_URL_FILE="$TMP/url"
"$HELPER" || fail 'valid pinned catalog fetch failed'
grep -Fq 'firmware_build_id=2026.10.20-123&channel=stable' "$TMP/url" || fail 'local identity missing in request'
grep -Fq '"apps":[]' "$TMP/runtime/apps-catalog.json" || fail 'catalog not stored'
# Wrong firmware builds must never be treated as approved.
sed 's/2026.10.20-123/2026.10.20-999/' "$TMP/response.json" > "$TMP/mismatch.json"
export APPS_TEST_RESPONSE="$TMP/mismatch.json"
if "$HELPER"; then fail 'mismatched firmware catalog accepted'; fi
export APPS_TEST_FAIL=1
if "$HELPER"; then fail 'offline catalog fetch accepted'; fi
unset APPS_TEST_FAIL
cat > "$TMP/firmware.json" <<'EOF'
{"device_code":"iptime-ax3000sm","build_id":"../../bad","channel":"stable"}
EOF
if "$HELPER"; then fail 'invalid firmware identity accepted'; fi
printf 'PASS: Apps navigation, catalog pinning and APK RPC contracts\n'
+72
View File
@@ -0,0 +1,72 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
python3 - "$ROOT" <<'PY'
from pathlib import Path
import re
import sys
root = Path(sys.argv[1])
ui = (root/'frontend/src/pages/AppsPage.tsx').read_text()
types = (root/'frontend/src/types/apps.ts').read_text()
route = (root/'frontend/src/utils/appsWebUi.ts').read_text()
catalog = (root/'root/usr/share/rpcd/ucode/smartsafehub/apps.uc').read_text()
manager = (root/'root/usr/libexec/smartsafehub-apps-manager').read_text()
status = (root/'root/usr/share/rpcd/ucode/smartsafehub/apps-manager.uc').read_text()
assert 'target="_blank"' in ui and 'rel="noopener noreferrer"' in ui
assert 'approvedAppPath(app.webUi)' in ui and 'state?.webUiReady' in ui
assert '웹 UI 미설치' in ui and '설치 마무리' in ui
assert 'onClick={() => dismissJob(jobKey)}' in ui
assert 'useState<string | null>(readDismissedAppsJob)' in ui
assert 'window.setTimeout(() => dismissJob(jobKey), 7000)' in ui
assert 'webUiReady?: boolean' in types and 'webUi?:' in types
assert 'operationId?: string' in types
assert 'updateAvailable?: boolean' in types
assert "updateAvailable: length(fields) >= 6 && fields[5] == '1'" in status
# rpcd must accept both existing timestamp-pid jobs and new receipt IDs.
assert r'^[0-9]{1,12}-[0-9]{1,12}(-[0-9a-f]{16})?$' in status
assert 'od -An' not in manager
assert "if (webUi.type !== 'luci')" in route
assert "'/cgi-bin/luci/admin/services/nlbw/display'" in catalog
assert 'luci_web_ui_path(item.web_ui.path)' in catalog
assert 'luci-app-nlbwmon' in manager
# Hub-approved Wake-on-LAN must route only to its matching LuCI app, never
# another administrator view or to the backend etherwake executable.
assert "id == 'wake-on-lan'" in catalog
assert "item?.web_ui?.package == 'luci-app-wol'" in catalog
assert "item?.web_ui?.path == '/cgi-bin/luci/admin/services/wol'" in catalog
assert '[ "$id" = wake-on-lan ]' in manager
assert 'web_ui.package")" = luci-app-wol' in manager
assert 'web_ui.path")" = /cgi-bin/luci/admin/services/wol' in manager
assert '[ "$app" = wake-on-lan ]' in manager
assert 'for required in etherwake luci-app-wol' in manager
assert 'primary_package=luci-app-wol' in manager
assert 'rm -f "$LUCI_INDEX_CACHE"' in manager
category = (root/'frontend/src/components/AppsCategoryIcon.tsx').read_text()
page = (root/'frontend/src/pages/AppsPage.tsx').read_text()
assert "case 'utilities': return 'utilities'" in category
assert "case 'utilities': return <WrenchIcon" in category
assert "{ id: 'utilities', label: '도구' }" in page
assert "case 'other': return <CircleEllipsisIcon" in category
assert "default: return <CircleEllipsisIcon" in category
assert "{ id: 'other', label: '기타' }" in page
assert "id == 'retro-arcade'" in catalog and "type: id == 'retro-arcade'" in catalog
assert "'/retro-arcade/'" in route and "webUi.type === 'static'" in route
assert 'smartsafehub-app-retro-arcade' in manager
assert 'SMARTSAFEHUB_APPS_WEB_ROOT' in manager
assert "{ id: 'entertainment', label: '엔터테인먼트' }" in ui
assert 'web_ui_package "$id" "$idx"' in manager
assert "webUiReady: length(fields) >= 5 && fields[4] == '1'" in status
allowed = re.compile(r'^/cgi-bin/luci/admin/[a-z0-9_-]+(?:/[a-z0-9_-]+)*$')
assert allowed.fullmatch('/cgi-bin/luci/admin/services/nlbw/display')
for bad in ('https://evil.example/', '//evil.example/', '/cgi-bin/luci/admin/../logout',
'/cgi-bin/luci/admin/%2e%2e', '/cgi-bin/luci/admin/services/x?url=http',
'/cgi-bin/luci/admin/services/x#fragment', '/cgi-bin/luci/admin//services/x'):
assert not allowed.fullmatch(bad), bad
PY
# Check TypeScript helper without producing frontend build files.
if command -v tsc >/dev/null 2>&1; then
tsc --noEmit --skipLibCheck --target es2020 "$ROOT/frontend/src/utils/appsWebUi.ts"
fi
echo 'PASS: Apps secure new-tab launch and notification contracts'
+728
View File
@@ -0,0 +1,728 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
MANAGER="$ROOT/root/usr/libexec/smartsafehub-apps-manager"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
fail() { echo "FAIL: $*" >&2; exit 1; }
mkdir -p "$TMP/bin" "$TMP/runtime" "$TMP/owned" "$TMP/repo" "$TMP/overlay"
# A default OpenWrt BusyBox image does not provide od. Simulate that
# environment even if the developer workstation has GNU/coreutils od.
cat > "$TMP/bin/od" <<'SH_OD'
#!/bin/sh
exit 127
SH_OD
chmod +x "$TMP/bin/od"
PATH="$TMP/bin:$PATH"
export PATH
# Linux /proc is unavailable on macOS. A fixture keeps the production check enabled.
printf 'MemTotal: 262144 kB\n' > "$TMP/meminfo"
printf 'all\naarch64_cortex-a53\n' > "$TMP/apk-arch"
printf 'signed immutable package-index test fixture\n' > "$TMP/repo/packages.adb"
printf 'signed mock package payload\n' > "$TMP/repo/nlbwmon-2025.06.02~29236be6-r1.apk"
printf 'approved LuCI mock package payload\n' > "$TMP/repo/luci-app-nlbwmon-26.288.56989~75a306d.apk"
printf 'approved chart mock package payload\n' > "$TMP/repo/luci-lib-chartjs-26.288.56989~75a306d.apk"
printf 'approved Retro Arcade mock package payload\n' > "$TMP/repo/smartsafehub-app-retro-arcade-1.0.0-r1.apk"
printf 'approved etherwake mock package payload\n' > "$TMP/repo/etherwake-1.09-r5.apk"
printf 'approved WOL LuCI mock package payload\n' > "$TMP/repo/luci-app-wol-26.288.56989~75a306d.apk"
INDEX_SHA="$(sha256sum "$TMP/repo/packages.adb" | cut -d ' ' -f 1)"
ARCHIVE_SHA="$(sha256sum "$TMP/repo/nlbwmon-2025.06.02~29236be6-r1.apk" | cut -d ' ' -f 1)"
LUCISH="$(sha256sum "$TMP/repo/luci-app-nlbwmon-26.288.56989~75a306d.apk" | cut -d ' ' -f 1)"
CHARTSHA="$(sha256sum "$TMP/repo/luci-lib-chartjs-26.288.56989~75a306d.apk" | cut -d ' ' -f 1)"
RETROSHA="$(sha256sum "$TMP/repo/smartsafehub-app-retro-arcade-1.0.0-r1.apk" | cut -d ' ' -f 1)"
ETHERSHA="$(sha256sum "$TMP/repo/etherwake-1.09-r5.apk" | cut -d ' ' -f 1)"
WOLSHA="$(sha256sum "$TMP/repo/luci-app-wol-26.288.56989~75a306d.apk" | cut -d ' ' -f 1)"
cat > "$TMP/catalog.json" <<EOF
{"schema":1,"device_code":"iptime-ax3000sm","firmware_build_id":"mock-build-1","channel":"stable","apps":[{"id":"network-usage-monitor","version":"2025.06.02-r1","name":"네트워크 사용량 모니터","runtime":"router","installer":"apk","package_arch":"aarch64_cortex-a53","repository_release_id":"20261027T123000","repository_index_sha256":"$INDEX_SHA","packages":[{"name":"nlbwmon","version":"2025.06.02~29236be6-r1","sha256":"$ARCHIVE_SHA"},{"name":"luci-app-nlbwmon","version":"26.288.56989~75a306d","sha256":"$LUCISH"},{"name":"luci-lib-chartjs","version":"26.288.56989~75a306d","sha256":"$CHARTSHA"}],"requirements":{"min_memory_mb":1,"min_storage_mb":1}}]}
EOF
# Use jq when available for faster field lookups; the Python fallback is portable.
cat > "$TMP/bin/jsonfilter" <<'SH2'
#!/bin/sh
if command -v jq >/dev/null 2>&1; then
file=''; expr=''
while [ "$#" -gt 0 ]; do
case "$1" in
-i) file="$2"; shift 2;;
-e) expr="$2"; shift 2;;
*) exit 2;;
esac
done
case "$expr" in
'@.apps[*].id') jq_expr='.apps[].id';;
@.*) jq_expr="${expr#@}";;
*) exit 2;;
esac
jq -r "($jq_expr) | select(. != null)" "$file" 2>/dev/null
else
exec python3 "$0.py" "$@"
fi
SH2
cat > "$TMP/bin/jsonfilter.py" <<'PY'
import json, re, sys
args=sys.argv
obj=json.load(open(args[args.index('-i')+1]))
expression=args[args.index('-e')+1]
if expression == '@.apps[*].id':
for app in obj.get('apps',[]): print(app['id'])
else:
try:
for part in re.findall(r'[^.\[\]]+|\[\d+\]', expression[2:]):
obj = obj[int(part[1:-1])] if part.startswith('[') else obj[part]
if isinstance(obj,(str,int,float)): print(obj)
except (KeyError,IndexError,TypeError): pass
PY
cat > "$TMP/bin/fetch-catalog" <<'SH2'
#!/bin/sh
cp "$APPS_TEST_CATALOG" "$SMARTSAFEHUB_APPS_RUNTIME_DIR/apps-catalog.json"
SH2
cat > "$TMP/bin/uclient-fetch" <<'SH2'
#!/bin/sh
while [ "$#" -gt 0 ]; do
case "$1" in
-O) dest="$2"; shift 2;;
-q|-T) [ "$1" != -T ] || shift; shift;;
*) url="$1"; shift;;
esac
done
case "$url" in
https://repo.smartsafehub.com/stable/packages/aarch64_cortex-a53/smartsafehub/20261027T123000/packages.adb)
cp "$APPS_TEST_REPO/packages.adb" "$dest";;
https://repo.smartsafehub.com/stable/packages/aarch64_cortex-a53/smartsafehub/20261027T123000/nlbwmon-2025.06.02~29236be6-r1.apk)
cp "$APPS_TEST_REPO/nlbwmon-2025.06.02~29236be6-r1.apk" "$dest";;
https://repo.smartsafehub.com/stable/packages/aarch64_cortex-a53/smartsafehub/20261027T123000/luci-app-nlbwmon-26.288.56989~75a306d.apk)
cp "$APPS_TEST_REPO/luci-app-nlbwmon-26.288.56989~75a306d.apk" "$dest";;
https://repo.smartsafehub.com/stable/packages/aarch64_cortex-a53/smartsafehub/20261027T123000/luci-lib-chartjs-26.288.56989~75a306d.apk)
cp "$APPS_TEST_REPO/luci-lib-chartjs-26.288.56989~75a306d.apk" "$dest";;
https://repo.smartsafehub.com/stable/packages/aarch64_cortex-a53/smartsafehub/20261027T123000/smartsafehub-app-retro-arcade-1.0.0-r1.apk)
cp "$APPS_TEST_REPO/smartsafehub-app-retro-arcade-1.0.0-r1.apk" "$dest";;
https://repo.smartsafehub.com/stable/packages/aarch64_cortex-a53/smartsafehub/20261027T123000/smartsafehub-app-retro-arcade-1.1.0-r1.apk)
cp "$APPS_TEST_REPO/smartsafehub-app-retro-arcade-1.1.0-r1.apk" "$dest";;
https://repo.smartsafehub.com/stable/packages/aarch64_cortex-a53/smartsafehub/20261027T123000/etherwake-1.09-r5.apk)
cp "$APPS_TEST_REPO/etherwake-1.09-r5.apk" "$dest";;
https://repo.smartsafehub.com/stable/packages/aarch64_cortex-a53/smartsafehub/20261027T123000/luci-app-wol-26.288.56989~75a306d.apk)
cp "$APPS_TEST_REPO/luci-app-wol-26.288.56989~75a306d.apk" "$dest";;
*) exit 2;;
esac
printf '%s\n' "$url" >> "$APPS_TEST_URL_LOG"
SH2
cat > "$TMP/bin/apk" <<'SH2'
#!/bin/sh
printf '%s\n' "$*" >> "$APPS_TEST_APK_LOG"
case "$1" in
--print-arch) [ "${APPS_TEST_ARCH_CLI_FAIL:-0}" = 0 ] || exit 1; echo "${APPS_TEST_ARCH_CLI:-aarch64_cortex-a53}"; exit 0;;
version)
[ "$2" = -t ] || exit 2
case "$3:$4" in
1.0.0-r1:1.1.0-r1) echo '<';;
1.1.0-r1:1.0.0-r1) echo '>';;
*) [ "$3" = "$4" ] && echo '=' || exit 2;;
esac
exit 0;;
info)
if [ "$2" = -s ]; then
shift 2
[ "${APPS_TEST_SIZE_MISSING:-0}" = 0 ] || exit 2
[ "$#" -eq 1 ] || exit 2
[ "${APPS_TEST_SIZE_FULL_INFO_ONLY:-0}" = 0 ] || exit 2
for name in "$@"; do
grep -qxF "$name" "$APPS_TEST_INSTALLED" 2>/dev/null || exit 2
[ "${APPS_TEST_SIZE_MISSING_PACKAGE:-}" != "$name" ] || exit 2
# Device capture (OpenWrt 25.12 APK): repeating the same installed
# size twice or three times is valid, but conflicting values are not.
# During an update the APK index can expose old and new versions.
# Only the installed version should determine the displayed size.
if [ "${APPS_TEST_SIZE_MIXED_RELEASES:-0}" != 0 ] &&
[ "$name" = smartsafehub-app-retro-arcade ]; then
printf '%s-1.0.0-r1 installed size:\n1362 KiB\n\n' "$name"
printf '%s-1.1.0-r1 installed size:\n2048 KiB\n\n' "$name"
if [ "$APPS_TEST_SIZE_MIXED_RELEASES" = conflicting ]; then
printf '%s-1.1.0-r1 installed size:\n2096 KiB\n\n' "$name"
fi
continue
fi
if [ "${APPS_TEST_SIZE_DEVICE_REPEAT:-0}" = 1 ]; then
case "$name" in
nlbwmon) version=2025.06.02~29236be6-r1; kib=46; copies=2;;
luci-app-nlbwmon) version=26.288.56989~75a306d; kib=36; copies=3;;
luci-lib-chartjs) version=26.288.56989~75a306d; kib=33; copies=3;;
*) exit 2;;
esac
i=0
while [ "$i" -lt "$copies" ]; do
value="$kib"
if [ "${APPS_TEST_SIZE_CONFLICTING_REPEAT:-0}" = 1 ] &&
[ "$name" = nlbwmon ] && [ "$i" -eq 1 ]; then
value=47
fi
printf '%s-%s installed size:\n%s KiB\n\n' "$name" "$version" "$value"
i=$((i + 1))
done
continue
fi
if [ "${APPS_TEST_SIZE_INLINE:-0}" = 1 ]; then
case "$name" in
nlbwmon) printf '%s-1.0.0-r1 installed size: 1.5 MiB\n' "$name";;
luci-app-nlbwmon) printf '%s-1.0.0-r1 installed size: 512KiB\n' "$name";;
luci-lib-chartjs) printf '%s-1.0.0-r1 installed size: 256 KiB\n' "$name";;
smartsafehub-app-retro-arcade) printf '%s-1.0.0-r1 installed size: 12.5 MiB\n' "$name";;
esac
continue
fi
printf '%s-1.0.0-r1 installed size:\n' "$name"
if [ "${APPS_TEST_SIZE_INVALID:-0}" = 1 ]; then
echo 'not-a-size'; continue
fi
case "$name" in
nlbwmon) printf '1.5 MiB\n';;
luci-app-nlbwmon) printf '512 KiB\n';;
luci-lib-chartjs) printf '256 KiB\n';;
smartsafehub-app-retro-arcade) printf '12.5 MiB\n';;
*) printf '0 B\n';;
esac
done
exit 0
fi
if [ "$#" -eq 2 ] && [ "${APPS_TEST_SIZE_FULL_INFO_ONLY:-0}" = 1 ]; then
name="$2"
grep -qxF "$name" "$APPS_TEST_INSTALLED" 2>/dev/null || exit 2
printf '%s-1.0.0-r1 description:\nMock metadata\n\n%s-1.0.0-r1 installed size:\n' "$name" "$name"
case "$name" in
nlbwmon) printf '1.5 MiB\n';;
luci-app-nlbwmon) printf '512 KiB\n';;
luci-lib-chartjs) printf '256 KiB\n';;
*) exit 2;;
esac
exit 0
fi
[ "$2" = -e ] || exit 2
grep -qxF "$3" "$APPS_TEST_INSTALLED" 2>/dev/null; exit $?;;
list)
[ "$2" = -I ] || exit 2
grep -qxF "$3" "$APPS_TEST_INSTALLED" 2>/dev/null || exit 0
if [ -s "$APPS_TEST_VERSION_DB" ]; then
version="$(awk -v n="$3" '$1 == n {print $2; exit}' "$APPS_TEST_VERSION_DB")"
[ -z "$version" ] || { echo "$3-$version aarch64_cortex-a53 [installed]"; exit 0; }
fi
if [ "$3" = etherwake ]; then
echo 'etherwake-1.09-r5 aarch64_cortex-a53 [installed]'
else
echo "$3-${APPS_TEST_SHARED_VERSION:-26.288.56989~75a306d} aarch64_cortex-a53 [installed]"
fi
exit 0;;
--repositories-file)
[ "$2" = /dev/null ] || exit 2
[ "$3" = -X ] || exit 2
case "$4" in
file://*/apps-work.*/packages/packages.adb) repo=hub; local_index="${4#file://}";;
*) exit 2;;
esac
[ -s "$local_index" ] || exit 2
cmp -s "$local_index" "$APPS_TEST_REPO/packages.adb" || exit 2
if [ -s "${local_index%/*}/smartsafehub-app-retro-arcade-1.0.0-r1.apk" ] ||
[ -s "${local_index%/*}/smartsafehub-app-retro-arcade-1.1.0-r1.apk" ]; then
[ "$(find "${local_index%/*}" -name '*.apk' | wc -l | tr -d ' ')" -eq 1 ] || exit 2
elif [ -s "${local_index%/*}/etherwake-1.09-r5.apk" ] ||
[ -s "${local_index%/*}/luci-app-wol-26.288.56989~75a306d.apk" ]; then
[ "$(find "${local_index%/*}" -name '*.apk' | wc -l | tr -d ' ')" -eq 2 ] || exit 2
for name in etherwake-1.09-r5 luci-app-wol-26.288.56989~75a306d; do
[ -s "${local_index%/*}/$name.apk" ] || exit 2
done
else
for name in nlbwmon-2025.06.02~29236be6-r1 \
luci-app-nlbwmon-26.288.56989~75a306d \
luci-lib-chartjs-26.288.56989~75a306d; do
[ -s "${local_index%/*}/$name.apk" ] || exit 2
done
fi
if [ "$5" = update ]; then
if [ "${APPS_TEST_REPO_UPDATE_FAIL:-0}" = 1 ]; then
echo 'ERROR: repository index update rejected' >&2
exit 1
fi
touch "$APPS_TEST_INDEX_READY"
exit 0
fi
[ "$5" = add ] || exit 2
[ -f "$APPS_TEST_INDEX_READY" ] || {
echo 'WARNING: opening from cache: No such file or directory' >&2
exit 1
}
shift 5;;
del)
if [ "$2" = --simulate ]; then
[ "${APPS_TEST_DEL_CORE:-0}" = 1 ] && echo 'Purging kmod-nf-conntrack-netlink (mock)'
[ "${APPS_TEST_DEL_SHARED:-0}" = 1 ] && echo 'Purging luci-lib-chartjs (mock)'
exit 0
fi
shift
for item in "$@"; do grep -vxF "$item" "$APPS_TEST_INSTALLED" > "$APPS_TEST_INSTALLED.new" || :; mv "$APPS_TEST_INSTALLED.new" "$APPS_TEST_INSTALLED"; done
exit 0;;
*) exit 2;;
esac
upgrade=0
if [ "${1:-}" = --upgrade ]; then upgrade=1; shift; fi
if [ "${1:-}" = --simulate ]; then
[ "${APPS_TEST_MISSING_DEP:-0}" = 1 ] && { echo 'ERROR: unable to select packages: missing dependency' >&2; exit 1; }
shift
for pin in "$@"; do
name="${pin%%=*}"
case "$repo:$name" in
hub:nlbwmon|hub:luci-app-nlbwmon|hub:luci-lib-chartjs|hub:smartsafehub-app-retro-arcade|hub:etherwake|hub:luci-app-wol) ;;
*) exit 2;;
esac
if [ "${APPS_TEST_UPGRADE:-0}" = 1 ]; then
echo '(1/3) Upgrading luci-base (mock)'
elif [ "$upgrade" = 1 ]; then
echo "(1/1) Upgrading $name (mock)"
else
echo "(1/2) Installing $name (mock)"
fi
done
exit 0
fi
for pin in "$@"; do
name="${pin%%=*}"
case "$repo:$name" in
hub:nlbwmon|hub:luci-app-nlbwmon|hub:luci-lib-chartjs|hub:smartsafehub-app-retro-arcade|hub:etherwake|hub:luci-app-wol) ;;
*) exit 2;;
esac
grep -qxF "$name" "$APPS_TEST_INSTALLED" 2>/dev/null || printf '%s\n' "$name" >> "$APPS_TEST_INSTALLED"
if [ "$upgrade" = 1 ]; then
awk -v n="$name" '$1 != n' "$APPS_TEST_VERSION_DB" > "$APPS_TEST_VERSION_DB.tmp"
printf '%s %s\n' "$name" "${pin#*=}" >> "$APPS_TEST_VERSION_DB.tmp"
mv -f "$APPS_TEST_VERSION_DB.tmp" "$APPS_TEST_VERSION_DB"
fi
done
SH2
cat > "$TMP/bin/start-stop-daemon" <<'SH2'
#!/bin/sh
[ "$1" = -S ] && [ "$2" = -b ] && [ "$3" = -x ] && [ "$5" = -- ] || exit 2
# Starting and final states must carry the same operation ID.
awk -F '\t' '$1 == "operation_id" { print $2; exit }' \
"$SMARTSAFEHUB_APPS_RUNTIME_DIR/apps-operation.state" >> "$APPS_TEST_OPERATION_START_LOG"
"$4" "$6" "$7" "$8"
SH2
chmod +x "$TMP/bin/"*
cat > "$TMP/bin/metrics-hook" <<'METRICS'
#!/bin/sh
printf '%s
' "$*" >> "$APPS_TEST_METRICS_LOG"
METRICS
chmod +x "$TMP/bin/metrics-hook"
export APPS_TEST_METRICS_LOG="$TMP/install-events"
export SMARTSAFEHUB_APPS_METRICS_BIN="$TMP/bin/metrics-hook"
: > "$APPS_TEST_METRICS_LOG"
export SMARTSAFEHUB_APPS_MANAGER_BIN="$MANAGER"
export SMARTSAFEHUB_APPS_HELPER_BIN="$TMP/bin/fetch-catalog"
export SMARTSAFEHUB_APPS_RUNTIME_DIR="$TMP/runtime"
export SMARTSAFEHUB_APPS_OWNED_DIR="$TMP/owned"
export SMARTSAFEHUB_APPS_UCLIENT_BIN="$TMP/bin/uclient-fetch"
export APPS_TEST_URL_LOG="$TMP/fetched-urls"
export SMARTSAFEHUB_APPS_OVERLAY_PATH="$TMP/overlay"
export SMARTSAFEHUB_APPS_MEMINFO_PATH="$TMP/meminfo"
export SMARTSAFEHUB_APPS_JSONFILTER_BIN="$TMP/bin/jsonfilter"
export SMARTSAFEHUB_APPS_APK_BIN="$TMP/bin/apk"
export SMARTSAFEHUB_APPS_ARCH_FILE="$TMP/apk-arch"
export SMARTSAFEHUB_APPS_DAEMON_BIN="$TMP/bin/start-stop-daemon"
export SMARTSAFEHUB_APPS_LUCI_INDEX_CACHE="$TMP/luci-indexcache"
export APPS_TEST_CATALOG="$TMP/catalog.json"
export APPS_TEST_REPO="$TMP/repo"
export APPS_TEST_INSTALLED="$TMP/installed"
export APPS_TEST_APK_LOG="$TMP/apk-commands"
export APPS_TEST_INDEX_READY="$TMP/index-ready"
export APPS_TEST_VERSION_DB="$TMP/installed-versions"
: > "$APPS_TEST_VERSION_DB"
export APPS_TEST_OPERATION_START_LOG="$TMP/operation-start-ids"
: > "$APPS_TEST_INSTALLED"
# A validated fixed release installs all three APKs from a single signed feed.
"$MANAGER" start install '../../../../etc/passwd' >/dev/null 2>&1 && fail 'invalid app ID accepted'
"$MANAGER" start install 'not-approved' || fail 'unknown app should queue'
grep -Fqx "$(printf 'code\tE22')" "$TMP/runtime/apps-operation.state" || fail 'unknown app was installed'
first_operation_id="$(awk -F '\t' '$1 == "operation_id" { print $2; exit }' "$TMP/runtime/apps-operation.state")"
[ -n "$first_operation_id" ] || fail 'first failed operation has no identity'
printf '%s\n' "$first_operation_id" | grep -Eq '^[0-9]{10}-[0-9]+-[0-9a-f]{16}$' || fail 'operation ID must include a valid random nonce without od'
[ "$(sed -n '1p' "$APPS_TEST_OPERATION_START_LOG")" = "$first_operation_id" ] || fail 'operation identity changed during failure'
"$MANAGER" start install not-approved || fail 'repeated failed install did not queue'
repeat_operation_id="$(awk -F '\t' '$1 == "operation_id" { print $2; exit }' "$TMP/runtime/apps-operation.state")"
[ "$repeat_operation_id" != "$first_operation_id" ] || fail 'repeated E22 failure reused dismissed operation ID'
[ "$(sed -n '2p' "$APPS_TEST_OPERATION_START_LOG")" = "$repeat_operation_id" ] || fail 'repeated failure changed identity between phases'
"$MANAGER" start install network-usage-monitor || fail 'install did not queue'
grep -Fqx "$(printf 'phase\tsuccess')" "$TMP/runtime/apps-operation.state" || fail 'three APK installation failed'
second_operation_id="$(awk -F '\t' '$1 == "operation_id" { print $2; exit }' "$TMP/runtime/apps-operation.state")"
[ -n "$second_operation_id" ] && [ "$first_operation_id" != "$second_operation_id" ] || fail 'new operation reused old identity'
[ "$(sed -n '3p' "$APPS_TEST_OPERATION_START_LOG")" = "$second_operation_id" ] || fail 'operation identity changed during success'
[ "$(wc -l < "$APPS_TEST_INSTALLED")" -eq 3 ] || fail 'all three APKs were not installed'
[ "$(wc -l < "$APPS_TEST_METRICS_LOG" | tr -d ' ')" -eq 1 ] || fail 'successful installation not recorded exactly once'
grep -Eq '^enqueue network-usage-monitor 2025\.06\.02-r1 [0-9]+-[0-9]+-[a-f0-9]{16} install$' "$APPS_TEST_METRICS_LOG" || fail 'invalid install receipt fields'
grep -Eq '^--repositories-file /dev/null -X file://.*/packages/packages.adb update$' "$APPS_TEST_APK_LOG" || fail 'approved local index not loaded before simulation'
grep -Eq '^--repositories-file /dev/null -X file://.*/packages/packages.adb add --simulate nlbwmon=2025\.06\.02~29236be6-r1 luci-app-nlbwmon=26\.288\.56989~75a306d luci-lib-chartjs=26\.288\.56989~75a306d$' "$APPS_TEST_APK_LOG" || fail 'isolated signed repository simulation not used'
grep -Eq '^--repositories-file /dev/null -X file://.*/packages/packages.adb add nlbwmon=' "$APPS_TEST_APK_LOG" || fail 'isolated signed repository install not used'
# A failed random-source read must never launch a removal or leave a lock.
# This also guards against accidentally generating a fixed nonce from empty data.
cat > "$TMP/bin/dd" <<'SH_DD'
#!/bin/sh
exit 1
SH_DD
chmod +x "$TMP/bin/dd"
if "$MANAGER" start remove network-usage-monitor >/dev/null 2>&1; then
fail 'failed entropy read started a removal'
fi
rm -f "$TMP/bin/dd"
[ ! -e "$TMP/runtime/apps-operation.lock" ] || fail 'failed entropy read left a lock'
grep -Fqx "$(printf 'phase\tsuccess')" "$TMP/runtime/apps-operation.state" || fail 'failed launch overwrote existing operation'
[ "$(wc -l < "$APPS_TEST_INSTALLED" | tr -d ' ')" -eq 3 ] || fail 'failed entropy read touched installed APKs'
"$MANAGER" status
grep -Fq "$(printf 'network-usage-monitor\t1\t1\t1\t1')" "$TMP/runtime/apps-status.tsv" || fail 'ready state missing'
grep -Fq "$(printf 'network-usage-monitor\t1\t1\t1\t1\t0\t')" "$TMP/runtime/apps-status.tsv" || fail 'installed state changed'
# Optional size lookup must never run on the critical installation-status path.
[ "$(grep -c '^info -s ' "$APPS_TEST_APK_LOG" || :)" -eq 0 ] || fail 'status unexpectedly queried APK sizes'
"$MANAGER" sizes
grep -Fqx "$(printf 'network-usage-monitor\t2359296')" "$TMP/runtime/apps-size.tsv" || fail 'multi-package installed size not summed'
[ "$(grep -c '^info -s ' "$APPS_TEST_APK_LOG" || :)" -eq 3 ] || fail 'each network APK size must be queried separately'
for package in nlbwmon luci-app-nlbwmon luci-lib-chartjs; do
grep -Fqx "info -s $package" "$APPS_TEST_APK_LOG" || fail "missing individual size lookup: $package"
done
# Standard apk info must work when this apk v3 variant does not expose -s.
export APPS_TEST_SIZE_FULL_INFO_ONLY=1
"$MANAGER" sizes
grep -Fqx "$(printf 'network-usage-monitor\t2359296')" "$TMP/runtime/apps-size.tsv" || fail 'full APK info fallback failed'
unset APPS_TEST_SIZE_FULL_INFO_ONLY
# OpenWrt APK versions may render the size on the header line or with no
# whitespace between the number and KiB. The sum must be identical.
export APPS_TEST_SIZE_INLINE=1
"$MANAGER" sizes
grep -Fqx "$(printf 'network-usage-monitor\t2359296')" "$TMP/runtime/apps-size.tsv" || fail 'inline/compact installed size not summed'
[ ! -e "$TMP/runtime/apps-size-diagnostics.tsv" ] || fail 'retired size diagnostic file was created'
unset APPS_TEST_SIZE_INLINE
# Real OpenWrt 25.12 device output repeats the *same* installed-size header:
# nlbwmon twice, luci-app-nlbwmon / luci-lib-chartjs three times each.
export APPS_TEST_SIZE_DEVICE_REPEAT=1
"$MANAGER" sizes
grep -Fqx "$(printf 'network-usage-monitor\t117760')" "$TMP/runtime/apps-size.tsv" || fail 'real duplicated APK info output did not sum to 115 KiB'
[ ! -e "$TMP/runtime/apps-size-diagnostics.tsv" ] || fail 'repeated APK size metadata wrote a diagnostic file'
# A repeated heading with a *different* size is ambiguous: refuse to guess.
export APPS_TEST_SIZE_CONFLICTING_REPEAT=1
"$MANAGER" sizes
[ ! -s "$TMP/runtime/apps-size.tsv" ] || fail 'conflicting duplicated size was accepted'
[ ! -e "$TMP/runtime/apps-size-diagnostics.tsv" ] || fail 'conflicting size metadata wrote a diagnostic file'
unset APPS_TEST_SIZE_CONFLICTING_REPEAT APPS_TEST_SIZE_DEVICE_REPEAT
# Do not show a misleading partial sum if one package cannot be measured.
export APPS_TEST_SIZE_MISSING_PACKAGE=luci-lib-chartjs
"$MANAGER" sizes
[ ! -s "$TMP/runtime/apps-size.tsv" ] || fail 'partial APK metadata reported as complete installed size'
[ ! -e "$TMP/runtime/apps-size-diagnostics.tsv" ] || fail 'missing package size wrote a diagnostic file'
unset APPS_TEST_SIZE_MISSING_PACKAGE
# Malformed and missing metadata must not alter the actual installed state.
export APPS_TEST_SIZE_INVALID=1
"$MANAGER" sizes
[ ! -s "$TMP/runtime/apps-size.tsv" ] || fail 'invalid APK size reported as bytes'
"$MANAGER" status
grep -Fq "$(printf 'network-usage-monitor\t1\t1\t1\t1\t0\t')" "$TMP/runtime/apps-status.tsv" || fail 'invalid size changed app state'
unset APPS_TEST_SIZE_INVALID
export APPS_TEST_SIZE_MISSING=1
"$MANAGER" sizes
[ ! -s "$TMP/runtime/apps-size.tsv" ] || fail 'missing APK size must be omitted'
[ ! -e "$TMP/runtime/apps-size-diagnostics.tsv" ] || fail 'invalid/missing APK size created retired diagnostics'
unset APPS_TEST_SIZE_MISSING
"$MANAGER" start remove network-usage-monitor || fail 'remove did not queue'
[ ! -s "$APPS_TEST_INSTALLED" ] || fail 'new Apps uninstall did not remove owned packages'
[ "$(wc -l < "$APPS_TEST_METRICS_LOG" | tr -d ' ')" -eq 1 ] || fail 'removal counted as an installation'
cp "$TMP/catalog.json" "$TMP/catalog-good.json"
python3 - "$TMP/catalog.json" <<'PY_BAD'
import json,sys
path=sys.argv[1]; obj=json.load(open(path)); obj['apps'][0].pop('repository_release_id')
open(path,'w').write(json.dumps(obj))
PY_BAD
"$MANAGER" start install network-usage-monitor || fail 'missing release ID did not queue'
grep -Fqx "$(printf 'code\tE25')" "$TMP/runtime/apps-operation.state" || fail 'missing release ID accepted'
cp "$TMP/catalog-good.json" "$TMP/catalog.json"
python3 - "$TMP/catalog.json" <<'PY_BAD'
import json,sys
path=sys.argv[1]; obj=json.load(open(path)); obj['apps'][0]['packages'].pop()
open(path,'w').write(json.dumps(obj))
PY_BAD
"$MANAGER" start install network-usage-monitor || fail 'partial package set did not queue'
grep -Fqx "$(printf 'code\tE27')" "$TMP/runtime/apps-operation.state" || fail 'partial package set accepted'
cp "$TMP/catalog-good.json" "$TMP/catalog.json"
# A repository signature/index failure must fail closed before any install.
export APPS_TEST_REPO_UPDATE_FAIL=1
rm -f "$APPS_TEST_INDEX_READY"
"$MANAGER" start install network-usage-monitor || fail 'failed repository update did not queue'
grep -Fqx "$(printf 'code\tE34')" "$TMP/runtime/apps-operation.state" || fail 'failed repository update was accepted'
grep -Fq 'repository index update rejected' "$TMP/runtime/apps-last-error.log" || fail 'repository update error was not preserved'
[ ! -s "$APPS_TEST_INSTALLED" ] || fail 'failed repository update installed packages'
unset APPS_TEST_REPO_UPDATE_FAIL
cp "$TMP/catalog-good.json" "$TMP/catalog.json"
printf 'tampered APK\n' > "$TMP/repo/luci-lib-chartjs-26.288.56989~75a306d.apk"
"$MANAGER" start install network-usage-monitor || fail 'tampered APK did not queue'
grep -Fqx "$(printf 'code\tE33')" "$TMP/runtime/apps-operation.state" || fail 'modified APK accepted'
[ "$(wc -l < "$APPS_TEST_METRICS_LOG" | tr -d ' ')" -eq 1 ] || fail 'failed installation counted'
printf 'PASS: Apps install/remove signed immutable release, three APKs, ownership and hash checks\n'
# Self-built App publications must install from immutable signed releases.
if ! grep -Fq 'repo_url="$REPO_BASE/$channel/packages/$arch/smartsafehub/$publication"' "$MANAGER"; then
echo 'FAIL: signed immutable Apps package publication is missing' >&2
exit 1
fi
# Retro Arcade is a single APK from the same signed feed. Verify readiness of
# its static entry point independently of the package-manager install state.
: > "$APPS_TEST_INSTALLED"
export SMARTSAFEHUB_APPS_WEB_ROOT="$TMP/www"
cat > "$TMP/catalog.json" <<EOF_RETRO
{"schema":1,"device_code":"iptime-ax3000sm","firmware_build_id":"mock-build-1","channel":"stable","apps":[{"id":"retro-arcade","version":"1.0.0-r1","name":"Retro Arcade","runtime":"router","installer":"apk","package_arch":"aarch64_cortex-a53","repository_release_id":"20261027T123000","repository_index_sha256":"$INDEX_SHA","web_ui":{"type":"static","path":"/retro-arcade/","package":"smartsafehub-app-retro-arcade"},"packages":[{"name":"smartsafehub-app-retro-arcade","version":"1.0.0-r1","sha256":"$RETROSHA"}],"requirements":{"min_memory_mb":1,"min_storage_mb":1}}]}
EOF_RETRO
"$MANAGER" start install retro-arcade || fail 'Retro Arcade install did not queue'
grep -Fqx "$(printf 'phase\tsuccess')" "$TMP/runtime/apps-operation.state" || fail 'single Retro APK installation failed'
[ "$(cat "$APPS_TEST_INSTALLED")" = smartsafehub-app-retro-arcade ] || fail 'unexpected Retro APK installation set'
[ "$(wc -l < "$APPS_TEST_METRICS_LOG" | tr -d ' ')" -eq 2 ] || fail 'Retro install not counted'
grep -Eq '^enqueue retro-arcade 1\.0\.0-r1 [0-9]+-[0-9]+-[a-f0-9]{16} install$' "$APPS_TEST_METRICS_LOG" || fail 'Retro receipt incorrect'
# Pin the exact installed package version used by the status query. The
# approved catalog version must never be substituted for this value.
printf 'smartsafehub-app-retro-arcade 1.0.0-r1\n' > "$APPS_TEST_VERSION_DB"
"$MANAGER" status
grep -Fq "$(printf 'retro-arcade\t1\t1\t1\t0')" "$TMP/runtime/apps-status.tsv" || fail 'Retro Arcade should not launch before its HTML exists'
mkdir -p "$TMP/www/retro-arcade"
printf 'retro entry\n' > "$TMP/www/retro-arcade/index.html"
"$MANAGER" status
grep -Fq "$(printf 'retro-arcade\t1\t1\t1\t1')" "$TMP/runtime/apps-status.tsv" || fail 'Retro Arcade entry point is not ready'
grep -Fqx "$(printf 'retro-arcade\t1\t1\t1\t1\t0\t1.0.0-r1')" "$TMP/runtime/apps-status.tsv" || fail 'installed Retro version missing'
"$MANAGER" sizes
grep -Fqx "$(printf 'retro-arcade\t13107200')" "$TMP/runtime/apps-size.tsv" || fail 'Retro Arcade size missing'
rm "$TMP/www/retro-arcade/index.html"
"$MANAGER" status
grep -Fq "$(printf 'retro-arcade\t1\t1\t1\t0')" "$TMP/runtime/apps-status.tsv" || fail 'removed Retro Arcade entry point still launches'
# A malformed catalog must not allow more than one approved Retro APK.
python3 - "$TMP/catalog.json" <<'PY_RETRO_BAD'
import json,sys
p=sys.argv[1]; data=json.load(open(p)); data['apps'][0]['packages'].append(data['apps'][0]['packages'][0]); open(p,'w').write(json.dumps(data))
PY_RETRO_BAD
"$MANAGER" start install retro-arcade || fail 'invalid multi-APK Retro install should queue'
grep -Fqx "$(printf 'code\tE27')" "$TMP/runtime/apps-operation.state" || fail 'multiple Retro packages not rejected'
# Restore the catalog for safe removal of the original installed APK.
python3 - "$TMP/catalog.json" <<'PY_RETRO_GOOD'
import json,sys
p=sys.argv[1]; data=json.load(open(p)); data['apps'][0]['packages'].pop(); open(p,'w').write(json.dumps(data))
PY_RETRO_GOOD
"$MANAGER" start remove retro-arcade || fail 'Retro Arcade removal did not queue'
[ ! -s "$APPS_TEST_INSTALLED" ] || fail 'Retro Arcade was not uninstalled'
printf 'PASS: Retro Arcade single APK, static entry point readiness and safe removal\n'
# Restore the mock file deliberately corrupted by the earlier hash test.
printf 'approved chart mock package payload\n' > "$TMP/repo/luci-lib-chartjs-26.288.56989~75a306d.apk"
# A catalog may contain multiple apps. Each app must be visible to the
# installer and status reporter regardless of its position in the list.
: > "$APPS_TEST_INSTALLED"
rm -f "$TMP/owned/retro-arcade" "$TMP/owned/network-usage-monitor"
printf '%s\n' smartsafehub-app-retro-arcade > "$APPS_TEST_INSTALLED"
cat > "$TMP/catalog.json" <<EOF_BOTH
{"schema":1,"device_code":"iptime-ax3000sm","firmware_build_id":"mock-build-1","channel":"stable","apps":[{"id":"retro-arcade","version":"1.0.0-r1","name":"Retro Arcade","runtime":"router","installer":"apk","package_arch":"aarch64_cortex-a53","repository_release_id":"20261027T123000","repository_index_sha256":"$INDEX_SHA","web_ui":{"type":"static","path":"/retro-arcade/","package":"smartsafehub-app-retro-arcade"},"packages":[{"name":"smartsafehub-app-retro-arcade","version":"1.0.0-r1","sha256":"$RETROSHA"}],"requirements":{"min_memory_mb":1,"min_storage_mb":1}},{"id":"network-usage-monitor","version":"2025.06.02-r1","name":"네트워크 사용량 모니터","runtime":"router","installer":"apk","package_arch":"aarch64_cortex-a53","repository_release_id":"20261027T123000","repository_index_sha256":"$INDEX_SHA","packages":[{"name":"nlbwmon","version":"2025.06.02~29236be6-r1","sha256":"$ARCHIVE_SHA"},{"name":"luci-app-nlbwmon","version":"26.288.56989~75a306d","sha256":"$LUCISH"},{"name":"luci-lib-chartjs","version":"26.288.56989~75a306d","sha256":"$CHARTSHA"}],"requirements":{"min_memory_mb":1,"min_storage_mb":1}}]}
EOF_BOTH
# Retro Arcade was installed manually; testing the other app must not adopt it.
"$TMP/bin/fetch-catalog"
"$MANAGER" status
[ "$(wc -l < "$TMP/runtime/apps-status.tsv" | tr -d ' ')" -eq 2 ] || fail 'status lost the second approved app'
grep -Fq "$(printf 'retro-arcade\t1\t1\t0\t0\t')" "$TMP/runtime/apps-status.tsv" || fail 'manual Retro Arcade status incorrect'
grep -Fq "$(printf 'network-usage-monitor\t0\t0\t0\t0\t')" "$TMP/runtime/apps-status.tsv" || fail 'second app omitted from status'
"$MANAGER" start install network-usage-monitor || fail 'second app installation did not queue'
grep -Fqx "$(printf 'phase\tsuccess')" "$TMP/runtime/apps-operation.state" || fail 'second app install unexpectedly failed E22'
[ "$(wc -l < "$APPS_TEST_INSTALLED" | tr -d ' ')" -eq 4 ] || fail 'install changed unrelated packages'
grep -Fqx smartsafehub-app-retro-arcade "$APPS_TEST_INSTALLED" || fail 'manual Retro Arcade was modified'
[ ! -e "$TMP/owned/retro-arcade" ] || fail 'manual Retro Arcade was adopted'
"$MANAGER" status
grep -Fq "$(printf 'network-usage-monitor\t1\t1\t1\t1\t')" "$TMP/runtime/apps-status.tsv" || fail 'second app installed state missing'
"$MANAGER" start remove network-usage-monitor || fail 'second app removal did not queue'
[ "$(cat "$APPS_TEST_INSTALLED")" = smartsafehub-app-retro-arcade ] || fail 'remove touched manual Retro Arcade'
# Reverse the catalog to verify lookup and status for the other second app.
python3 - "$TMP/catalog.json" <<'PY_BOTH_REVERSED'
import json
import sys
from pathlib import Path
path = Path(sys.argv[1])
data = json.loads(path.read_text())
data['apps'].reverse()
path.write_text(json.dumps(data))
PY_BOTH_REVERSED
"$TMP/bin/fetch-catalog"
"$MANAGER" status
[ "$(wc -l < "$TMP/runtime/apps-status.tsv" | tr -d ' ')" -eq 2 ] || fail 'reversed catalog lost an app'
grep -Fq "$(printf 'retro-arcade\t1\t1\t0\t0\t')" "$TMP/runtime/apps-status.tsv" || fail 'second Retro Arcade status missing'
"$MANAGER" start install retro-arcade || fail 'second Retro Arcade installation did not queue'
grep -Fqx "$(printf 'code\tE29')" "$TMP/runtime/apps-operation.state" || fail 'second app did not reach manual ownership check'
printf 'PASS: multi-app lookup, E22 regression and manual package protection\n'
# An Apps-owned app can update to a newer approved, signed package without
# reinstalling, touching system packages or generating a new install receipt.
: > "$APPS_TEST_INSTALLED"
rm -f "$TMP/owned/network-usage-monitor" "$TMP/owned/retro-arcade"
: > "$APPS_TEST_VERSION_DB"
cat > "$TMP/catalog.json" <<EOF_UPDATE_OLD
{"schema":1,"device_code":"iptime-ax3000sm","firmware_build_id":"mock-build-1","channel":"stable","apps":[{"id":"retro-arcade","version":"1.0.0-r1","name":"Retro Arcade","runtime":"router","installer":"apk","package_arch":"aarch64_cortex-a53","repository_release_id":"20261027T123000","repository_index_sha256":"$INDEX_SHA","packages":[{"name":"smartsafehub-app-retro-arcade","version":"1.0.0-r1","sha256":"$RETROSHA"}],"requirements":{"min_memory_mb":1,"min_storage_mb":1}}]}
EOF_UPDATE_OLD
"$MANAGER" start install retro-arcade || fail 'old Retro Arcade install not queued'
grep -Fqx "$(printf 'phase\tsuccess')" "$TMP/runtime/apps-operation.state" || fail 'old Retro Arcade install failed'
printf 'smartsafehub-app-retro-arcade 1.0.0-r1\n' > "$APPS_TEST_VERSION_DB"
receipt_before="$(wc -l < "$APPS_TEST_METRICS_LOG" | tr -d ' ')"
printf 'new signed Retro payload\n' > "$TMP/repo/smartsafehub-app-retro-arcade-1.1.0-r1.apk"
NEWRETROSHA="$(sha256sum "$TMP/repo/smartsafehub-app-retro-arcade-1.1.0-r1.apk" | cut -d ' ' -f 1)"
python3 - "$TMP/catalog.json" "$NEWRETROSHA" <<'PY_UPDATE'
import json,sys
p=sys.argv[1]; data=json.load(open(p)); app=data['apps'][0]
app['version']='1.1.0-r1'; app['packages'][0]['version']='1.1.0-r1'
app['packages'][0]['sha256']=sys.argv[2]
open(p,'w').write(json.dumps(data))
PY_UPDATE
"$TMP/bin/fetch-catalog"
"$MANAGER" status
grep -Fqx "$(printf 'retro-arcade\t1\t1\t1\t0\t1\t1.0.0-r1')" "$TMP/runtime/apps-status.tsv" || fail 'new approved release hid installed 1.0.0-r1'
# Prevent core upgrades even if the APK solver proposes them.
export APPS_TEST_UPGRADE=1
"$MANAGER" start update retro-arcade || fail 'unrelated package simulation did not queue'
grep -Fqx "$(printf 'code\tE53')" "$TMP/runtime/apps-operation.state" || fail 'unrelated system upgrade was not blocked'
grep -Fq "$(printf 'smartsafehub-app-retro-arcade\t1.0.0-r1\t')" "$TMP/owned/retro-arcade" || fail 'failed simulation changed ownership'
unset APPS_TEST_UPGRADE
# Reject altered signed payloads before mutation.
printf 'tampered\n' > "$TMP/repo/smartsafehub-app-retro-arcade-1.1.0-r1.apk"
"$MANAGER" start update retro-arcade || fail 'tampered update did not queue'
grep -Fqx "$(printf 'code\tE33')" "$TMP/runtime/apps-operation.state" || fail 'tampered update was not rejected'
printf 'new signed Retro payload\n' > "$TMP/repo/smartsafehub-app-retro-arcade-1.1.0-r1.apk"
"$MANAGER" start update retro-arcade || fail 'approved update did not queue'
grep -Fqx "$(printf 'phase\tsuccess')" "$TMP/runtime/apps-operation.state" || fail 'approved update did not succeed'
grep -Fqx "$(printf 'smartsafehub-app-retro-arcade\t1.1.0-r1\t%s' "$NEWRETROSHA")" "$TMP/owned/retro-arcade" || fail 'new version manifest not committed'
[ "$(awk '$1 == "smartsafehub-app-retro-arcade" {print $2}' "$APPS_TEST_VERSION_DB")" = 1.1.0-r1 ] || fail 'mock apk version did not update'
[ "$(wc -l < "$APPS_TEST_METRICS_LOG" | tr -d ' ')" -eq "$((receipt_before + 1))" ] || fail 'successful upgrade not recorded exactly once'
grep -Eq '^enqueue retro-arcade 1\.1\.0-r1 [0-9]+-[0-9]+-[a-f0-9]{16} update$' "$APPS_TEST_METRICS_LOG" || fail 'update receipt kind/version incorrect'
[ "$(awk '$1 == "enqueue" && $5 == "update" { n++ } END {print n+0}' "$APPS_TEST_METRICS_LOG")" -eq 1 ] || fail 'upgrade counted as a new install or multiple updates'
"$MANAGER" status
grep -Fqx "$(printf 'retro-arcade\t1\t1\t1\t0\t0\t1.1.0-r1')" "$TMP/runtime/apps-status.tsv" || fail 'installed version not updated after APK upgrade'
# Update releases may appear alongside older APK index metadata: the size
# must match the installed version, even if the older version appears first.
export APPS_TEST_SIZE_MIXED_RELEASES=1
"$MANAGER" sizes
grep -Fqx "$(printf 'retro-arcade\t2097152')" "$TMP/runtime/apps-size.tsv" || fail 'updated Retro size lost when APK info listed multiple releases'
# Contradictory records for that same installed version are still rejected.
export APPS_TEST_SIZE_MIXED_RELEASES=conflicting
"$MANAGER" sizes
[ ! -s "$TMP/runtime/apps-size.tsv" ] || fail 'conflicting current APK release size must be omitted'
unset APPS_TEST_SIZE_MIXED_RELEASES
"$MANAGER" start update retro-arcade || fail 'repeated update should report a no-op'
grep -Fqx "$(printf 'code\tE52')" "$TMP/runtime/apps-operation.state" || fail 'repeated update was not rejected'
[ "$(wc -l < "$APPS_TEST_METRICS_LOG" | tr -d ' ')" -eq "$((receipt_before + 1))" ] || fail 'no-op upgrade produced an event'
# Any attempt to downgrade a package must fail closed.
python3 - "$TMP/catalog.json" "$RETROSHA" <<'PY_DOWNGRADE'
import json,sys
p=sys.argv[1]; data=json.load(open(p)); app=data['apps'][0]
app['version']='1.0.0-r1'; app['packages'][0]['version']='1.0.0-r1'
app['packages'][0]['sha256']=sys.argv[2]
open(p,'w').write(json.dumps(data))
PY_DOWNGRADE
"$TMP/bin/fetch-catalog"
"$MANAGER" status
grep -Fqx "$(printf 'retro-arcade\t1\t1\t1\t0\t0\t1.1.0-r1')" "$TMP/runtime/apps-status.tsv" || fail 'downgrade must not be advertised'
"$MANAGER" start update retro-arcade || fail 'downgrade did not queue'
grep -Fqx "$(printf 'code\tE52')" "$TMP/runtime/apps-operation.state" || fail 'downgrade was not refused'
# Installed APK changed outside Apps ownership: never perform another upgrade.
python3 - "$TMP/catalog.json" "$NEWRETROSHA" <<'PY_NEW'
import json,sys
p=sys.argv[1]; data=json.load(open(p)); app=data['apps'][0]
app['version']='1.1.0-r1'; app['packages'][0]['version']='1.1.0-r1'
app['packages'][0]['sha256']=sys.argv[2]
open(p,'w').write(json.dumps(data))
PY_NEW
printf 'smartsafehub-app-retro-arcade 1.0.0-r1\n' > "$APPS_TEST_VERSION_DB"
"$MANAGER" start update retro-arcade || fail 'manual version change did not queue'
grep -Fqx "$(printf 'code\tE52')" "$TMP/runtime/apps-operation.state" || fail 'manual version drift not rejected'
printf 'PASS: selected-app signed updates, version checks, no core upgrades and no duplicate receipts\n'
# Approved catalog and installed status are fetched independently. A mismatch
# across requests is not an orphan: only an Apps-owned manifest for a package
# that remains installed can mark an approval as retired.
python3 - "$TMP/catalog.json" <<'PY_RETIRED'
import json, sys
p = sys.argv[1]
data = json.load(open(p))
data['apps'] = []
with open(p, 'w') as fp:
json.dump(data, fp)
PY_RETIRED
"$TMP/bin/fetch-catalog"
"$MANAGER" status
grep -Fqx "$(printf 'retro-arcade\t0\t1\t1\t0\t0\t\t1')" "$TMP/runtime/apps-status.tsv" || fail 'a genuinely Apps-owned removed approval was not marked retired'
# Manual APK installations have no ownership manifest and must not be promoted
# to managed/retired Apps. Likewise, forged/corrupt ownership is ignored.
rm -f "$TMP/owned/retro-arcade"
"$MANAGER" status
[ ! -s "$TMP/runtime/apps-status.tsv" ] || fail 'manually installed APK falsely marked retired'
printf 'invalid manifest contents\n' > "$TMP/owned/retro-arcade"
"$MANAGER" status
[ ! -s "$TMP/runtime/apps-status.tsv" ] || fail 'invalid manifest falsely marked retired'
rm -f "$TMP/owned/retro-arcade"
printf 'PASS: catalog/status refresh races do not mistake manual APK installs for retired Apps\n'
# Wake-on-LAN integrates with the same signed APK flow, but requires exactly
# etherwake + luci-app-wol. Its UI is NOT the etherwake helper executable.
printf 'signed index cache\n' > "$SMARTSAFEHUB_APPS_LUCI_INDEX_CACHE"
: > "$APPS_TEST_INSTALLED"
: > "$APPS_TEST_VERSION_DB"
cat > "$TMP/catalog.json" <<EOF_WOL
{"schema":1,"device_code":"iptime-ax3000sm","firmware_build_id":"mock-build-1","channel":"stable","apps":[{"id":"wake-on-lan","version":"26.288.56989~75a306d","name":"Wake-on-LAN","category":"utilities","runtime":"router","installer":"apk","package_arch":"aarch64_cortex-a53","repository_release_id":"20261027T123000","repository_index_sha256":"$INDEX_SHA","web_ui":{"type":"luci","path":"/cgi-bin/luci/admin/services/wol","package":"luci-app-wol"},"packages":[{"name":"etherwake","version":"1.09-r5","sha256":"$ETHERSHA"},{"name":"luci-app-wol","version":"26.288.56989~75a306d","sha256":"$WOLSHA"}],"requirements":{"min_memory_mb":1,"min_storage_mb":1}}]}
EOF_WOL
"$TMP/bin/fetch-catalog"
"$MANAGER" start install wake-on-lan || fail 'Wake-on-LAN install not queued'
grep -Fqx "$(printf 'phase\tsuccess')" "$TMP/runtime/apps-operation.state" || fail 'Wake-on-LAN installation failed'
[ ! -e "$SMARTSAFEHUB_APPS_LUCI_INDEX_CACHE" ] || fail 'LuCI menu cache not invalidated after WOL install'
[ "$(wc -l < "$TMP/owned/wake-on-lan" | tr -d ' ')" -eq 2 ] || fail 'WOL must own exactly two APKs'
grep -q "^etherwake$(printf '\t')" "$TMP/owned/wake-on-lan" || fail 'etherwake ownership missing'
grep -q "^luci-app-wol$(printf '\t')" "$TMP/owned/wake-on-lan" || fail 'luci-app-wol ownership missing'
"$MANAGER" status
grep -Fqx "$(printf 'wake-on-lan\t1\t1\t1\t1\t0\t26.288.56989~75a306d')" "$TMP/runtime/apps-status.tsv" || fail 'WOL should open LuCI after both APKs installed'
# A different LuCI route is not an approved WOL launch target.
python3 - "$TMP/catalog.json" <<'PY_WRONG_WOL_UI'
import json,sys
p=sys.argv[1]; data=json.load(open(p))
data['apps'][0]['web_ui']['path']='/cgi-bin/luci/admin/system/admin'
open(p,'w').write(json.dumps(data))
PY_WRONG_WOL_UI
"$TMP/bin/fetch-catalog"
"$MANAGER" status
grep -Fqx "$(printf 'wake-on-lan\t1\t1\t1\t0\t0\t26.288.56989~75a306d')" "$TMP/runtime/apps-status.tsv" || fail 'unexpected WOL LuCI route exposed'
# Fix approval, then remove both packages. LuCI must not retain a stale menu.
python3 - "$TMP/catalog.json" <<'PY_WOL_UI'
import json,sys
p=sys.argv[1]; data=json.load(open(p))
data['apps'][0]['web_ui']['path']='/cgi-bin/luci/admin/services/wol'
open(p,'w').write(json.dumps(data))
PY_WOL_UI
"$TMP/bin/fetch-catalog"
printf 'cached menu\n' > "$SMARTSAFEHUB_APPS_LUCI_INDEX_CACHE"
"$MANAGER" start remove wake-on-lan || fail 'WOL removal not queued'
grep -Fqx "$(printf 'phase\tsuccess')" "$TMP/runtime/apps-operation.state" || fail 'WOL removal failed'
[ ! -e "$SMARTSAFEHUB_APPS_LUCI_INDEX_CACHE" ] || fail 'LuCI menu cache not invalidated after WOL removal'
[ ! -e "$TMP/owned/wake-on-lan" ] || fail 'WOL ownership not cleared'
[ ! -s "$APPS_TEST_INSTALLED" ] || fail 'WOL left installed APKs behind'
# An extra/omitted package in a bad catalog must be rejected before any APK
# download/install, even if the index and SHA values look valid.
python3 - "$TMP/catalog.json" <<'PY_WOL_EXTRA'
import json,sys
p=sys.argv[1]; data=json.load(open(p))
data['apps'][0]['packages'].append({'name':'luci-app-nlbwmon','version':'26.288.56989~75a306d','sha256': 'b'*64})
open(p,'w').write(json.dumps(data))
PY_WOL_EXTRA
"$MANAGER" start install wake-on-lan || fail 'bad WOL catalog not queued'
grep -Fqx "$(printf 'code\tE27')" "$TMP/runtime/apps-operation.state" || fail 'WOL extra APK was not rejected'
[ ! -s "$APPS_TEST_INSTALLED" ] || fail 'invalid WOL feed changed package state'
[ ! -e "$TMP/owned/wake-on-lan" ] || fail 'invalid WOL feed wrote ownership'
python3 - "$TMP/catalog.json" <<'PY_WOL_MISSING'
import json,sys
p=sys.argv[1]; data=json.load(open(p))
data['apps'][0]['packages']=data['apps'][0]['packages'][:1]
open(p,'w').write(json.dumps(data))
PY_WOL_MISSING
"$MANAGER" start install wake-on-lan || fail 'incomplete WOL catalog not queued'
grep -Fqx "$(printf 'code\tE27')" "$TMP/runtime/apps-operation.state" || fail 'missing WOL LuCI APK was not rejected'
printf 'PASS: Wake-on-LAN signed dual-APK lifecycle, LuCI route and corrupt approvals\n'
+91
View File
@@ -0,0 +1,91 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
HELPER="$ROOT/root/usr/libexec/smartsafehub-apps-metrics"
MANAGER="$ROOT/root/usr/libexec/smartsafehub-apps-manager"
DEVICE="$ROOT/root/usr/libexec/smartsafehub-device"
RESTORE="$ROOT/root/usr/libexec/smartsafehub-apps-restore"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
fail() { echo "FAIL: $*" >&2; exit 1; }
mkdir -p "$TMP/bin" "$TMP/pending"
cat > "$TMP/bin/jsonfilter" <<'SH2'
#!/bin/sh
exec python3 - "$@" <<'PY'
import json, sys
args = sys.argv[1:]
obj = json.load(open(args[args.index('-i') + 1]))
key = args[args.index('-e') + 1].removeprefix('@.')
if key in obj: print(obj[key])
PY
SH2
cat > "$TMP/bin/uclient-fetch" <<'SH2'
#!/bin/sh
[ "$APPS_METRICS_TEST_MODE" = fail ] && exit 1
while [ "$#" -gt 0 ]; do
case "$1" in
-O) out="$2"; shift 2;;
--post-file=*) body="${1#--post-file=}"; shift;;
--header=Authorization:*) auth="$1"; shift;;
*) shift;;
esac
done
[ -n "${auth:-}" ] && [ -n "${body:-}" ] || exit 1
cp "$body" "$APPS_METRICS_TEST_LAST_BODY"
printf '{"status":"%s"}
' "$APPS_METRICS_TEST_MODE" > "$out"
SH2
chmod +x "$TMP/bin/"*
export SMARTSAFEHUB_APPS_METRICS_DIR="$TMP/pending"
export SMARTSAFEHUB_APPS_METRICS_CREDENTIAL="$TMP/credential.json"
export SMARTSAFEHUB_APPS_METRICS_FETCH_BIN="$TMP/bin/uclient-fetch"
export SMARTSAFEHUB_APPS_JSONFILTER_BIN="$TMP/bin/jsonfilter"
export APPS_METRICS_TEST_MODE=fail
export APPS_METRICS_TEST_LAST_BODY="$TMP/last-body.json"
EVENT=1793170000-4321-1234567890abcdef
"$HELPER" enqueue retro-arcade 1.0.0-r1 "$EVENT"
[ "$(find "$TMP/pending" -name '*.json' | wc -l | tr -d ' ')" -eq 1 ] || fail 'receipt not queued'
"$HELPER" enqueue retro-arcade 1.0.0-r1 "$EVENT"
[ "$(find "$TMP/pending" -name '*.json' | wc -l | tr -d ' ')" -eq 1 ] || fail 'same event duplicated'
"$HELPER" enqueue '../../etc/passwd' x "$EVENT" >/dev/null 2>&1 && fail 'unsafe app ID accepted'
"$HELPER" enqueue retro-arcade 1.0.0-r1 "$EVENT" invalid >/dev/null 2>&1 && fail 'invalid event kind accepted'
"$HELPER" enqueue retro-arcade 1.0.0-r1 "$EVENT" update extra >/dev/null 2>&1 && fail 'extra event fields accepted'
"$HELPER" enqueue retro-arcade x 'bad/id' >/dev/null 2>&1 && fail 'unsafe event accepted'
"$HELPER" flush
[ -f "$TMP/pending/$EVENT.json" ] || fail 'unregistered device lost receipt'
printf '{"token":"ssh_dev_%064d"}
' 0 > "$TMP/credential.json"
"$HELPER" flush >/dev/null 2>&1 && fail 'failed upload returned success'
[ -f "$TMP/pending/$EVENT.json" ] || fail 'offline request lost receipt'
export APPS_METRICS_TEST_MODE=recorded
"$HELPER" flush
[ ! -f "$TMP/pending/$EVENT.json" ] || fail 'acknowledged event not cleared'
grep -Fq '"app_id":"retro-arcade"' "$TMP/last-body.json" || fail 'app ID lost'
grep -Fq '"kind":"install"' "$TMP/last-body.json" || fail 'legacy install kind lost'
# The same device can report a newer app release as an update; retry must
# preserve the kind and event ID without changing the install counter.
UPDATE_EVENT=1793170001-4322-abcdef1234567890
"$HELPER" enqueue retro-arcade 1.1.0-r5 "$UPDATE_EVENT" update
[ "$(find "$TMP/pending" -name '*.json' | wc -l | tr -d ' ')" -eq 1 ] || fail 'update receipt not queued'
grep -Fq '"kind":"update"' "$TMP/pending/$UPDATE_EVENT.json" || fail 'update recorded as install'
export APPS_METRICS_TEST_MODE=fail
"$HELPER" flush >/dev/null 2>&1 && fail 'failed update upload returned success'
[ -f "$TMP/pending/$UPDATE_EVENT.json" ] || fail 'update receipt lost on failure'
export APPS_METRICS_TEST_MODE=recorded
"$HELPER" flush
[ ! -f "$TMP/pending/$UPDATE_EVENT.json" ] || fail 'updated receipt not acknowledged'
grep -Fq '"kind":"update"' "$TMP/last-body.json" || fail 'update kind lost over HTTP'
"$HELPER" enqueue retro-arcade 1.0.0-r1 "$EVENT"
export APPS_METRICS_TEST_MODE=duplicate
"$HELPER" flush
[ ! -f "$TMP/pending/$EVENT.json" ] || fail 'deduplicated event not cleared'
! grep -Eiq '(credential|mac|ip_address|device_uuid|user)' "$TMP/last-body.json" || fail 'private metadata uploaded'
grep -Fq '"$METRICS" enqueue "$app" "$version" "$operation_id" "$action"' "$MANAGER" || fail 'successful installation/update not queued'
grep -Fq '[ "$source" = user ]' "$MANAGER" || fail 'restores are included in download count'
grep -Fq 'start install "$id" restore' "$RESTORE" || fail 'restore source missing'
grep -Fq '"$APPS_METRICS" flush' "$DEVICE" || fail 'background queue retry missing'
grep -Fxq '/etc/smartsafehub/apps-metrics-pending/' "$ROOT/root/lib/upgrade/keep.d/smartsafehub-apps" || fail 'sysupgrade lost pending receipts'
sh -n "$HELPER" "$MANAGER" "$DEVICE" "$RESTORE" || fail 'shell syntax invalid'
printf 'PASS: authenticated Apps install/update receipt queue, retry, dedup and restore exclusion
'
+98
View File
@@ -0,0 +1,98 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
RESTORE="$ROOT/root/usr/libexec/smartsafehub-apps-restore"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
fail() { echo "FAIL: $*" >&2; exit 1; }
mkdir -p "$TMP/runtime" "$TMP/owned" "$TMP/bin"
cat > "$TMP/bin/apk" <<'MOCK'
#!/bin/sh
[ "$1" = info ] && [ "$2" = -e ] || exit 2
grep -qxF "$3" "$RESTORE_TEST_INSTALLED" 2>/dev/null
MOCK
cat > "$TMP/bin/manager" <<'MOCK'
#!/bin/sh
[ "$1" = start ] && [ "$2" = install ] && [ "$3" = network-usage-monitor ] && [ "$4" = restore ] || exit 4
printf '%s\n' "$3" >> "$RESTORE_TEST_CALLS"
case "${RESTORE_TEST_MODE:-success}" in
busy) exit 3;;
offline)
code=E21
phase=failed;;
revoked)
code=E22
phase=failed;;
success)
printf 'nlbwmon\nluci-app-nlbwmon\nluci-lib-chartjs\n' > "$RESTORE_TEST_INSTALLED"
code=NONE
phase=success;;
esac
printf 'phase\t%s\naction\tinstall\napp_id\t%s\ncode\t%s\n' "$phase" "$3" "$code" > "$SMARTSAFEHUB_APPS_RUNTIME_DIR/apps-operation.state"
MOCK
cat > "$TMP/bin/sleep" <<'MOCK'
#!/bin/sh
exit 0
MOCK
chmod +x "$TMP/bin/"*
export SMARTSAFEHUB_APPS_MANAGER_BIN="$TMP/bin/manager"
export SMARTSAFEHUB_APPS_APK_BIN="$TMP/bin/apk"
export SMARTSAFEHUB_APPS_RESTORE_SLEEP_BIN="$TMP/bin/sleep"
export SMARTSAFEHUB_APPS_RESTORE_START_DELAY=0
export SMARTSAFEHUB_APPS_RESTORE_RETRIES=2
export SMARTSAFEHUB_APPS_RESTORE_RETRY_DELAY=0
export SMARTSAFEHUB_APPS_RESTORE_POLL_INTERVAL=0
export SMARTSAFEHUB_APPS_RESTORE_POLL_LIMIT=2
export SMARTSAFEHUB_APPS_RUNTIME_DIR="$TMP/runtime"
export SMARTSAFEHUB_APPS_OWNED_DIR="$TMP/owned"
export RESTORE_TEST_INSTALLED="$TMP/installed"
export RESTORE_TEST_CALLS="$TMP/calls"
: > "$RESTORE_TEST_CALLS"
: > "$RESTORE_TEST_INSTALLED"
# No ownership record (or one from a removed app) must never install packages.
"$RESTORE" run
[ ! -s "$RESTORE_TEST_CALLS" ] || fail 'fresh device tried to restore an app'
# A malformed manifest must never invoke the package manager.
printf 'evil\t1\tbadsha\n' > "$TMP/owned/network-usage-monitor"
"$RESTORE" run
[ ! -s "$RESTORE_TEST_CALLS" ] || fail 'invalid manifest triggered package installation'
grep -Fqx "$(printf 'code\tINVALID_MANIFEST')" "$TMP/runtime/apps-restore.state" || fail 'invalid manifest was not diagnosed'
# The persisted manifest contains only Apps-owned packages. A previously shared
# chart dependency must not be recorded as owned.
HASH="$(printf %064d 0)"
printf 'nlbwmon\t2025.06.02~29236be6-r1\t%s\nluci-app-nlbwmon\t26.288.56989~75a306d\t%s\n' "$HASH" "$HASH" > "$TMP/owned/network-usage-monitor"
export RESTORE_TEST_MODE=offline
"$RESTORE" run
[ "$(wc -l < "$RESTORE_TEST_CALLS" | tr -d ' ')" -eq 2 ] || fail 'network error retry was not bounded'
grep -Fqx "$(printf 'code\tE21')" "$TMP/runtime/apps-restore.state" || fail 'network failure state missing'
: > "$RESTORE_TEST_CALLS"
export RESTORE_TEST_MODE=revoked
"$RESTORE" run
[ "$(wc -l < "$RESTORE_TEST_CALLS" | tr -d ' ')" -eq 1 ] || fail 'revoked approval was retried'
grep -Fqx "$(printf 'code\tE22')" "$TMP/runtime/apps-restore.state" || fail 'revoked approval error missing'
: > "$RESTORE_TEST_CALLS"
export RESTORE_TEST_MODE=success
"$RESTORE" run
[ "$(wc -l < "$RESTORE_TEST_CALLS" | tr -d ' ')" -eq 1 ] || fail 'owned app not restored exactly once'
grep -Fqx "$(printf 'phase\tsuccess')" "$TMP/runtime/apps-restore.state" || fail 'restore success not recorded'
"$RESTORE" run
[ "$(wc -l < "$RESTORE_TEST_CALLS" | tr -d ' ')" -eq 1 ] || fail 'already installed app was reinstalled'
# Apps remove deletes the ownership file. Never recreate it on future boots.
rm -f "$TMP/owned/network-usage-monitor"
: > "$RESTORE_TEST_INSTALLED"
"$RESTORE" run
[ "$(wc -l < "$RESTORE_TEST_CALLS" | tr -d ' ')" -eq 1 ] || fail 'removed app was restored'
# Structural contracts: ownership backup, existing firmware service hook and
# shared RPC status. Do not regress to another procd/init daemon.
grep -Fxq '/etc/smartsafehub/apps-installed/' "$ROOT/root/lib/upgrade/keep.d/smartsafehub-apps" || fail 'sysupgrade backup missing'
[ ! -e "$ROOT/root/etc/init.d/smartsafehub-apps-restore" ] || fail 'extra Apps restore init service introduced'
[ ! -e "$ROOT/root/etc/uci-defaults/93-smartsafehub-apps-restore" ] || fail 'extra Apps restore service enable introduced'
grep -Fq 'start_apps_restore_once' "$ROOT/root/usr/libexec/smartsafehub-firmware" || fail 'existing firmware daemon does not schedule restore'
grep -Fq '"$APPS_RESTORE_BIN" run </dev/null >/dev/null 2>&1 &' "$ROOT/root/usr/libexec/smartsafehub-firmware" || fail 'restore is not a detached one-shot'
grep -Fq 'restore: restore_status()' "$ROOT/root/usr/share/rpcd/ucode/smartsafehub/apps-manager.uc" || fail 'restore status missing from RPC'
grep -Fq 'E21|E25|E31|E35|BUSY' "$ROOT/root/etc/hotplug.d/iface/98-smartsafehub-apps-restore" || fail 'WAN retry safety contract missing'
grep -Fq '"$RUNNER" run </dev/null >/dev/null 2>&1 &' "$ROOT/root/etc/hotplug.d/iface/98-smartsafehub-apps-restore" || fail 'WAN should trigger the one-shot worker directly'
! grep -Fq '"$APK" add' "$RESTORE" || fail 'restore runner must not invoke APK directly'
[ -x "$RESTORE" ] || fail 'restore worker is not executable'
printf 'PASS: sysupgrade Apps restoration without new daemon, retry, revocation and ownership\n'
+243
View File
@@ -0,0 +1,243 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
set -eu
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
python3 - "$ROOT" <<'PY'
from pathlib import Path
import sys
root = Path(sys.argv[1])
page = (root / 'frontend/src/pages/AppsPage.tsx').read_text()
styles = (root / 'frontend/src/styles/app.css').read_text()
package = (root / 'Makefile').read_text()
def check(ok, message):
if not ok:
raise AssertionError(message)
# Version/revision validity is covered by test-package-contract.sh.
check('border-teal-300 text-teal-800 hover:bg-teal-50' in page, 'install should stay teal')
check('border-red-300 text-red-700 hover:bg-red-50' in page, 'removal must be red outline')
check('hover\\:bg-red-50:hover' in styles, 'red hover needs an explicit dark-mode style')
check('rgb(239 68 68 / 0.16)' in styles, 'dark-mode removal hover should be subtle')
check('ssh-apps-installed-badge' in page and 'ssh-apps-update-badge' in page and
'ssh-apps-update-button' in page,
'installed, update notice and update action need distinct styles')
check('.ssh-apps-update-button' in styles and ".ssh-app[data-theme='dark'] .ssh-apps-update-button" in styles,
'update action must have a distinct dark-mode style')
# App installation storage must refer to the writable APK filesystem.
backend = (root / 'root/usr/share/rpcd/ucode/smartsafehub/apps-manager.uc').read_text()
card = (root / 'frontend/src/components/AppsStorageCard.tsx').read_text()
utils = (root / 'frontend/src/utils/appsStorage.ts').read_text()
check("safe_call('system', 'info', {})" in backend and
"from_system_root(info?.root)" in backend,
'Apps disk data must prefer procd system.info root stats')
check("from_statvfs(stats, mount)" in backend and "[ '/overlay', '/' ]" in backend,
'statvfs fallback must support overlay and ext4 rootfs')
storage_module = (root / 'root/usr/share/rpcd/ucode/smartsafehub/apps-storage.uc').read_text()
check("type(value) == 'int'" in storage_module and "type(value) == 'double'" in storage_module,
'ucode type() emits int/double and must never require integer')
check("root.total * 1024" in storage_module and "root.avail * 1024" in storage_module,
'procd system.info root counters are KiB, not raw bytes')
check("MAX_BYTES / frsize" in storage_module,
'unexpected statvfs counters must not overflow safe byte values')
check('stats.bavail' in storage_module and 'stats.frsize' in storage_module and 'storage: storage' in backend,
'optional Apps metrics must report actual APK-available bytes without spawning df')
check('read_apps_metrics(request)' in backend and 'restore: restore_status(), installed: installed' in backend,
'critical Apps status must be separate from optional storage metrics')
check("ensure_job('sizes')" in backend and "ensure_job('status')" in backend,
'APK scan must execute asynchronously and independently from RPC status')
check('apps_metrics:' in (root / 'root/usr/share/rpcd/ucode/smartsafehub.uc').read_text(),
'metrics RPC must be independently accessible')
import json
acl = json.loads((root / 'root/usr/share/rpcd/acl.d/luci-app-smartsafehub.json').read_text())
check('apps_metrics' in acl['luci-app-smartsafehub']['read']['ubus']['smartsafehub'],
'critical regression: Apps metrics RPC is missing from LuCI read ACL')
check('fetchAppsMetrics' in (root / 'frontend/src/app/App.tsx').read_text() and
'!data?.ready ? 3_000 : 60000' in (root / 'frontend/src/app/App.tsx').read_text(),
'optional Apps metrics should run separately and less frequently')
check("'/bin/df'" not in backend and "'/bin/df'" not in storage_module, 'storage must not spawn a polling command')
check('lg:grid-cols-[minmax(0,1fr)_minmax(0,18.5rem)]' in page,
'Apps storage should be a compact fixed-width column on desktop, stacked on mobile')
check('ssh-apps-storage-card' in card and 'size-24' in card and 'gap-3' in card and 'sm:p-5' not in card,
'storage card must be compact without hiding the donut or capacity data')
check('<AppsStorageCard storage={metrics?.storage} />' in page,
'Apps storage should be read from independent optional metrics')
check('stroke-dasharray=' in card and 'aria-label="앱 설치 저장 공간"' in card,
'storage donut must be accessible and use SVG')
check('Math.PI * 39' in card and 'r="39"' in card and card.count('size-24') == 2,
'storage donut needs a larger ring and enough room around the center label')
check('용량 정보를 확인할 수 없습니다.' in card and 'summarizeAppsStorage(storage)' in card,
'storage errors and older RPC responses need a safe fallback')
check('storage.availableBytes > storage.totalBytes' in utils,
'malformed storage reports must be rejected')
check('usedText: formatStorageBytes(storage.totalBytes - storage.availableBytes)' in utils,
'used storage must be derived from validated total and APK-available counters')
check('사용 {summary.usedText} / 전체 {summary.totalText}' in card,
'storage card must show used and total bytes without increasing its height')
check('파일시스템 예약 공간' in card and '앱 설치 가능 공간</p>' not in card,
'capacity breakdown must explain reserved space and avoid redundant footer')
check(".ssh-app[data-theme='dark'] .ssh-apps-storage-track" in styles and
"[data-level='critical']" in styles,
'donut must support dark mode and dangerously low storage')
# Per-app size reflects the installed APK package metadata only (no du scan).
manager = (root / 'root/usr/libexec/smartsafehub-apps-manager').read_text()
check('installed_size_bytes() {' in manager and '"$APK" info -s "$name"' in manager,
'Apps installed size should query each package independently')
check('du -' not in manager and 'find /' not in manager,
'installed size must not recursively scan the router filesystem')
check('installedSizes[fields[0]]' in backend and 'length(fields) != 2' in backend and
'installedBytes:' not in backend and 'installedVersion:' in backend,
'package size data must be parsed separately from the six-column status')
check('sizes) mkdir -p "$RUNTIME"; sizes;;' in manager and 'installed_size_bytes "$name"' in manager,
'optional sizes command must report app APK totals without modifying status')
check('apps-size-diag' not in manager and 'diagnostic=' not in manager,
'optional size checks must not create the retired diagnostics TSV')
check('rm -f /tmp/smartsafehub/apps-size-diagnostics.tsv' in package,
'package upgrade must clean up the retired runtime diagnostic file')
check('app_bytes=' not in manager and 'set --' not in manager.split('sizes() {')[0].split('status() {')[1],
'status must not invoke optional size processing')
check('sizeLabel !== null' in page and '설치 용량 약 {sizeLabel}' in page,
'installed app card must show a real size only when known')
check('showOfferedVersion || sizeLabel !== null' in page and
'showOfferedVersion && sizeLabel !== null' in page and
'flex-wrap items-center gap-x-1.5' in page,
'offered version and installed size must share a compact wrapping metadata row')
check(page.count('설치 용량 약 {sizeLabel}') == 1 and page.count('제공 v{app.version}') == 1,
'Apps metadata must not duplicate version or installed size rows')
check('Number.isSafeInteger(bytes)' in page and 'installed && typeof bytes' in page and 'metrics?.installedSizes' in page,
'missing or invalid sizes must not be guessed')
check('캐시는 포함되지 않습니다' in page,
'metadata size must not be misrepresented as actual overlay consumption')
check('installedVersion?: string | null' in (root / 'frontend/src/types/apps.ts').read_text(),
'installed APK version must have a typed field')
check('installed_release="$(installed_version "$primary_package" || :)"' in manager and
'"$update_ready" "$installed_release"' in manager,
'current APK version must originate from installed package metadata')
check('installedVersion: length(fields) >= 7' in backend and
'match(fields[6]' in backend,
'RPC parser must validate installed APK version')
check('설치 v${installedVersion}' in page and '제공 v{app.version}' in page,
'installed and approved versions must have independent labels')
check('업데이트 가능</span>' in page and '설치 버전 확인 불가' in page,
'updates must be distinguished from unknown installed versions')
# Category icons are local, consistent across cards and filters, and safe
# for unknown/future catalog categories. No external icon service or assets.
icon_source = (root / 'frontend/src/components/AppsCategoryIcon.tsx').read_text()
shared_icons = (root / 'frontend/src/components/Icons.tsx').read_text()
check('AppsCategoryIcon, appsCategoryOf' in page and
'<AppsCategoryIcon category={tab.id} class="size-4 shrink-0" />' in page and
'<AppsCategoryIcon category={appsCategoryOf(app.category)} class="size-7" />' in page,
'filters and cards must share category-specific icons in appropriately sized frames')
check('class="grid size-14 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-600"' in page,
'app card category icon frame must be 56px and align with the installed size row')
check('<AppsCategoryIcon category={appsCategoryOf(app.category)} class="size-7" />' in page and
'class="grid size-11 shrink-0 place-items-center' not in page,
'app category icon itself must be 28px while retaining the 56px frame')
check('<AppsCategoryIcon category={tab.id} class="size-4 shrink-0" />' in page,
'category filter icons must remain compact and not change touch targets')
check('appsCategoryOf(app.category) === category' in page,
'icon categorization and filtering must use the same normalization')
check('aria-label="앱 카테고리"' in page and '>{tab.label}</button>' in page and
'inline-flex min-h-10 items-center gap-1.5' in page,
'filter icons must retain text labels, touch targets and wrapping')
for catalog_key, category_key in [('network', 'network'), ('security', 'security'),
('entertainment', 'entertainment'), ('smart_home', 'smart_home'),
('iot', 'smart_home')]:
check(f"case '{catalog_key}':" in icon_source,
f'category {catalog_key} must be explicitly mapped to {category_key}')
for tab_key, icon_name in [('all', 'AppsIcon'), ('network', 'NetworkNodesIcon'),
('security', 'ShieldIcon'), ('smart_home', 'HomeIcon'),
('entertainment', 'GamepadIcon')]:
check(f"case '{tab_key}': return <{icon_name}" in icon_source,
f'{tab_key} category has the wrong icon')
check("default: return 'other';" in icon_source and
'default: return <CircleEllipsisIcon' in icon_source and
"{ id: 'other', label: '기타' }" in page,
'unknown categories must use the other filter and its ellipsis icon')
check("case 'utilities': return <WrenchIcon" in icon_source and
"case 'other': return <CircleEllipsisIcon" in icon_source and
"case 'other': return 'other'" in icon_source and
"{ id: 'utilities', label: '도구' }" in page,
'tools and other must have their own explicit category and distinct icons')
check('PowerIcon' not in icon_source and 'PuzzleIcon' not in icon_source,
'category icons must not revert to power or puzzle symbols')
for name in ['NetworkNodesIcon', 'GamepadIcon', 'WrenchIcon', 'CircleEllipsisIcon']:
check(f'export function {name}(props: IconProps)' in shared_icons,
f'{name} should use the local SVG IconBase and remain in the UI bundle')
check('<circle cx="12" cy="12" r="10" />' in shared_icons and
'M8 12h.01M12 12h.01M16 12h.01' in shared_icons and
'3.106-3.105' in shared_icons,
'tools use a wrench, other uses a circle ellipsis, not network-like nodes')
check('aria-hidden={title ? undefined : true}' in shared_icons and
'fill="none"' in shared_icons and 'stroke="currentColor"' in shared_icons,
'icons must be decorative line SVGs that inherit light/dark text color')
check('https://' not in icon_source and '<img' not in icon_source,
'category icons must not load unverified remote artwork')
# A transient catalog failure must not erase a previous successful result or
# turn asynchronously fetched installation status into false retired warnings.
check('const currentData = data;' in page, 'cached catalog must remain visible after a refresh failure')
check('error && !currentData' in page and 'error && currentData' in page,
'catalog errors should be prominent only when no previous catalog exists')
check('role="status">이전 목록</span>' in page, 'cached catalog should have a compact non-blocking stale marker')
check('state.retired === true && state.managed' in page and '!error && !loading && !refreshing' in page,
'retirement must require explicit backend proof and a complete catalog')
status_rpc = (root / 'root/usr/share/rpcd/ucode/smartsafehub/apps-manager.uc').read_text()
check("retired: length(fields) == 8 && fields[7] == '1'" in status_rpc,
'retired status must originate only from a signed-off local owner manifest record')
check('retired?: boolean;' in (root / 'frontend/src/types/apps.ts').read_text(),
'retired proof must be optional for older router responses')
# Removal never calls onRemove from a card or from the orphan-list button.
check("onClick={(event) => removable ? openRemoveDialog(app, event.currentTarget)" in page,
'catalog removal must open confirmation')
check("onClick={(event) => openRemoveDialog({ id, name: id" in page,
'orphaned apps must open the same confirmation')
check("onClick={() => void requestOperation(removeTarget, 'remove')}" in page,
'dialog approval must be the only removal confirmation action')
check(page.count('onRemove(app.id)') == 1, 'only the accepted operation may call the remove RPC callback')
check('aria-modal="true"' in page and 'aria-labelledby="apps-remove-title"' in page,
'remove confirmation must be accessible')
check('취소</button>' in page and '앱 제거</button>' in page, 'dialog must offer both choices')
check("if (event.key === 'Escape')" in page and "event.key !== 'Tab'" in page,
'dialog must support escape and focus containment')
check("if (action === 'remove') closeRemoveDialog()" in page,
'confirmed operation should close the modal before starting')
check("if (!installStatus || statusError || pending !== null || running || restoring) return;" in page,
'operation must preserve status and concurrency guards')
# Installing still uses the existing confirmation and neither action bypasses RPC guards.
check("action === 'install' && !window.confirm" in page, 'existing install confirmation must remain')
check("<OperationIndicator action={activeAction} />" in page, 'loading must show on app and orphan entries')
check("{removing ? '제거 중…' : action === 'update' ? '업데이트 중…' : '설치 중…'}" in page,
'install, update and remove must show distinct spinner states')
check("job.action === 'remove' ? '앱 제거 중' : job.action === 'update' ? '앱 업데이트 중' : '앱 설치 중'" in page,
'running job status must use the action name')
check("job.phase === 'failed' ? 'border-rose-200 bg-rose-50 text-rose-800'" in page,
'errors must keep a red alert')
check('useState<string | null>(readDismissedAppsJob)' in page,
'dismissal must be restored after refreshing Apps')
check('saveDismissedAppsJob(key)' in page and 'onClick={() => dismissJob(jobKey)}' in page,
'dismissing a job should persist the dismissed operation ID')
check("onClick={() => void requestOperation(app, 'update')}" in page, 'approved update action must be shown')
check('setDismissedJob(null)' not in page,
'starting a new operation must not resurrect the previous failure')
PY
echo 'PASS: Apps removal confirmation, theme and operation states'
# Real ucode runtime coverage: prevents wrong numeric type names and unit
# conversions from passing the static Apps UI contract again.
if command -v ucode >/dev/null 2>&1; then
ucode "$ROOT/tests/apps-storage-test.uc"
elif [ "${SMARTSAFEHUB_REQUIRE_UCODE:-0}" = '1' ]; then
echo 'FAIL: Apps storage runtime test requires ucode' >&2
exit 1
fi
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
python3 - "$ROOT_DIR/CHANGELOG.md" <<'PYTEST'
from pathlib import Path
import sys
lines = Path(sys.argv[1]).read_text(encoding="utf-8").splitlines()
releases = [index for index, line in enumerate(lines) if line.startswith("## [")]
if len(releases) < 2:
raise SystemExit("FAIL: CHANGELOG must contain multiple releases")
for index in releases[1:]:
previous = index - 1
while previous >= 0 and not lines[previous].strip():
if lines[previous]:
raise SystemExit(f"FAIL: whitespace-only line before release at {index + 1}")
previous -= 1
blank_lines = index - previous - 1
if blank_lines != 2:
raise SystemExit(
f"FAIL: release at line {index + 1} has {blank_lines} blank lines; expected 2"
)
print(f"PASS: {len(releases)} CHANGELOG releases have two blank lines between entries")
PYTEST
@@ -43,4 +43,4 @@ for service in device health activity-sync; do
grep -Fq 'ssh_runtime_execute' "$script"
done
grep -Eq '^PKG_RELEASE:=[0-9]+$' "$ROOT/Makefile"
echo 'daemon runtime checks: OK'
echo 'PASS: daemon runtime checks'
@@ -15,6 +15,12 @@ assert "useFirmwareUpdates(true)" in app # sidebar firmware badge stays current
assert 'if (loading && !data)' in home
assert "let info_finished = false" in server and "let wan_finished = false" in server
assert server.index("const info_request = defer_call('system', 'info'") < server.index("const wan_request = defer_call('network.interface.wan'")
assert "storage: dashboard_writable_storage(info?.root)" in server
assert "storage: dashboard_writable_storage(info?.root)" in server[server.index('function system_status_payload('):server.index('function collect_system_status(')]
assert "const from_root = from_system_root(root)" in server, 'procd root counters must be the fast path'
fallback = server[server.index('function dashboard_writable_storage(root)'):server.index('function system_status_payload(')]
assert "from_statvfs(fs.statvfs(mount), mount)" in fallback, 'local statvfs should recover missing root counters'
assert "run_command" not in fallback and "defer_call" not in fallback, 'extra process or ubus call not allowed for storage'
for hook in ('useSoftwareUpdates', 'useFirmwareUpdates'):
source = Path(f'frontend/src/hooks/{hook}.ts').read_text()
assert 'const [pollingPhase, setPollingPhase]' in source
@@ -257,6 +257,68 @@ if grep -Fq '<span>메모리 사용률</span>' "$HOME"; then
fail '대시보드는 메모리 사용률을 별도 블록으로 중복 표시하지 않아야 합니다'
fi
# Disk belongs to the normal dashboard status payload; no Apps metrics polling.
python3 - "$HOME" "$ROOT_DIR/root/usr/share/rpcd/ucode/smartsafehub/system.uc" "$ROOT_DIR/frontend/src/types/status.ts" "$ROOT_DIR/frontend/src/demo/data.ts" <<'PY_DASH_DISK'
from pathlib import Path
import sys
home, backend, types, demo = [Path(name).read_text() for name in sys.argv[1:]]
labels = [f'<span class="text-xs font-extrabold">{title}</span>' for title in
('메모리', '디스크', '시스템 부하', '실행 시간')]
assert all(home.count(label) == 1 for label in labels), '리소스 카드 네 종류가 필요합니다'
assert [home.index(label) for label in labels] == sorted(home.index(label) for label in labels), '카드 순서 오류'
assert 'class="mt-5 grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4"' in home, '데스크톱 4열, 태블릿 2열, 모바일 1열이 필요합니다'
assert 'summarizeAppsStorage(data.runtime.storage)' in home, 'Apps 저장 공간 계산을 재사용해야 합니다'
assert 'disk.availablePercent' in home and 'disk.availableText' in home, 'Apps와 동일한 여유 공간 표시가 필요합니다'
assert '사용 {disk.usedText} / 전체 {disk.totalText}' in home, '사용/전체 용량을 표시해야 합니다'
assert "disk.availableRatio * DISK_RING_CIRCUMFERENCE" in home, '실제 여유 비율로 도넛 호를 계산해야 합니다'
assert 'DISK_RING_CIRCUMFERENCE = 2 * Math.PI * 39' in home, 'Apps 도넛과 같은 반지름을 사용해야 합니다'
assert 'ssh-apps-storage-track' in home and 'ssh-apps-storage-free' in home and 'ssh-apps-storage-ring' in home, 'Apps의 테마 색상과 도넛 스타일을 재사용해야 합니다'
assert 'disk && disk.availableRatio > 0' in home, '여유 공간이 0인 경우 가짜 호를 그리면 안 됩니다'
assert "data-level={disk?.level ?? 'unavailable'}" in home, 'Apps와 같은 부족 경고 색상 상태가 필요합니다'
assert 'aria-label={disk' in home and '디스크 여유 공간 ${disk.availablePercent}%' in home, '스크린 리더에 저장 공간 정보를 제공해야 합니다'
assert "{disk ? `${disk.availableText} 남음` : '저장 공간 확인 불가'}" in home, '용량 조회 실패를 0%로 표시하면 안 됩니다'
assert 'diskUsedPercent' not in home, '기존 선형 사용률 막대 로직을 제거해야 합니다'
assert '<svg aria-hidden="true" class="size-[76px]" viewBox="0 0 96 96">' in home, '대시보드 도넛은 76px로 줄이고 Apps 링 스타일을 유지해야 합니다'
assert 'stroke-dasharray={`${diskAvailableArc} ${DISK_RING_CIRCUMFERENCE.toFixed(2)}`}' in home, 'Apps와 동일한 SVG 호 표시가 필요합니다'
assert 'class="ssh-apps-storage-ring relative grid size-[76px] shrink-0 place-items-center"' in home, '도넛 외곽도 76px이어야 합니다'
ring = home[home.index('class="ssh-apps-storage-ring relative grid size-[76px]'):home.index('ssh-apps-storage-available mt-1 mb-0')]
assert 'text-base leading-none font-black' in ring, '도넛 % 글자는 16px로 표시해야 합니다'
assert 'text-[20px]' not in ring, '이전 20px 글자 크기가 남아 있으면 안 됩니다'
assert 'font-bold text-slate-500">여유</span>' not in ring, '도넛 중앙 여유 문구를 제거해야 합니다'
assert 'class="mt-1 flex justify-center"' in home, '도넛 위 간격을 줄여 전체 카드 높이를 낮춰야 합니다'
apps_card = Path('frontend/src/components/AppsStorageCard.tsx').read_text()
styles = Path('frontend/src/styles/app.css').read_text()
for css_class in ('ssh-apps-storage-ring', 'ssh-apps-storage-track', 'ssh-apps-storage-free'):
assert css_class in home and css_class in apps_card and css_class in styles, f'Apps와 대시보드가 {css_class} 스타일을 공유해야 합니다'
assert 'r="39"' in apps_card and 'r="39"' in home, '두 페이지의 도넛 반지름이 같아야 합니다'
# Same threshold semantics and readable warning colors on both pages.
utils = Path('frontend/src/utils/appsStorage.ts').read_text()
assert "availableRatio <= 0.1 ? 'critical' : availableRatio <= 0.2 ? 'warning' : 'normal'" in utils, '여유 10%/20% 경고 기준이 필요합니다'
assert 'ssh-dashboard-storage-card' in home and 'ssh-apps-storage-available' in home, '대시보드 남은 용량 텍스트도 경고 색상을 따라야 합니다'
assert 'ssh-apps-storage-card' in apps_card and 'ssh-apps-storage-available' in apps_card, 'Apps의 경고 색상을 유지해야 합니다'
for severity in ('warning', 'critical'):
assert f".ssh-apps-storage-ring[data-level='{severity}'] .ssh-apps-storage-free" in styles, '두 페이지 도넛에 부족 경고 색상이 적용되어야 합니다'
assert f".ssh-apps-storage-card[data-level='{severity}'] .ssh-apps-storage-available" in styles, 'Apps 텍스트에도 경고 색상이 필요합니다'
assert f".ssh-dashboard-storage-card[data-level='{severity}'] .ssh-apps-storage-available" in styles, '대시보드 텍스트에도 경고 색상이 필요합니다'
assert f".ssh-app[data-theme='dark'] .ssh-dashboard-storage-card[data-level='{severity}']" in styles, '다크 모드에서 경고 색상을 유지해야 합니다'
assert home.count('class="flex min-h-0 flex-1 flex-col justify-center py-1"') == 3, '메모리·시스템 부하·실행 시간은 세로 중앙 정렬하면서 패딩을 줄여야 합니다'
assert 'class="mt-5 grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4"' in home, '기존 반응형 4열을 유지해야 합니다'
assert "import { from_system_root, from_statvfs } from './apps-storage.uc'" in backend
assert 'storage: dashboard_writable_storage(info?.root)' in backend, '기존 system.info를 재사용해야 합니다'
assert 'const from_root = from_system_root(root)' in backend, '기존 procd 파일시스템 통계를 우선 사용해야 합니다'
assert "for (let mount in [ '/overlay', '/' ])" in backend, 'OpenWrt 쓰기 가능한 저장소 fallback이 필요합니다'
assert 'from_statvfs(fs.statvfs(mount), mount)' in backend, 'Apps와 동일한 statvfs 계산 fallback을 사용해야 합니다'
assert 'if (storage != null)' in backend and 'catch (e)' in backend, '스토리지 조회 실패 시 다른 대시보드 항목에 영향이 없어야 합니다'
assert 'run_command' not in backend.split('function dashboard_writable_storage(root)', 1)[1].split('function system_status_payload(', 1)[0], '새 프로세스 실행으로 디스크 통계를 구하지 않아야 합니다'
assert 'from_system_root(info?.root)' not in backend, '별도의 fallback 없이 procd 통계만 사용하면 안 됩니다'
assert "'apps_metrics'" not in backend and "'df'" not in backend, '새 저장 공간 조회 금지'
assert 'storage?: AppsStorage | null' in types, '구버전 RPC는 저장 공간 값을 생략할 수 있어야 합니다'
assert 'storage: demoAppsMetrics.storage' in demo, '체험 페이지도 디스크 정보를 제공해야 합니다'
print('PASS: 대시보드 디스크 카드 순서, 퍼센트, 미확인 처리, RPC 재사용')
PY_DASH_DISK
grep -Fq "import { SafeShieldBlockedBarChart } from './SafeShieldBlockedBarChart';" "$ACTIVITY" || \
fail '대시보드는 기존 SafeShield Chart.js 막대 차트를 재사용해야 합니다'
grep -Fq 'const DISPLAY_HOURS = 24;' "$ACTIVITY" || \
@@ -49,8 +49,19 @@ grep -Fq 'refresh_device_pairing' "$RPC"
# Pairing can bootstrap an unregistered local credential itself, so the
# dedicated account page must not deadlock the flow by requiring a registered
# phase before a code can be requested.
grep -Fq 'disabled={pairingBusy}' "$ACCOUNT_PAGE" || {
echo 'pairing button must stay available while device bootstrap is pending' >&2
# Disable the button only while an issuance is already in flight, including
# the new background worker's starting/running phases. A pending bootstrap
# alone must never prevent the first pairing request.
grep -Fq 'disabled={issuingCode}' "$ACCOUNT_PAGE" || {
echo 'pairing button must be gated by an active code issuance only' >&2
exit 1
}
grep -Fq 'const issuingCode = pairingBusy || status?.pairingOperation?.phase' "$ACCOUNT_PAGE" || {
echo 'pairing button must honor local and background issuance state' >&2
exit 1
}
grep -Fq "status?.pairingOperation?.phase === 'running'" "$ACCOUNT_PAGE" || {
echo 'pairing button must prevent duplicate requests while the worker runs' >&2
exit 1
}
if grep -Fq "pairingBusy || data?.phase !== 'registered'" "$ACCOUNT_PAGE"; then
@@ -99,7 +110,7 @@ grep -Fq 'const pairingCode = !connected && !expired ? status?.pairingCode : nul
! grep -Fq 'safeshield_license_update' "$MAIN" || fail 'legacy SafeShield license update RPC must stay removed'
! grep -Fq '"license_get"' "$ROOT/root/usr/share/rpcd/acl.d/luci-app-smartsafehub.json" || fail 'legacy SafeShield license_get ACL must stay removed'
! grep -Fq '"license_update"' "$ROOT/root/usr/share/rpcd/acl.d/luci-app-smartsafehub.json" || fail 'legacy SafeShield license_update ACL must stay removed'
echo 'device registration contract: ok'
echo 'PASS: device registration source contract'
grep -Fq '/etc/smartsafehub/device-credential.json' "$HELPER"
grep -Fq 'devices/bootstrap' "$HELPER"
@@ -698,3 +709,4 @@ grep -Fq '"plan":"free"' "$PLAN_RETRY_RUNTIME/device.json" || {
echo 'invalid entitlement must not request SafeShield refresh' >&2
exit 1
}
printf 'PASS: device registration, entitlements and refresh retries\n'
@@ -146,8 +146,41 @@ grep -Fq "'#wan': 'network'" "$HASH_ROUTE" || \
fail 'legacy #wan hash must resolve to combined network settings'
grep -Fq "'#lan': 'network'" "$HASH_ROUTE" || \
fail 'legacy #lan hash must resolve to combined network settings'
grep -Fq "{ label: 'Explore', routes: ['apps'] }" "$NAVIGATION" || \
fail 'Apps navigation must be promoted into its own Explore group'
grep -Fq "{ label: 'System', routes: ['account', 'system', 'settings'] }" "$NAVIGATION" || \
fail 'System navigation group must expose SmartSafeHub account before updates and settings'
fail 'System navigation group must keep account, updates and settings after the Apps section'
grep -Fq "return '앱으로 확장하기';" "$NAVIGATION" || \
fail 'Apps navigation item must show the short helper copy'
# Dedicated surface colors avoid rendering a pale grey overlay in dark mode.
# The active route keeps the shared teal selection style; only unselected Apps
# receives the subtle theme-specific expansion treatment.
python3 - "$NAVIGATION" "$STYLES" <<'PY_APPS_NAV' || fail 'Apps sidebar theme contract failed'
from pathlib import Path
import re
import sys
nav, css = (Path(p).read_text(encoding='utf-8') for p in sys.argv[1:])
assert "return '앱으로 확장하기';" in nav
assert '<AppsIcon class="size-5" />' in nav
assert "class={navigationClass(active, collapsed, routeName)}" in nav
assert 'ssh-apps-nav-link relative flex' in nav
assert 'ssh-apps-nav-description mt-0.5' in nav
assert "collapsed ? null : (" in nav
assert 'bg-teal-50/40' not in nav
selectors = {
".ssh-apps-nav-link:not([aria-current='page'])": ('background-color: #f0fdfa;', 'border: 1px solid #99f6e4;'),
".ssh-app[data-theme='dark'] .ssh-apps-nav-link:not([aria-current='page'])": ('background-color: rgb(20 184 166 / 0.09);', 'border-color: #134e4a;'),
".ssh-app[data-theme='dark'] .ssh-apps-nav-link:not([aria-current='page']):hover": ('background-color: rgb(20 184 166 / 0.16);',),
".ssh-app[data-theme='dark'] .ssh-apps-nav-description": ('color: #5eead4;',),
}
for selector, expected in selectors.items():
match = re.search(re.escape(selector) + r'\s*\{([^}]*)\}', css)
assert match is not None, f'Missing selector: {selector}'
for declaration in expected:
assert declaration in match.group(1), f'{selector}: expected {declaration}'
PY_APPS_NAV
grep -Fq "route: 'account'" "$ROUTES" || \
fail 'SmartSafeHub account route must be registered'
grep -Fq "hash: '#account'" "$ROUTES" || \
+250
View File
@@ -0,0 +1,250 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-or-later
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
CHECKER="$ROOT_DIR/tools/check_openwrt_commands.py"
POLICY="$ROOT_DIR/tools/openwrt-25.12-commands.txt"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT INT TERM
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
# Never silently enlarge the allowlist to hide a build failure. These applets
# are specifically disabled by OpenWrt 25.12 stock BusyBox configuration.
for applet in od xxd realpath stat wget; do
if grep -Eq "^${applet}$" "$POLICY"; then
fail "$applet must not be listed as an OpenWrt 25.12 default command"
fi
done
for applet in dd sha256sum hexdump cmp; do
grep -Eq "^${applet}$" "$POLICY" || fail "missing default BusyBox applet: $applet"
done
python3 "$CHECKER" >/dev/null || fail 'current router sources violate the OpenWrt 25.12 command policy'
mkdir -p "$TMP/root/usr/libexec" "$TMP/root/etc/init.d"
FILE="$TMP/root/usr/libexec/smartsafehub-fixture"
check_allowed() {
python3 "$CHECKER" --root "$TMP" >"$TMP/out" 2>"$TMP/err" || \
fail "supported commands or quoted shell data were rejected: $(cat "$TMP/err")"
}
check_blocked() {
applet="$1"
if python3 "$CHECKER" --root "$TMP" >"$TMP/out" 2>"$TMP/err"; then
fail "unsupported command $applet escaped the audit"
fi
grep -E "unsupported command: ${applet}|unsupported command default: [A-Z_]+=${applet}" "$TMP/err" >/dev/null || \
fail "failed for the wrong reason (expected $applet): $(cat "$TMP/err")"
}
cat > "$FILE" <<'EOF'
#!/bin/sh
# od -An -tx1 must be ignored: comment, not a command
printf '%s\n' 'od -An -tx1' "xxd and curl are ordinary text"
dd if=/dev/urandom bs=8 count=1 2>/dev/null | sha256sum
awk 'BEGIN { print "xxd is a literal" }'
EOF
check_allowed
cat >> "$FILE" <<'EOF'
od -An -tx1 /dev/urandom
EOF
check_blocked od
cat > "$FILE" <<'EOF'
#!/bin/sh
value="$(xxd /dev/urandom)"
EOF
check_blocked xxd
cat > "$FILE" <<'EOF'
#!/bin/sh
sha256sum /dev/null | base64
EOF
check_blocked base64
cat > "$FILE" <<'EOF'
#!/bin/sh
command -v od >/dev/null || :
EOF
check_blocked od
cat > "$FILE" <<'EOF'
#!/bin/sh
if [ -e /dev/null ]; then python3 --version; fi
EOF
check_blocked python3
# Reject indirect executable defaults as well; this catches a common pattern
# used for configurable commands on production routers.
cat > "$FILE" <<'EOF'
#!/bin/sh
OD_BIN="${SMARTSAFEHUB_TEST_OD_BIN:-od}"
"$OD_BIN" -An -tx1 /dev/urandom
EOF
check_blocked od
# Commands must be audited in init.d and the other shipped shell entry points,
# not only in /usr/libexec.
cat > "$FILE" <<'EOF'
#!/bin/sh
printf 'ok\n'
EOF
cat > "$TMP/root/etc/init.d/smartsafehub-fixture" <<'EOF'
#!/bin/sh /etc/rc.common
xxd /dev/urandom
EOF
check_blocked xxd
rm "$TMP/root/etc/init.d/smartsafehub-fixture"
# `curl` is NOT a default. A single legacy guarded fallback is documented in
# the checker; a new unguarded use must fail even in that same script.
cat > "$TMP/root/usr/libexec/smartsafehub-device" <<'EOF'
#!/bin/sh
curl -fsS https://example.invalid
EOF
check_blocked curl
# An extra unguarded call must not be allowed merely because the same script
# contains the previously reviewed guarded curl fallback.
cat > "$TMP/root/usr/libexec/smartsafehub-device" <<'EOF'
#!/bin/sh
post_json() {
if command -v curl >/dev/null 2>&1; then
curl -fsS https://example.invalid
fi
}
curl -fsS https://example.invalid
EOF
check_blocked curl
rm "$TMP/root/usr/libexec/smartsafehub-device"
cat > "$TMP/root/usr/libexec/smartsafehub-bad-interpreter" <<'EOF'
#!/bin/bash
echo hello
EOF
if python3 "$CHECKER" --root "$TMP" >"$TMP/out" 2>"$TMP/err"; then
fail 'bash interpreter escaped the audit'
fi
grep -F 'unsupported interpreter' "$TMP/err" >/dev/null || \
fail 'bash interpreter was not identified correctly'
rm "$TMP/root/usr/libexec/smartsafehub-bad-interpreter"
# Performance mutations use the SAME checker/CI entry point as the BusyBox
# applet audit. A newly added high-cost router command must fail at build time.
check_slow() {
label="$1"
if python3 "$CHECKER" --root "$TMP" >"$TMP/out" 2>"$TMP/err"; then
fail "high-cost router operation escaped the audit: $label"
fi
grep -F 'performance risk:' "$TMP/err" >/dev/null || \
fail "failed for an unrelated reason ($label): $(cat "$TMP/err")"
}
cat > "$FILE" <<'EOF'
#!/bin/sh
# Quoted strings and heredoc content do not execute high-cost commands.
printf '%s\n' 'find /' 'grep -R /' 'while true; do :; done'
cat <<'DATA'
find / -type f
grep -R word /tmp
while true; do echo spin; done
DATA
find /etc/config -maxdepth 1 -type f
while :; do
[ -r /tmp/done ] && break
sleep 1
done
EOF
check_allowed
cat > "$FILE" <<'EOF'
#!/bin/sh
find / -type f
EOF
check_slow 'unbounded find /'
cat > "$FILE" <<'EOF'
#!/bin/sh
find "/" -type f
EOF
check_slow 'quoted root scan'
cat > "$FILE" <<'EOF'
#!/bin/sh
LC_ALL=C grep -Rn 'password' /etc
EOF
check_slow 'recursive grep through env prefix'
cat > "$FILE" <<'EOF'
#!/bin/sh
if grep --recursive 'key' /tmp/config; then echo found; fi
EOF
check_slow 'recursive grep long option'
cat > "$FILE" <<'EOF'
#!/bin/sh
du -a /tmp
EOF
check_slow 'per-file du traversal'
cat > "$FILE" <<'EOF'
#!/bin/sh
ls -lR /tmp
EOF
check_slow 'recursive ls'
cat > "$FILE" <<'EOF'
#!/bin/sh
dd if=/dev/urandom bs=1024 of=/tmp/random
EOF
check_slow 'unbounded random read'
cat > "$FILE" <<'EOF'
#!/bin/sh
printf 'x\n' | xargs -P 0 echo
EOF
check_slow 'unbounded xargs parallelism'
cat > "$FILE" <<'EOF'
#!/bin/sh
sleep 0
EOF
check_slow 'zero-delay sleep'
cat > "$FILE" <<'EOF'
#!/bin/sh
while true; do
echo spin
:
done
EOF
check_slow 'infinite loop without sleep/break'
cat > "$FILE" <<'EOF'
#!/bin/sh
while true; do printf '%s\n' busy; done
EOF
check_slow 'inline infinite loop without delay'
# A finite, explicitly bounded batch and a throttled daemon are legitimate.
cat > "$FILE" <<'EOF'
#!/bin/sh
dd if=/dev/urandom bs=8 count=1 2>/dev/null | sha256sum
du -s /tmp/smartsafehub
find /etc/smartsafehub -maxdepth 1 -type f
n=0
while :; do
n=$((n + 1))
[ "$n" -le 32 ] || break
done
while true; do
echo periodic
sleep "$TICK_S"
done
EOF
check_allowed
printf 'PASS: OpenWrt 25.12 BusyBox and performance command audit\n'
@@ -74,6 +74,8 @@ require_executable "$ROOT_DIR/tests/test-recommended-rules.sh"
require_executable "$ROOT_DIR/root/usr/libexec/smartsafehub-backup"
[ ! -e "$ROOT_DIR/root/lib/upgrade/keep.d/smartsafehub" ] || fail "firmware.json must not be preserved through a SmartSafeHub keep.d rule"
require_file "$ROOT_DIR/root/lib/upgrade/keep.d/smartsafehub-device"
require_file "$ROOT_DIR/root/lib/upgrade/keep.d/smartsafehub-apps"
grep -Fxq '/etc/smartsafehub/apps-installed/' "$ROOT_DIR/root/lib/upgrade/keep.d/smartsafehub-apps" || fail 'Apps installation ownership must survive sysupgrade'
grep -Fxq '/etc/smartsafehub/device-credential.json' "$ROOT_DIR/root/lib/upgrade/keep.d/smartsafehub-device" || fail "device credential must be preserved across sysupgrade"
require_executable "$ROOT_DIR/root/etc/uci-defaults/91-smartsafehub-firmware-identity"
require_executable "$ROOT_DIR/root/etc/uci-defaults/92-smartsafehub-device"
@@ -1,10 +0,0 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/.."
grep -Fq 'ACCOUNT_STATUS_INTERVAL_MS = 60_000' frontend/src/hooks/useDashboardAccountStatus.ts
grep -Fq "document.visibilityState === 'hidden'" frontend/src/hooks/useDashboardAccountStatus.ts
grep -Fq 'staleTimeMs = 30_000' frontend/src/hooks/useAsyncResource.ts
grep -Fq 'if (!fresh) void load();' frontend/src/hooks/useAsyncResource.ts
grep -Fq 'const updates = useSoftwareUpdates(true);' frontend/src/app/App.tsx
printf 'PASS: periodic local account state, stale cache, global update polling
'
@@ -1,9 +0,0 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/.."
grep -Fq 'activity_history: {' root/usr/share/rpcd/ucode/smartsafehub.uc
jq -e '."luci-app-smartsafehub".read.ubus.smartsafehub | index("activity_history") != null' root/usr/share/rpcd/acl.d/luci-app-smartsafehub.json >/dev/null
grep -Fq "callApi<ActivityHistory>(API_OBJECT, 'activity_history')" frontend/src/api/smartsafehub.ts
grep -Fq 'import.meta.env.DEV && elapsedMs >= 1000' frontend/src/api/rpc.ts
printf 'PASS: activity RPC, ACL, slow RPC instrumentation
'
@@ -1,7 +0,0 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/.."
grep -Fq '`${item.label} (계정 연결 필요)`' frontend/src/components/ProductNavigation.tsx
grep -Fq 'aria-hidden="true"' frontend/src/components/ProductNavigation.tsx
printf 'PASS: accessible disconnected account navigation
'
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more