4 Commits
Author SHA1 Message Date
yuanyuanxianganddeepseek-flash f76cd79bb0 Fix: Renew expired licenses from today and report the actual expiry
Preset renewal stored its target date as the old expiry plus the entered
days, while the authorization actually issued to the client is always
today plus a day count. For a license that had already expired, the
preset date was therefore in the past: SendPendingRenewal clamped the
day count to one, the client received a one-day license starting today,
but the renewal tip and the logs still printed the stale preset date.
The result was a message such as "renewed until 2026-05-30" while the
license really ran until tomorrow.

Presetting a renewal now bases the new date on today when the license
has already expired, and on the original expiry date otherwise, which
matches the one-day-per-day extension admins expect for live licenses.
The renewal dialog and the success prompt state which base was used, so
the computed date is never ambiguous. Renewal tips and both logging
paths (heartbeat-driven SendPendingRenewal and client-requested
GenerateRenewalInfo) now print the date that was actually issued, which
also corrects legacy preset records whose stored date has already
passed.

Co-Authored-By: deepseek-flash
2026-10-19 15:15:49 +02:00
yuanyuanxianganddeepseek-flash 39c8daa9b0 Fix: Keep the audit log open across WebSocket reconnects
Hiding the browser tab while the audit log was open closed the socket,
because the visibility handler treats only the remote screen, terminal
and wall pages as streaming pages. Returning to the tab reconnected the
socket, and the onopen restore path took its catch-all branch, which
navigated to the device list. The audit log therefore vanished whenever
the browser was minimized.

The catch-all branch now leaves the active page alone when it is the
audit log, and only refreshes the device list in the background. Every
other page keeps the previous behaviour: the login page advances with a
cached token, and the terminal page falls back to the device list
because its session died with the socket. Audit results already rendered
stay on screen, so the admin can keep reading without re-querying.

Co-Authored-By: deepseek-flash
2026-10-19 10:39:36 +02:00
yuanyuanxianganddeepseek-flash cd4f3675fd Improve: Replace native browser prompts with themed in-app dialogs
Native alert() and confirm() render their buttons in the browser or OS
locale, so on a German system prompts such as the Clear Screen Wall
confirmation showed German labels that disagreed with the rest of the
web UI. Both are now served by a single themed modal that reuses the
logout confirmation styling: an info variant with one OK button for
notices, and a danger variant with Cancel/OK for destructive actions.

The alert helper is fire-and-forget, while the confirm helper returns a
Promise, so the two call sites that need an answer (clearWall and
deleteUser) are now async. Dialog title and message are written with
textContent so device names, user names and server error strings can
never be interpreted as markup. A pending dialog is resolved as
cancelled when another one supersedes it, keeping callers from hanging,
and clicking the backdrop or pressing Escape closes the dialog. Remote
keyboard forwarding now ignores keys while a dialog is open.

Co-Authored-By: deepseek-flash
2026-10-19 10:27:09 +02:00
yuanyuanxianganddeepseek-flash bbb3d8c2fa Fix: Recover proxied client IPs and fit audit filters on phones
The MFC web server now resolves the real client address from the
WebSocket upgrade request when the TCP peer is loopback, so audit records
and login rate limiting stop reporting the reverse proxy's 127.0.0.1.
X-Forwarded-For is preferred over X-Real-IP because its last hop is the
address the local proxy appended; a client-supplied header list cannot
forge it. Forwarded headers are ignored for non-loopback peers so a
client connecting directly over the internet cannot spoof its identity
or rotate addresses to bypass throttling. FRP deployments can keep using
the existing Proxy Protocol v2 support instead.

Collapse the audit log search conditions behind a toolbar toggle that
starts closed and switches to an accent-colored "Hide filters" state
while the panel is open. On portrait phones the panel uses one control
per row, and From/To move their border and padding onto a wrapper so the
iOS WebKit datetime-local width miscalculation cannot overflow the
viewport.

Co-Authored-By: deepseek-flash
2026-10-19 10:05:58 +02:00
4 changed files with 318 additions and 32 deletions

No files matched your search

+14 -7
View File
@@ -7445,8 +7445,10 @@ std::pair<std::string, std::string> CMy2015RemoteDlg::GenerateRenewalInfo(
newHmac = std::to_string(hmacVal); newHmac = std::to_string(hmacVal);
} }
// 新 passcode 的到期日是“今天 + days” (见上面的 newPasscode);
// 预设日期已过去时 days 被钳制为 1,日志应显示实际日期。
Mprintf("[TOKEN_AUTH] 生成续期: %s, %s -> %s, %d并发数, 配额剩余%d\n", Mprintf("[TOKEN_AUTH] 生成续期: %s, %s -> %s, %d并发数, 配额剩余%d\n",
sn.c_str(), currentExpire.c_str(), renewal.ExpireDate.c_str(), renewal.HostNum, renewal.Quota - 1); sn.c_str(), currentExpire.c_str(), getDateStr(days).c_str(), renewal.HostNum, renewal.Quota - 1);
// 配额递减,配额用完后自动清除预设续期记录 // 配额递减,配额用完后自动清除预设续期记录
DecrementPendingQuota(sn); DecrementPendingQuota(sn);
@@ -7546,13 +7548,18 @@ void CMy2015RemoteDlg::SendPendingRenewal(CONTEXT_OBJECT* ctx, const std::string
int days = (int)span.GetDays(); int days = (int)span.GetDays();
if (days <= 0) days = 1; if (days <= 0) days = 1;
// 客户端按“今天 + days”签发新授权(CMD_AUTHORIZATION 使用 getDateStr(0/days))。
// 预设日期若已经过去(过期授权在旧版本中设置的预设),实际到期日是今天 + 1,
// 日志和提示必须显示实际日期,而不是已失效的预设日期。
std::string actualExpire = getDateStr(days);
// 构建并发送 CMD_AUTHORIZATION // 构建并发送 CMD_AUTHORIZATION
if (source) { if (source) {
Mprintf("[%s] 自动下发续期: %s, %s -> %s, %d并发数\n", Mprintf("[%s] 自动下发续期: %s, %s -> %s, %d并发数\n",
source, sn.c_str(), currentExpire.c_str(), renewal.ExpireDate.c_str(), renewal.HostNum); source, sn.c_str(), currentExpire.c_str(), actualExpire.c_str(), renewal.HostNum);
} else { } else {
Mprintf("自动下发续期: %s, %s -> %s, %d并发数\n", Mprintf("自动下发续期: %s, %s -> %s, %d并发数\n",
sn.c_str(), currentExpire.c_str(), renewal.ExpireDate.c_str(), renewal.HostNum); sn.c_str(), currentExpire.c_str(), actualExpire.c_str(), renewal.HostNum);
} }
BYTE bToken[32] = { CMD_AUTHORIZATION }; BYTE bToken[32] = { CMD_AUTHORIZATION };
@@ -7571,10 +7578,10 @@ void CMy2015RemoteDlg::SendPendingRenewal(CONTEXT_OBJECT* ctx, const std::string
bool hasRemainingQuota = DecrementPendingQuota(sn); bool hasRemainingQuota = DecrementPendingQuota(sn);
Mprintf("[HEARTBEAT] 续期下发完成: %s, 剩余配额: %s\n", sn.c_str(), hasRemainingQuota ? "有" : "无"); Mprintf("[HEARTBEAT] 续期下发完成: %s, 剩余配额: %s\n", sn.c_str(), hasRemainingQuota ? "有" : "无");
// 提示续期已下发 // 提示续期已下发(显示实际到期日)
std::string expireFmt = renewal.ExpireDate.substr(0, 4) + "-" + std::string expireFmt = actualExpire.substr(0, 4) + "-" +
renewal.ExpireDate.substr(4, 2) + "-" + actualExpire.substr(4, 2) + "-" +
renewal.ExpireDate.substr(6, 2); actualExpire.substr(6, 2);
std::string tip = sn + g_Lang.Get(std::string(" 已自动续期至 ")) + expireFmt; std::string tip = sn + g_Lang.Get(std::string(" 已自动续期至 ")) + expireFmt;
PostMessageA(WM_SHOWMESSAGE, (WPARAM)new CharMsg(tip.c_str()), NULL); PostMessageA(WM_SHOWMESSAGE, (WPARAM)new CharMsg(tip.c_str()), NULL);
} }
+20 -5
View File
@@ -563,6 +563,17 @@ void CLicenseDlg::OnLicenseRenewal()
if (defaultHostNum <= 0) defaultHostNum = 100; if (defaultHostNum <= 0) defaultHostNum = 100;
int defaultQuota = lic.PendingQuota > 0 ? lic.PendingQuota : 1; int defaultQuota = lic.PendingQuota > 0 ? lic.PendingQuota : 1;
// 判断当前授权是否已过期。续期从“今天”起算:客户端收到 days 后按
// getDateStr(0) + days 签发新授权(见 CMD_AUTHORIZATION 处理)。
// 若从已过去的原到期日加天数,预设日期会落在过去,下发时被
// SendPendingRenewal 钳制成 1 天,实际到期日与预设/提示不一致。
CTime now = CTime::GetCurrentTime();
CTime today(now.GetYear(), now.GetMonth(), now.GetDay(), 0, 0, 0);
CString strToday;
strToday.Format(_T("%04d%02d%02d"), now.GetYear(), now.GetMonth(), now.GetDay());
bool expired = currentExpireDate.length() == 8 &&
currentExpireDate < std::string(CT2A(strToday));
// 格式化当前过期日期显示 // 格式化当前过期日期显示
CString strCurrentExpire; CString strCurrentExpire;
if (currentExpireDate.length() == 8) { if (currentExpireDate.length() == 8) {
@@ -570,6 +581,9 @@ void CLicenseDlg::OnLicenseRenewal()
currentExpireDate.substr(0, 4).c_str(), currentExpireDate.substr(0, 4).c_str(),
currentExpireDate.substr(4, 2).c_str(), currentExpireDate.substr(4, 2).c_str(),
currentExpireDate.substr(6, 2).c_str()); currentExpireDate.substr(6, 2).c_str());
if (expired) {
strCurrentExpire += _L(" (已过期,续期从今天起算)");
}
} else { } else {
strCurrentExpire = _TR("当前到期: 未知"); strCurrentExpire = _TR("当前到期: 未知");
} }
@@ -595,15 +609,13 @@ void CLicenseDlg::OnLicenseRenewal()
return; return;
} }
// 计算新的过期日期 (从原到期日期开始加天数) // 计算新的过期日期:未过期从原到期日加天数;已过期从今天起算。
CTime baseTime; CTime baseTime = today;
if (currentExpireDate.length() == 8) { if (!expired && currentExpireDate.length() == 8) {
int year = atoi(currentExpireDate.substr(0, 4).c_str()); int year = atoi(currentExpireDate.substr(0, 4).c_str());
int month = atoi(currentExpireDate.substr(4, 2).c_str()); int month = atoi(currentExpireDate.substr(4, 2).c_str());
int day = atoi(currentExpireDate.substr(6, 2).c_str()); int day = atoi(currentExpireDate.substr(6, 2).c_str());
baseTime = CTime(year, month, day, 0, 0, 0); baseTime = CTime(year, month, day, 0, 0, 0);
} else {
baseTime = CTime::GetCurrentTime(); // 无法解析时用当前日期
} }
CTimeSpan span(days, 0, 0, 0); CTimeSpan span(days, 0, 0, 0);
CTime newExpireTime = baseTime + span; CTime newExpireTime = baseTime + span;
@@ -635,6 +647,9 @@ void CLicenseDlg::OnLicenseRenewal()
CString msg; CString msg;
msg.FormatL("已预设续期至: %s\n并发连接数: %d\n配额: %d (支持%d台机器续期)\n客户端上线时将自动下发新授权", msg.FormatL("已预设续期至: %s\n并发连接数: %d\n配额: %d (支持%d台机器续期)\n客户端上线时将自动下发新授权",
strPending, hostNum, quota, quota); strPending, hostNum, quota, quota);
if (expired) {
msg += _L("\n(原授权已过期,续期从今天起算)");
}
MessageBox(msg, _TR("预设成功"), MB_ICONINFORMATION); MessageBox(msg, _TR("预设成功"), MB_ICONINFORMATION);
} }
} }
+76
View File
@@ -349,6 +349,75 @@ public:
void onHttp(HttpHandler handler) { m_httpHandler = handler; } void onHttp(HttpHandler handler) { m_httpHandler = handler; }
private: private:
// ---- Real client IP behind a reverse proxy ----
// A same-host nginx/Caddy connects from 127.0.0.1 and reports the original
// peer in X-Real-IP / X-Forwarded-For. Only trust those headers when the
// TCP peer is loopback: otherwise any internet client could forge its
// address in the audit log and rotate it to evade login throttling.
static std::string trimHttpValue(const std::string& s) {
size_t b = s.find_first_not_of(" \t");
if (b == std::string::npos) return "";
size_t e = s.find_last_not_of(" \t\r\n");
return s.substr(b, e - b + 1);
}
static std::string asciiLower(const std::string& s) {
std::string out = s;
for (char& c : out) {
if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a');
}
return out;
}
// Case-insensitive lookup of one header in the raw request. The request
// line is first, so matching "\r\n<name>:" cannot hit the request target.
static std::string httpHeader(const std::string& request, const char* name) {
const std::string needle = "\r\n" + asciiLower(name) + ":";
const std::string hay = asciiLower(request);
size_t pos = hay.find(needle);
if (pos == std::string::npos) return "";
size_t vstart = pos + needle.size();
size_t vend = hay.find("\r\n", vstart);
return trimHttpValue(request.substr(vstart,
vend == std::string::npos ? std::string::npos : vend - vstart));
}
// Accept only a bare IPv4/IPv6 literal; forwarded headers are attacker
// controlled even when the proxy is trusted, so no hostnames/ports.
static std::string normalizeIPLiteral(const std::string& value) {
std::string s = trimHttpValue(value);
if (s.empty() || s.size() > 63) return "";
struct in_addr v4;
if (inet_pton(AF_INET, s.c_str(), &v4) == 1) return s;
struct in6_addr v6;
if (inet_pton(AF_INET6, s.c_str(), &v6) == 1) return s;
return "";
}
static std::string forwardedClientIP(const std::string& request) {
// X-Forwarded-For is "client, proxy1, ..." with the oldest hop first.
// Our own proxy appends the peer it saw, so the last valid entry is
// the one it actually observed: a client-supplied list cannot forge
// it. Prefer this over X-Real-IP, which is only trustworthy when the
// proxy explicitly overwrites it (some configs pass it through).
std::string xff = httpHeader(request, "x-forwarded-for");
size_t end = xff.size();
while (end > 0) {
size_t comma = xff.rfind(',', end - 1);
size_t start = comma == std::string::npos ? 0 : comma + 1;
std::string ip = normalizeIPLiteral(xff.substr(start, end - start));
if (!ip.empty()) return ip;
if (comma == std::string::npos) break;
end = comma;
}
return normalizeIPLiteral(httpHeader(request, "x-real-ip"));
}
static bool isLoopbackPeer(const std::string& ip) {
return ip.rfind("127.", 0) == 0 || ip == "::1" ||
ip == "::ffff:127.0.0.1";
}
// Parse Proxy Protocol v2 header and extract real client IP // Parse Proxy Protocol v2 header and extract real client IP
bool parseProxyProtocolV2(SOCKET sock, std::string& clientIP) { bool parseProxyProtocolV2(SOCKET sock, std::string& clientIP) {
// PP2 signature (12 bytes) // PP2 signature (12 bytes)
@@ -448,6 +517,13 @@ private:
if (request.find("\r\n\r\n") != std::string::npos) break; if (request.find("\r\n\r\n") != std::string::npos) break;
} }
// Behind a same-host reverse proxy the TCP peer is 127.0.0.1; recover
// the real client address the proxy recorded in the handshake.
if (isLoopbackPeer(clientIP)) {
std::string forwarded = forwardedClientIP(request);
if (!forwarded.empty()) clientIP = forwarded;
}
// Check if WebSocket upgrade // Check if WebSocket upgrade
if (request.find("Upgrade: websocket") == std::string::npos) { if (request.find("Upgrade: websocket") == std::string::npos) {
// Regular HTTP request - extract path // Regular HTTP request - extract path
+208 -20
View File
@@ -391,6 +391,10 @@
padding: 16px; padding: 16px;
margin-bottom: 14px; margin-bottom: 14px;
} }
.audit-filters.collapsed { display: none; }
/* Filter labels are grid items; without this a wide intrinsic child
(notably datetime-local on mobile) can blow out the grid track. */
.audit-filters > * { min-width: 0; }
.audit-filters label { .audit-filters label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -409,8 +413,37 @@
font-size: 13px; font-size: 13px;
outline: none; outline: none;
min-width: 0; min-width: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
} }
.audit-filters input:focus, .audit-filters select:focus { border-color: #e67e22; } .audit-filters input:focus, .audit-filters select:focus { border-color: #e67e22; }
/* iOS Safari mis-sizes temporal controls: with horizontal padding on
the input itself, width:100% adds the padding again (WebKit #301648),
and iOS 17 ignores width entirely on the native inline-flex control.
Move the visual box to a wrapper and keep the input padding-free so
it can never exceed its parent. */
.audit-date-wrap {
display: block;
min-width: 0;
padding: 0 10px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
background: rgba(15, 52, 96, 0.7);
}
.audit-date-wrap:focus-within { border-color: #e67e22; }
.audit-date-wrap input[type="datetime-local"] {
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
padding: 8px 0;
border: none;
border-radius: 0;
background: transparent;
color: #fff;
font-size: 13px;
}
.audit-filters label.audit-warn { .audit-filters label.audit-warn {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@@ -430,6 +463,22 @@
.audit-search-btn:hover { transform: translateY(-1px); } .audit-search-btn:hover { transform: translateY(-1px); }
.audit-reset-btn { background: rgba(255,255,255,0.08); color: #ccc; } .audit-reset-btn { background: rgba(255,255,255,0.08); color: #ccc; }
.audit-reset-btn:hover { background: rgba(255,255,255,0.15); } .audit-reset-btn:hover { background: rgba(255,255,255,0.15); }
/* Filters visibility toggle: collapsed by default; the same button
stays in the toolbar and flips to an "open" style (accent tint +
caret pointing up) so it reads as "click to collapse". */
.view-btn.audit-filter-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid rgba(230, 126, 34, 0.45);
border-radius: 8px;
color: #e67e22;
background: rgba(230, 126, 34, 0.12);
}
.view-btn.audit-filter-toggle:hover { background: rgba(230, 126, 34, 0.22); color: #fff; }
.view-btn.audit-filter-toggle.open { background: rgba(230, 126, 34, 0.30); color: #fff; }
.audit-filter-toggle .caret { display: inline-block; font-size: 10px; transition: transform 0.2s; }
.audit-filter-toggle.open .caret { transform: rotate(180deg); }
.audit-status { color: #888; font-size: 12px; margin-bottom: 10px; min-height: 16px; } .audit-status { color: #888; font-size: 12px; margin-bottom: 10px; min-height: 16px; }
.audit-status.warn { color: #e67e22; } .audit-status.warn { color: #e67e22; }
.audit-table-wrap { .audit-table-wrap {
@@ -470,8 +519,11 @@
.audit-level.warn { background: rgba(230, 126, 34, 0.18); color: #e67e22; } .audit-level.warn { background: rgba(230, 126, 34, 0.18); color: #e67e22; }
.audit-empty { padding: 40px; text-align: center; color: #666; } .audit-empty { padding: 40px; text-align: center; color: #666; }
@media (max-width: 640px) { @media (max-width: 640px) {
.audit-filters { grid-template-columns: 1fr 1fr; } /* One control per row: two columns plus the intrinsic width of
.audit-actions { grid-column: 1 / -1; } datetime-local inputs overflows a portrait phone viewport. */
.audit-filters { grid-template-columns: minmax(0, 1fr); padding: 14px; }
.audit-actions { grid-column: 1 / -1; flex-wrap: wrap; }
.audit-actions button { flex: 1 1 auto; }
.audit-table td, .audit-table th { padding: 8px; } .audit-table td, .audit-table th { padding: 8px; }
} }
@@ -522,6 +574,14 @@
color: #fff; color: #fff;
} }
.confirm-modal-actions .danger-btn:hover { box-shadow: 0 4px 12px rgba(192, 57, 43, 0.4); transform: translateY(-1px); } .confirm-modal-actions .danger-btn:hover { box-shadow: 0 4px 12px rgba(192, 57, 43, 0.4); transform: translateY(-1px); }
/* Info variant for the generic alert dialog (single OK button). */
.confirm-modal-content.info { border-color: rgba(230, 126, 34, 0.25); }
.confirm-modal-icon.info { background: rgba(230, 126, 34, 0.15); color: #e67e22; }
.confirm-modal-actions .primary-btn {
background: linear-gradient(135deg, #b9770e 0%, #e67e22 100%);
color: #fff;
}
.confirm-modal-actions .primary-btn:hover { box-shadow: 0 4px 12px rgba(230, 126, 34, 0.4); transform: translateY(-1px); }
.device-grid { .device-grid {
display: grid; display: grid;
@@ -1787,15 +1847,24 @@
<h2>Audit Log <span id="audit-count" class="device-count">0</span></h2> <h2>Audit Log <span id="audit-count" class="device-count">0</span></h2>
</div> </div>
<div class="toolbar"> <div class="toolbar">
<button class="view-btn audit-filter-toggle" id="audit-filter-toggle" type="button"
aria-expanded="false" aria-controls="audit-filters"
onclick="toggleAuditFilters()">
<span class="label">Filters</span><span class="caret">&#9662;</span>
</button>
<button class="view-btn" onclick="queryAudit(0)" title="Reload newest records">Refresh</button> <button class="view-btn" onclick="queryAudit(0)" title="Reload newest records">Refresh</button>
</div> </div>
</div> </div>
<div class="audit-filters"> <div class="audit-filters collapsed" id="audit-filters">
<label>From <label>From
<span class="audit-date-wrap">
<input type="datetime-local" id="audit-from" onkeydown="if(event.key==='Enter')queryAudit(0)"> <input type="datetime-local" id="audit-from" onkeydown="if(event.key==='Enter')queryAudit(0)">
</span>
</label> </label>
<label>To <label>To
<span class="audit-date-wrap">
<input type="datetime-local" id="audit-to" onkeydown="if(event.key==='Enter')queryAudit(0)"> <input type="datetime-local" id="audit-to" onkeydown="if(event.key==='Enter')queryAudit(0)">
</span>
</label> </label>
<label>Event (exact or prefix) <label>Event (exact or prefix)
<input type="text" id="audit-event" list="audit-event-options" placeholder="e.g. web.* or web.login.failure" <input type="text" id="audit-event" list="audit-event-options" placeholder="e.g. web.* or web.login.failure"
@@ -1913,6 +1982,23 @@
</div> </div>
</div> </div>
<!-- Generic alert / confirmation dialog. Replaces native alert()/confirm():
their button labels come from the browser locale (e.g. German on a
German system) and do not match the app. -->
<div class="modal-overlay" id="app-dialog" onclick="if(event.target===this)closeAppDialog(false)"
onkeydown="if(event.key==='Escape')closeAppDialog(false)">
<div class="confirm-modal-content" id="app-dialog-content" role="dialog" aria-modal="true"
aria-labelledby="app-dialog-title" aria-describedby="app-dialog-message">
<div class="confirm-modal-icon" id="app-dialog-icon"></div>
<h3 id="app-dialog-title">Notice</h3>
<p id="app-dialog-message"></p>
<div class="confirm-modal-actions">
<button class="cancel-btn" id="app-dialog-cancel" onclick="closeAppDialog(false)">Cancel</button>
<button class="danger-btn" id="app-dialog-ok" onclick="closeAppDialog(true)">OK</button>
</div>
</div>
</div>
<script src="__BASE_PATH__/static/xterm.js"></script> <script src="__BASE_PATH__/static/xterm.js"></script>
<script src="__BASE_PATH__/static/xterm-fit.js"></script> <script src="__BASE_PATH__/static/xterm-fit.js"></script>
@@ -2333,7 +2419,17 @@
wallAllocBudget(); wallAllocBudget();
getDevices(); getDevices();
} else { } else {
// Hiding the tab closes the socket on non-streaming
// pages and returning reconnects here. Keep an open
// audit log visible instead of yanking the admin back
// to the device list. Every other page still moves to
// devices: a terminal session died with the socket,
// and the login page needs to advance with a cached
// token.
const active = document.querySelector('.page.active');
if (!active || active.id !== 'audit-page') {
showPage('devices-page'); showPage('devices-page');
}
getDevices(); getDevices();
} }
} }
@@ -2642,7 +2738,7 @@
// mode=removed:历史记录已删除,刷新后卡片消失。 // mode=removed:历史记录已删除,刷新后卡片消失。
getDevices(); getDevices();
} else { } else {
alert(msg.msg || 'Failed to delete device'); appAlert(msg.msg || 'Failed to delete device', 'Unable to Delete Device');
} }
break; break;
case 'list_users_result': case 'list_users_result':
@@ -3228,7 +3324,10 @@
const existing = wall.sessions.get(l32); const existing = wall.sessions.get(l32);
if (existing && existing.fullId !== id) { if (existing && existing.fullId !== id) {
// 两台设备的低 32 位相同:绝不能共用一个解码器。 // 两台设备的低 32 位相同:绝不能共用一个解码器。
if (!restoring) alert('Device ID collision (low 32 bits). Cannot open both on the same wall.'); if (!restoring) {
appAlert('Device ID collision (low 32 bits). Cannot open both on the same wall.',
'Device ID Collision');
}
return null; return null;
} }
const uid = 'w' + (wall.uidSeq++); const uid = 'w' + (wall.uidSeq++);
@@ -3250,11 +3349,11 @@
function addWallTile(deviceId) { function addWallTile(deviceId) {
if (!serverCaps.multiWatch) { if (!serverCaps.multiWatch) {
alert('Screen Wall is not supported by this server'); appAlert('Screen Wall is not supported by this server.', 'Screen Wall Unavailable');
return; return;
} }
if (wall.tiles.length >= WALL_MAX_TILES) { if (wall.tiles.length >= WALL_MAX_TILES) {
alert('Wall is full (max ' + WALL_MAX_TILES + ' tiles)'); appAlert('Wall is full (max ' + WALL_MAX_TILES + ' tiles).', 'Screen Wall Full');
return; return;
} }
const dev = devices.find(d => String(d.id) === String(deviceId)); const dev = devices.find(d => String(d.id) === String(deviceId));
@@ -3294,9 +3393,11 @@
wallUpdateSidebarBadges(); wallUpdateSidebarBadges();
} }
function clearWall() { async function clearWall() {
if (!wall.tiles.length) return; if (!wall.tiles.length) return;
if (!confirm('Close all tiles on the wall?')) return; const ok = await appConfirm('Close all tiles on the wall?',
{ title: 'Clear Screen Wall', okText: 'Clear' });
if (!ok) return;
const tiles = wall.tiles.slice(); const tiles = wall.tiles.slice();
wall.tiles = []; wall.tiles = [];
for (const tile of tiles) { for (const tile of tiles) {
@@ -3508,8 +3609,8 @@
const tile = wall.tiles.find(t => t.uid === uid); const tile = wall.tiles.find(t => t.uid === uid);
if (!tile) return; if (!tile) return;
const dev = devices.find(d => String(d.id) === tile.deviceId); const dev = devices.find(d => String(d.id) === tile.deviceId);
if (!dev) { alert('Device not found'); return; } if (!dev) { appAlert('Device not found.', 'Device Not Found'); return; }
if (!dev.online) { alert('Device is offline'); return; } if (!dev.online) { appAlert('Device is offline.', 'Device Offline'); return; }
const s = wall.sessions.get(tile.low32); const s = wall.sessions.get(tile.low32);
if (s && s.status === 'offline') { if (s && s.status === 'offline') {
// 设备重新上线:离线时服务端已移除 watch,且 streams 条目被整体删除, // 设备重新上线:离线时服务端已移除 watch,且 streams 条目被整体删除,
@@ -3548,12 +3649,13 @@
function showWall() { function showWall() {
if (!serverCaps.multiWatch) { if (!serverCaps.multiWatch) {
alert('Screen Wall requires a newer server with multi-view support.'); appAlert('Screen Wall requires a newer server with multi-view support.',
'Screen Wall Unavailable');
return; return;
} }
const compat = checkWebCodecs(); const compat = checkWebCodecs();
if (!compat.supported) { if (!compat.supported) {
alert('Browser does not support H.264: ' + compat.reason); appAlert('Browser does not support H.264: ' + compat.reason, 'H.264 Not Supported');
return; return;
} }
if (!wallRestored) wallRestoreState(); if (!wallRestored) wallRestoreState();
@@ -4389,6 +4491,74 @@
updateAdminButtons(); updateAdminButtons();
} }
// ===== In-app alert / confirmation dialog =====
// Native alert()/confirm() use the browser locale for their buttons
// (e.g. "Abbrechen"/"OK" on a German system) and ignore the app theme.
// Both now go through the same themed modal as the logout prompt.
const APP_DIALOG_ICONS = {
danger: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>' +
'<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
info: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/>' +
'<line x1="12" y1="8" x2="12.01" y2="8"/></svg>'
};
let appDialogResolve = null;
function openAppDialog(opts) {
// A pending dialog is superseded (e.g. an async error arrives while
// a confirm is open): resolve it as cancelled so no caller hangs.
if (appDialogResolve) {
const previous = appDialogResolve;
appDialogResolve = null;
previous(false);
}
const danger = opts.danger !== false;
const content = document.getElementById('app-dialog-content');
const icon = document.getElementById('app-dialog-icon');
const cancel = document.getElementById('app-dialog-cancel');
const ok = document.getElementById('app-dialog-ok');
content.className = 'confirm-modal-content' + (danger ? '' : ' info');
icon.className = 'confirm-modal-icon' + (danger ? '' : ' info');
icon.innerHTML = danger ? APP_DIALOG_ICONS.danger : APP_DIALOG_ICONS.info;
document.getElementById('app-dialog-title').textContent =
opts.title || (danger ? 'Confirm' : 'Notice');
// textContent: server/device/user supplied strings must never be
// interpreted as markup.
document.getElementById('app-dialog-message').textContent = opts.message || '';
cancel.style.display = opts.cancelText ? '' : 'none';
cancel.textContent = opts.cancelText || 'Cancel';
ok.className = danger ? 'danger-btn' : 'primary-btn';
ok.textContent = opts.okText || 'OK';
document.getElementById('app-dialog').classList.add('active');
setTimeout(() => ok.focus(), 0);
return new Promise(resolve => { appDialogResolve = resolve; });
}
function closeAppDialog(result) {
document.getElementById('app-dialog').classList.remove('active');
const resolve = appDialogResolve;
appDialogResolve = null;
if (resolve) resolve(!!result);
}
// Fire-and-forget replacement for alert().
function appAlert(message, title) {
return openAppDialog({ title: title || 'Notice', message, okText: 'OK', danger: false });
}
// Promise<boolean> replacement for confirm().
function appConfirm(message, opts) {
opts = opts || {};
return openAppDialog({
title: opts.title || 'Confirm',
message,
okText: opts.okText || 'OK',
cancelText: opts.cancelText || 'Cancel',
danger: opts.danger !== false
});
}
// ===== 删除设备 ===== // ===== 删除设备 =====
// 两种情况都删除服务端 devices.json 中的历史记录;在线设备额外按 MFC // 两种情况都删除服务端 devices.json 中的历史记录;在线设备额外按 MFC
// 的做法发送单字节 TOKEN_UNINSTALL(卸载主机)。都先弹二次确认。 // 的做法发送单字节 TOKEN_UNINSTALL(卸载主机)。都先弹二次确认。
@@ -4402,7 +4572,7 @@
function askDeleteDevice(id) { function askDeleteDevice(id) {
if (!canDeleteDevices()) return; if (!canDeleteDevices()) return;
const d = devices.find(x => String(x.id) === String(id)); const d = devices.find(x => String(x.id) === String(id));
if (!d) { alert('Device not found'); return; } if (!d) { appAlert('Device not found.', 'Device Not Found'); return; }
const offline = d.online === false; const offline = d.online === false;
const name = displayName(d); const name = displayName(d);
pendingDeleteDeviceId = String(id); pendingDeleteDeviceId = String(id);
@@ -4498,8 +4668,10 @@
} }
} }
function deleteUser(username) { async function deleteUser(username) {
if (!confirm('Delete user "' + username + '"?')) return; const ok = await appConfirm('Delete user "' + username + '"?',
{ title: 'Delete User', okText: 'Delete' });
if (!ok) return;
if (ws && ws.readyState === WebSocket.OPEN && token) { if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'delete_user', token, username })); ws.send(JSON.stringify({ cmd: 'delete_user', token, username }));
} }
@@ -4542,6 +4714,20 @@
if (auditBtn) auditBtn.classList.toggle('visible', admin && !!serverCaps.auditLog); if (auditBtn) auditBtn.classList.toggle('visible', admin && !!serverCaps.auditLog);
} }
// Search conditions start collapsed (especially important on portrait
// phones); the toolbar button stays put and switches to a highlighted
// "up" state while the panel is open, meaning clicking again folds it.
function toggleAuditFilters() {
const panel = document.getElementById('audit-filters');
const btn = document.getElementById('audit-filter-toggle');
if (!panel || !btn) return;
const collapsed = panel.classList.toggle('collapsed');
btn.classList.toggle('open', !collapsed);
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
const label = btn.querySelector('.label');
if (label) label.textContent = collapsed ? 'Filters' : 'Hide filters';
}
function openAuditPage() { function openAuditPage() {
if (currentUserRole !== 'admin' || !serverCaps.auditLog) return; if (currentUserRole !== 'admin' || !serverCaps.auditLog) return;
fillAuditEventOptions(); fillAuditEventOptions();
@@ -4717,7 +4903,7 @@
function connectDevice(id, fromWall) { function connectDevice(id, fromWall) {
const dev = devices.find(d => d.id === id || d.id === String(id)); const dev = devices.find(d => d.id === id || d.id === String(id));
if (!dev || !dev.online) { if (!dev || !dev.online) {
alert('Device is offline'); appAlert('Device is offline.', 'Device Offline');
return; return;
} }
// 无显示器(screen 字段格式 "n:WxH",n=0 表示无显示器)→ 远程桌面没有意义, // 无显示器(screen 字段格式 "n:WxH",n=0 表示无显示器)→ 远程桌面没有意义,
@@ -4729,7 +4915,7 @@
return; return;
} }
const compat = checkWebCodecs(); const compat = checkWebCodecs();
if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; } if (!compat.supported) { appAlert('Browser does not support H264: ' + compat.reason, 'H.264 Not Supported'); return; }
const sid = String(id); const sid = String(id);
// 同页直接切设备(正常 UI 不会发生):先释放旧 focus 引用。 // 同页直接切设备(正常 UI 不会发生):先释放旧 focus 引用。
if (currentDevice && String(currentDevice.id) !== sid) { if (currentDevice && String(currentDevice.id) !== sid) {
@@ -4767,11 +4953,11 @@
function openTerminal(id) { function openTerminal(id) {
if (typeof Terminal === 'undefined') { if (typeof Terminal === 'undefined') {
alert('Terminal library not loaded yet, please retry'); appAlert('Terminal library not loaded yet, please retry.', 'Terminal Unavailable');
return; return;
} }
const dev = devices.find(d => d.id === id || d.id === String(id)); const dev = devices.find(d => d.id === id || d.id === String(id));
if (!dev || !dev.online) { alert('Device is offline'); return; } if (!dev || !dev.online) { appAlert('Device is offline.', 'Device Offline'); return; }
// 已经有终端在跑:直接 show(重连同设备视为重置) // 已经有终端在跑:直接 show(重连同设备视为重置)
if (termState.deviceId && termState.deviceId !== String(id)) { if (termState.deviceId && termState.deviceId !== String(id)) {
closeTerminal(); closeTerminal();
@@ -6317,6 +6503,8 @@
// Desktop keyboard handling // Desktop keyboard handling
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (!document.getElementById('screen-page').classList.contains('active')) return; if (!document.getElementById('screen-page').classList.contains('active')) return;
// Keys belong to an open in-app dialog (buttons are not INPUT/TEXTAREA).
if (document.getElementById('app-dialog').classList.contains('active')) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// F11 belongs to the local fullscreen toggle UNLESS we're actively // F11 belongs to the local fullscreen toggle UNLESS we're actively
// controlling the remote — see the F11 handler above. Skip here // controlling the remote — see the F11 handler above. Skip here