diff --git a/luci-app-ddnsto/Makefile b/luci-app-ddnsto/Makefile index 77369c7a..d2a922b9 100644 --- a/luci-app-ddnsto/Makefile +++ b/luci-app-ddnsto/Makefile @@ -8,9 +8,39 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI support for ddnsto LUCI_DEPENDS:=+ddnsto +block-mount LUCI_PKGARCH:=all -PKG_VERSION:=4.2.2-r1 +PKG_VERSION:=4.2.2-r3 PKG_RELEASE:= +define Package/luci-app-ddnsto/postinst +#!/bin/sh +# Maintain the old desktop entry without a second manifest or file owner. +root="$${IPKG_INSTROOT:-}" +sh "$${root}/usr/share/ddnsto/register-desktop.sh" || exit $$? +icon="$${root}/usr/share/linkeasefull/app-meta/ddnsto/logo.png" +if [ "$$(readlink "$${icon}" 2>/dev/null)" = "/www/luci-static/resources/app-icons/ddnsto.png" ]; then + rm -f "$${icon}" +fi +rmdir "$${root}/usr/share/linkeasefull/app-meta/ddnsto" "$${root}/usr/share/linkeasefull/app-meta" 2>/dev/null || true +if [ -z "$${IPKG_INSTROOT}" ]; then + rm -f /tmp/luci-indexcache /tmp/luci-indexcache.* + rm -rf /tmp/luci-modulecache/ +fi +exit 0 +endef + +define Package/luci-app-ddnsto/prerm +#!/bin/sh +root="$${IPKG_INSTROOT:-}" +for directory in /usr/share/linkease/apps.d /usr/share/linkeasefull/desktop-apps.d; do + registration="$${root}$${directory}/16-ddnsto.json" + if [ "$$(readlink "$${registration}" 2>/dev/null)" = "/usr/share/ddnsto/ddnsto-plugin.json" ]; then + rm -f "$${registration}" + fi +done +rmdir "$${root}/usr/share/linkeasefull/desktop-apps.d" 2>/dev/null || true +exit 0 +endef + include $(TOPDIR)/feeds/luci/luci.mk # call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-ddnsto/root/usr/share/ddnsto/ddnsto-plugin.json b/luci-app-ddnsto/root/usr/share/ddnsto/ddnsto-plugin.json new file mode 100644 index 00000000..0ed3393a --- /dev/null +++ b/luci-app-ddnsto/root/usr/share/ddnsto/ddnsto-plugin.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "id": "ddnsto", + "name": "DDNSTO", + "icon": "logo.png", + "staticRoot": "/usr/share/ddnsto/www", + "desktop": { + "mode": "builtin", + "component": "LuciContainer", + "props": { + "initialPath": "/cgi-bin/luci/admin/services/ddnsto", + "contentOnly": true, + "isolation": "scoped-dom-css", + "mountIds": [ + "root", + "app" + ], + "hideChrome": true, + "preloadScripts": [ + "/luci-static/linkeasefull-embed/embed-prelude.js" + ], + "preloadStyles": [ + "/luci-static/linkeasefull-embed/embed.css" + ] + } + }, + "standalone": { + "url": "/cgi-bin/luci/admin/services/ddnsto" + }, + "window": { + "width": 980, + "height": 700, + "singleton": true + }, + "capabilities": [ + "ddnsto", + "openwrt", + "luci", + "network" + ], + "categories": [ + "network", + "service", + "openwrt" + ] +} diff --git a/luci-app-ddnsto/root/usr/share/ddnsto/register-desktop.sh b/luci-app-ddnsto/root/usr/share/ddnsto/register-desktop.sh new file mode 100755 index 00000000..aed53cbc --- /dev/null +++ b/luci-app-ddnsto/root/usr/share/ddnsto/register-desktop.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Called by the owning plugin and its metadata migration hook. +set -e +root="${IPKG_INSTROOT:-}" +manifest="/usr/share/ddnsto/ddnsto-plugin.json" +legacy="${root}/usr/share/linkeasefull/desktop-apps.d/16-ddnsto.json" +[ -f "${root}${manifest}" ] || exit 0 +# Replace only the exact historical metadata payload; preserve custom entries. +if [ -f "${legacy}" ] && [ ! -L "${legacy}" ] && [ "$(sha256sum "${legacy}" | cut -d ' ' -f 1)" = "a72a8b3e529c91b2a3bc32ef695e5f7dc2935e332fcbd3201f985e05c2c8763d" ]; then + rm -f "${legacy}" +fi +if [ ! -e "${legacy}" ] && [ ! -L "${legacy}" ]; then + mkdir -p "${legacy%/*}" + ln -s "${manifest}" "${legacy}" +fi diff --git a/luci-app-ddnsto/root/usr/share/ddnsto/www/logo.png b/luci-app-ddnsto/root/usr/share/ddnsto/www/logo.png new file mode 100644 index 00000000..c0ac79dc Binary files /dev/null and b/luci-app-ddnsto/root/usr/share/ddnsto/www/logo.png differ diff --git a/luci-app-ddnsto/root/usr/share/linkease/apps.d/16-ddnsto.json b/luci-app-ddnsto/root/usr/share/linkease/apps.d/16-ddnsto.json new file mode 120000 index 00000000..ccba184f --- /dev/null +++ b/luci-app-ddnsto/root/usr/share/linkease/apps.d/16-ddnsto.json @@ -0,0 +1 @@ +/usr/share/ddnsto/ddnsto-plugin.json \ No newline at end of file diff --git a/luci-app-fastnet/Makefile b/luci-app-fastnet/Makefile index cdaa239f..882bed8a 100644 --- a/luci-app-fastnet/Makefile +++ b/luci-app-fastnet/Makefile @@ -7,12 +7,32 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI support for fastnet LUCI_DEPENDS:=+fastnet +luci-lib-linkeaseauth +linkease-app-entry +# APK uses -rN; OPKG uses -N for runtime package releases. +LUCI_EXTRA_DEPENDS:=fastnet (>=0.8.9-$(if $(CONFIG_USE_APK),r)1) LUCI_PKGARCH:=all -PKG_VERSION:=0.8.1-r5 +PKG_VERSION:=0.8.9-r1 PKG_RELEASE:= +# Preserve LuCI translation version ordering when upgrading older git-* packages. +PKG_PO_VERSION:=git-26.313.00000-082 LUCI_MINIFY_CSS:=0 LUCI_MINIFY_JS:=0 +define Package/luci-app-fastnet/postinst +#!/bin/sh +set -e +"$${IPKG_INSTROOT}/usr/libexec/fastnet-card-register" install +if [ -z "$${IPKG_INSTROOT}" ]; then + rm -f /tmp/luci-indexcache /tmp/luci-indexcache.* + rm -rf /tmp/luci-modulecache/ + killall -HUP rpcd 2>/dev/null || true +fi +endef + +define Package/luci-app-fastnet/prerm +#!/bin/sh +"$${IPKG_INSTROOT}/usr/libexec/fastnet-card-register" remove +endef + include $(TOPDIR)/feeds/luci/luci.mk # call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-fastnet/luasrc/controller/fastnet.lua b/luci-app-fastnet/luasrc/controller/fastnet.lua index 00bb349b..b00cd38f 100644 --- a/luci-app-fastnet/luasrc/controller/fastnet.lua +++ b/luci-app-fastnet/luasrc/controller/fastnet.lua @@ -1,10 +1,15 @@ module("luci.controller.fastnet", package.seeall) function index() + for _,action in ipairs({"summary","run","task","cancel"}) do + entry({"admin","services","fastnet","card",action},call("action_card_"..action)).leaf=true + end if not nixio.fs.access("/etc/config/fastnet") then return end entry({"admin", "services", "fastnet"}, cbi("fastnet"), _("FastNet"), 50).dependent = true entry({"admin", "services", "fastnet", "status"}, call("action_status")).leaf = true + entry({"admin", "services", "fastnet", "browser-origin"}, call("action_browser_origin")).leaf = true + entry({"admin", "services", "fastnet", "embed"}, call("action_embed")).leaf = true local open = entry({"admin", "services", "fastnet", "open"}, call("action_open")) open.leaf = true open.dependent = false @@ -30,5 +35,64 @@ function action_status() end function action_open() - compat():open("fastnet") + local dispatcher = require "luci.dispatcher" + require("luci.http").redirect(dispatcher.build_url("admin", "services", "fastnet", "embed") .. "?standalone=1") +end + +-- Only the launch metadata passes through LuCI. The iframe and all test data +-- connect to the configured FastNet service port in the browser. +function action_embed() + local http = require "luci.http" + local ctx = require("luci.dispatcher").context + if http.getenv("REQUEST_METHOD") ~= "GET" then http.status(405); return end + if not ctx.authsession or ctx.authuser ~= "root" then http.status(403); return end + local authority = http.getenv("HTTP_HOST") or "" + local host = authority:match("^(%[[%x:%.]+%]):?%d*$") or authority:match("^([%w%.%-]+):?%d*$") + local uci = require("luci.model.uci").cursor() + local port = tonumber(uci:get_first("fastnet", "fastnet", "port") or "3200") + if uci:get_first("fastnet", "fastnet", "enabled") == "0" or not host or not port or port % 1 ~= 0 or port < 1 or port > 65535 then + http.status(503); return + end + local url = "http://" .. host .. ":" .. tostring(port) .. "/webui/" + local query = {} + if http.formvalue("standalone") ~= "1" then + query[#query+1] = "embed=quickstart" + query[#query+1] = "theme=" .. (http.formvalue("theme") == "dark" and "dark" or "light") + end + if http.formvalue("view") == "history" then query[#query+1] = "view=history" end + local token = uci:get_first("fastnet", "fastnet", "token") or "" + if token ~= "" then query[#query+1] = "token=" .. http.urlencode(token) end + if #query > 0 then url = url .. "?" .. table.concat(query, "&") end + http.header("Cache-Control", "no-store") + http.header("Referrer-Policy", "no-referrer") + if http.formvalue("format") == "json" then + http.prepare_content("application/json"); http.write_json({url=url}) + else + http.redirect(url) + end +end + +function action_card_summary() require("luci.model.fastnet.card").new():handle("summary") end +function action_card_run() require("luci.model.fastnet.card").new():handle("run") end +function action_card_task() require("luci.model.fastnet.card").new():handle("task") end +function action_card_cancel() require("luci.model.fastnet.card").new():handle("cancel") end + +-- uHTTPd owns REMOTE_ADDR. The app proxy's loopback peer and browser-provided +-- forwarded headers cannot stand in for the real browser in LAN capability UI. +function action_browser_origin() + local http = require "luci.http" + local ctx = require("luci.dispatcher").context + if not ctx.authsession or ctx.authuser ~= "root" then http.status(403); return end + if http.getenv("REQUEST_METHOD") ~= "GET" then http.status(405); return end + local address = http.getenv("REMOTE_ADDR") + if type(address) ~= "string" or address == "" or #address > 64 or not address:match("^[%x:%.]+$") then http.status(503); return end + local loopback = address == "::1" or address:match("^127%.") ~= nil or address:match("^::ffff:127%.") ~= nil + local local_address = loopback + for _, entry in ipairs(require("nixio").getifaddrs() or {}) do + if entry.addr == address then local_address = true; break end + end + http.prepare_content("application/json") + http.header("Cache-Control", "no-store") + http.header("X-Content-Type-Options", "nosniff") + http.write_json({isLoopback=loopback,isLocal=local_address,isLocalInterface=local_address}) end diff --git a/luci-app-fastnet/luasrc/model/fastnet/card.lua b/luci-app-fastnet/luasrc/model/fastnet/card.lua new file mode 100644 index 00000000..e5604ca9 --- /dev/null +++ b/luci-app-fastnet/luasrc/model/fastnet/card.lua @@ -0,0 +1,150 @@ +-- The public authenticated adapter. No shell commands, core tokens or probes. +local M = {} +local SOCKET = "/var/run/fastnet-card.sock" +local STATE = "/etc/fastnet/card-state.json" +local MAX_BODY, MAX_RESPONSE = 1024, 8192 + +local function socket_request(method, path, body) + local nixio = require "nixio" + local socket = nixio.socket("unix", "stream") + if not socket then return nil, nil, "service_unavailable" end + local deadline = os.time() + 6 + local function timeout() + local remaining = deadline - os.time() + if remaining <= 0 then return false end + socket:setopt("socket", "sndtimeo", remaining) + socket:setopt("socket", "rcvtimeo", remaining) + return true + end + local function fail(reason) socket:close(); return nil, nil, reason end + timeout() + if not socket:connect(SOCKET) then return fail("service_unavailable") end + body = body or "" + local request = method .. " /api/card/" .. path .. " HTTP/1.0\r\nHost: localhost\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: " .. #body .. "\r\n\r\n" .. body + local offset = 1 + while offset <= #request do + if not timeout() then return fail("upstream_timeout") end + local sent = socket:send(request:sub(offset)) + if not sent or sent <= 0 then return fail("upstream_timeout") end + offset = offset + sent + end + local chunks, length = {}, 0 + while true do + if not timeout() then return fail("upstream_timeout") end + local chunk = socket:recv(2048) + if not chunk then return fail("upstream_timeout") end + if chunk == "" then break end + length = length + #chunk + if length > MAX_RESPONSE + 4096 then return fail("upstream_too_large") end + chunks[#chunks + 1] = chunk + end + socket:close() + local raw = table.concat(chunks) + local headers, response = raw:match("^(.-)\r\n\r\n(.*)$") + local status = headers and tonumber(headers:match("^HTTP/1%.[01]%s+(%d%d%d)")) + if not status or not headers:lower():match("content%-type:%s*application/json") or #response > MAX_RESPONSE then return nil, nil, "invalid_upstream" end + return status, response +end + +local function defaults() + local nixio, fs = require "nixio", require "nixio.fs" + local function read(path, maximum) + local file = nixio.open(path, "r") + if not file then return nil end + local parts, length = {}, 0 + while true do + local part=file:read(4096) + if not part then file:close();return nil end + if part=="" then break end + length=length+#part + if length>maximum then file:close();return nil end + parts[#parts+1]=part + end + file:close();return table.concat(parts) + end + return {http=require "luci.http",ctx=require("luci.dispatcher").context,json=require "luci.jsonc",fs=fs, + enabled=function() return require("luci.model.uci").cursor():get_first("fastnet", "fastnet", "enabled") ~= "0" end, + state=function() return read(STATE, 128*1024) end,request=socket_request} +end + +function M.new(deps) + local d = deps or defaults() + local http, ctx, json = d.http, d.ctx, d.json + local function reply(status, value) + http.status(status) + http.prepare_content("application/json") + http.header("Cache-Control", "no-store") + http.header("X-Content-Type-Options", "nosniff") + http.write(type(value)=="string" and value or json.stringify(value)) + end + local function error_reply(status, code) return reply(status, {error=code}) end + -- jsonc drops null object members when decoding. Forward live core JSON as + -- bytes; reconstruct nullable fields explicitly only for a stopped service. + local function atom(value) return value==nil and "null" or json.stringify(value) end + local function measurement(value) + if type(value)~="table" then return '{"status":"not_tested","value":null,"measuredAt":null,"error":null}' end + return '{"status":'..atom(value.status)..',"value":'..atom(value.value)..',"measuredAt":'..atom(value.measuredAt)..',"error":'..atom(value.error)..',"runId":'..atom(value.runId)..'}' + end + local function stopped_summary(reason) + local raw=d.state();local disk=raw and json.parse(raw) or nil + local latest=type(disk)=="table" and (disk.schemaVersion==1 or disk.schemaVersion==2) and disk.latest or nil + local result="null" + if type(latest)=="table" then + result='{"runId":'..atom(latest.runId)..',"measuredAt":'..atom(latest.measuredAt)..',"download":'..measurement(latest.download)..',"upload":'..measurement(latest.upload)..',"nat":'..measurement(latest.nat)..',"ipv6":'..measurement(latest.ipv6) + if type(latest.bandwidth)=="table" then result=result..',"bandwidth":'..json.stringify(latest.bandwidth) end + result=result..'}' + end + local state = d.enabled() and "unavailable" or "disabled" + return reply(200,'{"schemaVersion":1,"instanceId":"","service":{"state":'..atom(state)..',"reason":'..atom(reason)..'},"latest":'..result..',"latestResults":'..json.stringify(type(disk)=="table" and disk.latestResults or {})..',"task":null}') + end + return {handle=function(_, action) + if type(ctx.authsession)~="string" or ctx.authsession=="" then return error_reply(401,"unauthenticated") end + if ctx.authuser~="root" then return error_reply(403,"forbidden") end + local writing=action=="run" or action=="cancel" + local method=http.getenv("REQUEST_METHOD") + if method~=(writing and "POST" or "GET") then return error_reply(405,"method") end + if not d.fs.access("/etc/config/fastnet") then return error_reply(503,"installation_incomplete") end + if not d.fs.access("/usr/sbin/FastNet") then return error_reply(503,"core_missing") end + local body, path = nil, action + if writing then + local site=http.getenv("HTTP_SEC_FETCH_SITE") + if site and site~="same-origin" and site~="none" then return error_reply(403,"cross_origin") end + local origin=http.getenv("HTTP_ORIGIN") + local secure=http.getenv("HTTPS");local scheme=(secure=="on" or secure=="1") and "https" or "http" + if origin and origin~=scheme.."://"..(http.getenv("HTTP_HOST") or "") then return error_reply(403,"cross_origin") end + -- Some uhttpd builds omit custom CGI headers. The standard LuCI query + -- token is read without consuming the JSON request body. + local csrf=http.getenv("HTTP_X_CSRF_TOKEN") or http.getenv("HTTP_X-CSRF-TOKEN") or http.formvalue("token",true) + if type(ctx.authtoken)~="string" or ctx.authtoken=="" or csrf~=ctx.authtoken then return error_reply(403,"csrf") end + local kind=(http.getenv("CONTENT_TYPE") or ""):match("^%s*([^;]+)") + if kind~="application/json" then return error_reply(422,"content_type") end + local size=tonumber(http.getenv("CONTENT_LENGTH")) + if size and size>MAX_BODY then return error_reply(413,"body_too_large") end + body=http.content() + if type(body)~="string" or #body>MAX_BODY then return error_reply(413,"body_too_large") end + local key=action=="run" and "idempotencyKey" or "id" + local value=body:match('^%s*{%s*"'..key..'"%s*:%s*"([A-Za-z0-9_-]+)"%s*}%s*$') + if not value or #value>(action=="run" and 64 or 96) or #value<(action=="run" and 16 or 1) then return error_reply(422,"invalid_request") end + elseif action=="task" then + local id=http.formvalue("id") + if type(id)~="string" or #id<1 or #id>96 or not id:match("^[A-Za-z0-9_-]+$") then return error_reply(422,"invalid_id") end + path="task?id="..id + elseif action~="summary" then return error_reply(404,"not_found") end + local status, raw, reason=d.request(method,path,body) + if not status then + if action=="summary" and reason=="service_unavailable" then return stopped_summary(reason) end + return error_reply(reason=="service_unavailable" and 503 or 502,reason or "invalid_upstream") + end + local value=type(raw)=="string" and #raw<=MAX_RESPONSE and json.parse(raw) or nil + if type(value)~="table" then return error_reply(502,"invalid_upstream") end + if status>=400 then + local allowed={busy=true,not_found=true,invalid_id=true,invalid_request=true,persistence_failed=true,local_only=true,method=true} + return error_reply(status,allowed[value.error] and value.error or "core_error") + end + if action=="summary" then + if value.schemaVersion~=1 or type(value.service)~="table" then return error_reply(502,"invalid_upstream") end + elseif type(value.id)~="string" or type(value.state)~="string" then return error_reply(502,"invalid_upstream") end + return reply(status,raw) + end} +end +return M diff --git a/luci-app-fastnet/root/etc/uci-defaults/50_luci-fastnet b/luci-app-fastnet/root/etc/uci-defaults/50_luci-fastnet index cd1c52f8..ca3bb052 100644 --- a/luci-app-fastnet/root/etc/uci-defaults/50_luci-fastnet +++ b/luci-app-fastnet/root/etc/uci-defaults/50_luci-fastnet @@ -1,5 +1,6 @@ #!/bin/sh +/usr/libexec/fastnet-card-register install || exit 1 rm -f /tmp/luci-indexcache exit 0 diff --git a/luci-app-fastnet/root/usr/libexec/fastnet-card-register b/luci-app-fastnet/root/usr/libexec/fastnet-card-register new file mode 100755 index 00000000..421926a8 --- /dev/null +++ b/luci-app-fastnet/root/usr/libexec/fastnet-card-register @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu +root=${IPKG_INSTROOT:-} +source="$root/usr/share/fastnet/quickstart/fastnet.json" +target="$root/usr/share/quickstart/cards.d/fastnet.json" +case "${1:-install}" in + install) + [ -f "$source" ] || exit 1 + entry=$(sed -n 's/^[[:space:]]*"entry": "\(\/luci-static\/fastnet\/quickstart\/card\.[a-f0-9][a-f0-9]*\.js\)".*/\1/p' "$source") + [ -n "$entry" ] && [ -f "$root/www$entry" ] || exit 1 + umask 022 + mkdir -p "${target%/*}" + temporary="$target.new.$$" + trap 'rm -f "$temporary"' EXIT HUP INT TERM + cp "$source" "$temporary" + mv -f "$temporary" "$target" + ;; + remove) + # Never delete another package's registration, including a replaced file. + if [ -f "$source" ] && cmp -s "$source" "$target"; then rm -f "$target"; fi + ;; + *) exit 2 ;; +esac diff --git a/luci-app-fastnet/root/usr/share/fastnet/quickstart/fastnet.json b/luci-app-fastnet/root/usr/share/fastnet/quickstart/fastnet.json new file mode 100644 index 00000000..be495b92 --- /dev/null +++ b/luci-app-fastnet/root/usr/share/fastnet/quickstart/fastnet.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "id": "plugin.fastnet", + "title": "外网测速", + "glyph": "activity", + "abi": "quickstart-card-v1", + "version": "0.8.9-1", + "apiBase": "/cgi-bin/luci/admin/services/fastnet/card", + "forms": { + "standard": { + "minWidth": 280, + "minHeight": 260 + }, + "summary": { + "minWidth": 240, + "minHeight": 148 + } + }, + "stacked": [ + "summary" + ], + "entry": "/luci-static/fastnet/quickstart/card.dfb0ce55.js" +} diff --git a/luci-app-fastnet/root/www/luci-static/fastnet/quickstart/card.dfb0ce55.js b/luci-app-fastnet/root/www/luci-static/fastnet/quickstart/card.dfb0ce55.js new file mode 100644 index 00000000..572d1ce6 --- /dev/null +++ b/luci-app-fastnet/root/www/luci-static/fastnet/quickstart/card.dfb0ce55.js @@ -0,0 +1,398 @@ +var D = (n, b, v) => new Promise((e, d) => { + var l = (r) => { + try { + f(v.next(r)); + } catch (q) { + d(q); + } + }, a = (r) => { + try { + f(v.throw(r)); + } catch (q) { + d(q); + } + }, f = (r) => r.done ? e(r.value) : Promise.resolve(r.value).then(l, a); + f((v = v.apply(n, b)).next()); +}); +import { defineAsyncComponent as ia, h as W, computed as z, ref as J, onMounted as ua, onUnmounted as pa, openBlock as C, createElementBlock as B, unref as c, normalizeClass as G, createElementVNode as s, createTextVNode as w, toDisplayString as o, createBlock as la, resolveDynamicComponent as oa, withCtx as fa, createCommentVNode as ca, reactive as ma } from "/luci-static/quickstart/card-runtime-v1.js"; +const ga = '[data-fastnet-card]{--fn-down:var(--qs-card-accent);position:relative;isolation:isolate;container-type:inline-size;gap:12px;border-radius:inherit}[data-fastnet-card] .qs-card-header h2>svg{color:var(--fn-down)}[data-fastnet-card] .qs-card-primary{background:var(--qs-card-accent);color:var(--qs-card-panel);border-color:transparent;padding:6px 13px;border-radius:9px;font-weight:600}[data-fastnet-card] .qs-card-menu>summary{border-color:transparent;color:var(--qs-card-muted)}[data-fastnet-card] .fn-speed-stage{flex:1;min-height:0;display:flex;flex-direction:column;justify-content:center;gap:14px;padding:18px 20px;border-radius:14px;border:1px solid var(--qs-card-line);background:var(--qs-card-soft)}[data-fastnet-card] .fn-speed-caption{display:flex;align-items:center;justify-content:space-between;gap:6px;font-size:10px;letter-spacing:.7px;color:var(--qs-card-muted)}[data-fastnet-card] .fn-live{display:flex;align-items:center;gap:5px;letter-spacing:0;white-space:nowrap}[data-fastnet-card] .fn-live:before{content:"";width:5px;height:5px;border-radius:50%;background:var(--qs-card-muted)}[data-fastnet-card] .fn-live.is-active{color:var(--qs-card-accent)}[data-fastnet-card] .fn-live.is-active:before{background:currentColor;box-shadow:0 0 0 3px var(--qs-card-soft)}[data-fastnet-card] .qs-card-metrics{gap:18px;align-items:center;grid-template-columns:1.25fr 1fr}[data-fastnet-card] .qs-card-metrics strong{font-size:clamp(30px,12cqi,52px);line-height:1.15;letter-spacing:-1.7px;font-weight:700;margin-top:4px;color:var(--fn-down)}[data-fastnet-card] .qs-card-metrics>div+div{border-left:1px solid var(--qs-card-line);padding-left:18px}[data-fastnet-card] .qs-card-metrics>div+div strong{font-size:clamp(25px,8cqi,36px);color:var(--qs-card-text);letter-spacing:-.9px}[data-fastnet-card] .qs-card-metrics small{letter-spacing:0}[data-fastnet-card] .qs-card-time{gap:5px;font-size:10px}[data-fastnet-card] .qs-card-statuses{padding:0;border:0;gap:10px}[data-fastnet-card] .qs-card-statuses>div{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:3px 8px;padding:8px 10px;border:1px solid var(--qs-card-line);border-radius:8px;background:var(--qs-card-panel)}[data-fastnet-card] .qs-card-statuses span{display:flex;align-items:center;gap:6px;letter-spacing:.3px;margin:0;flex:none}[data-fastnet-card] .qs-card-statuses i{width:6px;height:6px;border-radius:50%;background:var(--qs-card-muted)}[data-fastnet-card] .qs-card-statuses [data-tone=good] i{background:var(--qs-card-good)}[data-fastnet-card] .qs-card-statuses [data-tone=warning] i{background:var(--qs-card-warning)}[data-fastnet-card].qs-card-summary{gap:3px;background:none}[data-fastnet-card].qs-card-summary .fn-speed-stage{display:block;flex:none;padding:0;border:0;background:none}[data-fastnet-card].qs-card-summary .fn-speed-caption{display:none}[data-fastnet-card].qs-card-summary .qs-card-metrics{grid-template-columns:1fr 1fr;gap:12px}[data-fastnet-card].qs-card-summary .qs-card-metrics strong{font-size:23px;letter-spacing:-.5px;margin:0}[data-fastnet-card].qs-card-summary .qs-card-metrics>div+div{padding:0;border:0}[data-fastnet-card].qs-card-summary .qs-card-statuses>div{padding:0;border:0;background:none;max-width:50%;flex-wrap:nowrap;gap:5px}[data-fastnet-card].qs-card-summary .qs-card-statuses span{margin:0;gap:4px}[data-fastnet-card].qs-card-summary .qs-card-statuses i{width:4px;height:4px}[data-fastnet-card].qs-card-summary .qs-card-primary{padding:2px 7px;border-radius:6px}@media(max-width:600px){[data-fastnet-card]:not(.qs-card-summary) .fn-speed-stage{padding:14px;gap:12px}[data-fastnet-card]:not(.qs-card-summary) .qs-card-metrics strong{font-size:36px}[data-fastnet-card]:not(.qs-card-summary) .qs-card-metrics>div+div strong{font-size:28px}}[data-fastnet-card].fn-dense:not(.qs-card-summary){gap:5px;padding:10px 14px}[data-fastnet-card].fn-dense:not(.qs-card-summary) .fn-speed-stage{padding:6px 12px;gap:3px;min-height:81px;flex-shrink:0}[data-fastnet-card].fn-dense:not(.qs-card-summary) .fn-speed-caption{display:none}[data-fastnet-card].fn-dense:not(.qs-card-summary) .qs-card-metrics strong{font-size:28px;line-height:1.15;margin:0}[data-fastnet-card].fn-dense:not(.qs-card-summary) .qs-card-metrics>div+div strong{font-size:23px}[data-fastnet-card].fn-dense:not(.qs-card-summary) .qs-card-statuses>div{padding:6px 9px}[data-fastnet-card] .fn-lan-summary{flex:none;min-width:0;padding:0 20px;color:var(--qs-card-text)}[data-fastnet-card] .fn-lan-caption{display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:2px 8px;font-size:10px;color:var(--qs-card-muted)}[data-fastnet-card] .fn-lan-caption time{font-variant-numeric:tabular-nums;white-space:nowrap}[data-fastnet-card] .fn-lan-metrics{display:grid;grid-template-columns:1.25fr 1fr;gap:18px;margin-top:6px}[data-fastnet-card] .fn-lan-metrics>div{min-width:0}[data-fastnet-card] .fn-lan-metrics>div+div{padding-left:18px;border-left:1px solid var(--qs-card-line)}[data-fastnet-card] .fn-lan-label{display:block;font-size:10px;color:var(--qs-card-muted)}[data-fastnet-card] .fn-lan-metrics strong{display:flex;align-items:baseline;gap:4px;font-size:20px;line-height:1.3;white-space:nowrap;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.4px}[data-fastnet-card] .fn-lan-metrics small{font-size:10px;letter-spacing:0;font-weight:400;color:var(--qs-card-muted)}[data-fastnet-card].fn-dense:not(.qs-card-summary) .fn-lan-summary{padding:0 12px}[data-fastnet-card].fn-dense:not(.qs-card-summary) .fn-lan-metrics{margin-top:2px}[data-fastnet-card].fn-dense:not(.qs-card-summary) .fn-lan-metrics strong{font-size:17px}[data-fastnet-card].qs-card-summary .fn-lan-summary{display:grid;grid-template-columns:28px minmax(0,1fr);align-items:center;gap:5px;padding:0}[data-fastnet-card].qs-card-summary .fn-lan-route,[data-fastnet-card].qs-card-summary .fn-lan-caption time,[data-fastnet-card].qs-card-summary .fn-lan-label{display:none}[data-fastnet-card].qs-card-summary .fn-lan-metrics{grid-template-columns:1fr 1fr;gap:8px;margin:0}[data-fastnet-card].qs-card-summary .fn-lan-metrics>div+div{border:0;padding:0}[data-fastnet-card].qs-card-summary .fn-lan-metrics strong{font-size:12px;font-weight:500;letter-spacing:0}[data-fastnet-card].qs-card-summary .fn-lan-metrics small{font-size:9px}@container (max-width:340px){[data-fastnet-card] .fn-lan-summary{padding:0 14px}[data-fastnet-card] .fn-lan-metrics strong{font-size:18px}}[data-fastnet-card].fn-dense:not(.qs-card-summary) .fn-lan-label,[data-fastnet-card].fn-dense:not(.qs-card-summary) .qs-card-progress small{display:none}[data-fastnet-card].fn-dense:not(.qs-card-summary) .qs-card-progress>button{min-height:26px;padding:2px 8px}@media(pointer:coarse){[data-fastnet-card].fn-dense:not(.qs-card-summary) .qs-card-progress>button{min-width:44px;min-height:44px}}@media(pointer:coarse){[data-fastnet-card].fn-dense:not(.qs-card-summary){padding:8px 14px;gap:4px}[data-fastnet-card].fn-dense:not(.qs-card-summary) .fn-lan-summary{display:grid;grid-template-columns:28px minmax(0,1fr);gap:5px;align-items:center}[data-fastnet-card].fn-dense:not(.qs-card-summary) .fn-lan-route,[data-fastnet-card].fn-dense:not(.qs-card-summary) .fn-lan-caption time{display:none}[data-fastnet-card].fn-dense:not(.qs-card-summary) .fn-lan-metrics{margin:0;grid-template-columns:1fr 1fr;gap:8px}[data-fastnet-card].fn-dense:not(.qs-card-summary) .fn-lan-metrics>div+div{padding:0;border:0}[data-fastnet-card].fn-dense:not(.qs-card-summary) .fn-lan-metrics strong{font-size:14px}}@media(pointer:coarse){[data-fastnet-card].qs-card-summary{gap:2px}[data-fastnet-card].qs-card-summary .qs-card-metrics span{display:block;font-size:10px;line-height:1.2}[data-fastnet-card].qs-card-summary .qs-card-metrics strong{font-size:21px;line-height:1.1}[data-fastnet-card].qs-card-summary .qs-card-time{line-height:1.2}}', k = (n) => n && ["running", "cancel_requested"].includes(n.state), K = ["running", "cancel_requested", "succeeded", "partial", "failed", "cancelled", "interrupted"]; +function ya(n) { + var b, v, e, d; + if ((n == null ? void 0 : n.schemaVersion) !== 1 || !["ready", "disabled", "unavailable"].includes((b = n.service) == null ? void 0 : b.state) || !("latest" in n) || !("task" in n)) throw Error("invalid_summary"); + if ((v = n.latest) != null && v.bandwidth) { + const l = n.latest.bandwidth; + if (![l.downloadBps, l.uploadBps].every((a) => typeof a == "number" && Number.isFinite(a) && a >= 0) || !l.measuredAt || !Number.isFinite(Date.parse(l.measuredAt))) throw Error("invalid_bandwidth"); + } + if (n.task && !K.includes(n.task.state)) throw Error("invalid_task"); + for (const l of ["download", "upload", "nat", "ipv6"]) if (n.latest) { + const a = n.latest[l]; + if (!a || !["ok", "unavailable", "error", "not_tested"].includes(a.status) || a.value !== null && (["download", "upload"].includes(l) ? typeof a.value != "number" || !Number.isFinite(a.value) || a.value < 0 : typeof a.value != "string")) throw Error("invalid_measurement"); + } + for (const l of ["download", "upload", "lanDownload", "lanUpload", "lanPing"]) { + const a = (d = (e = n.latestResults) == null ? void 0 : e[l]) == null ? void 0 : d.value; + if (a != null && (!Number.isFinite(a) || a < 0)) throw Error("invalid_measurement"); + } + return n; +} +function va(n) { + var e, d; + const b = n == null ? void 0 : n.latestResults; + if (b != null && b.download || b != null && b.upload) { + const l = b.download, a = b.upload; + return { downloadBps: (e = l == null ? void 0 : l.value) != null ? e : null, uploadBps: (d = a == null ? void 0 : a.value) != null ? d : null, measuredAt: [l == null ? void 0 : l.measuredAt, a == null ? void 0 : a.measuredAt].filter(Boolean).sort().pop(), mixed: !!(l && a && l.runId !== a.runId) }; + } + const v = n == null ? void 0 : n.latest; + return v ? v.bandwidth || { downloadBps: v.download.status === "ok" ? v.download.value : null, uploadBps: v.upload.status === "ok" ? v.upload.value : null, measuredAt: v.measuredAt } : null; +} +function E(n) { + return n == null ? "—" : n > 0 && n < 1e5 ? "<0.1" : (n / 1e6).toLocaleString("en-US", { maximumFractionDigits: n >= 1e8 ? 0 : 1 }); +} +function H(n, b) { + return !n || !Number.isFinite(Date.parse(n)) ? "—" : new Date(n).toLocaleString(b === "en" ? "en-GB" : "zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: !1 }); +} +function xa(n, b = (e) => e, v = globalThis) { + const e = b({ loading: !1, summary: null, task: null, submitting: !1, cancelling: !1, error: "", errorAction: "read", paused: !1, failures: 0 }); + let d = !0, l = !0, a, f, r, q = null, y = 0; + const h = () => { + v.clearTimeout(a), a = void 0, f == null || f.abort(), f = void 0, y++; + }, N = (u) => [401, 403].includes(u == null ? void 0 : u.status) ? "permission" : (u == null ? void 0 : u.status) === 404 ? "upgrade" : "unavailable", S = () => { + d && l && !e.paused && (a = v.setTimeout(k(e.task) ? I : _, k(e.task) ? 2e3 : 15e3)); + }; + function I() { + return D(this, null, function* () { + if (!d || !l || !k(e.task)) return; + const u = e.task.id, m = ++y; + f = new AbortController(); + try { + const g = yield n.request("/task?id=" + encodeURIComponent(u), { signal: f.signal }); + if (!d || m !== y) return; + if (g.id !== u || !K.includes(g.state)) throw Error("invalid_task"); + if (e.task = g, e.failures = 0, e.error = "", !k(g)) { + q = null, yield _(); + return; + } + } catch (g) { + if (!d || m !== y) return; + e.failures++, e.error = N(g), e.errorAction = "read", (e.failures >= 3 || ["permission", "upgrade"].includes(e.error)) && (e.paused = !0); + } finally { + d && m === y && (f = void 0, S()); + } + }); + } + function _() { + return D(this, null, function* () { + if (h(), !d || !l) return; + const u = y; + e.loading = !0, f = new AbortController(); + try { + const m = ya(yield n.request("/summary", { signal: f.signal })); + if (!d || u !== y) return; + e.summary = m, e.task = m.task || m.lastTask || null, e.error = "", e.failures = 0, e.paused = !1, k(e.task) && (q = null); + } catch (m) { + d && u === y && (e.error = N(m), e.errorAction = "read", e.failures++, e.paused = e.failures >= 3 || ["permission", "upgrade"].includes(e.error)); + } finally { + d && u === y && (e.loading = !1, f = void 0, S()); + } + }); + } + function V() { + return D(this, null, function* () { + var m; + if (!d || e.error === "permission" || e.submitting || k(e.task) || e.loading || ((m = e.summary) == null ? void 0 : m.service.state) !== "ready") return; + h(); + const u = y; + e.submitting = !0, e.error = "", r = new AbortController(), q || (q = Array.from(crypto.getRandomValues(new Uint8Array(16)), (g) => g.toString(16).padStart(2, "0")).join("")); + try { + const g = yield n.request("/run", { method: "POST", body: JSON.stringify({ idempotencyKey: q }), signal: r.signal }); + if (!d) return; + if (!K.includes(g.state)) throw Error("invalid_task"); + if (q = null, u !== y) return; + e.task = g, e.paused = !1, e.failures = 0, k(g) ? S() : yield _(); + } catch (g) { + if (!d || u !== y) return; + (g == null ? void 0 : g.status) === 409 ? (q = null, yield _()) : (e.error = N(g), e.errorAction = ["permission", "upgrade"].includes(e.error) ? "read" : "run", g != null && g.status && g.status < 500 && (q = null)); + } finally { + d && (e.submitting = !1, r = void 0); + } + }); + } + function M() { + return D(this, null, function* () { + if (!d || !k(e.task) || e.cancelling) return; + h(); + const u = y; + e.cancelling = !0, r = new AbortController(); + try { + const m = yield n.request("/cancel", { method: "POST", body: JSON.stringify({ id: e.task.id }), signal: r.signal }); + if (!d || u !== y) return; + e.task = m, k(m) ? S() : yield _(); + } catch (m) { + d && u === y && (e.error = N(m), e.errorAction = "read", S()); + } finally { + d && (e.cancelling = !1, r = void 0); + } + }); + } + return { state: e, start: V, stop: M, reload: _, visibility(u) { + l = u, h(), u && _(); + }, dispose() { + d = !1, h(), r == null || r.abort(); + } }; +} +const ba = ["data-task-id"], qa = { class: "qs-card-header" }, ha = { + key: 0, + class: "widget-action-group" +}, wa = { + key: 1, + class: "qs-card-actions" +}, ka = { class: "qs-card-menu" }, _a = ["aria-label"], Aa = { class: "fn-speed-stage" }, Ca = { class: "fn-speed-caption" }, Na = { class: "qs-card-metrics" }, Sa = { class: "qs-card-time" }, Ta = ["datetime"], za = ["aria-label"], Ba = { class: "fn-lan-caption" }, Ra = { class: "fn-lan-route" }, Fa = ["datetime"], Pa = { class: "fn-lan-metrics" }, Da = { class: "fn-lan-label" }, Ea = { class: "fn-lan-label" }, Ia = { class: "qs-card-statuses" }, La = ["data-tone"], Ua = ["title"], Va = ["data-tone"], Ma = ["title"], Oa = { + key: 0, + class: "qs-card-progress", + role: "status" +}, ja = ["disabled"], $a = { + key: 1, + class: "qs-card-alert", + role: "alert" +}, Wa = { + key: 2, + class: "qs-card-alert", + role: "status" +}, Ha = { + __name: "Card", + props: ["context"], + setup(n) { + const b = ia(() => import("./chunk.31ac0177.js")), v = ia({ loader: () => import("./chunk.1932f6b1.js"), timeout: 8e3, errorComponent: { render: () => W("p", { role: "alert", style: "padding:24px" }, [W("span", "FastNet 暂时无法打开 / Unable to open FastNet. "), W("a", { href: "/cgi-bin/luci/admin/services/fastnet/open", target: "_blank", rel: "noopener" }, "新窗口打开 / Open in new window ↗")]) } }), e = n, d = e.context, l = d.locale === "en" ? "en" : "zh-cn", a = (i, t) => l === "en" ? t : i, f = xa(d, ma), r = f.state, q = "/cgi-bin/luci/admin/services/fastnet", y = z(() => va(r.summary)), h = z(() => k(r.task)), N = z(() => { + var i, t, p, x, A, P; + return [(p = (t = (i = r.summary) == null ? void 0 : i.latestResults) == null ? void 0 : t.lanDownload) == null ? void 0 : p.measuredAt, (P = (A = (x = r.summary) == null ? void 0 : x.latestResults) == null ? void 0 : A.lanUpload) == null ? void 0 : P.measuredAt].filter(Boolean).sort().pop(); + }), S = { preparing: a("准备测速", "Preparing"), ping: a("检测延迟", "Testing latency"), download: a("测试下载", "Testing download"), upload: a("测试上传", "Testing upload"), nat: a("检测 NAT", "Checking NAT"), ipv6: a("检测 IPv6", "Checking IPv6") }, I = z(() => { + var i, t; + return ((i = r.task) == null ? void 0 : i.state) === "cancel_requested" ? a("正在停止", "Stopping") : S[(t = r.task) == null ? void 0 : t.stage] || a("测速中", "Testing"); + }), _ = z(() => r.error === "permission" ? a("登录已失效或权限不足,请重新登录", "Session expired or permission denied; sign in again") : r.error === "upgrade" ? a("插件接口不可用,请检查或升级 FastNet", "Plugin API unavailable; check or update FastNet") : r.paused ? a("连接连续失败,已暂停查询;后台任务继续运行", "Queries paused after repeated failures; the background task continues") : a("暂时无法确认结果,保留已有数据", "Result unavailable; existing data retained")), V = z(() => [{ id: "details", label: a("查看上次结果", "Last result details") }, { id: "history", label: a("测速历史", "Test history") }, { id: "reload", label: a("重新读取", "Reload") }, { id: "settings", label: a("插件设置", "Plugin settings") }, { id: "full", label: a("完整 FastNet", "Full FastNet") }]); + function M(i) { + i === "details" ? X() : i === "history" ? T("history") : i === "reload" ? f.reload() : i === "settings" ? window.open(q, "_blank", "noopener") : i === "full" && T(); + } + const u = J(!1), m = J(null), g = J(!1); + let R, F, O = !0; + function Q(i) { + var p, x; + const t = (x = (p = r.summary) == null ? void 0 : p.latest) == null ? void 0 : x[i]; + return j(i) || (t == null ? void 0 : t.status) === "error" ? "warning" : (t == null ? void 0 : t.status) === "ok" ? "good" : "neutral"; + } + function T(i) { + var p, x; + const t = (p = i == null ? void 0 : i.currentTarget) == null ? void 0 : p.closest("details"); + t == null || t.removeAttribute("open"), (x = t == null ? void 0 : t.querySelector("summary")) == null || x.focus({ preventScroll: !0 }), u.value = !0, f.visibility(!1), d.openDetails(v, { context: d, locale: l, view: i === "history" ? "history" : "quick" }, { wide: !0, headerAction: { href: q + "/open", label: a("新窗口打开", "Open in new window") }, onClose() { + u.value = !1, f.visibility(!document.hidden && !u.value && O); + } }); + } + const j = (i) => { + var t, p, x; + return !h.value && !!((x = (p = (t = r.summary) == null ? void 0 : t.lastTask) == null ? void 0 : p.issues) != null && x[i]); + }; + function L(i) { + var x, A; + const t = (A = (x = r.summary) == null ? void 0 : x.latest) == null ? void 0 : A[i]; + let p; + return !t || t.status === "not_tested" ? p = a("未检测", "Not tested") : t.status === "error" ? p = a("检测失败", "Probe failed") : t.status === "unavailable" ? p = a("不可用", "Unavailable") : i === "ipv6" ? p = /部分|准备度 [0-9]\/10/.test(t.value) ? a("部分可用", "Partly available") : a("可用", "Available") : p = /port.?restricted/i.test(t.value) ? a("端口受限锥形", "Port Restricted Cone") : /restricted.*cone/i.test(t.value) ? a("受限锥形", "Restricted Cone") : /full.?cone/i.test(t.value) ? a("全锥形", "Full Cone") : /symmetric/i.test(t.value) ? a("对称型", "Symmetric") : t.value, j(i) ? p + " · " + a("本次失败", "latest failed") : p; + } + function X(i) { + var p, x; + const t = (p = i == null ? void 0 : i.currentTarget) == null ? void 0 : p.closest("details"); + t == null || t.removeAttribute("open"), (x = t == null ? void 0 : t.querySelector("summary")) == null || x.focus({ preventScroll: !0 }), d.openDetails(b, { state: r, locale: l, stamp: H, mbps: E }); + } + function U() { + f.visibility(!document.hidden && !u.value && O); + } + return ua(() => { + R = new ResizeObserver(() => { + var i; + g.value = ((i = m.value) == null ? void 0 : i.clientHeight) < 380; + }), m.value && R.observe(m.value), F = new IntersectionObserver((i) => { + var t; + O = ((t = i[0]) == null ? void 0 : t.isIntersecting) !== !1, U(); + }), m.value && F.observe(m.value), document.addEventListener("visibilitychange", U), U(); + }), pa(() => { + R == null || R.disconnect(), F == null || F.disconnect(), document.removeEventListener("visibilitychange", U), f.dispose(); + }), (i, t) => { + var p, x, A, P, Y, Z, aa, ta, ea, sa, na, ra, da; + return C(), B("article", { + ref_key: "root", + ref: m, + class: G(["qs-card-content", { "qs-card-summary": c(d).form === "summary", "fn-dense": g.value }]), + "data-fastnet-card": "", + "data-task-id": h.value ? c(r).task.id : void 0 + }, [ + s("header", qa, [ + s("h2", null, [ + t[4] || (t[4] = s("svg", { + viewBox: "0 0 24 24", + "aria-hidden": "true" + }, [ + s("path", { d: "M3 12h4l3-8 4 16 3-8h4" }) + ], -1)), + w(o(a("外网测速", "Internet speed")), 1) + ]), + (p = c(d).components) != null && p.PrimaryAction && ((x = c(d).components) != null && x.Menu) ? (C(), B("div", ha, [ + (C(), la(oa(c(d).components.PrimaryAction), { + icon: "activity", + "data-fastnet-test": "", + onClick: T + }, { + default: fa(() => [ + w(o(h.value ? a("查看进度", "View progress") : a("测速", "Test")), 1) + ]), + _: 1 + })), + (C(), la(oa(c(d).components.Menu), { + label: a("外网测速更多操作", "Internet speed actions"), + items: V.value, + onSelect: M + }, null, 40, ["label", "items"])) + ])) : (C(), B("div", wa, [ + s("button", { + class: "qs-card-primary", + "data-fastnet-test": "", + onClick: T + }, o(h.value ? a("查看进度", "View progress") : a("测速", "Test")), 1), + s("details", ka, [ + s("summary", { + "aria-label": a("外网测速更多操作", "Internet speed actions") + }, "⋮", 8, _a), + s("div", null, [ + s("button", { + "data-fastnet-details": "", + onClick: X + }, o(a("查看上次结果", "Last result details")), 1), + s("button", { + onClick: t[0] || (t[0] = ($) => T("history")) + }, o(a("测速历史", "Test history")), 1), + s("button", { + onClick: t[1] || (t[1] = ($) => c(f).reload()) + }, o(a("重新读取", "Reload")), 1), + s("a", { + href: q, + target: "_blank", + rel: "noopener" + }, o(a("插件设置", "Plugin settings")), 1), + s("button", { onClick: T }, o(a("完整 FastNet", "Full FastNet")), 1) + ]) + ]) + ])) + ]), + s("div", Aa, [ + s("div", Ca, [ + s("span", null, "WAN · " + o(a("最近有效结果", "LATEST VALID RESULTS")), 1), + s("span", { + class: G(["fn-live", { "is-active": h.value }]) + }, o(h.value ? I.value : (A = y.value) != null && A.measuredAt ? a("已保存", "Saved") : a("等待测速", "Ready to test")), 3) + ]), + s("div", Na, [ + s("div", null, [ + s("span", null, "↓ " + o(a("上次下载", "Last download")), 1), + s("strong", null, [ + w(o(c(E)((P = y.value) == null ? void 0 : P.downloadBps)), 1), + t[5] || (t[5] = s("small", null, "Mbps", -1)) + ]) + ]), + s("div", null, [ + s("span", null, "↑ " + o(a("上次上传", "Last upload")), 1), + s("strong", null, [ + w(o(c(E)((Y = y.value) == null ? void 0 : Y.uploadBps)), 1), + t[6] || (t[6] = s("small", null, "Mbps", -1)) + ]) + ]) + ]), + s("p", Sa, [ + w(o(a("上次测速", "Last tested")) + " ", 1), + s("time", { + datetime: ((Z = y.value) == null ? void 0 : Z.measuredAt) || void 0 + }, o((aa = y.value) != null && aa.measuredAt ? c(H)(y.value.measuredAt, c(l)) : a("尚未测速", "No test yet")), 9, Ta) + ]) + ]), + s("section", { + class: "fn-lan-summary", + "aria-label": a("内网测速结果", "LAN speed results") + }, [ + s("div", Ba, [ + s("span", null, [ + t[7] || (t[7] = w("LAN ", -1)), + s("span", Ra, "· " + o(a("浏览器 → 路由器", "Browser → Router")), 1) + ]), + s("time", { + datetime: N.value || void 0 + }, o(c(H)(N.value, c(l))), 9, Fa) + ]), + s("div", Pa, [ + s("div", null, [ + s("span", Da, o(a("下载", "Download")), 1), + s("strong", null, [ + w("↓ " + o(c(E)((sa = (ea = (ta = c(r).summary) == null ? void 0 : ta.latestResults) == null ? void 0 : ea.lanDownload) == null ? void 0 : sa.value)), 1), + t[8] || (t[8] = s("small", null, "Mbps", -1)) + ]) + ]), + s("div", null, [ + s("span", Ea, o(a("上传", "Upload")), 1), + s("strong", null, [ + w("↑ " + o(c(E)((da = (ra = (na = c(r).summary) == null ? void 0 : na.latestResults) == null ? void 0 : ra.lanUpload) == null ? void 0 : da.value)), 1), + t[9] || (t[9] = s("small", null, "Mbps", -1)) + ]) + ]) + ]) + ], 8, za), + s("div", Ia, [ + s("div", { + "data-tone": Q("nat") + }, [ + t[10] || (t[10] = s("span", null, [ + s("i", { "aria-hidden": "true" }), + w("NAT") + ], -1)), + s("strong", { + title: L("nat") + }, o(L("nat")), 9, Ua) + ], 8, La), + s("div", { + "data-tone": Q("ipv6") + }, [ + t[11] || (t[11] = s("span", null, [ + s("i", { "aria-hidden": "true" }), + w("IPv6") + ], -1)), + s("strong", { + class: G({ "qs-card-warning": j("ipv6") }), + title: L("ipv6") + }, o(L("ipv6")), 11, Ma) + ], 8, Va) + ]), + h.value ? (C(), B("div", Oa, [ + s("span", null, [ + w(o(I.value), 1), + s("small", null, o(a("上次有效结果保持显示", "Previous valid results remain visible")), 1) + ]), + s("button", { + disabled: c(r).cancelling || c(r).task.state === "cancel_requested", + onClick: t[2] || (t[2] = ($) => c(f).stop()) + }, o(c(r).cancelling || c(r).task.state === "cancel_requested" ? a("正在停止", "Stopping") : a("停止", "Stop")), 9, ja) + ])) : ca("", !0), + c(r).error ? (C(), B("p", $a, [ + w(o(_.value) + " ", 1), + s("button", { + onClick: t[3] || (t[3] = ($) => c(r).errorAction === "run" ? T() : c(f).reload()) + }, o(c(r).paused ? a("恢复查询", "Resume queries") : a("重试", "Retry")), 1) + ])) : c(r).summary && c(r).summary.service.state !== "ready" ? (C(), B("p", Wa, [ + w(o(c(r).summary.service.state === "disabled" ? a("服务未启用", "Service disabled") : a("服务暂不可用", "Service unavailable")) + " ", 1), + s("a", { href: q }, o(a("打开设置", "Open settings")), 1) + ])) : ca("", !0) + ], 10, ba); + }; + } +}; +if (!document.getElementById("fastnet-card-v1-style")) { + const n = document.createElement("style"); + n.id = "fastnet-card-v1-style", n.textContent = ga, document.head.appendChild(n); +} +const Ka = 1; +export { + Ka as apiVersion, + Ha as default +}; diff --git a/luci-app-fastnet/root/www/luci-static/fastnet/quickstart/chunk.1932f6b1.js b/luci-app-fastnet/root/www/luci-static/fastnet/quickstart/chunk.1932f6b1.js new file mode 100644 index 00000000..88a548f6 --- /dev/null +++ b/luci-app-fastnet/root/www/luci-static/fastnet/quickstart/chunk.1932f6b1.js @@ -0,0 +1,120 @@ +var S = (p, d, t) => new Promise((_, o) => { + var u = (i) => { + try { + n(t.next(i)); + } catch (a) { + o(a); + } + }, f = (i) => { + try { + n(t.throw(i)); + } catch (a) { + o(a); + } + }, n = (i) => i.done ? _(i.value) : Promise.resolve(i.value).then(u, f); + n((t = t.apply(p, d)).next()); +}); +import { ref as v, computed as B, watch as H, onMounted as L, onUnmounted as O, openBlock as w, createElementBlock as g, createElementVNode as x, createCommentVNode as y, toDisplayString as b } from "/luci-static/quickstart/card-runtime-v1.js"; +function R(p, d = location) { + const t = new URL(p); + if (t.protocol !== "http:" || t.hostname !== d.hostname || t.pathname !== "/webui/" || t.username || t.password || t.hash) throw Error("invalid_direct_target"); + return { url: t.href, origin: t.origin, mixedContent: d.protocol === "https:" }; +} +const j = ".fn-web-dialog{width:100%;min-width:0;min-height:0;display:flex;flex-direction:column;background:var(--qs-card-panel);color:var(--qs-card-text)}.fn-web-frame{position:relative;flex:1;min-height:0}.fn-web-frame iframe{display:block;border:0;width:100%;height:100%;background:var(--qs-card-panel)}.fn-web-loading{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:24px;text-align:center;background:var(--qs-card-panel);color:var(--qs-card-muted)}.fn-web-loading strong{color:var(--qs-card-text);font-size:16px}.fn-web-loading p{font-size:12px;line-height:1.7;max-width:360px;margin:0}.fn-web-loading button{font:inherit;color:var(--qs-card-accent);border:1px solid var(--qs-card-line);background:var(--qs-card-soft);padding:10px 20px;border-radius:8px;cursor:pointer}.fn-web-loading a{color:var(--qs-card-accent);font-size:14px}.fn-web-footnote{font-size:10px;color:var(--qs-card-muted);line-height:1.6;margin:0!important;padding:9px 20px;border-top:1px solid var(--qs-card-line)}@media(max-width:600px){.fn-web-footnote{padding:8px 14px}}", U = { + class: "fn-web-dialog", + "data-fastnet-web-dialog": "" +}, W = { class: "fn-web-frame" }, A = ["src", "title"], D = ["role"], M = ["href"], V = { class: "fn-web-footnote" }, J = { + __name: "WebDialog", + props: ["context", "locale", "view"], + emits: ["close"], + setup(p, { emit: d }) { + if (!document.getElementById("fastnet-web-v1-style")) { + const e = document.createElement("style"); + e.id = "fastnet-web-v1-style", e.textContent = j, document.head.appendChild(e); + } + const t = p, _ = d, o = (e, r) => t.locale === "en" ? r : e, u = v(null), f = v(!1), n = v(!1), i = v(0), a = v(null), P = B(() => { + if (!a.value) return ""; + const e = new URL(a.value.url); + return e.searchParams.delete("embed"), e.searchParams.delete("theme"), e.href; + }); + let c, s, k = !1; + function z() { + clearTimeout(c), c = setTimeout(() => { + f.value || (n.value = !0); + }, 15e3); + } + function T() { + return S(this, null, function* () { + s == null || s.abort(), s = new AbortController(); + const e = s; + clearTimeout(c), a.value = null, f.value = !1, n.value = !1, i.value++, z(); + try { + const r = yield fetch("/cgi-bin/luci/admin/services/fastnet/embed?format=json&theme=" + t.context.theme.effective + (t.view === "history" ? "&view=history" : ""), { credentials: "same-origin", cache: "no-store", redirect: "error", signal: e.signal }); + if (!r.ok) throw Error("launch_unavailable"); + const l = R((yield r.json()).url); + if (k || e.signal.aborted) return; + a.value = l, l.mixedContent && clearTimeout(c); + } catch (r) { + !k && !e.signal.aborted && (clearTimeout(c), n.value = !0); + } + }); + } + function q() { + var e, r; + a.value && ((r = (e = u.value) == null ? void 0 : e.contentWindow) == null || r.postMessage({ type: "quickstart:theme", theme: t.context.theme.effective }, a.value.origin)); + } + function C(e) { + var r, l, h, N, F, E; + if (!(!a.value || e.origin !== a.value.origin || e.source !== ((r = u.value) == null ? void 0 : r.contentWindow))) { + if (((l = e.data) == null ? void 0 : l.type) === "fastnet:ready") + clearTimeout(c), f.value = !0, n.value = !1, q(); + else if (((h = e.data) == null ? void 0 : h.type) === "fastnet:close") _("close"); + else if (((N = e.data) == null ? void 0 : N.type) === "fastnet:focus") { + const m = (F = u.value) == null ? void 0 : F.closest("section.network-dialog"); + (E = (m == null ? void 0 : m.querySelector(e.data.backward ? "[data-network-dialog-close]" : "[data-network-dialog-open]")) || (m == null ? void 0 : m.querySelector("[data-network-dialog-close]"))) == null || E.focus(); + } + } + } + return H(() => t.context.theme.effective, q), L(() => { + window.addEventListener("message", C), T(); + }), O(() => { + k = !0, s == null || s.abort(), clearTimeout(c), window.removeEventListener("message", C); + }), (e, r) => { + var l, h; + return w(), g("div", U, [ + x("div", W, [ + a.value && !n.value && !a.value.mixedContent ? (w(), g("iframe", { + key: i.value, + ref_key: "frame", + ref: u, + src: a.value.url, + title: o("FastNet 完整测速界面", "FastNet speed test interface"), + referrerpolicy: "origin" + }, null, 8, A)) : y("", !0), + f.value ? y("", !0) : (w(), g("div", { + key: 1, + class: "fn-web-loading", + role: n.value ? "alert" : "status" + }, [ + x("strong", null, b((l = a.value) != null && l.mixedContent ? o("在新窗口中直接测速", "Test directly in a new window") : n.value ? o("FastNet 页面暂时无法加载", "FastNet could not be loaded") : o("正在打开 FastNet…", "Opening FastNet…")), 1), + x("p", null, b((h = a.value) != null && h.mixedContent ? o("当前 HTTPS 页面无法嵌入 HTTP 测速服务,请在新窗口打开 FastNet。", "This HTTPS page cannot embed the HTTP service. Open FastNet in a new window.") : n.value ? o("可以重试,或在新窗口中打开。请确认插件服务已启用。", "Retry or open in a new window. Check that the plugin service is enabled.") : o("打开页面不会自动测速;请在子窗口中点击开始。", "Opening the page does not start a test. Select Start inside the window.")), 1), + a.value && (n.value || a.value.mixedContent) ? (w(), g("a", { + key: 0, + href: P.value, + target: "_blank", + rel: "noopener noreferrer" + }, b(o("打开 FastNet ↗", "Open FastNet ↗")), 9, M)) : y("", !0), + n.value ? (w(), g("button", { + key: 1, + onClick: T + }, b(o("重新加载", "Reload")), 1)) : y("", !0) + ], 8, D)) + ]), + x("p", V, b(o("核心已接受的测速继续运行;关闭窗口将中断浏览器测量,已保存结果保留。", "Accepted core tests continue. Browser tests stop when closed; saved results remain.")), 1) + ]); + }; + } +}; +export { + J as default +}; diff --git a/luci-app-fastnet/root/www/luci-static/fastnet/quickstart/chunk.31ac0177.js b/luci-app-fastnet/root/www/luci-static/fastnet/quickstart/chunk.31ac0177.js new file mode 100644 index 00000000..c66d0d1e --- /dev/null +++ b/luci-app-fastnet/root/www/luci-static/fastnet/quickstart/chunk.31ac0177.js @@ -0,0 +1,41 @@ +import { openBlock as d, createElementBlock as i, createElementVNode as l, toDisplayString as s, Fragment as N, renderList as g, createTextVNode as h, unref as y, createCommentVNode as P } from "/luci-static/quickstart/card-runtime-v1.js"; +const k = { class: "qs-card-details" }, D = { key: 0 }, T = { + href: "/cgi-bin/luci/admin/services/fastnet/open", + target: "_blank", + rel: "noopener" +}, x = { + __name: "Details", + props: ["state", "locale", "stamp", "mbps"], + setup(c) { + const o = c, b = o.stamp, w = o.mbps, _ = ["download", "upload", "nat", "ipv6", "lanPing", "lanDownload", "lanUpload"], e = (a, t) => o.locale === "en" ? t : a, f = { download: e("下载", "Download"), upload: e("上传", "Upload"), nat: "NAT", ipv6: "IPv6", lanPing: e("内网延迟", "LAN latency"), lanDownload: e("内网下载", "LAN download"), lanUpload: e("内网上传", "LAN upload") }; + function p(a) { + var t, n, r, u; + return ((n = (t = o.state.summary) == null ? void 0 : t.latestResults) == null ? void 0 : n[a]) || ((u = (r = o.state.summary) == null ? void 0 : r.latest) == null ? void 0 : u[a]); + } + function A(a) { + const t = p(a); + return !t || t.status === "not_tested" ? e("未检测", "Not tested") : t.status === "error" ? e("检测失败", "Probe failed") : t.status === "unavailable" ? e("不可用", "Unavailable") : a === "lanPing" ? t.value.toFixed(1) + " ms" : /download|upload/i.test(a) ? w(t.value) + " Mbps" : t.value; + } + return (a, t) => (d(), i("div", k, [ + l("p", null, s(e("默认外网出口 · 路由器 → 互联网", "Default WAN route · Router → Internet")), 1), + l("dl", null, [ + (d(), i(N, null, g(_, (n) => { + var r, u, m, v; + return d(), i(N, { key: n }, [ + l("dt", null, s(f[n]), 1), + l("dd", null, [ + h(s(A(n)), 1), + l("small", null, s(y(b)((r = p(n)) == null ? void 0 : r.measuredAt, c.locale)), 1), + (v = (m = (u = c.state.summary) == null ? void 0 : u.lastTask) == null ? void 0 : m.issues) != null && v[n] ? (d(), i("span", D, s(e("本次探测失败,保留上次有效结果", "Latest probe failed; previous valid result retained")), 1)) : P("", !0) + ]) + ], 64); + }), 64)) + ]), + l("p", null, s(e("测速会使用外网上传和下载带宽。NAT 与 IPv6 分别反映各自检测时间的结果。", "Testing uses WAN upload and download bandwidth. NAT and IPv6 describe their own detection times.")), 1), + l("a", T, s(e("打开完整 FastNet", "Open full FastNet")), 1) + ])); + } +}; +export { + x as default +}; diff --git a/luci-app-fastnet/root/www/luci-static/fastnet/quickstart/provenance.json b/luci-app-fastnet/root/www/luci-static/fastnet/quickstart/provenance.json new file mode 100644 index 00000000..999fb8e7 --- /dev/null +++ b/luci-app-fastnet/root/www/luci-static/fastnet/quickstart/provenance.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "abi": "quickstart-card-v1", + "vue": "3.5.44", + "compiler": "3.5.44", + "version": "0.8.9-1", + "sourceCommit": "082bf1e42911b8fdfed7ac421518ee990952b304", + "sourceFiles": { + "quickstart-card/manifest.json": "0f8b3a0dc15803c27243288e97ebea098ba2894c26b836293ed656f9396104d1", + "quickstart-card/package-lock.json": "daaa5d8d53587115f2fb7127adb51cbfaa9c5d55906e598ab643e8b4b0fc3a6d", + "quickstart-card/package.json": "7683ee1fb095bbf9897941bc4bf4796c488545cc9f712a943870ded1cdf7fccb", + "quickstart-card/src/Card.vue": "792c0528eb22ad3e84db8f023b29b430ea5faf8b1638766176bf24ce05aa90f7", + "quickstart-card/src/Details.vue": "a94eb71ec6ac389f4c1d6e5ea82c546ed1be5e31da410a63563c78e8a233407c", + "quickstart-card/src/WebDialog.vue": "9248328cf1e8d2afd671a44ba6340e7d84ef7f32f101461471f9f1d2920517f4", + "quickstart-card/src/directFrame.js": "648cd8ed2f9f2188aee71475df1ee4f936df73fdc7ce3fa7de03f0ae2c8b2f1a", + "quickstart-card/src/index.js": "48c908c983f14c159bf50c1d2ab3c54824f04bc2ecdacc714691164dea974c75", + "quickstart-card/src/model.js": "0497552a2caadd0f3d1333f359cb6704a9e370e68ce71ed80c2d9090857124e3", + "quickstart-card/src/style.css": "818e49ac458ee245b2c27fd7a7d62bd7e2117e44a85bbb388dddd175b05fce66", + "quickstart-card/src/webStyle.css": "7bc8849ff35cce46a1aec88239905f307eb7ebe655d8d19c9c88d854018526b7", + "quickstart-card/vite.config.js": "7dda71bfe6923df40b4142608d38fa239da9e79e0b30ff3cd6d1747c49f657eb", + "scripts/build-quickstart-card.sh": "345645c1513398221c612f6f1c0da77b3e4c958a86a18ef9382236307a55e3e8", + "scripts/card-provenance.py": "2bbed5b4ad40fe94310bc6b9ba6f4d20fc7ed8ec675800b4ebcb5ed68d01a026" + }, + "sourceSha256": "ca7ed2ac012210d4be444c080306e27e77c02eab0a998dda7b5f911f712f4385", + "assets": { + "card.dfb0ce55.js": "ed9555d2ead731e603cce340c950c681d8dcb64f301c52ed5380bf8e7cd62664", + "chunk.1932f6b1.js": "af27ba0c26dff540b8fd4cf07eaa50cca447539f7cc3d7d29f3fb3c44bf0ffef", + "chunk.31ac0177.js": "c9d9ebd0ea447c7cee8743a912f63c744ff426a50eb1c5dc8c8321b86103d4f1" + } +} diff --git a/luci-app-linkeasefull-embed/Makefile b/luci-app-linkeasefull-embed/Makefile index a9818099..1d39bd03 100644 --- a/luci-app-linkeasefull-embed/Makefile +++ b/luci-app-linkeasefull-embed/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-linkeasefull-embed -PKG_VERSION:=1.2.0-r1 +PKG_VERSION:=1.2.0-r3 PKG_RELEASE:= PKG_BUILD_PARALLEL:=1 @@ -28,14 +28,29 @@ define Package/$(PKG_NAME)/description Register OpenWrt LuCI as a LinkEaseFull desktop module and provide embedded-mode assets. endef +define Package/$(PKG_NAME)/postinst +#!/bin/sh +# Compatibility entry for desktops which only scan the old directory. +root="$${IPKG_INSTROOT:-}" +legacy="$${root}/usr/share/linkeasefull/desktop-apps.d/15-openwrt-luci.json" +if [ ! -e "$${legacy}" ] && [ ! -L "$${legacy}" ]; then + mkdir -p "$${legacy%/*}" + ln -s /usr/share/linkeasefull/openwrt-luci/openwrt-luci.json "$${legacy}" || exit $$? +fi +exit 0 +endef + define Package/$(PKG_NAME)/prerm #!/bin/sh set -e root="$${IPKG_INSTROOT:-}" -registration="$${root}/usr/share/linkeasefull/desktop-apps.d/15-openwrt-luci.json" -if [ "$$(readlink "$${registration}" 2>/dev/null)" = "/usr/share/linkeasefull/openwrt-luci/openwrt-luci.json" ]; then - rm -f "$${registration}" -fi +for directory in /usr/share/linkease/apps.d /usr/share/linkeasefull/desktop-apps.d; do + registration="$${root}$${directory}/15-openwrt-luci.json" + if [ "$$(readlink "$${registration}" 2>/dev/null)" = "/usr/share/linkeasefull/openwrt-luci/openwrt-luci.json" ]; then + rm -f "$${registration}" + fi +done +rmdir "$${root}/usr/share/linkeasefull/desktop-apps.d" 2>/dev/null || true exit 0 endef @@ -46,13 +61,13 @@ define Build/Compile endef define Package/$(PKG_NAME)/install - $(INSTALL_DIR) $(1)/usr/share/linkeasefull/openwrt-luci $(1)/usr/share/linkeasefull/desktop-apps.d + $(INSTALL_DIR) $(1)/usr/share/linkeasefull/openwrt-luci $(1)/usr/share/linkease/apps.d $(INSTALL_DIR) $(1)/www/luci-static/linkeasefull-embed $(INSTALL_DATA) ./files/openwrt-luci/openwrt-luci.json $(1)/usr/share/linkeasefull/openwrt-luci/openwrt-luci.json $(INSTALL_DATA) ./files/openwrt-luci/openwrt.svg $(1)/usr/share/linkeasefull/openwrt-luci/openwrt.svg $(INSTALL_DATA) ./files/www/embed.css $(1)/www/luci-static/linkeasefull-embed/embed.css $(INSTALL_DATA) ./files/www/embed-prelude.js $(1)/www/luci-static/linkeasefull-embed/embed-prelude.js - ln -sf /usr/share/linkeasefull/openwrt-luci/openwrt-luci.json $(1)/usr/share/linkeasefull/desktop-apps.d/15-openwrt-luci.json + ln -sf /usr/share/linkeasefull/openwrt-luci/openwrt-luci.json $(1)/usr/share/linkease/apps.d/15-openwrt-luci.json endef $(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/luci-app-linkeasefull-embed/README.md b/luci-app-linkeasefull-embed/README.md index 6a062220..80395366 100644 --- a/luci-app-linkeasefull-embed/README.md +++ b/luci-app-linkeasefull-embed/README.md @@ -7,7 +7,7 @@ separate from the independently installable `linkeasefull` runtime package. This package installs: - `/usr/share/linkeasefull/openwrt-luci/openwrt-luci.json` -- `/usr/share/linkeasefull/desktop-apps.d/15-openwrt-luci.json` +- `/usr/share/linkease/apps.d/15-openwrt-luci.json` - `/www/luci-static/linkeasefull-embed/embed-prelude.js` - `/www/luci-static/linkeasefull-embed/embed.css` diff --git a/luci-lib-linkeaseauth/Makefile b/luci-lib-linkeaseauth/Makefile index 214d5434..4b13ea53 100644 --- a/luci-lib-linkeaseauth/Makefile +++ b/luci-lib-linkeaseauth/Makefile @@ -5,7 +5,7 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI shared integration for LinkEase apps LUCI_PKGARCH:=all -PKG_VERSION:=1.2.1-r1 +PKG_VERSION:=1.2.1-r3 PKG_RELEASE:= include $(TOPDIR)/feeds/luci/luci.mk diff --git a/luci-lib-linkeaseauth/tests/test_linkease_apps_openwrt.lua b/luci-lib-linkeaseauth/tests/test_linkease_apps_openwrt.lua index 552e99c8..41ed4b8a 100644 --- a/luci-lib-linkeaseauth/tests/test_linkease_apps_openwrt.lua +++ b/luci-lib-linkeaseauth/tests/test_linkease_apps_openwrt.lua @@ -25,7 +25,16 @@ local manifests = { basePath = "/apps/kspeeder/", externalOpen = { enabled = true, path = "/" } }, - desktop = { mode = "module", entry = "desktop-entry.js" }, + desktop = { + mode = "iframe", + target = { + scheme = "http", hostMode = "request-host", path = "/", + port = { + default = 5003, + keys = { uci = "istoreenhance.@istoreenhance[0].adminport" } + } + } + }, backend = { type = "http", portFromUci = "istoreenhance.@istoreenhance[0].adminport", defaultPort = 5003 @@ -155,4 +164,47 @@ equal(builtin.registered, true) equal(builtin.available, false) equal(builtin.reason, "entry_unsupported_and_external_disabled") +local scanned = {} +local default_deps = dependencies("fastnet.json", false) +default_deps.registration_dirs = nil +default_deps.fs.dir = function(directory) + table.insert(scanned, directory) + return function() return nil end +end +apps_openwrt.new(default_deps):resolve("missing-app") +equal(#scanned, 2, "default discovery must retain the old desktop fallback") +equal(scanned[1], "/usr/share/linkease/apps.d") +equal(scanned[2], "/usr/share/linkeasefull/desktop-apps.d") + +local function compatibility_lookup(current, legacy, explicit) + local deps = dependencies("fastnet.json", true) + deps.registration_dirs = explicit + deps.fs.dir = function(directory) + local value = directory == "/usr/share/linkease/apps.d" and current + or directory == "/usr/share/linkeasefull/desktop-apps.d" and legacy + local emitted = false + return function() + if emitted or not value then return nil end + emitted = true + return "17-fastnet.json" + end + end + local readfile = deps.fs.readfile + deps.fs.readfile = function(path) + if path == "/usr/share/linkease/apps.d/17-fastnet.json" then return current end + if path == "/usr/share/linkeasefull/desktop-apps.d/17-fastnet.json" then return legacy end + return readfile(path) + end + deps.json.parse = function(raw) + if raw == "new" then return { id = "fastnet", desktop = { mode = "module" }, standalone = { basePath = "/apps/fastnet/new/" } } end + if raw == "old" then return { id = "fastnet", desktop = { mode = "module" }, standalone = { basePath = "/apps/fastnet/old/" } } end + if raw == "state" then return { active = "gateway" } end + end + return apps_openwrt.new(deps):resolve("fastnet") +end +equal(compatibility_lookup("new", "old").url, "/apps/fastnet/new/", "canonical ID wins") +equal(compatibility_lookup(nil, "old").url, "/apps/fastnet/old/", "legacy-only app stays visible") +equal(compatibility_lookup("invalid", "old").url, "/apps/fastnet/old/", "invalid JSON must not hide legacy") +equal(compatibility_lookup(nil, "old", { "/explicit" }).registered, false, "explicit directories stay isolated") + print("linkease apps OpenWrt adapter tests passed")