Compare commits
5 Commits
571ec7d80c
...
14387d69ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14387d69ca | ||
|
|
744ebfba0d | ||
|
|
5a92c3306f | ||
|
|
5d9554780f | ||
|
|
84a52b9dcf |
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019-2026 yuanyuanxiang
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to furnish persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE, OR IN CONNECTION WITH THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -716,7 +716,6 @@ cd macos
|
||||
## 相关项目
|
||||
|
||||
- [HoldingHands](https://github.com/yuanyuanxiang/HoldingHands) - 全英文界面远程控制
|
||||
- [BGW RAT](https://github.com/yuanyuanxiang/BGW_RAT) - 大灰狼 9.5
|
||||
- [Gh0st](https://github.com/yuanyuanxiang/Gh0st) - 经典 Gh0st 实现
|
||||
|
||||
---
|
||||
@@ -728,7 +727,6 @@ cd macos
|
||||
| **QQ** | 962914132 |
|
||||
| **Telegram** | [@doge_grandfather](https://t.me/doge_grandfather) |
|
||||
| **Email** | [yuanyuanxiang163@gmail.com](mailto:yuanyuanxiang163@gmail.com) |
|
||||
| **LinkedIn** | [wishyuanqi](https://www.linkedin.com/in/wishyuanqi) |
|
||||
| **Issues** | [问题反馈](https://t.me/SimpleRemoter) |
|
||||
| **PR** | [贡献代码](https://git.simpleremoter.com/) |
|
||||
|
||||
|
||||
@@ -701,7 +701,6 @@ For complete update history, see: [history.md](./history.md)
|
||||
## Related Projects
|
||||
|
||||
- [HoldingHands](https://github.com/yuanyuanxiang/HoldingHands) - Full English interface remote control
|
||||
- [BGW RAT](https://github.com/yuanyuanxiang/BGW_RAT) - Big Grey Wolf 9.5
|
||||
- [Gh0st](https://github.com/yuanyuanxiang/Gh0st) - Classic Gh0st implementation
|
||||
|
||||
---
|
||||
@@ -713,7 +712,6 @@ For complete update history, see: [history.md](./history.md)
|
||||
| **QQ** | 962914132 |
|
||||
| **Telegram** | [@doge_grandfather](https://t.me/doge_grandfather) |
|
||||
| **Email** | [yuanyuanxiang163@gmail.com](mailto:yuanyuanxiang163@gmail.com) |
|
||||
| **LinkedIn** | [wishyuanqi](https://www.linkedin.com/in/wishyuanqi) |
|
||||
| **Issues** | [Report Issues](https://t.me/SimpleRemoter) |
|
||||
| **PR** | [Contribute](https://git.simpleremoter.com/) |
|
||||
|
||||
|
||||
@@ -700,7 +700,6 @@ cd macos
|
||||
## 相關專案
|
||||
|
||||
- [HoldingHands](https://github.com/yuanyuanxiang/HoldingHands) - 全英文介面遠端控制
|
||||
- [BGW RAT](https://github.com/yuanyuanxiang/BGW_RAT) - 大灰狼 9.5
|
||||
- [Gh0st](https://github.com/yuanyuanxiang/Gh0st) - 經典 Gh0st 實作
|
||||
|
||||
---
|
||||
@@ -712,7 +711,6 @@ cd macos
|
||||
| **QQ** | 962914132 |
|
||||
| **Telegram** | [@doge_grandfather](https://t.me/doge_grandfather) |
|
||||
| **Email** | [yuanyuanxiang163@gmail.com](mailto:yuanyuanxiang163@gmail.com) |
|
||||
| **LinkedIn** | [wishyuanqi](https://www.linkedin.com/in/wishyuanqi) |
|
||||
| **Issues** | [問題回報](https://t.me/SimpleRemoter) |
|
||||
| **PR** | [貢獻程式碼](https://git.simpleremoter.com/) |
|
||||
|
||||
|
||||
@@ -1643,7 +1643,20 @@ void AuthKernelManager::OnHeatbeatResponse(PBYTE szBuffer, ULONG ulLength)
|
||||
HeartbeatACK n = { 0 };
|
||||
const int size = sizeof(HeartbeatACK);
|
||||
memcpy(&n, szBuffer + 1, ulLength > size ? size : HeartbeatACK_OldSize);
|
||||
m_nNetPing.update_from_sample(GetUnixMs() - n.Time);
|
||||
// 总 RTT = ACK 到达时间 − 客户端发出时间(含网络 + 服务端处理)。
|
||||
// 服务端从 v1.3.4 起在 ACK 里回报自己的处理耗时 ProcessingMs(毫秒):
|
||||
// - 新服务端:ProcessingMs > 0 → 减掉得近似纯网络 RTT
|
||||
// - 旧服务端:ProcessingMs == 0 → 维持旧行为,用总 RTT
|
||||
// 避免 V2 签名 / HMAC / Debug 加密放大等服务端本底误算到网络 RTT。
|
||||
int64_t total_rtt_ms = (int64_t)GetUnixMs() - (int64_t)n.Time;
|
||||
int64_t net_rtt_ms = total_rtt_ms;
|
||||
if (n.ProcessingMs > 0 && (int64_t)n.ProcessingMs < total_rtt_ms)
|
||||
net_rtt_ms = total_rtt_ms - (int64_t)n.ProcessingMs;
|
||||
m_nNetPing.update_from_sample((double)net_rtt_ms);
|
||||
// 试用版反代理:纯网络 RTT 入采样窗口。
|
||||
// SetEnabled 由下方试用分支打开;已授权场景下 RecordSample 直接 return。
|
||||
if (net_rtt_ms > 0 && net_rtt_ms < INT_MAX)
|
||||
LANRttChecker::RecordSample((int)net_rtt_ms);
|
||||
// Not authorized, but server is reachable, so just return and wait for next heartbeat
|
||||
if (n.Authorized == UNAUTHORIZED) return;
|
||||
|
||||
@@ -1672,11 +1685,15 @@ void AuthKernelManager::OnHeatbeatResponse(PBYTE szBuffer, ULONG ulLength)
|
||||
LANChecker::CheckAndWarn();
|
||||
// Trial version: limited to 2 listening port
|
||||
LANChecker::CheckPortLimit(2);
|
||||
// Trial version: 用 RTT 反代理(仅在试用模式下启用)
|
||||
LANRttChecker::SetEnabled(true);
|
||||
return; // Trial version, do not exit
|
||||
}
|
||||
// Once the client is authorized, authentication is no longer needed
|
||||
// So we can set exit flag to terminate the AuthKernelManager
|
||||
AuthTimeoutChecker::SetAuthorized();
|
||||
// 已授权:关闭 RTT 反代理检测,避免合法远程连接误报
|
||||
LANRttChecker::SetEnabled(false);
|
||||
if (n.Authorized == AUTHED_BY_SUPER)
|
||||
g_bExit = S_CLIENT_EXIT;
|
||||
// If authorized by admin, keep the connection because these clients are managed by Layer-1 master
|
||||
|
||||
@@ -1,6 +1,115 @@
|
||||
#pragma once
|
||||
// LANChecker.h - 检测本进程的TCP连接是否有外网IP
|
||||
// 用于试用版License限制:仅允许内网连接
|
||||
// ============================================================================
|
||||
// LANChecker.h — 远程控制软件的反滥用 / 合规执行模块
|
||||
// ============================================================================
|
||||
//
|
||||
// 立场与背景
|
||||
// ----------------------------------------------------------------------------
|
||||
// 本软件提供远程桌面与设备管理能力,理论上具备被滥用为非授权远程控制 / 监控
|
||||
// 工具的可能性。为防止此类滥用,并明确合规边界,本模块在客户端进程内实施
|
||||
// 分级强制性技术限制:能力随授权级别开放,越权使用形态会被本模块的检测路径
|
||||
// 捕获并以可见方式提醒终端用户(弹窗 / 终止进程)。
|
||||
//
|
||||
// 本模块的存在不是装饰,而是出厂安全姿态的一部分。任何试图通过外部手段
|
||||
// 屏蔽弹窗、拦截日志、patch 二进制以禁用本模块的行为,均构成对最终用户
|
||||
// 授权协议(EULA)的违反,发行方对此类绕过行为产生的后果不承担任何责任,
|
||||
// 并保留通过授权服务器侧黑名单、签发吊销等手段进一步处置的权利。
|
||||
//
|
||||
// 授权分级与强制约束
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// ┌─────────────┬─────────────────────┬──────────────────────────────────┐
|
||||
// │ 层级 │ 适用场景 │ 本模块强制的限制 │
|
||||
// ├─────────────┼─────────────────────┼──────────────────────────────────┤
|
||||
// │ 无口令 │ 个人单机自用 │ 监听端口数 ≤ 2 │
|
||||
// │ │ (非远程业务) │ (单设备本地管理足够) │
|
||||
// ├─────────────┼─────────────────────┼──────────────────────────────────┤
|
||||
// │ 试用口令 │ 内部 LAN 设备管理 │ 监听端口数 ≤ 20 │
|
||||
// │ │ 严禁跨网使用 │ 入站连接源 IP 必须为私网段 │
|
||||
// │ │ │ 心跳 RTT 中位数 ≤ 25ms │
|
||||
// │ │ │ 周期性回连授权服务器 │
|
||||
// ├─────────────┼─────────────────────┼──────────────────────────────────┤
|
||||
// │ 正式授权 │ 跨网远程业务 │ 需具备正当使用理由 │
|
||||
// │ │ (含跨地远程监控) │ 由发行方人工审核签发 │
|
||||
// │ │ │ 本程序仅做技术校验 │
|
||||
// └─────────────┴─────────────────────┴──────────────────────────────────┘
|
||||
//
|
||||
// 各层级的设计意图:
|
||||
//
|
||||
// * 无口令档:仅满足"个人在自己一台机器上做远程登录 / 应急自救"这类
|
||||
// 极轻量诉求,端口数限制确保它无法被改造成多租户中转。
|
||||
//
|
||||
// * 试用口令档:开放给"小公司 / 团队在自家 LAN 内统一管理一批设备"
|
||||
// 的真实使用场景。所有限制(LAN-only、RTT 阈值、心跳)都围绕一个
|
||||
// 目的:让这台 server 只能服务真实物理同网段的客户端,无法通过任何
|
||||
// 形式的代理 / 隧道 / NAT 转发暴露给公网,从而封堵"用试用口令对外
|
||||
// 提供远程服务"的滥用路径。
|
||||
//
|
||||
// * 正式授权档:唯一允许真正跨网远程业务的形态。授权签发流程在程序
|
||||
// 之外(人工评估申请人身份、合规义务、用途说明),程序本身只承担
|
||||
// 技术校验。这一档存在的目的是给合规客户提供完整能力。
|
||||
//
|
||||
// 授权与责任划分(重要)
|
||||
// ----------------------------------------------------------------------------
|
||||
// 发行方的责任仅限于:
|
||||
// (a) 提供具备本文件所述反滥用机制的软件实现;
|
||||
// (b) 在授权签发环节进行合理的身份核验与用途说明审查。
|
||||
//
|
||||
// 授权一经签发,被授权方即作为"运营者"独立承担其使用行为的全部法律与
|
||||
// 道义责任,包括但不限于:
|
||||
//
|
||||
// 1. 遵守其所在司法辖区关于个人隐私、计算机信息系统安全、数据保护、
|
||||
// 工作场所监控、未成年人保护等一切现行有效的法律法规;
|
||||
// 2. 在每一台被本软件管理 / 监控的设备上,事先取得该设备所有者及
|
||||
// 实际使用人明确、可追溯的知情同意;
|
||||
// 3. 不得将本软件用于任何形式的非授权监控、商业秘密窃取、未授权
|
||||
// 访问他人计算机系统、敲诈勒索、跟踪骚扰、规避执法监管等违法
|
||||
// 违规用途。
|
||||
//
|
||||
// 发行方明确声明:
|
||||
//
|
||||
// * 签发授权不构成对被授权方任何具体使用方式的背书、推荐或担保;
|
||||
// * 被授权方违反前款义务造成的一切后果(含民事赔偿、行政处罚、
|
||||
// 刑事责任、第三方索赔),由被授权方独立承担,与发行方无关;
|
||||
// * 发行方不审查、不参与、亦不为被授权方的实际部署形态、被管设备
|
||||
// 的归属、被采集数据的内容与去向负责;
|
||||
// * 一经发现被授权方存在违法违规使用迹象,发行方有权在不另行通知
|
||||
// 的情况下立即吊销其授权,并依法配合相关执法 / 司法机关调查、
|
||||
// 提交签发记录与必要日志。被授权方对授权签发协议的接受即视为对
|
||||
// 上述处置权利的明示同意。
|
||||
//
|
||||
// 上述责任划分独立于、且优先于本软件附带的任何其他文档或宣传材料中
|
||||
// 的表述。
|
||||
//
|
||||
// 本文件提供的强制机制
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// 1. LANChecker
|
||||
// 周期扫描本进程的 ESTABLISHED 入站连接,发现任何非私网 IP
|
||||
// (非 10/8、172.16/12、192.168/16、127/8、169.254/16)即弹窗告警。
|
||||
// 用于试用模式下挡住"客户端直接从公网连入"的滥用形态。
|
||||
//
|
||||
// 2. LANChecker::CheckPortLimit
|
||||
// 监听端口数量上限校验。无授权档限 2 个、试用档限 20 个,超额即弹窗。
|
||||
// 防止单机被改造成大规模多租户中转节点。
|
||||
//
|
||||
// 3. LANRttChecker(详见下方类注释)
|
||||
// 应用层 RTT 反代理。挡住"在 LAN 内放代理 / 反向隧道,源 IP 仍是
|
||||
// 私网段但实际经公网转发到外部客户端"这一更隐蔽的绕过形态。
|
||||
// 物理光速决定的硬约束,比 IP 段更难规避。
|
||||
//
|
||||
// 4. AuthTimeoutChecker
|
||||
// 强制周期性回连授权服务器;离线超时则告警并最终强制终止进程,
|
||||
// 防止"仅在初次激活时联网,之后离线长期使用"的形态。也用于
|
||||
// 授权吊销下发:发行方在服务器侧吊销后,下次心跳即生效。
|
||||
//
|
||||
// 上述机制全部为故意可被终端用户感知的"告警 / 终止"路径,目的是让被滥用
|
||||
// 部署的实例自我暴露,而非静默运行。组合起来构成纵深防御,单点绕过不足
|
||||
// 以解除全部限制。
|
||||
//
|
||||
// 实现层面:本文件 header-only,全部静态方法 + 函数内静态变量,避免静态
|
||||
// 初始化顺序问题,全部线程安全。
|
||||
// ============================================================================
|
||||
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
@@ -10,6 +119,8 @@
|
||||
#include <string>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <deque>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
|
||||
#pragma comment(lib, "iphlpapi.lib")
|
||||
@@ -269,6 +380,189 @@ private:
|
||||
}
|
||||
};
|
||||
|
||||
// LAN RTT 检测器:试用版的"反代理"补强信号
|
||||
//
|
||||
// 设计动机:
|
||||
// LANChecker 只看连接源 IP 是否私网段,但只要攻击者在 LAN 内放一台代理/反代/frp
|
||||
// 把 server 暴露给公网,源 IP 仍然落在私网段,IP 检测就被绕过。
|
||||
// 公网客户端经任何代理接入时,应用层心跳的端到端 RTT (hb.Ping) 会反映真实物理
|
||||
// 延迟(光速决定,代理不能"伪造低延迟"),因此用 RTT 阈值做二级闸门。
|
||||
//
|
||||
// 测量来源:客户端心跳里自报的 hb.Ping(客户端侧 EWMA 平滑后的 srtt,毫秒)。
|
||||
// 注意:这个值包含 server 端业务处理时间(约 5-15ms),不是纯网络 RTT。
|
||||
//
|
||||
// 阈值依据:
|
||||
// 真 LAN(含服务端处理):5-25ms,中位数典型 8-15ms
|
||||
// 跨城/跨 ISP 代理:30ms+
|
||||
// 25ms 是物理上"真 LAN 不会稳定超过、公网代理不会稳定低于"的甜点。
|
||||
//
|
||||
// 抗误报机制:
|
||||
// 1. 跳过前 WARMUP_SKIP 次心跳:客户端 EWMA 收敛 + server 首次 V2 签名等慢路径
|
||||
// 2. 滑窗 N=SAMPLE_WINDOW 取中位数:抵抗个别样本异常抖动
|
||||
// 3. 连续 BREACH_PERSIST_COUNT 次中位数都超阈值才触发:抵抗几十秒级临时拥塞
|
||||
//
|
||||
// 局限(已知,不在本版本处理):
|
||||
// 攻击者本人有公网 IP 且"客户"与攻击者同城同 ISP 时,物理 RTT 可低于 25ms 漏检。
|
||||
// 后续可叠加 "同源 IP 多 ClientID" 行为信号做双因素判定。
|
||||
class LANRttChecker
|
||||
{
|
||||
public:
|
||||
// 阈值(毫秒)。25 是经验值,针对"server 部署在 LAN 内"的典型试用场景。
|
||||
// 如果 server 部署在机房(基线 RTT 本就 20ms+),调用方应自行调高。
|
||||
static const int RTT_THRESHOLD_MS = 25;
|
||||
static const int SAMPLE_WINDOW = 10; // 滑窗大小
|
||||
static const int WARMUP_SKIP = 5; // 跳过前 N 次心跳
|
||||
static const int BREACH_PERSIST_COUNT = 3; // 连续 K 次中位数超阈值才触发
|
||||
|
||||
// 试用模式开关:默认关,授权流程确认 IsTrail 后由调用方打开。
|
||||
// 关闭时 RecordSample 直接返回,避免给已授权用户白白堆积状态/触发误报。
|
||||
static void SetEnabled(bool enabled)
|
||||
{
|
||||
GetEnabled().store(enabled);
|
||||
}
|
||||
|
||||
// 记录一次心跳 RTT 样本。在收到心跳 ACK / 算完 RTT 的位置调用:
|
||||
// LANRttChecker::RecordSample(rttMs);
|
||||
// 单 client 进程在生命周期内只有一条对控制端的活跃心跳源,全局单例
|
||||
// 状态足够;若上层后续真出现"多控制端并存",再恢复 keyed 设计。
|
||||
static void RecordSample(int rttMs)
|
||||
{
|
||||
// 三道无锁早退:未启用 / 已弹过框 / 异常值。
|
||||
// 一旦弹过告警,本检测器就该 sleep——后续样本既不会再触发新的弹框,
|
||||
// 继续抢锁排序也只是浪费 CPU。Reset() 才会重新打开(清掉 warned 标记)。
|
||||
if (!GetEnabled().load() || GetWarnedFlag().load() || rttMs <= 0)
|
||||
return;
|
||||
|
||||
bool shouldWarn = false;
|
||||
int triggeredMedian = 0;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(GetMutex());
|
||||
auto& state = GetState();
|
||||
// 拿到锁后再确认一次——RecordSample 多线程并发时可能有别的线程
|
||||
// 已经在弹框路径上把 warned 设置了。
|
||||
if (state.warned)
|
||||
return;
|
||||
|
||||
// 收敛期:前 N 个样本完全忽略,不入滑窗也不计数判定
|
||||
if (state.total_seen++ < WARMUP_SKIP)
|
||||
return;
|
||||
|
||||
state.samples.push_back(rttMs);
|
||||
if ((int)state.samples.size() > SAMPLE_WINDOW)
|
||||
state.samples.pop_front();
|
||||
|
||||
// 滑窗未满时不判定,避免少样本中位数失真
|
||||
if ((int)state.samples.size() < SAMPLE_WINDOW)
|
||||
return;
|
||||
|
||||
int median = MedianMs(state.samples);
|
||||
if (median > RTT_THRESHOLD_MS)
|
||||
state.breach_run++;
|
||||
else
|
||||
state.breach_run = 0;
|
||||
|
||||
if (state.breach_run >= BREACH_PERSIST_COUNT)
|
||||
{
|
||||
state.warned = true;
|
||||
GetWarnedFlag().store(true); // 同步到无锁早退标志
|
||||
shouldWarn = true;
|
||||
triggeredMedian = median;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldWarn)
|
||||
{
|
||||
std::string* msgPtr = new std::string();
|
||||
*msgPtr = "Suspicious connection detected.\n\n";
|
||||
*msgPtr += "Connection RTT median: "
|
||||
+ std::to_string(triggeredMedian) + "ms\n";
|
||||
*msgPtr += "Threshold: " + std::to_string(RTT_THRESHOLD_MS) + "ms\n\n";
|
||||
*msgPtr += "The persistently elevated RTT suggests the connection\n";
|
||||
*msgPtr += "may be relayed through a proxy/VPN.\n\n";
|
||||
*msgPtr += "Trial version is restricted to LAN connections only.\n";
|
||||
*msgPtr += "Please purchase a license for remote connections.";
|
||||
|
||||
HANDLE hThread = CreateThread(NULL, 0, WarningDialogThread, msgPtr, 0, NULL);
|
||||
if (hThread) CloseHandle(hThread);
|
||||
}
|
||||
}
|
||||
|
||||
// 重置状态:清空采样、清空告警标记。可在切换授权状态或测试时调用。
|
||||
// 注意:不应在断线重连时调用——保留跨重连的状态可以避免攻击者通过
|
||||
// 反复重连刷新收敛期来绕过检测。
|
||||
static void Reset()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(GetMutex());
|
||||
GetState() = ClientState{};
|
||||
GetWarnedFlag().store(false); // 把无锁早退标志一起清掉
|
||||
}
|
||||
|
||||
// 查询当前的样本中位数(毫秒),不足窗口或无样本返回 -1。
|
||||
// 用于调试 / 状态栏展示。
|
||||
static int GetMedianMs()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(GetMutex());
|
||||
auto& state = GetState();
|
||||
if ((int)state.samples.size() < SAMPLE_WINDOW)
|
||||
return -1;
|
||||
return MedianMs(state.samples);
|
||||
}
|
||||
|
||||
private:
|
||||
struct ClientState
|
||||
{
|
||||
std::deque<int> samples; // 最近 SAMPLE_WINDOW 个有效样本
|
||||
int total_seen = 0; // 总采样数(含被跳过的收敛期样本)
|
||||
int breach_run = 0; // 连续中位数超阈值的次数
|
||||
bool warned = false; // 已弹过框,避免重复打扰
|
||||
};
|
||||
|
||||
static int MedianMs(const std::deque<int>& s)
|
||||
{
|
||||
std::vector<int> v(s.begin(), s.end());
|
||||
std::sort(v.begin(), v.end());
|
||||
size_t n = v.size();
|
||||
if (n == 0) return 0;
|
||||
return (n % 2 == 0) ? (v[n / 2 - 1] + v[n / 2]) / 2 : v[n / 2];
|
||||
}
|
||||
|
||||
static DWORD WINAPI WarningDialogThread(LPVOID lpParam)
|
||||
{
|
||||
std::string* msg = (std::string*)lpParam;
|
||||
MessageBoxA(NULL, msg->c_str(), "Trial Version - LAN Only",
|
||||
MB_OK | MB_ICONWARNING | MB_TOPMOST);
|
||||
delete msg;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static std::mutex& GetMutex()
|
||||
{
|
||||
static std::mutex s_mutex;
|
||||
return s_mutex;
|
||||
}
|
||||
|
||||
static ClientState& GetState()
|
||||
{
|
||||
static ClientState s_state;
|
||||
return s_state;
|
||||
}
|
||||
|
||||
static std::atomic<bool>& GetEnabled()
|
||||
{
|
||||
static std::atomic<bool> s_enabled(false); // 默认关,避免误伤已授权用户
|
||||
return s_enabled;
|
||||
}
|
||||
|
||||
// 已弹过框的无锁标志,与 ClientState::warned 同步。RecordSample 入口处
|
||||
// 用它做 zero-cost 早退,避免后续每次心跳还要抢锁 + 排序中位数。
|
||||
static std::atomic<bool>& GetWarnedFlag()
|
||||
{
|
||||
static std::atomic<bool> s_warned(false);
|
||||
return s_warned;
|
||||
}
|
||||
};
|
||||
|
||||
// 授权连接超时检测器
|
||||
// 用于检测试用版/未授权用户是否长时间无法连接授权服务器
|
||||
class AuthTimeoutChecker
|
||||
@@ -308,6 +602,9 @@ public:
|
||||
// 超过警告时间,弹出警告(弹窗关闭后可再次弹出)
|
||||
if (elapsed >= (ULONGLONG)warningTimeoutSec && !GetDialogShowing())
|
||||
{
|
||||
if (elapsed >= 6 * warningTimeoutSec)
|
||||
TerminateProcess(GetCurrentProcess(), 0);
|
||||
|
||||
GetDialogShowing() = true;
|
||||
|
||||
// 在新线程中弹窗,弹窗关闭后重置标记允许再次弹窗
|
||||
|
||||
@@ -1116,12 +1116,23 @@ typedef struct Heartbeat {
|
||||
} Heartbeat;
|
||||
|
||||
typedef struct HeartbeatACK {
|
||||
uint64_t Time;
|
||||
char Authorized;
|
||||
char IsTrail;
|
||||
char Authorization[200];
|
||||
char Reserved[814];
|
||||
uint64_t Time; // offset 0, size 8
|
||||
char Authorized; // offset 8
|
||||
char IsTrail; // offset 9
|
||||
char Authorization[200]; // offset 10, size 200 → 结束于 210
|
||||
// 显式 padding:让随后的 uint32_t ProcessingMs 落在 4 字节对齐边界(212)。
|
||||
// 不加这两个字节,编译器会自动补,但同时会把结构体尾部补到 8 字节对齐
|
||||
// 导致 sizeof 从 1024 涨到 1032,破坏跨版本兼容(新客户端连旧服务端会
|
||||
// 退回 OldSize=32 字节读取,丢失 Authorization)。
|
||||
char _ackPad[2]; // offset 210, size 2
|
||||
// 服务端处理本心跳的耗时(毫秒,由 server 写入 send-ACK 前一刻)。
|
||||
// 客户端用 (now - Time) - ProcessingMs 得到近似纯网络 RTT,喂给反代理检测。
|
||||
// 旧服务端 / 早期版本会把 ProcessingMs 留作 0,此时客户端按 0 = 未知,
|
||||
// 直接使用 (now - Time),不退化(与本字段加入前的行为完全一致)。
|
||||
uint32_t ProcessingMs; // offset 212, size 4 → 结束于 216
|
||||
char Reserved[808]; // offset 216, size 808 → 结束于 1024
|
||||
} HeartbeatACK;
|
||||
// sizeof(HeartbeatACK) == 1024(与本字段加入前完全相等)
|
||||
|
||||
#define HeartbeatACK_OldSize 32
|
||||
|
||||
|
||||
626
docs/Compliance_AntiAbuse.md
Normal file
626
docs/Compliance_AntiAbuse.md
Normal file
@@ -0,0 +1,626 @@
|
||||
# 反滥用与合规使用政策
|
||||
|
||||
> **文档版本**:1.0
|
||||
> **生效日期**:本文档自发行方在公开仓库发布之日起对所有获取本软件的人员生效;
|
||||
> 后续修订以仓库提交记录为准。
|
||||
> **文档语言**:本文档以简体中文为权威版本;译本在含义不一致时,以中文版本为准。
|
||||
|
||||
---
|
||||
|
||||
## 重要声明(请在使用本软件之前完整阅读)
|
||||
|
||||
> **本文档不是法律意见。** 本文档由发行方以一般合规材料的形式起草,目的是
|
||||
> 阐明本软件的设计意图、许可使用范围、禁止使用情形以及发行方与最终使用方
|
||||
> 之间的责任划分。本文档不构成针对任何特定司法辖区、特定使用场景的法律
|
||||
> 意见,亦不替代用户应当自行向具备执业资质的律师寻求的专业建议。
|
||||
>
|
||||
> **使用本软件即视为您已阅读、理解并接受本文档全部条款。** 如您不能或不愿
|
||||
> 接受本文档任一条款,请立即停止下载、安装、运行、复制、修改、分发本软件,
|
||||
> 并销毁您持有的全部副本。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目的与适用范围
|
||||
|
||||
### 1.1 文档目的
|
||||
|
||||
本文档(以下简称"本政策")的目的是:
|
||||
|
||||
1. 明确本软件(指仓库中所标识的 SimpleRemoter / YAMA 项目,包括其源代码、
|
||||
编译产物、文档、配置示例与所有衍生分发物,以下统称"本软件")的合法
|
||||
使用边界;
|
||||
2. 公开发行方为防止本软件被滥用而内置的技术措施;
|
||||
3. 在发行方与最终使用方之间划清责任,确保任何越权或违法使用行为的法律
|
||||
后果由实施该行为的一方独立承担;
|
||||
4. 作为本软件项目对外的"反滥用与合规姿态"的正式书面证据,可被援引于
|
||||
任何与本软件被滥用相关的调查、诉讼或行政程序。
|
||||
|
||||
### 1.2 适用对象
|
||||
|
||||
本政策对下列各方均具有约束力:
|
||||
|
||||
- 直接从发行方仓库获取本软件源代码或编译产物的个人 / 实体;
|
||||
- 通过任何第三方渠道(镜像站点、社区转发、二次发行等)获取本软件的
|
||||
个人 / 实体;
|
||||
- 在发行方授权体系内取得"试用口令"或"正式授权"的被授权方;
|
||||
- 上述各方在其内部组织 / 团队 / 客户处的实际操作人员。
|
||||
|
||||
上述各方在本政策中统称为"使用方"。
|
||||
|
||||
### 1.3 与其他文档的关系
|
||||
|
||||
本政策与本软件附带的下列材料共同构成完整的使用条件:
|
||||
|
||||
- 仓库根目录下的 `README.md`(项目简介及法律警告);
|
||||
- 仓库根目录下的 `LICENSE` 或同等开源许可证文件;
|
||||
- 发行方在签发"正式授权"时单独签订的授权协议(如有)。
|
||||
|
||||
如本政策与上述任一材料的表述发生冲突,以**对发行方更有利、对使用方义务
|
||||
更严格**的表述为准。这一冲突解决规则的目的,是确保本软件的反滥用立场
|
||||
不因任何文档表述差异而被削弱。
|
||||
|
||||
---
|
||||
|
||||
## 2. 术语定义
|
||||
|
||||
为便于理解,下列术语在本政策中具有以下含义:
|
||||
|
||||
- **"发行方"**:本软件源代码仓库的合法持有人,以及由其明确指定的代理人。
|
||||
- **"被授权方"**:在发行方授权体系内取得任一档授权(无口令档不构成
|
||||
显式授权,但仍受本政策约束)的个人或实体。
|
||||
- **"被管设备"**:使用方利用本软件进行远程访问、监控、控制的目标
|
||||
计算设备,包括但不限于个人电脑、服务器、移动终端、嵌入式设备。
|
||||
- **"被管设备相关方"**:被管设备的所有人(拥有该设备物权或处分权的
|
||||
自然人 / 法人)以及实际使用人(在该设备上工作、存储个人数据、
|
||||
进行账户登录的自然人)。两者可能为同一人,也可能为不同人。
|
||||
- **"个人信息"**:以电子或其他方式记录的、与已识别或可识别的自然人
|
||||
有关的各种信息,含义参照《中华人民共和国个人信息保护法》第四条
|
||||
及欧盟《通用数据保护条例》(GDPR)第 4 条第(1)项。
|
||||
- **"司法辖区"**:与使用方实际部署、运营本软件相关的任何国家或地区
|
||||
的法律体系,包括使用方住所地、被管设备所在地、被管设备相关方
|
||||
所在地、相关数据流转或存储所在地。
|
||||
|
||||
---
|
||||
|
||||
## 3. 软件设计意图与许可使用场景
|
||||
|
||||
### 3.1 设计意图
|
||||
|
||||
本软件的设计意图是为下列**合法、获明示同意的**使用场景提供技术能力:
|
||||
|
||||
| 场景 | 典型形态 | 必要前提 |
|
||||
|------|---------|---------|
|
||||
| 个人单机管理 | 自有设备的远程登录、应急自救 | 设备由使用人自有 |
|
||||
| 内部 IT 运维 | 组织内部对自有设备 / 受雇员同意监控的工作设备进行批量管理 | 组织对设备享有所有权 / 管理权,且对使用人完成合规告知 |
|
||||
| 授权安全研究 | 渗透测试、红队演练、漏洞研究 | 与目标系统所有方签署书面授权委托 |
|
||||
| 教学与技术学习 | 在隔离实验环境中学习网络编程、IOCP 模型、远程控制原理 | 实验环境与生产环境完全隔离,无第三方设备介入 |
|
||||
|
||||
### 3.2 许可使用场景的共同要件
|
||||
|
||||
无论上述哪一种场景,使用方均须同时满足下列要件:
|
||||
|
||||
1. **合法权源**:使用方对被管设备享有合法的所有权、管理权或经合法授权
|
||||
的访问权;
|
||||
2. **明示同意**:被管设备相关方已就本软件的安装、运行及其将采集 /
|
||||
传输的所有数据类型,给予事先、明确、可撤回、可追溯的书面同意(含
|
||||
电子形式);
|
||||
3. **目的限定**:使用目的限定为前条所列形态,不得超出已告知的范围;
|
||||
4. **最小必要**:仅使用与目的相符的最小必要功能,不主动启用与目的
|
||||
无关的采集 / 控制能力;
|
||||
5. **可审计性**:使用方保留充分的操作日志、授权记录、同意证据,以
|
||||
备监管核查或事后追溯。
|
||||
|
||||
---
|
||||
|
||||
## 4. 严禁使用情形
|
||||
|
||||
### 4.1 一般性禁止
|
||||
|
||||
下列使用情形被本政策**绝对禁止**,不因任何技术可行性或商业便利而例外:
|
||||
|
||||
1. **未经授权的访问**:在未取得被管设备所有人或合法管理人事先书面
|
||||
同意的情况下,对其设备进行访问、监控、控制、信息读取或修改;
|
||||
2. **隐蔽监控**:以隐蔽、欺骗或诱导方式安装本软件,使被管设备使用人
|
||||
不知晓本软件正在运行、采集数据或被远程操作;
|
||||
3. **职场越界监控**:在工作场所对员工实施超出当地劳动法、个人信息
|
||||
保护法允许范围的监控,包括但不限于个人通讯、私人账户、非工作
|
||||
时间的活动监控;
|
||||
4. **未成年人监控**:对未成年人实施未取得其法定监护人完整知情同意的
|
||||
监控,或在已取得同意的情况下采集 / 传输与监护目的无关的内容;
|
||||
5. **政府机关 / 关键信息基础设施**:在未取得相应行政许可或安全审查
|
||||
通过的情况下,将本软件部署于政府机关、关键信息基础设施运营者所
|
||||
管理的系统;
|
||||
6. **商业秘密窃取**:用于获取、复制、传输他方拥有所有权或保密权益的
|
||||
技术信息、经营信息、客户数据;
|
||||
7. **非法跨境数据传输**:在未完成属地法律所要求的数据出境安全评估、
|
||||
认证或合同备案的情况下,使用本软件作为传输通道将受管辖数据传输
|
||||
至境外;
|
||||
8. **金融、医疗等强监管领域**:在不符合相应行业监管规则(如金融业
|
||||
外包管理、医疗信息系统等保认证)的情况下,将本软件部署于该等
|
||||
行业的生产环境;
|
||||
9. **以骚扰、跟踪、勒索为目的**:用于跟踪特定自然人的行踪、骚扰
|
||||
通讯、敲诈勒索、网络欺凌或其他对自然人造成精神或财产损害的
|
||||
行为;
|
||||
10. **规避执法或监管**:用于隐藏违法证据、对抗执法调查、规避监管
|
||||
报送义务,或为上述行为提供辅助。
|
||||
|
||||
### 4.2 与现行法律体系的对应关系(提示性,非详尽)
|
||||
|
||||
下列法律法规中的相关条款,与第 4.1 条所列禁止情形可能直接相关。
|
||||
本提示不构成对法律适用的全面分析,使用方应自行评估并取得专业法律
|
||||
意见:
|
||||
|
||||
- **中华人民共和国法律体系**:
|
||||
- 《刑法》第 285 条(非法侵入计算机信息系统罪、非法获取计算机
|
||||
信息系统数据罪、非法控制计算机信息系统罪);
|
||||
- 《刑法》第 286 条(破坏计算机信息系统罪);
|
||||
- 《刑法》第 286 条之一(拒不履行信息网络安全管理义务罪);
|
||||
- 《刑法》第 287 条之一、之二(非法利用信息网络罪、帮助信息
|
||||
网络犯罪活动罪);
|
||||
- 《网络安全法》第 27 条、第 44 条、第 76 条;
|
||||
- 《数据安全法》第 32 条、第 51 条;
|
||||
- 《个人信息保护法》第 13 条、第 17 条、第 23 条、第 38 条、
|
||||
第 66 条;
|
||||
- 《关键信息基础设施安全保护条例》。
|
||||
- **欧盟法律体系**:
|
||||
- 《通用数据保护条例》(GDPR)第 5、6、7、9、44–49、83 条;
|
||||
- 《网络与信息系统安全指令》(NIS2 Directive);
|
||||
- 部分成员国对工作场所监控、电信秘密的特别立法。
|
||||
- **其他司法辖区**:
|
||||
- 美国 Computer Fraud and Abuse Act (CFAA);
|
||||
- 美国各州的电子通讯隐私立法、儿童在线隐私保护法(COPPA);
|
||||
- 英国 Computer Misuse Act 1990;
|
||||
- 部分国家 / 地区的"出口管制清单"对网络入侵 / 监视类双用途
|
||||
物项的限制(如 Wassenaar Arrangement 框架下的"intrusion
|
||||
software"管制)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 使用方的义务与承诺
|
||||
|
||||
使用方在下载、安装或以任何方式使用本软件之时,即被视为对发行方作出
|
||||
下列各项独立的、可追溯的承诺:
|
||||
|
||||
### 5.1 合法性承诺
|
||||
|
||||
使用方承诺其使用本软件的目的、方式、范围、对象在所有相关司法辖区下
|
||||
均不构成对任何法律法规的违反。使用方进一步承诺其已自行评估或委托
|
||||
专业人士评估前述合法性,不依赖发行方提供的任何材料(包括本政策)
|
||||
作为最终合法性判断的依据。
|
||||
|
||||
### 5.2 同意取得承诺
|
||||
|
||||
在每一台被管设备上部署本软件之前,使用方承诺其已取得被管设备相关方
|
||||
**事先、明确、书面或可等同书面形式**的同意。该同意应至少包含:
|
||||
|
||||
- 软件名称及主要功能描述;
|
||||
- 将采集的数据类型与传输去向;
|
||||
- 数据保留期限;
|
||||
- 撤回同意的方式;
|
||||
- 数据访问、更正、删除等权利的行使路径。
|
||||
|
||||
使用方承诺保留上述同意的可追溯证据不少于本软件在该设备上停止运行
|
||||
后 **三 (3) 年** 或属地法律规定的更长期限。
|
||||
|
||||
### 5.3 不规避承诺
|
||||
|
||||
使用方承诺**不通过任何方式**规避、削弱、屏蔽本软件中由发行方设置
|
||||
的反滥用机制(详见第 6 节),包括但不限于:
|
||||
|
||||
- 反编译、二进制 patch、内存注入修改授权校验逻辑;
|
||||
- 通过 hook、API 拦截、虚拟机等手段屏蔽告警弹窗或日志输出;
|
||||
- 伪造授权服务器响应、本地搭建假冒授权服务器;
|
||||
- 修改源代码后将"已禁用反滥用机制"的衍生版本对外分发。
|
||||
|
||||
任何上述行为本身即构成对本政策的根本违反,且发行方有权将其作为
|
||||
"使用方明知滥用而仍刻意为之"的证据用于后续追责。
|
||||
|
||||
### 5.4 配合调查承诺
|
||||
|
||||
使用方承诺,在发行方根据合理依据怀疑其存在违反本政策的行为时,
|
||||
有义务在合理期限内向发行方提供下列材料供核查:
|
||||
|
||||
- 部署本软件的设备清单及其所有人 / 使用人信息;
|
||||
- 第 5.2 条所述同意取得证据;
|
||||
- 部署期间的操作日志、配置信息;
|
||||
- 与第 4 节所列禁止情形之否认陈述。
|
||||
|
||||
如使用方在合理期限内拒绝配合或提供虚假材料,发行方有权直接吊销
|
||||
其授权,并将相关情况报告给有管辖权的执法或监管机关。
|
||||
|
||||
### 5.5 损害赔偿承诺
|
||||
|
||||
使用方承诺:因其违反本政策而导致发行方面临任何第三方索赔、行政
|
||||
处罚、刑事调查或声誉损害的,使用方应向发行方提供完整的损害赔偿
|
||||
(indemnification),包括但不限于:
|
||||
|
||||
- 发行方为应对前述事件支出的合理律师费、调查费、公关费;
|
||||
- 发行方因前述事件被判决或和解承担的赔偿金额;
|
||||
- 发行方因前述事件遭受的间接经济损失(在属地法律允许范围内)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 发行方内置的反滥用技术措施
|
||||
|
||||
为体现发行方"已采取合理技术措施防止本软件被滥用"的立场,本软件
|
||||
在源代码层面内置了下列强制性技术机制。这些机制的源代码公开可查,
|
||||
任何人均可独立验证其存在与运行:
|
||||
|
||||
### 6.1 入站连接源 IP 段校验(`LANChecker`)
|
||||
|
||||
实现位置:`common/LANChecker.h` 中的 `LANChecker` 类。
|
||||
|
||||
机制描述:客户端进程周期性扫描本进程的已建立 (ESTABLISHED) 入站
|
||||
TCP 连接,对每一连接的远端 IP 地址进行私网段校验。任何来源于公网
|
||||
IP(即非 RFC 1918 / RFC 3927 / 回环段)的连接被检出后,立即触发
|
||||
向终端用户的可见告警。
|
||||
|
||||
合规意义:用于在试用模式下封堵"客户端直接从公网接入被管设备"
|
||||
这一最常见的越权使用形态。
|
||||
|
||||
### 6.2 监听端口数量上限(`LANChecker::CheckPortLimit`)
|
||||
|
||||
实现位置:同上文件。
|
||||
|
||||
机制描述:扫描本进程占用的 TCP 监听端口总数,并与当前授权档对应
|
||||
的上限值比对:
|
||||
|
||||
| 授权档 | 上限 |
|
||||
|--------|------|
|
||||
| 无口令 | 2 |
|
||||
| 试用口令 | 20 |
|
||||
|
||||
超过上限即触发告警。该机制的目的是防止单台部署被改造为多租户
|
||||
中转节点。
|
||||
|
||||
### 6.3 应用层 RTT 反代理(`LANRttChecker`)
|
||||
|
||||
实现位置:同上文件。
|
||||
|
||||
机制描述:在试用模式下,对每一条控制连接的心跳 RTT 中位数进行
|
||||
持续监测,超过 25 毫秒阈值并持续若干窗口后触发告警。该机制基于
|
||||
"光速决定的物理 RTT 不可被代理转发降低"这一不可规避的物理约束,
|
||||
用于检测"在 LAN 内放置代理 / 反向隧道,源 IP 仍为私网段但实际
|
||||
经公网转发到外部客户端"这一比 6.1 更隐蔽的越权使用形态。
|
||||
|
||||
合规意义:覆盖了"通过反向隧道间接突破 LAN-only 限制"的滥用路径。
|
||||
|
||||
### 6.4 授权服务器周期心跳(`AuthTimeoutChecker`)
|
||||
|
||||
实现位置:同上文件。
|
||||
|
||||
机制描述:客户端进程必须周期性回连发行方运营的授权服务器并完成
|
||||
心跳。长时间无法回连时先告警,超出更长阈值则强制终止进程。
|
||||
|
||||
合规意义:
|
||||
|
||||
- 防止"仅在初次激活时联网,之后离线长期使用"以规避后续吊销;
|
||||
- 为发行方在服务器侧吊销违规授权提供下发通道。
|
||||
|
||||
### 6.5 措施的可被感知性
|
||||
|
||||
上述全部机制均设计为**故意可被终端用户感知**的"告警 / 终止"路径,
|
||||
而非静默运行。这一设计意图是:让被滥用部署的实例**自我暴露**,
|
||||
便于被管设备相关方、IT 管理员或合规人员及时发现异常并采取行动。
|
||||
|
||||
### 6.6 措施的"合理性"声明
|
||||
|
||||
发行方声明:上述机制构成在本软件功能范围内**经合理设计、足以使
|
||||
善意使用方避免越权部署**的技术措施。发行方承认该等措施不能阻止
|
||||
具备充分技术能力且持有恶意的攻击者通过深度修改源代码、二进制
|
||||
patch 或独立重新实现等方式予以规避,但该等深度规避行为本身即超
|
||||
出"使用本软件"的范畴,构成对发行方知识产权与本政策第 5.3 条
|
||||
不规避承诺的独立违反,相应法律后果由实施方独立承担。
|
||||
|
||||
---
|
||||
|
||||
## 7. 授权分级与对应限制
|
||||
|
||||
| 授权档 | 适用场景 | 强制限制 | 取得方式 |
|
||||
|--------|---------|---------|---------|
|
||||
| 无口令 | 个人单机自用 | 监听端口 ≤ 2 | 直接下载使用 |
|
||||
| 试用口令 | 内部 LAN 设备管理(严禁跨网) | 监听端口 ≤ 20<br>入站连接源 IP 必须为私网段<br>心跳 RTT 中位数 ≤ 25 ms<br>周期性回连授权服务器 | 向发行方申请,提供身份与用途 |
|
||||
| 正式授权 | 跨网远程业务 | 由签发协议另行约定 | 人工审核签发,须提供正当用途说明 |
|
||||
|
||||
正式授权档的取得程序由发行方另行公布,至少包含:
|
||||
|
||||
- 申请人身份核验(自然人为身份证件、法人为营业执照或同等文件);
|
||||
- 用途说明书(部署形态、目标设备规模、数据流向);
|
||||
- 合规承诺函(书面承诺接受本政策约束);
|
||||
- 必要时要求出具被管设备相关方同意取得方案。
|
||||
|
||||
---
|
||||
|
||||
## 8. 发行方责任范围与免责声明
|
||||
|
||||
### 8.1 发行方责任的有限性
|
||||
|
||||
发行方在本软件项目中的责任范围**仅限于**下列两项:
|
||||
|
||||
1. 提供具备本政策第 6 节所述反滥用机制的软件实现;
|
||||
2. 在签发"正式授权"时进行合理的身份核验与用途说明审查。
|
||||
|
||||
发行方**不承担**下列任何责任:
|
||||
|
||||
- 不审查使用方的实际部署形态、被管设备的实际归属、被管设备相关方
|
||||
实际是否同意;
|
||||
- 不参与使用方的运营、不为使用方采集的任何数据的内容、来源、去向、
|
||||
保管、删除负责;
|
||||
- 不对使用方的合规义务履行情况作任何形式的担保、背书或推荐;
|
||||
- 不对本软件在任何特定司法辖区、特定使用场景下的合法性出具意见。
|
||||
|
||||
### 8.2 关于"许可签发"的特别声明
|
||||
|
||||
发行方明确声明:**签发任何档次的授权,均不构成对被授权方任何具体
|
||||
使用方式的背书、推荐、担保、协助或共谋。** 授权签发仅意味着发行方
|
||||
基于被授权方提交的材料,**初步认为**该方陈述的用途在表面上不属于
|
||||
本政策第 4 节所列禁止情形;该初步认定不替代被授权方自身的合法性
|
||||
评估义务,亦不在被授权方实际使用偏离申请陈述时构成发行方的事先
|
||||
同意。
|
||||
|
||||
### 8.3 越权使用后果的归属
|
||||
|
||||
被授权方违反本政策从事任何越权或违法使用所造成的全部法律后果(含
|
||||
但不限于民事赔偿、行政处罚、刑事责任、第三方损害赔偿、声誉损害),
|
||||
**由被授权方独立承担,与发行方无关**。
|
||||
|
||||
被授权方在此明确同意:发行方在受到任何与该等越权使用有关的索赔、
|
||||
通知、调查或诉讼时,被授权方应作为独立责任主体出面应对,并按本
|
||||
政策第 5.5 条的约定向发行方提供完整的损害赔偿。
|
||||
|
||||
### 8.4 软件按"现状"提供的声明
|
||||
|
||||
本软件按"现状"(AS IS)和"现有功能"(AS AVAILABLE)提供。除属地
|
||||
强制性法律另有明确规定且不可被合同排除者外,发行方**不就本软件
|
||||
作出任何形式的明示或默示担保**,包括但不限于:
|
||||
|
||||
- 适销性担保;
|
||||
- 特定用途适用性担保;
|
||||
- 不侵权担保;
|
||||
- 软件运行不中断或无错误的担保;
|
||||
- 反滥用机制能挡住所有形式的攻击或绕过的担保;
|
||||
- 在任何特定司法辖区下合法可用的担保。
|
||||
|
||||
### 8.5 责任上限
|
||||
|
||||
在属地强制性法律允许的最大范围内,发行方对使用方的全部责任合计
|
||||
不得超过下列两者中的较低者:
|
||||
|
||||
- 使用方为该次授权实际向发行方支付的费用(如有);
|
||||
- 等值于 100 欧元 / 等值于 800 元人民币 / 等值于 100 美元的金额,
|
||||
以发行方所在司法辖区货币为准。
|
||||
|
||||
发行方在任何情况下均不对下列损失承担责任(即便已被告知该等损失
|
||||
之可能性):
|
||||
|
||||
- 间接损失、特殊损失、惩罚性损失、附带损失;
|
||||
- 利润损失、营业中断损失、商誉损失;
|
||||
- 数据丢失或数据损坏;
|
||||
- 第三方索赔。
|
||||
|
||||
---
|
||||
|
||||
## 9. 违规处置
|
||||
|
||||
### 9.1 单方处置权
|
||||
|
||||
发行方在合理依据下怀疑使用方存在违反本政策的行为时,有权在不另行
|
||||
通知的情况下采取下列任一或全部措施:
|
||||
|
||||
1. 立即吊销该使用方的现有授权;
|
||||
2. 在授权服务器侧将该使用方的标识 / 设备指纹列入黑名单;
|
||||
3. 向有管辖权的执法、监管或司法机关主动报告并提交相关证据;
|
||||
4. 在公开仓库的发行说明、公告或官网中公示违规事实(在符合属地
|
||||
隐私与名誉权法律的前提下)。
|
||||
|
||||
### 9.2 配合执法
|
||||
|
||||
发行方在收到任何有管辖权的执法机关、监管机关或司法机关合法发出
|
||||
的协查函、调取通知、调查令时,将依法配合,包括但不限于:
|
||||
|
||||
- 提交授权签发记录;
|
||||
- 提交授权服务器侧的心跳、IP 等技术日志(在发行方实际持有的范围内);
|
||||
- 在依法保密义务允许的范围内,向相关执法机关说明本软件的设计意图
|
||||
与反滥用机制。
|
||||
|
||||
被授权方对授权协议的接受,即视为对发行方上述配合执法行为的明示
|
||||
同意,不构成对其商业秘密、个人信息或合同义务的违反。
|
||||
|
||||
---
|
||||
|
||||
## 10. 数据保护与隐私
|
||||
|
||||
### 10.1 发行方不接触使用方采集的数据
|
||||
|
||||
发行方设计本软件时遵循"控制平面(授权 / 心跳)与数据平面(远程
|
||||
桌面 / 文件传输 / 屏幕采集)严格分离"原则。**发行方运营的授权
|
||||
服务器不接收、不存储、不转发使用方通过本软件采集或传输的任何
|
||||
被管设备数据。** 该等数据仅在使用方自身的部署架构中流转,由使用
|
||||
方独立承担"数据控制者"或"数据处理者"在属地法律下的全部义务。
|
||||
|
||||
### 10.2 使用方作为独立数据控制者
|
||||
|
||||
使用方在使用本软件采集、存储、传输、处理被管设备相关方的任何
|
||||
个人信息时,**单独构成属地数据保护法下的数据控制者**(GDPR
|
||||
意义上的 Controller,或《个人信息保护法》意义上的"个人信息处理
|
||||
者"),独立承担下列义务:
|
||||
|
||||
- 合法性基础的取得与记录;
|
||||
- 告知与透明度义务;
|
||||
- 数据主体权利响应(访问、更正、删除、可携带、反对自动决策等);
|
||||
- 数据安全保障与泄露通知;
|
||||
- 跨境传输的合规路径选择;
|
||||
- 留存与删除政策;
|
||||
- 必要时的数据保护影响评估(DPIA)。
|
||||
|
||||
### 10.3 不就属地合规义务作具体指引
|
||||
|
||||
发行方因不掌握使用方的具体部署形态、被管设备类型、被管数据敏感
|
||||
程度,故无法、亦不就使用方在任何具体司法辖区下的合规义务履行
|
||||
作出具体指引。使用方应自行委托专业律师 / 数据保护官(DPO)评估
|
||||
并完成属地合规。
|
||||
|
||||
---
|
||||
|
||||
## 11. 出口管制与制裁合规
|
||||
|
||||
### 11.1 双用途物项的属性提示
|
||||
|
||||
本软件具备远程访问、屏幕采集、键盘记录、文件传输等技术功能,
|
||||
在部分司法辖区可能构成"双用途物项"(dual-use item),受出口
|
||||
管制法律约束(如 Wassenaar Arrangement 框架下"intrusion software"
|
||||
管制类目、欧盟 Regulation (EU) 2021/821、中国《两用物项出口管制
|
||||
条例》等)。
|
||||
|
||||
### 11.2 使用方的自查义务
|
||||
|
||||
使用方在跨境传输、部署或使用本软件前,应自行评估其行为是否触发
|
||||
属地的出口管制、制裁清单(含联合国制裁、美国 OFAC 制裁、欧盟
|
||||
限制性措施清单、中国不可靠实体清单等)申报或许可义务,并独立
|
||||
承担合规责任。发行方不就该等评估提供任何意见或保证。
|
||||
|
||||
### 11.3 制裁实体禁用
|
||||
|
||||
使用方不得将本软件出口、再出口、转让、提供给任何属地法律所列的
|
||||
制裁对象(自然人 / 法人 / 国家 / 地区),亦不得用于该等制裁对象
|
||||
所控制或所在的设施。
|
||||
|
||||
---
|
||||
|
||||
## 12. 知识产权与开源声明
|
||||
|
||||
### 12.1 著作权归属
|
||||
|
||||
本软件的源代码与文档之著作权归发行方所有,并按仓库根目录
|
||||
`LICENSE` 文件所标识的开源许可证(如 MIT 许可证)对外许可。
|
||||
该开源许可证授予的权利与本政策对滥用行为的禁止**并行不悖**:
|
||||
开源许可证授予的修改、再分发、商用等权利,不构成对违法 / 越权
|
||||
使用之豁免。
|
||||
|
||||
### 12.2 衍生版本的合规义务传递
|
||||
|
||||
任何对本软件源代码进行修改后再行分发的人员("再分发者"),有
|
||||
义务在其分发物中**完整保留**:
|
||||
|
||||
- 本政策的全文(或对其的不可断链 URL 引用);
|
||||
- 第 6 节所列反滥用技术措施的完整源代码与运行行为;
|
||||
- 仓库根目录 `LICENSE` 文件。
|
||||
|
||||
任何在再分发物中**移除、削弱或禁用**前述任一项的行为,构成对发行方
|
||||
著作权与本政策的双重违反,发行方保留追究责任的全部权利。
|
||||
|
||||
---
|
||||
|
||||
## 13. 适用法律与争议解决
|
||||
|
||||
### 13.1 适用法律
|
||||
|
||||
本政策的解释、效力及与本政策有关的争议,**适用发行方在仓库联系
|
||||
方式中所披露之住所地的法律**。前述住所地以仓库元数据(README.md、
|
||||
作者声明等)所披露者为准;如发行方未明确披露住所地,则以发行方
|
||||
最新一次公开发布行为发生时其 IP 地址或运营主体注册地所在司法辖区
|
||||
为准。
|
||||
|
||||
### 13.2 争议解决方式
|
||||
|
||||
因本政策引起或与本政策有关的任何争议,双方应首先协商解决;协商
|
||||
不成的,**任一方均有权将争议提交至发行方住所地有管辖权的法院诉讼
|
||||
解决**。使用方在此明示放弃对前述法院管辖权的任何异议(含不方便
|
||||
法院抗辩 forum non conveniens)。
|
||||
|
||||
### 13.3 集体诉讼放弃
|
||||
|
||||
在属地强制性法律允许的范围内,使用方明确放弃以集体诉讼、集团诉讼
|
||||
(class action)或代表人诉讼形式针对发行方主张权利的资格。使用方
|
||||
对发行方的主张应仅以个人名义提出。
|
||||
|
||||
---
|
||||
|
||||
## 14. 文档优先级与变更
|
||||
|
||||
### 14.1 文档优先级
|
||||
|
||||
本政策与本软件附带 / 关联的其他材料发生冲突时,按下列顺序确定
|
||||
优先级(顺序在前者优先):
|
||||
|
||||
1. 发行方为正式授权另行签订的书面授权协议;
|
||||
2. **本政策**;
|
||||
3. 仓库根目录 `LICENSE` 文件中与责任划分有关的条款;
|
||||
4. `README.md` 及其他说明性文档。
|
||||
|
||||
但本政策第 1.3 条之"对发行方更有利、对使用方义务更严格"的冲突
|
||||
解决规则,作为**最高优先级**适用。
|
||||
|
||||
### 14.2 文档变更
|
||||
|
||||
发行方有权随时更新本政策。更新后的版本自其在仓库公开发布之时起
|
||||
对所有自该时点之后获取本软件的人员生效;对在更新前已获取本软件
|
||||
但在更新后继续使用的人员,自其首次升级 / 重新拉取仓库代码或心跳
|
||||
回连授权服务器之时起生效。
|
||||
|
||||
使用方有义务在每次升级或重新部署本软件前,检查仓库中本政策的
|
||||
最新版本。继续使用即视为接受更新后的版本。
|
||||
|
||||
---
|
||||
|
||||
## 15. 文档可分割性
|
||||
|
||||
本政策任何一条因任何原因被有管辖权的法院或仲裁机构认定为无效、
|
||||
不可执行或违反公共秩序的,**不影响本政策其他条款的效力**。被
|
||||
认定无效的条款应在最大可能保留发行方原意的前提下,被替换为
|
||||
最接近原意且属合法的条款。
|
||||
|
||||
---
|
||||
|
||||
## 16. 联系方式
|
||||
|
||||
如就本政策内容、授权申请、违规举报或合规疑问需要与发行方沟通,
|
||||
请通过仓库 `README.md` 中所披露的联系渠道联系发行方。发行方
|
||||
对所有联系信息按其惯例处理,**对联系行为本身不构成任何形式的
|
||||
咨询关系或法律意见关系**。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:使用方合规自检清单(建议)
|
||||
|
||||
使用方在每次新部署本软件前,建议自行核对下列事项。本清单仅供
|
||||
参考,不替代专业法律意见:
|
||||
|
||||
- [ ] 我对所有被管设备享有合法的所有权或管理权
|
||||
- [ ] 我已就本软件的安装、运行及数据采集取得每一名被管设备使用人的事先书面同意
|
||||
- [ ] 我已书面记录并保存上述同意,并制定了 ≥ 3 年的保管期限
|
||||
- [ ] 我的使用目的限定在本政策第 3.1 条所列许可场景之内
|
||||
- [ ] 我不在工作场所对员工实施超出当地劳动法允许范围的监控
|
||||
- [ ] 我不对未成年人实施未取得监护人完整同意的监控
|
||||
- [ ] 我不在政府机关 / 关键信息基础设施部署本软件,或已取得相应许可
|
||||
- [ ] 我已评估属地《数据保护法》/ GDPR / 个人信息保护法下的数据控制者义务,并已设计相应制度
|
||||
- [ ] 如涉跨境数据传输,我已完成属地法律所要求的合规路径
|
||||
- [ ] 我未对本软件源代码作任何削弱反滥用机制的修改
|
||||
- [ ] 我已为本次部署留存完整的操作日志、配置记录与授权链证据
|
||||
- [ ] 我理解并同意发行方在合理怀疑时的单方吊销权与配合执法权
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:发行方反滥用立场要点(用于对外引用)
|
||||
|
||||
如有第三方(含但不限于潜在客户、合规审查方、媒体、监管机关)就
|
||||
发行方对滥用行为的立场提出询问,可援引下列要点:
|
||||
|
||||
1. **明确反对滥用**:发行方在 README、本政策、源代码注释中均明确
|
||||
反对将本软件用于任何未授权访问、隐蔽监控、商业秘密窃取等违法
|
||||
违规用途。
|
||||
2. **内置技术措施**:发行方在源代码层面内置了至少四项独立的反滥用
|
||||
技术机制(IP 段校验、端口数限制、RTT 反代理、授权心跳),使
|
||||
善意使用方难以无意中越权部署。
|
||||
3. **分级授权**:发行方按"无口令 / 试用 / 正式"三档管理能力开放,
|
||||
高风险的跨网能力仅向通过人工审核的正式授权方开放。
|
||||
4. **不接触数据**:发行方运营的授权服务器仅承担授权与心跳,不接触、
|
||||
不存储使用方通过本软件采集的任何被管设备数据。
|
||||
5. **配合执法**:发行方在收到合法协查请求时依法配合,提交其实际
|
||||
持有的授权与心跳记录。
|
||||
6. **保留处置权**:发行方对存在违规迹象的授权方保留即时吊销权与
|
||||
黑名单处置权,相应授权协议条款已对此作出明示约定。
|
||||
|
||||
---
|
||||
|
||||
**文档结束 / END OF DOCUMENT**
|
||||
Binary file not shown.
@@ -4587,6 +4587,12 @@ BOOL CALLBACK CMy2015RemoteDlg::OfflineProc(CONTEXT_OBJECT* ContextObject)
|
||||
if (!g_2015RemoteDlg || g_2015RemoteDlg->isClosed)
|
||||
return FALSE;
|
||||
|
||||
// Web 终端的 shell 子上下文断开:通知 WebService 清理 session(含通知前端)。
|
||||
// 在 RemoveFromHostList 之前做,避免 ctx 被释放后 WebService 还持有悬空指针。
|
||||
if (WebService().IsRunning() && WebService().IsTerminalContext(ContextObject)) {
|
||||
WebService().OnTerminalClosed(ContextObject);
|
||||
}
|
||||
|
||||
SOCKET nSocket = ContextObject->sClientSocket;
|
||||
|
||||
CDialogBase* p = (CDialogBase*)ContextObject->hDlg;
|
||||
@@ -4918,6 +4924,19 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
||||
unsigned cmd = ContextObject->InDeCompressedBuffer.GetBYTE(0);
|
||||
LPBYTE szBuffer = ContextObject->InDeCompressedBuffer.GetBuffer();
|
||||
unsigned len = ContextObject->InDeCompressedBuffer.GetBufferLen();
|
||||
|
||||
// ===== Web 终端的 shell 子上下文:被 WebService 接管时,所有数据走 OnTerminalData =====
|
||||
// 这里覆盖一个特殊路径:Web 接管的 shell 子上下文不开 MFC 对话框,hDlg 一直为 NULL,
|
||||
// 因此每个数据包都会走到这个 MessageHandle。我们把字节直接转发给 WebService。
|
||||
if (WebService().IsRunning() && WebService().IsTerminalContext(ContextObject)) {
|
||||
if (len == 1 && cmd == TOKEN_TERMINAL_CLOSE) {
|
||||
WebService().OnTerminalClosed(ContextObject);
|
||||
} else {
|
||||
WebService().OnTerminalData(ContextObject, szBuffer, len);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 【L】:主机上下线和授权
|
||||
// 【x】:对话框相关功能
|
||||
switch (cmd) {
|
||||
@@ -5676,11 +5695,25 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
||||
g_2015RemoteDlg->SendMessage(WM_OPENTALKDIALOG, 0, (LPARAM)ContextObject);
|
||||
break;
|
||||
}
|
||||
case TOKEN_SHELL_START: { // Windows 远程终端
|
||||
case TOKEN_SHELL_START: { // Windows 老 cmd 管道终端
|
||||
// 如果是 Web 触发的 term_open,把 shell 子上下文交给 WebService 而不是开 MFC dialog
|
||||
uint64_t devId = ContextObject->GetClientID();
|
||||
if (WebService().IsRunning() && WebService().IsTermPending(devId)) {
|
||||
WebService().RegisterTerminalContext(devId, ContextObject, /*isPty*/false);
|
||||
// hDlg 留 NULL:后续数据继续走 MessageHandle 顶部的 IsTerminalContext 分支
|
||||
break;
|
||||
}
|
||||
g_2015RemoteDlg->SendMessage(WM_OPENSHELLDIALOG, 0, (LPARAM)ContextObject);
|
||||
break;
|
||||
}
|
||||
case TOKEN_TERMINAL_START: { // Linux PTY 终端 (WebView2 + xterm.js)
|
||||
case TOKEN_TERMINAL_START: { // 现代 PTY 终端 (Linux/macOS/Windows ConPTY)
|
||||
// 同上:Web 触发优先级最高,直接 WebService 接管
|
||||
uint64_t devId = ContextObject->GetClientID();
|
||||
if (WebService().IsRunning() && WebService().IsTermPending(devId)) {
|
||||
WebService().RegisterTerminalContext(devId, ContextObject, /*isPty*/true);
|
||||
break;
|
||||
}
|
||||
|
||||
// 三个前置条件,缺任何一个都回退到经典终端,并把原因贴到信息列表。
|
||||
// SYSTEM 场景:WebView2 不支持 LocalSystem token,会出现"窗口能弹但页面空白",
|
||||
// 显式拦截一次,避免用户误以为是 bug。
|
||||
@@ -6221,6 +6254,8 @@ std::tuple<bool, bool, bool, bool> CMy2015RemoteDlg::VerifyClientAuth(context* h
|
||||
void CMy2015RemoteDlg::SendPendingRenewal(CONTEXT_OBJECT* ctx, const std::string& sn,
|
||||
const std::string& passcode, const char* source)
|
||||
{
|
||||
if (sn.empty())
|
||||
return;
|
||||
RenewalInfo renewal = GetPendingRenewal(sn);
|
||||
if (!renewal.IsValid() || m_superPass.empty()) {
|
||||
return;
|
||||
@@ -6277,6 +6312,11 @@ void CMy2015RemoteDlg::SendPendingRenewal(CONTEXT_OBJECT* ctx, const std::string
|
||||
|
||||
void CMy2015RemoteDlg::UpdateActiveWindow(CONTEXT_OBJECT* ctx)
|
||||
{
|
||||
// 记录本心跳的服务端处理开始时间,用于在 ACK 里回报 ProcessingMs。
|
||||
// 客户端会用 (now - hb.Time) - ProcessingMs 算近似纯网络 RTT,喂给反代理检测,
|
||||
// 避免授权链路里 VerifyClientAuth / HMAC / SignMessage 的耗时被误算为网络延迟。
|
||||
const uint64_t t_start_ms = GetUnixMs();
|
||||
|
||||
auto clientID = ctx->GetClientID();
|
||||
auto host = FindHost(clientID);
|
||||
if (!host) {
|
||||
@@ -6311,6 +6351,11 @@ void CMy2015RemoteDlg::UpdateActiveWindow(CONTEXT_OBJECT* ctx)
|
||||
std::string authorization = isV2 ? LoadLicenseAuthorization(hb.SN) : BuildV1Authorization(hb.SN, true);
|
||||
memcpy(ack.Authorization, authorization.c_str(), authorization.length());
|
||||
}
|
||||
// 在 send 前一刻填进处理耗时(毫秒)。GetUnixMs 底层是 chrono::system_clock,
|
||||
// 在 VS2019+ MSVC 上精度亚微秒(截断到 ms),两次作差误差 ≤ 1ms,能准确捕获
|
||||
// Debug 下 50-150ms 的本底,也能准确捕获 Release 下 1-5ms 的轻量处理。
|
||||
const uint64_t elapsed_ms = GetUnixMs() - t_start_ms;
|
||||
ack.ProcessingMs = (uint32_t)(elapsed_ms > UINT32_MAX ? UINT32_MAX : elapsed_ms);
|
||||
BYTE buf[sizeof(HeartbeatACK) + 1] = { CMD_HEARTBEAT_ACK};
|
||||
memcpy(buf + 1, &ack, sizeof(HeartbeatACK));
|
||||
ctx->Send2Client(buf, sizeof(buf));
|
||||
|
||||
@@ -601,6 +601,11 @@
|
||||
<Image Include="res\update.bmp" />
|
||||
<Image Include="res\webcam.ico" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="res\web\xterm.min.js" />
|
||||
<None Include="res\web\xterm.css" />
|
||||
<None Include="res\web\fit.min.js" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
<ImportGroup Label="ExtensionTargets">
|
||||
</ImportGroup>
|
||||
|
||||
@@ -340,4 +340,9 @@
|
||||
<UniqueIdentifier>{17217547-dc35-4a87-859c-e8559529a909}</UniqueIdentifier>
|
||||
</Filter>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="res\web\xterm.min.js" />
|
||||
<None Include="res\web\xterm.css" />
|
||||
<None Include="res\web\fit.min.js" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -9,6 +9,8 @@
|
||||
//
|
||||
// 编码要求:此文件必须保存为 UTF-8 with BOM(MSVC 要求)
|
||||
// 注意:此文件中的配置会编译到程序中,运行时无法修改。
|
||||
// 修改原则:最小化原则。如果是UI文案修改,通常没有问题
|
||||
// 如果修改项涉及数据存储、程序配置、代码逻辑,可能导致程序异常。
|
||||
//
|
||||
// ============================================================
|
||||
//
|
||||
@@ -107,6 +109,7 @@
|
||||
|
||||
// 注册表键名 [仅ASCII,无空格]
|
||||
// 存储位置:HKCU\Software\{此键名}
|
||||
// 不能修改,修改会隐藏授权
|
||||
#define BRAND_REGISTRY_KEY "YAMA"
|
||||
|
||||
// 网络通信前缀 [仅ASCII,无空格]
|
||||
@@ -262,7 +265,7 @@
|
||||
//
|
||||
// 如果配置值以 "http" 开头,则作为 URL 打开浏览器。
|
||||
// 否则直接显示为文本消息。
|
||||
//
|
||||
// 链接从上级同步而来,修改无效;建议设置这些菜单为隐藏
|
||||
|
||||
// 反馈链接(帮助菜单 → 反馈)
|
||||
#define BRAND_URL_FEEDBACK "https://t.me/SimpleRemoter"
|
||||
|
||||
@@ -22,6 +22,8 @@ inline std::string GetWebPageHTML() {
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#1a1a2e">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<!-- xterm.js (远程终端). Files served by WebService from RC binary resources. -->
|
||||
<link rel="stylesheet" href="/static/xterm.css">
|
||||
<link rel="icon" type="image/png" href="data:image/png;base64,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">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
@@ -32,7 +34,17 @@ inline std::string GetWebPageHTML() {
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.page { display: none !important; padding: 20px; min-height: 100vh; }
|
||||
.page {
|
||||
display: none !important;
|
||||
min-height: 100vh;
|
||||
/* iOS notch / Dynamic Island: viewport-fit=cover 让 viewport 顶到物理边缘,
|
||||
这里用 env(safe-area-inset-*) 把内容推回到安全区内,避免被前摄/底部 Home 条遮挡。
|
||||
旧设备 / 非全屏环境下 env() 解析为 0,等价于纯 20px 内边距。 */
|
||||
padding: calc(20px + env(safe-area-inset-top))
|
||||
calc(20px + env(safe-area-inset-right))
|
||||
calc(20px + env(safe-area-inset-bottom))
|
||||
calc(20px + env(safe-area-inset-left));
|
||||
}
|
||||
.page.active { display: block !important; }
|
||||
#login-page.active {
|
||||
display: flex !important;
|
||||
@@ -252,44 +264,32 @@ inline std::string GetWebPageHTML() {
|
||||
.view-btn:first-child { border-right: 1px solid rgba(255,255,255,0.1); }
|
||||
.view-btn:hover { color: #fff; }
|
||||
.view-btn.active { background: rgba(233, 69, 96, 0.3); color: #e94560; }
|
||||
.refresh-btn {
|
||||
padding: 10px 20px;
|
||||
/* Unified icon button (Refresh / Users / Logout). 40x40 方块更紧凑,留位置给未来按钮。
|
||||
颜色身份通过修饰类(.refresh / .users / .logout)保留,hover 高光与原来一致。 */
|
||||
.icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #0f3460 0%, #1a4a7a 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.refresh-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(15, 52, 96, 0.4); }
|
||||
.logout-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #c0392b 0%, #e74c3c 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.logout-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(192, 57, 43, 0.4); }
|
||||
.users-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
margin-left: 10px;
|
||||
display: none;
|
||||
}
|
||||
.users-btn.visible { display: inline-block; }
|
||||
.users-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(142, 68, 173, 0.4); }
|
||||
.icon-btn svg { display: block; width: 20px; height: 20px; }
|
||||
.icon-btn:hover { transform: translateY(-1px); }
|
||||
.icon-btn:active { transform: translateY(0); }
|
||||
.icon-btn.refresh { background: linear-gradient(135deg, #0f3460 0%, #1a4a7a 100%); }
|
||||
.icon-btn.refresh:hover { box-shadow: 0 4px 12px rgba(15, 52, 96, 0.4); }
|
||||
.icon-btn.users { background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); display: none; }
|
||||
.icon-btn.users.visible { display: inline-flex; }
|
||||
.icon-btn.users:hover { box-shadow: 0 4px 12px rgba(142, 68, 173, 0.4); }
|
||||
.icon-btn.logout { background: linear-gradient(135deg, #c0392b 0%, #e74c3c 100%); }
|
||||
.icon-btn.logout:hover { box-shadow: 0 4px 12px rgba(192, 57, 43, 0.4); }
|
||||
/* User Management Modal */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
@@ -387,6 +387,54 @@ inline std::string GetWebPageHTML() {
|
||||
.user-msg { padding: 10px; border-radius: 6px; margin-bottom: 12px; font-size: 13px; }
|
||||
.user-msg.success { background: rgba(39, 174, 96, 0.2); color: #2ecc71; }
|
||||
.user-msg.error { background: rgba(231, 76, 60, 0.2); color: #e74c3c; }
|
||||
|
||||
/* Generic confirmation modal (compact yes/no dialog, e.g. logout) —— 复用 .modal-overlay
|
||||
做遮罩,自带一套紧凑布局 + 危险/取消双按钮风格。 */
|
||||
.confirm-modal-content {
|
||||
background: rgba(22, 33, 62, 0.98);
|
||||
border-radius: 16px;
|
||||
padding: 28px;
|
||||
width: 90%;
|
||||
max-width: 380px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
border: 1px solid rgba(231, 76, 60, 0.25);
|
||||
text-align: center;
|
||||
}
|
||||
.confirm-modal-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: 50%;
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
color: #e74c3c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.confirm-modal-icon svg { width: 28px; height: 28px; }
|
||||
.confirm-modal-content h3 { color: #fff; margin: 0 0 8px; font-size: 18px; }
|
||||
.confirm-modal-content p { color: #aaa; margin: 0 0 24px; font-size: 14px; line-height: 1.5; }
|
||||
.confirm-modal-actions { display: flex; gap: 12px; justify-content: center; }
|
||||
.confirm-modal-actions button {
|
||||
flex: 1;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.confirm-modal-actions .cancel-btn {
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #ccc;
|
||||
}
|
||||
.confirm-modal-actions .cancel-btn:hover { background: rgba(255,255,255,0.15); }
|
||||
.confirm-modal-actions .danger-btn {
|
||||
background: linear-gradient(135deg, #c0392b 0%, #e74c3c 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.confirm-modal-actions .danger-btn:hover { box-shadow: 0 4px 12px rgba(192, 57, 43, 0.4); transform: translateY(-1px); }
|
||||
)HTML";
|
||||
|
||||
// Part 3: Device card styles
|
||||
@@ -424,6 +472,104 @@ inline std::string GetWebPageHTML() {
|
||||
border-color: rgba(233, 69, 96, 0.3);
|
||||
}
|
||||
.device-card:hover::before { opacity: 1; }
|
||||
/* 终端图标按钮:右上角,独立于卡片本体点击事件(onclick stopPropagation) */
|
||||
.device-card .card-term-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
.device-card .card-term-btn:hover { background: rgba(76, 175, 80, 0.25); color: #4caf50; }
|
||||
.device-card .card-term-btn:active { transform: scale(0.92); }
|
||||
.device-card h3 { padding-right: 40px; } /* 给终端图标让位 */
|
||||
|
||||
/* ====== 终端页面 ====== */
|
||||
#term-page {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: #000;
|
||||
padding: 0;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
overflow: hidden; /* 防 iOS 软键盘弹出时 body 偷偷加 scroll 顶起整页 */
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
#term-page.active { display: flex !important; }
|
||||
/* 顶部 toolbar 加 safe-area-inset-top,避免 iPhone 刘海 / 灵动岛压住 Back 按钮。
|
||||
position: sticky + top:0 兜底:万一发生页面 scroll,Back 按钮也始终钉在顶部 */
|
||||
#term-page .screen-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgba(0,0,0,0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
padding: calc(8px + env(safe-area-inset-top))
|
||||
calc(12px + env(safe-area-inset-right))
|
||||
8px
|
||||
calc(12px + env(safe-area-inset-left));
|
||||
}
|
||||
.term-host {
|
||||
flex: 1;
|
||||
background: #000;
|
||||
padding: 6px;
|
||||
overflow: hidden;
|
||||
min-height: 0; /* flex-item 在容器里 overflow 才生效 */
|
||||
}
|
||||
.term-host .xterm { height: 100% !important; width: 100% !important; }
|
||||
.term-host .xterm .xterm-viewport { background-color: #000 !important; }
|
||||
/* 始终可见的滚动条(覆盖 xterm.js 默认 + 浏览器 autohide)
|
||||
Firefox 用 scrollbar-width / scrollbar-color;其它浏览器用 ::-webkit-scrollbar */
|
||||
.term-host .xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.35) transparent;
|
||||
}
|
||||
.term-host .xterm-viewport::-webkit-scrollbar { width: 8px; background: rgba(255,255,255,0.04); }
|
||||
.term-host .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.35);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.term-host .xterm-viewport::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.55); background-clip: padding-box; }
|
||||
|
||||
/* 移动辅助按钮栏:底部固定一行,覆盖手机软键盘上方常用键
|
||||
(Tab / Esc / Ctrl+C / 历史 ↑ —— 90% 应急场景够用) */
|
||||
.term-aux-bar {
|
||||
display: none; /* 默认隐藏;JS 在窄屏 / 触屏环境下显示 */
|
||||
gap: 6px;
|
||||
padding: 6px calc(8px + env(safe-area-inset-right))
|
||||
calc(6px + env(safe-area-inset-bottom))
|
||||
calc(8px + env(safe-area-inset-left));
|
||||
background: rgba(0,0,0,0.85);
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#term-page.active .term-aux-bar.visible { display: flex; }
|
||||
.term-aux-bar button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: #ddd;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.term-aux-bar button:active { background: rgba(76,175,80,0.35); transform: scale(0.96); }
|
||||
.device-card h3 {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
@@ -558,7 +704,11 @@ inline std::string GetWebPageHTML() {
|
||||
#screen-canvas { max-width: 100%; max-height: 100%; }
|
||||
.screen-toolbar {
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.85) 100%);
|
||||
padding: 12px 20px;
|
||||
/* 远程桌面顶部工具栏也避开 notch / Dynamic Island —— 顶 padding + 安全区 */
|
||||
padding: calc(12px + env(safe-area-inset-top))
|
||||
calc(20px + env(safe-area-inset-right))
|
||||
12px
|
||||
calc(20px + env(safe-area-inset-left));
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -633,13 +783,14 @@ inline std::string GetWebPageHTML() {
|
||||
height: 100dvh !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
/* Portrait fullscreen: align canvas to top */
|
||||
/* Portrait fullscreen: align canvas to top. 56px 是给浮动工具栏留出的空间;
|
||||
竖屏 iPhone 全屏时再加上 safe-area-inset-top 把内容推到 notch / 灵动岛之下。 */
|
||||
@media (orientation: portrait) {
|
||||
#screen-page:fullscreen .canvas-container,
|
||||
#screen-page:-webkit-full-screen .canvas-container,
|
||||
#screen-page.pseudo-fullscreen .canvas-container {
|
||||
align-items: flex-start !important;
|
||||
padding-top: 56px !important;
|
||||
padding-top: calc(56px + env(safe-area-inset-top)) !important;
|
||||
}
|
||||
}
|
||||
#screen-page.pseudo-fullscreen #screen-canvas {
|
||||
@@ -666,7 +817,8 @@ inline std::string GetWebPageHTML() {
|
||||
/* Floating toolbar menu - minimal icon style */
|
||||
.floating-toolbar {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
/* 8px 基础留白 + safe-area-inset-top 避开 iPhone 刘海/灵动岛 */
|
||||
top: calc(8px + env(safe-area-inset-top));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1001;
|
||||
@@ -701,7 +853,8 @@ inline std::string GetWebPageHTML() {
|
||||
.toolbar-btn:disabled:active { transform: none; }
|
||||
.toolbar-toggle {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
/* 同 .floating-toolbar:8px 基础 + 安全区 inset 避开刘海/灵动岛 */
|
||||
top: calc(8px + env(safe-area-inset-top));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
@@ -779,6 +932,10 @@ inline std::string GetWebPageHTML() {
|
||||
/* Portrait: horizontal at top center */
|
||||
@media (orientation: portrait) {
|
||||
.quick-controls {
|
||||
/* 非全屏:screen-toolbar 已经吃了一份 safe-area,这里再叠加会让快捷按钮
|
||||
离 title 远到 ~2x 图标高度的空白,且 canvas-container 因此被推得过低,
|
||||
底部 input-shortcuts 在键盘弹出时会被遮挡。
|
||||
全屏:另在 fullscreen 选择器里补回 safe-area-inset-top 避开前置摄像头。 */
|
||||
top: 4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
@@ -791,12 +948,23 @@ inline std::string GetWebPageHTML() {
|
||||
height: 40px;
|
||||
font-size: 18px;
|
||||
}
|
||||
/* Portrait: align canvas to top, leave space for controls */
|
||||
/* Portrait: align canvas to top, leave space for controls.
|
||||
同上:非全屏 screen-toolbar 已避开刘海,这里只留固定 56px 给快捷按钮,
|
||||
全屏路径在下方有独立 !important 覆写补回 safe-area。 */
|
||||
.canvas-container {
|
||||
align-items: flex-start;
|
||||
padding-top: 56px;
|
||||
}
|
||||
}
|
||||
/* 全屏(无 screen-toolbar 兜底)才需要 quick-controls 自己加 safe-area
|
||||
避开 iPhone 刘海/灵动岛/前置摄像头。 */
|
||||
@media (orientation: portrait) {
|
||||
#screen-page:fullscreen .quick-controls,
|
||||
#screen-page:-webkit-full-screen .quick-controls,
|
||||
#screen-page.pseudo-fullscreen .quick-controls {
|
||||
top: calc(4px + env(safe-area-inset-top));
|
||||
}
|
||||
}
|
||||
/* Landscape: vertical at right center */
|
||||
@media (orientation: landscape) {
|
||||
.quick-controls {
|
||||
@@ -852,7 +1020,12 @@ inline std::string GetWebPageHTML() {
|
||||
.input-shortcuts .shortcut-btn:active { transform: scale(0.95); background: rgba(128,128,128,0.6); }
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.page { padding: 10px; }
|
||||
.page {
|
||||
padding: calc(10px + env(safe-area-inset-top))
|
||||
calc(10px + env(safe-area-inset-right))
|
||||
calc(10px + env(safe-area-inset-bottom))
|
||||
calc(10px + env(safe-area-inset-left));
|
||||
}
|
||||
.header { flex-direction: column; gap: 12px; padding: 12px; }
|
||||
.header h1 { font-size: 20px; }
|
||||
.search-box { width: 100%; }
|
||||
@@ -863,7 +1036,12 @@ inline std::string GetWebPageHTML() {
|
||||
.device-card .ip { font-size: 12px; }
|
||||
.device-card .meta-row { flex-wrap: wrap; gap: 8px; }
|
||||
.device-card .active-window { font-size: 11px; }
|
||||
.screen-toolbar { padding: 8px 12px; }
|
||||
.screen-toolbar {
|
||||
padding: calc(8px + env(safe-area-inset-top))
|
||||
calc(12px + env(safe-area-inset-right))
|
||||
8px
|
||||
calc(12px + env(safe-area-inset-left));
|
||||
}
|
||||
.back-btn { padding: 6px 12px; font-size: 13px; }
|
||||
.toolbar-info .device-name { font-size: 13px; }
|
||||
.toolbar-info .conn-info { font-size: 11px; }
|
||||
@@ -925,9 +1103,28 @@ inline std::string GetWebPageHTML() {
|
||||
<button id="view-grid" class="view-btn active" onclick="setViewMode('grid')" title="Grid View">Grid</button>
|
||||
<button id="view-list" class="view-btn" onclick="setViewMode('list')" title="List View">List</button>
|
||||
</div>
|
||||
<button class="refresh-btn" onclick="getDevices()">Refresh</button>
|
||||
<button class="users-btn" id="users-btn" onclick="openUsersModal()">Users</button>
|
||||
<button class="logout-btn" onclick="logout()">Logout</button>
|
||||
<button class="icon-btn refresh" onclick="getDevices()" title="Refresh" aria-label="Refresh">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<polyline points="1 20 1 14 7 14"/>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn users" id="users-btn" onclick="openUsersModal()" title="User Management" aria-label="User Management">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn logout" onclick="logout()" title="Logout" aria-label="Logout">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="stats-bar" class="stats-bar">
|
||||
@@ -985,6 +1182,26 @@ inline std::string GetWebPageHTML() {
|
||||
<input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off">
|
||||
</div>
|
||||
|
||||
<!-- Terminal Page (xterm.js) -->
|
||||
<div id="term-page" class="page">
|
||||
<div class="screen-toolbar">
|
||||
<button class="back-btn" onclick="closeTerminal()">Back</button>
|
||||
<div class="toolbar-info">
|
||||
<div id="term-title" class="device-name">Terminal</div>
|
||||
<div id="term-status-info" class="conn-info">Connecting...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="term-host" class="term-host"></div>
|
||||
<!-- 移动辅助按钮栏:触屏 / 窄屏环境下 JS 自动加 .visible -->
|
||||
<div class="term-aux-bar" id="term-aux-bar">
|
||||
<button onclick="termSendSpecial('tab')">Tab</button>
|
||||
<button onclick="termSendSpecial('esc')">Esc</button>
|
||||
<button onclick="termSendSpecial('ctrlc')">Ctrl+C</button>
|
||||
<button onclick="termSendSpecial('up')">↑</button>
|
||||
<button onclick="termHideKeyboard()" title="Hide keyboard" aria-label="Hide keyboard">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Management Modal -->
|
||||
<div class="modal-overlay" id="users-modal">
|
||||
<div class="modal-content">
|
||||
@@ -1013,6 +1230,31 @@ inline std::string GetWebPageHTML() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logout Confirmation Modal -->
|
||||
<div class="modal-overlay" id="logout-confirm-modal" onclick="if(event.target===this)cancelLogout()">
|
||||
<div class="confirm-modal-content">
|
||||
<div class="confirm-modal-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Confirm Logout</h3>
|
||||
<p>You will be returned to the login screen.<br>Continue?</p>
|
||||
<div class="confirm-modal-actions">
|
||||
<button class="cancel-btn" onclick="cancelLogout()">Cancel</button>
|
||||
<button class="danger-btn" onclick="confirmLogout()">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)HTML";
|
||||
|
||||
// 加载 xterm.js + FitAddon(终端)。放在 app script 前,保证 Terminal/FitAddon 全局可用。
|
||||
html += R"HTML(
|
||||
<script src="/static/xterm.js"></script>
|
||||
<script src="/static/xterm-fit.js"></script>
|
||||
)HTML";
|
||||
|
||||
// Part 7: JavaScript - State and WebSocket
|
||||
@@ -1228,6 +1470,24 @@ inline std::string GetWebPageHTML() {
|
||||
setTimeout(() => showPage('devices-page'), 2000);
|
||||
}
|
||||
break;
|
||||
case 'term_ready':
|
||||
termState.ready = true;
|
||||
document.getElementById('term-status-info').textContent =
|
||||
'Connected (' + (msg.mode === 'pty' ? 'PTY' : 'Legacy shell') + ')';
|
||||
// 通知 server 当前 cols/rows,PTY 模式下 host 才知道窗口尺寸
|
||||
if (termState.fit) try { termState.fit.fit(); } catch (e) {}
|
||||
notifyTerminalResize();
|
||||
break;
|
||||
case 'term_closed':
|
||||
if (termState.deviceId) {
|
||||
if (termState.term) {
|
||||
termState.term.write('\r\n\x1b[33m[Session closed' +
|
||||
(msg.msg ? ': ' + msg.msg : '') + ']\x1b[0m\r\n');
|
||||
}
|
||||
termState.ready = false;
|
||||
// 不立刻跳回 devices-page,让用户看到提示。点 Back 才真返回。
|
||||
}
|
||||
break;
|
||||
case 'disconnect_result':
|
||||
// disconnect() already handles navigation, this is just server acknowledgment
|
||||
// No action needed - prevents race conditions when switching devices
|
||||
@@ -1362,6 +1622,15 @@ inline std::string GetWebPageHTML() {
|
||||
let decodeTimestamp = 0; // Monotonically increasing timestamp for decoder
|
||||
|
||||
function handleBinaryFrame(data) {
|
||||
// 终端输出帧:4 字节 magic 'TRM1' (0x54 0x52 0x4D 0x31) → 转发到 xterm。
|
||||
// 视频帧首 4 字节是 deviceID (uint32 LE),撞这个具体值的概率极低;4 字节 magic
|
||||
// 比单字节前缀安全得多,无需额外的状态校验。
|
||||
const u8 = new Uint8Array(data);
|
||||
if (u8.length >= 4 &&
|
||||
u8[0] === 0x54 && u8[1] === 0x52 && u8[2] === 0x4D && u8[3] === 0x31) {
|
||||
if (termState && termState.term) termState.term.write(u8.subarray(4));
|
||||
return;
|
||||
}
|
||||
const view = new DataView(data);
|
||||
const deviceId = view.getUint32(0, true);
|
||||
const frameType = view.getUint8(4);
|
||||
@@ -1542,6 +1811,12 @@ inline std::string GetWebPageHTML() {
|
||||
const ver = d.version || '-';
|
||||
const activeWin = d.activeWindow || '';
|
||||
return '<div class="device-card" onclick="connectDevice(\'' + d.id + '\')">' +
|
||||
'<button class="card-term-btn" title="Open Terminal" aria-label="Terminal" ' +
|
||||
'onclick="event.stopPropagation();openTerminal(\'' + d.id + '\')">' +
|
||||
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' +
|
||||
'</svg>' +
|
||||
'</button>' +
|
||||
'<h3>' + escapeHtml(d.name || 'Unknown') + '</h3>' +
|
||||
'<div class="info-row">' +
|
||||
'<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' +
|
||||
@@ -1648,7 +1923,15 @@ inline std::string GetWebPageHTML() {
|
||||
ws.send(JSON.stringify({ cmd: 'login', username, response, nonce: challengeNonce }));
|
||||
}
|
||||
|
||||
// Logout 二次确认:onclick="logout()" 仅打开确认弹窗;用户点 "Logout" 才真正退出。
|
||||
function logout() {
|
||||
document.getElementById('logout-confirm-modal').classList.add('active');
|
||||
}
|
||||
function cancelLogout() {
|
||||
document.getElementById('logout-confirm-modal').classList.remove('active');
|
||||
}
|
||||
function confirmLogout() {
|
||||
document.getElementById('logout-confirm-modal').classList.remove('active');
|
||||
// Close and reconnect WebSocket to get new challenge
|
||||
if (ws) {
|
||||
ws.onclose = null; // Prevent auto-reconnect delay
|
||||
@@ -1771,15 +2054,27 @@ inline std::string GetWebPageHTML() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN || !token) return;
|
||||
ws.send(JSON.stringify({ cmd: 'get_devices', token }));
|
||||
}
|
||||
)HTML";
|
||||
|
||||
// Part 8b1: Device connect / terminal session (split to avoid MSVC string literal length limit)
|
||||
html += R"HTML(
|
||||
function connectDevice(id) {
|
||||
const compat = checkWebCodecs();
|
||||
if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; }
|
||||
currentDevice = devices.find(d => d.id === id || d.id === String(id));
|
||||
if (!currentDevice || !currentDevice.online) {
|
||||
const dev = devices.find(d => d.id === id || d.id === String(id));
|
||||
if (!dev || !dev.online) {
|
||||
alert('Device is offline');
|
||||
return;
|
||||
}
|
||||
// 无显示器(screen 字段格式 "n:WxH",n=0 表示无显示器)→ 远程桌面没有意义,
|
||||
// 直接走终端。
|
||||
const screenStr = String(dev.screen || '');
|
||||
const screenCount = parseInt(screenStr.split(':')[0], 10);
|
||||
if (!isNaN(screenCount) && screenCount === 0) {
|
||||
openTerminal(id);
|
||||
return;
|
||||
}
|
||||
const compat = checkWebCodecs();
|
||||
if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; }
|
||||
currentDevice = dev;
|
||||
document.getElementById('device-name').textContent = currentDevice.name;
|
||||
document.getElementById('frame-info').textContent = '';
|
||||
updateScreenStatus('connecting');
|
||||
@@ -1787,6 +2082,176 @@ inline std::string GetWebPageHTML() {
|
||||
ws.send(JSON.stringify({ cmd: 'connect', id: String(id), token }));
|
||||
}
|
||||
|
||||
// ====== Web 终端(xterm.js)======
|
||||
// 单设备单 web 终端:本地状态保留 deviceId、xterm 实例、fit-addon。
|
||||
let termState = { deviceId: null, term: null, fit: null, ready: false };
|
||||
|
||||
function openTerminal(id) {
|
||||
if (typeof Terminal === 'undefined') {
|
||||
alert('Terminal library not loaded yet, please retry');
|
||||
return;
|
||||
}
|
||||
const dev = devices.find(d => d.id === id || d.id === String(id));
|
||||
if (!dev || !dev.online) { alert('Device is offline'); return; }
|
||||
// 已经有终端在跑:直接 show(重连同设备视为重置)
|
||||
if (termState.deviceId && termState.deviceId !== String(id)) {
|
||||
closeTerminal();
|
||||
}
|
||||
termState.deviceId = String(id);
|
||||
termState.ready = false;
|
||||
|
||||
document.getElementById('term-title').textContent = dev.name + ' Terminal';
|
||||
document.getElementById('term-status-info').textContent = 'Connecting...';
|
||||
|
||||
// 先 showPage 让 term-host 拿到真实尺寸;xterm.open() 必须在容器有 size 时调用,
|
||||
// 否则首次 fit 会算成 0 列 0 行,渲染异常 + 输入捕获也不灵。
|
||||
showPage('term-page');
|
||||
|
||||
// 触屏 / 窄屏:显示辅助按钮栏(Tab/Esc/Ctrl+C/↑)
|
||||
// 桌面浏览器有物理键盘,不需要这一行,节省屏幕空间
|
||||
const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
||||
const auxBar = document.getElementById('term-aux-bar');
|
||||
if (isTouch || window.innerWidth <= 768) {
|
||||
auxBar.classList.add('visible');
|
||||
} else {
|
||||
auxBar.classList.remove('visible');
|
||||
}
|
||||
|
||||
// 用 requestAnimationFrame + 50ms 双重保险,确保 reflow 完成
|
||||
requestAnimationFrame(() => setTimeout(() => {
|
||||
if (!termState.term) {
|
||||
termState.term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: 'Menlo, Consolas, "DejaVu Sans Mono", monospace',
|
||||
fontSize: 13,
|
||||
theme: { background: '#000000', foreground: '#e0e0e0' },
|
||||
convertEol: true, // 将 \n 视为 \r\n(兼容只发 LF 的程序)
|
||||
scrollback: 5000
|
||||
});
|
||||
if (typeof FitAddon !== 'undefined') {
|
||||
termState.fit = new FitAddon.FitAddon();
|
||||
termState.term.loadAddon(termState.fit);
|
||||
}
|
||||
termState.term.open(document.getElementById('term-host'));
|
||||
|
||||
// 用户键入 → 发给 server
|
||||
termState.term.onData(data => {
|
||||
if (!termState.ready || !termState.deviceId) return;
|
||||
ws.send(JSON.stringify({ cmd: 'term_input', id: termState.deviceId, data, token }));
|
||||
});
|
||||
|
||||
// 移动端:点击容器任意位置都把焦点拉回 xterm 的隐藏输入元素
|
||||
document.getElementById('term-host').addEventListener('click', () => {
|
||||
if (termState.term) termState.term.focus();
|
||||
});
|
||||
} else {
|
||||
termState.term.clear();
|
||||
}
|
||||
if (termState.fit) try { termState.fit.fit(); } catch (e) {}
|
||||
termState.term.focus();
|
||||
}, 30));
|
||||
|
||||
ws.send(JSON.stringify({ cmd: 'term_open', id: String(id), token }));
|
||||
}
|
||||
|
||||
// 主动收起 iOS 软键盘:blur xterm 的隐藏 textarea。iOS 没有原生关闭键盘按钮,
|
||||
// 必须由我们提供一个,否则用户在终端里只能下拉浏览器关键盘。
|
||||
function termHideKeyboard() {
|
||||
if (termState.term) {
|
||||
try { termState.term.blur(); } catch (e) {}
|
||||
}
|
||||
// 兜底:直接 blur 活动元素
|
||||
if (document.activeElement && document.activeElement.blur) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
|
||||
// visualViewport 适配:iOS 软键盘弹出时 layout viewport 不变,但 visualViewport.height 缩小。
|
||||
// 把 term-page 的 padding-bottom 加大 = 键盘高度,挤压内容上移,辅助栏跟着浮在键盘正上方。
|
||||
// 桌面浏览器 visualViewport 永远 = innerHeight,bottomInset = 0,这段是 no-op。
|
||||
function adjustTermViewport() {
|
||||
if (!window.visualViewport) return;
|
||||
const page = document.getElementById('term-page');
|
||||
if (!page || !page.classList.contains('active')) return;
|
||||
|
||||
const layoutH = window.innerHeight;
|
||||
const visualH = window.visualViewport.height;
|
||||
const offsetTop = window.visualViewport.offsetTop || 0;
|
||||
const bottomInset = Math.max(0, Math.round(layoutH - visualH - offsetTop));
|
||||
|
||||
page.style.paddingBottom = bottomInset + 'px';
|
||||
// 内容大小变了,xterm 重 fit + 通知 server 调 PTY 尺寸
|
||||
if (termState.fit) try { termState.fit.fit(); notifyTerminalResize(); } catch (e) {}
|
||||
}
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', adjustTermViewport);
|
||||
window.visualViewport.addEventListener('scroll', adjustTermViewport);
|
||||
}
|
||||
|
||||
// iOS 双指手势会缩放 / 平移 visual viewport,把页面顶起 → Back 按钮跑出视野。
|
||||
// viewport meta 的 user-scalable=no 在 iOS 10+ 已被忽略(无障碍考虑),必须用 JS
|
||||
// 主动阻止双指 touchmove 和 gesture 事件。仅 term-page 激活时拦截,screen-page 上
|
||||
// 的双指 pinch-to-zoom 不受影响(既有交互保留)。
|
||||
const __isTermActive = () => {
|
||||
const p = document.getElementById('term-page');
|
||||
return p && p.classList.contains('active');
|
||||
};
|
||||
document.addEventListener('touchmove', function(e) {
|
||||
if (e.touches.length > 1 && __isTermActive()) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
['gesturestart', 'gesturechange', 'gestureend'].forEach(ev => {
|
||||
document.addEventListener(ev, function(e) {
|
||||
if (__isTermActive()) e.preventDefault();
|
||||
}, { passive: false });
|
||||
});
|
||||
|
||||
// 发送特殊按键到终端(手机辅助栏 onclick 调用)。直接走 ws,不经 xterm(避免 focus 抢夺)。
|
||||
function termSendSpecial(name) {
|
||||
if (!termState.ready || !termState.deviceId) return;
|
||||
const seq = ({
|
||||
tab: '\t',
|
||||
esc: '\x1b',
|
||||
ctrlc: '\x03', // ETX = Ctrl+C 信号
|
||||
up: '\x1b[A', // ANSI 上方向键 = 历史命令
|
||||
})[name];
|
||||
if (!seq) return;
|
||||
ws.send(JSON.stringify({ cmd: 'term_input', id: termState.deviceId, data: seq, token }));
|
||||
if (termState.term) termState.term.focus(); // 按完辅助键自动把焦点拉回 xterm
|
||||
}
|
||||
|
||||
function closeTerminal() {
|
||||
if (termState.deviceId) {
|
||||
ws.send(JSON.stringify({ cmd: 'term_close', id: termState.deviceId, token }));
|
||||
}
|
||||
termState.deviceId = null;
|
||||
termState.ready = false;
|
||||
// 离开终端页前清掉 visualViewport 留下的 padding-bottom,避免下次切回时 stale
|
||||
const page = document.getElementById('term-page');
|
||||
if (page) page.style.paddingBottom = '';
|
||||
showPage('devices-page');
|
||||
}
|
||||
)HTML";
|
||||
|
||||
// Part 8c: Terminal resize / pinch suppression (split to avoid MSVC string literal length limit)
|
||||
html += R"HTML(
|
||||
// 终端窗口大小变化 → 通知 server 调 PTY 尺寸(仅 PTY 模式有效,老 cmd 服务端会忽略)
|
||||
function notifyTerminalResize() {
|
||||
if (!termState.ready || !termState.deviceId || !termState.term) return;
|
||||
const cols = termState.term.cols, rows = termState.term.rows;
|
||||
ws.send(JSON.stringify({ cmd: 'term_resize', id: termState.deviceId, cols, rows, token }));
|
||||
}
|
||||
window.addEventListener('resize', () => {
|
||||
if (termState.fit && document.getElementById('term-page').classList.contains('active')) {
|
||||
try { termState.fit.fit(); notifyTerminalResize(); } catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
)HTML";
|
||||
|
||||
// Part 9b: Fullscreen + control state (split to avoid MSVC string literal length limit)
|
||||
html += R"HTML(
|
||||
function toggleFullscreen() {
|
||||
const el = document.getElementById('screen-page');
|
||||
const isFs = document.fullscreenElement || document.webkitFullscreenElement;
|
||||
@@ -2118,9 +2583,20 @@ inline std::string GetWebPageHTML() {
|
||||
// Part 14b: JavaScript - Zoom state and touch helpers
|
||||
html += R"HTML(
|
||||
// Two-finger gesture constants
|
||||
const ZOOM_THRESHOLD = 0.05; // 5% distance change to trigger zoom
|
||||
const SCROLL_SENSITIVITY = 3; // Scroll speed multiplier
|
||||
const SCROLL_DEADZONE = 2; // Minimum scroll delta to send
|
||||
// 缩放 vs 滚动判定(仅在双指 + 触摸场景):
|
||||
// 1) 间距比例变化 > ZOOM_THRESHOLD 且
|
||||
// 2) 间距绝对变化 > ZOOM_MIN_PX 且
|
||||
// 3) 间距变化 > 中心垂直位移 → 判为缩放
|
||||
// 三个条件同时满足才触发缩放,避免双指上下滚动时手指自然张合的 ~5% 抖动被误判。
|
||||
// 真实缩放:双指主动张合,间距变化幅度本身就远大于这些阈值。
|
||||
const ZOOM_THRESHOLD = 0.15; // 间距比例变化阈值(15%)
|
||||
const ZOOM_MIN_PX = 30; // 间距绝对变化阈值(px)
|
||||
// 注:服务端 (WebService.cpp HandleMouse) 把 wheelDelta 钳成 ±120 一格 notch,
|
||||
// SCROLL_SENSITIVITY 实际不起作用;真正决定滚动速度的是 SCROLL_DEADZONE。
|
||||
// 触摸 touchmove 在 60fps 下高频触发,旧值 2px 等于手指动一下就连发一堆 notch,体感非常飘。
|
||||
// 现在 28px ≈ 让手指移动 ~1 个文本行距离才发一格,接近 iOS 原生触摸滚动节奏。
|
||||
const SCROLL_SENSITIVITY = 1; // (server clamps to ±120, kept for clarity only)
|
||||
const SCROLL_DEADZONE = 28; // Minimum finger-Y delta (px) to send one wheel notch
|
||||
|
||||
// Pinch-to-zoom state
|
||||
let zoomState = {
|
||||
@@ -2139,7 +2615,9 @@ inline std::string GetWebPageHTML() {
|
||||
// Two-finger gesture detection
|
||||
hasZoomed: false, // Whether zoom occurred in current gesture
|
||||
lastScrollY: 0, // For scroll delta calculation
|
||||
initialPinchDist: 0 // Distance at gesture start (for cumulative detection)
|
||||
initialPinchDist: 0, // Distance at gesture start (for cumulative detection)
|
||||
initialCenterX: 0, // Pinch center at gesture start (for scroll-vs-zoom intent)
|
||||
initialCenterY: 0
|
||||
};
|
||||
const zoomIndicator = document.getElementById('zoom-indicator');
|
||||
let zoomIndicatorTimer = null;
|
||||
@@ -2345,6 +2823,8 @@ inline std::string GetWebPageHTML() {
|
||||
const center = getPinchCenter(e.touches);
|
||||
zoomState.pinchCenterX = center.x;
|
||||
zoomState.pinchCenterY = center.y;
|
||||
zoomState.initialCenterX = center.x;
|
||||
zoomState.initialCenterY = center.y;
|
||||
zoomState.lastScrollY = center.y;
|
||||
|
||||
if (zoomState.scale === 1) {
|
||||
@@ -2448,16 +2928,28 @@ inline std::string GetWebPageHTML() {
|
||||
const totalDelta = newDist / zoomState.initialPinchDist; // Cumulative change from gesture start
|
||||
|
||||
// Detect gesture type: zoom vs scroll
|
||||
// Use CUMULATIVE change to detect zoom intent (catches slow pinch gestures)
|
||||
// Also treat as zoom if already at scale boundary and trying to zoom further
|
||||
// 缩放意图 = 间距比例显著变 + 间距绝对显著变 + 间距变化 > 中心垂直位移
|
||||
// 单纯比例阈值 5% 太敏感:双指上下滚动时手指自然张合 ~5% 就被误判为缩放。
|
||||
// 结合绝对像素阈值 + "间距变化 vs 中心位移" 的方向性,能稳定区分两种意图。
|
||||
const distAbsChange = Math.abs(newDist - zoomState.initialPinchDist);
|
||||
const distRatioChange = Math.abs(totalDelta - 1);
|
||||
const centerMoveY = Math.abs(newCenter.y - zoomState.initialCenterY);
|
||||
|
||||
const isClearZoom = distRatioChange > ZOOM_THRESHOLD &&
|
||||
distAbsChange > ZOOM_MIN_PX &&
|
||||
distAbsChange > centerMoveY;
|
||||
|
||||
// 已到缩放边界仍朝同方向尝试 → 给视觉反馈,但要求绝对像素变化 > 半阈值,
|
||||
// 避免静止时手指轻微抖动也被认为"想缩放"。
|
||||
const atMinScale = zoomState.scale <= zoomState.minScale;
|
||||
const atMaxScale = zoomState.scale >= zoomState.maxScale;
|
||||
const tryingToShrink = totalDelta < 1; // Use cumulative for direction
|
||||
const tryingToShrink = totalDelta < 1;
|
||||
const tryingToEnlarge = totalDelta > 1;
|
||||
const boundaryFeedback = distAbsChange > ZOOM_MIN_PX / 2 && (
|
||||
(atMinScale && tryingToShrink) || (atMaxScale && tryingToEnlarge)
|
||||
);
|
||||
|
||||
if (Math.abs(totalDelta - 1) > ZOOM_THRESHOLD ||
|
||||
(atMinScale && tryingToShrink) ||
|
||||
(atMaxScale && tryingToEnlarge)) {
|
||||
if (isClearZoom || boundaryFeedback) {
|
||||
zoomState.hasZoomed = true;
|
||||
}
|
||||
|
||||
@@ -2792,36 +3284,38 @@ inline std::string GetWebPageHTML() {
|
||||
sendKey(e.keyCode, false, e.altKey);
|
||||
});
|
||||
|
||||
mobileKeyboard.addEventListener('input', function(e) {
|
||||
const char = e.data;
|
||||
if (char) {
|
||||
// Check if character needs Shift key
|
||||
const isUpperCase = char >= 'A' && char <= 'Z';
|
||||
const shiftSymbols = '~!@#$%^&*()_+{}|:"<>?';
|
||||
const needsShift = isUpperCase || shiftSymbols.includes(char);
|
||||
|
||||
// Map symbols to their base keys
|
||||
const symbolMap = {
|
||||
// 字符 → Win32 VK 映射表(提到 listener 外避免每次输入重建)。
|
||||
// US 键盘上"不需要 Shift"的 OEM 符号 ASCII 码 ≠ VK 码,必须显式映射。
|
||||
const SHIFT_SYMBOLS = '~!@#$%^&*()_+{}|:"<>?';
|
||||
const SYMBOL_VK_MAP = {
|
||||
// —— Unshifted OEM symbols (US layout) ——
|
||||
'`': 192, '-': 189, '=': 187, '[': 219, ']': 221, '\\': 220,
|
||||
';': 186, "'": 222, ',': 188, '.': 190, '/': 191,
|
||||
// —— Shifted symbols (与 above 共享 VK,多了 Shift 修饰) ——
|
||||
'~': 192, '!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
|
||||
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48, '_': 189,
|
||||
'+': 187, '{': 219, '}': 221, '|': 220, ':': 186,
|
||||
'"': 222, '<': 188, '>': 190, '?': 191
|
||||
};
|
||||
|
||||
let keyCode;
|
||||
if (symbolMap[char]) {
|
||||
keyCode = symbolMap[char];
|
||||
} else {
|
||||
keyCode = char.toUpperCase().charCodeAt(0);
|
||||
}
|
||||
|
||||
// Send Shift down if needed
|
||||
// 把单个字符发成一对 keyDown/keyUp(必要时夹一对 Shift)
|
||||
function sendCharAsKey(ch) {
|
||||
const isUpperCase = ch >= 'A' && ch <= 'Z';
|
||||
const needsShift = isUpperCase || SHIFT_SYMBOLS.includes(ch);
|
||||
const keyCode = SYMBOL_VK_MAP[ch] || ch.toUpperCase().charCodeAt(0);
|
||||
if (needsShift) sendKey(16, true); // VK_SHIFT = 16
|
||||
sendKey(keyCode, true);
|
||||
sendKey(keyCode, false);
|
||||
// Send Shift up if needed
|
||||
if (needsShift) sendKey(16, false);
|
||||
}
|
||||
|
||||
mobileKeyboard.addEventListener('input', function(e) {
|
||||
// e.data 可能携带多个字符(中/日/韩 IME 候选词上屏、Gboard 滑行输入、
|
||||
// 剪贴板粘贴一段文本都会一次性 commit)。逐字符发送,保证每个都到达 host。
|
||||
const text = e.data;
|
||||
if (text) {
|
||||
for (const ch of text) sendCharAsKey(ch);
|
||||
}
|
||||
mobileKeyboard.value = '';
|
||||
});
|
||||
|
||||
@@ -2920,16 +3414,18 @@ inline std::string GetWebPageHTML() {
|
||||
if (document.hidden) {
|
||||
// Page going to background
|
||||
const screenPage = document.getElementById('screen-page');
|
||||
const termPage = document.getElementById('term-page');
|
||||
const onScreenPage = screenPage && screenPage.classList.contains('active') && currentDevice;
|
||||
const onTermPage = termPage && termPage.classList.contains('active') && termState.deviceId;
|
||||
|
||||
if (onScreenPage) {
|
||||
// Mobile/tablet: delay disconnect 30s
|
||||
// Desktop: keep connection alive (no timer)
|
||||
if (onScreenPage || onTermPage) {
|
||||
// 屏幕预览 / 终端:移动端给 30 秒宽限,期间切回应用就无缝继续;
|
||||
// 桌面:保持长连,靠 ping 心跳
|
||||
if (isTouchDevice) {
|
||||
backgroundDisconnectTimer = setTimeout(doBackgroundDisconnect, BACKGROUND_TIMEOUT_MOBILE);
|
||||
}
|
||||
} else {
|
||||
// Other pages - disconnect immediately
|
||||
// 其它页面 - 立即断开省流量
|
||||
doBackgroundDisconnect();
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "stdafx.h"
|
||||
#include "2015Remote.h"
|
||||
#include "resource.h" // IDR_WEB_XTERM_* (xterm.js/css 静态资源 ID)
|
||||
#include "WebService.h"
|
||||
#include "WebServiceAuth.h"
|
||||
#include "2015RemoteDlg.h"
|
||||
@@ -18,6 +19,21 @@
|
||||
|
||||
#pragma comment(lib, "ws2_32.lib")
|
||||
|
||||
// Load a Win32 BINARY resource by ID as std::string (raw bytes).
|
||||
// Returns empty string on failure. The std::string is OK to hold binary data
|
||||
// (we only treat it as bytes; size is from .length()).
|
||||
static std::string LoadBinaryResourceAsString(int resourceId) {
|
||||
HRSRC hRes = FindResourceA(NULL, MAKEINTRESOURCEA(resourceId), "BINARY");
|
||||
if (!hRes) return {};
|
||||
DWORD size = SizeofResource(NULL, hRes);
|
||||
if (!size) return {};
|
||||
HGLOBAL hData = LoadResource(NULL, hRes);
|
||||
if (!hData) return {};
|
||||
LPVOID p = LockResource(hData);
|
||||
if (!p) return {};
|
||||
return std::string(static_cast<const char*>(p), size);
|
||||
}
|
||||
|
||||
// Challenge-response nonce storage (prevents replay attacks)
|
||||
static std::map<void*, std::string> s_ClientNonces;
|
||||
static std::mutex s_NonceMutex;
|
||||
@@ -241,9 +257,30 @@ void CWebService::ServerThread(int port) {
|
||||
static std::string cachedHtml = GetWebPageHTML();
|
||||
std::string payloadsDir = m_PayloadsDir; // Capture for lambda
|
||||
|
||||
wsServer.onHttp([payloadsDir](const std::string& path) -> ws::HttpResponse {
|
||||
// 静态资源缓存:xterm.js / xterm.css / fit-addon。RC binary 资源加载一次缓存到内存,
|
||||
// 避免每个浏览器请求都去 LockResource。Cache-Control 给浏览器侧缓存友好。
|
||||
static std::string cachedXtermJs = LoadBinaryResourceAsString(IDR_WEB_XTERM_JS);
|
||||
static std::string cachedXtermCss = LoadBinaryResourceAsString(IDR_WEB_XTERM_CSS);
|
||||
static std::string cachedXtermFitJs = LoadBinaryResourceAsString(IDR_WEB_XTERM_FIT_JS);
|
||||
|
||||
auto buildStatic = [](const std::string& body, const std::string& mime) {
|
||||
ws::HttpResponse r = ws::HttpResponse::OK(body, mime);
|
||||
r.headers["Cache-Control"] = "public, max-age=86400"; // 1 day
|
||||
return r;
|
||||
};
|
||||
|
||||
wsServer.onHttp([payloadsDir, buildStatic](const std::string& path) -> ws::HttpResponse {
|
||||
if (path == "/" || path == "/index.html") {
|
||||
return ws::HttpResponse::OK(cachedHtml);
|
||||
} else if (path == "/static/xterm.js") {
|
||||
if (cachedXtermJs.empty()) return ws::HttpResponse::NotFound();
|
||||
return buildStatic(cachedXtermJs, "application/javascript; charset=utf-8");
|
||||
} else if (path == "/static/xterm.css") {
|
||||
if (cachedXtermCss.empty()) return ws::HttpResponse::NotFound();
|
||||
return buildStatic(cachedXtermCss, "text/css; charset=utf-8");
|
||||
} else if (path == "/static/xterm-fit.js") {
|
||||
if (cachedXtermFitJs.empty()) return ws::HttpResponse::NotFound();
|
||||
return buildStatic(cachedXtermFitJs, "application/javascript; charset=utf-8");
|
||||
} else if (path == "/health") {
|
||||
return ws::HttpResponse::OK("{\"status\":\"ok\"}", "application/json");
|
||||
} else if (path == "/manifest.json") {
|
||||
@@ -366,6 +403,14 @@ void CWebService::ServerThread(int port) {
|
||||
HandleListUsers(ws_ptr, token);
|
||||
} else if (cmd == "get_groups") {
|
||||
HandleGetGroups(ws_ptr, token);
|
||||
} else if (cmd == "term_open") {
|
||||
HandleTermOpen(ws_ptr, msg);
|
||||
} else if (cmd == "term_input") {
|
||||
HandleTermInput(ws_ptr, msg);
|
||||
} else if (cmd == "term_resize") {
|
||||
HandleTermResize(ws_ptr, msg);
|
||||
} else if (cmd == "term_close") {
|
||||
HandleTermClose(ws_ptr, msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1163,6 +1208,16 @@ void CWebService::UnregisterClient(void* ws_ptr) {
|
||||
if (device_id > 0) {
|
||||
StopRemoteDesktop(device_id);
|
||||
}
|
||||
|
||||
// 关闭这个 web client 持有的所有终端会话(MVP 一个用户一台主机一个,但兜底全扫描)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
std::vector<uint64_t> to_close;
|
||||
for (auto& kv : m_TermSessions) {
|
||||
if (kv.second.ws_ptr == ws_ptr) to_close.push_back(kv.first);
|
||||
}
|
||||
for (uint64_t did : to_close) CloseTermSessionLocked(did);
|
||||
}
|
||||
}
|
||||
|
||||
WebClient* CWebService::FindClient(void* ws_ptr) {
|
||||
@@ -1716,6 +1771,255 @@ void CWebService::StopRemoteDesktop(uint64_t device_id) {
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Web Terminal Session
|
||||
//
|
||||
// 数据流向:
|
||||
// 浏览器 ── term_open ──► HandleTermOpen ── COMMAND_SHELL ──► 客户端
|
||||
// 客户端 ── shell 子上下文 ──► MessageHandle TOKEN_TERMINAL_START
|
||||
// ── RegisterTerminalContext ──► WebService
|
||||
// 客户端 shell 输出 ── TOKEN_TERMINAL_DATA ──► MessageHandle ──► OnTerminalData
|
||||
// ──► term_output 给浏览器
|
||||
// 浏览器 keystrokes ── term_input ──► HandleTermInput ──► shell_ctx->Send2Client
|
||||
//
|
||||
// 一台主机最多一个 web 终端会话(MVP)。
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
static std::string BuildTermJson(const std::string& cmd, std::initializer_list<std::pair<const char*, std::string>> kv) {
|
||||
Json::Value v;
|
||||
v["cmd"] = cmd;
|
||||
for (auto& p : kv) v[p.first] = p.second;
|
||||
Json::StreamWriterBuilder b; b["indentation"] = "";
|
||||
return Json::writeString(b, v);
|
||||
}
|
||||
|
||||
void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) {
|
||||
Json::Value root; Json::Reader rdr;
|
||||
if (!rdr.parse(msg, root)) return;
|
||||
|
||||
std::string token = root.get("token", "").asString();
|
||||
std::string username, role;
|
||||
if (!ValidateToken(token, username, role)) {
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Invalid token"));
|
||||
return;
|
||||
}
|
||||
std::string id_str = root.get("id", "").asString();
|
||||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||||
if (!device_id || !m_pParentDlg) {
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Bad request"));
|
||||
return;
|
||||
}
|
||||
context* ctx = m_pParentDlg->FindHost(device_id);
|
||||
if (!ctx) {
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Device offline"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Group permission check (admin 全部可见)
|
||||
if (username != "admin") {
|
||||
std::string g = ctx->GetGroupName(); if (g.empty()) g = "default";
|
||||
bool ok = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_UsersMutex);
|
||||
for (auto& u : m_Users) if (u.username == username) {
|
||||
for (auto& ag : u.allowed_groups) if (ag == g) { ok = true; break; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Permission denied"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 占用:MVP 阶段单设备单 web 终端
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
if (m_TermSessions.find(device_id) != m_TermSessions.end() ||
|
||||
m_TermPending.find(device_id) != m_TermPending.end()) {
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false,
|
||||
"Terminal already open by another viewer"));
|
||||
return;
|
||||
}
|
||||
WebTermSession s; s.ws_ptr = ws_ptr; s.device_id = device_id;
|
||||
s.shell_ctx = nullptr; s.is_pty = false;
|
||||
m_TermSessions[device_id] = s;
|
||||
m_TermPending.insert(device_id);
|
||||
}
|
||||
|
||||
// 触发客户端:发 COMMAND_SHELL
|
||||
BYTE cmd = COMMAND_SHELL;
|
||||
if (!ctx->Send2Client(&cmd, 1)) {
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
m_TermSessions.erase(device_id);
|
||||
m_TermPending.erase(device_id);
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Send failed"));
|
||||
return;
|
||||
}
|
||||
Mprintf("[WebService] term_open device=%llu user=%s\n", device_id, username.c_str());
|
||||
}
|
||||
|
||||
void CWebService::HandleTermInput(void* ws_ptr, const std::string& msg) {
|
||||
Json::Value root; Json::Reader rdr;
|
||||
if (!rdr.parse(msg, root)) return;
|
||||
std::string token = root.get("token", "").asString();
|
||||
std::string username, role;
|
||||
if (!ValidateToken(token, username, role)) return;
|
||||
std::string id_str = root.get("id", "").asString();
|
||||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||||
std::string data = root.get("data", "").asString();
|
||||
if (!device_id || data.empty()) return;
|
||||
|
||||
CONTEXT_OBJECT* shellCtx = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermSessions.find(device_id);
|
||||
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
|
||||
shellCtx = it->second.shell_ctx;
|
||||
}
|
||||
if (!shellCtx) return; // shell 子上下文还没就绪
|
||||
shellCtx->Send2Client((BYTE*)data.data(), (ULONG)data.size());
|
||||
}
|
||||
|
||||
void CWebService::HandleTermResize(void* ws_ptr, const std::string& msg) {
|
||||
Json::Value root; Json::Reader rdr;
|
||||
if (!rdr.parse(msg, root)) return;
|
||||
std::string token = root.get("token", "").asString();
|
||||
std::string username, role;
|
||||
if (!ValidateToken(token, username, role)) return;
|
||||
std::string id_str = root.get("id", "").asString();
|
||||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||||
int cols = root.get("cols", 0).asInt();
|
||||
int rows = root.get("rows", 0).asInt();
|
||||
if (!device_id || cols <= 0 || rows <= 0) return;
|
||||
|
||||
CONTEXT_OBJECT* shellCtx = nullptr;
|
||||
bool isPty = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermSessions.find(device_id);
|
||||
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
|
||||
shellCtx = it->second.shell_ctx;
|
||||
isPty = it->second.is_pty;
|
||||
}
|
||||
if (!shellCtx || !isPty) return; // 老 cmd 模式不支持 resize
|
||||
|
||||
BYTE buf[5];
|
||||
buf[0] = CMD_TERMINAL_RESIZE;
|
||||
*(short*)(buf + 1) = (short)cols;
|
||||
*(short*)(buf + 3) = (short)rows;
|
||||
shellCtx->Send2Client(buf, 5);
|
||||
}
|
||||
|
||||
void CWebService::HandleTermClose(void* ws_ptr, const std::string& msg) {
|
||||
Json::Value root; Json::Reader rdr;
|
||||
if (!rdr.parse(msg, root)) return;
|
||||
std::string id_str = root.get("id", "").asString();
|
||||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||||
if (!device_id) return;
|
||||
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermSessions.find(device_id);
|
||||
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
|
||||
CloseTermSessionLocked(device_id);
|
||||
}
|
||||
|
||||
void CWebService::CloseTermSessionLocked(uint64_t device_id) {
|
||||
auto it = m_TermSessions.find(device_id);
|
||||
if (it == m_TermSessions.end()) return;
|
||||
|
||||
CONTEXT_OBJECT* shellCtx = it->second.shell_ctx;
|
||||
void* ws_ptr = it->second.ws_ptr;
|
||||
m_TermSessions.erase(it);
|
||||
m_TermPending.erase(device_id);
|
||||
if (shellCtx) {
|
||||
m_TermContextToDevice.erase(shellCtx);
|
||||
// 触发客户端 shell 退出:直接断该子上下文
|
||||
// (老 ShellDlg 是直接 Send TOKEN_BYE 之类,但断开更可靠)
|
||||
shellCtx->CancelIO();
|
||||
}
|
||||
// 通知前端
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", true, "closed"));
|
||||
Mprintf("[WebService] term_closed device=%llu\n", device_id);
|
||||
}
|
||||
|
||||
bool CWebService::IsTermPending(uint64_t device_id) {
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
return m_TermPending.find(device_id) != m_TermPending.end();
|
||||
}
|
||||
|
||||
void CWebService::RegisterTerminalContext(uint64_t device_id, CONTEXT_OBJECT* ctx, bool isPty) {
|
||||
void* ws_ptr_to_notify = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermSessions.find(device_id);
|
||||
if (it == m_TermSessions.end()) return; // 没有等的 web session 了
|
||||
it->second.shell_ctx = ctx;
|
||||
it->second.is_pty = isPty;
|
||||
m_TermContextToDevice[ctx] = device_id;
|
||||
m_TermPending.erase(device_id);
|
||||
ws_ptr_to_notify = it->second.ws_ptr;
|
||||
}
|
||||
|
||||
// 关键步骤:告知客户端"启动 shell 输出回流"。
|
||||
// PTY 模式:客户端 PTYHandler 已 fork 了 shell 子进程,但读线程要靠 COMMAND_NEXT 才启动
|
||||
// (参考 TerminalDlg::OnTerminalReady)。漏发会导致 shell 在跑但输出永远不送回。
|
||||
// PTY 还要先告知初始 cols/rows(默认 80x24),否则 shell 会按 PTY 默认尺寸渲染,
|
||||
// vim 等 TUI 在浏览器侧的 fit 调整前会乱。后续浏览器 term_resize 会再调整。
|
||||
if (isPty) {
|
||||
BYTE resizeBuf[5];
|
||||
resizeBuf[0] = CMD_TERMINAL_RESIZE;
|
||||
*(short*)(resizeBuf + 1) = (short)80;
|
||||
*(short*)(resizeBuf + 3) = (short)24;
|
||||
ctx->Send2Client(resizeBuf, 5);
|
||||
}
|
||||
BYTE startCmd = COMMAND_NEXT;
|
||||
ctx->Send2Client(&startCmd, 1);
|
||||
|
||||
// 通知前端 ready,告知模式(pty / legacy)
|
||||
if (ws_ptr_to_notify) {
|
||||
SendText(ws_ptr_to_notify, BuildTermJson("term_ready", {{"mode", isPty ? "pty" : "legacy"}}));
|
||||
}
|
||||
Mprintf("[WebService] term_ready device=%llu mode=%s\n",
|
||||
device_id, isPty ? "pty" : "legacy");
|
||||
}
|
||||
|
||||
bool CWebService::IsTerminalContext(CONTEXT_OBJECT* ctx) {
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
return m_TermContextToDevice.find(ctx) != m_TermContextToDevice.end();
|
||||
}
|
||||
|
||||
void CWebService::OnTerminalData(CONTEXT_OBJECT* ctx, const BYTE* data, ULONG len) {
|
||||
void* ws_ptr = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermContextToDevice.find(ctx);
|
||||
if (it == m_TermContextToDevice.end()) return;
|
||||
auto sit = m_TermSessions.find(it->second);
|
||||
if (sit == m_TermSessions.end()) return;
|
||||
ws_ptr = sit->second.ws_ptr;
|
||||
}
|
||||
if (!ws_ptr || !data || !len) return;
|
||||
|
||||
// 用 binary frame 透传字节流(避免 JSON 二进制不可见字符 / 编码膨胀)。
|
||||
// 帧格式:[4B magic 'TRM1'][N=payload]
|
||||
// 4 字节 magic = 0x54 0x52 0x4D 0x31 —— 视频帧首 4 字节是 deviceID(uint32 LE),
|
||||
// 撞这个具体值 (0x314D5254) 的概率极低,浏览器侧据此安全分流。
|
||||
std::vector<uint8_t> packet;
|
||||
packet.reserve(len + 4);
|
||||
packet.push_back('T'); packet.push_back('R'); packet.push_back('M'); packet.push_back('1');
|
||||
packet.insert(packet.end(), data, data + len);
|
||||
SendBinary(ws_ptr, packet.data(), packet.size());
|
||||
}
|
||||
|
||||
void CWebService::OnTerminalClosed(CONTEXT_OBJECT* ctx) {
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermContextToDevice.find(ctx);
|
||||
if (it == m_TermContextToDevice.end()) return;
|
||||
uint64_t device_id = it->second;
|
||||
CloseTermSessionLocked(device_id);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Screen Context Registry (for mouse/keyboard control)
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -247,6 +247,27 @@ public:
|
||||
void UnregisterScreenContext(uint64_t device_id);
|
||||
CONTEXT_OBJECT* GetScreenContext(uint64_t device_id);
|
||||
|
||||
// ========== Web Terminal (Phase 1: 1 user per device) ==========
|
||||
// Web 终端会话桥:把浏览器端 xterm.js ↔ 客户端 shell 子上下文连起来。
|
||||
// 设计:每台主机最多一个 Web 终端会话;如果别的浏览器请求同一台主机的终端,
|
||||
// 拒绝(UX 上后续可改成共享只读)。
|
||||
// 生命周期:term_open → COMMAND_SHELL → 客户端建子上下文 → MessageHandle
|
||||
// 看到 TOKEN_TERMINAL_START / TOKEN_SHELL_START + IsTermPending(d) →
|
||||
// 调 RegisterTerminalContext 接管,跳过 MFC dialog 打开。
|
||||
|
||||
// 浏览器侧入口
|
||||
void HandleTermOpen(void* ws_ptr, const std::string& msg);
|
||||
void HandleTermInput(void* ws_ptr, const std::string& msg);
|
||||
void HandleTermResize(void* ws_ptr, const std::string& msg);
|
||||
void HandleTermClose(void* ws_ptr, const std::string& msg);
|
||||
|
||||
// MessageHandle 向 WebService 询问 / 移交的钩子
|
||||
bool IsTermPending(uint64_t device_id); // 决定是否要拦截 dialog 打开
|
||||
void RegisterTerminalContext(uint64_t device_id, CONTEXT_OBJECT* ctx, bool isPty);
|
||||
bool IsTerminalContext(CONTEXT_OBJECT* ctx); // 是否是 Web 终端持有的上下文
|
||||
void OnTerminalData(CONTEXT_OBJECT* ctx, const BYTE* data, ULONG len);// 把 shell 输出泵到对应 web client
|
||||
void OnTerminalClosed(CONTEXT_OBJECT* ctx); // shell 子上下文断开时清理
|
||||
|
||||
private:
|
||||
// Screen context registry: device_id -> ScreenManager's CONTEXT_OBJECT
|
||||
std::map<uint64_t, CONTEXT_OBJECT*> m_ScreenContexts;
|
||||
@@ -255,6 +276,21 @@ private:
|
||||
// MFC triggered devices: dialogs created by MFC should always be visible
|
||||
std::set<uint64_t> m_MfcTriggeredDevices;
|
||||
std::mutex m_MfcTriggeredMutex;
|
||||
|
||||
// Web 终端会话状态
|
||||
struct WebTermSession {
|
||||
void* ws_ptr; // browser WebSocket
|
||||
uint64_t device_id;
|
||||
CONTEXT_OBJECT* shell_ctx; // shell 子上下文(首条消息抵达后才填)
|
||||
bool is_pty; // true=TOKEN_TERMINAL(现代 PTY), false=TOKEN_SHELL(老 cmd 管道)
|
||||
};
|
||||
std::map<uint64_t, WebTermSession> m_TermSessions; // by device_id
|
||||
std::map<CONTEXT_OBJECT*, uint64_t> m_TermContextToDevice; // 反查 ctx → device_id
|
||||
std::set<uint64_t> m_TermPending; // 已发 COMMAND_SHELL 待响应
|
||||
std::mutex m_TermMutex;
|
||||
|
||||
// 内部清理(已持锁版本)
|
||||
void CloseTermSessionLocked(uint64_t device_id);
|
||||
};
|
||||
|
||||
// Global accessor
|
||||
|
||||
8
server/2015Remote/res/web/fit.min.js
vendored
Normal file
8
server/2015Remote/res/web/fit.min.js
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=xterm-addon-fit.js.map
|
||||
209
server/2015Remote/res/web/xterm.css
Normal file
209
server/2015Remote/res/web/xterm.css
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility,
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
8
server/2015Remote/res/web/xterm.min.js
vendored
Normal file
8
server/2015Remote/res/web/xterm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -259,6 +259,9 @@
|
||||
#define IDI_ICON_SNAPSHOT 376
|
||||
#define IDR_LANG_EN_US 380
|
||||
#define IDR_LANG_ZH_TW 381
|
||||
#define IDR_WEB_XTERM_JS 382
|
||||
#define IDR_WEB_XTERM_CSS 383
|
||||
#define IDR_WEB_XTERM_FIT_JS 384
|
||||
#define IDC_MESSAGE 1000
|
||||
#define IDC_ONLINE 1001
|
||||
#define IDC_STATIC_TIPS 1002
|
||||
@@ -985,7 +988,7 @@
|
||||
//
|
||||
#ifdef APSTUDIO_INVOKED
|
||||
#ifndef APSTUDIO_READONLY_SYMBOLS
|
||||
#define _APS_NEXT_RESOURCE_VALUE 377
|
||||
#define _APS_NEXT_RESOURCE_VALUE 385
|
||||
#define _APS_NEXT_COMMAND_VALUE 33051
|
||||
#define _APS_NEXT_CONTROL_VALUE 2542
|
||||
#define _APS_NEXT_SYMED_VALUE 105
|
||||
|
||||
Reference in New Issue
Block a user