mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Merge branch 'screentinker:main' into main
This commit is contained in:
commit
98e742c612
|
|
@ -115,6 +115,11 @@ Schema migrations run automatically on first boot — no manual migration comman
|
||||||
| `JWT_SECRET` | JWT signing key (auto-generated if not set) | _(auto)_ |
|
| `JWT_SECRET` | JWT signing key (auto-generated if not set) | _(auto)_ |
|
||||||
| `SSL_CERT` | Path to SSL certificate | `server/certs/cert.pem` |
|
| `SSL_CERT` | Path to SSL certificate | `server/certs/cert.pem` |
|
||||||
| `SSL_KEY` | Path to SSL private key | `server/certs/key.pem` |
|
| `SSL_KEY` | Path to SSL private key | `server/certs/key.pem` |
|
||||||
|
| `PING_INTERVAL` | Socket.IO Engine.IO ping interval (ms). Raise for slow TV WebKits that miss pongs under decode load. | `30000` |
|
||||||
|
| `PING_TIMEOUT` | Socket.IO Engine.IO pong wait (ms). Lower = faster dead-socket detection; higher = more forgiving of laggy clients. | `30000` |
|
||||||
|
| `HEARTBEAT_INTERVAL` | App-level offline-checker frequency (ms). How often the server sweeps the device list looking for stale heartbeats. | `10000` |
|
||||||
|
| `HEARTBEAT_TIMEOUT` | How long without an app-level heartbeat (ms) before marking a device offline. Raise for slow/jittery networks. | `45000` |
|
||||||
|
| `COMMAND_QUEUE_TTL_MS` | How long the server holds commands and playlist-updates for a device that's offline at emit time (ms). Flushed in order on reconnect within this window; dropped past TTL. | `30000` |
|
||||||
|
|
||||||
### Optional Integrations
|
### Optional Integrations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -369,6 +369,9 @@ export default {
|
||||||
'device.toast.launch_sent': 'Launch command sent',
|
'device.toast.launch_sent': 'Launch command sent',
|
||||||
'device.toast.update_triggered': 'Update check triggered',
|
'device.toast.update_triggered': 'Update check triggered',
|
||||||
'device.toast.remote_started': 'Remote session started',
|
'device.toast.remote_started': 'Remote session started',
|
||||||
|
'device.toast.command_queued': '{cmd} — device offline, will deliver on reconnect',
|
||||||
|
'device.toast.command_undeliverable': '{cmd} — device offline and queue unavailable',
|
||||||
|
'device.toast.command_no_ack': '{cmd} — no server response',
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
'settings.title': 'Settings',
|
'settings.title': 'Settings',
|
||||||
|
|
|
||||||
|
|
@ -119,8 +119,20 @@ export function sendKey(deviceId, keycode) {
|
||||||
if (dashboardSocket) dashboardSocket.emit('dashboard:remote-key', { device_id: deviceId, keycode });
|
if (dashboardSocket) dashboardSocket.emit('dashboard:remote-key', { device_id: deviceId, keycode });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendCommand(deviceId, type, payload) {
|
// Optional callback receives the server-side ack: { delivered, queued, reason }.
|
||||||
if (dashboardSocket) dashboardSocket.emit('dashboard:device-command', { device_id: deviceId, type, payload });
|
// Callers without a callback keep firing-and-forgetting (no behavior change).
|
||||||
|
// With a callback, we use Socket.IO's .timeout() so the callback always fires -
|
||||||
|
// either with the ack or with an Error if the server doesn't respond in 5s.
|
||||||
|
export function sendCommand(deviceId, type, payload, callback) {
|
||||||
|
if (!dashboardSocket) return;
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
dashboardSocket.timeout(5000).emit('dashboard:device-command', { device_id: deviceId, type, payload }, (err, ack) => {
|
||||||
|
if (err) callback({ delivered: false, reason: 'no_ack' });
|
||||||
|
else callback(ack || { delivered: false, reason: 'no_ack' });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dashboardSocket.emit('dashboard:device-command', { device_id: deviceId, type, payload });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSocket() { return dashboardSocket; }
|
export function getSocket() { return dashboardSocket; }
|
||||||
|
|
|
||||||
|
|
@ -705,14 +705,26 @@ async function setupActions(device) {
|
||||||
}, 3000);
|
}, 3000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send a command and surface the three-state ack as a toast.
|
||||||
|
// - delivered: device received it (green/success)
|
||||||
|
// - queued: device is offline, will deliver on reconnect (amber/warning)
|
||||||
|
// - no_ack / fallback: server didn't respond or queue unavailable (red/error)
|
||||||
|
function sendWithFeedback(type, cmdLabel, successKey) {
|
||||||
|
sendCommand(device.id, type, {}, (ack) => {
|
||||||
|
if (ack?.delivered) showToast(t(successKey), 'success');
|
||||||
|
else if (ack?.queued) showToast(t('device.toast.command_queued', { cmd: cmdLabel }), 'warning');
|
||||||
|
else if (ack?.reason === 'no_ack') showToast(t('device.toast.command_no_ack', { cmd: cmdLabel }), 'error');
|
||||||
|
else showToast(t('device.toast.command_undeliverable', { cmd: cmdLabel }), 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Reboot (double-click to confirm)
|
// Reboot (double-click to confirm)
|
||||||
const rebootBtn = document.getElementById('rebootBtn');
|
const rebootBtn = document.getElementById('rebootBtn');
|
||||||
let rebootConfirming = false;
|
let rebootConfirming = false;
|
||||||
let rebootTimeout = null;
|
let rebootTimeout = null;
|
||||||
rebootBtn?.addEventListener('click', () => {
|
rebootBtn?.addEventListener('click', () => {
|
||||||
if (rebootConfirming) {
|
if (rebootConfirming) {
|
||||||
sendCommand(device.id, 'reboot', {});
|
sendWithFeedback('reboot', 'Reboot', 'device.toast.reboot_sent');
|
||||||
showToast(t('device.toast.reboot_sent'), 'info');
|
|
||||||
rebootConfirming = false;
|
rebootConfirming = false;
|
||||||
rebootBtn.textContent = t('device.ctl.reboot_device');
|
rebootBtn.textContent = t('device.ctl.reboot_device');
|
||||||
return;
|
return;
|
||||||
|
|
@ -732,8 +744,7 @@ async function setupActions(device) {
|
||||||
let shutdownTimeout = null;
|
let shutdownTimeout = null;
|
||||||
shutdownBtn?.addEventListener('click', () => {
|
shutdownBtn?.addEventListener('click', () => {
|
||||||
if (shutdownConfirming) {
|
if (shutdownConfirming) {
|
||||||
sendCommand(device.id, 'shutdown', {});
|
sendWithFeedback('shutdown', 'Shutdown', 'device.toast.shutdown_sent');
|
||||||
showToast(t('device.toast.shutdown_sent'), 'info');
|
|
||||||
shutdownConfirming = false;
|
shutdownConfirming = false;
|
||||||
shutdownBtn.textContent = t('device.ctl.shutdown');
|
shutdownBtn.textContent = t('device.ctl.shutdown');
|
||||||
return;
|
return;
|
||||||
|
|
@ -753,26 +764,22 @@ async function setupActions(device) {
|
||||||
|
|
||||||
// Screen Off
|
// Screen Off
|
||||||
document.getElementById('screenOffBtn')?.addEventListener('click', () => {
|
document.getElementById('screenOffBtn')?.addEventListener('click', () => {
|
||||||
sendCommand(device.id, 'screen_off', {});
|
sendWithFeedback('screen_off', 'Screen off', 'device.toast.screen_off_sent');
|
||||||
showToast(t('device.toast.screen_off_sent'), 'info');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Screen On
|
// Screen On
|
||||||
document.getElementById('screenOnBtn')?.addEventListener('click', () => {
|
document.getElementById('screenOnBtn')?.addEventListener('click', () => {
|
||||||
sendCommand(device.id, 'screen_on', {});
|
sendWithFeedback('screen_on', 'Screen on', 'device.toast.screen_on_sent');
|
||||||
showToast(t('device.toast.screen_on_sent'), 'info');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Launch Player
|
// Launch Player
|
||||||
document.getElementById('launchAppBtn')?.addEventListener('click', () => {
|
document.getElementById('launchAppBtn')?.addEventListener('click', () => {
|
||||||
sendCommand(device.id, 'launch', {});
|
sendWithFeedback('launch', 'Launch', 'device.toast.launch_sent');
|
||||||
showToast(t('device.toast.launch_sent'), 'info');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Force Update
|
// Force Update
|
||||||
document.getElementById('forceUpdateBtn')?.addEventListener('click', () => {
|
document.getElementById('forceUpdateBtn')?.addEventListener('click', () => {
|
||||||
sendCommand(device.id, 'update', {});
|
sendWithFeedback('update', 'Update', 'device.toast.update_triggered');
|
||||||
showToast(t('device.toast.update_triggered'), 'info');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,24 @@
|
||||||
.platform-item .icon { font-size:40px; margin-bottom:8px; }
|
.platform-item .icon { font-size:40px; margin-bottom:8px; }
|
||||||
.platform-item .name { font-size:13px; color:var(--muted); }
|
.platform-item .name { font-size:13px; color:var(--muted); }
|
||||||
|
|
||||||
|
/* Modal (mirrors frontend/css/main.css conventions so screenshots look
|
||||||
|
consistent across landing and dashboard; copied inline since
|
||||||
|
landing.html doesn't import main.css). */
|
||||||
|
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); display:flex; align-items:center; justify-content:center; z-index:1000; padding:16px; }
|
||||||
|
.modal { background:var(--card); border:1px solid var(--border); border-radius:12px; width:100%; max-width:560px; max-height:90vh; overflow-y:auto; }
|
||||||
|
.modal-header { padding:20px 24px; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center; }
|
||||||
|
.modal-header h3 { font-size:18px; margin:0; }
|
||||||
|
.modal-close { background:none; border:none; color:var(--muted); font-size:24px; cursor:pointer; padding:0; line-height:1; }
|
||||||
|
.modal-body { padding:20px 24px; }
|
||||||
|
.modal-description { color:var(--muted); font-size:14px; margin-bottom:16px; }
|
||||||
|
.modal-footer { padding:16px 24px; border-top:1px solid var(--border); display:flex; gap:12px; justify-content:flex-end; }
|
||||||
|
.modal-body label { display:block; margin-bottom:12px; font-size:13px; color:var(--text); }
|
||||||
|
.modal-body input, .modal-body select, .modal-body textarea { width:100%; margin-top:4px; padding:8px 10px; background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:6px; font-size:14px; font-family:inherit; box-sizing:border-box; }
|
||||||
|
.modal-body input:focus, .modal-body select:focus, .modal-body textarea:focus { outline:none; border-color:var(--accent); }
|
||||||
|
.modal-body textarea { resize:vertical; }
|
||||||
|
.contact-status-success { color:#10b981; font-size:13px; }
|
||||||
|
.contact-status-error { color:#f87171; font-size:13px; }
|
||||||
|
|
||||||
/* Pricing */
|
/* Pricing */
|
||||||
.pricing { max-width:1200px; margin:0 auto; padding:80px 24px; }
|
.pricing { max-width:1200px; margin:0 auto; padding:80px 24px; }
|
||||||
.pricing h2 { text-align:center; font-size:36px; margin-bottom:12px; }
|
.pricing h2 { text-align:center; font-size:36px; margin-bottom:12px; }
|
||||||
|
|
@ -376,6 +394,56 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Enterprise contact form modal. Opened by the Enterprise / Custom card's
|
||||||
|
Contact Us button. Submits to POST /api/contact/enterprise which sends
|
||||||
|
the inquiry via Microsoft Graph to dan@bytetinker.net. -->
|
||||||
|
<div class="modal-overlay" id="contactModal" style="display:none">
|
||||||
|
<div class="modal" role="dialog" aria-labelledby="contactModalTitle" aria-modal="true">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="contactModalTitle">Enterprise Inquiry</h3>
|
||||||
|
<button type="button" class="modal-close" onclick="closeContactModal()" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="modal-description">Tell us about your deployment and we'll get back to you within one business day.</p>
|
||||||
|
<form id="contactForm" novalidate>
|
||||||
|
<label>Name *<input name="name" type="text" required autocomplete="name" maxlength="200"></label>
|
||||||
|
<label>Email *<input name="email" type="email" required autocomplete="email" maxlength="200"></label>
|
||||||
|
<label>Company / organization *<input name="company" type="text" required autocomplete="organization" maxlength="200"></label>
|
||||||
|
<label>Estimated number of screens *<input name="screens" type="number" min="1" max="100000" required></label>
|
||||||
|
<label>Multi-tenant? *
|
||||||
|
<select name="multi_tenant" required>
|
||||||
|
<option value="">Select one</option>
|
||||||
|
<option value="single">Single organization</option>
|
||||||
|
<option value="multi">Multiple organizations / managing screens for multiple clients</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Hosting preference *
|
||||||
|
<select name="hosting" required>
|
||||||
|
<option value="">Select one</option>
|
||||||
|
<option value="hosted">Hosted for me</option>
|
||||||
|
<option value="self">Self-host</option>
|
||||||
|
<option value="unsure">Not sure yet</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Message<textarea name="message" rows="4" maxlength="5000"></textarea></label>
|
||||||
|
<!-- Honeypot: hidden from real users (off-screen + aria-hidden +
|
||||||
|
tabindex=-1), bots fill anything they see. If this comes back
|
||||||
|
populated, the server returns success but drops the submission.
|
||||||
|
Field name 'fax_number' is plausible enough to fool mid-tier
|
||||||
|
bots that explicitly skip the obvious 'website' name. -->
|
||||||
|
<div style="position:absolute;left:-9999px" aria-hidden="true">
|
||||||
|
<label>Fax number<input name="fax_number" type="text" tabindex="-1" autocomplete="off"></label>
|
||||||
|
</div>
|
||||||
|
<div id="contactFormStatus" style="min-height:20px;margin-top:8px"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline" onclick="closeContactModal()">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="contactSubmitBtn" onclick="submitContactForm()">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Structured Data for Google -->
|
<!-- Structured Data for Google -->
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{
|
{
|
||||||
|
|
@ -495,10 +563,18 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Load pricing from API
|
// Load pricing from API. The 'enterprise' plan is filtered out of the
|
||||||
|
// public render because its DB row has price=0 / max=-1 (renders as
|
||||||
|
// "Free" + "Unlimited") which would undercut the actual Free tier and
|
||||||
|
// confuse visitors. The row itself stays in the DB - it's actively used
|
||||||
|
// for self-hosted first-user assignment and white-label gating - we just
|
||||||
|
// replace it on the public marketing page with a hardcoded Contact Us
|
||||||
|
// card. Other consumers of /api/subscription/plans (billing.js,
|
||||||
|
// settings.js, admin.js) get the full list as before.
|
||||||
fetch('/api/subscription/plans').then(r => r.json()).then(plans => {
|
fetch('/api/subscription/plans').then(r => r.json()).then(plans => {
|
||||||
const grid = document.getElementById('pricingGrid');
|
const grid = document.getElementById('pricingGrid');
|
||||||
grid.innerHTML = plans.filter(p => p.active).map((p, i) => `
|
const publicPlans = plans.filter(p => p.active && p.name !== 'enterprise');
|
||||||
|
const planCardsHtml = publicPlans.map((p, i) => `
|
||||||
<div class="price-card ${i === 2 ? 'featured' : ''}">
|
<div class="price-card ${i === 2 ? 'featured' : ''}">
|
||||||
<h3>${p.display_name}</h3>
|
<h3>${p.display_name}</h3>
|
||||||
<div class="price">${p.price_monthly > 0 ? '$' + p.price_monthly : 'Free'}<span>${p.price_monthly > 0 ? '/mo' : ''}</span></div>
|
<div class="price">${p.price_monthly > 0 ? '$' + p.price_monthly : 'Free'}<span>${p.price_monthly > 0 ? '/mo' : ''}</span></div>
|
||||||
|
|
@ -513,6 +589,25 @@
|
||||||
<a href="/app#/login" class="btn ${i === 0 ? 'btn-outline' : 'btn-primary'}" style="width:100%;justify-content:center">${p.price_monthly > 0 ? 'Start Trial' : 'Get Started'}</a>
|
<a href="/app#/login" class="btn ${i === 0 ? 'btn-outline' : 'btn-primary'}" style="width:100%;justify-content:center">${p.price_monthly > 0 ? 'Start Trial' : 'Get Started'}</a>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
// Hardcoded Enterprise / Custom card. Uses .price class for vertical
|
||||||
|
// alignment with the other cards (same baseline for the feature list);
|
||||||
|
// empty .yearly spacer matches the Free card's structure.
|
||||||
|
const enterpriseCardHtml = `
|
||||||
|
<div class="price-card">
|
||||||
|
<h3>Enterprise / Custom</h3>
|
||||||
|
<div class="price">Let's talk</div>
|
||||||
|
<div class="yearly"> </div>
|
||||||
|
<ul>
|
||||||
|
<li>Everything in Business</li>
|
||||||
|
<li>Multi-tenant / multiple organizations</li>
|
||||||
|
<li>Volume device pricing</li>
|
||||||
|
<li>Custom hosting options</li>
|
||||||
|
<li>Priority support</li>
|
||||||
|
</ul>
|
||||||
|
<button type="button" class="btn btn-primary" style="width:100%;justify-content:center" onclick="openContactModal()">Contact Us</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
grid.innerHTML = planCardsHtml + enterpriseCardHtml;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Smooth scroll for anchor links
|
// Smooth scroll for anchor links
|
||||||
|
|
@ -523,6 +618,73 @@
|
||||||
if (target) { e.preventDefault(); target.scrollIntoView({ behavior: 'smooth' }); }
|
if (target) { e.preventDefault(); target.scrollIntoView({ behavior: 'smooth' }); }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Contact modal: open/close, submit, validation. Functions are attached
|
||||||
|
// to window so the inline onclick handlers on the Contact Us button (in
|
||||||
|
// the dynamically-rendered pricing card) can find them.
|
||||||
|
window.openContactModal = function() {
|
||||||
|
document.getElementById('contactModal').style.display = 'flex';
|
||||||
|
setTimeout(() => document.querySelector('#contactForm input[name="name"]')?.focus(), 50);
|
||||||
|
};
|
||||||
|
window.closeContactModal = function() {
|
||||||
|
document.getElementById('contactModal').style.display = 'none';
|
||||||
|
const status = document.getElementById('contactFormStatus');
|
||||||
|
status.className = '';
|
||||||
|
status.textContent = '';
|
||||||
|
};
|
||||||
|
window.submitContactForm = async function() {
|
||||||
|
const form = document.getElementById('contactForm');
|
||||||
|
const status = document.getElementById('contactFormStatus');
|
||||||
|
const btn = document.getElementById('contactSubmitBtn');
|
||||||
|
status.className = '';
|
||||||
|
status.textContent = '';
|
||||||
|
const data = Object.fromEntries(new FormData(form).entries());
|
||||||
|
const required = ['name', 'email', 'company', 'screens', 'multi_tenant', 'hosting'];
|
||||||
|
for (const f of required) {
|
||||||
|
if (!data[f] || String(data[f]).trim() === '') {
|
||||||
|
status.className = 'contact-status-error';
|
||||||
|
status.textContent = 'Please fill all required fields.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||||
|
status.className = 'contact-status-error';
|
||||||
|
status.textContent = 'Please enter a valid email address.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/contact/enterprise', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
status.className = 'contact-status-success';
|
||||||
|
status.textContent = "Thanks! We'll be in touch within one business day.";
|
||||||
|
form.reset();
|
||||||
|
setTimeout(() => window.closeContactModal(), 3000);
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
status.className = 'contact-status-error';
|
||||||
|
status.textContent = err.error || "Sorry, something went wrong. Try emailing dan@bytetinker.net directly.";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
status.className = 'contact-status-error';
|
||||||
|
status.textContent = "Network error. Try emailing dan@bytetinker.net directly.";
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Close on background click + Escape
|
||||||
|
document.getElementById('contactModal').addEventListener('click', e => {
|
||||||
|
if (e.target.id === 'contactModal') window.closeContactModal();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape' && document.getElementById('contactModal').style.display === 'flex') {
|
||||||
|
window.closeContactModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,22 @@ module.exports = {
|
||||||
contentDir: path.join(__dirname, 'uploads', 'content'),
|
contentDir: path.join(__dirname, 'uploads', 'content'),
|
||||||
screenshotsDir: path.join(__dirname, 'uploads', 'screenshots'),
|
screenshotsDir: path.join(__dirname, 'uploads', 'screenshots'),
|
||||||
frontendDir: path.join(__dirname, '..', 'frontend'),
|
frontendDir: path.join(__dirname, '..', 'frontend'),
|
||||||
heartbeatInterval: 10000, // Check every 10s
|
// App-level heartbeat. Checker runs every heartbeatInterval and marks
|
||||||
heartbeatTimeout: 45000, // Offline after 45s (3 missed 15s beats)
|
// devices offline if last_heartbeat is older than heartbeatTimeout.
|
||||||
|
// Env override for self-hosters on slow/jittery networks (issue #3:
|
||||||
|
// reporter found raising HEARTBEAT_TIMEOUT to 60s reduced false offlines).
|
||||||
|
heartbeatInterval: parseInt(process.env.HEARTBEAT_INTERVAL) || 10000,
|
||||||
|
heartbeatTimeout: parseInt(process.env.HEARTBEAT_TIMEOUT) || 45000,
|
||||||
|
// How long the server holds commands/playlist-updates for a device that's
|
||||||
|
// offline at emit time (ms). On reconnect within this window, queued events
|
||||||
|
// are flushed in order. Past TTL they're dropped. See lib/command-queue.js.
|
||||||
|
commandQueueTtlMs: parseInt(process.env.COMMAND_QUEUE_TTL_MS) || 30000,
|
||||||
|
// Engine.IO transport-level ping/pong. Raised from Socket.IO defaults
|
||||||
|
// (25000/20000) because TV WebKits (LG webOS, older Tizen) miss pongs
|
||||||
|
// under decode load - tighter values cause spurious transport drops.
|
||||||
|
// Worst-case dead-socket detection: pingInterval + pingTimeout = 60s.
|
||||||
|
pingInterval: parseInt(process.env.PING_INTERVAL) || 30000,
|
||||||
|
pingTimeout: parseInt(process.env.PING_TIMEOUT) || 30000,
|
||||||
maxFileSize: 500 * 1024 * 1024, // 500MB
|
maxFileSize: 500 * 1024 * 1024, // 500MB
|
||||||
thumbnailWidth: 320,
|
thumbnailWidth: 320,
|
||||||
screenshotQuality: 70,
|
screenshotQuality: 70,
|
||||||
|
|
|
||||||
153
server/lib/command-queue.js
Normal file
153
server/lib/command-queue.js
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
// Short-lived per-device queue for events that target a currently-offline
|
||||||
|
// device. Designed for the TV-flap case where a device disconnects for a few
|
||||||
|
// seconds (Engine.IO ping miss, Wi-Fi blip, decode stall) and reconnects via
|
||||||
|
// Socket.IO's auto-reconnect. Without this queue, any device:command or
|
||||||
|
// device:playlist-update emitted during the disconnect window goes nowhere -
|
||||||
|
// the room is empty, the emit is silently dropped.
|
||||||
|
//
|
||||||
|
// Two structures, both keyed by device_id, both pruned by TTL:
|
||||||
|
//
|
||||||
|
// pendingPlaylistUpdate: Map<deviceId, { expiresAt }>
|
||||||
|
// We don't store the payload. On flush we rebuild via buildPlaylistPayload
|
||||||
|
// so the device gets the LATEST DB state, not a stale snapshot from when
|
||||||
|
// the update was first queued.
|
||||||
|
//
|
||||||
|
// pendingCommands: Map<deviceId, Map<type, { payload, expiresAt }>>
|
||||||
|
// One entry per command type per device. Last-of-type wins (the most
|
||||||
|
// recent screen_off supersedes any earlier ones). Payloads stored verbatim
|
||||||
|
// because commands are stateless declarations.
|
||||||
|
//
|
||||||
|
// Memory bounds: worst-case ~6 entries per device (1 playlist marker + 5
|
||||||
|
// command types), each ~200 bytes. 10,000 offline devices = ~12MB. Sweep
|
||||||
|
// thread prunes empty per-device records every 30s.
|
||||||
|
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
const pendingPlaylistUpdate = new Map();
|
||||||
|
const pendingCommands = new Map();
|
||||||
|
|
||||||
|
let _sweepTimer = null;
|
||||||
|
|
||||||
|
// Internal helper - drop expired entries for a single device. Called lazily
|
||||||
|
// from queue/flush paths AND from the sweep thread.
|
||||||
|
function pruneDevice(deviceId) {
|
||||||
|
const now = Date.now();
|
||||||
|
const pu = pendingPlaylistUpdate.get(deviceId);
|
||||||
|
if (pu && pu.expiresAt <= now) pendingPlaylistUpdate.delete(deviceId);
|
||||||
|
|
||||||
|
const cmds = pendingCommands.get(deviceId);
|
||||||
|
if (cmds) {
|
||||||
|
for (const [type, entry] of cmds) {
|
||||||
|
if (entry.expiresAt <= now) cmds.delete(type);
|
||||||
|
}
|
||||||
|
if (cmds.size === 0) pendingCommands.delete(deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark a pending playlist-update for a device. Caller used to call
|
||||||
|
// deviceNs.to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId));
|
||||||
|
// directly. Now they call queueOrEmitPlaylistUpdate which checks room presence
|
||||||
|
// first and queues only if the device is offline.
|
||||||
|
function queueOrEmitPlaylistUpdate(deviceNs, deviceId, buildPayload) {
|
||||||
|
if (!deviceNs || !deviceId || typeof buildPayload !== 'function') return { delivered: false };
|
||||||
|
const room = deviceNs.adapter.rooms.get(deviceId);
|
||||||
|
if (room && room.size > 0) {
|
||||||
|
deviceNs.to(deviceId).emit('device:playlist-update', buildPayload(deviceId));
|
||||||
|
return { delivered: true };
|
||||||
|
}
|
||||||
|
pendingPlaylistUpdate.set(deviceId, { expiresAt: Date.now() + config.commandQueueTtlMs });
|
||||||
|
return { delivered: false, queued: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue a single command for an offline device. Returns true if accepted
|
||||||
|
// (always true under current logic; reserved for future "rejected because
|
||||||
|
// stale/full" cases). Used by item 6 in commit D - dashboard command handler
|
||||||
|
// calls this when the device room is empty.
|
||||||
|
function queueCommand(deviceId, type, payload) {
|
||||||
|
if (!deviceId || !type) return false;
|
||||||
|
let perDevice = pendingCommands.get(deviceId);
|
||||||
|
if (!perDevice) {
|
||||||
|
perDevice = new Map();
|
||||||
|
pendingCommands.set(deviceId, perDevice);
|
||||||
|
}
|
||||||
|
perDevice.set(type, { payload: payload || {}, expiresAt: Date.now() + config.commandQueueTtlMs });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called on device:register success, after heartbeat.registerConnection and
|
||||||
|
// socket.join. Drains both queues to the just-reconnected device.
|
||||||
|
//
|
||||||
|
// buildPayload is the buildPlaylistPayload function from deviceSocket.js,
|
||||||
|
// passed in to avoid a circular require. We call it at flush time so the
|
||||||
|
// playlist reflects current DB state, not whatever it was when queued.
|
||||||
|
function flushQueue(deviceNs, deviceId, buildPayload) {
|
||||||
|
if (!deviceNs || !deviceId) return { playlistUpdate: false, commands: 0 };
|
||||||
|
pruneDevice(deviceId);
|
||||||
|
|
||||||
|
let playlistUpdate = false;
|
||||||
|
let commands = 0;
|
||||||
|
|
||||||
|
const pu = pendingPlaylistUpdate.get(deviceId);
|
||||||
|
if (pu) {
|
||||||
|
pendingPlaylistUpdate.delete(deviceId);
|
||||||
|
if (typeof buildPayload === 'function') {
|
||||||
|
deviceNs.to(deviceId).emit('device:playlist-update', buildPayload(deviceId));
|
||||||
|
playlistUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmds = pendingCommands.get(deviceId);
|
||||||
|
if (cmds) {
|
||||||
|
pendingCommands.delete(deviceId);
|
||||||
|
for (const [type, entry] of cmds) {
|
||||||
|
deviceNs.to(deviceId).emit('device:command', { type, payload: entry.payload });
|
||||||
|
commands++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlistUpdate || commands > 0) {
|
||||||
|
console.log(`Flushed queue for ${deviceId}: playlistUpdate=${playlistUpdate}, commands=${commands}`);
|
||||||
|
}
|
||||||
|
return { playlistUpdate, commands };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueueDepth(deviceId) {
|
||||||
|
pruneDevice(deviceId);
|
||||||
|
const hasPlaylist = pendingPlaylistUpdate.has(deviceId) ? 1 : 0;
|
||||||
|
const cmdCount = pendingCommands.get(deviceId)?.size || 0;
|
||||||
|
return hasPlaylist + cmdCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active sweep prunes devices that never come back. Without this, a device
|
||||||
|
// that goes permanently offline leaves its queue entries in memory until TTL,
|
||||||
|
// which is fine, but the Map keys themselves linger. Cheap to walk.
|
||||||
|
function startSweep() {
|
||||||
|
if (_sweepTimer) return;
|
||||||
|
_sweepTimer = setInterval(() => {
|
||||||
|
for (const deviceId of pendingPlaylistUpdate.keys()) pruneDevice(deviceId);
|
||||||
|
for (const deviceId of pendingCommands.keys()) pruneDevice(deviceId);
|
||||||
|
}, 30000);
|
||||||
|
if (_sweepTimer.unref) _sweepTimer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSweep() {
|
||||||
|
if (_sweepTimer) { clearInterval(_sweepTimer); _sweepTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test helpers - reset internal state. Not exported via module.exports for
|
||||||
|
// production callers; bound below for the test harness only.
|
||||||
|
function _resetForTests() {
|
||||||
|
pendingPlaylistUpdate.clear();
|
||||||
|
pendingCommands.clear();
|
||||||
|
stopSweep();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
queueOrEmitPlaylistUpdate,
|
||||||
|
queueCommand,
|
||||||
|
flushQueue,
|
||||||
|
getQueueDepth,
|
||||||
|
startSweep,
|
||||||
|
stopSweep,
|
||||||
|
_resetForTests,
|
||||||
|
};
|
||||||
|
|
@ -496,6 +496,14 @@
|
||||||
reconnectionDelay: 2000,
|
reconnectionDelay: 2000,
|
||||||
reconnectionDelayMax: 10000,
|
reconnectionDelayMax: 10000,
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
|
// Prefer WebSocket but allow polling fallback. Socket.IO default is
|
||||||
|
// polling-first with an upgrade dance that's fragile on TV WebKits
|
||||||
|
// (LG webOS especially). Reversing the order opens a WebSocket directly;
|
||||||
|
// if that fails (rare - blocked by firewall), it falls back to polling
|
||||||
|
// on the same connect attempt. Tradeoff: WS-blocked networks add a few
|
||||||
|
// seconds to first connect while WS times out. Worth it for the common
|
||||||
|
// case where WS is fine but the upgrade dance was hanging the device.
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
|
|
|
||||||
85
server/routes/contact.js
Normal file
85
server/routes/contact.js
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
// Public (unauthenticated) contact form endpoint. Used by the Enterprise /
|
||||||
|
// Custom card on the marketing landing page to send a lead to Dan's inbox via
|
||||||
|
// the existing Microsoft Graph email service.
|
||||||
|
//
|
||||||
|
// Honeypot strategy: the form has a hidden 'fax_number' field that real users
|
||||||
|
// never see (off-screen + aria-hidden + tabindex=-1). If a submission arrives
|
||||||
|
// with that field populated, we return success to the bot but drop the
|
||||||
|
// submission silently. Combined with the rate limit applied in server.js
|
||||||
|
// (5 req/min/IP+path), this is enough friction for a low-traffic public form.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { sendEmail } = require('../services/email');
|
||||||
|
|
||||||
|
function isEmail(s) {
|
||||||
|
return typeof s === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
|
||||||
|
}
|
||||||
|
function clamp(s, max) {
|
||||||
|
return String(s || '').slice(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/enterprise', async (req, res) => {
|
||||||
|
const { name, email, company, screens, multi_tenant, hosting, message, fax_number } = req.body || {};
|
||||||
|
|
||||||
|
// Honeypot. Real users can't see or tab to this field; only bots fill it.
|
||||||
|
// Return 200 so the bot's retry logic doesn't kick in, but skip the send.
|
||||||
|
if (fax_number && String(fax_number).trim() !== '') {
|
||||||
|
console.log(`[contact] honeypot triggered from ${req.ip}; dropping`);
|
||||||
|
return res.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side validation. Client validates too but we never trust that.
|
||||||
|
if (!name || !email || !company || !screens || !multi_tenant || !hosting) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
if (!isEmail(email)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid email address' });
|
||||||
|
}
|
||||||
|
const screensNum = parseInt(screens);
|
||||||
|
if (!Number.isFinite(screensNum) || screensNum < 1 || screensNum > 100000) {
|
||||||
|
return res.status(400).json({ error: 'Screens must be a positive number' });
|
||||||
|
}
|
||||||
|
if (!['single', 'multi'].includes(multi_tenant)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid multi-tenant selection' });
|
||||||
|
}
|
||||||
|
if (!['hosted', 'self', 'unsure'].includes(hosting)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid hosting selection' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length caps - keeps a 10MB textarea from filling the mailbox
|
||||||
|
const cleanName = clamp(name, 200);
|
||||||
|
const cleanEmail = clamp(email, 200);
|
||||||
|
const cleanCompany = clamp(company, 200);
|
||||||
|
const cleanMessage = clamp(message, 5000);
|
||||||
|
|
||||||
|
const tenantLabel = multi_tenant === 'multi' ? 'Multiple organizations' : 'Single organization';
|
||||||
|
const hostingLabel = { hosted: 'Hosted for me', self: 'Self-host', unsure: 'Not sure yet' }[hosting];
|
||||||
|
|
||||||
|
const subject = `Enterprise inquiry: ${cleanCompany}`;
|
||||||
|
const text =
|
||||||
|
`New enterprise inquiry from ${cleanName} (${cleanEmail})
|
||||||
|
|
||||||
|
Company: ${cleanCompany}
|
||||||
|
Estimated screens: ${screensNum}
|
||||||
|
Multi-tenant: ${tenantLabel}
|
||||||
|
Hosting preference: ${hostingLabel}
|
||||||
|
|
||||||
|
Message:
|
||||||
|
${cleanMessage || '(none)'}
|
||||||
|
|
||||||
|
---
|
||||||
|
Submitted from screentinker.com pricing page
|
||||||
|
Source IP: ${req.ip}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await sendEmail({ to: 'dan@bytetinker.net', subject, text });
|
||||||
|
if (!result.sent) {
|
||||||
|
console.error(`[contact] email send failed for ${cleanEmail}: reason=${result.reason} error=${result.error || ''}`);
|
||||||
|
return res.status(500).json({ error: 'Could not send your message. Please email dan@bytetinker.net directly.' });
|
||||||
|
}
|
||||||
|
console.log(`[contact] enterprise inquiry from ${cleanEmail} (${cleanCompany}) delivered`);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -432,8 +432,10 @@ router.delete('/:id', (req, res) => {
|
||||||
const io = req.app.get('io');
|
const io = req.app.get('io');
|
||||||
if (io) {
|
if (io) {
|
||||||
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
||||||
|
const commandQueue = require('../lib/command-queue');
|
||||||
|
const deviceNs = io.of('/device');
|
||||||
for (const d of affectedDevices) {
|
for (const d of affectedDevices) {
|
||||||
io.of('/device').to(d.device_id).emit('device:playlist-update', buildPlaylistPayload(d.device_id));
|
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, d.device_id, buildPlaylistPayload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) { /* silent */ }
|
} catch (e) { /* silent */ }
|
||||||
|
|
|
||||||
|
|
@ -217,8 +217,8 @@ function pushPlaylistToDevice(req, deviceId) {
|
||||||
const io = req.app.get('io');
|
const io = req.app.get('io');
|
||||||
if (!io) return;
|
if (!io) return;
|
||||||
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
||||||
const deviceNs = io.of('/device');
|
const commandQueue = require('../lib/command-queue');
|
||||||
deviceNs.to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId));
|
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), deviceId, buildPlaylistPayload);
|
||||||
} catch (e) { /* silent */ }
|
} catch (e) { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,9 +89,11 @@ function pushToDevices(playlistId, req) {
|
||||||
const io = req.app.get('io');
|
const io = req.app.get('io');
|
||||||
if (!io) return;
|
if (!io) return;
|
||||||
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
||||||
|
const commandQueue = require('../lib/command-queue');
|
||||||
|
const deviceNs = io.of('/device');
|
||||||
const devices = db.prepare('SELECT id FROM devices WHERE playlist_id = ?').all(playlistId);
|
const devices = db.prepare('SELECT id FROM devices WHERE playlist_id = ?').all(playlistId);
|
||||||
for (const d of devices) {
|
for (const d of devices) {
|
||||||
io.of('/device').to(d.id).emit('device:playlist-update', buildPlaylistPayload(d.id));
|
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, d.id, buildPlaylistPayload);
|
||||||
}
|
}
|
||||||
} catch (e) { /* silent */ }
|
} catch (e) { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
@ -449,7 +451,8 @@ router.post('/:id/assign', requirePlaylistWrite, (req, res) => {
|
||||||
const io = req.app.get('io');
|
const io = req.app.get('io');
|
||||||
if (io) {
|
if (io) {
|
||||||
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
||||||
io.of('/device').to(device_id).emit('device:playlist-update', buildPlaylistPayload(device_id));
|
const commandQueue = require('../lib/command-queue');
|
||||||
|
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), device_id, buildPlaylistPayload);
|
||||||
}
|
}
|
||||||
} catch (e) { /* silent */ }
|
} catch (e) { /* silent */ }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,8 @@ function pushWallPayloadToDevice(req, deviceId) {
|
||||||
const io = req.app.get('io');
|
const io = req.app.get('io');
|
||||||
if (!io) return;
|
if (!io) return;
|
||||||
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
||||||
io.of('/device').to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId));
|
const commandQueue = require('../lib/command-queue');
|
||||||
|
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), deviceId, buildPlaylistPayload);
|
||||||
} catch (e) { /* silent */ }
|
} catch (e) { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,9 @@ const io = new Server(server, {
|
||||||
origin: (origin, cb) => corsOriginCheck(origin, cb),
|
origin: (origin, cb) => corsOriginCheck(origin, cb),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
},
|
},
|
||||||
maxHttpBufferSize: 10 * 1024 * 1024 // 10MB for screenshot uploads
|
maxHttpBufferSize: 10 * 1024 * 1024, // 10MB for screenshot uploads
|
||||||
|
pingInterval: config.pingInterval,
|
||||||
|
pingTimeout: config.pingTimeout,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
|
|
@ -232,6 +234,11 @@ app.use('/api/content', rateLimit(60000, 30)); // 30 content operations per minu
|
||||||
// Subscription routes (mixed auth)
|
// Subscription routes (mixed auth)
|
||||||
app.use('/api/subscription', require('./routes/subscription'));
|
app.use('/api/subscription', require('./routes/subscription'));
|
||||||
|
|
||||||
|
// Public contact form (enterprise inquiries from landing page). Rate limited
|
||||||
|
// to 5 submissions per minute per IP; honeypot enforced inside the route.
|
||||||
|
app.use('/api/contact', rateLimit(60000, 5));
|
||||||
|
app.use('/api/contact', require('./routes/contact'));
|
||||||
|
|
||||||
// Stripe billing routes (checkout, portal)
|
// Stripe billing routes (checkout, portal)
|
||||||
app.use('/api/stripe', stripeRouter);
|
app.use('/api/stripe', stripeRouter);
|
||||||
|
|
||||||
|
|
@ -434,6 +441,10 @@ app.set('io', io);
|
||||||
const { startHeartbeatChecker } = require('./services/heartbeat');
|
const { startHeartbeatChecker } = require('./services/heartbeat');
|
||||||
startHeartbeatChecker(io);
|
startHeartbeatChecker(io);
|
||||||
|
|
||||||
|
// Start command-queue sweep (prunes expired entries for offline devices)
|
||||||
|
const commandQueue = require('./lib/command-queue');
|
||||||
|
commandQueue.startSweep();
|
||||||
|
|
||||||
// Start scheduler
|
// Start scheduler
|
||||||
const { startScheduler } = require('./services/scheduler');
|
const { startScheduler } = require('./services/scheduler');
|
||||||
startScheduler(io);
|
startScheduler(io);
|
||||||
|
|
|
||||||
|
|
@ -106,8 +106,8 @@ function parseSimpleRRule(rrule) {
|
||||||
function pushPlaylistToDevice(deviceId, deviceNs) {
|
function pushPlaylistToDevice(deviceId, deviceNs) {
|
||||||
// Use the single-source buildPlaylistPayload from deviceSocket
|
// Use the single-source buildPlaylistPayload from deviceSocket
|
||||||
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
||||||
const payload = buildPlaylistPayload(deviceId);
|
const commandQueue = require('../lib/command-queue');
|
||||||
deviceNs.to(deviceId).emit('device:playlist-update', payload);
|
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, deviceId, buildPlaylistPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { startScheduler, pushPlaylistToDevice };
|
module.exports = { startScheduler, pushPlaylistToDevice };
|
||||||
|
|
|
||||||
|
|
@ -92,11 +92,30 @@ module.exports = function setupDashboardSocket(io) {
|
||||||
console.log(`Remote session stopped for device ${device_id}`);
|
console.log(`Remote session stopped for device ${device_id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('dashboard:device-command', (data) => {
|
socket.on('dashboard:device-command', (data, ack) => {
|
||||||
const { device_id, type, payload } = data;
|
const { device_id, type, payload } = data;
|
||||||
if (!canActOnDevice(socket, device_id, 'write')) return;
|
if (!canActOnDevice(socket, device_id, 'write')) {
|
||||||
|
if (typeof ack === 'function') ack({ delivered: false, reason: 'forbidden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const room = deviceNs.adapter.rooms.get(device_id);
|
||||||
|
if (room && room.size > 0) {
|
||||||
deviceNs.to(device_id).emit('device:command', { type, payload });
|
deviceNs.to(device_id).emit('device:command', { type, payload });
|
||||||
console.log(`Command sent to device ${device_id}: ${type}`);
|
console.log(`Command delivered to device ${device_id}: ${type}`);
|
||||||
|
if (typeof ack === 'function') ack({ delivered: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Device offline at emit time. Try to queue (lazy require so reverting
|
||||||
|
// the queue commit doesn't break this commit - MODULE_NOT_FOUND on the
|
||||||
|
// first try gets cached by Node's module loader, giving consistent
|
||||||
|
// queued=false behavior on every subsequent call).
|
||||||
|
let queued = false;
|
||||||
|
try {
|
||||||
|
const queue = require('../lib/command-queue');
|
||||||
|
queued = queue.queueCommand(device_id, type, payload);
|
||||||
|
} catch (e) { /* command-queue module absent; fall through to lost */ }
|
||||||
|
console.log(`Command for offline device ${device_id}: ${type} (queued=${queued})`);
|
||||||
|
if (typeof ack === 'function') ack({ delivered: false, queued, reason: 'offline' });
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,19 @@ const fs = require('fs');
|
||||||
const { db, pruneTelemetry, pruneScreenshots } = require('../db/database');
|
const { db, pruneTelemetry, pruneScreenshots } = require('../db/database');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const heartbeat = require('../services/heartbeat');
|
const heartbeat = require('../services/heartbeat');
|
||||||
|
const commandQueue = require('../lib/command-queue');
|
||||||
|
|
||||||
|
// Debounce window for marking a device offline on socket disconnect. Brief
|
||||||
|
// flap (Wi-Fi blip, Engine.IO ping miss, server-side eviction-then-reconnect)
|
||||||
|
// shouldn't toggle the dashboard. If a fresh register lands within this
|
||||||
|
// window, the pending offline transition is cancelled. Per-device timer is
|
||||||
|
// stored here; cleared by the register handlers and by stale-disconnect
|
||||||
|
// guards. In-memory only - the heartbeat checker is the safety net for
|
||||||
|
// server-restart-during-grace-window edge cases (any 'online' rows whose
|
||||||
|
// last_heartbeat is older than heartbeatTimeout get marked offline by the
|
||||||
|
// next checker sweep within heartbeatInterval).
|
||||||
|
const pendingOfflines = new Map();
|
||||||
|
const OFFLINE_DEBOUNCE_MS = 5000;
|
||||||
const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription');
|
const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription');
|
||||||
// Phase 2.3: deviceRoom() resolves a device_id to its workspace room so
|
// Phase 2.3: deviceRoom() resolves a device_id to its workspace room so
|
||||||
// dashboardNs.emit can be scoped instead of broadcast platform-wide.
|
// dashboardNs.emit can be scoped instead of broadcast platform-wide.
|
||||||
|
|
@ -242,6 +255,11 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
db.prepare('UPDATE devices SET device_token = ? WHERE id = ?').run(newToken, existing.device_id);
|
db.prepare('UPDATE devices SET device_token = ? WHERE id = ?').run(newToken, existing.device_id);
|
||||||
console.log(`Fingerprint match: linking reinstalled app to existing device ${existing.device_id} (new token issued)`);
|
console.log(`Fingerprint match: linking reinstalled app to existing device ${existing.device_id} (new token issued)`);
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
|
// Cancel any pending offline timer - device is back in the grace window
|
||||||
|
if (pendingOfflines.has(existing.device_id)) {
|
||||||
|
clearTimeout(pendingOfflines.get(existing.device_id));
|
||||||
|
pendingOfflines.delete(existing.device_id);
|
||||||
|
}
|
||||||
evictPriorSocket(existing.device_id, socket.id);
|
evictPriorSocket(existing.device_id, socket.id);
|
||||||
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
||||||
.run(getClientIp(socket), existing.device_id);
|
.run(getClientIp(socket), existing.device_id);
|
||||||
|
|
@ -255,6 +273,8 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
socket.join(existing.device_id);
|
socket.join(existing.device_id);
|
||||||
logDeviceStatus(existing.device_id, 'online');
|
logDeviceStatus(existing.device_id, 'online');
|
||||||
emitToDeviceWorkspace(dashboardNs, existing.device_id, 'dashboard:device-status', { device_id: existing.device_id, status: 'online' });
|
emitToDeviceWorkspace(dashboardNs, existing.device_id, 'dashboard:device-status', { device_id: existing.device_id, status: 'online' });
|
||||||
|
// Flush any commands/playlist-updates queued while this device was offline.
|
||||||
|
commandQueue.flushQueue(deviceNs, existing.device_id, buildPlaylistPayload);
|
||||||
// Send playlist
|
// Send playlist
|
||||||
const access = checkDeviceAccess(existing.device_id);
|
const access = checkDeviceAccess(existing.device_id);
|
||||||
if (!access.allowed) {
|
if (!access.allowed) {
|
||||||
|
|
@ -287,6 +307,11 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
|
|
||||||
currentDeviceId = device_id;
|
currentDeviceId = device_id;
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
|
// Cancel any pending offline timer - device is back in the grace window
|
||||||
|
if (pendingOfflines.has(device_id)) {
|
||||||
|
clearTimeout(pendingOfflines.get(device_id));
|
||||||
|
pendingOfflines.delete(device_id);
|
||||||
|
}
|
||||||
evictPriorSocket(device_id, socket.id);
|
evictPriorSocket(device_id, socket.id);
|
||||||
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
||||||
.run(getClientIp(socket), device_id);
|
.run(getClientIp(socket), device_id);
|
||||||
|
|
@ -307,6 +332,8 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
socket.join(device_id);
|
socket.join(device_id);
|
||||||
socket.emit('device:registered', { device_id, device_token: tokenToSend, status: 'online' });
|
socket.emit('device:registered', { device_id, device_token: tokenToSend, status: 'online' });
|
||||||
logDeviceStatus(device_id, 'online');
|
logDeviceStatus(device_id, 'online');
|
||||||
|
// Flush any commands/playlist-updates queued while this device was offline.
|
||||||
|
commandQueue.flushQueue(deviceNs, device_id, buildPlaylistPayload);
|
||||||
|
|
||||||
// If this device is part of a wall, re-evaluate leadership.
|
// If this device is part of a wall, re-evaluate leadership.
|
||||||
// Preferred leader = online member with smallest (canvas_x +
|
// Preferred leader = online member with smallest (canvas_x +
|
||||||
|
|
@ -333,7 +360,7 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(wall.id);
|
const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(wall.id);
|
||||||
for (const m of members) {
|
for (const m of members) {
|
||||||
if (m.device_id !== device_id) {
|
if (m.device_id !== device_id) {
|
||||||
deviceNs.to(m.device_id).emit('device:playlist-update', buildPlaylistPayload(m.device_id));
|
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, m.device_id, buildPlaylistPayload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -560,68 +587,82 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
if (currentDeviceId) {
|
if (!currentDeviceId) return;
|
||||||
// If a newer socket has already taken over this device_id, this is a stale
|
|
||||||
// disconnect from a replaced socket — skip the offline transition so we don't
|
// Stale-disconnect guard: a newer socket already took over this device_id
|
||||||
// flip an actively-connected device offline or clobber the new heartbeat entry.
|
// via eviction. Skip the offline transition entirely - don't even start a
|
||||||
|
// debounce timer.
|
||||||
const activeConn = heartbeat.getConnection(currentDeviceId);
|
const activeConn = heartbeat.getConnection(currentDeviceId);
|
||||||
if (activeConn && activeConn.socketId !== socket.id) {
|
if (activeConn && activeConn.socketId !== socket.id) {
|
||||||
console.log(`Stale disconnect for ${currentDeviceId} (socket ${socket.id}); active is ${activeConn.socketId}, skipping offline`);
|
console.log(`Stale disconnect for ${currentDeviceId} (socket ${socket.id}); active is ${activeConn.socketId}, skipping offline`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Device disconnected: ${currentDeviceId}`);
|
const deviceId = currentDeviceId;
|
||||||
db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now') WHERE id = ?")
|
const closingSocketId = socket.id;
|
||||||
.run(currentDeviceId);
|
console.log(`Device disconnected: ${deviceId} (offline transition deferred ${OFFLINE_DEBOUNCE_MS}ms)`);
|
||||||
heartbeat.removeConnection(currentDeviceId);
|
|
||||||
logDeviceStatus(currentDeviceId, 'offline');
|
// Defensive: clear any existing timer for this device. Shouldn't happen
|
||||||
emitToDeviceWorkspace(dashboardNs, currentDeviceId, 'dashboard:device-status', { device_id: currentDeviceId, status: 'offline' });
|
// (register would have cleared it), but if two disconnects fire in
|
||||||
|
// sequence we want the second to refresh the window, not double up.
|
||||||
|
if (pendingOfflines.has(deviceId)) clearTimeout(pendingOfflines.get(deviceId));
|
||||||
|
|
||||||
|
pendingOfflines.set(deviceId, setTimeout(() => {
|
||||||
|
pendingOfflines.delete(deviceId);
|
||||||
|
// Re-check at fire time: did a DIFFERENT socket reclaim during the
|
||||||
|
// grace window? If activeConn exists but it's still our (now-closed)
|
||||||
|
// socket's entry, the entry is just stale - heartbeat.removeConnection
|
||||||
|
// hasn't run yet because we defer it inside this same block. Only
|
||||||
|
// abort if a genuinely different socket has registered.
|
||||||
|
const activeNow = heartbeat.getConnection(deviceId);
|
||||||
|
if (activeNow && activeNow.socketId !== closingSocketId) return;
|
||||||
|
|
||||||
|
db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now') WHERE id = ?").run(deviceId);
|
||||||
|
heartbeat.removeConnection(deviceId);
|
||||||
|
logDeviceStatus(deviceId, 'offline');
|
||||||
|
emitToDeviceWorkspace(dashboardNs, deviceId, 'dashboard:device-status', { device_id: deviceId, status: 'offline' });
|
||||||
|
|
||||||
// If this device was leading a wall, reassign leadership to the next
|
// If this device was leading a wall, reassign leadership to the next
|
||||||
// online member so playback stays driven. Without this the wall freezes
|
// online member so playback stays driven.
|
||||||
// when the leader drops.
|
|
||||||
try {
|
try {
|
||||||
const wall = db.prepare('SELECT id FROM video_walls WHERE leader_device_id = ?').get(currentDeviceId);
|
const wall = db.prepare('SELECT id FROM video_walls WHERE leader_device_id = ?').get(deviceId);
|
||||||
if (wall) {
|
if (wall) {
|
||||||
const candidates = db.prepare(`
|
const candidates = db.prepare(`
|
||||||
SELECT vwd.device_id FROM video_wall_devices vwd
|
SELECT vwd.device_id FROM video_wall_devices vwd
|
||||||
JOIN devices d ON d.id = vwd.device_id
|
JOIN devices d ON d.id = vwd.device_id
|
||||||
WHERE vwd.wall_id = ? AND d.status = 'online' AND vwd.device_id != ?
|
WHERE vwd.wall_id = ? AND d.status = 'online' AND vwd.device_id != ?
|
||||||
ORDER BY vwd.grid_row, vwd.grid_col LIMIT 1
|
ORDER BY vwd.grid_row, vwd.grid_col LIMIT 1
|
||||||
`).all(wall.id, currentDeviceId);
|
`).all(wall.id, deviceId);
|
||||||
const newLeader = candidates[0]?.device_id || null;
|
const newLeader = candidates[0]?.device_id || null;
|
||||||
db.prepare('UPDATE video_walls SET leader_device_id = ? WHERE id = ?').run(newLeader, wall.id);
|
db.prepare('UPDATE video_walls SET leader_device_id = ? WHERE id = ?').run(newLeader, wall.id);
|
||||||
// Notify the new leader (and refresh peers' is_leader flags).
|
|
||||||
const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(wall.id);
|
const members = db.prepare('SELECT device_id FROM video_wall_devices WHERE wall_id = ?').all(wall.id);
|
||||||
for (const m of members) {
|
for (const m of members) {
|
||||||
if (m.device_id !== currentDeviceId) {
|
if (m.device_id !== deviceId) {
|
||||||
deviceNs.to(m.device_id).emit('device:playlist-update', buildPlaylistPayload(m.device_id));
|
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, m.device_id, buildPlaylistPayload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) { console.error('Wall leader reassign failed:', e.message); }
|
} catch (e) { console.error('Wall leader reassign failed:', e.message); }
|
||||||
|
|
||||||
// Save last screenshot to disk as offline snapshot
|
// Save last screenshot to disk as offline snapshot
|
||||||
const lastB64 = lastScreenshots[currentDeviceId];
|
const lastB64 = lastScreenshots[deviceId];
|
||||||
if (lastB64) {
|
if (lastB64) {
|
||||||
try {
|
try {
|
||||||
const filename = `${currentDeviceId}_latest.jpg`;
|
const filename = `${deviceId}_latest.jpg`;
|
||||||
const buffer = Buffer.from(lastB64, 'base64');
|
const buffer = Buffer.from(lastB64, 'base64');
|
||||||
fs.writeFileSync(path.join(config.screenshotsDir, filename), buffer);
|
fs.writeFileSync(path.join(config.screenshotsDir, filename), buffer);
|
||||||
// Upsert screenshot record
|
const existing = db.prepare('SELECT id FROM screenshots WHERE device_id = ?').get(deviceId);
|
||||||
const existing = db.prepare('SELECT id FROM screenshots WHERE device_id = ?').get(currentDeviceId);
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare('UPDATE screenshots SET filepath = ?, captured_at = strftime(\'%s\',\'now\') WHERE device_id = ?')
|
db.prepare('UPDATE screenshots SET filepath = ?, captured_at = strftime(\'%s\',\'now\') WHERE device_id = ?').run(filename, deviceId);
|
||||||
.run(filename, currentDeviceId);
|
|
||||||
} else {
|
} else {
|
||||||
db.prepare('INSERT INTO screenshots (device_id, filepath) VALUES (?, ?)').run(currentDeviceId, filename);
|
db.prepare('INSERT INTO screenshots (device_id, filepath) VALUES (?, ?)').run(deviceId, filename);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save offline screenshot:', e.message);
|
console.error('Failed to save offline screenshot:', e.message);
|
||||||
}
|
}
|
||||||
delete lastScreenshots[currentDeviceId];
|
delete lastScreenshots[deviceId];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, OFFLINE_DEBOUNCE_MS));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue