From e33ed8f6fd4644077b7fbe80f73a77bdd2a1e0ab Mon Sep 17 00:00:00 2001 From: Bouteillepleine <76688492+Bouteillepleine@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:17:48 +0200 Subject: [PATCH] Update to v2.7 (versionCode 72) --- CHANGELOG.md | 44 +- module/customize.sh | 12 + module/module.prop | 4 +- module/post-fs-data.sh | 32 +- module/service.sh | 391 +++++++++++--- module/system.prop | 2 - module/utils.sh | 15 +- module/webroot/config.json | 4 + module/webroot/css/common.css | 239 +++++---- module/webroot/css/home.css | 51 +- module/webroot/css/results.css | 51 ++ module/webroot/css/settings.css | 70 ++- module/webroot/css/tools.css | 65 +++ module/webroot/icon.png | Bin 0 -> 38262 bytes module/webroot/icons/results.svg | 3 + module/webroot/icons/tools.svg | 7 + module/webroot/index.html | 22 +- module/webroot/js/bench.js | 124 +++++ module/webroot/js/common.js | 151 ++++-- module/webroot/js/home.js | 189 +++++-- module/webroot/js/logs.js | 2 +- module/webroot/js/navpill.js | 61 +++ module/webroot/js/results.js | 102 ++++ module/webroot/js/router.js | 13 +- module/webroot/js/settings.js | 131 ++++- module/webroot/js/tools.js | 783 +++++++++++++++++++++++++++++ module/webroot/pages/home.html | 16 + module/webroot/pages/results.html | 8 + module/webroot/pages/settings.html | 8 + module/webroot/pages/tools.html | 63 +++ 30 files changed, 2384 insertions(+), 279 deletions(-) delete mode 100644 module/system.prop create mode 100644 module/webroot/config.json create mode 100644 module/webroot/css/results.css create mode 100644 module/webroot/css/tools.css create mode 100644 module/webroot/icon.png create mode 100644 module/webroot/icons/results.svg create mode 100644 module/webroot/icons/tools.svg create mode 100644 module/webroot/js/bench.js create mode 100644 module/webroot/js/navpill.js create mode 100644 module/webroot/js/results.js create mode 100644 module/webroot/js/tools.js create mode 100644 module/webroot/pages/results.html create mode 100644 module/webroot/pages/tools.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 54e7be4..b699de9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,22 @@ -1. Fix multiq total_bands detection. -2. Update service.sh timings to prevent some race conditions. [OnePlus devices] -3. Create post-fs-data.d directory if it doesn't exist. -4. Add support for local tc binary which supports all qdics. -5. Fix qdisc reset in following cases: - - Toggle Airplane Mode. - - Toggle WiFi / Cellular. -6. Add qdisc monitor to prevent netd from changing qdisc. -7. Simplify fq_codel leaf queue for htb. -8. Add bbr3 detection in post-fs stage. -9. Enable full screen mode to prevent navigation bar from interfering. -10. Add pfifo_head_drop, pie, fq_pie and cake full support. -11. Optimize sysctl parameters for low latency, high throughput and stability. -12. Add newly introduced PLB sysctl param support for optimisations. -13. Reduce initrwnd max value to 20 based on RFC 6928. -14. Set tcp_mem dynamically. - -Note: -1. More qdisc tuning will be added in next version when I get more information from many users. -2. qdisc fine-tuning currently doesn't support custom mode. Might be added in future. -3. If your kernel supports bbr3, then please use fq / cake / fq_pie for best results. -4. Some kernels convert bbrv1 to bbrv3 and keep the name as bbr. Even in that case use fq / cake / fq_pie instead of other qdisc. +1. New Tools tab: Auto-Optimize (benchmarks every available congestion control, not a fixed whitelist), CC Duel A/B test, live BBR telemetry graph and a multi-server bufferbloat test. +2. New Results tab: every bufferbloat run is saved (connection type, cc, qdisc, signal, download, idle/loaded ping, grade) and survives upgrades. +3. Optional "Advanced TCP buffers" toggle: raise-only (never lowers ROM-tuned min/default values), bumps rmem/wmem ceilings to 16 MiB. +4. initcwnd/initrwnd and per-route congctl are now also applied to the per-interface route table — Android keeps the default route there, so the old main-table-only write never touched internet traffic. +5. Watchdog extended to a full drift guard: global cc, per-route congctl/initcwnd and buffer ceilings are guarded, not just the qdisc; re-asserts quietly (no ss -K) and logs what drifted. Root-anchored qdisc match — a foreign root with a matching leaf no longer passes. +6. Drift guard pauses while WebUI tests run, so Auto-Optimize / CC Duel probes are no longer reverted mid-measurement (pause is crash-safe, capped at 10 minutes). +7. Boot sysctl block made raise-only — no longer clobbers ROM-tuned tcp_rmem/tcp_wmem min/default; tcp_mtu_probing enabled. +8. Fix qdisc matching to use word boundaries (fq no longer false-matches fq_codel in apply checks and the watchdog). +9. Fix per-route congctl bbr/bbr3 prefix collision that left stale bbr3 pins on routes after switching to bbr. +10. Fix VoWiFi detection on OxygenOS/ColorOS (OPlus SystemUI pattern + AOSP fallback; previously every Wi-Fi join ate the full 20 s timeout). +11. Fix cellular signal reading: real dBm (RSRP/ssRsrp) from the active-data SIM's primary RAT instead of the dead CDMA level= that pinned dual-SIM devices at "poor". +12. Fix Manual Settings not applying on Wi-Fi (force-apply was gated behind the VoWiFi connect timer). +13. VPN-aware interface resolution: tun/ppp/wg map to the underlying wlan/rmnet interface. +14. Upgrades preserve all user state: markers, Advanced-buffers toggle, cake hints, active profile and test history. +15. Mount debugfs when absent (Android 11+) so bbr3 socket telemetry works. +16. Drop inert system.prop (set Android props nothing consumes; could contradict the live sysctls). +17. Fix Logs tab re-rendering every poll (visible flicker / scroll jump). +18. WebUI polish: themed dropdowns, animated nav pill, per-series lanes + 60 fps glide on the telemetry graph. + +Note: +1. If your kernel supports bbr3, then please use fq / cake / fq_pie for best results. +2. Some kernels convert bbrv1 to bbrv3 and keep the name as bbr. Even in that case use fq / cake / fq_pie instead of other qdisc. diff --git a/module/customize.sh b/module/customize.sh index 00f426a..1593570 100644 --- a/module/customize.sh +++ b/module/customize.sh @@ -122,6 +122,18 @@ if check_exists_anywhere "initcwnd"; then fi fi +# Preserve the remaining per-user state across upgrades. The wlan_*/rmnet_data_*/ +# kill_connections/initcwnd_initrwnd markers are carried over above, but the +# newer 2.7 state files were not, so every upgrade silently reset the Advanced +# TCP buffers toggle, the Smart-cake hints, the active profile label and wiped +# the whole Results-tab test history. +for m in adv_buffers cake_hint_wlan cake_hint_rmnet active_profile bench_results.jsonl; do + if [ -f "$MODULE_PATH/$m" ] && [ ! -f "$MODPATH/$m" ]; then + cp "$MODULE_PATH/$m" "$MODPATH/$m" + ui_print " [+] Preserved user state: $m" + fi +done + cp "$MODPATH/module.prop" "$MODPATH/module.prop.bak" chmod +x "$MODPATH/bin/tc" diff --git a/module/module.prop b/module/module.prop index aa3a574..497c016 100644 --- a/module/module.prop +++ b/module/module.prop @@ -1,7 +1,7 @@ id=tcp_optimiser name=TCP Optimiser -version=2.5 -versionCode=17 +version=2.7 +versionCode=72 author=fatalcoder524 description=TCP Optimisations & update tcp_cong_algo based on interface updateJson=https://raw.githubusercontent.com/fatalcoder524/TCP_Optimiser_Module/main/update.json diff --git a/module/post-fs-data.sh b/module/post-fs-data.sh index 31f3d77..40ae623 100644 --- a/module/post-fs-data.sh +++ b/module/post-fs-data.sh @@ -2,6 +2,11 @@ sleep 2 +# Mount debugfs (Android 11+ user builds leave it unmounted) so the WebUI +# telemetry card can read /sys/kernel/debug/tcp_bbr3/sockets. Idempotent. +mountpoint -q /sys/kernel/debug 2>/dev/null || \ + mount -t debugfs debugfs /sys/kernel/debug 2>/dev/null + AVAIL_CC="$(cat /proc/sys/net/ipv4/tcp_available_congestion_control)" # Check if BBR3 is available if echo "$AVAIL_CC" | grep -qw bbr3; then @@ -26,18 +31,31 @@ echo "fq_codel" > /proc/sys/net/core/default_qdisc 2>/dev/null echo 120 > /proc/sys/net/ipv4/tcp_pacing_ca_ratio 2>/dev/null echo 180 > /proc/sys/net/ipv4/tcp_pacing_ss_ratio 2>/dev/null echo 1 > /proc/sys/net/ipv4/tcp_window_scaling 2>/dev/null -echo "4096 2097152 16777216" > /proc/sys/net/ipv4/tcp_rmem 2>/dev/null -echo "4096 2097152 16777216" > /proc/sys/net/ipv4/tcp_wmem 2>/dev/null -echo 16777216 > /proc/sys/net/core/rmem_max 2>/dev/null -echo 16777216 > /proc/sys/net/core/wmem_max 2>/dev/null + +# Socket buffers: RAISE ONLY. The old code wrote a fixed "4096 2097152 16777216" +# over tcp_rmem/tcp_wmem, which replaces the ROM's tuned min & default (OxygenOS +# ships 524288 1048576 16777216) with a 2 MiB default on every socket. That is +# exactly what apply_tcp_buffers() in service.sh was written to avoid, so use the +# same raise-only rule here and let the "Advanced TCP buffers" toggle stay +# meaningful instead of being pre-empted by this block. +for f in /proc/sys/net/core/rmem_max /proc/sys/net/core/wmem_max; do + cur=$(cat "$f" 2>/dev/null) + [ -n "$cur" ] && [ "$cur" -lt 16777216 ] && echo 16777216 > "$f" 2>/dev/null +done +for f in /proc/sys/net/ipv4/tcp_rmem /proc/sys/net/ipv4/tcp_wmem \ + /proc/sys/net/ipv6/tcp_rmem /proc/sys/net/ipv6/tcp_wmem; do + set -- $(cat "$f" 2>/dev/null) + [ -n "$3" ] && [ "$3" -lt 16777216 ] && echo "$1 $2 16777216" > "$f" 2>/dev/null +done + echo 4096 > /proc/sys/net/ipv4/tcp_max_syn_backlog 2>/dev/null -echo 0 > /proc/sys/net/ipv4/tcp_mtu_probing 2>/dev/null +# PMTU probing: OxygenOS already ships 1; writing 0 here downgraded the ROM on +# every boot (and fought apply_tcp_buffers, which sets 1). +echo 1 > /proc/sys/net/ipv4/tcp_mtu_probing 2>/dev/null echo 16384 > /proc/sys/net/ipv4/tcp_notsent_lowat 2>/dev/null # IPv6 TCP tuning echo 1 > /proc/sys/net/ipv6/tcp_ecn 2>/dev/null -echo "4096 2097152 16777216" > /proc/sys/net/ipv6/tcp_rmem 2>/dev/null -echo "4096 2097152 16777216" > /proc/sys/net/ipv6/tcp_wmem 2>/dev/null # Extra settings for optimal stability echo 100000 > /proc/sys/net/core/netdev_max_backlog 2>/dev/null diff --git a/module/service.sh b/module/service.sh index 66d532c..d9af9a9 100644 --- a/module/service.sh +++ b/module/service.sh @@ -14,15 +14,40 @@ CURRENT_QDISC="" update_description() { local iface="$1" - local icon="⁉️" + local icon="⁉️" label="$iface" case "$iface" in Wi-Fi) icon="🛜" ;; Cellular) icon="📶" ;; + none) icon="✈️"; label="Offline" ;; esac - local desc="TCP Optimisations \& update tcp_cong_algo based on interface \| iface\: $iface $icon \| algo\: $CURRENT_ALGO \| qdisc\: $CURRENT_QDISC" - sed -i -e "s/^description=.*/description=$desc/" "$MODPATH/module.prop" + # live status shown on the module card in the KernelSU / SukiSU manager + local extra="" + if [ "$iface" = "Wi-Fi" ]; then + local wi=$(dumpsys wifi 2>/dev/null | grep -m1 'mWifiInfo SSID') + local spd=$(echo "$wi" | sed -n 's/.*Max Supported Tx Link speed: \([0-9]*\)Mbps.*/\1/p') + local rssi=$(echo "$wi" | sed -n 's/.*RSSI: \(-*[0-9][0-9]*\).*/\1/p') + [ -n "$spd" ] && extra="$extra · ${spd} Mbps" + [ -n "$rssi" ] && extra="$extra · ${rssi} dBm" + elif [ "$iface" = "Cellular" ]; then + # active-data SIM signal + RAT, same logic as the WebUI (getCellSignalDbm) + local celld csig crat csub cdbm + celld=$(dumpsys telephony.registry 2>/dev/null) + csig=$(echo "$celld" | awk '/mSignalStrength=SignalStrength/{s=$0} /mDataConnectionState=2/{print s; exit}') + [ -z "$csig" ] && csig=$(echo "$celld" | grep -m1 "mSignalStrength=SignalStrength") + crat=$(echo "$csig" | grep -oE 'primary=CellSignalStrength[A-Za-z]+' | head -1 | sed 's/.*Strength//') + [ -z "$crat" ] && crat=Lte + case "$crat" in Nr) label="5G" ;; Lte) label="4G" ;; Wcdma|Tdscdma) label="3G" ;; Gsm|Cdma) label="2G" ;; esac + csub=$(echo "$csig" | grep -oE "m$crat=CellSignalStrength$crat[^,]*") + cdbm=$(echo "$csub" | grep -oiE '(ss)?rsrp *= *-?[0-9]+' | grep -oE '\-?[0-9]+' | head -1) + case "$cdbm" in -*) extra="$extra · ${cdbm} dBm" ;; esac + fi + [ -f "$MODPATH/active_profile" ] && extra="$extra · $(cat "$MODPATH/active_profile")" + [ -f "$MODPATH/adv_buffers" ] && extra="$extra · +16M" + # sed uses | as delimiter so the cc/qdisc slash is safe; keep no | in $desc + local desc="TCP tuning · $label $icon · $CURRENT_ALGO/$CURRENT_QDISC$extra" + sed -i -e "s|^description=.*|description=$desc|" "$MODPATH/module.prop" } kill_tcp_connections() { @@ -46,24 +71,53 @@ set_max_initcwnd_initrwnd() { if [ "$maxInitrwndValue" -gt 20 ]; then maxInitrwndValue=20 fi - local applied - applied=0 - + local applied cmds4 cmds6 + applied=0; cmds4=""; cmds6="" + + # Build one batched script per address family instead of spawning a fresh + # `su` for every route (the read-only `route show` runs directly since the + # service is already root). This collapses ~14 /data/adb execs into 2, + # which keeps OxygenOS's kernel anti-tamper from tripping the "malicious + # app" popup when settings are (re)applied. + # Android policy routing keeps the operative routes - including `default` - + # in the per-interface table, while `main` only holds the on-link LAN + # route. Writing initcwnd/initrwnd to `main` alone therefore never touched + # internet traffic. Cover both tables, exactly like set_route_congctl. + while IFS= read -r line; do + [ -z "$line" ] && continue + case "$line" in unreachable*|throw*|blackhole*|prohibit*) continue ;; esac + cmds4="${cmds4}/system/bin/ip route change $line initcwnd 10 initrwnd $maxInitrwndValue 2>/dev/null +" + done </dev/null | grep "dev $active_iface") +EOF while IFS= read -r line; do [ -z "$line" ] && continue - run_as_su "/system/bin/ip route change $line initcwnd 10 initrwnd $maxInitrwndValue" - [ $? -eq 0 ] && applied=1 + case "$line" in unreachable*|throw*|blackhole*|prohibit*) continue ;; esac + cmds4="${cmds4}/system/bin/ip route change $line table $active_iface initcwnd 10 initrwnd $maxInitrwndValue 2>/dev/null +" done </dev/null) EOF + [ -n "$cmds4" ] && run_as_su "$cmds4" && applied=1 while IFS= read -r line; do [ -z "$line" ] && continue - run_as_su "/system/bin/ip -6 route change $line initcwnd 10 initrwnd $maxInitrwndValue" - [ $? -eq 0 ] && applied=1 + case "$line" in unreachable*|throw*|blackhole*|prohibit*) continue ;; esac + cmds6="${cmds6}/system/bin/ip -6 route change $line initcwnd 10 initrwnd $maxInitrwndValue 2>/dev/null +" + done </dev/null | grep "dev $active_iface") +EOF + while IFS= read -r line; do + [ -z "$line" ] && continue + case "$line" in unreachable*|throw*|blackhole*|prohibit*) continue ;; esac + cmds6="${cmds6}/system/bin/ip -6 route change $line table $active_iface initcwnd 10 initrwnd $maxInitrwndValue 2>/dev/null +" done </dev/null) EOF + [ -n "$cmds6" ] && run_as_su "$cmds6" && applied=1 if [ "$applied" -eq 1 ]; then log_print "Setting initcwnd = 10; initrwnd = $maxInitrwndValue!" @@ -83,6 +137,7 @@ set_qdisc() { local qdisc_args="$qdisc" local handle_flag="" + local cake_hint="" cake_bw="" cake_rtt="" chbw="" chrtt="" case "$qdisc" in fq) qdisc_args="fq pacing limit 2000 flow_limit 40 buckets 1024 initial_quantum 15000" ;; @@ -96,7 +151,19 @@ set_qdisc() { pfifo_head_drop) qdisc_args="pfifo_head_drop limit 2000" ;; bfifo) qdisc_args="bfifo limit 3145728" ;; pfifo_fast) qdisc_args="pfifo_fast" ;; - cake) qdisc_args="cake besteffort triple-isolate wash" ;; + cake) + # "Smart cake": if a bufferbloat test left a measured rate/RTT for this + # link, shape to it (engages the shaper + ack-filter) instead of running + # cake unlimited. Hint file "cake_hint_" holds " ". + cake_hint="$MODPATH/cake_hint_wlan" + case "$iface" in *rmnet*|*ccmni*) cake_hint="$MODPATH/cake_hint_rmnet" ;; esac + if [ -f "$cake_hint" ]; then + read chbw chrtt _ < "$cake_hint" + [ -n "$chbw" ] && [ "$chbw" -gt 0 ] 2>/dev/null && cake_bw="bandwidth ${chbw}mbit" + [ -n "$chrtt" ] && [ "$chrtt" -gt 0 ] 2>/dev/null && cake_rtt="rtt ${chrtt}ms" + fi + qdisc_args="cake $cake_bw $cake_rtt besteffort triple-isolate wash ack-filter" + ;; pie) qdisc_args="pie target 15ms tupdate 15ms alpha 2 beta 20 ecn" ;; fq_pie) qdisc_args="fq_pie limit 2000 target 15ms tupdate 15ms alpha 2 beta 20 ecn" ;; *) qdisc_args="$qdisc" ;; @@ -109,7 +176,11 @@ set_qdisc() { fi sleep 2 - local check_qdisc=$(get_active_root_qdisc "$iface" | grep "$qdisc") + # Match the qdisc name on a word boundary AND anchored to the root: a bare + # `grep "$qdisc"` reports success for "fq" when the link actually runs + # fq_codel (same trap for pfifo vs pfifo_fast), and an unanchored line match + # is satisfied by a leaf — e.g. OxygenOS's htb root with an fq_codel child. + local check_qdisc=$(get_active_root_qdisc "$iface" | grep -E "^qdisc ${qdisc} [0-9a-f]+: root") if [ -n "$check_qdisc" ]; then log_print "Applied qdisc: $qdisc ($iface)" sleep 0.1 @@ -155,6 +226,46 @@ set_qdisc() { fi } +# Pin the congestion control onto the routes of one interface (RTAX_CC_ALGO, +# `ip route ... congctl`). Unlike the global sysctl, a pinned route keeps its +# CC even while another interface is the default route — so wlan and rmnet +# can run different algorithms simultaneously. Android policy routing keeps +# the operative routes in the per-interface table (`table wlan0`), so target +# that first and the main table as a fallback. Batched into one `su` exec per +# family (same OxygenOS anti-tamper rationale as the initcwnd block above). +set_route_congctl() { + local iface="$1" + local algo="$2" + local cmds4 cmds6 + [ -z "$iface" ] && return + cmds4=""; cmds6="" + while IFS= read -r line; do + [ -z "$line" ] && continue + case "$line" in + *"congctl $algo "*|*"congctl $algo") continue ;; # already pinned + unreachable*|throw*|blackhole*|prohibit*) continue ;; + esac + cmds4="${cmds4}/system/bin/ip route change $line table $iface congctl $algo 2>/dev/null +" + done </dev/null) +EOF + while IFS= read -r line; do + [ -z "$line" ] && continue + case "$line" in + *"congctl $algo "*|*"congctl $algo") continue ;; + unreachable*|throw*|blackhole*|prohibit*) continue ;; + esac + cmds6="${cmds6}/system/bin/ip -6 route change $line table $iface congctl $algo 2>/dev/null +" + done </dev/null) +EOF + [ -n "$cmds4" ] && run_as_su "$cmds4" + [ -n "$cmds6" ] && run_as_su "$cmds6" + [ -n "$cmds4$cmds6" ] && log_print "Pinned congctl $algo on $iface routes" +} + set_congestion() { local algo="$1" local mode="$2" @@ -163,6 +274,7 @@ set_congestion() { log_print "Applied congestion control: $algo ($mode)" kill_tcp_connections CURRENT_ALGO=$algo + set_route_congctl "$(get_active_iface)" "$algo" update_description "$mode" else log_print "Unavailable algorithm: $algo" @@ -181,6 +293,32 @@ get_active_iface() { echo "$iface" } +# With a VPN up the default route points at tun0, but tuning tun0 is useless: +# the bottleneck is the physical link underneath, `ip route show table tun0` is +# empty so congctl pinning is a no-op, and get_wifi_freq finds no band. Resolve +# a tunnel to the physical interface that actually carries the traffic. +resolve_phys_iface() { + local iface="$1" cand st + case "$iface" in + tun*|ppp*|ipsec*|wg*) ;; + *) echo "$iface"; return ;; + esac + # Wi-Fi first: Android prefers it as the default transport whenever it is + # connected, and the cellular interfaces stay registered (with default routes + # in their tables) even while Wi-Fi carries the traffic. Skip the r_rmnet_* + # reverse-tether devices. Note rmnet links report operstate "unknown", never + # "up", so accept both. + for cand in $(ls /sys/class/net 2>/dev/null | grep -E '^wlan') \ + $(ls /sys/class/net 2>/dev/null | grep -E '^(rmnet_data|ccmni)'); do + st=$(cat "/sys/class/net/$cand/operstate" 2>/dev/null) + [ "$st" = "up" ] || [ "$st" = "unknown" ] || continue + ip route show table "$cand" 2>/dev/null | grep -q '^default' || continue + echo "$cand" + return + done + echo "$iface" +} + get_wifi_freq() { local iface="$1" iw dev "$iface" link 2>/dev/null | grep "freq:" | awk '{print $2}' @@ -201,6 +339,43 @@ extract_qdisc_from_path() { esac } +apply_tcp_buffers() { + # Optional "Advanced TCP buffers" toggle (adv_buffers marker). Lets BBR/CUBIC + # fill high-BDP Wi-Fi 6 / 5G links by RAISING the socket-buffer ceilings only. + # It never lowers a value: on ROMs that already tune these high (e.g. OxygenOS + # already ships 16 MiB core max, slow_start_after_idle=0, mtu_probing=1) it is + # a no-op; the tcp_rmem/tcp_wmem min & default are always preserved so a well + # tuned ROM is never degraded. Global sysctls; disabling reverts on reboot. + [ -f "$MODPATH/adv_buffers" ] || return 0 + local target=16777216 + local changed=0 + + # core ceilings: raise only + for f in /proc/sys/net/core/rmem_max /proc/sys/net/core/wmem_max; do + cur=$(cat "$f" 2>/dev/null) + [ -n "$cur" ] && [ "$cur" -lt "$target" ] && echo "$target" > "$f" 2>/dev/null && changed=1 + done + + # tcp_rmem / tcp_wmem: keep "min default", raise only the max + for f in /proc/sys/net/ipv4/tcp_rmem /proc/sys/net/ipv4/tcp_wmem; do + set -- $(cat "$f" 2>/dev/null) + [ -n "$3" ] && [ "$3" -lt "$target" ] && echo "$1 $2 $target" > "$f" 2>/dev/null && changed=1 + done + + # tolerate more packet reordering before cutting cwnd (helps on wide / + # aggregated Wi-Fi where frames arrive out of order) — raise only, default 300 + cur=$(cat /proc/sys/net/ipv4/tcp_max_reordering 2>/dev/null) + [ -n "$cur" ] && [ "$cur" -lt 1000 ] && echo 1000 > /proc/sys/net/ipv4/tcp_max_reordering 2>/dev/null && changed=1 + + # strict improvements (idempotent): keep cwnd across idle, probe PMTU + echo 0 > /proc/sys/net/ipv4/tcp_slow_start_after_idle 2>/dev/null + echo 1 > /proc/sys/net/ipv4/tcp_mtu_probing 2>/dev/null + # log only when something was actually raised — this also runs from the + # 30/60s drift guard, which would otherwise spam the (200-line) log + [ "$changed" -eq 1 ] && log_print "Advanced TCP buffers: raised socket-buffer ceilings to >=16MiB + tcp_max_reordering>=1000 (min/default preserved)" + return 0 +} + apply_wifi_settings() { local iface="$1" local applied=0 @@ -218,12 +393,12 @@ apply_wifi_settings() { set_tcp_pacing 250 350 fi fi - + for algo in $congestion_algorithms; do for filepath in "$MODPATH/wlan_${algo}_"*; do if [ -f "$filepath" ]; then set_congestion "$algo" "Wi-Fi" - + local qdisc=$(extract_qdisc_from_path "$filepath" "$algo") set_qdisc "$iface" "$qdisc" "Wi-Fi" @@ -235,6 +410,7 @@ apply_wifi_settings() { done done [ "$applied" -eq 0 ] && set_congestion cubic "Wi-Fi" && set_max_initcwnd_initrwnd "$iface" + apply_tcp_buffers return $applied } @@ -260,41 +436,51 @@ apply_cellular_settings() { done done [ "$applied" -eq 0 ] && set_congestion cubic "Cellular" && set_max_initcwnd_initrwnd "$iface" + apply_tcp_buffers return $applied } +# Drift guard. Before this module's apply cycle, OxygenOS owns the interface +# with its own state (on OP15: an `htb default 1` root qdisc, stock sysctl +# profile incl. 8.8M buffer maxes, bare route tables, cc at the kernel +# default) — and any later network event that re-applies parts of that stock +# state would previously stick, because nothing in the module converged back. +# So guard ALL of it, not just the qdisc: root qdisc, global cc, per-route +# congctl, initcwnd, buffer ceilings. Re-asserts are quiet (no ss -K — killing +# every TCP connection on a periodic guard would be destructive) and each +# drift is logged with what drifted, which doubles as forensics on when/what +# the OS touches. run_qdisc_watchdog() { local watch_iface="$1" local mode="$2" local sleep_interval="${3:-15}" local target_qdisc="" + local target_algo="" + local prefix="wlan" - if [ "$mode" = "Wi-Fi" ]; then - for algo in $congestion_algorithms; do - for filepath in "$MODPATH/wlan_${algo}_"*; do - if [ -f "$filepath" ]; then - target_qdisc=$(extract_qdisc_from_path "$filepath" "$algo") - break 2 - fi - done - done - elif [ "$mode" = "Cellular" ]; then - for algo in $congestion_algorithms; do - for filepath in "$MODPATH/rmnet_data_${algo}_"*; do - if [ -f "$filepath" ]; then - target_qdisc=$(extract_qdisc_from_path "$filepath" "$algo") - break 2 - fi - done + [ "$mode" = "Cellular" ] && prefix="rmnet_data" + for algo in $congestion_algorithms; do + for filepath in "$MODPATH/${prefix}_${algo}_"*; do + if [ -f "$filepath" ]; then + target_algo="$algo" + target_qdisc=$(extract_qdisc_from_path "$filepath" "$algo") + break 2 + fi done - fi + done + # No marker file => apply_{wifi,cellular}_settings applied no qdisc at all + # (it only falls back to cubic). Forcing htb/multiq here would install a + # qdisc the user never chose, so guard nothing instead. if [ -z "$target_qdisc" ]; then - if [ "$mode" = "Wi-Fi" ]; then target_qdisc="htb"; else target_qdisc="multiq"; fi - fi - - log_print "[WATCHDOG] Started monitoring $watch_iface for $target_qdisc..." + log_print "[WATCHDOG] No qdisc marker for $mode - nothing to guard on $watch_iface." + rm -f "$MODPATH/.qdisc_guard" + return 0 + fi + + log_print "[WATCHDOG] Guarding $watch_iface: qdisc=$target_qdisc cc=$target_algo" + local guard_paused=0 while true; do local link_state="" if [ -f "/sys/class/net/$watch_iface/operstate" ]; then @@ -303,17 +489,85 @@ run_qdisc_watchdog() { if [ "$link_state" = "down" ] || [ -z "$link_state" ]; then log_print "[WATCHDOG] Interface $watch_iface is inactive (state: $link_state). Stopping monitor." + rm -f "$MODPATH/.qdisc_guard" break fi + # heartbeat for the WebUI "Guarding" indicator (freshness = still alive); + # write-then-rename so a concurrent reader never sees a truncated file + echo "$watch_iface $target_qdisc" > "$MODPATH/.qdisc_guard.tmp" && \ + mv -f "$MODPATH/.qdisc_guard.tmp" "$MODPATH/.qdisc_guard" + + # WebUI probe runs (Auto-Optimize, CC duel) change cc/qdisc live ON + # PURPOSE; fighting them corrupts the measurements (seen live: the guard + # reverted cake->fq_codel and cubic->bbr mid-probe four times during an + # Auto-Optimize run). The WebUI drops .guard_pause for the duration; + # honor it, capped at 10 min so a crashed WebUI can never disable the + # guard permanently. + if [ -f "$MODPATH/.guard_pause" ]; then + local pt=$(stat -c '%Y' "$MODPATH/.guard_pause" 2>/dev/null) + local page=999999 + [ -n "$pt" ] && page=$(( $(date +%s) - pt )) + if [ "$page" -lt 600 ]; then + if [ "$guard_paused" -eq 0 ]; then + log_print "[WATCHDOG] Paused - WebUI test running" + guard_paused=1 + fi + sleep "$sleep_interval" + continue + fi + rm -f "$MODPATH/.guard_pause" + fi + if [ "$guard_paused" -eq 1 ]; then + log_print "[WATCHDOG] Resumed" + guard_paused=0 + fi + + local drift="" + + # 1. root qdisc — anchored to the root line; an fq_codel LEAF under a + # foreign htb root must not satisfy the check local current_status=$(get_active_root_qdisc "$watch_iface") - if ! echo "$current_status" | grep -q "$target_qdisc"; then - log_print "[!] Hijack detected on $watch_iface!" - log_print "[!] Current state: $(echo "$current_status" | head -n 1)" - log_print "[#] Re-applying $target_qdisc optimization..." - - set_qdisc "$watch_iface" "$target_qdisc" "$mode" - set_max_initcwnd_initrwnd "$watch_iface" + echo "$current_status" | grep -qE "^qdisc ${target_qdisc} [0-9a-f]+: root" || drift="qdisc" + + # 2. global congestion control + local cur_cc=$(cat /proc/sys/net/ipv4/tcp_congestion_control 2>/dev/null) + [ "$cur_cc" = "$target_algo" ] || drift="$drift cc($cur_cc)" + + # 3. route pinning — only judged when a default route exists (a missing + # default is a transient netd rebuild; re-pinning mid-rebuild thrashes) + local def_route=$(ip route show table "$watch_iface" 2>/dev/null | grep '^default') + if [ -n "$def_route" ]; then + case "$def_route" in + *"congctl $target_algo "*|*"congctl $target_algo") ;; + *) drift="$drift route" ;; + esac + if [ -f "$MODPATH/initcwnd_initrwnd" ]; then + case "$def_route" in + *"initcwnd "*) ;; + *) drift="$drift initcwnd" ;; + esac + fi + fi + + # 4. buffer ceilings (only meaningful when the toggle is on) + if [ -f "$MODPATH/adv_buffers" ]; then + set -- $(cat /proc/sys/net/ipv4/tcp_rmem 2>/dev/null) + [ -n "$3" ] && [ "$3" -lt 16777216 ] && drift="$drift buffers" + fi + + if [ -n "$drift" ]; then + log_print "[!] Drift on $watch_iface: $drift" + case "$drift" in *"qdisc"*) + log_print "[!] Root was: $(echo "$current_status" | head -n 1)" + set_qdisc "$watch_iface" "$target_qdisc" "$mode" + ;; esac + # quiet cc + route re-assert: no ss -K, no description rewrite + echo "$target_algo" > /proc/sys/net/ipv4/tcp_congestion_control 2>/dev/null + set_route_congctl "$watch_iface" "$target_algo" + set_max_initcwnd_initrwnd "$watch_iface" + apply_tcp_buffers + log_print "[#] Re-asserted qdisc=$target_qdisc cc=$target_algo on $watch_iface" fi sleep "$sleep_interval" done @@ -321,7 +575,9 @@ run_qdisc_watchdog() { set_tcp_mem() { local mem_total_kb=$(awk '/MemTotal/{print $2}' /proc/meminfo) - local mem_total_pages=$((mem_total_kb * 1024 / 4096)) + # pages = KB*1024/4096 == KB/4. Divide first: KB*1024 overflows 32-bit shell + # arithmetic on high-RAM devices (e.g. 16 GB -> ~15.8e9) and wraps negative. + local mem_total_pages=$((mem_total_kb / 4)) # cap tcp_mem at ~6-8% of total RAM for "pressure", ~10% for "max" local low=$((mem_total_pages * 4 / 100)) @@ -340,18 +596,28 @@ echo "fq_codel" > /proc/sys/net/core/default_qdisc 2>/dev/null echo 120 > /proc/sys/net/ipv4/tcp_pacing_ca_ratio 2>/dev/null echo 180 > /proc/sys/net/ipv4/tcp_pacing_ss_ratio 2>/dev/null echo 1 > /proc/sys/net/ipv4/tcp_window_scaling 2>/dev/null -echo "4096 2097152 16777216" > /proc/sys/net/ipv4/tcp_rmem 2>/dev/null -echo "4096 2097152 16777216" > /proc/sys/net/ipv4/tcp_wmem 2>/dev/null -echo 16777216 > /proc/sys/net/core/rmem_max 2>/dev/null -echo 16777216 > /proc/sys/net/core/wmem_max 2>/dev/null + +# Socket buffers: RAISE ONLY, same rule as apply_tcp_buffers(). The old fixed +# "4096 2097152 16777216" replaced the ROM's tuned min & default (OxygenOS ships +# 524288 1048576 16777216) with a 2 MiB default per socket, and pre-empted the +# "Advanced TCP buffers" toggle by putting the ceilings at 16 MiB unconditionally. +for f in /proc/sys/net/core/rmem_max /proc/sys/net/core/wmem_max; do + cur=$(cat "$f" 2>/dev/null) + [ -n "$cur" ] && [ "$cur" -lt 16777216 ] && echo 16777216 > "$f" 2>/dev/null +done +for f in /proc/sys/net/ipv4/tcp_rmem /proc/sys/net/ipv4/tcp_wmem \ + /proc/sys/net/ipv6/tcp_rmem /proc/sys/net/ipv6/tcp_wmem; do + set -- $(cat "$f" 2>/dev/null) + [ -n "$3" ] && [ "$3" -lt 16777216 ] && echo "$1 $2 16777216" > "$f" 2>/dev/null +done + echo 4096 > /proc/sys/net/ipv4/tcp_max_syn_backlog 2>/dev/null -echo 0 > /proc/sys/net/ipv4/tcp_mtu_probing 2>/dev/null +# PMTU probing: OxygenOS already ships 1; writing 0 downgraded the ROM every boot. +echo 1 > /proc/sys/net/ipv4/tcp_mtu_probing 2>/dev/null echo 16384 > /proc/sys/net/ipv4/tcp_notsent_lowat 2>/dev/null # IPv6 TCP tuning echo 1 > /proc/sys/net/ipv6/tcp_ecn 2>/dev/null -echo "4096 2097152 16777216" > /proc/sys/net/ipv6/tcp_rmem 2>/dev/null -echo "4096 2097152 16777216" > /proc/sys/net/ipv6/tcp_wmem 2>/dev/null # Extra settings for optimal stability echo 100000 > /proc/sys/net/core/netdev_max_backlog 2>/dev/null @@ -397,11 +663,14 @@ current_time=0 while true; do iface=$(get_active_iface) + iface=$(resolve_phys_iface "$iface") new_mode="none" case "$iface" in - wlan*|tun*) new_mode="Wi-Fi" ;; + wlan*) new_mode="Wi-Fi" ;; *rmnet*|*ccmni*) new_mode="Cellular" ;; + # unresolved tunnel (no physical link found): keep the old Wi-Fi default + tun*|ppp*|ipsec*|wg*) new_mode="Wi-Fi" ;; *) new_mode="none" ;; esac @@ -414,9 +683,17 @@ while true; do fi if [ "$new_mode" = "Wi-Fi" ]; then - # Start waiting for VoWiFi - vowifi_pending=1 - vowifi_start_time="$current_time" + if [ "$new_mode" = "$last_mode" ]; then + # force_apply on an already-connected Wi-Fi: apply now (skip VoWiFi wait) + vowifi_pending=0 + apply_wifi_settings "$iface" + run_qdisc_watchdog "$iface" "Wi-Fi" "30" & + WATCHDOG_PID=$! + else + # fresh Wi-Fi connection: wait for VoWiFi to settle before applying + vowifi_pending=1 + vowifi_start_time="$current_time" + fi elif [ "$new_mode" = "Cellular" ]; then vowifi_pending=0 apply_cellular_settings "$iface" diff --git a/module/system.prop b/module/system.prop deleted file mode 100644 index 03cea83..0000000 --- a/module/system.prop +++ /dev/null @@ -1,2 +0,0 @@ -net.ipv4.tcp_congestion_control=bbr -net.core.default_qdisc=fq diff --git a/module/utils.sh b/module/utils.sh index 124c4f6..33b96bd 100644 --- a/module/utils.sh +++ b/module/utils.sh @@ -26,10 +26,8 @@ log_print() { } run_as_su() { - local cmd="$*" - su -c "$cmd" - local status=$? - return $status + # already root; run directly instead of re-spawning su + eval "$*" } run_tc() { @@ -40,7 +38,14 @@ run_tc() { get_wifi_calling_state() { rm -f "$DUMPSYS_TMP_FILE" dumpsys activity service SystemUIService > "$DUMPSYS_TMP_FILE" 2>/dev/null - grep -qEm 1 "slot='vowifi'.*visible user=.*" "$DUMPSYS_TMP_FILE" + # OxygenOS never prints the AOSP `slot='vowifi' ... visible user=` string, so + # the old pattern never matched and every fresh Wi-Fi join sat out the full + # VOWIFI_CONNECT_TIME timeout. Mirror the OxygenOS status-bar icon instead - + # `NN:(vowifi) holder=StatusBarIconHolder(... visible=true)` / IMS + # `vowifiState=true` - the same rule the WebUI uses (common.js + # get_wifi_calling_state). The AOSP pattern is kept as a fallback for + # non-OPlus ROMs. + grep -qEm 1 "\(vowifi\).*visible=true|vowifiState=true|slot='vowifi'.*visible user=" "$DUMPSYS_TMP_FILE" local status=$? rm -f "$DUMPSYS_TMP_FILE" # echo's result: 0 = true (VoWiFi active), 1 = false diff --git a/module/webroot/config.json b/module/webroot/config.json new file mode 100644 index 0000000..d2c8194 --- /dev/null +++ b/module/webroot/config.json @@ -0,0 +1,4 @@ +{ + "title": "TCP Optimiser", + "icon": "icon.png" +} diff --git a/module/webroot/css/common.css b/module/webroot/css/common.css index 133758d..adfdc50 100644 --- a/module/webroot/css/common.css +++ b/module/webroot/css/common.css @@ -1,127 +1,180 @@ :root { - --bg-color: #121212; - --card-bg: #1e1e1e; - --primary: #bb86fc; - --text: #e1e1e1; - --text-secondary: #a0a0a0; - --border-radius: 12px; + /* modern dark palette */ + --bg-0: #0c0e13; + --bg-1: #12151c; + --card-bg: #161a23; + --card-bg-2: #1b2030; + --border: rgba(255,255,255,0.07); + --primary: #8b7cff; + --primary-2: #6c5ce7; + --accent-grad: linear-gradient(135deg, #8b7cff 0%, #5c8cff 100%); + --success: #2ecc82; + --danger: #ff5c6c; + --warn: #ffb84d; + --text: #eef0f5; + --text-secondary: #9aa2b1; + --border-radius: 16px; + --radius-sm: 10px; --spacing: 16px; - --footer-height: 60px; + --footer-height: 66px; + --shadow: 0 8px 24px rgba(0,0,0,0.35); + --ease: cubic-bezier(.22,.61,.36,1); } -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} +* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - background-color: var(--bg-color); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--text); min-height: 100vh; position: relative; padding: var(--spacing); - padding-top: var(--window-inset-top); - padding-bottom: var(--window-inset-bottom); + padding-top: calc(var(--window-inset-top, 24px) + 4px); + padding-bottom: calc(var(--footer-height) + var(--window-inset-bottom, 16px) + 8px); + background: + radial-gradient(1200px 600px at 100% -10%, rgba(139,124,255,.14), transparent 60%), + radial-gradient(900px 500px at -10% 10%, rgba(92,140,255,.10), transparent 55%), + var(--bg-0); + background-attachment: fixed; } -.container { - max-width: 600px; - margin: 0 auto; - display: grid; - gap: var(--spacing); -} +.container { max-width: 620px; margin: 0 auto; display: grid; gap: var(--spacing); } +/* cards */ .card { - background-color: var(--card-bg); + background: linear-gradient(180deg, var(--card-bg-2), var(--card-bg)); + border: 1px solid var(--border); border-radius: var(--border-radius); padding: var(--spacing); - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); -} - -.card-header { - margin-bottom: 16px; + box-shadow: var(--shadow); + transition: transform .25s var(--ease), border-color .25s var(--ease); } +.card:active { transform: scale(.995); } +.card-header { margin-bottom: 14px; display: flex; align-items: center; gap: 8px; } .card-header h3 { - font-size: 1.1rem; - font-weight: 600; - color: var(--text); + font-size: 1.05rem; font-weight: 700; letter-spacing: .2px; color: var(--text); + position: relative; padding-left: 12px; } - -.footer-nav { - position: fixed; - bottom: 0; - left: 0; - right: 0; - height: var(--footer-height); - background-color: var(--card-bg); - display: flex; - justify-content: space-around; - align-items: center; - box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2); +.card-header h3::before { + content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%); + width: 4px; height: 16px; border-radius: 4px; background: var(--accent-grad); } -.nav-item { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-decoration: none; - color: var(--text-secondary); - height: 100%; - transition: color 0.3s; +/* hero module card */ +.module-info { position: relative; overflow: hidden; text-align: center; } +.module-info::before { + content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: var(--accent-grad); } - -.nav-item.active { - color: var(--primary); +.module-content { display: flex; flex-direction: column; align-items: center; gap: 2px; } +.module-name { + font-size: 1.4rem; font-weight: 800; letter-spacing: .3px; + background: var(--accent-grad); -webkit-background-clip: text; background-clip: text; + -webkit-text-fill-color: transparent; } - -.nav-icon { - font-size: 1.4rem; - margin-bottom: 4px; +.version { color: var(--text-secondary); font-size: .82rem; margin: 2px 0 10px; } +.link { + display: inline-block; color: var(--primary); text-decoration: none; + padding: 6px 14px; margin: 3px; font-size: .8rem; font-weight: 600; + border: 1px solid var(--border); border-radius: 999px; background: rgba(255,255,255,.03); + transition: all .2s var(--ease); } +.link:active { background: rgba(139,124,255,.15); border-color: var(--primary); } -.nav-text { - font-size: 0.8rem; +/* info rows */ +.info-row { + display: flex; justify-content: space-between; align-items: center; gap: 12px; + padding: 12px 2px; border-bottom: 1px solid var(--border); } - -.module-content { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: 0; +.info-row:last-child { border-bottom: 0; } +.label { color: var(--text-secondary); font-size: .9rem; } +.value { + font-size: .92rem; font-weight: 700; text-align: right; + padding: 4px 10px; border-radius: 999px; + /* subtle glossy relief: top highlight + soft drop shadow = raised chip */ + background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.02)); + box-shadow: inset 0 1px 0 rgba(255,255,255,.10), 0 1px 2px rgba(0,0,0,.35); } -.module-name { - font-size: 1.25rem; - font-weight: 600; +/* bottom nav - glass + active pill */ +.footer-nav { + /* floating capsule: fully rounded, detached from the edges, glass + lift */ + position: fixed; left: 16px; right: 16px; bottom: calc(var(--window-inset-bottom, 16px) + 14px); + height: var(--footer-height); + background: rgba(20,24,33,.72); + -webkit-backdrop-filter: blur(18px) saturate(160%); + backdrop-filter: blur(18px) saturate(160%); + border: 1px solid var(--border); + border-radius: 999px; + display: flex; justify-content: space-around; align-items: center; + box-shadow: 0 14px 34px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.08); +} +/* Single active indicator. The pill is exactly one tab slot wide (20%), so it can + be moved with translateX(N * 100%) — pure percentages, no calc(). These rules + position it without JS; navpill.js additionally drives the move with + element.animate() so the slide survives the main-thread freeze that the + blocking ksu.exec calls cause right after a tab tap. The overshooting + cubic-bezier gives the bounce. */ +.nav-pill { + position: absolute; top: 0; left: 0; width: 20%; height: 100%; + transform: translateX(0); + transition: transform .45s cubic-bezier(.34, 1.56, .64, 1); + pointer-events: none; z-index: 0; +} +/* the visible lozenge: inset inside the slot so its corners stay symmetric + (the previous transparent-border trick gave elliptical, ugly corners) */ +.nav-pill::before { + content: ''; position: absolute; inset: 11% 6px; + border-radius: 18px; background: rgba(139,124,255,.14); +} +.nav-item:nth-of-type(1).active ~ .nav-pill { transform: translateX(0); } +.nav-item:nth-of-type(2).active ~ .nav-pill { transform: translateX(100%); } +.nav-item:nth-of-type(3).active ~ .nav-pill { transform: translateX(200%); } +.nav-item:nth-of-type(4).active ~ .nav-pill { transform: translateX(300%); } +.nav-item:nth-of-type(5).active ~ .nav-pill { transform: translateX(400%); } +.nav-item { + position: relative; z-index: 1; + flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; + text-decoration: none; color: var(--text-secondary); height: 78%; margin: 0; + border-radius: 18px; transition: color .25s var(--ease); +} +.nav-item.active { color: var(--primary); } +/* The icons are inline in index.html painted with currentColor — deliberately + NOT CSS masks. A mask that fails to re-raster mid-animation paints the element as + a solid square (the flicker we chased), and the data: URI variant was invisible + entirely because the WebUI host's CSP blocks it. Inline SVG has neither failure + mode: there is no external resource to load. The relief now comes from the bevel + drop-shadows rather than a gradient fill. */ +.nav-icon { + display: block; flex: none; + width: 24px; height: 24px; margin-bottom: 3px; + color: #d7dae4; + /* bevel: bright top edge + dark bottom edge for a raised, relief feel */ + filter: drop-shadow(0 -0.5px 0 rgba(255,255,255,.35)) drop-shadow(0 1px 1.2px rgba(0,0,0,.55)); + transition: transform .25s var(--ease), filter .25s var(--ease), color .25s var(--ease); +} +.nav-item.active .nav-icon { + /* active: purple that lights up */ + color: var(--primary); + filter: drop-shadow(0 -0.5px 0 rgba(255,255,255,.5)) drop-shadow(0 0 7px rgba(139,124,255,.85)); + transform: translateY(-1px) scale(1.12); } +.nav-text { font-size: .74rem; font-weight: 600; } -.version { - color: var(--text-secondary); - font-size: 0.9rem; - margin-top: 4px; - margin-bottom: 8px; -} +/* page transitions */ +.page { opacity: 0; transform: translateY(8px); transition: opacity .18s var(--ease), transform .18s var(--ease); will-change: opacity, transform; } +.page.active { opacity: 1; transform: translateY(0); } +.hidden { display: none !important; } -.link { - color: var(--primary); - text-decoration: none; - padding: 2px 0; - font-size: 0.8rem; -} +/* helper status chips (optional; JS can add .on/.off to a .value) */ +.value.on { color: #0c1a12; background: linear-gradient(180deg, #43e79a, #22b06e); box-shadow: inset 0 1px 0 rgba(255,255,255,.35), 0 1px 3px rgba(0,0,0,.32); } +.value.off { color: #1a0c0e; background: linear-gradient(180deg, #ff7480, #e2434f); box-shadow: inset 0 1px 0 rgba(255,255,255,.30), 0 1px 3px rgba(0,0,0,.32); } +.value.warn{ color: #1a1206; background: linear-gradient(180deg, #ffc766, #eba336); box-shadow: inset 0 1px 0 rgba(255,255,255,.35), 0 1px 3px rgba(0,0,0,.32); } -.page { - opacity: 0; - transform: translateY(20px); - transition: all 0.4s ease; +.banner { + border: 1px solid rgba(139,124,255,.35); + background: linear-gradient(180deg, rgba(139,124,255,.14), rgba(139,124,255,.05)); + color: var(--text); border-radius: var(--radius-sm); + padding: 12px 14px; font-size: .86rem; line-height: 1.5; } - -.page.active { - opacity: 1; - transform: translateY(0); -} \ No newline at end of file +.banner b { color: var(--primary); } diff --git a/module/webroot/css/home.css b/module/webroot/css/home.css index 0bb6ede..f32f50c 100644 --- a/module/webroot/css/home.css +++ b/module/webroot/css/home.css @@ -1,13 +1,60 @@ .info-row { display: flex; justify-content: space-between; - padding: 8px 0; + align-items: center; + gap: 12px; + padding: 11px 0; +} + +/* hairline divider between visible rows (hidden rows collapse, so the line + always lands between the two rows the user actually sees) */ +.system-info .info-row + .info-row { + border-top: 1px solid var(--border); } .label { color: var(--text-secondary); + font-size: .82rem; + letter-spacing: .2px; } .info-row.hidden { display: none; -} \ No newline at end of file +} + +/* polished header for the taller status card */ +.system-info .card-header { + margin-bottom: 6px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} + +/* signal strength bars */ +.signal-wrap { + display: inline-flex; + align-items: center; + gap: 8px; +} +#signal_text { + color: var(--text-secondary); + font-size: .82rem; +} +.bars { + display: inline-flex; + align-items: flex-end; + gap: 3px; + height: 16px; +} +.bars i { + width: 4px; + border-radius: 2px; + background: rgba(255, 255, 255, .16); + transition: background .25s ease; +} +.bars i:nth-child(1) { height: 6px; } +.bars i:nth-child(2) { height: 9px; } +.bars i:nth-child(3) { height: 12px; } +.bars i:nth-child(4) { height: 16px; } +.bars.lvl-hi i.on { background: var(--success, #2ecc82); } +.bars.lvl-mid i.on { background: var(--warn, #ffb84d); } +.bars.lvl-low i.on { background: var(--danger, #ff5c6c); } \ No newline at end of file diff --git a/module/webroot/css/results.css b/module/webroot/css/results.css new file mode 100644 index 0000000..9bd5e83 --- /dev/null +++ b/module/webroot/css/results.css @@ -0,0 +1,51 @@ +.results-head { justify-content: space-between; } +.hint { color: var(--text-secondary); font-size: .84rem; margin: -4px 0 14px; } + +.clear-btn { + background: transparent; border: 1px solid var(--border); color: var(--text-secondary); + border-radius: 10px; padding: 6px 12px; font-size: .8rem; font-weight: 600; cursor: pointer; + transition: transform .15s var(--ease), color .2s, border-color .2s; +} +.clear-btn:active { transform: scale(.95); color: var(--danger); border-color: var(--danger); } + +.res-card { + background: rgba(255,255,255,.03); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 14px; margin-bottom: 12px; +} +.res-card:last-child { margin-bottom: 0; } + +.res-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; } +.res-del { + flex: none; width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; + background: transparent; border: 1px solid var(--border); color: var(--text-secondary); + font-size: .8rem; line-height: 1; cursor: pointer; padding: 0; + transition: transform .15s var(--ease), color .2s, border-color .2s, background .2s; +} +.res-del:active { transform: scale(.9); color: var(--danger); border-color: var(--danger); background: rgba(255,92,108,.12); } +.conn-badge { font-size: .76rem; font-weight: 700; padding: 3px 11px; border-radius: 999px; white-space: nowrap; } +.conn-wifi { background: rgba(91,140,255,.18); color: #7ea6ff; } +.conn-5g { background: rgba(46,204,130,.18); color: #3fd18a; } +.conn-4g { background: rgba(255,184,77,.18); color: #ffb84d; } +.conn-other { background: rgba(255,255,255,.08); color: var(--text-secondary); } +.res-date { color: var(--text-secondary); font-size: .76rem; flex: 1; } +.res-grade { + width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; + font-weight: 800; font-size: 1rem; color: #0c1013; flex: none; +} +.res-grade.grade-A { background: #2ecc82; } .res-grade.grade-B { background: #7ed957; } +.res-grade.grade-C { background: #ffd24d; } .res-grade.grade-D { background: #ffab4d; } +.res-grade.grade-F { background: #ff5c6c; } + +.res-metrics { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; text-align: center; margin-bottom: 12px; } +.metric .m-val { font-size: 1.45rem; font-weight: 800; color: var(--text); line-height: 1; } +.metric .m-unit { font-size: .68rem; font-weight: 600; color: var(--text-secondary); } +.metric .m-label { font-size: .66rem; text-transform: uppercase; letter-spacing: .5px; color: var(--text-secondary); margin-top: 5px; } + +.res-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; } +.res-chips { display: flex; gap: 6px; flex-wrap: wrap; } +.chip { font-size: .74rem; font-weight: 600; padding: 3px 9px; border-radius: 999px; background: rgba(139,124,255,.15); color: var(--primary); } +.chip-dim { background: rgba(255,255,255,.06); color: var(--text-secondary); } +.chip-profile { background: var(--accent-grad); color: #fff; font-weight: 700; } +.res-bloat { font-size: .78rem; font-weight: 700; color: var(--text-secondary); } + +.results-empty { color: var(--text-secondary); font-size: .9rem; text-align: center; padding: 22px 8px; line-height: 1.6; } diff --git a/module/webroot/css/settings.css b/module/webroot/css/settings.css index cbc4b58..bb30181 100644 --- a/module/webroot/css/settings.css +++ b/module/webroot/css/settings.css @@ -14,30 +14,84 @@ flex: 1; } -#kill-connections-label, #initcwnd-initrwnd-label { +#kill-connections-label, #initcwnd-initrwnd-label, #adv-buffers-label { color: var(--text-secondary); } .dropdown { - width: 180px; - padding: 8px 12px; - background-color: var(--card-bg); - border: 1px solid #3d3d3d; - border-radius: 6px; + width: 190px; + max-width: 56%; + padding: 10px 38px 10px 14px; + background-color: var(--card-bg-2); + /* custom chevron (appearance:none removes the native one) */ + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239aa2b1' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 13px center; + border: 1px solid var(--border); + border-radius: var(--radius-sm); color: var(--text); font-size: 0.9rem; + font-weight: 600; appearance: none; + -webkit-appearance: none; cursor: pointer; + text-overflow: ellipsis; + transition: border-color .2s var(--ease), background-color .2s var(--ease), box-shadow .2s var(--ease); +} + +.dropdown:hover { + border-color: rgba(139,124,255,0.5); + background-color: var(--card-bg); } .dropdown:focus { outline: none; border-color: var(--primary); -} + box-shadow: 0 0 0 3px rgba(139,124,255,0.18); +} + +/* ---- custom dropdown (replaces the native diff --git a/module/webroot/icons/tools.svg b/module/webroot/icons/tools.svg new file mode 100644 index 0000000..95b8a6c --- /dev/null +++ b/module/webroot/icons/tools.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/module/webroot/index.html b/module/webroot/index.html index aece626..a9b9d46 100644 --- a/module/webroot/index.html +++ b/module/webroot/index.html @@ -6,8 +6,9 @@ TCP Optimiser - - + + + @@ -18,7 +19,7 @@
TCP Optimiser
Loading Version...
GitHub Repository - Telegram + Telegram @@ -29,17 +30,26 @@ \ No newline at end of file diff --git a/module/webroot/js/bench.js b/module/webroot/js/bench.js new file mode 100644 index 0000000..7d427fa --- /dev/null +++ b/module/webroot/js/bench.js @@ -0,0 +1,124 @@ +import { exec } from './kernelsu.js'; +import { + get_active_iface, get_active_algorithm, get_active_qdisc, + getWifiSignalDbm, getCellSignalDbm +} from './common.js'; + +// One benchmark record per line (JSON-lines). Persists in the module dir so it +// survives reboots. Appended on each bufferbloat run, read by the Results tab. +const STORE = '/data/adb/modules/tcp_optimiser/bench_results.jsonl'; + +// Primary cellular radio -> a friendly network label ("5G", "4G", ...). +// Uses the active-data SIM's primary CellSignalStrength class (NSA 5G that keeps +// an LTE primary will read as 4G — acceptable for a benchmark label). +export async function getCellRat () { + const script = ` +CELLD=$(dumpsys telephony.registry 2>/dev/null) +CSIG=$(echo "$CELLD" | awk '/mSignalStrength=SignalStrength/{s=$0} /mDataConnectionState=2/{print s; exit}') +[ -z "$CSIG" ] && CSIG=$(echo "$CELLD" | grep -m1 "mSignalStrength=SignalStrength") +echo "$CSIG" | grep -oE 'primary=CellSignalStrength[A-Za-z]+' | head -1 | sed 's/.*Strength//' +`; + try { + const { stdout } = await exec(script); + const rat = stdout.trim(); + const map = { Nr: '5G', Lte: '4G', Wcdma: '3G', Tdscdma: '3G', Gsm: '2G', Cdma: '2G' }; + return map[rat] || (rat || 'Cellular'); + } catch (error) { + console.error('Error reading cell RAT:', error); + return 'Cellular'; + } +} + +// Snapshot of what a test was run under: interface, connection label, the active +// congestion control + qdisc, and signal (dBm). +export async function getBenchContext () { + const iface = await get_active_iface(); + const iftype = /^wlan|^tun/.test(iface) ? 'wifi' + : (/rmnet|ccmni/.test(iface) ? 'cell' : 'none'); + + const cc = await get_active_algorithm(); + const qdisc = await get_active_qdisc(iface); + + let conn = iface || 'Unknown'; + let signal = ''; + if (iftype === 'wifi') { + conn = 'Wi-Fi'; + signal = await getWifiSignalDbm(iface); + } else if (iftype === 'cell') { + conn = await getCellRat(); + signal = await getCellSignalDbm(); + } + + return { + iface: iface && iface !== 'error' ? iface : '', + iftype, conn, + cc: cc && cc !== 'error' ? cc : '', + qdisc: qdisc && qdisc !== 'error' ? qdisc : '', + signal + }; +} + +// Stable per-record key. New records carry an `id`; legacy records (saved before +// the id field existed) fall back to their `ts`. Used to target a single card for +// deletion. +export function recordKey (r) { + return String(r.id != null ? r.id : r.ts); +} + +// Append one record, base64-encoded so no field value can break the shell redirect. +export async function saveBenchResult (record) { + try { + if (record.id == null) { + record.id = (record.ts != null ? record.ts : Date.now()) + '-' + + Math.random().toString(36).slice(2, 8); + } + const json = JSON.stringify(record); + const b64 = btoa(unescape(encodeURIComponent(json + '\n'))); + await exec(`echo '${b64}' | base64 -d >> ${STORE}`); + return true; + } catch (error) { + console.error('Error saving benchmark result:', error); + return false; + } +} + +// Delete a single record by key (see recordKey), keeping the rest. The surviving +// records are rewritten to the store base64-encoded so no JSON character can break +// the shell redirect (the append path can assume clean tokens; a bulk rewrite can't). +export async function deleteBenchResult (key) { + try { + const kept = (await readBenchResults()).filter(r => recordKey(r) !== String(key)); + const body = kept.map(r => JSON.stringify(r)).join('\n'); + const payload = body ? body + '\n' : ''; + const b64 = btoa(unescape(encodeURIComponent(payload))); + await exec(`echo '${b64}' | base64 -d > ${STORE}`); + return true; + } catch (error) { + console.error('Error deleting benchmark result:', error); + return false; + } +} + +export async function readBenchResults () { + try { + const { stdout } = await exec(`cat ${STORE} 2>/dev/null || true`); + return stdout.split('\n') + .map(l => l.trim()) + .filter(l => l) + .map(l => { try { return JSON.parse(l); } catch { return null; } }) + .filter(Boolean); + } catch (error) { + console.error('Error reading benchmark results:', error); + return []; + } +} + +export async function clearBenchResults () { + try { + await exec(`rm -f ${STORE}`); + return true; + } catch (error) { + console.error('Error clearing benchmark results:', error); + return false; + } +} diff --git a/module/webroot/js/common.js b/module/webroot/js/common.js index a04d946..ef32279 100644 --- a/module/webroot/js/common.js +++ b/module/webroot/js/common.js @@ -15,7 +15,7 @@ async function readModuleProp () { return acc; }, {}); - moduleInfo["moduleDir"] = `/data/adb/modules/${moduleInfo.id}`; + moduleInfo["moduleDir"] = `/data/adb/modules/tcp_optimiser`; return moduleInfo; } catch (error) { @@ -24,16 +24,20 @@ async function readModuleProp () { export async function updateModuleInformation () { try { - router_state.moduleInformation = JSON.parse(moduleInfo()); - if(router_state.moduleInformation != {}) { - router_state.moduleInformation = await readModuleProp(); - } + // ksu.moduleInfo() isn't available/consistent across every WebUI host + // (WebUI X, KSUWebUI, …). Read module.prop via exec — which always works + // where exec does — and treat ksu.moduleInfo() as a best-effort seed only, + // so its absence can't block moduleDir/version from being set. + let seed = {}; + try { seed = JSON.parse(moduleInfo()) || {}; } catch (e) { /* host lacks moduleInfo() */ } + const prop = await readModuleProp(); + router_state.moduleInformation = prop || seed || {}; }catch (error) { console.error('Error updating module info:', error); - toast("Error fetching module info."); } - var versionStr = router_state.moduleInformation.version ? 'v' + router_state.moduleInformation.version : ''; - var versionCodeStr = router_state.moduleInformation.versionCode ? router_state.moduleInformation.versionCode : ''; + const info = router_state.moduleInformation || {}; + var versionStr = info.version ? 'v' + info.version : ''; + var versionCodeStr = info.versionCode ? info.versionCode : ''; var finalVersionStr = versionStr != '' && versionCodeStr != '' ? `${versionStr} (${versionCodeStr})` : "module.prop might be corrupted!" document.getElementById('version').textContent = finalVersionStr; } @@ -75,16 +79,25 @@ export async function get_active_algorithm () { export async function get_active_qdisc(iface) { try { const moduleInfo = await readModuleProp(); - const tcBin = moduleInfo && moduleInfo.moduleDir ? `${moduleInfo.moduleDir}/bin/tc` : 'tc'; - const { stdout: qdiscRaw } = await exec(`${tcBin} qdisc show dev ${iface} 2>/dev/null`); - if (qdiscRaw) { - // Extract the very first word after 'qdisc ' (e.g., 'fq', 'netem', 'fq_codel') - const match = qdiscRaw.trim().match(/^qdisc\s+(\S+)/); - if (match && match[1]) { - return match[1]; - } + const dir = moduleInfo && moduleInfo.moduleDir ? moduleInfo.moduleDir : '/data/adb/modules/tcp_optimiser'; + // Report the CONFIGURED qdisc from the marker file "__" + // (e.g. wlan_bbr3_fq_codel), NOT the live `tc` root. Android wraps the real + // qdisc in a classful root (prio/mq/multiq) with the chosen qdisc on each + // band, so the live root is ambiguous (shows "prio"/"multiq"/"fq"…). The + // marker is exactly what the user picked in Settings / via a profile. + const prefix = /^wlan|^tun/.test(iface) ? 'wlan' + : (/rmnet|ccmni/.test(iface) ? 'rmnet_data' : 'wlan'); + const { stdout } = await exec(`ls ${dir}/${prefix}_* 2>/dev/null | xargs -n1 basename | head -1`); + const marker = (stdout || '').trim(); + if (marker) { + // strip "_", then the cc token (up to the first underscore); the + // remainder is the qdisc (which may itself contain underscores, e.g. + // fq_codel / pfifo_fast). + const rest = marker.replace(new RegExp('^' + prefix + '_'), ''); + const us = rest.indexOf('_'); + if (us >= 0) return rest.slice(us + 1); } - + return null; } catch (error) { console.error('Error fetching active qdisc: ', error); @@ -108,22 +121,15 @@ export async function getInitcwndInitrwndValue () { }; export async function get_wifi_calling_state() { - const DUMPSYS_TMP_FILE = `${router_state.moduleInformation.moduleDir}/dumpsys.tmp`; - try { - // Run dumpsys and save to file - await exec(`dumpsys activity service SystemUIService > "${DUMPSYS_TMP_FILE}" 2>/dev/null`); - - // Check for VoWiFi pattern - const { stdout: returnCode } = await exec(` - grep -qE "slot=\'vowifi\'.*visible user=.*" "${DUMPSYS_TMP_FILE}" && echo $?` + // Mirror the OxygenOS status-bar VoWiFi icon: it shows exactly when a call is + // actively carried over Wi-Fi (icon holder visible=true / IMS vowifiState=true). + // Reads Inactive on VoLTE, matching what the status bar displays. The AOSP + // `slot='vowifi'` string is never printed on OxygenOS, hence the OPlus format. + const { stdout } = await exec( + `dumpsys activity service SystemUIService 2>/dev/null | grep -iE "\\(vowifi\\) holder=|vowifiState="` ); - - // Clean up temp file - await exec(`rm -f "${DUMPSYS_TMP_FILE}"`); - - // Return true if match found (exit code 0) - return returnCode.trim() === '0'; + return /\(vowifi\)[^\n]*visible=true/.test(stdout) || /vowifiState=true/.test(stdout); } catch (error) { console.error('Error checking VoWiFi state:', error); addLog('Error checking VoWiFi state.'); @@ -131,14 +137,89 @@ export async function get_wifi_calling_state() { } } +/* ---------- per-metric Home-status helpers ---------- */ + +// Is bbr3 in the kernel's available congestion-control list? +export async function getBbr3Available () { + try { + const { stdout } = await exec(`cat /proc/sys/net/ipv4/tcp_available_congestion_control`); + return /(^|\s)bbr3(\s|$)/.test(stdout.trim()); + } catch (error) { + console.error('Error checking bbr3 availability:', error); + return false; + } +} + +// Wi-Fi link speed (Mbps, digits only). +export async function getWifiLinkSpeed () { + try { + const { stdout } = await exec(`dumpsys wifi 2>/dev/null | grep -oiE 'Link ?speed[:= ]+[0-9]+' | grep -oE '[0-9]+' | head -1`); + return stdout.trim(); + } catch (error) { + console.error('Error fetching link speed:', error); + return ''; + } +} + +// Wi-Fi signal in dBm (RSSI) via iw. +export async function getWifiSignalDbm (iface) { + try { + const { stdout } = await exec(`iw dev ${iface} link 2>/dev/null | grep -i signal | grep -oE '\\-?[0-9]+' | head -1`); + return stdout.trim(); + } catch (error) { + console.error('Error fetching Wi-Fi signal:', error); + return ''; + } +} + +// Cellular signal in dBm. The first rsrp/level in telephony.registry belongs to +// dead RATs (mCdma, always 0), so read the ACTIVE data SIM's block +// (mDataConnectionState=2), find which radio is primary (Lte/Nr/...), then its +// rsrp/ssRsrp. Keep only negative (real) values — ignore the 2147483647 sentinel. +// NB: shell vars use $VAR (never ${VAR}) so this JS template literal stays valid. +export async function getCellSignalDbm () { + const script = ` +CELLD=$(dumpsys telephony.registry 2>/dev/null) +CSIG=$(echo "$CELLD" | awk '/mSignalStrength=SignalStrength/{s=$0} /mDataConnectionState=2/{print s; exit}') +[ -z "$CSIG" ] && CSIG=$(echo "$CELLD" | grep -m1 "mSignalStrength=SignalStrength") +CRAT=$(echo "$CSIG" | grep -oE 'primary=CellSignalStrength[A-Za-z]+' | head -1 | sed 's/.*Strength//') +[ -z "$CRAT" ] && CRAT=Lte +CSUB=$(echo "$CSIG" | grep -oE "m$CRAT=CellSignalStrength$CRAT[^,]*") +CDBM=$(echo "$CSUB" | grep -oiE '(ss)?rsrp *= *-?[0-9]+' | grep -oE '\\-?[0-9]+' | head -1) +case "$CDBM" in -*) : ;; *) CDBM="" ;; esac +echo "$CDBM" +`; + try { + const { stdout } = await exec(script); + return stdout.trim(); + } catch (error) { + console.error('Error fetching cellular signal:', error); + return ''; + } +} + +// qdisc-guard heartbeat: only "guarding" if the marker was touched in the last 2 min. +export async function getQdiscGuard () { + const G = `/data/adb/modules/tcp_optimiser/.qdisc_guard`; + try { + const { stdout } = await exec(`[ -n "$(find "${G}" -mmin -2 2>/dev/null)" ] && cat "${G}" 2>/dev/null || true`); + return stdout.trim(); + } catch (error) { + console.error('Error fetching qdisc guard:', error); + return ''; + } +} + export async function fetchIsConfigFile (file_name) { + // Module info may not be ready yet (or the host lacks moduleInfo); bail + // quietly so we don't deref null and spam an error toast every poll. + const dir = router_state.moduleInformation && router_state.moduleInformation.moduleDir; + if (!dir) return false; try { - const { stdout: output } = await exec(`[ -f "${router_state.moduleInformation.moduleDir}/${file_name}" ] && echo "exist" || echo ""`); + const { stdout: output } = await exec(`[ -f "${dir}/${file_name}" ] && echo "exist" || echo ""`); return output == "exist"; } catch (error) { - console.error('Error fetching kill connections status: ', error); - addLog('Error fetching kill connections status.'); - toast("Error fetching kill connections status."); + console.error('Error fetching config file: ', error); return false; } }; diff --git a/module/webroot/js/home.js b/module/webroot/js/home.js index 171f172..b739c7c 100644 --- a/module/webroot/js/home.js +++ b/module/webroot/js/home.js @@ -1,6 +1,17 @@ -import { exec, toast } from './kernelsu.js'; -import { get_active_iface, get_active_algorithm, get_active_qdisc, getInitcwndInitrwndValue, get_wifi_calling_state, getModuleActiveState } from './common.js'; +import { toast } from './kernelsu.js'; +import { + getModuleActiveState, get_active_iface, get_active_algorithm, get_active_qdisc, + getInitcwndInitrwndValue, get_wifi_calling_state, getBbr3Available, + getWifiLinkSpeed, getWifiSignalDbm, getCellSignalDbm, getQdiscGuard +} from './common.js'; import router_state from './router.js'; +import { addLog } from './logs.js'; + +function _chip(el, cls) { + if (!el) return; + el.classList.remove('on', 'off', 'warn'); + if (cls) el.classList.add(cls); +} export async function updateModuleStatus () { var module_status = "Loading Module Status...⌛"; @@ -10,19 +21,61 @@ export async function updateModuleStatus () { var active_qdisc = "Unknown ⁉️"; var wifi_calling_state = "Unknown ⁉️"; var active_InitcwndInitrwndValue = []; + var link_speed = ""; + var bbr3_avail = false; + var signal = { text: '', level: -1 }; + var guard = { on: false, text: '' }; try { - module_status = (await getModuleActiveState()) == true ? "Enabled ✅" : "Disabled ❌"; - active_iface = await get_active_iface(); - active_iface = active_iface ? active_iface : "None"; - active_iface_type = active_iface.match("rmnet") || active_iface.match("ccmni") ? "Cellular 📶" : active_iface.startsWith("wlan") || active_iface.startsWith("tun") ? "Wi-Fi 🛜" : "Unknown ⁉️"; - active_algorithm = await get_active_algorithm(); - var active_qdisc_tmp = await get_active_qdisc(active_iface); - active_qdisc = active_qdisc_tmp ? active_qdisc_tmp : active_qdisc; + module_status = (await getModuleActiveState()) ? "Enabled ✅" : "Disabled ❌"; + + const iface = await get_active_iface(); + active_iface = (iface && iface !== 'error') ? iface : "None"; + const iftype = /^wlan|^tun/.test(iface) ? 'wifi' + : (/rmnet|ccmni/.test(iface) ? 'cell' : 'none'); + active_iface_type = iftype === 'wifi' ? "Wi-Fi 🛜" + : (iftype === 'cell' ? "Cellular 📶" : "Unknown ⁉️"); + + const algo = await get_active_algorithm(); + if (algo && algo !== 'error') active_algorithm = algo; + + const qdisc = await get_active_qdisc(iface); + if (qdisc && qdisc !== 'error') active_qdisc = qdisc; + active_InitcwndInitrwndValue = await getInitcwndInitrwndValue(); - if(active_iface_type == "Wi-Fi 🛜") - { - wifi_calling_state = await get_wifi_calling_state() ? "Active ": "Inactive "; + + bbr3_avail = await getBbr3Available(); + + // signal -> {text, level 0..4}: both Wi-Fi and cellular report dBm. + // Wi-Fi uses RSSI thresholds; cellular uses typical LTE/NR RSRP thresholds. + if (iftype === 'wifi') { + wifi_calling_state = (await get_wifi_calling_state()) ? "Active " : "Inactive "; + const ls = await getWifiLinkSpeed(); + link_speed = ls ? ls + ' Mbps' : ''; + + const dbm = parseInt(await getWifiSignalDbm(iface), 10); + if (!isNaN(dbm)) { + let lvl = 0; + if (dbm >= -55) lvl = 4; else if (dbm >= -65) lvl = 3; else if (dbm >= -73) lvl = 2; else if (dbm >= -82) lvl = 1; else lvl = 0; + signal = { text: dbm + ' dBm', level: lvl }; + } + } else if (iftype === 'cell') { + const dbm = parseInt(await getCellSignalDbm(), 10); + if (!isNaN(dbm)) { + let lvl = 0; + if (dbm >= -80) lvl = 4; else if (dbm >= -90) lvl = 3; else if (dbm >= -100) lvl = 2; else if (dbm >= -110) lvl = 1; else lvl = 0; + signal = { text: dbm + ' dBm', level: lvl }; + } + } + + // qdisc guard heartbeat: getQdiscGuard() already applied the 2-min + // freshness check, so a non-empty value means it's actively guarding + const guardRaw = await getQdiscGuard(); + if (guardRaw) { + const parts = guardRaw.split(/\s+/); + const gIface = parts[0] || ''; + const gQdisc = parts[1] || ''; + guard = (iface && gIface && gIface !== iface) ? { on: false, text: '' } : { on: true, text: gQdisc }; } } catch (error) { console.error('Error updating status: ', error); @@ -36,6 +89,82 @@ export async function updateModuleStatus () { router_state.homePageParams.active_qdisc = active_qdisc; router_state.homePageParams.active_InitcwndInitrwndValue = active_InitcwndInitrwndValue; router_state.homePageParams.wifi_calling_state = wifi_calling_state; + router_state.homePageParams.link_speed = link_speed; + router_state.homePageParams.bbr3_avail = bbr3_avail; + router_state.homePageParams.signal = signal; + router_state.homePageParams.guard = guard; + } +} + +/* color the status chips + qdisc banner + link speed (called from updateHomeUI) */ +function enhanceHomeUI () { + const p = router_state.homePageParams; + + const ms = document.getElementById('module_status_value'); + _chip(ms, p.module_status && p.module_status.includes('Enabled') ? 'on' + : (p.module_status && p.module_status.includes('Disabled') ? 'off' : null)); + + const qd = document.getElementById('qdisc_value'); + if (qd) { + const t = (p.active_qdisc || '').toLowerCase(); + _chip(qd, /fq|cake|codel|pie/.test(t) ? 'on' + : (t && !t.includes('loading') && !t.includes('unknown') ? 'warn' : null)); + } + const cc = document.getElementById('tcp_cong_value'); + if (cc) _chip(cc, /bbr/.test((p.active_algorithm || '').toLowerCase()) ? 'on' : null); + + const banner = document.getElementById('qdisc-banner'); + if (banner) { + const q = (p.active_qdisc || '').toLowerCase(); + if (p.bbr3_avail && q && !/fq|cake|pie/.test(q) && !q.includes('loading') && !q.includes('unknown')) { + banner.textContent = '💡 bbr3 detected - for best results use cake, fq or fq_pie. See Tools > Profiles.'; + banner.classList.remove('hidden'); + } else { + banner.classList.add('hidden'); + } + } + + const lsDiv = document.getElementById('linkspeed_div'); + const lsVal = document.getElementById('linkspeed_value'); + if (lsDiv && lsVal) { + if (p.active_iface_type === 'Wi-Fi 🛜' && p.link_speed) { + lsVal.textContent = p.link_speed; + lsDiv.classList.remove('hidden'); + } else { + lsDiv.classList.add('hidden'); + } + } + + // signal strength: label + 4 bars filled to level + const sigDiv = document.getElementById('signal_div'); + const sigTxt = document.getElementById('signal_text'); + const sigBars = document.getElementById('signal_bars'); + const sig = p.signal || { level: -1 }; + if (sigDiv && sigBars) { + if (sig.level >= 0) { + if (sigTxt) sigTxt.textContent = sig.text || ''; + const bars = sigBars.querySelectorAll('i'); + bars.forEach((b, i) => b.classList.toggle('on', i < sig.level)); + sigBars.classList.remove('lvl-low', 'lvl-mid', 'lvl-hi'); + sigBars.classList.add(sig.level <= 1 ? 'lvl-low' : (sig.level <= 2 ? 'lvl-mid' : 'lvl-hi')); + sigDiv.classList.remove('hidden'); + } else { + sigDiv.classList.add('hidden'); + } + } + + // qdisc guard heartbeat + const gDiv = document.getElementById('guard_div'); + const gVal = document.getElementById('guard_value'); + const g = p.guard || { on: false }; + if (gDiv && gVal) { + if (g.on) { + gVal.textContent = 'Guarding ✅' + (g.text ? ' (' + g.text + ')' : ''); + _chip(gVal, 'on'); + gDiv.classList.remove('hidden'); + } else { + gDiv.classList.add('hidden'); + } } } @@ -48,70 +177,72 @@ export function updateHomeUI () { const ifaceValDiv = document.getElementById('active_iface_div'); const tcpCongValDiv = document.getElementById('tcp_cong_div'); const qdiscValDiv = document.getElementById('qdisc_div'); - + document.getElementById('active_iface_type_value').textContent = router_state.homePageParams.active_iface_type; document.getElementById('active_iface_value').textContent = router_state.homePageParams.active_iface; document.getElementById('tcp_cong_value').textContent = router_state.homePageParams.active_algorithm; document.getElementById('qdisc_value').textContent = router_state.homePageParams.active_qdisc; - + if (ifaceTypeDiv?.classList.contains('hidden')) ifaceTypeDiv.classList.remove('hidden'); - + if (ifaceValDiv?.classList.contains('hidden')) ifaceValDiv.classList.remove('hidden'); - + if (tcpCongValDiv?.classList.contains('hidden')) tcpCongValDiv.classList.remove('hidden'); - + if (qdiscValDiv?.classList.contains('hidden')) qdiscValDiv.classList.remove('hidden'); - + const wifiCallingDiv = document.getElementById('wifi_calling_value_div'); const wifiCallingSpan = document.getElementById('wifi_calling_value'); - + if(router_state.homePageParams.active_iface_type == "Wi-Fi 🛜") { if (wifiCallingDiv?.classList.contains('hidden')) wifiCallingDiv.classList.remove('hidden'); - + wifiCallingSpan.textContent = router_state.homePageParams.wifi_calling_state; } else { - if (wifiCallingDiv.classList.contains('hidden')) + // Not on Wi-Fi: Wi-Fi Calling is meaningless on cellular, so hide the + // row entirely instead of showing an "Unknown" placeholder. + if (!wifiCallingDiv.classList.contains('hidden')) wifiCallingDiv.classList.add('hidden'); - wifiCallingSpan.textContent = "Unknown ⁉️"; } - + const initcwndDiv = document.getElementById('initcwnd_value_div'); const initrwndDiv = document.getElementById('initrwnd_value_div'); const initcwndSpan = document.getElementById('initcwnd_value'); const initrwndSpan = document.getElementById('initrwnd_value'); - + const values = router_state.homePageParams.active_InitcwndInitrwndValue; const isLoading = values.length < 2 && router_state.settingsPageParams.initcwndInitrwnd; - + if(values.length == 2 || isLoading) { if (initcwndDiv?.classList.contains('hidden')) initcwndDiv.classList.remove('hidden'); - + if (initrwndDiv?.classList.contains('hidden')) initrwndDiv.classList.remove('hidden'); - + initcwndSpan.textContent = values.length == 2 ? values[0] : "Loading initcwnd value..."; initrwndSpan.textContent = values.length == 2 ? values[1] : "Loading initrwnd value..."; } else { - // No data and not loading → hide the section if (initcwndDiv && !initcwndDiv.classList.contains('hidden')) initcwndDiv.classList.add('hidden'); - + if (initrwndDiv && !initrwndDiv.classList.contains('hidden')) initrwndDiv.classList.add('hidden'); } } + // color chips + qdisc banner + link speed + enhanceHomeUI(); } } diff --git a/module/webroot/js/logs.js b/module/webroot/js/logs.js index ae8a7d2..784b8bc 100644 --- a/module/webroot/js/logs.js +++ b/module/webroot/js/logs.js @@ -55,7 +55,7 @@ export function updateLogsUI () { router_state.logsList.forEach(log => { addLogToScreen(log); }); - router_state.logsList.length = prev_logs_count; + prev_logs_count = router_state.logsList.length; } } } diff --git a/module/webroot/js/navpill.js b/module/webroot/js/navpill.js new file mode 100644 index 0000000..2cb50c8 --- /dev/null +++ b/module/webroot/js/navpill.js @@ -0,0 +1,61 @@ +/* Sliding active-tab indicator. + * + * Standalone classic script (NOT a module): loaded from index.html with a ?v= + * cache-buster so it is always fresh, and it imports nothing — which keeps + * router.js a single instance (a second copy would fork router_state). + * + * Why not a plain CSS transition: tapping a tab immediately runs loadPage(), + * which fetches the page, imports its module and calls initX() — and those make + * blocking ksu.exec calls that freeze the JS/main thread for a while (the same + * reason spinners stop mid-test). A CSS transition started right before that + * freeze gets its whole duration eaten while nothing can be painted, so the pill + * appears to jump. Web Animations API transform animations run on the compositor, + * so they keep playing through main-thread jank. + */ +(function () { + var DURATION = 450; + var EASING = 'cubic-bezier(.34, 1.56, .64, 1)'; // slight overshoot = bounce + + function init() { + var nav = document.querySelector('.footer-nav'); + if (!nav) return; + var pill = nav.querySelector('.nav-pill'); + var items = [].slice.call(nav.querySelectorAll('.nav-item')); + if (!pill || !items.length) return; + + function activeIndex() { + for (var i = 0; i < items.length; i++) { + if (items[i].classList.contains('active')) return i; + } + return -1; + } + + var current = activeIndex(); + if (current < 0) current = 0; + pill.style.transform = 'translateX(' + (current * 100) + '%)'; + + function place() { + var idx = activeIndex(); + if (idx < 0 || idx === current) return; + var from = 'translateX(' + (current * 100) + '%)'; + var to = 'translateX(' + (idx * 100) + '%)'; + current = idx; + pill.style.transform = to; // final state + if (pill.animate) { + pill.animate([{ transform: from }, { transform: to }], + { duration: DURATION, easing: EASING }); + } + } + + var mo = new MutationObserver(place); + for (var j = 0; j < items.length; j++) { + mo.observe(items[j], { attributes: true, attributeFilter: ['class'] }); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/module/webroot/js/results.js b/module/webroot/js/results.js new file mode 100644 index 0000000..48014cf --- /dev/null +++ b/module/webroot/js/results.js @@ -0,0 +1,102 @@ +import { readBenchResults, clearBenchResults, deleteBenchResult, recordKey } from './bench.js'; +import { toast } from './kernelsu.js'; + +function el(tag, cls, text) { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; +} +function clearNode(n) { while (n.firstChild) n.removeChild(n.firstChild); } + +function fmtDate(ts) { + if (!ts) return ''; + const d = new Date(ts); + const p = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; +} + +function connClass(conn) { + if (conn === 'Wi-Fi') return 'conn-wifi'; + if (conn === '5G') return 'conn-5g'; + if (conn === '4G') return 'conn-4g'; + return 'conn-other'; +} + +function metric(value, unit, label) { + const m = el('div', 'metric'); + const v = el('div', 'm-val', value); + v.appendChild(el('span', 'm-unit', ' ' + unit)); + m.appendChild(v); + m.appendChild(el('div', 'm-label', label)); + return m; +} + +function resultCard(r, onDelete) { + const card = el('div', 'res-card'); + + const head = el('div', 'res-head'); + head.appendChild(el('span', 'conn-badge ' + connClass(r.conn), r.conn || '—')); + head.appendChild(el('span', 'res-date', fmtDate(r.ts))); + if (r.grade && r.grade !== 'n/a') { + head.appendChild(el('span', 'res-grade grade-' + r.grade, r.grade)); + } + const del = el('button', 'res-del', '✕'); + del.setAttribute('aria-label', 'Delete this result'); + del.addEventListener('click', () => onDelete(r)); + head.appendChild(del); + card.appendChild(head); + + const metrics = el('div', 'res-metrics'); + metrics.appendChild(metric(r.down != null ? String(r.down) : '—', 'Mbps ↓', 'download')); + metrics.appendChild(metric(r.idle != null ? String(r.idle) : '—', 'ms', 'idle ping')); + metrics.appendChild(metric(r.loaded != null ? String(r.loaded) : '—', 'ms', 'under load')); + card.appendChild(metrics); + + const foot = el('div', 'res-foot'); + const chips = el('div', 'res-chips'); + if (r.profile) chips.appendChild(el('span', 'chip chip-profile', '★ ' + r.profile)); + if (r.cc) chips.appendChild(el('span', 'chip', r.cc)); + if (r.qdisc) chips.appendChild(el('span', 'chip', r.qdisc)); + if (r.signal) chips.appendChild(el('span', 'chip chip-dim', r.signal + ' dBm')); + foot.appendChild(chips); + if (r.bloat != null) foot.appendChild(el('span', 'res-bloat', '+' + r.bloat + ' ms bloat')); + card.appendChild(foot); + + return card; +} + +async function render() { + const list = document.getElementById('results-list'); + if (!list) return; + clearNode(list); + + const results = await readBenchResults(); + if (!results.length) { + list.appendChild(el('div', 'results-empty', + 'No results yet. Run a Bufferbloat test in the Tools tab — each run is saved here with its settings.')); + return; + } + + results.sort((a, b) => (b.ts || 0) - (a.ts || 0)); + results.forEach(r => list.appendChild(resultCard(r, onDeleteCard))); +} + +async function onDeleteCard(r) { + await deleteBenchResult(recordKey(r)); + toast('Result deleted'); + await render(); +} + +export async function initResults() { + await render(); + + const clearBtn = document.getElementById('results-clear'); + if (clearBtn) { + clearBtn.addEventListener('click', async () => { + await clearBenchResults(); + toast('Results cleared'); + await render(); + }); + } +} diff --git a/module/webroot/js/router.js b/module/webroot/js/router.js index 8b57216..648e219 100644 --- a/module/webroot/js/router.js +++ b/module/webroot/js/router.js @@ -29,6 +29,9 @@ const router_state = { available_algorithms: [], available_qdiscs: [], current_active_page: null, + // last profile applied via Tools (name + the cc/qdisc it set), so a bufferbloat + // run can tag its Results record with the profile — but only while still active. + activeProfile: null, }; let currentPageStyle = null; // Store reference to the current style link @@ -105,7 +108,7 @@ document.addEventListener('DOMContentLoaded', async () => { // Start exit animation currentPage.classList.remove('active'); - currentPage.style.transition = 'opacity 0.4s ease'; + currentPage.style.transition = 'opacity 0.12s ease'; setTimeout(() => { fetch(`./pages/${pageName}.html`) @@ -130,6 +133,12 @@ document.addEventListener('DOMContentLoaded', async () => { case 'settings': module.initSettings(); break; + case 'tools': + module.initTools(); + break; + case 'results': + module.initResults(); + break; case 'logs': module.initLogs(); break; @@ -155,7 +164,7 @@ document.addEventListener('DOMContentLoaded', async () => { `; reject(err); }); - }, 400); // Wait for exit animation to finish + }, 120); // Wait for exit animation to finish }); } diff --git a/module/webroot/js/settings.js b/module/webroot/js/settings.js index 2746a27..9fd5bf0 100644 --- a/module/webroot/js/settings.js +++ b/module/webroot/js/settings.js @@ -7,7 +7,7 @@ async function getSelectedQdisc(prefix) { try { const { stdout: rawFile } = await exec(`ls ${router_state.moduleInformation.moduleDir}/${prefix}_* 2>/dev/null | xargs -n 1 basename | head -n1`); const fileName = rawFile.trim(); - if (!fileName) return "fifo"; + if (!fileName) return ""; const stripInterface = fileName.replace(`${prefix}_`, ''); const qdisc = stripInterface.substring(stripInterface.indexOf('_') + 1); switch(prefix) @@ -34,7 +34,7 @@ async function getSelectedAlgorithm(prefix) { try { const { stdout: rawFile } = await exec(`ls ${router_state.moduleInformation.moduleDir}/${prefix}_* 2>/dev/null | xargs -n 1 basename | head -n1`); const fileName = rawFile.trim(); - if (!fileName) return "fifo"; + if (!fileName) return ""; const stripInterface = fileName.replace(`${prefix}_`, ''); const algo = stripInterface.substring(0, stripInterface.indexOf('_')); switch(prefix) @@ -99,13 +99,16 @@ async function populateDropdown(dropdown, options, prefix, type) { valueExists = (value == option) ? true: valueExists; }); + // Always land on an option that actually exists in the list, so the in a themed dropdown, keeping the original element +// (hidden) as the value store so applySettings() still reads select.value. +// Built with safe DOM only (textContent), never innerHTML. +function enhanceSelect(sel) { + if (!sel) return; + if (sel.dataset.enhanced === '1') { if (sel._csync) sel._csync(); return; } + sel.dataset.enhanced = '1'; + bindOutsideClose(); + + const wrap = document.createElement('div'); + wrap.className = 'cselect'; + sel.parentNode.insertBefore(wrap, sel); + wrap.appendChild(sel); + sel.classList.add('cselect-native'); + + const trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.className = 'cselect-trigger'; + const label = document.createElement('span'); + label.className = 'cselect-label'; + const chev = document.createElement('span'); + chev.className = 'cselect-chev'; + chev.setAttribute('aria-hidden', 'true'); + trigger.appendChild(label); + trigger.appendChild(chev); + wrap.appendChild(trigger); + + const menu = document.createElement('div'); + menu.className = 'cselect-menu'; + wrap.appendChild(menu); + + const syncLabel = () => { + const o = sel.options[sel.selectedIndex]; + label.textContent = o ? o.textContent : ''; + }; + const buildMenu = () => { + clearNode(menu); + Array.from(sel.options).forEach(opt => { + const item = document.createElement('div'); + item.className = 'cselect-option' + (opt.value === sel.value ? ' selected' : ''); + item.textContent = opt.textContent; + item.addEventListener('click', (e) => { + e.stopPropagation(); + sel.value = opt.value; + sel.dispatchEvent(new Event('change')); + syncLabel(); + wrap.classList.remove('open'); + }); + menu.appendChild(item); + }); + }; + + trigger.addEventListener('click', (e) => { + e.stopPropagation(); + const isOpen = wrap.classList.contains('open'); + document.querySelectorAll('.cselect.open').forEach(c => c.classList.remove('open')); + if (!isOpen) { buildMenu(); wrap.classList.add('open'); } + }); + + // refresh hook: call after populateDropdown repopulates/reselects the + + + diff --git a/module/webroot/pages/tools.html b/module/webroot/pages/tools.html new file mode 100644 index 0000000..d9e109d --- /dev/null +++ b/module/webroot/pages/tools.html @@ -0,0 +1,63 @@ +
+

Profiles

+

One tap sets congestion control + qdisc + tuning for a use-case.

+
+ + + + +
+ +
+ +
+

Auto-Optimize

+

Probes every available congestion control (except reno) × AQM qdisc (cake / fq_codel / fq) on this connection and applies the best throughput-vs-bufferbloat. ~3 min, uses data (Wi-Fi recommended).

+ + +
+ +
+

Bufferbloat & Latency Test

+

Measures ping when idle vs under heavy download, and grades it. Each run is saved to the Results tab.

+ + +
+ +
+

Live BBR Telemetry

+

Real download throughput (summed TCP byte deltas) and the busiest connection's min RTT, sampled every second — plus the live bbr model below. Uses kernel debugfs when available.

+ + +
+ +
+

CC Duel (A/B)

+

Times the same single-stream download once per algorithm and compares. Your congestion-control setting is restored afterwards.

+
+ + vs + +
+ + +