Compare commits
4
Commits
ca671d6453
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f76cd79bb0 | ||
|
|
39c8daa9b0 | ||
|
|
cd4f3675fd | ||
|
|
bbb3d8c2fa |
No files matched your search
@@ -7445,8 +7445,10 @@ std::pair<std::string, std::string> CMy2015RemoteDlg::GenerateRenewalInfo(
|
||||
newHmac = std::to_string(hmacVal);
|
||||
}
|
||||
|
||||
// 新 passcode 的到期日是“今天 + days” (见上面的 newPasscode);
|
||||
// 预设日期已过去时 days 被钳制为 1,日志应显示实际日期。
|
||||
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);
|
||||
@@ -7546,13 +7548,18 @@ void CMy2015RemoteDlg::SendPendingRenewal(CONTEXT_OBJECT* ctx, const std::string
|
||||
int days = (int)span.GetDays();
|
||||
if (days <= 0) days = 1;
|
||||
|
||||
// 客户端按“今天 + days”签发新授权(CMD_AUTHORIZATION 使用 getDateStr(0/days))。
|
||||
// 预设日期若已经过去(过期授权在旧版本中设置的预设),实际到期日是今天 + 1,
|
||||
// 日志和提示必须显示实际日期,而不是已失效的预设日期。
|
||||
std::string actualExpire = getDateStr(days);
|
||||
|
||||
// 构建并发送 CMD_AUTHORIZATION
|
||||
if (source) {
|
||||
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 {
|
||||
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 };
|
||||
@@ -7571,10 +7578,10 @@ void CMy2015RemoteDlg::SendPendingRenewal(CONTEXT_OBJECT* ctx, const std::string
|
||||
bool hasRemainingQuota = DecrementPendingQuota(sn);
|
||||
Mprintf("[HEARTBEAT] 续期下发完成: %s, 剩余配额: %s\n", sn.c_str(), hasRemainingQuota ? "有" : "无");
|
||||
|
||||
// 提示续期已下发
|
||||
std::string expireFmt = renewal.ExpireDate.substr(0, 4) + "-" +
|
||||
renewal.ExpireDate.substr(4, 2) + "-" +
|
||||
renewal.ExpireDate.substr(6, 2);
|
||||
// 提示续期已下发(显示实际到期日)
|
||||
std::string expireFmt = actualExpire.substr(0, 4) + "-" +
|
||||
actualExpire.substr(4, 2) + "-" +
|
||||
actualExpire.substr(6, 2);
|
||||
std::string tip = sn + g_Lang.Get(std::string(" 已自动续期至 ")) + expireFmt;
|
||||
PostMessageA(WM_SHOWMESSAGE, (WPARAM)new CharMsg(tip.c_str()), NULL);
|
||||
}
|
||||
|
||||
@@ -563,6 +563,17 @@ void CLicenseDlg::OnLicenseRenewal()
|
||||
if (defaultHostNum <= 0) defaultHostNum = 100;
|
||||
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;
|
||||
if (currentExpireDate.length() == 8) {
|
||||
@@ -570,6 +581,9 @@ void CLicenseDlg::OnLicenseRenewal()
|
||||
currentExpireDate.substr(0, 4).c_str(),
|
||||
currentExpireDate.substr(4, 2).c_str(),
|
||||
currentExpireDate.substr(6, 2).c_str());
|
||||
if (expired) {
|
||||
strCurrentExpire += _L(" (已过期,续期从今天起算)");
|
||||
}
|
||||
} else {
|
||||
strCurrentExpire = _TR("当前到期: 未知");
|
||||
}
|
||||
@@ -595,15 +609,13 @@ void CLicenseDlg::OnLicenseRenewal()
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算新的过期日期 (从原到期日期开始加天数)
|
||||
CTime baseTime;
|
||||
if (currentExpireDate.length() == 8) {
|
||||
// 计算新的过期日期:未过期从原到期日加天数;已过期从今天起算。
|
||||
CTime baseTime = today;
|
||||
if (!expired && currentExpireDate.length() == 8) {
|
||||
int year = atoi(currentExpireDate.substr(0, 4).c_str());
|
||||
int month = atoi(currentExpireDate.substr(4, 2).c_str());
|
||||
int day = atoi(currentExpireDate.substr(6, 2).c_str());
|
||||
baseTime = CTime(year, month, day, 0, 0, 0);
|
||||
} else {
|
||||
baseTime = CTime::GetCurrentTime(); // 无法解析时用当前日期
|
||||
}
|
||||
CTimeSpan span(days, 0, 0, 0);
|
||||
CTime newExpireTime = baseTime + span;
|
||||
@@ -635,6 +647,9 @@ void CLicenseDlg::OnLicenseRenewal()
|
||||
CString msg;
|
||||
msg.FormatL("已预设续期至: %s\n并发连接数: %d\n配额: %d (支持%d台机器续期)\n客户端上线时将自动下发新授权",
|
||||
strPending, hostNum, quota, quota);
|
||||
if (expired) {
|
||||
msg += _L("\n(原授权已过期,续期从今天起算)");
|
||||
}
|
||||
MessageBox(msg, _TR("预设成功"), MB_ICONINFORMATION);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +349,75 @@ public:
|
||||
void onHttp(HttpHandler handler) { m_httpHandler = handler; }
|
||||
|
||||
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
|
||||
bool parseProxyProtocolV2(SOCKET sock, std::string& clientIP) {
|
||||
// PP2 signature (12 bytes)
|
||||
@@ -448,6 +517,13 @@ private:
|
||||
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
|
||||
if (request.find("Upgrade: websocket") == std::string::npos) {
|
||||
// Regular HTTP request - extract path
|
||||
|
||||
+211
-23
@@ -391,6 +391,10 @@
|
||||
padding: 16px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -409,8 +413,37 @@
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.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 {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -430,6 +463,22 @@
|
||||
.audit-search-btn:hover { transform: translateY(-1px); }
|
||||
.audit-reset-btn { background: rgba(255,255,255,0.08); color: #ccc; }
|
||||
.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.warn { color: #e67e22; }
|
||||
.audit-table-wrap {
|
||||
@@ -470,8 +519,11 @@
|
||||
.audit-level.warn { background: rgba(230, 126, 34, 0.18); color: #e67e22; }
|
||||
.audit-empty { padding: 40px; text-align: center; color: #666; }
|
||||
@media (max-width: 640px) {
|
||||
.audit-filters { grid-template-columns: 1fr 1fr; }
|
||||
.audit-actions { grid-column: 1 / -1; }
|
||||
/* One control per row: two columns plus the intrinsic width of
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -522,6 +574,14 @@
|
||||
color: #fff;
|
||||
}
|
||||
.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 {
|
||||
display: grid;
|
||||
@@ -1787,15 +1847,24 @@
|
||||
<h2>Audit Log <span id="audit-count" class="device-count">0</span></h2>
|
||||
</div>
|
||||
<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">▾</span>
|
||||
</button>
|
||||
<button class="view-btn" onclick="queryAudit(0)" title="Reload newest records">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="audit-filters">
|
||||
<div class="audit-filters collapsed" id="audit-filters">
|
||||
<label>From
|
||||
<input type="datetime-local" id="audit-from" onkeydown="if(event.key==='Enter')queryAudit(0)">
|
||||
<span class="audit-date-wrap">
|
||||
<input type="datetime-local" id="audit-from" onkeydown="if(event.key==='Enter')queryAudit(0)">
|
||||
</span>
|
||||
</label>
|
||||
<label>To
|
||||
<input type="datetime-local" id="audit-to" onkeydown="if(event.key==='Enter')queryAudit(0)">
|
||||
<span class="audit-date-wrap">
|
||||
<input type="datetime-local" id="audit-to" onkeydown="if(event.key==='Enter')queryAudit(0)">
|
||||
</span>
|
||||
</label>
|
||||
<label>Event (exact or prefix)
|
||||
<input type="text" id="audit-event" list="audit-event-options" placeholder="e.g. web.* or web.login.failure"
|
||||
@@ -1913,6 +1982,23 @@
|
||||
</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-fit.js"></script>
|
||||
|
||||
@@ -2333,7 +2419,17 @@
|
||||
wallAllocBudget();
|
||||
getDevices();
|
||||
} else {
|
||||
showPage('devices-page');
|
||||
// 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');
|
||||
}
|
||||
getDevices();
|
||||
}
|
||||
}
|
||||
@@ -2642,7 +2738,7 @@
|
||||
// mode=removed:历史记录已删除,刷新后卡片消失。
|
||||
getDevices();
|
||||
} else {
|
||||
alert(msg.msg || 'Failed to delete device');
|
||||
appAlert(msg.msg || 'Failed to delete device', 'Unable to Delete Device');
|
||||
}
|
||||
break;
|
||||
case 'list_users_result':
|
||||
@@ -3228,7 +3324,10 @@
|
||||
const existing = wall.sessions.get(l32);
|
||||
if (existing && existing.fullId !== id) {
|
||||
// 两台设备的低 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;
|
||||
}
|
||||
const uid = 'w' + (wall.uidSeq++);
|
||||
@@ -3250,11 +3349,11 @@
|
||||
|
||||
function addWallTile(deviceId) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
const dev = devices.find(d => String(d.id) === String(deviceId));
|
||||
@@ -3294,9 +3393,11 @@
|
||||
wallUpdateSidebarBadges();
|
||||
}
|
||||
|
||||
function clearWall() {
|
||||
async function clearWall() {
|
||||
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();
|
||||
wall.tiles = [];
|
||||
for (const tile of tiles) {
|
||||
@@ -3508,8 +3609,8 @@
|
||||
const tile = wall.tiles.find(t => t.uid === uid);
|
||||
if (!tile) return;
|
||||
const dev = devices.find(d => String(d.id) === tile.deviceId);
|
||||
if (!dev) { alert('Device not found'); return; }
|
||||
if (!dev.online) { alert('Device is offline'); return; }
|
||||
if (!dev) { appAlert('Device not found.', 'Device Not Found'); return; }
|
||||
if (!dev.online) { appAlert('Device is offline.', 'Device Offline'); return; }
|
||||
const s = wall.sessions.get(tile.low32);
|
||||
if (s && s.status === 'offline') {
|
||||
// 设备重新上线:离线时服务端已移除 watch,且 streams 条目被整体删除,
|
||||
@@ -3548,12 +3649,13 @@
|
||||
|
||||
function showWall() {
|
||||
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;
|
||||
}
|
||||
const compat = checkWebCodecs();
|
||||
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;
|
||||
}
|
||||
if (!wallRestored) wallRestoreState();
|
||||
@@ -4389,6 +4491,74 @@
|
||||
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
|
||||
// 的做法发送单字节 TOKEN_UNINSTALL(卸载主机)。都先弹二次确认。
|
||||
@@ -4402,7 +4572,7 @@
|
||||
function askDeleteDevice(id) {
|
||||
if (!canDeleteDevices()) return;
|
||||
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 name = displayName(d);
|
||||
pendingDeleteDeviceId = String(id);
|
||||
@@ -4498,8 +4668,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function deleteUser(username) {
|
||||
if (!confirm('Delete user "' + username + '"?')) return;
|
||||
async function deleteUser(username) {
|
||||
const ok = await appConfirm('Delete user "' + username + '"?',
|
||||
{ title: 'Delete User', okText: 'Delete' });
|
||||
if (!ok) return;
|
||||
if (ws && ws.readyState === WebSocket.OPEN && token) {
|
||||
ws.send(JSON.stringify({ cmd: 'delete_user', token, username }));
|
||||
}
|
||||
@@ -4542,6 +4714,20 @@
|
||||
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() {
|
||||
if (currentUserRole !== 'admin' || !serverCaps.auditLog) return;
|
||||
fillAuditEventOptions();
|
||||
@@ -4717,7 +4903,7 @@
|
||||
function connectDevice(id, fromWall) {
|
||||
const dev = devices.find(d => d.id === id || d.id === String(id));
|
||||
if (!dev || !dev.online) {
|
||||
alert('Device is offline');
|
||||
appAlert('Device is offline.', 'Device Offline');
|
||||
return;
|
||||
}
|
||||
// 无显示器(screen 字段格式 "n:WxH",n=0 表示无显示器)→ 远程桌面没有意义,
|
||||
@@ -4729,7 +4915,7 @@
|
||||
return;
|
||||
}
|
||||
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);
|
||||
// 同页直接切设备(正常 UI 不会发生):先释放旧 focus 引用。
|
||||
if (currentDevice && String(currentDevice.id) !== sid) {
|
||||
@@ -4767,11 +4953,11 @@
|
||||
|
||||
function openTerminal(id) {
|
||||
if (typeof Terminal === 'undefined') {
|
||||
alert('Terminal library not loaded yet, please retry');
|
||||
appAlert('Terminal library not loaded yet, please retry.', 'Terminal Unavailable');
|
||||
return;
|
||||
}
|
||||
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(重连同设备视为重置)
|
||||
if (termState.deviceId && termState.deviceId !== String(id)) {
|
||||
closeTerminal();
|
||||
@@ -6317,6 +6503,8 @@
|
||||
// Desktop keyboard handling
|
||||
document.addEventListener('keydown', function(e) {
|
||||
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;
|
||||
// F11 belongs to the local fullscreen toggle UNLESS we're actively
|
||||
// controlling the remote — see the F11 handler above. Skip here
|
||||
|
||||
Reference in new issue
Block a user