mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-03 10:23:56 +08:00
🐤 Sync 2026-10-30 12:53:07
This commit is contained in:
1 parent
57d68d2cec
commit
8da5d996a7
124 files changed
+8650
-562
No files matched your search
+3
-3
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:= \
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 화면에 표시하지 않습니다.
|
||||
@@ -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
|
||||
|
||||
@@ -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`가 긴 경우 큰 응답 또는 브라우저 메인 스레드 부하를 우선 확인합니다. 수치만으로 원인을 단정할 수는 없습니다.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+16
@@ -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/
|
||||
@@ -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',
|
||||
});
|
||||
};
|
||||
+34
-16
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
Executable
+243
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user