release: Mark VIII 1.0

This commit is contained in:
c4ch3c4d3
2026-08-11 20:24:24 -07:00
commit 3e1805dab8
86 changed files with 20678 additions and 0 deletions
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 c4ch3c4d3
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,33 @@
# Mark VIII
Mark VIII is a community web-management payload for the WiFi Pineapple Pager.
It runs locally on the Pager and exposes its interface on port `8080`.
## Install
Copy this complete directory to either:
```text
/root/payloads/user/remote_access/pager-webui/
/mmc/root/payloads/user/remote_access/pager-webui/
```
Ensure `payload.sh` is executable. It will then appear under Remote Access in
the Pager payload menu.
## Run
Run Mark VIII from the Pager. Choose background mode to install and enable its
`procd` service, or foreground mode to keep it attached to the payload screen.
Open `http://172.16.52.1:8080/` and authenticate with the Pager's device
password. Re-running the payload stops and removes the background service.
## Requirements and security
- WiFi Pineapple Pager firmware 24.10.1
- `python3` (included with the supported firmware)
- Access to port 8080 should be limited to trusted management networks
Mark VIII runs as root because it manages the device. Review the source before
installation and use it only on systems and networks you are authorized to
administer.
@@ -0,0 +1,29 @@
# Third-party notices
Mark VIII bundles xterm.js and the xterm.js fit addon.
Copyright (c) 2014 The xterm.js authors. All rights reserved.
These components are distributed under the MIT License. Permission is hereby
granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software
without restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies of the
Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
WiFi Pineapple, PineAP, Pager, and related names and imagery are trademarks or
assets of Hak5 LLC and their respective owners. They are not covered by the
Mark VIII MIT license. Mark VIII is an independent community payload and is not
an official Hak5 product.
@@ -0,0 +1,15 @@
{
"payload": "pager-webui",
"parent": "user/remote_access",
"key": "user~remote_access~pager-webui",
"time": "",
"last_hash": "",
"zip": "",
"title": "Mark VIII",
"author": "c4ch3c4d3",
"description": "Mark VII-style web management UI for the WiFi Pineapple Pager",
"version": "1.0",
"category": "remote_access",
"tags": ["remote-access", "web-interface", "device-management", "pineap"],
"firmware": "Pineapple Pager 24.10.1"
}
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh /etc/rc.common
START=99
STOP=10
USE_PROCD=1
PAGER_WEBUI_DIR="/root/payloads/user/remote_access/pager-webui"
[ -f "$PAGER_WEBUI_DIR/server.py" ] || PAGER_WEBUI_DIR="/mmc/root/payloads/user/remote_access/pager-webui"
start_service() {
[ -f "$PAGER_WEBUI_DIR/server.py" ] || return 1
chmod -R 755 "$PAGER_WEBUI_DIR" 2>/dev/null
procd_open_instance pagerwebui
procd_set_param command /usr/bin/python3 "$PAGER_WEBUI_DIR/server.py"
procd_set_param respawn
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param pidfile /tmp/pagerwebui.pid
procd_close_instance
}
stop_service() {
rm -f /tmp/pagerwebui.pid
}
+113
View File
@@ -0,0 +1,113 @@
#!/bin/bash
# Title: Mark VIII
# Description: Mark VII-style web management UI for the WiFi Pineapple Pager
# Author: c4ch3c4d3
# Version: 1.0
# Category: Remote-Access
# Tags: remote-access, web-interface, device-management, pineap
# Firmware: Pineapple Pager 24.10.1
SCRIPT_SOURCE="${BASH_SOURCE[0]:-$0}"
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" 2>/dev/null && pwd)"
[ -f "$SCRIPT_DIR/server.py" ] || SCRIPT_DIR="/root/payloads/user/remote_access/pager-webui"
[ -f "$SCRIPT_DIR/server.py" ] || SCRIPT_DIR="/mmc/root/payloads/user/remote_access/pager-webui"
PORT=8080
PID_FILE="/tmp/pagerwebui.pid"
INIT_SCRIPT="/etc/init.d/pagerwebui"
user_confirmed() {
[ "$1" = "true" ] || [ "$1" = "$DUCKYSCRIPT_USER_CONFIRMED" ]
}
get_pager_ip() {
for iface in br-lan eth0 wlan0 usb0; do
IP=$(ip -4 addr show "$iface" 2>/dev/null | awk '/inet / {print $2}' | cut -d'/' -f1 | head -1)
[ -n "$IP" ] && echo "$IP" && return
done
echo "172.16.52.1"
}
LOG "cyan" "+---------------------------+"
LOG "cyan" "| Mark VIII v1.0 |"
LOG "cyan" "+---------------------------+"
if ! command -v python3 >/dev/null 2>&1; then
LOG "red" "python3 required (present on current firmware)"
exit 1
fi
if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" running 2>/dev/null; then
PAGER_IP=$(get_pager_ip)
LOG "green" "Mark VIII service is running"
LOG "green" "http://$PAGER_IP:$PORT"
resp=$(CONFIRMATION_DIALOG "Stop service?")
if user_confirmed "$resp"; then
LOG "yellow" "Stopping service..."
"$INIT_SCRIPT" stop
"$INIT_SCRIPT" disable
rm -f "$INIT_SCRIPT"
LOG "cyan" "Service stopped"
fi
exit 0
fi
AUTO_MODE=$(PAYLOAD_GET_CONFIG pager_webui auto_mode 2>/dev/null)
RUN_MODE=$(PAYLOAD_GET_CONFIG pager_webui run_mode 2>/dev/null)
if [ "$AUTO_MODE" = "true" ]; then
if [ "$RUN_MODE" = "background" ]; then
resp="true"
LOG "cyan" "Auto-starting background mode..."
else
resp=""
LOG "cyan" "Auto-starting foreground mode..."
fi
else
resp=$(CONFIRMATION_DIALOG "Run as background service?")
fi
if user_confirmed "$resp"; then
LOG "cyan" "Starting as background service..."
[ ! -f "$SCRIPT_DIR/server.py" ] && { LOG "red" "server.py not found!"; exit 1; }
cp "$SCRIPT_DIR/pagerwebui.init" "$INIT_SCRIPT"
chmod +x "$INIT_SCRIPT"
"$INIT_SCRIPT" enable
"$INIT_SCRIPT" start
sleep 1
PAGER_IP=$(get_pager_ip)
LOG "green" "Service started!"
LOG "green" "http://$PAGER_IP:$PORT"
LOG "cyan" "Re-run payload to stop"
sleep 3
exit 0
fi
LOG "cyan" "Starting foreground mode..."
cleanup() {
LOG "yellow" "Stopping Mark VIII..."
[ -f "$PID_FILE" ] && kill "$(cat "$PID_FILE")" 2>/dev/null
rm -f "$PID_FILE"
LOG "cyan" "Stopped."
}
trap cleanup EXIT INT TERM
[ ! -f "$SCRIPT_DIR/server.py" ] && { LOG "red" "server.py not found!"; exit 1; }
python3 "$SCRIPT_DIR/server.py" >/tmp/pagerwebui.log 2>&1 &
echo $! > "$PID_FILE"
sleep 1
PAGER_IP=$(get_pager_ip)
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
LOG "green" "http://$PAGER_IP:$PORT"
LOG ""
LOG "magenta" "Press B to stop"
while true; do
BUTTON=$(WAIT_FOR_INPUT)
if [ "$BUTTON" = "B" ] || [ "$BUTTON" = "Escape" ]; then
break
fi
done
else
LOG "red" "Failed to start server! Check /tmp/pagerwebui.log"
exit 1
fi
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,484 @@
:root {
--bg: #fafafa; --surface: #fff; --surface-alt: #f5f5f5;
--toolbar: #424242; --rail: #f3f3f3; --rail-hover: #a9a9a9;
--rail-fg: #212121; --rail-fg-active: #1976d2;
--text: #212121; --muted: #686868; --border: #e0e0e0;
--primary: #1976d2; --primary-dark: #105694; --danger: #d32f2f;
--ok: #7cb342; --warn: #f9a825; --terminal-bg: #000;
--shadow: 0 2px 2px rgba(0,0,0,.24), 0 0 2px rgba(0,0,0,.12);
--ink: #1976d2;
}
html.dark {
--bg: #303030; --surface: #424242; --surface-alt: #3a3a3a;
--toolbar: #424242; --rail: #3a3a3a; --rail-hover: #545454;
--rail-fg: #e0e0e0; --rail-fg-active: #fff;
--text: #fff; --muted: #bdbdbd; --border: #545454;
--shadow: 0 2px 2px rgba(0,0,0,.5), 0 0 2px rgba(0,0,0,.3);
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--bg); color: var(--text);
font: 14px/1.45 Roboto, "Segoe UI", Arial, sans-serif;
display: flex; flex-direction: column;
}
.hidden { display: none !important; }
#app { flex: 1; display: flex; flex-direction: column; min-height: 0; }
#login-screen { flex: 1; display: flex; align-items: center; justify-content: center; background: #9c9c9c; }
.login-card {
background: #fff; border-radius: 2px; padding: 32px; width: 360px;
box-shadow: 0 8px 20px rgba(0,0,0,.4); text-align: center;
}
.login-logo { height: 148px; margin-bottom: 8px; }
.login-title { margin: 0 0 20px; font-size: 26px; font-weight: 400; color: #212121; }
.login-field {
width: 100%; padding: 10px 12px; border: 1px solid #ccc;
border-radius: 2px; margin: 6px 0; font-size: 14px;
}
.login-error { color: #d32f2f; font-size: 13px; min-height: 18px; }
.spinner {
display: inline-block; width: 18px; height: 18px;
border: 2px solid rgba(25,118,210,.25); border-top-color: var(--primary);
border-radius: 50%; animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#topbar {
height: 64px; background: var(--toolbar); color: #fff;
display: flex; align-items: center; padding: 0 16px;
box-shadow: 0 2px 4px rgba(0,0,0,.4); position: relative; z-index: 80;
}
.brand-logo { height: 32px; margin-right: 10px; }
.brand-text { font-size: 20px; font-weight: 400; letter-spacing: .5px; }
.toolbar-spacer { flex: 1; }
#live-status {
color: #e0e0e0; font-size: 12px; margin-right: 16px; font-variant-numeric: tabular-nums;
}
.toolbar-action { position: relative; }
.toolbar-icon-btn {
width: 44px; height: 44px; padding: 10px; border: 0; border-radius: 50%;
background: transparent; color: #fff; cursor: pointer; display: flex;
align-items: center; justify-content: center;
}
.toolbar-icon-btn:hover, .toolbar-icon-btn:focus-visible { background: rgba(255,255,255,.12); outline: none; }
.toolbar-icon-btn svg { width: 24px; height: 24px; display: block; }
#terminal-btn.active, #pager-btn.active, .toolbar-icon-btn[aria-expanded="true"] {
background: #1976d2; color: #fff;
}
.notification-badge {
position: absolute; top: 2px; right: 1px; min-width: 18px; height: 18px;
padding: 0 5px; border-radius: 9px; background: #f44336; color: #fff;
border: 2px solid var(--toolbar); font-size: 10px; font-weight: 600;
line-height: 14px; text-align: center; pointer-events: none;
}
.toolbar-menu {
position: absolute; top: calc(100% + 8px); right: 0; width: 280px;
background: var(--surface); color: var(--text); border-radius: 3px;
box-shadow: 0 6px 18px rgba(0,0,0,.35); overflow: hidden;
}
.toolbar-menu:not(.hidden) { animation: menu-in .12s ease-out; }
@keyframes menu-in { from { opacity: 0; transform: translateY(-4px); } }
.toolbar-menu-header {
min-height: 48px; padding: 0 16px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between; font-weight: 500;
}
.menu-link {
border: 0; background: transparent; color: var(--primary); padding: 8px;
cursor: pointer; font: inherit; font-size: 12px;
}
.menu-link:disabled { color: var(--muted); cursor: default; opacity: .65; }
.notifications-menu { width: min(360px, calc(100vw - 24px)); }
.notifications-list { max-height: min(420px, calc(100vh - 96px)); overflow-y: auto; }
.notification-empty { padding: 28px 16px; color: var(--muted); text-align: center; }
.notification-item {
display: grid; grid-template-columns: 10px 1fr auto; gap: 10px; align-items: start;
padding: 12px 16px; border-bottom: 1px solid var(--border);
}
.notification-item:last-child { border-bottom: 0; }
.notification-kind { width: 8px; height: 8px; border-radius: 50%; margin-top: 6px; background: var(--primary); }
.notification-kind.error { background: var(--danger); }
.notification-kind.success { background: var(--ok); }
.notification-message { min-width: 0; overflow-wrap: anywhere; }
.notification-time { color: var(--muted); font-size: 11px; white-space: nowrap; margin-top: 2px; }
.toolbar-menu-item {
width: 100%; min-height: 48px; padding: 8px 16px; border: 0; background: transparent;
color: var(--text); display: flex; align-items: center; gap: 16px; text-align: left;
cursor: pointer; font: inherit;
}
.toolbar-menu-item:hover, .toolbar-menu-item:focus-visible { background: var(--surface-alt); outline: none; }
.menu-icon { width: 22px; height: 22px; color: var(--muted); flex: none; }
.menu-icon svg { width: 22px; height: 22px; display: block; }
.toolbar-menu-divider { height: 1px; background: var(--border); margin: 4px 0; }
.internet-menu-item > span:nth-child(2) { flex: 1; }
.internet-menu-item small { display: block; color: var(--muted); font-size: 11px; margin-top: 1px; }
.connection-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); flex: none; }
.connection-dot.online { background: var(--ok); }
.connection-dot.offline { background: var(--danger); }
.connection-dot.checking { animation: connection-pulse 1s ease-in-out infinite alternate; }
@keyframes connection-pulse { to { opacity: .35; } }
.danger-item { color: var(--danger); }
.danger-item .menu-icon { color: var(--danger); }
@media (max-width: 620px) {
#topbar { padding: 0 8px; }
#live-status { display: none; }
.toolbar-icon-btn { width: 40px; height: 40px; padding: 8px; }
}
#layout { flex: 1; display: flex; min-height: 0; }
#rail {
width: 60px; background: var(--rail); color: var(--rail-fg);
display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; transition: width .2s;
}
#rail.open { width: 220px; }
#rail .entry {
height: 48px; display: flex; align-items: center; padding: 0 0 0 18px;
overflow: hidden; color: var(--rail-fg); cursor: pointer; border-right: 3px solid transparent; font-size: 14px;
}
#rail .entry:hover { background: var(--rail-hover); color: var(--text); }
#rail .entry.active {
border-right: 3px solid #1976d2; background: transparent; color: var(--rail-fg-active);
}
.entry-icon {
font-size: 24px; width: 24px; height: 24px; display: flex;
align-items: center; justify-content: center; margin-right: 14px; flex: none;
}
.entry-icon svg { width: 24px; height: 24px; display: block; }
#topbar .btn.ghost { color: #fff; border-color: #fff; }
.entry-text { white-space: nowrap; opacity: 0; transition: opacity .2s; }
#rail.open .entry-text { opacity: 1; }
#rail .entry.divider {
height: 0; padding: 0; margin: 8px 0; border-top: 1px solid rgba(0,0,0,.12); flex: none;
}
.rail-footer { margin-top: auto; }
#content { flex: 1; overflow-y: auto; padding: 20px 30px 5px 5px; }
.tabbar {
display: flex; border-bottom: 2px solid #e0e0e0;
margin-bottom: 16px; position: relative;
}
.tabbar .tab {
padding: 10px 16px; cursor: pointer; color: var(--muted); font-size: 14px;
border-bottom: 2px solid transparent; margin-bottom: -2px;
}
.tabbar .tab.active { color: var(--primary); border-bottom-color: var(--ink); }
.page-title { font-size: 24px; font-weight: 400; margin: 0 0 12px; }
.cards {
display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin-bottom: 16px;
}
.card { background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); padding: 14px; }
.card-label { font-size: 12px; color: var(--muted); }
.card-value { font-size: 28px; font-weight: 400; margin-top: 4px; }
.section {
background: var(--surface); border-radius: 2px; box-shadow: var(--shadow);
padding: 16px; margin-bottom: 16px;
}
.section h2 { margin: 0 0 12px; font-size: 16px; font-weight: 500; }
.row { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }
.row > div { flex: 1; min-width: 140px; }
.tbl { width: 100%; border-collapse: collapse; }
.tbl th, .tbl td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
.tbl th { color: var(--muted); font-size: 12px; font-weight: 500; }
.tbl tr:hover td { background: var(--surface-alt); }
.toggle { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
.toggle input { width: auto; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 11px; }
.badge.on { background: #e8f5e9; color: #2e7d32; }
.badge.off { background: #fff3e0; color: #e65100; }
.badge.unknown { background: #eeeeee; color: #616161; }
.btn {
background: var(--primary); color: #fff; border: 0; border-radius: 2px;
padding: 8px 14px; font-size: 14px; cursor: pointer;
box-shadow: 0 1px 3px rgba(0,0,0,.3);
}
.btn:hover { background: var(--primary-dark); }
.btn:disabled, .btn.busy { opacity: .5; cursor: default; pointer-events: none; }
.btn.ghost {
background: transparent; color: var(--primary); box-shadow: none;
border: 1px solid var(--primary);
}
.btn.danger { background: var(--danger); }
input, select {
background: var(--surface); color: var(--text); border: 1px solid var(--border);
border-radius: 2px; padding: 8px 10px; width: 100%;
}
label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 4px; }
code { background: var(--surface-alt); padding: 1px 5px; border-radius: 2px; }
pre.logs {
background: var(--terminal-bg); color: #c9d1d9; padding: 12px; overflow: auto;
max-height: 420px; font-size: 12px; white-space: pre-wrap; border-radius: 2px;
}
.log-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.log-toolbar h2 { margin: 0; }
.log-search { width: min(260px, 35vw); }
.empty { padding: 24px; text-align: center; color: var(--muted); }
#terminal-panel {
position: fixed; left: 0; right: 0; bottom: 0;
border-top: 1px solid var(--border); background: #000; height: 280px;
display: flex; flex-direction: column; z-index: 60;
box-shadow: 0 -3px 10px rgba(0,0,0,.35);
}
#terminal-panel:not(.hidden) { animation: term-up .18s ease-out; }
@keyframes term-up { from { transform: translateY(100%); } to { transform: translateY(0); } }
#terminal-bar {
display: flex; justify-content: space-between; align-items: center;
background: var(--toolbar); padding: 4px 10px; font-size: 12px; color: #e0e0e0;
}
#toast-container {
position: fixed; right: 16px; bottom: 16px; display: flex;
flex-direction: column; gap: 8px; z-index: 100;
}
.toast {
padding: 10px 14px; border-radius: 2px; background: var(--surface);
box-shadow: var(--shadow); color: var(--text);
}
.toast.error { border-left: 4px solid var(--danger); }
.toast.info { border-left: 4px solid var(--primary); }
.toast.success { border-left: 4px solid var(--ok); }
#terminal .xterm { height: 100%; }
#pager-panel {
position: fixed; left: 0; right: 0; bottom: 0;
border-top: 1px solid var(--border); background: #303030; height: 540px;
display: flex; flex-direction: column; z-index: 60;
box-shadow: 0 -3px 10px rgba(0,0,0,.35);
}
#pager-panel:not(.hidden) { animation: term-up .18s ease-out; }
#pager-bar {
display: flex; justify-content: space-between; align-items: center;
background: var(--toolbar); padding: 4px 10px; font-size: 12px; color: #e0e0e0;
}
#pager-body { flex: 1; min-height: 0; overflow: hidden; padding: 10px; }
#pager_ui {
border-collapse: collapse; border-spacing: 0; margin: 0 auto;
border-radius: 62px;
box-shadow: inset 0 1px 0 rgba(255,255,255,.035), inset 0 0 34px rgb(24 24 24),
inset 0 -22px 36px rgba(0,0,0,.42), 0 14px 38px rgba(0,0,0,.48),
0 0 22px rgba(0,239,53,.09);
}
#pager_ui td { padding: 0; margin: 0; }
#pager_ui img { display: block; }
.pager-scale-wrapper { transform-origin: top center; }
.pager-btn {
cursor: pointer; user-select: none; -webkit-user-drag: none;
transition: filter 0.08s ease;
}
.pager-btn.pressed { filter: brightness(0.6); }
#pager_error {
position: absolute; top: 0; left: 0; width: 480px; height: 222px;
font-size: large; text-align: center; color: #fff;
display: flex; align-items: center; justify-content: center; flex-direction: column;
background: rgba(0,0,0,.8); z-index: 5;
}
#pager_error[hidden] { display: none; }
.title-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; margin: 8px 0 16px; }
.title-card { background: var(--card, #fff); border: 1px solid var(--border, #e0e0e0); border-radius: 4px; padding: 12px 16px; box-shadow: 0 2px 4px rgba(0, 0, 0, .12); }
.title-card h2 { margin: 0 0 8px; font-size: 15px; font-weight: 500; }
.chart-box { height: 160px; }
.card-stat { font-size: 28px; font-weight: 500; margin: 4px 0; }
.switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; -webkit-user-select: none; user-select: none; }
.switch input { display: none; }
.switch .track { width: 40px; height: 20px; border-radius: 10px; background: #bdbdbd; position: relative; transition: background .2s; }
.switch .track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: left .2s; }
.switch input:checked + .track { background: #1976d2; }
.switch input:checked + .track::after { left: 22px; }
.switch input:indeterminate + .track { background: #9e9e9e; }
.switch input:indeterminate + .track::after { left: 12px; }
.sel { padding: 6px 8px; border: 1px solid var(--border, #e0e0e0); border-radius: 4px; background: var(--card, #fff); color: var(--text, #212121); }
.pager { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.muted { color: var(--muted, #686868); }
html.dark .title-card { background: #424242; border-color: #545454; }
html.dark .sel { background: #424242; border-color: #545454; color: #fff; }
html.dark .muted { color: #bdbdbd; }
/* ---- Recon (Mark VII parity) ---- */
.recon-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 10px; margin: 8px 0 16px; }
.recon-title-card { flex: 1 1 220px; min-width: 220px; margin-bottom: 1em; }
.recon-card { background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); height: 200px; padding: 12px 16px; display: flex; flex-direction: column; }
.recon-title-card-title { font-size: 20px; margin-bottom: 15px; display: flex; align-items: center; color: var(--text); }
.recon-card-title-link { color: inherit; text-decoration: none; }
.recon-card-title-link:visited { color: inherit; }
.recon-card-title-link:hover { text-decoration: underline; }
.recon-title-card-content { display: flex; justify-content: center; align-items: center; height: 70%; }
.recon-chart-box { width: 100%; height: 150px; position: relative; }
.recon-chart-box canvas { width: 100%; height: 100%; }
.recon-no-data { font-style: italic; color: #787878; display: flex; justify-content: center; padding: 12px; }
.recon-hs-col { display: flex; flex-direction: column; justify-content: center; align-items: center; }
.recon-hs-count { font-size: 32px; font-weight: 700; line-height: 1.1; }
.recon-hs-label { color: grey; margin: 2px 0 10px; }
.recon-toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); margin: 0; cursor: pointer; }
.recon-ps-row { display: flex; align-items: center; width: 100%; gap: 4px; }
.recon-ps-row .sel { width: 100%; }
.icon-btn { background: transparent; color: var(--muted); border: 0; border-radius: 50%; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; }
.icon-btn:hover { background: var(--surface-alt); color: var(--text); }
.icon-btn:disabled { opacity: .38; cursor: default; }
.icon-btn svg { width: 22px; height: 22px; }
.recon-scan-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.recon-scan-bar .sel { width: auto; }
.recon-table-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; }
.recon-table-head h2 { margin: 0; }
.recon-search { max-width: 180px; }
.recon-paginator { display: flex; align-items: center; gap: 2px; font-size: 12px; }
.recon-paginator .icon-btn { width: 30px; height: 30px; }
.recon-paginator .icon-btn svg { width: 18px; height: 18px; }
.recon-row-selected td { background: #eaeaea; }
html.dark .recon-row-selected td { background: #565656; }
.recon-settings-sidebar {
position: fixed; top: 64px; right: 0; bottom: 0; width: 270px; z-index: 50;
background: var(--surface); box-shadow: -2px 0 6px rgba(0,0,0,.24); padding: 16px;
overflow-y: auto;
}
.recon-settings-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.recon-settings-title { font-size: 20px; }
.recon-settings-section { font-size: 14px; font-weight: 500; margin: 14px 0 4px; color: var(--muted); }
.recon-settings-sidebar .toggle { font-size: 13px; }
.recon-handshakes-card .recon-table-head h2 { font-size: 20px; margin-bottom: 15px; }
/* ---- Recon AP focus sidebar + table sort ---- */
.recon-focus-sidebar {
position: fixed; top: 64px; right: 0; bottom: 0; width: 270px; z-index: 50;
background: var(--surface); box-shadow: -2px 0 6px rgba(0,0,0,.24); padding: 16px;
overflow-y: auto;
}
.recon-focus-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.recon-focus-header-text { font-size: 20px; line-height: 1.2; flex: 1; word-break: break-word; }
.recon-focus-bssid { font-family: Consolas, Menlo, monospace; font-size: 13px; -webkit-user-select: text; user-select: text; }
.recon-focus-subtext { color: grey; font-style: italic; font-size: 12px; margin-bottom: 8px; }
.recon-focus-body { margin-top: 18px; }
.recon-focus-body-title { font-size: 16px; margin-bottom: 8px; color: var(--text); }
.recon-focus-action-button { width: 100%; margin-bottom: 5px; }
.recon-focus-detail { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.recon-focus-detail-label { color: var(--muted); flex: none; }
.recon-sort-arrow { color: var(--muted); font-size: 11px; }
th.recon-sorted { color: var(--primary); }
.recon-per { width: auto; }
/* ---- Mark VII handshakes table + settings dialog ---- */
.hs-cell-center { text-align: center; }
.hs-ok, .hs-bad, .hs-na { display: inline-flex; vertical-align: middle; }
.hs-ok svg, .hs-bad svg, .hs-na svg { width: 18px; height: 18px; }
.hs-ok { color: #7cb342; }
.hs-bad { color: #d32f2f; }
.hs-na { color: #9e9e9e; }
.hs-actions { display: inline-flex; align-items: center; gap: 4px; }
.hs-warn { color: #d32f2f; }
.hs-flash { font-size: 12px; margin-left: 8px; }
.hs-flash-ok { color: #7cb342; }
.hs-flash-error { color: #d32f2f; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 100; display: flex; align-items: center; justify-content: center; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; min-width: 420px; max-width: 600px; box-shadow: 0 8px 24px rgba(0,0,0,.3); padding: 20px; }
.modal-title { font-size: 20px; margin-bottom: 16px; }
.modal-body { display: flex; flex-direction: column; gap: 12px; }
.modal-actions { display: flex; justify-content: flex-end; margin-top: 16px; }
.hs-settings-row { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; }
.hs-settings-label { color: var(--muted); }
.hs-settings-value { font-family: Consolas, Menlo, monospace; word-break: break-all; }
html.dark .modal { background: #303030; }
/* ---- PineAP overview segmented control ---- */
.seg { display: inline-flex; margin-top: 8px; border: 1px solid var(--border, #e0e0e0); border-radius: 4px; overflow: hidden; }
.seg-btn { background: transparent; border: none; padding: 5px 14px; font-size: 12px; cursor: pointer; color: var(--muted, #666); }
.seg-btn + .seg-btn { border-left: 1px solid var(--border, #e0e0e0); }
.seg-btn.active { background: var(--primary, #1976d2); color: #fff; }
.seg-btn.busy { opacity: .5; pointer-events: none; }
/* ---- PineAP Mark VII layout ---- */
.pineap-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 30px; margin: 8px 0 16px; }
.pineap-title-card { flex: 1; min-width: 220px; background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); padding: 14px 16px; margin-bottom: 16px; }
.pineap-card-title { font-size: 20px; display: flex; align-items: center; margin-bottom: 10px; }
.pineap-card-title-flex { display: flex; align-items: center; font-size: 20px; margin-bottom: 15px; }
.pineap-card-title-link { color: inherit; text-decoration: none; cursor: pointer; }
.pineap-card-title-link:visited { color: inherit; }
.pineap-card-title-link:hover { text-decoration: underline; }
.pineap-card-title-content { display: flex; justify-content: center; align-items: center; font-size: 24px; }
.pineap-card-button-group { width: 100%; height: 30px; display: flex; }
.pineap-card-button-group .seg { flex: 1; height: 100%; margin-top: 0; }
.pineap-card-button-group .seg-btn { flex: 1; }
.pineap-mode-save { display: flex; justify-content: flex-end; margin-top: 10px; }
.pineap-mode-features { margin: 6px 0 0; padding-left: 20px; }
.pineap-card-settings, .pineap-card-pool, .pineap-card-handshakes, .pineap-card-inject { flex: 1; }
.pineap-handshakes-none { display: flex; justify-content: center; font-style: italic; color: var(--muted); }
/* ---- Open AP: Mark VII PineAP settings card ---- */
.pineap-card-subtitle { color: var(--muted); font-size: 13px; margin: -8px 0 10px; }
.pineap-settings-section { font-size: 13px; font-weight: 500; color: var(--muted); margin: 14px 0 4px; }
.pineap-card-settings .switch { margin: 6px 0; }
.pineap-title-card > .switch { display: flex; margin: 8px 0; }
/* ---- PineAP filters ---- */
.filter-mode-seg { margin: 0 0 8px; }
.pineap-filter-description { color: var(--muted); font-size: 13px; margin: 2px 0 12px; }
.pineap-filter-entry-row { align-items: flex-end; }
.pineap-filter-value { flex: 1 1 280px; }
.pineap-filter-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0 14px; }
.pineap-filter-card .tbl tbody tr { cursor: pointer; }
.pineap-filter-card .tbl tbody tr:hover { background: rgba(25, 118, 210, .08); }
/* ---- Open AP: Mk7 filter notice boxes ---- */
.pineap-infobox { border-radius: 2px; padding: 10px 12px; margin-top: 10px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
.pineap-infobox.error { background: #fdecea; color: #b71c1c; border: 1px solid #f5c6cb; }
.pineap-infobox.info { background: #e3f2fd; color: #0d47a1; border: 1px solid #90caf9; }
.pineap-infobox-actions { display: flex; gap: 8px; flex-wrap: wrap; }
html.dark .pineap-infobox.error { background: #4a2020; color: #ffb4a9; border-color: #6b2d2d; }
html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-color: #1d3a54; }
/* ---- Payloads (Pager-native Mark VII layout) ---- */
.payload-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.payload-heading h2 { margin-bottom: 4px; font-size: 20px; font-weight: 400; }
.payload-heading p { margin: 0; max-width: 760px; }
.payload-filters { display: grid; grid-template-columns: minmax(220px, 2fr) minmax(170px, 1fr); gap: 10px; max-width: 720px; }
.payload-result-count { margin: 9px 0; font-size: 12px; }
.payload-list { display: grid; gap: 10px; }
.payload-card { display: flex; align-items: center; gap: 16px; padding: 14px 2px; border-top: 1px solid var(--border); }
.payload-card-main { flex: 1; min-width: 0; }
.payload-card h3, .payload-run h3, .payload-dev-block h3 { margin: 0; font-size: 16px; font-weight: 500; }
.payload-author { margin: 2px 0 7px; font-size: 12px; }
.payload-description { margin: 0 0 10px; max-width: 850px; }
.payload-meta { display: flex; flex-wrap: wrap; gap: 6px; }
.payload-tag { padding: 2px 8px; border-radius: 10px; background: var(--surface-alt); color: var(--muted); font-size: 11px; text-transform: capitalize; }
.payload-actions { display: flex; align-items: center; justify-content: flex-end; gap: 7px; flex-wrap: wrap; }
.payload-actions .btn { white-space: nowrap; }
.payload-empty { padding: 30px 16px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; font-style: italic; }
.payload-runs { display: grid; gap: 12px; }
.payload-run { border: 1px solid var(--border); border-radius: 3px; padding: 13px; }
.payload-run-head { display: flex; align-items: center; gap: 12px; }
.payload-run-head > div:first-child { flex: 1; }
.payload-run-head p { margin: 3px 0 0; font-size: 12px; }
.payload-run-state { border-radius: 10px; padding: 3px 9px; font-size: 11px; }
.payload-run-state.running { color: #2e7d32; background: #e8f5e9; }
.payload-run-state.finished { color: var(--muted); background: var(--surface-alt); }
.payload-output { max-height: 240px; overflow: auto; margin: 12px 0 0; padding: 10px; border-radius: 2px; background: #111; color: #d8d8d8; font: 12px/1.45 monospace; white-space: pre-wrap; }
.payload-dev-grid { display: grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); gap: 14px; margin: 18px 0; }
.payload-dev-block { padding: 15px; border: 1px solid var(--border); background: var(--surface-alt); }
.payload-dev-block p { margin-bottom: 0; }
.payload-code { margin: 10px 0 0; padding: 12px; overflow: auto; background: #111; color: #d8d8d8; font-size: 12px; }
/* ---- Settings (Mark VII structure, Pager capabilities) ---- */
.settings-content { margin-top: 14px; }
.settings-card { margin-bottom: 16px; }
.settings-card h2 { font-size: 20px; font-weight: 400; margin-bottom: 10px; }
.settings-subtitle { margin: -4px 0 14px; font-size: 13px; line-height: 1.45; }
.settings-form-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 4px 16px; align-items: end;
}
.settings-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
.settings-table-wrap { overflow-x: auto; width: 100%; }
.settings-table-wrap .tbl { min-width: 680px; }
.settings-kv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px 20px; }
.settings-kv { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); padding: 7px 0; }
.settings-kv > span { color: var(--muted); }
.settings-chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
.settings-chip { padding: 5px 9px; }
.settings-diagnostics { max-height: 520px; white-space: pre-wrap; word-break: break-word; }
.settings-card > .switch { display: flex; margin: 10px 0; color: var(--text); font-size: 13px; }
.settings-card a { color: var(--primary); }
@media (max-width: 700px) {
.tabbar { overflow-x: auto; flex-wrap: nowrap; }
.tabbar .tab { flex: 0 0 auto; }
.settings-form-grid { grid-template-columns: 1fr; }
.payload-heading, .payload-card, .payload-run-head { align-items: stretch; flex-direction: column; }
.payload-filters, .payload-dev-grid { grid-template-columns: 1fr; }
.payload-actions { justify-content: flex-start; }
}
@@ -0,0 +1,273 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>WiFi Pineapple</title>
<link rel="icon" type="image/png" href="assets/logo.png">
<link rel="stylesheet" href="css/app.css?v=20260811-9">
<link rel="stylesheet" href="js/xterm.css">
</head>
<body>
<section id="login-screen">
<form id="login-form" class="login-card">
<img class="login-logo" src="assets/logo.png" alt="WiFi Pineapple">
<h2 class="login-title">WiFi Pineapple</h2>
<input id="login-password" class="login-field" type="password" placeholder="Password" autocomplete="current-password" required>
<p id="login-error" class="login-error"></p>
<button id="login-button" class="btn" type="submit">Login</button>
</form>
</section>
<div id="app" class="hidden">
<header id="topbar">
<img id="brand-logo" class="brand-logo" src="assets/logo.png" alt="WiFi Pineapple">
<span id="brand-text" class="brand-text">Mark VIII</span>
<span class="toolbar-spacer"></span>
<span id="live-status"></span>
<div class="toolbar-action">
<button id="notifications-btn" class="toolbar-icon-btn" type="button" title="Notifications"
aria-label="Notifications" aria-haspopup="menu" aria-controls="notifications-menu" aria-expanded="false"></button>
<span id="notification-badge" class="notification-badge hidden" aria-hidden="true"></span>
<section id="notifications-menu" class="toolbar-menu notifications-menu hidden" role="menu" aria-label="Notifications">
<header class="toolbar-menu-header">
<span>Notifications</span>
<button id="notifications-clear" class="menu-link" type="button">Clear all</button>
</header>
<div id="notifications-list" class="notifications-list"></div>
</section>
</div>
<button id="terminal-btn" class="toolbar-icon-btn" type="button" title="Terminal" aria-label="Open Terminal"></button>
<button id="pager-btn" class="toolbar-icon-btn" type="button" title="Virtual Pager" aria-label="Open Virtual Pager"></button>
<div class="toolbar-action">
<button id="overflow-btn" class="toolbar-icon-btn" type="button" title="More options"
aria-label="More options" aria-haspopup="menu" aria-controls="overflow-menu" aria-expanded="false"></button>
<section id="overflow-menu" class="toolbar-menu overflow-menu hidden" role="menu" aria-label="More options">
<button class="toolbar-menu-item" type="button" data-menu-action="help" role="menuitem">
<span class="menu-icon" data-icon="help"></span><span>Help</span>
</button>
<button class="toolbar-menu-item" type="button" data-menu-action="updates" role="menuitem">
<span class="menu-icon" data-icon="update"></span><span>Check for Updates</span>
</button>
<button class="toolbar-menu-item internet-menu-item" type="button" data-menu-action="internet" role="menuitem">
<span class="menu-icon" data-icon="wifi"></span>
<span>Internet Connection<small id="internet-status">Checking…</small></span>
<span id="internet-status-dot" class="connection-dot checking" aria-hidden="true"></span>
</button>
<div class="toolbar-menu-divider" role="separator"></div>
<button class="toolbar-menu-item" type="button" data-menu-action="logout" role="menuitem">
<span class="menu-icon" data-icon="logout"></span><span>Logout</span>
</button>
<button class="toolbar-menu-item danger-item" type="button" data-menu-action="reboot" role="menuitem">
<span class="menu-icon" data-icon="reboot"></span><span>Reboot</span>
</button>
</section>
</div>
</header>
<div id="layout">
<nav id="rail"></nav>
<main id="content"></main>
</div>
<div id="terminal-panel" class="hidden">
<div id="terminal-bar">
<span>Terminal</span>
<button id="terminal-close" class="btn ghost">×</button>
</div>
<div id="terminal"></div>
</div>
<div id="pager-panel" class="hidden">
<div id="pager-bar">
<span>Virtual Pager</span>
<button id="pager-close" class="btn ghost">×</button>
</div>
<div id="pager-body">
<div id="pager-scale-wrapper" class="pager-scale-wrapper">
<table id="pager_ui" width="745" height="531" border="0" cellpadding="0" cellspacing="0">
<tr>
<td colspan="13">
<img src="assets/pager/virtual_pager_01.png" width="744" height="67" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="1" height="67" alt="">
</td>
</tr>
<tr>
<td colspan="2" rowspan="3">
<img src="assets/pager/virtual_pager_02.png" width="132" height="324" alt="">
</td>
<td colspan="9" style="position:relative;padding:0;margin:0;font-size:0;line-height:0;">
<!-- IMPORTANT: this is the live screen surface -->
<img id="pager" width="480" height="222" tabindex="0">
<!-- hidden canvas used to convert RGBA framebuffer -> PNG -->
<canvas id="pager_canvas" width="480" height="222" style="display:none;"></canvas>
<div hidden id="pager_error">
Lost connection to virtual pager
<br>
<button id="screen_retry">Reconnect</button>
</div>
</td>
<td colspan="2" rowspan="2">
<img src="assets/pager/virtual_pager_04.png" width="132" height="287" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="1" height="222" alt="">
</td>
</tr>
<tr>
<td colspan="9">
<img src="assets/pager/virtual_pager_05.png" width="480" height="65" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="1" height="65" alt="">
</td>
</tr>
<tr>
<td colspan="4">
<img src="assets/pager/virtual_pager_06.png" width="228" height="37" alt="">
</td>
<td rowspan="5">
<img class="pager-btn" src="assets/pager/LEFT.png" role="button" width="87" height="109" alt="">
</td>
<td rowspan="6">
<img src="assets/pager/virtual_pager_08.png" width="7" height="176" alt="">
</td>
<td rowspan="2">
<img class="pager-btn" src="assets/pager/UP.png" role="button" width="139" height="49" alt="">
</td>
<td rowspan="6">
<img src="assets/pager/virtual_pager_10.png" width="9" height="176" alt="">
</td>
<td colspan="2" rowspan="5">
<img class="pager-btn" src="assets/pager/RIGHT.png" role="button" width="86" height="109" alt="">
</td>
<td rowspan="6">
<img src="assets/pager/virtual_pager_12.png" width="56" height="176" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="1" height="37" alt="">
</td>
</tr>
<tr>
<td rowspan="5">
<img src="assets/pager/virtual_pager_13.png" width="73" height="139" alt="">
</td>
<td colspan="2" rowspan="3">
<img class="pager-btn" src="assets/pager/B_Button.png" role="button" width="115" height="44" alt="">
</td>
<td rowspan="5">
<img src="assets/pager/virtual_pager_15.png" width="22" height="139" alt="">
</td>
<td rowspan="3">
<img class="pager-btn" src="assets/pager/A_Button.png" role="button" width="115" height="44" alt="">
</td>
<td rowspan="5">
<img src="assets/pager/virtual_pager_17.png" width="35" height="139" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="1" height="12" alt="">
</td>
</tr>
<tr>
<td>
<img src="assets/pager/virtual_pager_18.png" width="139" height="11" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="1" height="11" alt="">
</td>
</tr>
<tr>
<td rowspan="2">
<img class="pager-btn" src="assets/pager/DOWN.png" role="button" width="139" height="49" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="1" height="21" alt="">
</td>
</tr>
<tr>
<td colspan="2" rowspan="2">
<img src="assets/pager/virtual_pager_20.png" width="115" height="95" alt="">
</td>
<td rowspan="2">
<img src="assets/pager/virtual_pager_21.png" width="115" height="95" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="1" height="28" alt="">
</td>
</tr>
<tr>
<td>
<img src="assets/pager/virtual_pager_22.png" width="87" height="67" alt="">
</td>
<td>
<img src="assets/pager/virtual_pager_23.png" width="139" height="67" alt="">
</td>
<td colspan="2">
<img src="assets/pager/virtual_pager_24.png" width="86" height="67" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="1" height="67" alt="">
</td>
</tr>
<tr>
<td>
<img src="assets/pager/spacer.gif" width="73" height="1" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="59" height="1" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="56" height="1" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="22" height="1" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="115" height="1" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="35" height="1" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="87" height="1" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="7" height="1" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="139" height="1" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="9" height="1" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="10" height="1" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="76" height="1" alt="">
</td>
<td>
<img src="assets/pager/spacer.gif" width="56" height="1" alt="">
</td>
<td></td>
</tr>
</table>
</div>
</div>
</div>
</div>
<div id="toast-container"></div>
<script src="js/config.js"></script>
<script src="js/icons.js?v=20260811-6"></script>
<script src="js/api.js?v=20260811-3"></script>
<script src="js/chart.js"></script>
<script src="js/xterm.min.js"></script>
<script src="js/xterm-addon-fit.min.js"></script>
<script src="js/terminal.js"></script>
<script src="js/pager.js"></script>
<script src="js/views.js?v=20260811-11"></script>
<script src="js/app.js?v=20260811-9"></script>
</body>
</html>
@@ -0,0 +1,42 @@
'use strict';
const PagerAPI = (() => {
let apiBase = '';
let on401 = null;
async function request(method, path, body) {
const opts = { method, headers: {}, credentials: 'include' };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(apiBase + path, opts);
if (res.status === 401) {
if (on401) on401();
throw new Error('unauthorized');
}
const ct = res.headers.get('Content-Type') || '';
let data;
if (ct.indexOf('json') !== -1) {
try { data = await res.json(); }
catch (e) { data = null; }
} else {
data = await res.text();
}
if (!res.ok) {
const message = data && data.error ? data.error : ('HTTP ' + res.status);
const error = new Error(message);
error.status = res.status;
error.data = data;
throw error;
}
return { status: res.status, data };
}
return {
setBase: (b) => { apiBase = b; },
on401: (fn) => { on401 = fn; },
get: (p) => request('GET', p),
post: (p, b) => request('POST', p, b === undefined ? {} : b),
del: (p, b) => request('DELETE', p, b === undefined ? {} : b),
login: async (username, password) => request('POST', '/api/login', { username, password })
};
})();
@@ -0,0 +1,488 @@
'use strict';
const Theme = (() => {
const KEY = 'pw_theme';
function apply() {
document.documentElement.classList.toggle('dark', localStorage.getItem(KEY) === 'dark');
}
function toggle() {
localStorage.setItem(KEY, Theme.current() === 'dark' ? 'light' : 'dark');
apply();
}
function current() { return document.documentElement.classList.contains('dark') ? 'dark' : 'light'; }
return { apply, toggle, current, KEY };
})();
const App = (() => {
const cfg = window.PAGER_CONFIG || {};
const API_BASE = cfg.apiBase || '';
const WS_BASE = (cfg.wsBase || location.origin).replace(/^http/, 'ws');
const TERMINAL_WS = cfg.terminalWs || ('ws://' + location.hostname + ':1471/api/terminal/openWs');
const PAGER_SCREEN_WS = cfg.pagerScreenWs || (WS_BASE + '/api/pager/display/screen.ws');
const PAGER_KEYS_WS = cfg.pagerKeysWs || (WS_BASE + '/api/pager/input/keys.ws');
const els = {};
let currentView = null;
let notifications = [];
let unreadNotifications = 0;
const NOTIFICATIONS_KEY = 'markviii_notifications';
const railItems = [
{ key: 'dashboard', label: 'Dashboard', hash: '#/dashboard', icon: 'dashboard' },
{ key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'wifi' },
{ key: 'recon', label: 'Recon', hash: '#/recon', icon: 'recon' },
{ key: 'logging', label: 'Logging', hash: '#/logging', icon: 'logging' },
{ key: 'modules', label: 'Payloads', hash: '#/modules', icon: 'modules' },
{ key: 'settings', label: 'Settings', hash: '#/settings', icon: 'settings' }
];
const railDividers = new Set(['logging']);
function keyOf(hash) {
const seg = (hash || '#/dashboard').replace(/^#\//, '').split('/')[0];
return seg || 'dashboard';
}
function buildRail() {
const rail = els.rail;
rail.innerHTML = '';
function railEntry(it) {
const a = document.createElement('a');
a.className = 'entry';
a.href = it.hash;
a.title = it.label;
a.innerHTML = '<span class="entry-icon">' + PineappleIcons[it.icon] + '</span><span class="entry-text">' + it.label + '</span>';
a.addEventListener('click', (e) => { e.preventDefault(); location.hash = it.hash; });
return a;
}
railItems.filter((it) => it.key !== 'settings').forEach((it) => {
if (railDividers.has(it.key)) {
const d = document.createElement('div');
d.className = 'entry divider';
rail.appendChild(d);
}
rail.appendChild(railEntry(it));
});
const foot = document.createElement('div');
foot.className = 'rail-footer';
const settings = railItems.find((it) => it.key === 'settings');
if (settings) foot.appendChild(railEntry(settings));
const open = document.createElement('a');
open.className = 'entry';
open.title = 'Open Menu';
open.innerHTML = '<span class="entry-icon">' + PineappleIcons.chevron_right + '</span><span class="entry-text">Open Menu</span>';
open.addEventListener('click', (e) => {
e.preventDefault();
const openState = localStorage.getItem('pw_rail') === 'open';
localStorage.setItem('pw_rail', openState ? 'closed' : 'open');
els.rail.classList.toggle('open', !openState);
});
foot.appendChild(open);
rail.appendChild(foot);
rail.classList.toggle('open', localStorage.getItem('pw_rail') === 'open');
}
function route() {
closeToolbarMenus();
const hash = location.hash || '#/dashboard';
const name = routes[hash];
if (currentView && currentView.destroy) currentView.destroy();
els.content.innerHTML = '';
if (!name || !views[name]) {
const ph = document.createElement('div');
ph.className = 'section empty';
ph.textContent = 'View not available.';
els.content.appendChild(ph);
currentView = null;
} else {
currentView = views[name](els.content);
}
const key = keyOf(hash);
Array.prototype.forEach.call(els.rail.querySelectorAll('.entry'), (a) => {
const href = a.getAttribute('href');
a.classList.toggle('active', !!href && (href === hash || keyOf(href) === key));
});
}
function loadNotifications() {
try {
const stored = JSON.parse(sessionStorage.getItem(NOTIFICATIONS_KEY) || '{}');
notifications = Array.isArray(stored.items) ? stored.items.slice(0, 30) : [];
unreadNotifications = Math.max(0, Number(stored.unread) || 0);
} catch (e) {
notifications = [];
unreadNotifications = 0;
}
}
function saveNotifications() {
try {
sessionStorage.setItem(NOTIFICATIONS_KEY, JSON.stringify({
items: notifications.slice(0, 30), unread: unreadNotifications
}));
} catch (e) {}
}
function notificationTime(timestamp) {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return '';
const today = new Date();
if (date.toDateString() === today.toDateString()) {
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
function renderNotifications() {
if (!els.notificationsList) return;
els.notificationsList.innerHTML = '';
els.notificationBadge.classList.toggle('hidden', unreadNotifications === 0);
els.notificationBadge.textContent = unreadNotifications > 99 ? '99+' : String(unreadNotifications);
els.notificationsClear.disabled = notifications.length === 0;
if (!notifications.length) {
const empty = document.createElement('div');
empty.className = 'notification-empty';
empty.textContent = 'No Notifications';
els.notificationsList.appendChild(empty);
return;
}
notifications.forEach((notice) => {
const item = document.createElement('div');
item.className = 'notification-item';
const marker = document.createElement('span');
marker.className = 'notification-kind ' + (notice.kind || 'info');
const message = document.createElement('span');
message.className = 'notification-message';
message.textContent = notice.message;
const time = document.createElement('time');
time.className = 'notification-time';
time.dateTime = new Date(notice.timestamp).toISOString();
time.textContent = notificationTime(notice.timestamp);
item.append(marker, message, time);
els.notificationsList.appendChild(item);
});
}
function addNotification(message, kind) {
const normalizedKind = ['error', 'success'].indexOf(kind) === -1 ? 'info' : kind;
notifications.unshift({
message: String(message).slice(0, 300), kind: normalizedKind, timestamp: Date.now()
});
notifications = notifications.slice(0, 30);
unreadNotifications += 1;
saveNotifications();
renderNotifications();
}
function toast(msg, kind, options) {
if (!options || options.notify !== false) addNotification(msg, kind);
const d = document.createElement('div');
d.className = 'toast ' + (kind || 'info');
d.textContent = msg;
els.toasts.appendChild(d);
setTimeout(() => d.remove(), 4000);
}
function closeToolbarMenus() {
if (!els.notificationsMenu) return;
els.notificationsMenu.classList.add('hidden');
els.overflowMenu.classList.add('hidden');
els.notificationsButton.setAttribute('aria-expanded', 'false');
els.overflowButton.setAttribute('aria-expanded', 'false');
}
function toggleToolbarMenu(name) {
const menu = name === 'notifications' ? els.notificationsMenu : els.overflowMenu;
const button = name === 'notifications' ? els.notificationsButton : els.overflowButton;
const opening = menu.classList.contains('hidden');
closeToolbarMenus();
if (!opening) return;
menu.classList.remove('hidden');
button.setAttribute('aria-expanded', 'true');
if (name === 'notifications') {
unreadNotifications = 0;
saveNotifications();
renderNotifications();
}
}
function setInternetStatus(state) {
const labels = { checking: 'Checking\u2026', online: 'Online', offline: 'Offline' };
els.internetStatus.textContent = labels[state];
els.internetStatusDot.className = 'connection-dot ' + state;
}
function checkInternet(announce) {
setInternetStatus('checking');
return PagerAPI.get('/api/settings/internet')
.then((response) => {
const online = !!response.data.online;
setInternetStatus(online ? 'online' : 'offline');
if (announce) toast(online ? 'Internet connection is online' : 'Internet connection is offline', online ? 'success' : 'error');
})
.catch(() => {
setInternetStatus('offline');
if (announce) toast('Unable to check the internet connection', 'error');
});
}
function handleMenuAction(action) {
closeToolbarMenus();
if (action === 'help') {
location.hash = '#/settings/help';
} else if (action === 'updates') {
location.hash = '#/settings/advanced';
toast('Update controls are available in Advanced settings');
} else if (action === 'internet') {
checkInternet(true);
} else if (action === 'logout') {
PagerAPI.post('/api/logout')
.then(() => showLogin())
.catch((error) => toast(error.message || 'Logout failed', 'error'));
} else if (action === 'reboot') {
if (!window.confirm('Reboot Mark VIII now?')) return;
PagerAPI.post('/api/settings/reboot')
.then(() => toast('Reboot requested. Mark VIII will disconnect shortly.'))
.catch((error) => toast(error.message || 'Reboot failed', 'error'));
}
}
function showDock(name) {
if (name === 'pager') {
if (typeof Term !== 'undefined' && Term.isOpen()) Term.close();
} else {
if (typeof Pager !== 'undefined' && Pager.isOpen()) Pager.close();
}
}
function showApp() {
els.login.classList.add('hidden');
els.app.classList.remove('hidden');
Theme.apply();
Live.start();
route();
checkInternet(false);
}
function showLogin() {
closeToolbarMenus();
if (typeof Live !== 'undefined') Live.stop();
if (typeof Term !== 'undefined' && Term.isOpen()) Term.close();
if (typeof Pager !== 'undefined' && Pager.isOpen()) Pager.close();
els.app.classList.add('hidden');
els.login.classList.remove('hidden');
}
function init() {
els.login = document.getElementById('login-screen');
els.app = document.getElementById('app');
els.rail = document.getElementById('rail');
els.content = document.getElementById('content');
els.toasts = document.getElementById('toast-container');
els.notificationsButton = document.getElementById('notifications-btn');
els.notificationsMenu = document.getElementById('notifications-menu');
els.notificationsList = document.getElementById('notifications-list');
els.notificationsClear = document.getElementById('notifications-clear');
els.notificationBadge = document.getElementById('notification-badge');
els.overflowButton = document.getElementById('overflow-btn');
els.overflowMenu = document.getElementById('overflow-menu');
els.internetStatus = document.getElementById('internet-status');
els.internetStatusDot = document.getElementById('internet-status-dot');
els.notificationsButton.innerHTML = PineappleIcons.notifications;
document.getElementById('terminal-btn').innerHTML = PineappleIcons.terminal;
document.getElementById('pager-btn').innerHTML = PineappleIcons.pager;
els.overflowButton.innerHTML = PineappleIcons.more_vert;
Array.prototype.forEach.call(document.querySelectorAll('[data-icon]'), (icon) => {
icon.innerHTML = PineappleIcons[icon.getAttribute('data-icon')] || '';
});
loadNotifications();
renderNotifications();
els.notificationsButton.addEventListener('click', (event) => {
event.stopPropagation();
toggleToolbarMenu('notifications');
});
els.overflowButton.addEventListener('click', (event) => {
event.stopPropagation();
toggleToolbarMenu('overflow');
});
els.notificationsMenu.addEventListener('click', (event) => event.stopPropagation());
els.overflowMenu.addEventListener('click', (event) => event.stopPropagation());
els.notificationsClear.addEventListener('click', () => {
notifications = [];
unreadNotifications = 0;
saveNotifications();
renderNotifications();
});
Array.prototype.forEach.call(els.overflowMenu.querySelectorAll('[data-menu-action]'), (item) => {
item.addEventListener('click', () => handleMenuAction(item.getAttribute('data-menu-action')));
});
document.addEventListener('click', closeToolbarMenus);
document.getElementById('login-form').addEventListener('submit', (e) => {
e.preventDefault();
const btn = document.getElementById('login-button');
const pw = document.getElementById('login-password').value;
document.getElementById('login-error').textContent = '';
btn.disabled = true;
PagerAPI.login('root', pw)
.then(() => { document.getElementById('login-password').value = ''; showApp(); toast('Logged in'); })
.catch((err) => {
document.getElementById('login-error').textContent = (err && err.message && err.message !== 'unauthorized')
? 'Login failed.' : 'Invalid credentials.';
})
.finally(() => { btn.disabled = false; });
});
document.getElementById('terminal-btn').addEventListener('click', () => {
if (typeof Term === 'undefined') { toast('Terminal not available yet'); return; }
showDock('terminal');
Term.toggle();
});
document.getElementById('terminal-close').addEventListener('click', () => {
if (typeof Term === 'undefined') { toast('Terminal not available yet'); return; }
showDock('terminal');
Term.toggle();
});
document.getElementById('pager-btn').addEventListener('click', () => {
if (typeof Pager === 'undefined') { toast('Virtual Pager not available yet'); return; }
showDock('pager');
Pager.toggle();
});
document.getElementById('pager-close').addEventListener('click', () => {
if (typeof Pager === 'undefined') { toast('Virtual Pager not available yet'); return; }
showDock('pager');
Pager.toggle();
});
window.addEventListener('hashchange', route);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeToolbarMenus();
return;
}
if (localStorage.getItem('pw_hotkeys') === 'false') return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const map = { d: '#/dashboard', p: '#/pineap', r: '#/recon', l: '#/logging', m: '#/modules' };
if (map[e.key.toLowerCase()]) { location.hash = map[e.key.toLowerCase()]; }
else if (e.key === '`') {
if (typeof Term === 'undefined') return;
if (typeof Pager !== 'undefined' && Pager.isOpen()) Pager.close();
Term.toggle();
}
});
PagerAPI.setBase(API_BASE);
PagerAPI.on401(() => showLogin());
buildRail();
Theme.apply();
checkSession();
}
function checkSession() {
PagerAPI.get('/api/api_ping')
.then(() => showApp())
.catch(() => showLogin());
}
const routes = {
'#/dashboard': 'dashboard',
'#/pineap': 'pineap',
'#/pineap/open': 'pineap_open',
'#/pineap/evilwpa': 'pineap_evilwpa',
'#/pineap/impersonation': 'pineap_impersonation',
'#/pineap/clients': 'pineap_clients',
'#/pineap/filtering': 'pineap_filtering',
'#/recon': 'recon',
'#/recon/handshakes': 'recon_handshakes',
'#/logging': 'logging',
'#/logging/system': 'logging_system',
'#/modules': 'modules',
'#/modules/online': 'modules_online',
'#/modules/running': 'modules_running',
'#/modules/develop': 'modules_develop',
'#/settings': 'settings',
'#/settings/networking': 'settings_networking',
'#/settings/wifi': 'settings_wifi',
'#/settings/led': 'settings_led',
'#/settings/advanced': 'settings_advanced',
'#/settings/help': 'settings_help'
};
return { init, route, toast, showLogin, wsUrl: (p) => WS_BASE + p, terminalWs: TERMINAL_WS,
pagerScreenWs: PAGER_SCREEN_WS, pagerKeysWs: PAGER_KEYS_WS, apiBase: API_BASE,
keyOf, railItems, go: (h) => { location.hash = h; },
get key() { return keyOf(location.hash); } };
})();
const Live = (() => {
let ws = null;
let ever = false;
let poll = null;
const subs = [];
let timer = null;
function stopPoll() {
if (poll) { clearInterval(poll); poll = null; }
}
function start() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
stopPoll();
try { ws = new WebSocket(App.wsUrl('/api/ws')); }
catch (e) { fallback(); return; }
ws.onopen = () => { ever = true; };
ws.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch (e) { return; }
subs.forEach((fn) => fn(msg));
updateBar(msg);
};
ws.onclose = () => {
ws = null;
clearTimeout(timer);
if (ever) timer = setTimeout(start, 5000);
else fallback();
};
ws.onerror = () => { try { ws.close(); } catch (e) {} };
}
function stop() {
ever = false;
clearTimeout(timer);
timer = null;
stopPoll();
if (ws) {
const active = ws;
ws = null;
active.onclose = null;
try { active.close(); } catch (e) {}
}
}
function fallback() {
stopPoll();
const seconds = Math.max(2, Math.min(120, parseInt(localStorage.getItem('pw-poll') || '5', 10) || 5));
poll = setInterval(async () => {
try {
const r = await PagerAPI.get('/api/status');
const msg = { type: 'tick', status: r.data, clients: r.data.clients };
subs.forEach((fn) => fn(msg));
updateBar(msg);
} catch (e) {}
}, seconds * 1000);
}
function updateBar(msg) {
const b = (msg.status || {}).battery || {};
const n = (msg.clients || []).length;
const el = document.getElementById('live-status');
if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n;
}
function onTick(fn) {
subs.push(fn);
return () => {
const i = subs.indexOf(fn);
if (i !== -1) subs.splice(i, 1);
};
}
return { start, stop, onTick };
})();
document.addEventListener('DOMContentLoaded', () => App.init());
@@ -0,0 +1,137 @@
'use strict';
const MiniChart = (() => {
function draw(canvas, series, opts) {
const o = opts || {};
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.clientWidth * dpr;
canvas.height = 140 * dpr;
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const w = canvas.clientWidth, h = 140;
ctx.clearRect(0, 0, w, h);
const max = Math.max(o.max || 10, ...series.map((s) => Math.max(...s.points, 0)), 1);
const pad = 8;
ctx.strokeStyle = o.grid || '#e0e0e0';
ctx.lineWidth = 1;
for (let g = 0; g <= 4; g++) {
const y = pad + (h - pad * 2) * g / 4;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
series.forEach((s) => {
const pts = s.points;
if (!pts || pts.length < 2) return;
ctx.strokeStyle = s.color || '#1976d2';
ctx.lineWidth = 2;
ctx.beginPath();
let started = false;
pts.forEach((v, i) => {
if (v == null) { started = false; return; }
const x = pad + (w - pad * 2) * i / Math.max(pts.length - 1, 1);
const y = h - pad - (h - pad * 2) * (v / max);
if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
});
ctx.stroke();
const last = pts[pts.length - 1];
if (last != null) {
const x = pad + (w - pad * 2) * (pts.length - 1) / Math.max(pts.length - 1, 1);
const y = h - pad - (h - pad * 2) * (last / max);
ctx.fillStyle = s.color || '#1976d2';
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill();
}
});
}
function doughnut(canvas, segments, opts) {
const o = opts || {};
const dpr = window.devicePixelRatio || 1;
const legendH = o.legend ? 22 : 0;
const H = (o.height || 160) + legendH;
canvas.width = canvas.clientWidth * dpr;
canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const w = canvas.clientWidth, h = o.height || 160;
ctx.clearRect(0, 0, w, H);
const cx = w / 2, cy = h / 2;
const r = Math.min(w, h) / 2 - 8;
const hole = (o.hole == null ? 0.65 : o.hole) * r;
const total = segments.reduce((s, x) => s + x.value, 0);
if (!total) {
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.arc(cx, cy, hole, 0, Math.PI * 2); ctx.stroke();
return;
}
let a0 = -Math.PI / 2;
segments.forEach((seg) => {
const a1 = a0 + (seg.value / total) * Math.PI * 2;
ctx.fillStyle = seg.color;
ctx.beginPath();
ctx.arc(cx, cy, r, a0, a1);
ctx.arc(cx, cy, hole, a1, a0, true);
ctx.closePath();
ctx.fill();
a0 = a1;
});
ctx.strokeStyle = o.stroke || '#ffffff';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.arc(cx, cy, hole, 0, Math.PI * 2); ctx.stroke();
if (o.legend) {
ctx.font = '11px Roboto, "Segoe UI", Arial, sans-serif';
const dots = segments.filter((s) => s.value > 0);
let tw = 0;
dots.forEach((s) => { tw += 16 + ctx.measureText(s.label).width + 8; });
tw = Math.max(tw - 8, 0);
let x = (w - tw) / 2;
const ly = h + 13;
dots.forEach((s) => {
ctx.fillStyle = s.color;
ctx.beginPath(); ctx.arc(x + 4, ly - 3, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#686868';
ctx.textAlign = 'left';
ctx.fillText(s.label, x + 12, ly);
x += 16 + ctx.measureText(s.label).width + 8;
});
}
}
function bar(canvas, items, opts) {
const o = opts || {};
const dpr = window.devicePixelRatio || 1;
const H = o.height || 160;
canvas.width = canvas.clientWidth * dpr;
canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const w = canvas.clientWidth, h = H;
ctx.clearRect(0, 0, w, h);
if (!items || !items.length) return;
const max = Math.max(1, ...items.map((i) => i.value));
const padB = 16, padT = 8, padL = 6, padR = 6;
const plotW = w - padL - padR, plotH = h - padT - padB;
const bw = plotW / items.length;
ctx.strokeStyle = o.grid || '#e0e0e0';
ctx.lineWidth = 1;
for (let g = 0; g <= 4; g++) {
const y = padT + plotH * g / 4;
ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(w - padR, y); ctx.stroke();
}
items.forEach((it, i) => {
const bh = it.value / max * plotH;
const x = padL + bw * i + bw * 0.15;
const wd = bw * 0.7;
const y = padT + plotH - bh;
ctx.fillStyle = it.color;
ctx.fillRect(x, y, wd, bh);
ctx.fillStyle = '#686868';
ctx.font = '10px Roboto, "Segoe UI", Arial, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(String(it.label), padL + bw * i + bw / 2, h - 4);
});
}
return { draw, doughnut, bar };
})();
@@ -0,0 +1,7 @@
window.PAGER_CONFIG = {
apiBase: '',
wsBase: '',
terminalWs: '',
pagerScreenWs: '',
pagerKeysWs: ''
};
@@ -0,0 +1,34 @@
'use strict';
window.PineappleIcons = {
dashboard: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,16A3,3 0 0,1 9,13C9,11.88 9.61,10.9 10.5,10.39L20.21,4.77L14.68,14.35C14.18,15.33 13.17,16 12,16M12,3C13.81,3 15.5,3.5 16.97,4.32L14.87,5.53C14,5.19 13,5 12,5A8,8 0 0,0 4,13C4,15.21 4.89,17.21 6.34,18.65H6.35C6.74,19.04 6.74,19.67 6.35,20.06C5.96,20.45 5.32,20.45 4.93,20.07V20.07C3.12,18.26 2,15.76 2,13A10,10 0 0,1 12,3M22,13C22,15.76 20.88,18.26 19.07,20.07V20.07C18.68,20.45 18.05,20.45 17.66,20.06C17.27,19.67 17.27,19.04 17.66,18.65V18.65C19.11,17.2 20,15.21 20,13C20,12 19.81,11 19.46,10.1L20.67,8C21.5,9.5 22,11.18 22,13Z"/></svg>',
pineap: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,21L15.6,16.2C16.2,15.4 16.8,14.5 17.2,13.6C18.1,11.5 18,9 18,9C18,6.5 16.5,4.3 15,3.5C13.5,2.7 10.5,2.7 9,3.5C7.5,4.3 6,6.5 6,9C6,9 5.9,11.5 6.8,13.6C7.2,14.5 7.8,15.4 8.4,16.2L12,21M12,5.5C13.4,5.5 14.5,6.6 14.5,8C14.5,9.4 13.4,10.5 12,10.5C10.6,10.5 9.5,9.4 9.5,8C9.5,6.6 10.6,5.5 12,5.5M7.1,13.1C7.1,13.1 8.2,14 12,14C15.8,14 16.9,13.1 16.9,13.1L15.9,12.1C15.9,12.1 14.8,12.8 12,12.8C9.2,12.8 8.1,12.1 8.1,12.1L7.1,13.1M12,17C10,17 9,17.6 9,17.6L10.3,19.3C10.3,19.3 11.1,19 12,19C12.9,19 13.7,19.3 13.7,19.3L15,17.6C15,17.6 14,17 12,17Z"/></svg>',
recon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11,6H13V13H11V6M9,20A1,1 0 0,1 8,21H5A1,1 0 0,1 4,20V15L6,6H10V13A1,1 0 0,1 9,14V20M10,5H7V3H10V5M15,20V14A1,1 0 0,1 14,13V6H18L20,15V20A1,1 0 0,1 19,21H16A1,1 0 0,1 15,20M14,5V3H17V5H14Z"/></svg>',
logging: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14,17H4V15H14V17M14,13H4V11H14V13M14,9H4V7H14V9M18,13V11H16V9H18V7H20V9H22V11H20V13H18M20,3H2A2,2 0 0,0 0,5V19A2,2 0 0,0 2,21H20A2,2 0 0,0 22,19V17H20V19H2V5H20V3Z"/></svg>',
modules: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z"/></svg>',
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 11.11V7A2 2 0 0 0 19 5H15V3A2 2 0 0 0 13 1H9A2 2 0 0 0 7 3V5H3A2 2 0 0 0 1 7V18A2 2 0 0 0 3 20H10.26A7 7 0 1 0 21 11.11M9 3H13V5H9M19 20A5 5 0 0 1 13 20A5 5 0 1 1 19 20M15 13H16.5V15.82L18.94 17.23L18.19 18.53L15 16.69V13"/></svg>',
chevron: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z"/></svg>',
terminal: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20,19V7H4V19H20M20,3A2,2 0 0,1 22,5V19A2,2 0 0,1 20,21H4A2,2 0 0,1 2,19V5C2,3.89 2.9,3 4,3H20M13,17V15H18V17H13M9.58,13L5.57,9H8.4L11.7,12.3C12.09,12.69 12.09,13.33 11.7,13.72L8.42,17H5.59L9.58,13Z"/></svg>',
wifi: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1,9L3,11C8,6 16,6 21,11L23,9C17,3 7,3 1,9M5,13L7,15C10,12.5 14,12.5 17,15L19,13C15,9 9,9 5,13M9,17L12,21L15,17C13.34,15.67 10.66,15.67 9,17Z"/></svg>',
extension: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z"/></svg>',
receipt: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14,17H4V15H14V17M14,13H4V11H14V13M14,9H4V7H14V9M18,13V11H16V9H18V7H20V9H22V11H20V13H18M20,3H2A2,2 0 0,0 0,5V19A2,2 0 0,0 2,21H20A2,2 0 0,0 22,19V17H20V19H2V5H20V3Z"/></svg>',
refresh: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"/></svg>',
file_download: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,9H15V3H9V9H5L12,16L19,9M5,18V20H19V18H5Z"/></svg>',
delete: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6,19C6,20.1 6.9,21 8,21H16C17.1,21 18,20.1 18,19V7H6V19M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19V4Z"/></svg>',
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94C19.18,12.64 19.2,12.33 19.2,12C19.2,11.68 19.18,11.36 19.13,11.06L21.16,9.48C21.34,9.34 21.39,9.07 21.28,8.87L19.36,5.55C19.24,5.33 18.99,5.26 18.77,5.33L16.38,6.29C15.88,5.91 15.35,5.59 14.76,5.35L14.4,2.81C14.36,2.57 14.16,2.4 13.92,2.4H10.08C9.84,2.4 9.65,2.57 9.61,2.81L9.25,5.35C8.66,5.59 8.12,5.91 7.63,6.29L5.24,5.33C5.02,5.26 4.77,5.33 4.65,5.55L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48L4.89,11.06C4.84,11.36 4.8,11.67 4.8,12C4.8,12.33 4.82,12.64 4.87,12.94L2.84,14.52C2.66,14.66 2.61,14.93 2.72,15.13L4.64,18.45C4.76,18.67 5.01,18.74 5.23,18.67L7.62,17.71C8.12,18.09 8.65,18.41 9.24,18.65L9.6,21.19C9.65,21.43 9.84,21.6 10.08,21.6H13.92C14.16,21.6 14.36,21.43 14.4,21.19L14.76,18.65C15.35,18.41 15.88,18.09 16.38,17.71L18.77,18.67C18.99,18.74 19.24,18.67 19.36,18.45L21.28,15.13C21.39,14.93 21.34,14.66 21.16,14.52L19.14,12.94M12,15.6C10.02,15.6 8.4,13.98 8.4,12C8.4,10.02 10.02,8.4 12,8.4C13.98,8.4 15.6,10.02 15.6,12C15.6,13.98 13.98,15.6 12,15.6Z"/></svg>',
search: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5C16,5.91 13.09,3 9.5,3C5.91,3 3,5.91 3,9.5C3,13.09 5.91,16 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.49L20.49,19L15.5,14M9.5,14C7.01,14 5,11.99 5,9.5C5,7.01 7.01,5 9.5,5C11.99,5 14,7.01 14,9.5C14,11.99 11.99,14 9.5,14Z"/></svg>',
first_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.41,16.59L13.82,12L18.41,7.41L17,6L11,12L17,18L18.41,16.59M6,6H8V18H6V6Z"/></svg>',
last_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.59,7.41L10.18,12L5.59,16.59L7,18L13,12L7,6L5.59,7.41M16,6H18V18H16V6Z"/></svg>',
chevron_left: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z"/></svg>',
check: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9,16.17L4.83,12L3.41,13.41L9,19L21,7L19.59,5.59L9,16.17Z"/></svg>',
close: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/></svg>',
question_mark: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.07,12.85C11.07,12.85 12.23,12.5 13.03,11.64C13.83,10.79 13.85,9.61 13.24,8.63C12.5,7.58 11.3,7.63 10.67,7.85C10.17,8.03 9.9,8.36 9.63,8.84L8.4,8.12C8.77,7.42 9.23,6.82 9.98,6.39C11.09,5.78 12.58,5.66 13.85,6.53C15.12,7.41 15.83,8.85 15.42,10.13C15.04,11.31 14.05,11.96 13.03,12.45C12.44,12.73 12,13.06 12,13.86V14H11.07V12.85M11,16H12.93V18H11V16Z"/></svg>',
chevron_right: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z"/></svg>',
notifications: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,22A2,2 0 0,0 14,20H10A2,2 0 0,0 12,22M18,16V11C18,7.93 16.36,5.36 13.5,4.68V4A1.5,1.5 0 0,0 10.5,4V4.68C7.63,5.36 6,7.92 6,11V16L4,18V19H20V18L18,16Z"/></svg>',
pager: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17,1H7A2,2 0 0,0 5,3V21A2,2 0 0,0 7,23H17A2,2 0 0,0 19,21V3A2,2 0 0,0 17,1M17,19H7V5H17V19M9,7H15V9H9V7M9,11H15V13H9V11Z"/></svg>',
more_vert: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,8A2,2 0 1,0 12,4A2,2 0 0,0 12,8M12,10A2,2 0 1,0 12,14A2,2 0 0,0 12,10M12,16A2,2 0 1,0 12,20A2,2 0 0,0 12,16Z"/></svg>',
help: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A10,10 0 1,0 22,12A10,10 0 0,0 12,2M13,19H11V17H13V19M15.07,11.25L14.17,12.17C13.45,12.9 13,13.5 13,15H11V14.5C11,13.4 11.45,12.4 12.17,11.67L13.41,10.41C13.78,10.05 14,9.55 14,9A2,2 0 0,0 10,9H8A4,4 0 0,1 16,9C16,9.88 15.64,10.68 15.07,11.25Z"/></svg>',
update: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21,10.12H14.22L16.96,7.3C14.23,4.6 9.81,4.5 7.08,7.2A6.85,6.85 0 0,0 7.08,17C9.81,19.7 14.23,19.7 16.96,17C18.32,15.65 19,14.08 19,12.1H21C21,14.08 20.18,16.4 18.36,18.2C14.85,21.7 9.15,21.7 5.64,18.2C2.14,14.72 2.14,9.05 5.64,5.57C9.15,2.08 14.85,2.08 18.36,5.57L21,2.88V10.12M12.5,8V12.25L16,14.33L15.28,15.54L11,13V8H12.5Z"/></svg>',
logout: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14.08,15.59L16.67,13H7V11H16.67L14.08,8.41L15.5,7L20.5,12L15.5,17L14.08,15.59M5,3H13A2,2 0 0,1 15,5V8H13V5H5V19H13V16H15V19A2,2 0 0,1 13,21H5A2,2 0 0,1 3,19V5A2,2 0 0,1 5,3Z"/></svg>',
reboot: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13,3H11V13H13V3M17.83,5.17L16.42,6.58A7,7 0 1,1 7.58,6.58L6.17,5.17A9,9 0 1,0 17.83,5.17Z"/></svg>'
};
@@ -0,0 +1,158 @@
'use strict';
const Pager = (() => {
const SCREEN_WIDTH = 480;
const SCREEN_HEIGHT = 222;
const FB_STRIDE = SCREEN_WIDTH * 4;
const PAGER_WIDTH = 745;
const KEY_MAP = {
'LEFT.png': 'ArrowLeft',
'UP.png': 'ArrowUp',
'RIGHT.png': 'ArrowRight',
'DOWN.png': 'ArrowDown',
'A_Button.png': 'Enter',
'B_Button.png': 'Escape'
};
let panel = null;
let bodyEl = null;
let table = null;
let pager = null;
let canvas = null;
let screenerr = null;
let keyws = null;
let screenws = null;
function ensure() {
if (table) return;
panel = document.getElementById('pager-panel');
bodyEl = document.getElementById('pager-body');
table = document.getElementById('pager_ui');
pager = document.getElementById('pager');
canvas = document.getElementById('pager_canvas');
screenerr = document.getElementById('pager_error');
table.querySelectorAll('img.pager-btn').forEach((img) => {
const src = img.getAttribute('src').split('/').pop();
const key = KEY_MAP[src];
if (!key) return;
img.addEventListener('click', () => press(img, key));
});
const retry = document.getElementById('screen_retry');
if (retry) retry.addEventListener('click', () => connect());
}
function press(el, key) {
el.classList.add('pressed');
setTimeout(() => el.classList.remove('pressed'), 80);
sendKey(key);
}
function sendKey(k) {
if (keyws && keyws.readyState === WebSocket.OPEN) keyws.send(k);
}
function renderRGBAFrame(bytes) {
if (bytes.length < FB_STRIDE * SCREEN_HEIGHT) {
console.warn('pager frame too small for 480x222', bytes.length);
return;
}
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(SCREEN_WIDTH, SCREEN_HEIGHT);
const dst = imageData.data;
let di = 0;
for (let y = 0; y < SCREEN_HEIGHT; y++) {
const rowStart = y * FB_STRIDE;
for (let x = 0; x < SCREEN_WIDTH; x++) {
const si = rowStart + x * 4;
dst[di] = bytes[si];
dst[di + 1] = bytes[si + 1];
dst[di + 2] = bytes[si + 2];
dst[di + 3] = bytes[si + 3];
di += 4;
}
}
ctx.putImageData(imageData, 0, 0);
pager.src = canvas.toDataURL('image/png');
}
function connect() {
disconnect();
try {
const sock = new WebSocket(App.pagerScreenWs);
sock.binaryType = 'arraybuffer';
screenws = sock;
sock.onopen = () => { screenerr.hidden = true; };
sock.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) renderRGBAFrame(new Uint8Array(ev.data));
else if (ev.data && ev.data.arrayBuffer) ev.data.arrayBuffer().then((b) => renderRGBAFrame(new Uint8Array(b)));
};
sock.onerror = () => { screenerr.hidden = false; };
sock.onclose = () => { if (screenws === sock) screenws = null; screenerr.hidden = false; };
} catch (e) {
screenerr.hidden = false;
}
try {
const sock = new WebSocket(App.pagerKeysWs);
keyws = sock;
sock.onclose = () => { if (keyws === sock) keyws = null; };
sock.onerror = () => { try { sock.close(); } catch (e2) {} };
} catch (e) {
keyws = null;
}
}
function disconnect() {
if (screenws) { try { screenws.close(); } catch (e) {} screenws = null; }
if (keyws) { try { keyws.close(); } catch (e) {} keyws = null; }
}
function applyScale() {
if (!bodyEl || !table) return;
const availableWidth = bodyEl.clientWidth - 20;
const scale = Math.min(1, availableWidth / PAGER_WIDTH);
table.style.zoom = String(scale);
table.style.marginBottom = '';
}
function toggle() {
ensure();
if (panel.classList.contains('hidden')) {
panel.classList.remove('hidden');
document.getElementById('pager-btn').classList.add('active');
applyScale();
connect();
try { pager.focus(); } catch (e) {}
} else {
panel.classList.add('hidden');
document.getElementById('pager-btn').classList.remove('active');
disconnect();
}
}
function isOpen() {
return !!panel && !panel.classList.contains('hidden');
}
function close() {
if (isOpen()) toggle();
}
window.addEventListener('resize', () => {
if (table && panel && !panel.classList.contains('hidden')) applyScale();
});
document.addEventListener('keydown', (e) => {
if (!isOpen()) return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT'
|| t.tagName === 'BUTTON' || t.tagName === 'A' || t.isContentEditable)) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const keys = ['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'Enter', 'Escape'];
if (keys.indexOf(e.key) === -1) return;
if (e.key.indexOf('Arrow') === 0) e.preventDefault();
if (!e.repeat) sendKey(e.key);
});
return { toggle, close, isOpen };
})();
@@ -0,0 +1,72 @@
'use strict';
const Term = (() => {
let term = null;
let fitAddon = null;
let ws = null;
let panel = null;
function ensure() {
if (term) return;
panel = document.getElementById('terminal-panel');
term = new Terminal({ cursorBlink: true, scrollback: 2000, cols: 80, rows: 24 });
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal'));
try { fitAddon.fit(); } catch (e) {}
term.onData((d) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(d); });
}
function toggle() {
ensure();
if (panel.classList.contains('hidden')) {
panel.classList.remove('hidden');
document.getElementById('terminal-btn').classList.add('active');
try { fitAddon.fit(); } catch (e) {}
connect();
} else {
panel.classList.add('hidden');
document.getElementById('terminal-btn').classList.remove('active');
disconnect();
}
}
function connect() {
if (ws) return;
if (term) term.reset();
let sock;
try {
sock = new WebSocket(App.terminalWs);
} catch (e) {
term.writeln('\r\n[cannot reach daemon terminal: ' + e.message + ']');
return;
}
ws = sock;
sock.onmessage = (ev) => {
if (typeof ev.data === 'string') term.write(ev.data);
else ev.data.text().then((t) => term.write(t));
};
sock.onclose = () => { if (ws === sock) ws = null; if (term) term.writeln('\r\n[connection closed]'); };
sock.onerror = () => { try { sock.close(); } catch (e) {} };
}
function disconnect() {
if (ws) { try { ws.close(); } catch (e) {} ws = null; }
}
function isOpen() {
return !!panel && !panel.classList.contains('hidden');
}
function close() {
if (isOpen()) toggle();
}
window.addEventListener('resize', () => {
if (fitAddon && panel && !panel.classList.contains('hidden')) {
try { fitAddon.fit(); } catch (e) {}
}
});
return { toggle, close, isOpen };
})();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
@@ -0,0 +1,209 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
File diff suppressed because one or more lines are too long