fix: harden UI actions and daemon calls for reliable control (v1.3.2)
Retry and serialize pineapd/hak5 calls, queue virtual-pager keys, and grey out buttons until the pager finishes. Deploy now installs python3-light after factory firmware. Bump version to 1.3.2. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -62,6 +62,10 @@ body {
|
||||
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:disabled, .toolbar-icon-btn.busy,
|
||||
.menu-link:disabled, .menu-link.busy {
|
||||
opacity: .45; cursor: wait; pointer-events: 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;
|
||||
@@ -200,12 +204,26 @@ html.dark .health-chip.bad { background: #4a2020; color: #ffb4a9; }
|
||||
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); }
|
||||
.btn:disabled, .btn.busy {
|
||||
background: #9e9e9e; color: #fafafa; opacity: 1;
|
||||
cursor: wait; pointer-events: none; box-shadow: none;
|
||||
}
|
||||
.btn.ghost:disabled, .btn.ghost.busy {
|
||||
background: var(--surface-alt); color: var(--muted);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn.danger:disabled, .btn.danger.busy { background: #9e9e9e; color: #fafafa; }
|
||||
html.dark .btn:disabled, html.dark .btn.busy {
|
||||
background: #616161; color: #eeeeee;
|
||||
}
|
||||
html.dark .btn.ghost:disabled, html.dark .btn.ghost.busy {
|
||||
background: var(--surface-alt); color: var(--muted);
|
||||
}
|
||||
input, select {
|
||||
background: var(--surface); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 2px; padding: 8px 10px; width: 100%;
|
||||
@@ -293,6 +311,9 @@ pre.logs {
|
||||
.switch input:checked + .track::after { left: 22px; }
|
||||
.switch input:indeterminate + .track { background: #9e9e9e; }
|
||||
.switch input:indeterminate + .track::after { left: 12px; }
|
||||
.switch:has(input:disabled), .switch:has(input.busy) {
|
||||
opacity: .55; cursor: wait; pointer-events: none;
|
||||
}
|
||||
.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); }
|
||||
@@ -342,7 +363,7 @@ html.dark .muted { color: #bdbdbd; }
|
||||
.recon-ps-actions .icon-btn svg { width: 18px; height: 18px; }
|
||||
.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:disabled, .icon-btn.busy { opacity: .38; cursor: wait; pointer-events: none; }
|
||||
.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; }
|
||||
@@ -406,7 +427,7 @@ html.dark .recon-dbm-bar { background: #333; }
|
||||
.recon-chip.active { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
.recon-pill { border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: 12px; padding: 3px 11px; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.recon-pill:hover { color: var(--text); border-color: var(--primary); }
|
||||
.recon-pill:disabled { opacity: .5; cursor: default; }
|
||||
.recon-pill:disabled, .recon-pill.busy { opacity: .5; cursor: wait; pointer-events: none; }
|
||||
.recon-pill.on { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; }
|
||||
html.dark .recon-pill.on { background: #1b3a23; color: #81c784; }
|
||||
|
||||
@@ -475,7 +496,7 @@ html.dark .modal { background: #303030; }
|
||||
.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; }
|
||||
.seg-btn:disabled, .seg-btn.busy { opacity: .5; cursor: wait; 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; }
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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=20260819-1">
|
||||
<link rel="stylesheet" href="css/app.css?v=20260820-4">
|
||||
<link rel="stylesheet" href="js/xterm.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -262,13 +262,13 @@
|
||||
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/icons.js?v=20260818-7"></script>
|
||||
<script src="js/api.js?v=20260819-2"></script>
|
||||
<script src="js/api.js?v=20260820-4"></script>
|
||||
<script src="js/chart.js?v=20260819-1"></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=20260819-2"></script>
|
||||
<script src="js/app.js?v=20260819-1"></script>
|
||||
<script src="js/terminal.js?v=20260820-4"></script>
|
||||
<script src="js/pager.js?v=20260820-4"></script>
|
||||
<script src="js/views.js?v=20260820-4"></script>
|
||||
<script src="js/app.js?v=20260820-4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,32 +3,32 @@
|
||||
const PagerAPI = (() => {
|
||||
let apiBase = '';
|
||||
let on401 = null;
|
||||
// Reads are polled and must never hang a page's refresh loop; writes have
|
||||
// server-side timeouts up to 45s (radio deploys) so they get no client
|
||||
// abort.
|
||||
const GET_TIMEOUT_MS = 20000;
|
||||
async function request(method, path, body) {
|
||||
const WRITE_TIMEOUT_MS = 45000;
|
||||
async function request(method, path, body, attempt) {
|
||||
attempt = attempt || 0;
|
||||
const opts = { method, headers: {}, credentials: 'include' };
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const timeoutMs = method === 'GET' ? GET_TIMEOUT_MS : WRITE_TIMEOUT_MS;
|
||||
const ctl = new AbortController();
|
||||
const timer = method === 'GET' ? setTimeout(() => ctl.abort(), GET_TIMEOUT_MS) : null;
|
||||
if (timer) opts.signal = ctl.signal;
|
||||
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||
opts.signal = ctl.signal;
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(apiBase + path, opts);
|
||||
} catch (e) {
|
||||
if (timer && e && e.name === 'AbortError') {
|
||||
const error = new Error('Request timed out');
|
||||
error.status = 0;
|
||||
throw error;
|
||||
clearTimeout(timer);
|
||||
if (method === 'GET' && attempt < 1) {
|
||||
return request(method, path, body, attempt + 1);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
const error = new Error((e && e.name === 'AbortError') ? 'Request timed out' : (e && e.message) || 'Network error');
|
||||
error.status = 0;
|
||||
throw error;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
if (res.status === 401) {
|
||||
if (on401) on401();
|
||||
throw new Error('unauthorized');
|
||||
|
||||
@@ -241,7 +241,7 @@ const App = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
function handleMenuAction(action) {
|
||||
function handleMenuAction(action, button) {
|
||||
closeToolbarMenus();
|
||||
if (action === 'help') {
|
||||
location.hash = '#/settings/help';
|
||||
@@ -252,14 +252,18 @@ const App = (() => {
|
||||
if (typeof views.openClientModeModal === 'function') views.openClientModeModal();
|
||||
else checkInternet(true);
|
||||
} else if (action === 'logout') {
|
||||
if (button) { button.disabled = true; button.classList.add('busy'); }
|
||||
PagerAPI.post('/api/logout')
|
||||
.then(() => showLogin())
|
||||
.catch((error) => toast(error.message || 'Logout failed', 'error'));
|
||||
.catch((error) => toast(error.message || 'Logout failed', 'error'))
|
||||
.finally(() => { if (button) { button.disabled = false; button.classList.remove('busy'); } });
|
||||
} else if (action === 'reboot') {
|
||||
if (!window.confirm('Reboot Mark VIII now?')) return;
|
||||
if (button) { button.disabled = true; button.classList.add('busy'); }
|
||||
PagerAPI.post('/api/settings/reboot')
|
||||
.then(() => toast('Reboot requested. Mark VIII will disconnect shortly.'))
|
||||
.catch((error) => toast(error.message || 'Reboot failed', 'error'));
|
||||
.catch((error) => toast(error.message || 'Reboot failed', 'error'))
|
||||
.finally(() => { if (button) { button.disabled = false; button.classList.remove('busy'); } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +336,7 @@ const App = (() => {
|
||||
renderNotifications();
|
||||
});
|
||||
Array.prototype.forEach.call(els.overflowMenu.querySelectorAll('[data-menu-action]'), (item) => {
|
||||
item.addEventListener('click', () => handleMenuAction(item.getAttribute('data-menu-action')));
|
||||
item.addEventListener('click', () => handleMenuAction(item.getAttribute('data-menu-action'), item));
|
||||
});
|
||||
document.addEventListener('click', closeToolbarMenus);
|
||||
|
||||
@@ -342,13 +346,14 @@ const App = (() => {
|
||||
const pw = document.getElementById('login-password').value;
|
||||
document.getElementById('login-error').textContent = '';
|
||||
btn.disabled = true;
|
||||
btn.classList.add('busy');
|
||||
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; });
|
||||
.finally(() => { btn.disabled = false; btn.classList.remove('busy'); });
|
||||
});
|
||||
|
||||
document.getElementById('terminal-btn').addEventListener('click', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ const Pager = (() => {
|
||||
const SCREEN_HEIGHT = 222;
|
||||
const FB_STRIDE = SCREEN_WIDTH * 4;
|
||||
const PAGER_WIDTH = 745;
|
||||
const MAX_QUEUED_KEYS = 24;
|
||||
|
||||
const KEY_MAP = {
|
||||
'LEFT.png': 'ArrowLeft',
|
||||
@@ -23,6 +24,10 @@ const Pager = (() => {
|
||||
let screenerr = null;
|
||||
let keyws = null;
|
||||
let screenws = null;
|
||||
let wantOpen = false;
|
||||
let retryTimer = null;
|
||||
let retryMs = 400;
|
||||
const pendingKeys = [];
|
||||
|
||||
function ensure() {
|
||||
if (table) return;
|
||||
@@ -36,10 +41,17 @@ const Pager = (() => {
|
||||
const src = img.getAttribute('src').split('/').pop();
|
||||
const key = KEY_MAP[src];
|
||||
if (!key) return;
|
||||
img.addEventListener('click', () => press(img, key));
|
||||
img.setAttribute('alt', key.replace('Arrow', ''));
|
||||
img.addEventListener('pointerdown', (event) => {
|
||||
event.preventDefault();
|
||||
press(img, key);
|
||||
});
|
||||
});
|
||||
const retry = document.getElementById('screen_retry');
|
||||
if (retry) retry.addEventListener('click', () => connect());
|
||||
if (retry) retry.addEventListener('click', () => {
|
||||
retryMs = 400;
|
||||
connect();
|
||||
});
|
||||
}
|
||||
|
||||
function press(el, key) {
|
||||
@@ -48,8 +60,26 @@ const Pager = (() => {
|
||||
sendKey(key);
|
||||
}
|
||||
|
||||
function queueKey(k) {
|
||||
pendingKeys.push(k);
|
||||
while (pendingKeys.length > MAX_QUEUED_KEYS) pendingKeys.shift();
|
||||
}
|
||||
|
||||
function flushKeys() {
|
||||
while (pendingKeys.length && keyws && keyws.readyState === WebSocket.OPEN) {
|
||||
try { keyws.send(pendingKeys.shift()); }
|
||||
catch (e) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
function sendKey(k) {
|
||||
if (keyws && keyws.readyState === WebSocket.OPEN) keyws.send(k);
|
||||
if (keyws && keyws.readyState === WebSocket.OPEN) {
|
||||
try { keyws.send(k); return true; }
|
||||
catch (e) {}
|
||||
}
|
||||
queueKey(k);
|
||||
if (wantOpen) connectKeys();
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderRGBAFrame(bytes) {
|
||||
@@ -76,33 +106,81 @@ const Pager = (() => {
|
||||
pager.src = canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
function connect() {
|
||||
disconnect();
|
||||
function showError(show) {
|
||||
if (screenerr) screenerr.hidden = !show;
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!wantOpen) return;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = setTimeout(() => {
|
||||
if (!wantOpen) return;
|
||||
connectScreen();
|
||||
connectKeys();
|
||||
}, retryMs);
|
||||
retryMs = Math.min(5000, Math.max(400, retryMs * 2));
|
||||
}
|
||||
|
||||
function connectScreen() {
|
||||
if (!wantOpen) return;
|
||||
if (screenws && (screenws.readyState === WebSocket.OPEN || screenws.readyState === WebSocket.CONNECTING)) return;
|
||||
try {
|
||||
const sock = new WebSocket(App.pagerScreenWs);
|
||||
sock.binaryType = 'arraybuffer';
|
||||
screenws = sock;
|
||||
sock.onopen = () => { screenerr.hidden = true; };
|
||||
sock.onopen = () => {
|
||||
retryMs = 400;
|
||||
if (keyws && keyws.readyState === WebSocket.OPEN) showError(false);
|
||||
};
|
||||
sock.onmessage = (ev) => {
|
||||
showError(false);
|
||||
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; };
|
||||
sock.onerror = () => { showError(true); };
|
||||
sock.onclose = () => {
|
||||
if (screenws === sock) screenws = null;
|
||||
showError(true);
|
||||
scheduleReconnect();
|
||||
};
|
||||
} 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;
|
||||
showError(true);
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function connectKeys() {
|
||||
if (!wantOpen) return;
|
||||
if (keyws && (keyws.readyState === WebSocket.OPEN || keyws.readyState === WebSocket.CONNECTING)) return;
|
||||
try {
|
||||
const sock = new WebSocket(App.pagerKeysWs);
|
||||
keyws = sock;
|
||||
sock.onopen = () => {
|
||||
retryMs = 400;
|
||||
flushKeys();
|
||||
if (screenws && screenws.readyState === WebSocket.OPEN) showError(false);
|
||||
};
|
||||
sock.onclose = () => {
|
||||
if (keyws === sock) keyws = null;
|
||||
scheduleReconnect();
|
||||
};
|
||||
sock.onerror = () => { try { sock.close(); } catch (e2) {} };
|
||||
} catch (e) {
|
||||
keyws = null;
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
connectScreen();
|
||||
connectKeys();
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
wantOpen = false;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
pendingKeys.length = 0;
|
||||
if (screenws) { try { screenws.close(); } catch (e) {} screenws = null; }
|
||||
if (keyws) { try { keyws.close(); } catch (e) {} keyws = null; }
|
||||
}
|
||||
@@ -121,6 +199,8 @@ const Pager = (() => {
|
||||
panel.classList.remove('hidden');
|
||||
document.getElementById('pager-btn').classList.add('active');
|
||||
applyScale();
|
||||
wantOpen = true;
|
||||
retryMs = 400;
|
||||
connect();
|
||||
try { pager.focus(); } catch (e) {}
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,9 @@ const Term = (() => {
|
||||
let fitAddon = null;
|
||||
let ws = null;
|
||||
let panel = null;
|
||||
let wantOpen = false;
|
||||
let retryTimer = null;
|
||||
let retryMs = 400;
|
||||
|
||||
function ensure() {
|
||||
if (term) return;
|
||||
@@ -14,7 +17,20 @@ const Term = (() => {
|
||||
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); });
|
||||
term.onData((d) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(d); } catch (e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!wantOpen) return;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = setTimeout(() => {
|
||||
if (wantOpen) connect();
|
||||
}, retryMs);
|
||||
retryMs = Math.min(5000, Math.max(400, retryMs * 2));
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
@@ -23,6 +39,8 @@ const Term = (() => {
|
||||
panel.classList.remove('hidden');
|
||||
document.getElementById('terminal-btn').classList.add('active');
|
||||
try { fitAddon.fit(); } catch (e) {}
|
||||
wantOpen = true;
|
||||
retryMs = 400;
|
||||
connect();
|
||||
} else {
|
||||
panel.classList.add('hidden');
|
||||
@@ -32,25 +50,39 @@ const Term = (() => {
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (ws) return;
|
||||
if (!wantOpen) return;
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) 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 + ']');
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
ws = sock;
|
||||
sock.onopen = () => { retryMs = 400; };
|
||||
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.onclose = () => {
|
||||
if (ws === sock) ws = null;
|
||||
if (term && wantOpen) {
|
||||
term.writeln('\r\n[connection closed — reconnecting]');
|
||||
scheduleReconnect();
|
||||
} else if (term) {
|
||||
term.writeln('\r\n[connection closed]');
|
||||
}
|
||||
};
|
||||
sock.onerror = () => { try { sock.close(); } catch (e) {} };
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
wantOpen = false;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
if (ws) { try { ws.close(); } catch (e) {} ws = null; }
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user